Convert Section Plugin to Use Sandy

Summary:
Convert section plugin to Sandy API.

TODO
- Fix layout issues
  - scrollbar occurs in small component (bottom layout)
  - scrollbar in wrong place (top layout)
  - text shrunk in bottom part of tree component
- (?) move away from d3
- better typing for payload
- move components to functional one
- unit test

Reviewed By: mweststrate

Differential Revision: D22385993

fbshipit-source-id: 862d4b775caf2d9a7bcb37446299251965a5d6db
This commit is contained in:
Chaiwat Ekkaewnumchai
2020-07-27 08:58:37 -07:00
committed by Facebook GitHub Bot
parent 10f9a48540
commit 8ac0c4c6c4
13 changed files with 495 additions and 530 deletions

View File

@@ -16,7 +16,7 @@
<PROJECT_ROOT>/desktop/babel-transformer/.* <PROJECT_ROOT>/desktop/babel-transformer/.*
<PROJECT_ROOT>/desktop/plugins/fb/relaydevtools/relay-devtools/DevtoolsUI.js$ <PROJECT_ROOT>/desktop/plugins/fb/relaydevtools/relay-devtools/DevtoolsUI.js$
<PROJECT_ROOT>/website/.* <PROJECT_ROOT>/website/.*
<PROJECT_ROOT>/desktop/plugins/sections/d3/d3.js$ <PROJECT_ROOT>/desktop/plugins/sections/src/d3/d3.js$
<PROJECT_ROOT>/react-native/ReactNativeFlipperExample/.* <PROJECT_ROOT>/react-native/ReactNativeFlipperExample/.*
[libs] [libs]

View File

@@ -1,98 +0,0 @@
/**
* 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
*/
export type SectionComponentHierarchy = {|
type: string,
children: Array<SectionComponentHierarchy>,
|};
export type AddEventPayload = {|
id: string,
reason: string,
stack_trace: Array<string>,
skip_stack_trace_format?: boolean,
surface_key: string,
event_timestamp: number,
update_mode: number,
reentrant_count: number,
payload: ?Object,
|};
export type UpdateTreeGenerationHierarchyGenerationPayload = {|
hierarchy_generation_timestamp: number,
id: string,
reason: string,
tree?: Array<{
didTriggerStateUpdate?: boolean,
identifier: string,
isDirty?: boolean,
isReused?: boolean,
name: string,
parent: string | '',
inserted?: boolean,
removed?: boolean,
updated?: boolean,
unchanged?: boolean,
isSection?: boolean,
isDataModel?: boolean,
}>,
|};
export type UpdateTreeGenerationChangesetGenerationPayload = {|
timestamp: number,
tree_generation_id: string,
identifier: string,
type: string,
changesets: {
section_key: {
changesets: {
id: {
count: number,
index: number,
toIndex?: number,
type: string,
render_infos?: Array<String>,
prev_data?: Array<String>,
next_data?: Array<String>,
},
},
},
},
|};
export type UpdateTreeGenerationChangesetApplicationPayload = {|
changeset: {
section_key: {
changesets: {
id: {
count: number,
index: number,
toIndex?: number,
type: string,
render_infos?: Array<String>,
prev_data?: Array<String>,
next_data?: Array<String>,
},
},
},
},
type: string,
identifier: string,
timestamp: number,
section_component_hierarchy: SectionComponentHierarchy,
tree_generation_id: string,
payload: ?Object,
|};
export type TreeGeneration = {|
...AddEventPayload,
...$Shape<UpdateTreeGenerationHierarchyGenerationPayload>,
...$Shape<UpdateTreeGenerationChangesetGenerationPayload>,
changeSets: Array<UpdateTreeGenerationChangesetApplicationPayload>,
|};

View File

