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

@@ -0,0 +1,120 @@
/**
* 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 type {UpdateTreeGenerationChangesetApplicationPayload} from './Models';
import React from 'react';
import {
MarkerTimeline,
Component,
styled,
FlexBox,
ManagedDataInspector,
Panel,
colors,
} from 'flipper';
const NoContent = styled(FlexBox)({
alignItems: 'center',
justifyContent: 'center',
flexGrow: 1,
fontWeight: 500,
color: colors.light30,
});
type Props = {
changeSets:
| Array<UpdateTreeGenerationChangesetApplicationPayload>
| null
| undefined;
eventUserInfo: any;
focusedChangeSet:
| UpdateTreeGenerationChangesetApplicationPayload
| null
| undefined;
onFocusChangeSet: (
focusedChangeSet:
| UpdateTreeGenerationChangesetApplicationPayload
| null
| undefined,
) => void;
selectedNodeInfo: any;
};
export default class DetailsPanel extends Component<Props> {
render() {
const {changeSets, eventUserInfo} = this.props;
const firstChangeSet =
(changeSets || []).reduce(
(min, cs) => Math.min(min, cs.timestamp),
Infinity,
) || 0;
return (
<React.Fragment>
{eventUserInfo && (
<Panel
key="eventUserInfo"
collapsable={false}
floating={false}
heading={'Event User Info'}>
<ManagedDataInspector data={eventUserInfo} expandRoot={true} />
</Panel>
)}
{changeSets && changeSets.length > 0 ? (
<Panel
key="Changesets"
collapsable={false}
floating={false}
heading={'Changesets'}>
<MarkerTimeline
points={changeSets.map((p) => ({
label:
p.type === 'CHANGESET_GENERATED' ? 'Generated' : 'Rendered',
time: Math.round((p.timestamp || 0) - firstChangeSet),
color:
p.type === 'CHANGESET_GENERATED' ? colors.lemon : colors.teal,
key: p.identifier,
}))}
onClick={(ids) =>
this.props.onFocusChangeSet(
changeSets.find((c) => c.identifier === ids[0]),
)
}
selected={this.props.focusedChangeSet?.identifier}
/>
</Panel>
) : (
<NoContent>No changes sets available</NoContent>
)}
{this.props.focusedChangeSet && (
<Panel
key="Changeset Details"
floating={false}
heading="Changeset Details">
<ManagedDataInspector
data={this.props.focusedChangeSet.changeset}
expandRoot={true}
/>
</Panel>
)}
{this.props.selectedNodeInfo && (
<Panel
key="Selected Node Info"
floating={false}
heading="Selected Node Info">
<ManagedDataInspector
data={this.props.selectedNodeInfo}
expandRoot={true}
/>
</Panel>
)}
</React.Fragment>
);
}
}

View File

@@ -0,0 +1,248 @@
/**
* 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 type {TreeGeneration} from './Models';
import {
FlexColumn,
FlexRow,
Component,
Tooltip,
Glyph,
styled,
colors,
} from 'flipper';
import React from 'react';
const PADDING = 15;
const WIDTH = 70;
const LABEL_WIDTH = 140;
const Container = styled(FlexRow)({
flexShrink: 0,
flexGrow: 1,
});
const SurfaceContainer = styled(FlexColumn)((props: {scrolled: boolean}) => ({
position: 'relative',
'::after': {
display: props.scrolled ? 'block' : 'none',
content: '""',
top: 0,
bottom: 0,
right: -15,
width: 15,
background: `linear-gradient(90deg, ${colors.macOSTitleBarBackgroundBlur} 0%, transparent 100%)`,
zIndex: 3,
position: 'absolute',
},
}));
const TimeContainer = styled(FlexColumn)({
overflow: 'scroll',
flexGrow: 1,
flexShrink: 1,
});
const Row = styled(FlexRow)((props: {showTimeline?: boolean}) => ({
alignItems: 'center',
paddingBottom: 3,
marginTop: 3,
flexGrow: 1,
flexShrink: 0,
maxHeight: 75,
position: 'relative',
minWidth: '100%',
alignSelf: 'flex-start',
'::before': {
display: props.showTimeline ? 'block' : 'none',
zIndex: 1,
content: '""',
position: 'absolute',
borderTop: `1px dotted ${colors.light15}`,
height: 1,
top: '50%',
left: 0,
right: 0,
},
}));
const Label = styled.div({
width: LABEL_WIDTH,
paddingLeft: 10,
paddingRight: 10,
fontWeight: 'bold',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
textAlign: 'right',
flexShrink: 0,
position: 'sticky',
left: 0,
zIndex: 2,
});
const Content = styled.div({
textOverflow: 'ellipsis',
overflow: 'hidden',
fontSize: 11,
textAlign: 'center',
textTransform: 'uppercase',
fontWeight: 500,
color: colors.light50,
});
const Record = styled.div((props: {highlighted: boolean}) => ({
border: `1px solid ${colors.light15}`,
boxShadow: props.highlighted
? `inset 0 0 0 2px ${colors.macOSTitleBarIconSelected}`
: 'none',
borderRadius: 5,
padding: 5,
marginRight: PADDING,
backgroundColor: colors.white,
zIndex: 2,
position: 'relative',
width: WIDTH,
flexShrink: 0,
alignSelf: 'stretch',
display: 'flex',
alignItems: 'center',
}));
const Empty = styled.div({
width: WIDTH,
padding: '10px 5px',
marginRight: PADDING,
flexShrink: 0,
position: 'relative',
});
const Icon = styled(Glyph)({
position: 'absolute',
right: 5,
top: 5,
});
type Props = {
generations: Array<TreeGeneration>;
focusedGenerationId: string | null | undefined;
onClick: (id: string) => any;
};
type State = {
scrolled: boolean;
};
export default class extends Component<Props, State> {
state = {
scrolled: false,
};
componentDidMount() {
document.addEventListener('keydown', this.onKeyDown);
}
componentWillUnmount() {
document.removeEventListener('keydown', this.onKeyDown);
}
componentDidUpdate(prevProps: Props) {
const {focusedGenerationId} = this.props;
if (
focusedGenerationId &&
focusedGenerationId !== prevProps.focusedGenerationId
) {
const node = document.querySelector(`[data-id="${focusedGenerationId}"]`);
if (node) {
node.scrollIntoView();
}
}
}
onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') {
return;
}
e.preventDefault();
let nextGenerationId = null;
const index = this.props.generations.findIndex(
(g) => g.id === this.props.focusedGenerationId,
);
const direction = e.key === 'ArrowRight' ? 1 : -1;
const bound = e.key === 'ArrowRight' ? this.props.generations.length : -1;
for (let i = index + direction; i !== bound; i += direction) {
if (
this.props.generations[i].surface_key ===
this.props.generations[index].surface_key
) {
nextGenerationId = this.props.generations[i].id;
break;
}
}
if (nextGenerationId) {
this.props.onClick(nextGenerationId);
}
};
onScroll = (e: React.UIEvent<HTMLDivElement>) =>
this.setState({scrolled: e.currentTarget.scrollLeft > 0});
render() {
const surfaces: Set<string> = this.props.generations.reduce(
(acc, cv) => acc.add(cv.surface_key),
new Set<string>(),
);
return (
<Container>
<SurfaceContainer scrolled={this.state.scrolled}>
{[...surfaces].map((surface) => (
<Row key={surface}>
<Label title={surface}>{surface}</Label>
</Row>
))}
</SurfaceContainer>
<TimeContainer onScroll={this.onScroll}>
{[...surfaces].map((surface) => (
<Row key={surface} showTimeline>
{this.props.generations.map((record: TreeGeneration) =>
record.surface_key === surface ? (
<Record
key={`${surface}${record.id}`}
data-id={record.id}
highlighted={record.id === this.props.focusedGenerationId}
onClick={() => this.props.onClick(record.id)}>
<Content>{record.reason}</Content>
{record.reentrant_count > 0 && (
<Tooltip
title={'Reentrant count ' + record.reentrant_count}>
<Icon
color={colors.red}
name="caution-circle"
variant="filled"
size={12}
/>
</Tooltip>
)}
</Record>
) : (
<Empty key={`${surface}${record.id}`} />
),
)}
</Row>
))}
</TimeContainer>
</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

@@ -0,0 +1,60 @@
/**
* 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 React from 'react';
import {colors, StackTrace} from 'flipper';
const FacebookLibraries = ['Facebook'];
const REGEX = /\d+\s+(?<library>(\s|\w|\.)+\w)\s+(?<address>0x\w+?)\s+(?<caller>.+) \+ (?<lineNumber>\d+)/;
function isSystemLibrary(libraryName: string | null | undefined): boolean {
return libraryName ? !FacebookLibraries.includes(libraryName) : false;
}
type Props = {
data: Array<string>;
skipStackTraceFormat?: boolean | undefined;
};
export default class extends React.Component<Props> {
render() {
if (this.props.skipStackTraceFormat) {
return (
<StackTrace backgroundColor={colors.white}>
{this.props.data.map((stack_trace_line) => {
return {
caller: stack_trace_line,
};
})}
</StackTrace>
);
}
return (
<StackTrace backgroundColor={colors.white}>
{/* We need to filter out from the stack trace any reference to the plugin such that the information is more coincised and focused */}
{this.props.data
.filter((stack_trace_line) => {
return !stack_trace_line.includes('FlipperKitSectionsPlugin');
})
.map((stack_trace_line) => {
const trace = REGEX.exec(stack_trace_line)?.groups;
return {
bold: !isSystemLibrary(trace?.library),
library: trace?.library,
address: trace?.address,
caller: trace?.caller,
lineNumber: trace?.lineNumber,
};
})}
</StackTrace>
);
}
}

