Files
flipper/desktop/plugins/sections/src/index.tsx
Michel Weststrate 5731e3a155 Scrolling improvements
Summary:
Split container had a convenient property `scrollable`, that automatically applies a Scroll container to the main content.

But I noticed it leads to bad design choices because it is so convenient. So sometimes scrolling would be unnecessarily in two directions because of this.

Or, since the scroll container wraps around the whole content, toolbars would scroll out of view. By forcing scrolling to be put explicitly in the component tree, we encourage plugin developers to think about where they actually want to have that scroll, and in which direction.

Also added options to use the Container padding properties on ScrollContainer, which is great since we can keep the scrollbars outside the padding, and apply it to the content only, to prevent an accidental mistake where people would put a scroll container in a padded container, that would put the scrollbar inside the padding.

Reviewed By: cekkaewnumchai

Differential Revision: D24502546

fbshipit-source-id: 524004a1c5f33a185f9b959251b72875dd623cb3
2020-10-23 06:46:15 -07:00

390 lines
11 KiB
TypeScript

/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
TreeGeneration,
AddEventPayload,
UpdateTreeGenerationHierarchyGenerationPayload,
UpdateTreeGenerationChangesetGenerationPayload,
UpdateTreeGenerationChangesetApplicationPayload,
} from './Models';
import Tree from './Tree';
import StackTrace from './StackTrace';
import EventTable from './EventsTable';
import DetailsPanel from './DetailsPanel';
import React, {useState, useMemo} from 'react';
import {
Toolbar,
Glyph,
Sidebar,
FlexBox,
styled,
Button,
Spacer,
colors,
DetailSidebar,
SearchInput,
SearchBox,
SearchIcon,
Layout,
} from 'flipper';
import {PluginClient, createState, usePlugin, useValue} from 'flipper-plugin';
const Waiting = styled(FlexBox)({
width: '100%',
height: '100%',
flexGrow: 1,
background: colors.light02,
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
});
const InfoText = styled.div({
marginTop: 10,
marginBottom: 10,
fontWeight: 500,
color: colors.light30,
});
const InfoBox = styled.div({
maxWidth: 400,
margin: 'auto',
textAlign: 'center',
});
const TreeContainer = styled.div({
width: '100%',
height: '100%',
overflow: 'hidden',
});
type Events = {
addEvent: AddEventPayload;
updateTreeGenerationHierarchyGeneration: UpdateTreeGenerationHierarchyGenerationPayload;
updateTreeGenerationChangesetApplication: UpdateTreeGenerationChangesetApplicationPayload;
updateTreeGenerationChangesetGeneration: UpdateTreeGenerationChangesetGenerationPayload;
};
type FocusInfo = {
generationId: string;
treeNodeIndexPath?: number[];
};
export function plugin(client: PluginClient<Events, {}>) {
const generations = createState<{[id: string]: TreeGeneration}>(
{},
{persist: 'generations'},
);
const focusInfo = createState<FocusInfo | undefined>(undefined);
const recording = createState<boolean>(true);
client.onMessage('addEvent', (data) => {
if (!recording.get()) {
return;
}
generations.update((draft) => {
draft[data.id] = {...data, changeSets: []};
});
focusInfo.set({
generationId: focusInfo.get()?.generationId || data.id,
treeNodeIndexPath: undefined,
});
});
client.onMessage('updateTreeGenerationHierarchyGeneration', (data) => {
generations.update((draft) => {
draft[data.id] = {...draft[data.id], ...data};
});
});
function updateTreeGenerationChangeset(
data:
| UpdateTreeGenerationChangesetGenerationPayload
| UpdateTreeGenerationChangesetApplicationPayload,
) {
generations.update((draft) => {
draft[data.tree_generation_id].changeSets.push(data);
});
}
client.onMessage(
'updateTreeGenerationChangesetApplication',
updateTreeGenerationChangeset,
);
client.onMessage(
'updateTreeGenerationChangesetGeneration',
updateTreeGenerationChangeset,
);
client.onDeepLink((payload) => {
if (typeof payload === 'string') {
handleDeepLinkPayload(payload);
}
});
function handleDeepLinkPayload(payload: string) {
// Payload expected to be something like
// 1.1.FBAnimatingComponent[0].CKFlexboxComponent[2].CKComponent where path components are separated by '.'
// - The first '1' is the scope root ID.
// - The numbers in square brackets are the indexes of the following component in the children array
// of the preceding component. In this example, the last CKComponent is the 2nd child of CKFlexboxComponent.
const pathComponents = payload.split('.');
const rootId = pathComponents[0];
const generationValues = Object.values(generations.get());
const mostRecentTreeBuild = generationValues.reverse().find((g) => {
return g.surface_key == rootId && g.reason == 'Tree Build';
});
if (mostRecentTreeBuild) {
const regex = /\w+\[(\d+)\]/;
const indexPath = pathComponents.reduce((acc, component) => {
const match = regex.exec(component);
if (match) {
acc.push(+match[1]);
}
return acc;
}, [] as number[]);
focusInfo.set({
generationId: mostRecentTreeBuild.id,
treeNodeIndexPath: indexPath,
});
}
}
function setRecording(value: boolean) {
recording.set(value);
}
function clear() {
generations.set({});
focusInfo.set(undefined);
recording.set(true);
}
return {
generations,
focusInfo,
recording,
setRecording,
clear,
};
}
export function Component() {
const instance = usePlugin(plugin);
const generations = useValue(instance.generations);
const focusInfo = useValue(instance.focusInfo);
const recording = useValue(instance.recording);
const [userSelectedGenerationId, setUserSelectedGenerationId] = useState<
string | undefined
>();
const [searchString, setSearchString] = useState<string>('');
const [focusedChangeSet, setFocusedChangeSet] = useState<
UpdateTreeGenerationChangesetApplicationPayload | null | undefined
>(null);
const [selectedTreeNode, setSelectedTreeNode] = useState<any>();
const focusedTreeGeneration: TreeGeneration | null = useMemo(() => {
const id = userSelectedGenerationId || focusInfo?.generationId;
if (id === undefined) {
return null;
}
return generations[id];
}, [userSelectedGenerationId, focusInfo, generations]);
const filteredGenerations: Array<TreeGeneration> = useMemo(() => {
const generationValues = Object.values(generations);
if (searchString.length <= 0) {
return generationValues;
}
const matchesCurrentSearchString = (s: string): boolean => {
return s.toLowerCase().includes(searchString.toLowerCase());
};
const matchingKeys: Array<string> = generationValues
.filter((g) => {
if (g.payload) {
const componentClassName: string | null | undefined =
g.payload.component_class_name;
if (componentClassName) {
return matchesCurrentSearchString(componentClassName);
}
}
return g.tree?.some((node) => {
return matchesCurrentSearchString(node.name);
});
})
.map((g) => {
return g.surface_key;
});
return generationValues.filter((g) => matchingKeys.includes(g.surface_key));
}, [generations, searchString]);
return (
<Layout.Right>
<Layout.Top>
<Toolbar>
<SearchBox tabIndex={-1}>
<SearchIcon
name="magnifying-glass"
color={colors.macOSTitleBarIcon}
size={16}
/>
<SearchInput
placeholder={'Search'}
onChange={(e) => setSearchString(e.target.value)}
value={searchString}
/>
</SearchBox>
<Spacer />
{recording ? (
<Button
onClick={() => instance.setRecording(false)}
iconVariant="filled"
icon="stop-playback">
Stop
</Button>
) : (
<Button onClick={instance.clear} icon="trash" iconVariant="outline">
Clear
</Button>
)}
</Toolbar>
<Layout.Top>
<Sidebar position="top" minHeight={80} height={80}>
<EventTable
generations={filteredGenerations}
focusedGenerationId={
userSelectedGenerationId || focusInfo?.generationId
}
onClick={(id?: string) => {
setFocusedChangeSet(null);
setUserSelectedGenerationId(id);
setSelectedTreeNode(null);
}}
/>
</Sidebar>
<Layout.Top>
<Sidebar position="top" minHeight={400} height={400}>
<TreeContainer>
<TreeHierarchy
generation={focusedTreeGeneration}
focusedChangeSet={focusedChangeSet}
setSelectedTreeNode={setSelectedTreeNode}
selectedNodeIndexPath={focusInfo?.treeNodeIndexPath}
/>
</TreeContainer>
</Sidebar>
{focusedTreeGeneration && (
<Layout.ScrollContainer>
<StackTrace
data={focusedTreeGeneration.stack_trace}
skipStackTraceFormat={
focusedTreeGeneration.skip_stack_trace_format
}
/>
</Layout.ScrollContainer>
)}
</Layout.Top>
</Layout.Top>
</Layout.Top>
<DetailSidebar>
<DetailsPanel
eventUserInfo={focusedTreeGeneration?.payload}
changeSets={focusedTreeGeneration?.changeSets}
onFocusChangeSet={(
focusedChangeSet:
| UpdateTreeGenerationChangesetApplicationPayload
| null
| undefined,
) => {
setFocusedChangeSet(focusedChangeSet);
setSelectedTreeNode(null);
}}
focusedChangeSet={focusedChangeSet}
selectedNodeInfo={selectedTreeNode}
/>
</DetailSidebar>
</Layout.Right>
);
}
function TreeHierarchy({
generation,
focusedChangeSet,
setSelectedTreeNode,
selectedNodeIndexPath,
}: {
generation: TreeGeneration | null;
focusedChangeSet:
| UpdateTreeGenerationChangesetApplicationPayload
| null
| undefined;
setSelectedTreeNode: (node: any) => void;
selectedNodeIndexPath?: number[];
}) {
const onNodeClicked = useMemo(
() => (targetNode: any) => {
if (targetNode.attributes.isSection) {
const sectionData: any = {};
sectionData.global_key = targetNode.attributes.identifier;
setSelectedTreeNode({sectionData});
return;
}
let dataModel;
// Not all models can be parsed.
if (targetNode.attributes.isDataModel) {
try {
dataModel = JSON.parse(targetNode.attributes.identifier);
} catch (e) {
dataModel = targetNode.attributes.identifier;
}
}
setSelectedTreeNode({dataModel});
},
[setSelectedTreeNode],
);
if (generation && generation.tree && generation.tree.length > 0) {
// Display component tree hierarchy, if any
return (
<Tree
data={generation.tree}
nodeClickHandler={onNodeClicked}
selectedNodeIndexPath={selectedNodeIndexPath}
/>
);
} else if (focusedChangeSet && focusedChangeSet.section_component_hierarchy) {
// Display section component hierarchy for specific changeset
return (
<Tree
data={focusedChangeSet.section_component_hierarchy}
nodeClickHandler={onNodeClicked}
selectedNodeIndexPath={selectedNodeIndexPath}
/>
);
} else {
return (
<Waiting>
<InfoBox>
<Glyph
name="face-unhappy"
variant="outline"
size={24}
color={colors.light30}
/>
<InfoText>No data available...</InfoText>
</InfoBox>
</Waiting>
);
}
}