@@ -1,372 +0,0 @@
/**
* 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
* @flow
*/
import type {
TreeGeneration,
AddEventPayload,
UpdateTreeGenerationHierarchyGenerationPayload,
UpdateTreeGenerationChangesetGenerationPayload,
UpdateTreeGenerationChangesetApplicationPayload,
} from './Models.js';
import {FlipperPlugin} from 'flipper';
import React from 'react';
import Tree from './Tree.js';
import StackTrace from './StackTrace.js';
import EventTable from './EventsTable.js';
import DetailsPanel from './DetailsPanel.js';
import {
Toolbar,
Glyph,
Sidebar,
FlexBox,
styled,
Button,
Spacer,
colors,
DetailSidebar,
SearchInput,
SearchBox,
SearchIcon,
} from 'flipper';
const Waiting = styled(FlexBox)((props) => ({
width: '100%',
height: '100%',
flexGrow: 1,
background: colors.light02,
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
}));
const InfoText = styled.div((props) => ({
marginTop: 10,
marginBottom: 10,
fontWeight: '500',
color: colors.light30,
}));
const InfoBox = styled.div((props) => ({
maxWidth: 400,
margin: 'auto',
textAlign: 'center',
}));
type State = {
focusedChangeSet: ?UpdateTreeGenerationChangesetApplicationPayload,
userSelectedGenerationId: ?string,
selectedTreeNode: ?Object,
searchString: string,
};
type PersistedState = {
generations: {
[id: string]: TreeGeneration,
},
focusedGenerationId: ?string,
recording: boolean,
};
export default class extends FlipperPlugin<State, *, PersistedState> {
static title = 'Sections';
static id = 'Sections';
static icon = 'tree';
static defaultPersistedState = {
generations: {},
focusedGenerationId: null,
recording: true,
};
static persistedStateReducer = (
persistedState: PersistedState,
method: string,
payload: Object,
): $Shape<PersistedState> => {
if (!persistedState.recording) {
return persistedState;
}
const addEvent = (data: AddEventPayload) => ({
...persistedState,
generations: {
...persistedState.generations,
[data.id]: {
...data,
changeSets: [],
},
},
focusedGenerationId: persistedState.focusedGenerationId || data.id,
});
const updateTreeGenerationHierarchyGeneration = (
data: UpdateTreeGenerationHierarchyGenerationPayload,
) => ({
...persistedState,
generations: {
...persistedState.generations,
[data.id]: {
...persistedState.generations[data.id],
...data,
},
},
});
const updateTreeGenerationChangeset = (
data:
| UpdateTreeGenerationChangesetGenerationPayload
| UpdateTreeGenerationChangesetApplicationPayload,
) => ({
...persistedState,
generations: {
...persistedState.generations,
[data.tree_generation_id]: {
...persistedState.generations[data.tree_generation_id],
changeSets: [
...persistedState.generations[data.tree_generation_id].changeSets,
data,
],
},
},
});
if (method === 'addEvent') {
return addEvent(payload);
} else if (method === 'updateTreeGenerationHierarchyGeneration') {
return updateTreeGenerationHierarchyGeneration(payload);
} else if (
method === 'updateTreeGenerationChangesetApplication' ||
method === 'updateTreeGenerationChangesetGeneration'
) {
return updateTreeGenerationChangeset(payload);
} else {
return persistedState;
}
};
state = {
focusedChangeSet: null,
userSelectedGenerationId: null,
selectedTreeNode: null,
searchString: '',
};
onTreeGenerationFocused = (focusedGenerationId: ?string) => {
this.setState({
focusedChangeSet: null,
userSelectedGenerationId: focusedGenerationId,
selectedTreeNode: null,
});
};
onFocusChangeSet = (
focusedChangeSet: ?UpdateTreeGenerationChangesetApplicationPayload,
) => {
this.setState({
focusedChangeSet,
selectedTreeNode: null,
});
};
onNodeClicked = (targetNode: any, evt: InputEvent) => {
if (targetNode.attributes.isSection) {
const sectionData = {};
sectionData['global_key'] = targetNode.attributes.identifier;
this.setState({
selectedTreeNode: {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;
}
}
this.setState({
selectedTreeNode: {dataModel},
});
};
renderTreeHierarchy = (generation: ?TreeGeneration) => {
if (generation && generation.tree && generation.tree.length > 0) {
// Display component tree hierarchy, if any
return (
<Tree data={generation.tree} nodeClickHandler={this.onNodeClicked} />
);
} else if (
this.state.focusedChangeSet &&
this.state.focusedChangeSet.section_component_hierarchy
) {
// Display section component hierarchy for specific changeset
return (
<Tree
data={this.state.focusedChangeSet.section_component_hierarchy}
nodeClickHandler={this.onNodeClicked}
/>
);
} else {
return this.renderWaiting();
}
};
renderWaiting = () => (
<Waiting>
<InfoBox>
<Glyph
name="face-unhappy"
variant="outline"
size={24}
color={colors.light30}
/>
<InfoText>No data available...</InfoText>
</InfoBox>
</Waiting>
);
clear = () => {
this.props.setPersistedState({
...this.constructor.defaultPersistedState,
});
};
onChange = (e: any) => {
this.setState({searchString: e.target.value});
};
generationValues = () => {
const {generations} = this.props.persistedState;
const generationKeys = Object.keys(generations);
return (generationKeys.map(
(key) => generations[key],
): Array<TreeGeneration>);
};
matchesCurrentSearchString = (s: string) => {
return s.toLowerCase().includes(this.state.searchString.toLowerCase());
};
matchingGenerationKeys = () => {
const matchingKeys: Array<string> = this.generationValues()
.filter((g) => {
if (g.payload) {
const componentClassName: ?string = g.payload['component_class_name'];
if (componentClassName) {
return this.matchesCurrentSearchString(componentClassName);
}
}
return g.tree?.some((node) => {
return this.matchesCurrentSearchString(node.name);
});
})
.map((g) => {
return g.surface_key;
});
return new Set<string>(matchingKeys);
};
filteredGenerations = () => {
if (this.state.searchString.length <= 0) {
return Object.values(this.props.persistedState.generations);
}
const matchingKeys = this.matchingGenerationKeys();
return (this.generationValues().filter((g) => {
return matchingKeys.has(g.surface_key);
}): Array<TreeGeneration>);
};
render() {
const {generations} = this.props.persistedState;
if (Object.values(this.props.persistedState.generations).length === 0) {
return this.renderWaiting();
}
const focusedGenerationId =
this.state.userSelectedGenerationId ||
this.props.persistedState.focusedGenerationId;
const focusedTreeGeneration: ?TreeGeneration = focusedGenerationId
? generations[focusedGenerationId]
: null;
return (
<React.Fragment>
<Toolbar>
<SearchBox tabIndex={-1}>
<SearchIcon
name="magnifying-glass"
color={colors.macOSTitleBarIcon}
size={16}
/>
<SearchInput
placeholder={'Search'}
onChange={this.onChange}
value={this.state.searchString}
/>
</SearchBox>
<Spacer />
{this.props.persistedState.recording ? (
<Button
onClick={() =>
this.props.setPersistedState({
recording: false,
})
}
iconVariant="filled"
icon="stop-playback">
Stop
</Button>
) : (
<Button onClick={this.clear} icon="trash" iconVariant="outline">
Clear
</Button>
)}
</Toolbar>
<Sidebar position="top" minHeight={80} height={80}>
<EventTable
generations={this.filteredGenerations()}
focusedGenerationId={focusedGenerationId}
onClick={this.onTreeGenerationFocused}
/>
</Sidebar>
{this.renderTreeHierarchy(focusedTreeGeneration)}
{focusedTreeGeneration && (
<Sidebar position="bottom" minHeight={100} height={250}>
<StackTrace
data={focusedTreeGeneration.stack_trace}
skip_stack_trace_format={
focusedTreeGeneration.skip_stack_trace_format
}
/>
</Sidebar>
)}
<DetailSidebar>
<DetailsPanel
eventUserInfo={focusedTreeGeneration?.payload}
changeSets={focusedTreeGeneration?.changeSets}
onFocusChangeSet={this.onFocusChangeSet}
focusedChangeSet={this.state.focusedChangeSet}
selectedNodeInfo={this.state.selectedTreeNode}
/>
</DetailSidebar>
</React.Fragment>
);
}
}

View File

@@ -1,7 +1,8 @@
{ {
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json", "$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
"name": "flipper-plugin-sections", "name": "flipper-plugin-sections",
"id": "flipper-plugin-sections", "id": "Sections",
"icon": "tree",
"title": "Sections", "title": "Sections",
"bugs": { "bugs": {
"email": "oncall+ios_componentkit@xmail.facebook.com", "email": "oncall+ios_componentkit@xmail.facebook.com",
@@ -9,7 +10,7 @@
}, },
"version": "0.51.0", "version": "0.51.0",
"main": "dist/bundle.js", "main": "dist/bundle.js",
"flipperBundlerEntry": "index.js", "flipperBundlerEntry": "src/index.tsx",
"license": "MIT", "license": "MIT",
"keywords": [ "keywords": [
"flipper-plugin" "flipper-plugin"
@@ -18,6 +19,10 @@
"dependencies": { "dependencies": {
"react-d3-tree": "^1.12.1" "react-d3-tree": "^1.12.1"
}, },
"peerDependencies": {
"flipper": "0.51.0",
"flipper-plugin": "0.51.0"
},
"resolutions": { "resolutions": {
"react-d3-tree/d3": "file:./d3" "react-d3-tree/d3": "file:./d3"
} }

View File

@@ -7,7 +7,7 @@
* @format * @format
*/ */
import type {UpdateTreeGenerationChangesetApplicationPayload} from './Models.js'; import type {UpdateTreeGenerationChangesetApplicationPayload} from './Models';
import React from 'react'; import React from 'react';
import { import {
@@ -24,19 +24,28 @@ const NoContent = styled(FlexBox)({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
flexGrow: 1, flexGrow: 1,
fontWeight: '500', fontWeight: 500,
color: colors.light30, color: colors.light30,
}); });
type Props = {| type Props = {
changeSets: ?Array<UpdateTreeGenerationChangesetApplicationPayload>, changeSets:
eventUserInfo: ?Object, | Array<UpdateTreeGenerationChangesetApplicationPayload>
focusedChangeSet: ?UpdateTreeGenerationChangesetApplicationPayload, | null
| undefined;
eventUserInfo: any;
focusedChangeSet:
| UpdateTreeGenerationChangesetApplicationPayload
| null
| undefined;
onFocusChangeSet: ( onFocusChangeSet: (
focusedChangeSet: ?UpdateTreeGenerationChangesetApplicationPayload, focusedChangeSet:
) => void, | UpdateTreeGenerationChangesetApplicationPayload
selectedNodeInfo: ?Object, | null
|}; | undefined,
) => void;
selectedNodeInfo: any;
};
export default class DetailsPanel extends Component<Props> { export default class DetailsPanel extends Component<Props> {
render() { render() {

View File

@@ -7,7 +7,7 @@
* @format * @format
*/ */
import type {TreeGeneration} from './Models.js'; import type {TreeGeneration} from './Models';
import { import {
FlexColumn, FlexColumn,
@@ -29,7 +29,7 @@ const Container = styled(FlexRow)({
flexGrow: 1, flexGrow: 1,
}); });
const SurfaceContainer = styled(FlexColumn)((props) => ({ const SurfaceContainer = styled(FlexColumn)((props: {scrolled: boolean}) => ({
position: 'relative', position: 'relative',
'::after': { '::after': {
display: props.scrolled ? 'block' : 'none', display: props.scrolled ? 'block' : 'none',
@@ -50,7 +50,7 @@ const TimeContainer = styled(FlexColumn)({
flexShrink: 1, flexShrink: 1,
}); });
const Row = styled(FlexRow)((props) => ({ const Row = styled(FlexRow)((props: {showTimeline?: boolean}) => ({
alignItems: 'center', alignItems: 'center',
paddingBottom: 3, paddingBottom: 3,
marginTop: 3, marginTop: 3,
@@ -94,13 +94,13 @@ const Content = styled.div({
fontSize: 11, fontSize: 11,
textAlign: 'center', textAlign: 'center',
textTransform: 'uppercase', textTransform: 'uppercase',
fontWeight: '500', fontWeight: 500,
color: colors.light50, color: colors.light50,
}); });
const Record = styled.div(({highlighted}) => ({ const Record = styled.div((props: {highlighted: boolean}) => ({
border: `1px solid ${colors.light15}`, border: `1px solid ${colors.light15}`,
boxShadow: highlighted boxShadow: props.highlighted
? `inset 0 0 0 2px ${colors.macOSTitleBarIconSelected}` ? `inset 0 0 0 2px ${colors.macOSTitleBarIconSelected}`
: 'none', : 'none',
borderRadius: 5, borderRadius: 5,
@@ -130,14 +130,14 @@ const Icon = styled(Glyph)({
top: 5, top: 5,
}); });
type Props = {| type Props = {
generations: Array<TreeGeneration>, generations: Array<TreeGeneration>;
focusedGenerationId: ?string, focusedGenerationId: string | null | undefined;
onClick: (id: string) => mixed, onClick: (id: string) => any;
|}; };
type State = { type State = {
scrolled: boolean, scrolled: boolean;
}; };
export default class extends Component<Props, State> { export default class extends Component<Props, State> {
@@ -161,7 +161,7 @@ export default class extends Component<Props, State> {
) { ) {
const node = document.querySelector(`[data-id="${focusedGenerationId}"]`); const node = document.querySelector(`[data-id="${focusedGenerationId}"]`);
if (node) { if (node) {
node.scrollIntoViewIfNeeded(); node.scrollIntoView();
} }
} }
} }
@@ -195,13 +195,13 @@ export default class extends Component<Props, State> {
} }
}; };
onScroll = (e: SyntheticUIEvent<HTMLElement>) => onScroll = (e: React.UIEvent<HTMLDivElement>) =>
this.setState({scrolled: e.currentTarget.scrollLeft > 0}); this.setState({scrolled: e.currentTarget.scrollLeft > 0});
render() { render() {
const surfaces = this.props.generations.reduce( const surfaces: Set<string> = this.props.generations.reduce(
(acc, cv) => acc.add(cv.surface_key), (acc, cv) => acc.add(cv.surface_key),
new Set(), new Set<string>(),
); );
return ( return (
<Container> <Container>

View File

@@ -0,0 +1,97 @@
/**
* 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
*/
export type SectionComponentHierarchy = {
type: string;
children: Array<SectionComponentHierarchy>;
};
export type AddEventPayload = {
id: string;
reason: string;
stack_trace: Array<string>;
skip_stack_trace_format?: boolean;
surface_key: string;
event_timestamp: number;
update_mode: number;
reentrant_count: number;
payload: any;
};
export type UpdateTreeGenerationHierarchyGenerationPayload = {
hierarchy_generation_timestamp: number;
id: string;
reason: string;
tree?: Array<{
didTriggerStateUpdate?: boolean;
identifier: string;
isDirty?: boolean;
isReused?: boolean;
name: string;
parent: string | '';
inserted?: boolean;
removed?: boolean;
updated?: boolean;
unchanged?: boolean;
isSection?: boolean;
isDataModel?: boolean;
}>;
};
export type UpdateTreeGenerationChangesetGenerationPayload = {
timestamp: number;
tree_generation_id: string;
identifier: string;
type: string;
changeset: {
section_key: {
changesets: {
id: {
count: number;
index: number;
toIndex?: number;
type: string;
render_infos?: Array<String>;
prev_data?: Array<String>;
next_data?: Array<String>;
};
};
};
};
};
export type UpdateTreeGenerationChangesetApplicationPayload = {
changeset: {
section_key: {
changesets: {
id: {
count: number;
index: number;
toIndex?: number;
type: string;
render_infos?: Array<String>;
prev_data?: Array<String>;
next_data?: Array<String>;
};
};
};
};
type: string;
identifier: string;
timestamp: number;
section_component_hierarchy?: SectionComponentHierarchy;
tree_generation_id: string;
payload?: any;
};
export type TreeGeneration = {
changeSets: Array<UpdateTreeGenerationChangesetApplicationPayload>;
} & AddEventPayload &
Partial<UpdateTreeGenerationHierarchyGenerationPayload> &
Partial<UpdateTreeGenerationChangesetGenerationPayload>;

View File

@@ -14,13 +14,13 @@ const FacebookLibraries = ['Facebook'];
const REGEX = /\d+\s+(?<library>(\s|\w|\.)+\w)\s+(?<address>0x\w+?)\s+(?<caller>.+) \+ (?<lineNumber>\d+)/; const REGEX = /\d+\s+(?<library>(\s|\w|\.)+\w)\s+(?<address>0x\w+?)\s+(?<caller>.+) \+ (?<lineNumber>\d+)/;
function isSystemLibrary(libraryName: ?string): boolean { function isSystemLibrary(libraryName: string | null | undefined): boolean {
return !FacebookLibraries.includes(libraryName); return libraryName ? !FacebookLibraries.includes(libraryName) : false;
} }
type Props = { type Props = {
data: Array<string>, data: Array<string>;
skipStackTraceFormat?: boolean, skipStackTraceFormat?: boolean | undefined;
}; };
export default class extends React.Component<Props> { export default class extends React.Component<Props> {

View File

@@ -11,7 +11,7 @@ import type {SectionComponentHierarchy} from './Models';
import {Glyph, PureComponent, styled, Toolbar, Spacer, colors} from 'flipper'; import {Glyph, PureComponent, styled, Toolbar, Spacer, colors} from 'flipper';
import {Tree} from 'react-d3-tree'; import {Tree} from 'react-d3-tree';
import {Fragment} from 'react'; import React, {Fragment} from 'react';
const Legend = styled.div((props) => ({ const Legend = styled.div((props) => ({
color: colors.dark50, color: colors.dark50,
@@ -35,7 +35,7 @@ const Label = styled.div({
left: 7, left: 7,
maxWidth: 270, maxWidth: 270,
overflow: 'hidden', overflow: 'hidden',
fontWeight: '500', fontWeight: 500,
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
paddingLeft: 5, paddingLeft: 5,
paddingRight: 5, paddingRight: 5,
@@ -65,40 +65,43 @@ const IconButton = styled.div({
}); });
type TreeData = Array<{ type TreeData = Array<{
identifier: string, identifier: string;
name: string, name: string;
parent: string | '', parent: string | '';
didTriggerStateUpdate?: boolean, didTriggerStateUpdate?: boolean;
isReused?: boolean, isReused?: boolean;
isDirty?: boolean, isDirty?: boolean;
inserted?: boolean, inserted?: boolean;
removed?: boolean, removed?: boolean;
updated?: boolean, updated?: boolean;
unchanged?: boolean, unchanged?: boolean;
isSection?: boolean, isSection?: boolean;
isDataModel?: boolean, isDataModel?: boolean;
}>; }>;
type Props = { type Props = {
data: TreeData | SectionComponentHierarchy, data: TreeData | SectionComponentHierarchy;
nodeClickHandler?: (node: any, evt: InputEvent) => void, nodeClickHandler: (node: any) => void;
}; };
type State = { type State = {
translate: { translate: {
x: number, x: number;
y: number, y: number;
}, };
tree: ?Object, tree: Object | null | undefined;
zoom: number, zoom: number;
}; };
class NodeLabel extends PureComponent<Props, State> { class NodeLabel extends PureComponent<
{onLabelClicked: (node: any) => void; nodeData?: any},
{collapsed: boolean}
> {
state = { state = {
collapsed: false, collapsed: false,
}; };
showNodeData = (e) => { showNodeData = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
this.props.onLabelClicked(this.props?.nodeData); this.props.onLabelClicked(this.props?.nodeData);
}; };
@@ -158,7 +161,7 @@ export default class extends PureComponent<Props, State> {
return { return {
name: n.name, name: n.name,
children: [], children: [] as Array<any>,
attributes: {...n}, attributes: {...n},
nodeSvgShape: { nodeSvgShape: {
shapeProps: { shapeProps: {
@@ -171,7 +174,7 @@ export default class extends PureComponent<Props, State> {
}; };
}); });
const parentMap: Map<string, Array<Object>> = tree.reduce((acc, cv) => { const parentMap: Map<string, Array<any>> = tree.reduce((acc, cv) => {
const {parent} = cv.attributes; const {parent} = cv.attributes;
if (typeof parent !== 'string') { if (typeof parent !== 'string') {
return acc; return acc;
@@ -236,7 +239,7 @@ export default class extends PureComponent<Props, State> {
} }
} }
onZoom = (e: SyntheticInputEvent<HTMLInputElement>) => { onZoom = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({zoom: e.target.valueAsNumber}); this.setState({zoom: e.target.valueAsNumber});
}; };
@@ -244,7 +247,7 @@ export default class extends PureComponent<Props, State> {
return ( return (
<Fragment> <Fragment>
<Container <Container
innerRef={(ref) => { ref={(ref) => {
this.treeContainer = ref; this.treeContainer = ref;
}}> }}>
<style> <style>

View File

@@ -0,0 +1,321 @@
/**
* 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 {FlipperClient, 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',
});
type Events = {
addEvent: AddEventPayload;
updateTreeGenerationHierarchyGeneration: UpdateTreeGenerationHierarchyGenerationPayload;
updateTreeGenerationChangesetApplication: UpdateTreeGenerationChangesetApplicationPayload;
updateTreeGenerationChangesetGeneration: UpdateTreeGenerationChangesetGenerationPayload;
};
export function plugin(client: FlipperClient<Events, {}>) {
const generations = createState<{[id: string]: TreeGeneration}>(
{},
{persist: 'generations'},
);
const focusedGenerationId = createState<string | null>(null);
const recording = createState<boolean>(true);
client.onMessage('addEvent', (data) => {
if (!recording.get()) {
return;
}
generations.update((draft) => {
draft[data.id] = {...data, changeSets: []};
});
focusedGenerationId.set(focusedGenerationId.get() || data.id);
});
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,
);
function setRecording(value: boolean) {
recording.set(value);
}
function clear() {
generations.set({});
focusedGenerationId.set(null);
recording.set(true);
}
return {generations, focusedGenerationId, recording, setRecording, clear};
}
export function Component() {
const instance = usePlugin(plugin);
const generations = useValue(instance.generations);
const focusedGenerationId = useValue(instance.focusedGenerationId);
const recording = useValue(instance.recording);
const [userSelectedGenerationId, setUserSelectedGenerationId] = useState<
string | null
>(null);
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 || focusedGenerationId;
if (id === null) {
return null;
}
return generations[id];
}, [userSelectedGenerationId, focusedGenerationId, 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 scrollable={false}>
<Sidebar position="top" minHeight={80} height={80}>
<EventTable
generations={filteredGenerations}
focusedGenerationId={
userSelectedGenerationId || focusedGenerationId
}
onClick={(id: string | null) => {
setFocusedChangeSet(null);
setUserSelectedGenerationId(id);
setSelectedTreeNode(null);
}}
/>
</Sidebar>
<Layout.Top>
<Sidebar position="top" minHeight={80} height={80}>
<TreeHierarchy
generation={focusedTreeGeneration}
focusedChangeSet={focusedChangeSet}
setSelectedTreeNode={setSelectedTreeNode}
/>
</Sidebar>
{focusedTreeGeneration && (
<StackTrace
data={focusedTreeGeneration.stack_trace}
skipStackTraceFormat={
focusedTreeGeneration.skip_stack_trace_format
}
/>
)}
</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,
}: {
generation: TreeGeneration | null;
focusedChangeSet:
| UpdateTreeGenerationChangesetApplicationPayload
| null
| undefined;
setSelectedTreeNode: (node: any) => void;
}) {
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} />;
} else if (focusedChangeSet && focusedChangeSet.section_component_hierarchy) {
// Display section component hierarchy for specific changeset
return (
<Tree
data={focusedChangeSet.section_component_hierarchy}
nodeClickHandler={onNodeClicked}
/>
);
} else {
return (
<Waiting>
<InfoBox>
<Glyph
name="face-unhappy"
variant="outline"
size={24}
color={colors.light30}
/>
<InfoText>No data available...</InfoText>
</InfoBox>
</Waiting>
);
}
}