View File

@@ -0,0 +1,305 @@
/**
* 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 type {SectionComponentHierarchy} from './Models';
import {Glyph, PureComponent, styled, Toolbar, Spacer, colors} from 'flipper';
import {Tree} from 'react-d3-tree';
import React, {Fragment} from 'react';
const Legend = styled.div((props) => ({
color: colors.dark50,
marginLeft: 20,
'&::before': {
content: '""',
display: 'inline-block',
width: 10,
height: 10,
borderRadius: 6,
backgroundColor: props.color,
border: `1px solid rgba(0,0,0,0.2)`,
marginRight: 4,
marginBottom: -1,
},
}));
const Label = styled.div({
position: 'relative',
top: -7,
left: 7,
maxWidth: 270,
overflow: 'hidden',
fontWeight: 500,
textOverflow: 'ellipsis',
paddingLeft: 5,
paddingRight: 5,
background: colors.white,
display: 'inline-block',
});
const Container = styled.div({
width: '100%',
height: '100%',
overflow: 'hidden',
background:
'linear-gradient(-90deg,rgba(0,0,0,.02) 1px,transparent 0),linear-gradient(rgba(0,0,0,.02) 1px,transparent 0),linear-gradient(-90deg,rgba(0,0,0,.03) 1px,transparent 0),linear-gradient(rgba(0,0,0,.03) 1px,transparent 0)',
backgroundSize:
'10px 10px,10px 10px,100px 100px,100px 100px,100px 100px,100px 100px,100px 100px,100px 100px',
});
const LabelContainer = styled.div({
display: 'flex',
});
const IconButton = styled.div({
position: 'relative',
left: 5,
top: -8,
background: colors.white,
});
type TreeData = Array<{
identifier: string;
name: string;
parent: string | '';
didTriggerStateUpdate?: boolean;
isReused?: boolean;
isDirty?: boolean;
inserted?: boolean;
removed?: boolean;
updated?: boolean;
unchanged?: boolean;
isSection?: boolean;
isDataModel?: boolean;
}>;
type Props = {
data: TreeData | SectionComponentHierarchy;
nodeClickHandler: (node: any) => void;
};
type State = {
translate: {
x: number;
y: number;
};
tree: Object | null | undefined;
zoom: number;
};
class NodeLabel extends PureComponent<
{onLabelClicked: (node: any) => void; nodeData?: any},
{collapsed: boolean}
> {
state = {
collapsed: false,
};
showNodeData = (e: React.MouseEvent) => {
e.stopPropagation();
this.props.onLabelClicked(this.props?.nodeData);
};
toggleClicked = () => {
this.setState({
collapsed: !this.state.collapsed,
});
};
render() {
const name = this.props?.nodeData?.name;
const isSection = this.props?.nodeData?.attributes.isSection;
const chevron = this.state.collapsed ? 'chevron-right' : 'chevron-left';
return (
<LabelContainer>
<Label title={name} onClick={this.showNodeData}>
{name}
</Label>
{isSection && (
<IconButton onClick={this.toggleClicked}>
<Glyph
color={colors.blueGreyTint70}
name={chevron}
variant={'filled'}
size={12}
/>
</IconButton>
)}
</LabelContainer>
);
}
}
export default class extends PureComponent<Props, State> {
treeFromFlatArray = (data: TreeData) => {
const tree = data.map((n) => {
let fill = colors.blueGreyTint70;
if (n.didTriggerStateUpdate) {
fill = colors.lemon;
} else if (n.isReused) {
fill = colors.teal;
} else if (n.isDirty) {
fill = colors.grape;
}
if (n.removed) {
fill = colors.light20;
} else if (n.inserted) {
fill = colors.pinkDark1;
} else if (n.updated) {
fill = colors.orangeTint15;
} else if (n.unchanged) {
fill = colors.teal;
}
return {
name: n.name,
children: [] as Array<any>,
attributes: {...n},
nodeSvgShape: {
shapeProps: {
fill,
r: 6,
strokeWidth: 1,
stroke: 'rgba(0,0,0,0.2)',
},
},
};
});
const parentMap: Map<string, Array<any>> = tree.reduce((acc, cv) => {
const {parent} = cv.attributes;
if (typeof parent !== 'string') {
return acc;
}
const children = acc.get(parent);
if (children) {
return acc.set(parent, children.concat(cv));
} else {
return acc.set(parent, [cv]);
}
}, new Map());
tree.forEach((n) => {
n.children = parentMap.get(n.attributes.identifier) || [];
});
// find the root node
return tree.find((node) => !node.attributes.parent);
};
treeFromHierarchy = (data: SectionComponentHierarchy): Object => {
return {
name: data.type,
children: data.children ? data.children.map(this.treeFromHierarchy) : [],
};
};
state = {
translate: {
x: 0,
y: 0,
},
tree: Array.isArray(this.props.data)
? this.treeFromFlatArray(this.props.data)
: this.treeFromHierarchy(this.props.data),
zoom: 1,
};
treeContainer: any = null;
UNSAFE_componentWillReceiveProps(props: Props) {
if (this.props.data === props.data) {
return;
}
this.setState({
tree: Array.isArray(props.data)
? this.treeFromFlatArray(props.data)
: this.treeFromHierarchy(props.data),
});
}
componentDidMount() {
if (this.treeContainer) {
const dimensions = this.treeContainer.getBoundingClientRect();
this.setState({
translate: {
x: 50,
y: dimensions.height / 2,
},
});
}
}
onZoom = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({zoom: e.target.valueAsNumber});
};
render() {
return (
<Fragment>
<Container
ref={(ref) => {
this.treeContainer = ref;
}}>
<style>
{'.rd3t-tree-container foreignObject {overflow: visible;}'}
</style>
{this.state.tree && (
<Tree
transitionDuration={0}
separation={{siblings: 0.5, nonSiblings: 0.5}}
data={this.state.tree}
translate={this.state.translate}
zoom={this.state.zoom}
nodeLabelComponent={{
render: (
<NodeLabel onLabelClicked={this.props.nodeClickHandler} />
),
}}
allowForeignObjects
nodeSvgShape={{
shape: 'circle',
shapeProps: {
stroke: 'rgba(0,0,0,0.2)',
strokeWidth: 1,
},
}}
styles={{
links: {
stroke: '#b3b3b3',
},
}}
nodeSize={{x: 300, y: 100}}
/>
)}
</Container>
<Toolbar position="bottom" compact>
<input
type="range"
onChange={this.onZoom}
value={this.state.zoom}
min="0.1"
max="1"
step="0.01"
/>
<Spacer />
<Legend color={colors.light20}>Item removed</Legend>
<Legend color={colors.pinkDark1}>Item inserted</Legend>
<Legend color={colors.orangeTint15}>Item updated</Legend>
<Legend color={colors.teal}>Item/Section Reused</Legend>
<Legend color={colors.lemon}>Section triggered state update</Legend>
<Legend color={colors.grape}>Section is dirty</Legend>
</Toolbar>
</Fragment>
);
}
}

View File

@@ -0,0 +1,26 @@
Copyright (c) 2010-2016, Michael Bostock
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* The name Michael Bostock may not be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL MICHAEL BOSTOCK BE LIABLE FOR ANY DIRECT,
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

9560
desktop/plugins/sections/src/d3/d3.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
{
"title": "sections-d3",
"name": "d3",
"version": "3.5.17",
"description": "A JavaScript visualization library for HTML and SVG.",
"bugs": {
"email": "danielbuechele@fb.com"
},
"keywords": [
"dom",
"w3c",
"visualization",
"svg",
"animation",
"canvas"
],
"homepage": "http://d3js.org",
"author": {
"name": "Mike Bostock",
"url": "http://bost.ocks.org/mike"
},
"contributors": [
{
"name": "Jason Davies",
"url": "http://jasondavies.com"
}
],
"repository": {
"type": "git",
"url": "https://github.com/mbostock/d3.git"
},
"main": "d3.js",
"browser": "d3.js",
"jspm": {
"main": "d3",
"shim": {
"d3": {
"exports": "d3"
}
},
"files": [
"d3.js"
],
"buildConfig": {
"uglify": true
}
},
"jam": {
"main": "d3.js",
"shim": {
"exports": "d3"
}
},
"spm": {
"main": "d3.js"
},
"devDependencies": {
"jsdom": "^16.0.1",
"seedrandom": "^3.0.5",
"smash": "0.0",
"uglify-js": "^3.7.5",
"vows": "0.8"
},
"scripts": {
"test": "vows && echo",
"prepublish": "npm test && rm -f package.js src/start.js d3.js d3.min.js d3.zip && bin/start > src/start.js && bin/meteor > package.js && smash src/d3.js | uglifyjs - -b indent-level=2 -o d3.js && bin/uglify d3.js > d3.min.js && chmod a-w d3.js d3.min.js package.js && zip d3.zip LICENSE d3.js d3.min.js",
"postpublish": "VERSION=`node -e 'console.log(require(\"./package.json\").version)'`; git push && git push --tags && cp -v README.md LICENSE d3.js d3.min.js ../d3-bower && cd ../d3-bower && git add README.md LICENSE d3.js d3.min.js && git commit -m \"Release $VERSION.\" && git tag -am \"Release $VERSION.\" v${VERSION} && git push && git push --tags && cd - && cp -v d3.js ../d3.github.com/d3.v3.js && cp -v d3.min.js ../d3.github.com/d3.v3.min.js && cd ../d3.github.com && git add d3.v3.js d3.v3.min.js && git commit -m \"d3 ${VERSION}\" && git push"
},
"license": "BSD-3-Clause"
}

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>
);
}
}