Move app/src (mostly) to flipper-ui-core/src
Summary: This diff moves all UI code from app/src to app/flipper-ui-core. That is now slightly too much (e.g. node deps are not removed yet), but from here it should be easier to move things out again, as I don't want this diff to be open for too long to avoid too much merge conflicts. * But at least flipper-ui-core is Electron free :) * Killed all cross module imports as well, as they where now even more in the way * Some unit test needed some changes, most not too big (but emotion hashes got renumbered in the snapshots, feel free to ignore that) * Found some files that were actually meaningless (tsconfig in plugins, WatchTools files, that start generating compile errors, removed those Follow up work: * make flipper-ui-core configurable, and wire up flipper-server-core in Electron instead of here * remove node deps (aigoncharov) * figure out correct place to load GKs, plugins, make intern requests etc., and move to the correct module * clean up deps Reviewed By: aigoncharov Differential Revision: D32427722 fbshipit-source-id: 14fe92e1ceb15b9dcf7bece367c8ab92df927a70
This commit is contained in:
committed by
Facebook GitHub Bot
parent
54b7ce9308
commit
7e50c0466a
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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 {Layout, styled} from '../ui';
|
||||
|
||||
export const CenteredContainer = styled(Layout.Container)({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
flexGrow: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 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 {Layout, styled} from '../ui';
|
||||
import {theme} from 'flipper-plugin';
|
||||
|
||||
export const ContentContainer = styled(Layout.Container)({
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
background: theme.backgroundDefault,
|
||||
border: `1px solid ${theme.dividerColor}`,
|
||||
borderRadius: theme.containerBorderRadius,
|
||||
boxShadow: `0px 0px 5px rgba(0, 0, 0, 0.05), 0px 0px 1px rgba(0, 0, 0, 0.05)`,
|
||||
});
|
||||
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* 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 {Typography, Card, Table, Collapse, Button} from 'antd';
|
||||
import {Layout, Link} from '../ui';
|
||||
import {
|
||||
NUX,
|
||||
Panel,
|
||||
theme,
|
||||
Tracked,
|
||||
TrackingScope,
|
||||
Tabs,
|
||||
Tab,
|
||||
} from 'flipper-plugin';
|
||||
import reactElementToJSXString from 'react-element-to-jsx-string';
|
||||
import {CodeOutlined} from '@ant-design/icons';
|
||||
|
||||
const {Text} = Typography;
|
||||
|
||||
const demoStyle: Record<string, React.CSSProperties> = {
|
||||
square: {
|
||||
background: theme.successColor,
|
||||
width: 50,
|
||||
height: 50,
|
||||
lineHeight: '50px',
|
||||
textAlign: 'center',
|
||||
},
|
||||
border: {border: `1px dotted ${theme.primaryColor}`},
|
||||
} as const;
|
||||
|
||||
type PreviewProps = {
|
||||
title: string;
|
||||
description?: React.ReactNode;
|
||||
props: [string, React.ReactNode, React.ReactNode][];
|
||||
demos: Record<string, React.ReactNode>;
|
||||
};
|
||||
|
||||
const largeChild = (
|
||||
<div style={{background: theme.warningColor}}>
|
||||
<img src="https://fbflipper.com/img/mascot.png" height={500} />
|
||||
</div>
|
||||
);
|
||||
const aButton = <Button>A button</Button>;
|
||||
const aBox = <div style={{...demoStyle.square, width: 100}}>A fixed child</div>;
|
||||
const aFixedWidthBox = (
|
||||
<div style={{background: theme.primaryColor, width: 150, color: 'white'}}>
|
||||
Fixed width box
|
||||
</div>
|
||||
);
|
||||
const aFixedHeightBox = (
|
||||
<div
|
||||
style={{
|
||||
background: theme.primaryColor,
|
||||
height: 40,
|
||||
lineHeight: '40px',
|
||||
color: 'white',
|
||||
}}>
|
||||
Fixed height box
|
||||
</div>
|
||||
);
|
||||
const aDynamicBox = (
|
||||
<div style={{background: theme.warningColor, flex: 1}}>
|
||||
A dynamic child (flex: 1)
|
||||
</div>
|
||||
);
|
||||
const someText = <Text>Some text</Text>;
|
||||
|
||||
const demos: PreviewProps[] = [
|
||||
{
|
||||
title: 'Layout.Container',
|
||||
description: `Layout.Container can be used to organize the UI in regions. It takes care of paddings and borders. Children will be arranged vertically. Use Layout.Horizontal instead for arranging children horizontally. If you need a margin on this component, try to wrap it in other Layout component instead.`,
|
||||
props: [
|
||||
['rounded', 'boolean (false)', 'Make the corners rounded'],
|
||||
[
|
||||
'padv / padh / pad',
|
||||
Object.keys(theme.space).join(' | ') + ' | number | true',
|
||||
'Short-hand to set the horizontal, vertical or both paddings. The keys correspond to the theme space settings. Using `true` picks the default horizontal / vertical padding for inline elements.',
|
||||
],
|
||||
[
|
||||
'width / height',
|
||||
'number',
|
||||
'Set the width / height of this container in pixels. Use sparingly.',
|
||||
],
|
||||
[
|
||||
'bordered',
|
||||
'boolean (false)',
|
||||
'This container will use a default border on all sides',
|
||||
],
|
||||
[
|
||||
'borderTop / borderRight / borderBottom / borderLeft',
|
||||
'boolean (false)',
|
||||
'Use a standard padding on the top side',
|
||||
],
|
||||
[
|
||||
'gap',
|
||||
'true / number (0)',
|
||||
'Set the spacing between children. If just set, theme.space.small will be used.',
|
||||
],
|
||||
[
|
||||
'center',
|
||||
'boolean (false)',
|
||||
'If set, all children will use their own naturally width, and they will be centered horizontally in the Container. If not set, all children will be stretched to the width of the Container.',
|
||||
],
|
||||
],
|
||||
demos: {
|
||||
'Basic container with fixed dimensions': (
|
||||
<Layout.Container style={demoStyle.square}></Layout.Container>
|
||||
),
|
||||
'Basic container with fixed height': (
|
||||
<Layout.Container
|
||||
style={{
|
||||
height: 50,
|
||||
background: theme.successColor,
|
||||
}}></Layout.Container>
|
||||
),
|
||||
'bordered pad rounded': (
|
||||
<Layout.Container
|
||||
bordered
|
||||
pad
|
||||
rounded
|
||||
style={{background: theme.backgroundDefault, width: 200}}>
|
||||
<div style={demoStyle.square}>child</div>
|
||||
</Layout.Container>
|
||||
),
|
||||
'Multiple children, gap={24}': (
|
||||
<Layout.Container gap={24}>
|
||||
{aButton}
|
||||
{someText}
|
||||
{aBox}
|
||||
{aDynamicBox}
|
||||
</Layout.Container>
|
||||
),
|
||||
'Multiple children icmw. pad center gap': (
|
||||
<Layout.Container pad center gap>
|
||||
{aButton}
|
||||
{someText}
|
||||
{aBox}
|
||||
{aDynamicBox}
|
||||
</Layout.Container>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Layout.Horizontal',
|
||||
description:
|
||||
'Use this component to arrange multiple items horizontally. All vanilla Container props can be used as well.',
|
||||
props: [
|
||||
[
|
||||
'center',
|
||||
'boolean (false)',
|
||||
'If set, all children will use their own height, and they will be centered vertically in the layout. If not set, all children will be stretched to the height of the layout.',
|
||||
],
|
||||
],
|
||||
demos: {
|
||||
'Basic usage, gap="large"': (
|
||||
<Layout.Horizontal gap="large">
|
||||
{aButton}
|
||||
{someText}
|
||||
{aBox}
|
||||
{aDynamicBox}
|
||||
</Layout.Horizontal>
|
||||
),
|
||||
'Using flags: pad center gap={8} (great for toolbars and such)': (
|
||||
<Layout.Horizontal pad center gap={8}>
|
||||
{aButton}
|
||||
{someText}
|
||||
{aBox}
|
||||
{aDynamicBox}
|
||||
</Layout.Horizontal>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Layout.ScrollContainer',
|
||||
description:
|
||||
'Use this component to create an area that can be scrolled. The scrollable area will automatically consume all available space. ScrollContainer accepts all properties that Container accepts as well. Padding will be applied to the child rather than the parent.',
|
||||
props: [
|
||||
[
|
||||
'horizontal / vertical',
|
||||
'boolean',
|
||||
'specifies in which directions the container should scroll. If none is specified the container will scroll in both directions',
|
||||
],
|
||||
[
|
||||
'padv / padh / pad',
|
||||
'see Container',
|
||||
'Padding will be applied to the child',
|
||||
],
|
||||
],
|
||||
demos: {
|
||||
'Basic usage': (
|
||||
<Layout.ScrollContainer style={{height: 100}}>
|
||||
{largeChild}
|
||||
</Layout.ScrollContainer>
|
||||
),
|
||||
'ScrollContainer + Vertical for vertical scroll only': (
|
||||
<Layout.ScrollContainer
|
||||
vertical
|
||||
style={{
|
||||
height: 100,
|
||||
width: 100,
|
||||
border: `2px solid ${theme.primaryColor}`,
|
||||
}}>
|
||||
<Layout.Container>
|
||||
<Text ellipsis>
|
||||
This text is truncated because it is too long and scroll is
|
||||
vertical only...
|
||||
</Text>
|
||||
{largeChild}
|
||||
</Layout.Container>
|
||||
</Layout.ScrollContainer>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Layout.Top|Left|Right|Bottom',
|
||||
description:
|
||||
"Divides all available space over two children. The (top|left|right|bottom)-most first child will keep it's own dimensions, and positioned (top|left|right|bottom) of the other child. All remaining space will be assigned to the remaining child. If you are using a Layout.Right at the top level of your plugin, consider using DetailSidebar component instead, which will move its children to the right sidebar of Flipper.",
|
||||
props: [
|
||||
[
|
||||
'scrollable',
|
||||
'boolean (false)',
|
||||
'If set, the area of the second child will automatically be made scrollable.',
|
||||
],
|
||||
[
|
||||
'center',
|
||||
'boolean (false)',
|
||||
'If set, all children will use their own height, and they will be centered vertically in the layout. If not set, all children will be stretched to the height of the layout.',
|
||||
],
|
||||
[
|
||||
'gap',
|
||||
'true / number (0)',
|
||||
'Set the spacing between children. If just set, theme.space.small will be used.',
|
||||
],
|
||||
[
|
||||
'resizable',
|
||||
'true / undefined',
|
||||
'If set, this split container will be resizable by the user. It is recommend to set width, maxWidth, minWidth respectively height, maxHeight, minHeight properties as well.',
|
||||
],
|
||||
[
|
||||
'width / height / minWidth / minHeight / maxWidth / maxHeight',
|
||||
'number / undefined',
|
||||
'These dimensions in pixels will be used for clamping if the layout is marked as resizable',
|
||||
],
|
||||
],
|
||||
demos: {
|
||||
'Layout.Top': (
|
||||
<Layout.Top>
|
||||
{aFixedHeightBox}
|
||||
{aDynamicBox}
|
||||
</Layout.Top>
|
||||
),
|
||||
'Layout.Left': (
|
||||
<Layout.Left>
|
||||
{aFixedWidthBox}
|
||||
{aDynamicBox}
|
||||
</Layout.Left>
|
||||
),
|
||||
'Layout.Right': (
|
||||
<Layout.Right>
|
||||
{aDynamicBox}
|
||||
{aFixedWidthBox}
|
||||
</Layout.Right>
|
||||
),
|
||||
'Layout.Bottom': (
|
||||
<Layout.Bottom>
|
||||
{aDynamicBox}
|
||||
{aFixedHeightBox}
|
||||
</Layout.Bottom>
|
||||
),
|
||||
'Layout.Top + Layout.ScrollContainer': (
|
||||
<Layout.Container style={{height: 150}}>
|
||||
<Layout.Top>
|
||||
{aFixedHeightBox}
|
||||
<Layout.ScrollContainer>{largeChild}</Layout.ScrollContainer>
|
||||
</Layout.Top>
|
||||
</Layout.Container>
|
||||
),
|
||||
'Layout.Left + Layout.ScrollContainer': (
|
||||
<Layout.Container style={{height: 150}}>
|
||||
<Layout.Left>
|
||||
{aFixedWidthBox}
|
||||
<Layout.ScrollContainer>{largeChild}</Layout.ScrollContainer>
|
||||
</Layout.Left>
|
||||
</Layout.Container>
|
||||
),
|
||||
'Layout.Right resizable + Layout.ScrollContainer': (
|
||||
<Layout.Container style={{height: 150}}>
|
||||
<Layout.Right resizable>
|
||||
<Layout.ScrollContainer>{largeChild}</Layout.ScrollContainer>
|
||||
{aDynamicBox}
|
||||
</Layout.Right>
|
||||
</Layout.Container>
|
||||
),
|
||||
'Layout.Bottom resizable + Layout.ScrollContainer': (
|
||||
<Layout.Container style={{height: 150}}>
|
||||
<Layout.Bottom resizable height={50} minHeight={20}>
|
||||
<Layout.ScrollContainer>{largeChild}</Layout.ScrollContainer>
|
||||
{aDynamicBox}
|
||||
</Layout.Bottom>
|
||||
</Layout.Container>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Panel',
|
||||
description:
|
||||
'A collapsible UI region. The collapsed state of the pane will automatically be persisted so that the collapsed state is restored the next time user visits the plugin again. Note that the children of a Panel should have some size, either a fixed or a natural size. Elements that grow to their parent size will become invisible.',
|
||||
props: [
|
||||
['title', 'string', 'Title of the pane'],
|
||||
[
|
||||
'collapsible',
|
||||
'boolean (true)',
|
||||
"If set to false it won't be possible to collapse the panel",
|
||||
],
|
||||
[
|
||||
'collapsed',
|
||||
'boolean (false)',
|
||||
'The initial collapsed state of the panel.',
|
||||
],
|
||||
[
|
||||
'pad / gap',
|
||||
'boolean / number (false)',
|
||||
'See the pad property of Layout.Container, determines whether the pane contents will have some padding and space between the items. By default no padding / gap is applied.',
|
||||
],
|
||||
],
|
||||
demos: {
|
||||
'Two panels in a fixed height container': (
|
||||
<Layout.Container>
|
||||
<Panel title="Panel 1">Some content</Panel>
|
||||
<Panel title="Panel 2 (collapsed)" collapsed>
|
||||
{aFixedHeightBox}
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Panel 3 (not collapsible, pad, gap)"
|
||||
collapsible={false}
|
||||
pad
|
||||
gap>
|
||||
{aFixedHeightBox}
|
||||
{aFixedHeightBox}
|
||||
</Panel>
|
||||
</Layout.Container>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Tabs / Tab',
|
||||
description:
|
||||
"Tabs represents a tab control and all it's children should be Tab components. By default the Tab control uses all available space, but set grow=false to only use the minimally required space",
|
||||
props: [
|
||||
[
|
||||
'grow (Tabs)',
|
||||
'boolean (true)',
|
||||
'If true, the tab control will grow all tabs to the maximum available vertical space. If false, only the minimal required (natural) vertical space will be used',
|
||||
],
|
||||
[
|
||||
'pad / gap (Tab)',
|
||||
'boolean / number (false)',
|
||||
'See the pad property of Layout.Container, determines whether the pane contents will have some padding and space between the items. By default no padding / gap is applied.',
|
||||
],
|
||||
[
|
||||
'other props',
|
||||
'',
|
||||
'This component wraps Tabs from ant design, see https://ant.design/components/tabs/ for more details',
|
||||
],
|
||||
],
|
||||
demos: {
|
||||
'Two tabs': (
|
||||
<Layout.Container height={200}>
|
||||
<Tabs>
|
||||
<Tab tab="Pane 1">{aDynamicBox}</Tab>
|
||||
<Tab tab="Pane 2 pad gap" pad gap>
|
||||
{aFixedHeightBox}
|
||||
{aFixedHeightBox}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Layout.Container>
|
||||
),
|
||||
'Two tabs (no grow)': (
|
||||
<Layout.Container grow={false}>
|
||||
<Tabs>
|
||||
<Tab tab="Pane 1">{aDynamicBox}</Tab>
|
||||
<Tab tab="Pane 2 pad gap" pad gap>
|
||||
{aFixedHeightBox}
|
||||
{aFixedHeightBox}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Layout.Container>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'NUX',
|
||||
description:
|
||||
'A component to provide a New-User-eXperience: Highlight new features to first time users. For tooltips that should stay available, use ToolTip from ANT design',
|
||||
props: [
|
||||
['title', 'string / React element', 'The tooltip contents'],
|
||||
[
|
||||
'placement',
|
||||
<>
|
||||
See{' '}
|
||||
<Link href="https://ant.design/components/tooltip/#components-tooltip-demo-placement">
|
||||
docs
|
||||
</Link>
|
||||
</>,
|
||||
'(optional) on which side to place the tooltip',
|
||||
],
|
||||
],
|
||||
demos: {
|
||||
'NUX example': (
|
||||
<NUX title="This button does something cool" placement="right">
|
||||
<Button>Hello world</Button>
|
||||
</NUX>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Tracked',
|
||||
description:
|
||||
'A component that tracks component interactions. For Facebook internal builds, global stats for these interactions will be tracked. Wrap this component around another element to track its events',
|
||||
props: [
|
||||
[
|
||||
'events',
|
||||
'string | string[] (default: "onClick")',
|
||||
'The event(s) of the child component that should be tracked',
|
||||
],
|
||||
[
|
||||
'action',
|
||||
'string (optional)',
|
||||
'Describes the element the user interacted with. Will by default be derived from the title, key or contents of the element',
|
||||
],
|
||||
],
|
||||
demos: {
|
||||
'Basic example': (
|
||||
<Tracked>
|
||||
<Button onClick={() => {}}>Test</Button>
|
||||
</Tracked>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'TrackingScope',
|
||||
description:
|
||||
'Describes more precisely the place in the UI for all underlying Tracked elements. Multiple Tracking scopes are automatically nested. Use the `withTrackingScope` HoC to automatically wrap a component definition in a tracking scope',
|
||||
props: [
|
||||
['scope', 'string', 'The name of the scope. For example "Login Dialog"'],
|
||||
],
|
||||
demos: {
|
||||
'Basic example': (
|
||||
<TrackingScope scope="tracking scope demo">
|
||||
<Tracked>
|
||||
<Button onClick={() => {}}>Test</Button>
|
||||
</Tracked>
|
||||
</TrackingScope>
|
||||
),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function ComponentPreview({title, demos, description, props}: PreviewProps) {
|
||||
return (
|
||||
<Card title={title} size="small" type="inner">
|
||||
<TrackingScope scope={title}>
|
||||
<Layout.Container gap="small">
|
||||
<Text type="secondary">{description}</Text>
|
||||
<Collapse ghost>
|
||||
<Collapse.Panel header="Examples" key="demos">
|
||||
<Layout.Container gap="large">
|
||||
{Object.entries(demos).map(([name, children]) => (
|
||||
<Tabs type="line" key={name}>
|
||||
<Tab tab={name} key="1">
|
||||
<div
|
||||
style={{
|
||||
background: theme.backgroundWash,
|
||||
width: '100%',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab tab={<CodeOutlined />} key="2">
|
||||
<div
|
||||
style={{
|
||||
background: theme.backgroundWash,
|
||||
width: '100%',
|
||||
padding: theme.space.medium,
|
||||
}}>
|
||||
<pre>{reactElementToJSXString(children)}</pre>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
))}
|
||||
</Layout.Container>
|
||||
</Collapse.Panel>
|
||||
<Collapse.Panel header="Props" key="props">
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={props.map((prop) =>
|
||||
Object.assign(prop, {key: prop[0]}),
|
||||
)}
|
||||
columns={[
|
||||
{
|
||||
title: 'Property',
|
||||
dataIndex: 0,
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: 'Type and default',
|
||||
dataIndex: 1,
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
dataIndex: 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Layout.Container>
|
||||
</TrackingScope>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export const DesignComponentDemos = () => (
|
||||
<Layout.Container gap={theme.space.large}>
|
||||
{demos.map((demo) => (
|
||||
<ComponentPreview key={demo.title} {...demo} />
|
||||
))}
|
||||
</Layout.Container>
|
||||
);
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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, {useEffect, useState} from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {toggleRightSidebarAvailable} from '../reducers/application';
|
||||
import {useDispatch, useStore} from '../utils/useStore';
|
||||
import {ContentContainer} from '../sandy-chrome/ContentContainer';
|
||||
import {Layout, _Sidebar} from 'flipper-plugin';
|
||||
|
||||
export type DetailSidebarProps = {
|
||||
children: any;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
};
|
||||
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
export function DetailSidebarImpl({
|
||||
children,
|
||||
width,
|
||||
minWidth,
|
||||
}: DetailSidebarProps) {
|
||||
const [domNode, setDomNode] = useState(
|
||||
document.getElementById('detailsSidebar'),
|
||||
);
|
||||
|
||||
if (typeof jest !== 'undefined') {
|
||||
// For unit tests, make sure to render elements inline
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const {rightSidebarAvailable, rightSidebarVisible} = useStore((state) => {
|
||||
const {rightSidebarAvailable, rightSidebarVisible} = state.application;
|
||||
return {rightSidebarAvailable, rightSidebarVisible};
|
||||
});
|
||||
|
||||
useEffect(
|
||||
function updateSidebarAvailablility() {
|
||||
const available = Boolean(children);
|
||||
if (available !== rightSidebarAvailable) {
|
||||
dispatch(toggleRightSidebarAvailable(available));
|
||||
}
|
||||
},
|
||||
[children, rightSidebarAvailable, dispatch],
|
||||
);
|
||||
|
||||
// If the plugin container is mounting and rendering a sidbar immediately, the domNode might not yet be available
|
||||
useEffect(() => {
|
||||
if (!domNode) {
|
||||
const newDomNode = document.getElementById('detailsSidebar');
|
||||
if (!newDomNode) {
|
||||
// if after layouting domNode is still not available, something is wrong...
|
||||
console.error('Failed to obtain detailsSidebar node');
|
||||
} else {
|
||||
setDomNode(newDomNode);
|
||||
}
|
||||
}
|
||||
}, [domNode]);
|
||||
|
||||
return (
|
||||
(children &&
|
||||
rightSidebarVisible &&
|
||||
domNode &&
|
||||
ReactDOM.createPortal(
|
||||
<_Sidebar
|
||||
minWidth={minWidth}
|
||||
width={width || 300}
|
||||
position="right"
|
||||
gutter>
|
||||
<ContentContainer>
|
||||
<Layout.ScrollContainer vertical>{children}</Layout.ScrollContainer>
|
||||
</ContentContainer>
|
||||
</_Sidebar>,
|
||||
domNode,
|
||||
)) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
479
desktop/flipper-ui-core/src/sandy-chrome/LeftRail.tsx
Normal file
479
desktop/flipper-ui-core/src/sandy-chrome/LeftRail.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* 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, {cloneElement, useState, useCallback, useMemo} from 'react';
|
||||
import {Button, Divider, Badge, Tooltip, Avatar, Popover, Menu} from 'antd';
|
||||
import {
|
||||
MobileFilled,
|
||||
AppstoreOutlined,
|
||||
BellOutlined,
|
||||
FileExclamationOutlined,
|
||||
LoginOutlined,
|
||||
SettingOutlined,
|
||||
MedicineBoxOutlined,
|
||||
RocketOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {SidebarLeft, SidebarRight} from './SandyIcons';
|
||||
import {useDispatch, useStore} from '../utils/useStore';
|
||||
import {
|
||||
toggleLeftSidebarVisible,
|
||||
toggleRightSidebarVisible,
|
||||
} from '../reducers/application';
|
||||
import {
|
||||
theme,
|
||||
Layout,
|
||||
withTrackingScope,
|
||||
Dialog,
|
||||
useTrackedCallback,
|
||||
NUX,
|
||||
} from 'flipper-plugin';
|
||||
import SetupDoctorScreen, {checkHasNewProblem} from './SetupDoctorScreen';
|
||||
import SettingsSheet from '../chrome/SettingsSheet';
|
||||
import WelcomeScreen from './WelcomeScreen';
|
||||
import {errorCounterAtom} from '../chrome/ConsoleLogs';
|
||||
import {ToplevelProps} from './SandyApp';
|
||||
import {useValue} from 'flipper-plugin';
|
||||
import {logout} from '../reducers/user';
|
||||
import config from '../fb-stubs/config';
|
||||
import styled from '@emotion/styled';
|
||||
import {showEmulatorLauncher} from './appinspect/LaunchEmulator';
|
||||
import SupportRequestFormV2 from '../fb-stubs/SupportRequestFormV2';
|
||||
import {setStaticView} from '../reducers/connections';
|
||||
import {getLogger} from 'flipper-common';
|
||||
import {SandyRatingButton} from '../chrome/RatingButton';
|
||||
import {filterNotifications} from './notification/notificationUtils';
|
||||
import {useMemoize} from 'flipper-plugin';
|
||||
import isProduction from '../utils/isProduction';
|
||||
import NetworkGraph from '../chrome/NetworkGraph';
|
||||
import FpsGraph from '../chrome/FpsGraph';
|
||||
import UpdateIndicator from '../chrome/UpdateIndicator';
|
||||
import PluginManager from '../chrome/plugin-manager/PluginManager';
|
||||
import {showLoginDialog} from '../chrome/fb-stubs/SignInSheet';
|
||||
import SubMenu from 'antd/lib/menu/SubMenu';
|
||||
import constants from '../fb-stubs/constants';
|
||||
import {
|
||||
canFileExport,
|
||||
canOpenDialog,
|
||||
showOpenDialog,
|
||||
startFileExport,
|
||||
startLinkExport,
|
||||
} from '../utils/exportData';
|
||||
import {openDeeplinkDialog} from '../deeplink';
|
||||
import {css} from '@emotion/css';
|
||||
|
||||
const LeftRailButtonElem = styled(Button)<{kind?: 'small'}>(({kind}) => ({
|
||||
width: kind === 'small' ? 32 : 36,
|
||||
height: kind === 'small' ? 32 : 36,
|
||||
padding: '5px 0',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
}));
|
||||
LeftRailButtonElem.displayName = 'LeftRailButtonElem';
|
||||
|
||||
export function LeftRailButton({
|
||||
icon,
|
||||
small,
|
||||
selected,
|
||||
toggled,
|
||||
count,
|
||||
title,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon?: React.ReactElement;
|
||||
small?: boolean;
|
||||
toggled?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
count?: number | true;
|
||||
title?: string;
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}) {
|
||||
let iconElement =
|
||||
icon && cloneElement(icon, {style: {fontSize: small ? 16 : 24}});
|
||||
if (count !== undefined) {
|
||||
iconElement =
|
||||
count === true ? (
|
||||
<Badge dot>{iconElement}</Badge>
|
||||
) : (
|
||||
<Badge count={count}>{iconElement}</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
let res = (
|
||||
<LeftRailButtonElem
|
||||
title={title}
|
||||
kind={small ? 'small' : undefined}
|
||||
type={selected ? 'primary' : 'ghost'}
|
||||
icon={iconElement}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
color: toggled ? theme.primaryColor : undefined,
|
||||
background: toggled ? theme.backgroundWash : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (title) {
|
||||
res = (
|
||||
<Tooltip title={title} placement="right">
|
||||
{res}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
const LeftRailDivider = styled(Divider)({
|
||||
margin: `10px 0`,
|
||||
width: 32,
|
||||
minWidth: 32,
|
||||
});
|
||||
LeftRailDivider.displayName = 'LeftRailDividier';
|
||||
|
||||
export const LeftRail = withTrackingScope(function LeftRail({
|
||||
toplevelSelection,
|
||||
setToplevelSelection,
|
||||
}: ToplevelProps) {
|
||||
return (
|
||||
<Layout.Container borderRight padv={12} width={48}>
|
||||
<Layout.Bottom style={{overflow: 'visible'}}>
|
||||
<Layout.Container center gap={10} padh={6}>
|
||||
<LeftRailButton
|
||||
icon={<MobileFilled />}
|
||||
title="App Inspect"
|
||||
selected={toplevelSelection === 'appinspect'}
|
||||
onClick={() => {
|
||||
setToplevelSelection('appinspect');
|
||||
}}
|
||||
/>
|
||||
<LeftRailButton
|
||||
icon={<AppstoreOutlined />}
|
||||
title="Plugin Manager"
|
||||
onClick={() => {
|
||||
Dialog.showModal((onHide) => <PluginManager onHide={onHide} />);
|
||||
}}
|
||||
/>
|
||||
<NotificationButton
|
||||
toplevelSelection={toplevelSelection}
|
||||
setToplevelSelection={setToplevelSelection}
|
||||
/>
|
||||
<LeftRailDivider />
|
||||
<DebugLogsButton
|
||||
toplevelSelection={toplevelSelection}
|
||||
setToplevelSelection={setToplevelSelection}
|
||||
/>
|
||||
</Layout.Container>
|
||||
<Layout.Container center gap={10} padh={6}>
|
||||
{!isProduction() && (
|
||||
<div>
|
||||
<FpsGraph />
|
||||
<NetworkGraph />
|
||||
</div>
|
||||
)}
|
||||
<UpdateIndicator />
|
||||
<SandyRatingButton />
|
||||
<LaunchEmulatorButton />
|
||||
<SetupDoctorButton />
|
||||
<RightSidebarToggleButton />
|
||||
<LeftSidebarToggleButton />
|
||||
<ExtrasMenu />
|
||||
{config.showLogin && <LoginButton />}
|
||||
</Layout.Container>
|
||||
</Layout.Bottom>
|
||||
</Layout.Container>
|
||||
);
|
||||
});
|
||||
|
||||
const menu = css`
|
||||
border: none;
|
||||
`;
|
||||
const submenu = css`
|
||||
.ant-menu-submenu-title {
|
||||
width: 32px;
|
||||
height: 32px !important;
|
||||
line-height: 32px !important;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.ant-menu-submenu-arrow {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const MenuDividerPadded = styled(Menu.Divider)({
|
||||
marginBottom: '8px !important',
|
||||
});
|
||||
|
||||
function ExtrasMenu() {
|
||||
const store = useStore();
|
||||
|
||||
const startFileExportTracked = useTrackedCallback(
|
||||
'File export',
|
||||
() => startFileExport(store.dispatch),
|
||||
[store.dispatch],
|
||||
);
|
||||
const startLinkExportTracked = useTrackedCallback(
|
||||
'Link export',
|
||||
() => startLinkExport(store.dispatch),
|
||||
[store.dispatch],
|
||||
);
|
||||
const startImportTracked = useTrackedCallback(
|
||||
'File import',
|
||||
() => showOpenDialog(store),
|
||||
[store],
|
||||
);
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const onSettingsClose = useCallback(() => setShowSettings(false), []);
|
||||
|
||||
const settings = useStore((state) => state.settingsState);
|
||||
const {showWelcomeAtStartup} = settings;
|
||||
const [welcomeVisible, setWelcomeVisible] = useState(showWelcomeAtStartup);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NUX
|
||||
title="Find import, export, deeplink, feedback, settings, and help (welcome) here"
|
||||
placement="right">
|
||||
<Menu mode="vertical" className={menu} selectable={false}>
|
||||
<SubMenu
|
||||
popupOffset={[10, 0]}
|
||||
key="extras"
|
||||
title={<LeftRailButton icon={<SettingOutlined />} small />}
|
||||
className={submenu}>
|
||||
{canOpenDialog() ? (
|
||||
<Menu.Item key="importFlipperFile" onClick={startImportTracked}>
|
||||
Import Flipper file
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{canFileExport() ? (
|
||||
<Menu.Item key="exportFile" onClick={startFileExportTracked}>
|
||||
Export Flipper file
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{constants.ENABLE_SHAREABLE_LINK ? (
|
||||
<Menu.Item
|
||||
key="exportShareableLink"
|
||||
onClick={startLinkExportTracked}>
|
||||
Export shareable link
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
<Menu.Item
|
||||
key="triggerDeeplink"
|
||||
onClick={() => openDeeplinkDialog(store)}>
|
||||
Trigger deeplink
|
||||
</Menu.Item>
|
||||
{config.isFBBuild ? (
|
||||
<>
|
||||
<MenuDividerPadded />
|
||||
<Menu.Item
|
||||
key="feedback"
|
||||
onClick={() => {
|
||||
getLogger().track('usage', 'support-form-source', {
|
||||
source: 'sidebar',
|
||||
group: undefined,
|
||||
});
|
||||
store.dispatch(setStaticView(SupportRequestFormV2));
|
||||
}}>
|
||||
Feedback
|
||||
</Menu.Item>
|
||||
</>
|
||||
) : null}
|
||||
<MenuDividerPadded />
|
||||
<Menu.Item key="settings" onClick={() => setShowSettings(true)}>
|
||||
Settings
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="help" onClick={() => setWelcomeVisible(true)}>
|
||||
Help
|
||||
</Menu.Item>
|
||||
</SubMenu>
|
||||
</Menu>
|
||||
</NUX>
|
||||
{showSettings && (
|
||||
<SettingsSheet platform={process.platform} onHide={onSettingsClose} />
|
||||
)}
|
||||
<WelcomeScreen
|
||||
visible={welcomeVisible}
|
||||
onClose={() => setWelcomeVisible(false)}
|
||||
showAtStartup={showWelcomeAtStartup}
|
||||
onCheck={(value) =>
|
||||
store.dispatch({
|
||||
type: 'UPDATE_SETTINGS',
|
||||
payload: {...settings, showWelcomeAtStartup: value},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LeftSidebarToggleButton() {
|
||||
const dispatch = useDispatch();
|
||||
const mainMenuVisible = useStore(
|
||||
(state) => state.application.leftSidebarVisible,
|
||||
);
|
||||
|
||||
return (
|
||||
<LeftRailButton
|
||||
icon={<SidebarLeft />}
|
||||
small
|
||||
title="Left Sidebar Toggle"
|
||||
toggled={mainMenuVisible}
|
||||
onClick={() => {
|
||||
dispatch(toggleLeftSidebarVisible());
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RightSidebarToggleButton() {
|
||||
const dispatch = useDispatch();
|
||||
const rightSidebarAvailable = useStore(
|
||||
(state) => state.application.rightSidebarAvailable,
|
||||
);
|
||||
const rightSidebarVisible = useStore(
|
||||
(state) => state.application.rightSidebarVisible,
|
||||
);
|
||||
|
||||
return (
|
||||
<LeftRailButton
|
||||
icon={<SidebarRight />}
|
||||
small
|
||||
title="Right Sidebar Toggle"
|
||||
toggled={rightSidebarVisible}
|
||||
disabled={!rightSidebarAvailable}
|
||||
onClick={() => {
|
||||
dispatch(toggleRightSidebarVisible());
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationButton({
|
||||
toplevelSelection,
|
||||
setToplevelSelection,
|
||||
}: ToplevelProps) {
|
||||
const notifications = useStore((state) => state.notifications);
|
||||
const activeNotifications = useMemoize(filterNotifications, [
|
||||
notifications.activeNotifications,
|
||||
notifications.blocklistedPlugins,
|
||||
notifications.blocklistedCategories,
|
||||
]);
|
||||
return (
|
||||
<LeftRailButton
|
||||
icon={<BellOutlined />}
|
||||
title="Notifications"
|
||||
selected={toplevelSelection === 'notification'}
|
||||
count={activeNotifications.length}
|
||||
onClick={() => setToplevelSelection('notification')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DebugLogsButton({
|
||||
toplevelSelection,
|
||||
setToplevelSelection,
|
||||
}: ToplevelProps) {
|
||||
const errorCount = useValue(errorCounterAtom);
|
||||
return (
|
||||
<LeftRailButton
|
||||
icon={<FileExclamationOutlined />}
|
||||
title="Flipper Logs"
|
||||
selected={toplevelSelection === 'flipperlogs'}
|
||||
count={errorCount}
|
||||
onClick={() => {
|
||||
setToplevelSelection('flipperlogs');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LaunchEmulatorButton() {
|
||||
const store = useStore();
|
||||
|
||||
return (
|
||||
<LeftRailButton
|
||||
icon={<RocketOutlined />}
|
||||
title="Start Emulator / Simulator"
|
||||
onClick={() => {
|
||||
showEmulatorLauncher(store);
|
||||
}}
|
||||
small
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupDoctorButton() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const result = useStore(
|
||||
(state) => state.healthchecks.healthcheckReport.result,
|
||||
);
|
||||
const hasNewProblem = useMemo(() => checkHasNewProblem(result), [result]);
|
||||
const onClose = useCallback(() => setVisible(false), []);
|
||||
return (
|
||||
<>
|
||||
<LeftRailButton
|
||||
icon={<MedicineBoxOutlined />}
|
||||
small
|
||||
title="Setup Doctor"
|
||||
count={hasNewProblem ? true : undefined}
|
||||
onClick={() => setVisible(true)}
|
||||
/>
|
||||
<SetupDoctorScreen visible={visible} onClose={onClose} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginButton() {
|
||||
const dispatch = useDispatch();
|
||||
const user = useStore((state) => state.user);
|
||||
const login = (user?.id ?? null) !== null;
|
||||
const profileUrl = user?.profile_picture?.uri;
|
||||
const [showLogout, setShowLogout] = useState(false);
|
||||
const onHandleVisibleChange = useCallback(
|
||||
(visible) => setShowLogout(visible),
|
||||
[],
|
||||
);
|
||||
|
||||
return login ? (
|
||||
<Popover
|
||||
content={
|
||||
<Button
|
||||
block
|
||||
style={{backgroundColor: theme.backgroundDefault}}
|
||||
onClick={() => {
|
||||
onHandleVisibleChange(false);
|
||||
dispatch(logout());
|
||||
}}>
|
||||
Log Out
|
||||
</Button>
|
||||
}
|
||||
trigger="click"
|
||||
placement="right"
|
||||
visible={showLogout}
|
||||
overlayStyle={{padding: 0}}
|
||||
onVisibleChange={onHandleVisibleChange}>
|
||||
<Layout.Container padv={theme.inlinePaddingV}>
|
||||
<Avatar size="small" src={profileUrl} />
|
||||
</Layout.Container>
|
||||
</Popover>
|
||||
) : (
|
||||
<>
|
||||
<LeftRailButton
|
||||
icon={<LoginOutlined />}
|
||||
title="Log In"
|
||||
onClick={() => showLoginDialog()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
desktop/flipper-ui-core/src/sandy-chrome/LeftSidebar.tsx
Normal file
59
desktop/flipper-ui-core/src/sandy-chrome/LeftSidebar.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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 {theme} from 'flipper-plugin';
|
||||
import styled from '@emotion/styled';
|
||||
import {Layout} from '../ui';
|
||||
import {Button, Tooltip, Typography} from 'antd';
|
||||
import {InfoCircleOutlined} from '@ant-design/icons';
|
||||
|
||||
export const LeftSidebar: React.FC = ({children}) => (
|
||||
<Layout.Container
|
||||
borderRight
|
||||
style={{paddingTop: theme.space.small}}
|
||||
grow
|
||||
shrink>
|
||||
{children}
|
||||
</Layout.Container>
|
||||
);
|
||||
|
||||
export function SidebarTitle({
|
||||
children,
|
||||
actions,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<LeftMenuTitle center>
|
||||
<Typography.Text>{children}</Typography.Text>
|
||||
<>{actions}</>
|
||||
</LeftMenuTitle>
|
||||
);
|
||||
}
|
||||
|
||||
const LeftMenuTitle = styled(Layout.Horizontal)({
|
||||
padding: `0px ${theme.inlinePaddingH}px`,
|
||||
lineHeight: `${theme.space.large}px`,
|
||||
fontSize: theme.fontSize.small,
|
||||
textTransform: 'uppercase',
|
||||
'> :first-child': {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export const InfoIcon: React.FC<{}> = ({children}) => (
|
||||
<Tooltip placement="bottom" title={children} mouseEnterDelay={0.5}>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<InfoCircleOutlined color={theme.textColorSecondary} />}></Button>
|
||||
</Tooltip>
|
||||
);
|
||||
242
desktop/flipper-ui-core/src/sandy-chrome/SandyApp.tsx
Normal file
242
desktop/flipper-ui-core/src/sandy-chrome/SandyApp.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 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, {useEffect, useState, useCallback} from 'react';
|
||||
import {
|
||||
TrackingScope,
|
||||
useLogger,
|
||||
_Sidebar,
|
||||
Layout,
|
||||
Dialog,
|
||||
_PortalsManager,
|
||||
} from 'flipper-plugin';
|
||||
import {Link, styled} from '../ui';
|
||||
import {theme} from 'flipper-plugin';
|
||||
import {Logger} from 'flipper-common';
|
||||
|
||||
import {LeftRail} from './LeftRail';
|
||||
import {useStore, useDispatch} from '../utils/useStore';
|
||||
import {FlipperDevTools} from '../chrome/FlipperDevTools';
|
||||
import {setStaticView} from '../reducers/connections';
|
||||
import {toggleLeftSidebarVisible} from '../reducers/application';
|
||||
import {AppInspect} from './appinspect/AppInspect';
|
||||
import PluginContainer from '../PluginContainer';
|
||||
import {ContentContainer} from './ContentContainer';
|
||||
import {Notification} from './notification/Notification';
|
||||
import ChangelogSheet, {hasNewChangesToShow} from '../chrome/ChangelogSheet';
|
||||
import PlatformSelectWizard, {
|
||||
hasPlatformWizardBeenDone,
|
||||
} from '../chrome/PlatformSelectWizard';
|
||||
import {getVersionString} from '../utils/versionString';
|
||||
import config from '../fb-stubs/config';
|
||||
import {WelcomeScreenStaticView} from './WelcomeScreen';
|
||||
import fbConfig from '../fb-stubs/config';
|
||||
import {isFBEmployee} from '../utils/fbEmployee';
|
||||
import {notification} from 'antd';
|
||||
import isProduction from '../utils/isProduction';
|
||||
import {getRenderHostInstance} from '../RenderHost';
|
||||
|
||||
export type ToplevelNavItem =
|
||||
| 'appinspect'
|
||||
| 'flipperlogs'
|
||||
| 'notification'
|
||||
| undefined;
|
||||
export type ToplevelProps = {
|
||||
toplevelSelection: ToplevelNavItem;
|
||||
setToplevelSelection: (_newSelection: ToplevelNavItem) => void;
|
||||
};
|
||||
|
||||
export function SandyApp() {
|
||||
const logger = useLogger();
|
||||
const dispatch = useDispatch();
|
||||
const leftSidebarVisible = useStore(
|
||||
(state) => state.application.leftSidebarVisible,
|
||||
);
|
||||
const staticView = useStore((state) => state.connections.staticView);
|
||||
|
||||
/**
|
||||
* top level navigation uses two pieces of state, selection stored here, and selection that is based on what is stored in the reducer (which might be influenced by redux action dispatches to different means).
|
||||
* The logic here is to sync both, but without modifying the navigation related reducers to not break classic Flipper.
|
||||
* It is possible to simplify this in the future.
|
||||
*/
|
||||
const [toplevelSelection, setStoredToplevelSelection] =
|
||||
useState<ToplevelNavItem>('appinspect');
|
||||
|
||||
// Handle toplevel nav clicks from LeftRail
|
||||
const setToplevelSelection = useCallback(
|
||||
(newSelection: ToplevelNavItem) => {
|
||||
// toggle sidebar visibility if needed
|
||||
const hasLeftSidebar =
|
||||
newSelection === 'appinspect' || newSelection === 'notification';
|
||||
if (hasLeftSidebar) {
|
||||
if (newSelection === toplevelSelection) {
|
||||
dispatch(toggleLeftSidebarVisible());
|
||||
} else {
|
||||
dispatch(toggleLeftSidebarVisible(true));
|
||||
}
|
||||
}
|
||||
switch (newSelection) {
|
||||
case 'flipperlogs':
|
||||
dispatch(setStaticView(FlipperDevTools));
|
||||
break;
|
||||
default:
|
||||
}
|
||||
setStoredToplevelSelection(newSelection);
|
||||
},
|
||||
[dispatch, toplevelSelection],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `Flipper (${getVersionString()}${
|
||||
config.isFBBuild ? '@FB' : ''
|
||||
})`;
|
||||
|
||||
registerStartupTime(logger);
|
||||
if (hasNewChangesToShow(window.localStorage)) {
|
||||
Dialog.showModal((onHide) => <ChangelogSheet onHide={onHide} recent />);
|
||||
}
|
||||
|
||||
if (hasPlatformWizardBeenDone(window.localStorage)) {
|
||||
Dialog.showModal((onHide) => (
|
||||
<PlatformSelectWizard onHide={onHide} platform={process.platform} />
|
||||
));
|
||||
}
|
||||
|
||||
// don't warn about logger, even with a new logger we don't want to re-register
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (fbConfig.warnFBEmployees && isProduction()) {
|
||||
isFBEmployee()
|
||||
.then((isEmployee) => {
|
||||
if (isEmployee) {
|
||||
notification.warning({
|
||||
placement: 'bottomLeft',
|
||||
message: 'Please use Flipper@FB',
|
||||
description: (
|
||||
<>
|
||||
You are using the open-source version of Flipper. Install the
|
||||
internal build from{' '}
|
||||
<Link href="munki://detail-Flipper">
|
||||
Managed Software Center
|
||||
</Link>{' '}
|
||||
to get access to more plugins.
|
||||
</>
|
||||
),
|
||||
duration: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('Failed to check if user is employee', e);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const leftMenuContent = !leftSidebarVisible ? null : toplevelSelection ===
|
||||
'appinspect' ? (
|
||||
<AppInspect />
|
||||
) : toplevelSelection === 'notification' ? (
|
||||
<Notification />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<RootElement>
|
||||
<Layout.Bottom>
|
||||
<Layout.Left>
|
||||
<Layout.Horizontal>
|
||||
<LeftRail
|
||||
toplevelSelection={toplevelSelection}
|
||||
setToplevelSelection={setToplevelSelection}
|
||||
/>
|
||||
<_Sidebar width={250} minWidth={220} maxWidth={800} gutter>
|
||||
{leftMenuContent && (
|
||||
<TrackingScope scope={toplevelSelection!}>
|
||||
{leftMenuContent}
|
||||
</TrackingScope>
|
||||
)}
|
||||
</_Sidebar>
|
||||
</Layout.Horizontal>
|
||||
<MainContainer>
|
||||
{staticView ? (
|
||||
<TrackingScope
|
||||
scope={
|
||||
(staticView as any).displayName ??
|
||||
staticView.name ??
|
||||
staticView.constructor?.name ??
|
||||
'unknown static view'
|
||||
}>
|
||||
{staticView === WelcomeScreenStaticView ? (
|
||||
React.createElement(staticView) /* avoid shadow */
|
||||
) : (
|
||||
<ContentContainer>
|
||||
{React.createElement(staticView, {
|
||||
logger: logger,
|
||||
})}
|
||||
</ContentContainer>
|
||||
)}
|
||||
</TrackingScope>
|
||||
) : (
|
||||
<PluginContainer logger={logger} />
|
||||
)}
|
||||
{outOfContentsContainer}
|
||||
</MainContainer>
|
||||
</Layout.Left>
|
||||
<_PortalsManager />
|
||||
</Layout.Bottom>
|
||||
</RootElement>
|
||||
);
|
||||
}
|
||||
|
||||
const outOfContentsContainer = (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'none',
|
||||
}}>
|
||||
<div
|
||||
id="flipper-out-of-contents-container"
|
||||
style={{
|
||||
display: 'none',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MainContainer = styled(Layout.Container)({
|
||||
background: theme.backgroundWash,
|
||||
padding: `${theme.space.large}px ${theme.space.large}px ${theme.space.large}px 0`,
|
||||
});
|
||||
|
||||
const RootElement = styled.div({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
});
|
||||
RootElement.displayName = 'SandyAppRootElement';
|
||||
|
||||
function registerStartupTime(logger: Logger) {
|
||||
// track time since launch
|
||||
const [s, ns] = process.hrtime();
|
||||
const launchEndTime = s * 1e3 + ns / 1e6;
|
||||
const renderHost = getRenderHostInstance();
|
||||
renderHost.onIpcEvent('getLaunchTime', (launchStartTime: number) => {
|
||||
logger.track('performance', 'launchTime', launchEndTime - launchStartTime);
|
||||
});
|
||||
|
||||
renderHost.sendIpcEvent('getLaunchTime');
|
||||
renderHost.sendIpcEvent('componentDidMount');
|
||||
}
|
||||
178
desktop/flipper-ui-core/src/sandy-chrome/SandyDesignSystem.tsx
Normal file
178
desktop/flipper-ui-core/src/sandy-chrome/SandyDesignSystem.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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 {Typography, Button, Space, Input, Card, Alert, List} from 'antd';
|
||||
import {Layout} from '../ui';
|
||||
import {theme} from 'flipper-plugin';
|
||||
import {css} from '@emotion/css';
|
||||
import {DesignComponentDemos} from './DesignComponentDemos';
|
||||
|
||||
const {Title, Text, Link} = Typography;
|
||||
|
||||
export default function SandyDesignSystem() {
|
||||
return (
|
||||
<Layout.ScrollContainer className={reset}>
|
||||
<Layout.Container gap="large">
|
||||
<Card title="Flipper Design System" bordered={false}>
|
||||
<p>
|
||||
Welcome to the Flipper Design System. The Flipper design system is
|
||||
based on{' '}
|
||||
<Link href="https://ant.design/components/overview/">
|
||||
Ant Design
|
||||
</Link>
|
||||
. Any component found in the ANT documentation can be used. This
|
||||
page demonstrates the usage of:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Colors</li>
|
||||
<li>Typography</li>
|
||||
<li>Theme Variables</li>
|
||||
<li>Layout components</li>
|
||||
</ul>
|
||||
<p>
|
||||
The following components from Ant should <em>not</em> be used:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<code>Layout</code>: use Flipper's <code>Layout.*</code> instead.
|
||||
</li>
|
||||
</ul>
|
||||
<p>Sandy guidelines</p>
|
||||
<ul>
|
||||
<li>
|
||||
Avoid using `margin` properties, use padding on the container
|
||||
indeed, or <code>gap</code> in combination with{' '}
|
||||
<code>Layout.Horizontal|Vertical</code>
|
||||
</li>
|
||||
<li>
|
||||
Avoid using <code>width / height: 100%</code>, use{' '}
|
||||
<code>Layout.Container</code> instead.
|
||||
</li>
|
||||
<li>
|
||||
In general, components that have a <code>grow</code> property will
|
||||
grow to use the full height of their <em>parents</em> if{' '}
|
||||
<code>true</code>. In contrast, if grow is set to{' '}
|
||||
<code>false</code> components will use their natural size, based
|
||||
on their <em>children</em>.
|
||||
</li>
|
||||
<li>
|
||||
The other important property here is <em>scrollable</em>. If an
|
||||
element supports this property, setting it will imply{' '}
|
||||
<code>grow</code>, and the element will show a scrollbar if
|
||||
needed. Setting <code>scrollabe</code> to <code>false</code>{' '}
|
||||
causes the element to always use its natural size, growing or
|
||||
shrinking based on the contents rather than the parent.
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
<Card title="Colors" bordered={false}>
|
||||
<Alert message="The following colors are available on the <code>theme</code> object. Please stick to this color palette, as these colors will be translated to dark mode correctly." />
|
||||
<ColorPreview name="primaryColor" />
|
||||
<ColorPreview name="successColor" />
|
||||
<ColorPreview name="errorColor" />
|
||||
<ColorPreview name="warningColor" />
|
||||
<ColorPreview name="textColorPrimary" />
|
||||
<ColorPreview name="textColorSecondary" />
|
||||
<ColorPreview name="textColorPlaceholder" />
|
||||
<ColorPreview name="textColorActive" />
|
||||
<ColorPreview name="disabledColor" />
|
||||
<ColorPreview name="backgroundDefault" />
|
||||
<ColorPreview name="backgroundWash" />
|
||||
<ColorPreview name="buttonDefaultBackground" />
|
||||
<ColorPreview name="backgroundTransparentHover" />
|
||||
<ColorPreview name="dividerColor" />
|
||||
</Card>
|
||||
<Card title="Typography" bordered={false}>
|
||||
<Space direction="vertical">
|
||||
<Alert
|
||||
message={
|
||||
<>
|
||||
Common Ant components, with modifiers applied. The{' '}
|
||||
<code>Title</code>, <code>Text</code> and <code>Link</code>{' '}
|
||||
components can be found by importing the{' '}
|
||||
<code>Typography</code> namespace from Ant.
|
||||
</>
|
||||
}
|
||||
type="info"
|
||||
/>
|
||||
<Title>Title</Title>
|
||||
<Title level={2}>Title level=2</Title>
|
||||
<Title level={3}>Title level=3</Title>
|
||||
<Title level={4}>Title level=4</Title>
|
||||
<Text>Text</Text>
|
||||
<Text type="secondary">Text - type=secondary</Text>
|
||||
<Text type="success">Text - type=success</Text>
|
||||
<Text type="warning">Text - type=warning</Text>
|
||||
<Text type="danger">Text - danger</Text>
|
||||
<Text disabled>Text - disbled </Text>
|
||||
<Text strong>Text - strong</Text>
|
||||
<Text code>Text - code</Text>
|
||||
<Link href="https://fbflipper.com/">Link</Link>
|
||||
<Link type="secondary" href="https://fbflipper.com/">
|
||||
Link - type=secondary
|
||||
</Link>
|
||||
<Button>Button</Button>
|
||||
<Button size="small">Button - size=small</Button>
|
||||
<Input placeholder="Input" />
|
||||
</Space>
|
||||
</Card>
|
||||
<Card title="Theme variables" bordered={false}>
|
||||
<Alert
|
||||
message={
|
||||
<>
|
||||
The following theme veriables are exposed from the Flipper{' '}
|
||||
<code>theme</code> object. See the colors section above for a
|
||||
preview of the colors.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<pre>{JSON.stringify(theme, null, 2)}</pre>
|
||||
</Card>
|
||||
<Card title="Layout components" bordered={false}>
|
||||
<DesignComponentDemos />
|
||||
</Card>
|
||||
</Layout.Container>
|
||||
</Layout.ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorPreview({name}: {name: keyof typeof theme}) {
|
||||
return (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<div
|
||||
style={{
|
||||
background: theme[name] as any,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={`theme.${name}`}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
|
||||
const reset = css`
|
||||
ol,
|
||||
ul {
|
||||
list-style: revert;
|
||||
margin-left: ${theme.space.huge}px;
|
||||
}
|
||||
.ant-alert {
|
||||
margin-bottom: ${theme.space.huge}px;
|
||||
}
|
||||
.ant-card {
|
||||
background: transparent;
|
||||
}
|
||||
`;
|
||||
40
desktop/flipper-ui-core/src/sandy-chrome/SandyIcons.tsx
Normal file
40
desktop/flipper-ui-core/src/sandy-chrome/SandyIcons.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
export const SidebarRight = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg width={16} height={16} viewBox="0 0 16 16" fill="none" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M14.667 1.333H1.333v13.334h13.334V1.333zM1.333 0C.597 0 0 .597 0 1.333v13.334C0 15.403.597 16 1.333 16h13.334c.736 0 1.333-.597 1.333-1.333V1.333C16 .597 15.403 0 14.667 0H1.333z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M9.778 1.333h1.333v13.334H9.778V1.333zM11.556 3c0-.184.149-.333.333-.333h2c.184 0 .333.149.333.333v.667c0 .184-.149.333-.333.333h-2a.333.333 0 01-.333-.333V3zM11.556 5c0-.184.149-.333.333-.333h2c.184 0 .333.149.333.333v.667c0 .184-.149.333-.333.333h-2a.333.333 0 01-.333-.333V5zM11.556 7c0-.184.149-.333.333-.333h2c.184 0 .333.149.333.333v.667c0 .184-.149.333-.333.333h-2a.333.333 0 01-.333-.333V7zM11.556 9c0-.184.149-.333.333-.333h2c.184 0 .333.149.333.333v.667c0 .184-.149.333-.333.333h-2a.333.333 0 01-.333-.333V9z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SidebarLeft = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg width={16} height={16} viewBox="0 0 16 16" fill="none" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M14.667 1.333H1.333v13.334h13.334V1.333zM1.333 0C.597 0 0 .597 0 1.333v13.334C0 15.403.597 16 1.333 16h13.334c.736 0 1.333-.597 1.333-1.333V1.333C16 .597 15.403 0 14.667 0H1.333z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M4.889 1.333h1.333v13.334H4.89V1.333zM1.778 3c0-.184.149-.333.333-.333h2c.184 0 .333.149.333.333v.667c0 .184-.149.333-.333.333h-2a.333.333 0 01-.333-.333V3zM1.778 5c0-.184.149-.333.333-.333h2c.184 0 .333.149.333.333v.667c0 .184-.149.333-.333.333h-2a.333.333 0 01-.333-.333V5zM1.778 7c0-.184.149-.333.333-.333h2c.184 0 .333.149.333.333v.667c0 .184-.149.333-.333.333h-2a.333.333 0 01-.333-.333V7zM1.778 9c0-.184.149-.333.333-.333h2c.184 0 .333.149.333.333v.667c0 .184-.149.333-.333.333h-2a.333.333 0 01-.333-.333V9z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
275
desktop/flipper-ui-core/src/sandy-chrome/SetupDoctorScreen.tsx
Normal file
275
desktop/flipper-ui-core/src/sandy-chrome/SetupDoctorScreen.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 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, {useEffect, useCallback, useMemo, useState} from 'react';
|
||||
import {useDispatch, useStore} from '../utils/useStore';
|
||||
import {Typography, Collapse, Button, Modal, Checkbox, Alert} from 'antd';
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
CloseCircleFilled,
|
||||
WarningFilled,
|
||||
QuestionCircleFilled,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {Layout} from '../ui';
|
||||
import {
|
||||
HealthcheckReport,
|
||||
HealthcheckReportItem,
|
||||
HealthcheckStatus,
|
||||
HealthcheckResult,
|
||||
} from '../reducers/healthchecks';
|
||||
import {theme} from 'flipper-plugin';
|
||||
import {
|
||||
startHealthchecks,
|
||||
updateHealthcheckResult,
|
||||
finishHealthchecks,
|
||||
acknowledgeProblems,
|
||||
resetAcknowledgedProblems,
|
||||
} from '../reducers/healthchecks';
|
||||
import runHealthchecks from '../utils/runHealthchecks';
|
||||
import {Healthchecks} from 'flipper-doctor';
|
||||
import {reportUsage} from 'flipper-common';
|
||||
|
||||
const {Title, Paragraph, Text} = Typography;
|
||||
|
||||
const statusTypeAndMessage: {
|
||||
[key in HealthcheckStatus]: {
|
||||
type: 'success' | 'info' | 'warning' | 'error';
|
||||
message: string;
|
||||
};
|
||||
} = {
|
||||
IN_PROGRESS: {type: 'info', message: 'Doctor is running healthchecks...'},
|
||||
FAILED: {
|
||||
type: 'error',
|
||||
message:
|
||||
'Problems have been discovered with your installation. Please expand items for details.',
|
||||
},
|
||||
WARNING: {
|
||||
type: 'warning',
|
||||
message: 'Doctor has discovered warnings. Please expand items for details.',
|
||||
},
|
||||
SUCCESS: {
|
||||
type: 'success',
|
||||
message:
|
||||
'All good! Doctor has not discovered any issues with your installation.',
|
||||
},
|
||||
// This is deduced from default case (for completeness)
|
||||
SKIPPED: {
|
||||
type: 'success',
|
||||
message:
|
||||
'All good! Doctor has not discovered any issues with your installation.',
|
||||
},
|
||||
};
|
||||
|
||||
function checkHasProblem(result: HealthcheckResult) {
|
||||
return result.status === 'FAILED' || result.status === 'WARNING';
|
||||
}
|
||||
|
||||
export function checkHasNewProblem(result: HealthcheckResult) {
|
||||
return checkHasProblem(result) && !result.isAcknowledged;
|
||||
}
|
||||
|
||||
function ResultTopDialog(props: {status: HealthcheckStatus}) {
|
||||
const messages = statusTypeAndMessage[props.status];
|
||||
return (
|
||||
<Alert
|
||||
type={messages.type}
|
||||
showIcon
|
||||
message={messages.message}
|
||||
style={{
|
||||
fontSize: theme.fontSize.small,
|
||||
lineHeight: '16px',
|
||||
fontWeight: 'bold',
|
||||
paddingTop: '10px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon(props: {status: HealthcheckStatus}) {
|
||||
switch (props.status) {
|
||||
case 'SUCCESS':
|
||||
return (
|
||||
<CheckCircleFilled style={{fontSize: 24, color: theme.successColor}} />
|
||||
);
|
||||
case 'FAILED':
|
||||
return (
|
||||
<CloseCircleFilled style={{fontSize: 24, color: theme.errorColor}} />
|
||||
);
|
||||
case 'WARNING':
|
||||
return (
|
||||
<WarningFilled style={{fontSize: 24, color: theme.warningColor}} />
|
||||
);
|
||||
case 'SKIPPED':
|
||||
return (
|
||||
<QuestionCircleFilled
|
||||
style={{fontSize: 24, color: theme.disabledColor}}
|
||||
/>
|
||||
);
|
||||
case 'IN_PROGRESS':
|
||||
return (
|
||||
<LoadingOutlined style={{fontSize: 24, color: theme.primaryColor}} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function CollapsableCategory(props: {checks: Array<HealthcheckReportItem>}) {
|
||||
return (
|
||||
<Collapse ghost>
|
||||
{props.checks.map((check) => (
|
||||
<Collapse.Panel
|
||||
key={check.key}
|
||||
header={check.label}
|
||||
extra={<CheckIcon status={check.result.status} />}>
|
||||
<Paragraph>{check.result.message}</Paragraph>
|
||||
</Collapse.Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthCheckList(props: {report: HealthcheckReport}) {
|
||||
useEffect(() => reportUsage('doctor:report:opened'), []);
|
||||
return (
|
||||
<Layout.Container gap>
|
||||
<ResultTopDialog status={props.report.result.status} />
|
||||
{Object.values(props.report.categories).map((category) => (
|
||||
<Layout.Container key={category.key}>
|
||||
<Title level={3}>{category.label}</Title>
|
||||
<CollapsableCategory
|
||||
checks={
|
||||
category.result.status !== 'SKIPPED'
|
||||
? Object.values(category.checks)
|
||||
: [
|
||||
{
|
||||
key: 'Skipped',
|
||||
label: 'Skipped',
|
||||
result: {
|
||||
status: 'SKIPPED',
|
||||
message: category.result.message,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Layout.Container>
|
||||
))}
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupDoctorFooter(props: {
|
||||
onClose: () => void;
|
||||
onRerunDoctor: () => Promise<void>;
|
||||
showAcknowledgeCheckbox: boolean;
|
||||
acknowledgeCheck: boolean;
|
||||
onAcknowledgeCheck: (checked: boolean) => void;
|
||||
disableRerun: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Layout.Right>
|
||||
{props.showAcknowledgeCheckbox ? (
|
||||
<Checkbox
|
||||
checked={props.acknowledgeCheck}
|
||||
onChange={(e) => props.onAcknowledgeCheck(e.target.checked)}
|
||||
style={{display: 'flex', alignItems: 'center'}}>
|
||||
<Text style={{fontSize: theme.fontSize.small}}>
|
||||
Do not show warning about these problems at startup
|
||||
</Text>
|
||||
</Checkbox>
|
||||
) : (
|
||||
<Layout.Container />
|
||||
)}
|
||||
<Layout.Horizontal>
|
||||
<Button onClick={props.onClose}>Close</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => props.onRerunDoctor()}
|
||||
disabled={props.disableRerun}>
|
||||
Re-run
|
||||
</Button>
|
||||
</Layout.Horizontal>
|
||||
</Layout.Right>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SetupDoctorScreen(props: {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const healthcheckReport = useStore(
|
||||
(state) => state.healthchecks.healthcheckReport,
|
||||
);
|
||||
const settings = useStore((state) => state.settingsState);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [acknowlodgeProblem, setAcknowlodgeProblem] = useState(
|
||||
checkHasNewProblem(healthcheckReport.result),
|
||||
);
|
||||
const hasProblem = useMemo(
|
||||
() => checkHasProblem(healthcheckReport.result),
|
||||
[healthcheckReport],
|
||||
);
|
||||
const onCloseModal = useCallback(() => {
|
||||
const hasNewProblem = checkHasNewProblem(healthcheckReport.result);
|
||||
if (acknowlodgeProblem) {
|
||||
if (hasNewProblem) {
|
||||
reportUsage('doctor:report:closed:newProblems:acknowledged');
|
||||
}
|
||||
reportUsage('doctor:report:closed:acknowleged');
|
||||
dispatch(acknowledgeProblems());
|
||||
} else {
|
||||
if (hasNewProblem) {
|
||||
reportUsage('doctor:report:closed:newProblems:notAcknowledged');
|
||||
}
|
||||
reportUsage('doctor:report:closed:notAcknowledged');
|
||||
dispatch(resetAcknowledgedProblems());
|
||||
}
|
||||
props.onClose();
|
||||
}, [healthcheckReport.result, acknowlodgeProblem, props, dispatch]);
|
||||
const runDoctor = useCallback(async () => {
|
||||
await runHealthchecks({
|
||||
settings,
|
||||
startHealthchecks: (healthchecks: Healthchecks) =>
|
||||
dispatch(startHealthchecks(healthchecks)),
|
||||
updateHealthcheckResult: (
|
||||
categoryKey: string,
|
||||
itemKey: string,
|
||||
result: HealthcheckResult,
|
||||
) => dispatch(updateHealthcheckResult(categoryKey, itemKey, result)),
|
||||
finishHealthchecks: () => dispatch(finishHealthchecks()),
|
||||
});
|
||||
}, [settings, dispatch]);
|
||||
|
||||
// This will act like componentDidMount
|
||||
useEffect(() => {
|
||||
runDoctor();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Modal
|
||||
width={570}
|
||||
title="Setup Doctor"
|
||||
visible={props.visible}
|
||||
destroyOnClose
|
||||
footer={
|
||||
<SetupDoctorFooter
|
||||
onClose={onCloseModal}
|
||||
onRerunDoctor={runDoctor}
|
||||
showAcknowledgeCheckbox={hasProblem}
|
||||
acknowledgeCheck={acknowlodgeProblem}
|
||||
onAcknowledgeCheck={(checked) => setAcknowlodgeProblem(checked)}
|
||||
disableRerun={healthcheckReport.result.status === 'IN_PROGRESS'}
|
||||
/>
|
||||
}
|
||||
onCancel={onCloseModal}>
|
||||
<HealthCheckList report={healthcheckReport} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
15
desktop/flipper-ui-core/src/sandy-chrome/StyleGuide.tsx
Normal file
15
desktop/flipper-ui-core/src/sandy-chrome/StyleGuide.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 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 SandyDesignSystem from './SandyDesignSystem';
|
||||
|
||||
export function StyleGuide() {
|
||||
return <SandyDesignSystem />;
|
||||
}
|
||||
235
desktop/flipper-ui-core/src/sandy-chrome/WelcomeScreen.tsx
Normal file
235
desktop/flipper-ui-core/src/sandy-chrome/WelcomeScreen.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* 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, {cloneElement} from 'react';
|
||||
import {styled, FlexRow, FlexColumn} from '../ui';
|
||||
import {Modal, Button, Image, Checkbox, Space, Typography, Tooltip} from 'antd';
|
||||
import {
|
||||
RocketOutlined,
|
||||
AppstoreAddOutlined,
|
||||
CodeOutlined,
|
||||
BugOutlined,
|
||||
HistoryOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Dialog,
|
||||
Layout,
|
||||
NUX,
|
||||
theme,
|
||||
Tracked,
|
||||
TrackingScope,
|
||||
} from 'flipper-plugin';
|
||||
|
||||
const {Text, Title} = Typography;
|
||||
|
||||
import constants from '../fb-stubs/constants';
|
||||
import config from '../fb-stubs/config';
|
||||
import isProduction from '../utils/isProduction';
|
||||
import {getAppVersion} from '../utils/info';
|
||||
import ReleaseChannel from '../ReleaseChannel';
|
||||
import {getFlipperLib} from 'flipper-plugin';
|
||||
import ChangelogSheet from '../chrome/ChangelogSheet';
|
||||
|
||||
const RowContainer = styled(FlexRow)({
|
||||
alignItems: 'flex-start',
|
||||
padding: `${theme.space.small}px`,
|
||||
cursor: 'pointer',
|
||||
'&:hover, &:focus, &:active': {
|
||||
backgroundColor: theme.backgroundTransparentHover,
|
||||
borderRadius: theme.borderRadius,
|
||||
textDecoration: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
function Row(props: {
|
||||
icon: React.ReactElement;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Tracked action={props.title}>
|
||||
<RowContainer onClick={props.onClick}>
|
||||
<Space size="middle">
|
||||
{cloneElement(props.icon, {
|
||||
style: {fontSize: 36, color: theme.primaryColor},
|
||||
})}
|
||||
<FlexColumn>
|
||||
<Title level={3} style={{color: theme.primaryColor}}>
|
||||
{props.title}
|
||||
</Title>
|
||||
<Text type="secondary">{props.subtitle}</Text>
|
||||
</FlexColumn>
|
||||
</Space>
|
||||
</RowContainer>
|
||||
</Tracked>
|
||||
);
|
||||
}
|
||||
|
||||
const FooterContainer = styled(FlexRow)({
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
function WelcomeFooter({
|
||||
onClose,
|
||||
checked,
|
||||
onCheck,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
checked: boolean;
|
||||
onCheck: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<FooterContainer>
|
||||
<Checkbox checked={checked} onChange={(e) => onCheck(e.target.checked)}>
|
||||
<Text style={{fontSize: theme.fontSize.small}}>
|
||||
Show this when app opens (or use ? icon on left)
|
||||
</Text>
|
||||
</Checkbox>
|
||||
<Button type="primary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</FooterContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const openExternal = (url: string) => () => getFlipperLib().openLink(url);
|
||||
|
||||
export default function WelcomeScreen({
|
||||
visible,
|
||||
onClose,
|
||||
showAtStartup,
|
||||
onCheck,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
showAtStartup: boolean;
|
||||
onCheck: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
closable={false}
|
||||
visible={visible}
|
||||
footer={
|
||||
<WelcomeFooter
|
||||
onClose={onClose}
|
||||
checked={showAtStartup}
|
||||
onCheck={onCheck}
|
||||
/>
|
||||
}
|
||||
onCancel={onClose}>
|
||||
<WelcomeScreenContent />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function WelcomeScreenStaticView() {
|
||||
return (
|
||||
<Layout.Container
|
||||
center
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
pad
|
||||
grow>
|
||||
<Layout.Container width={400} center>
|
||||
<WelcomeScreenContent />
|
||||
</Layout.Container>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
|
||||
function WelcomeScreenContent() {
|
||||
function isInsidersChannel() {
|
||||
return config.getReleaseChannel() === ReleaseChannel.INSIDERS;
|
||||
}
|
||||
|
||||
return (
|
||||
<TrackingScope scope="welcomescreen">
|
||||
<Space
|
||||
direction="vertical"
|
||||
size="middle"
|
||||
style={{width: '100%', padding: '0 32px 32px', alignItems: 'center'}}>
|
||||
<Image
|
||||
style={{
|
||||
filter: isInsidersChannel() ? 'hue-rotate(230deg)' : 'none',
|
||||
}}
|
||||
width={125}
|
||||
height={125}
|
||||
src="./icon.png"
|
||||
preview={false}
|
||||
/>
|
||||
<Title level={1}>Welcome to Flipper</Title>
|
||||
<Text>
|
||||
Using release channel{' '}
|
||||
<code
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
color: isInsidersChannel() ? 'rgb(62, 124, 66)' : '#000',
|
||||
textTransform: 'capitalize',
|
||||
fontWeight: isInsidersChannel() ? 'bold' : 'normal',
|
||||
}}>
|
||||
{config.getReleaseChannel()}
|
||||
</code>
|
||||
</Text>
|
||||
<Space direction="horizontal" size="middle">
|
||||
<Text style={{color: theme.textColorPlaceholder}}>
|
||||
{isProduction() ? `Version ${getAppVersion()}` : 'Development Mode'}
|
||||
</Text>
|
||||
<Tooltip title="Changelog" placement="bottom">
|
||||
<NUX title="See Flipper changelog" placement="top">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<HistoryOutlined />}
|
||||
title="Changelog"
|
||||
onClick={() =>
|
||||
Dialog.showModal((onHide) => (
|
||||
<ChangelogSheet onHide={onHide} />
|
||||
))
|
||||
}
|
||||
/>
|
||||
</NUX>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</Space>
|
||||
<Space direction="vertical" size="large" style={{width: '100%'}}>
|
||||
<Row
|
||||
icon={<RocketOutlined />}
|
||||
title="Using Flipper"
|
||||
subtitle="Learn how Flipper can help you debug your App"
|
||||
onClick={openExternal('https://fbflipper.com/docs/features/index')}
|
||||
/>
|
||||
<Row
|
||||
icon={<AppstoreAddOutlined />}
|
||||
title="Create Your Own Plugin"
|
||||
subtitle="Get started with these pointers"
|
||||
onClick={openExternal('https://fbflipper.com/docs/tutorial/intro')}
|
||||
/>
|
||||
<Row
|
||||
icon={<CodeOutlined />}
|
||||
title="Add Flipper Support to Your App"
|
||||
subtitle="Get started with these pointers"
|
||||
onClick={openExternal(
|
||||
'https://fbflipper.com/docs/getting-started/index',
|
||||
)}
|
||||
/>
|
||||
<Row
|
||||
icon={<BugOutlined />}
|
||||
title="Contributing and Feedback"
|
||||
subtitle="Report issues and help us improve Flipper"
|
||||
onClick={openExternal(constants.FEEDBACK_GROUP_LINK)}
|
||||
/>
|
||||
</Space>
|
||||
</TrackingScope>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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 {Typography} from 'antd';
|
||||
import {LeftSidebar, SidebarTitle, InfoIcon} from '../LeftSidebar';
|
||||
import {Layout, Link, styled} from '../../ui';
|
||||
import {theme, useValue} from 'flipper-plugin';
|
||||
import {AppSelector} from './AppSelector';
|
||||
import {PluginList} from './PluginList';
|
||||
import ScreenCaptureButtons from '../../chrome/ScreenCaptureButtons';
|
||||
import MetroButton from '../../chrome/MetroButton';
|
||||
import {BookmarkSection} from './BookmarkSection';
|
||||
import Client from '../../Client';
|
||||
import BaseDevice from '../../devices/BaseDevice';
|
||||
import {ExclamationCircleOutlined, FieldTimeOutlined} from '@ant-design/icons';
|
||||
import {useSelector} from 'react-redux';
|
||||
import {
|
||||
getActiveClient,
|
||||
getActiveDevice,
|
||||
getMetroDevice,
|
||||
} from '../../selectors/connections';
|
||||
import * as connections from '../../selectors/connections';
|
||||
import {PluginActionsMenu} from '../../chrome/PluginActionsMenu';
|
||||
|
||||
const {Text} = Typography;
|
||||
|
||||
const appTooltip = (
|
||||
<>
|
||||
Inspect apps by selecting connected devices and emulators. Navigate and
|
||||
bookmark frequent destinations in the app. Refresh, screenshot and
|
||||
screenrecord is also available.{' '}
|
||||
<Link href="https://fbflipper.com/docs/getting-started/index">
|
||||
Learn More
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
|
||||
export function AppInspect() {
|
||||
const metroDevice = useSelector(getMetroDevice);
|
||||
const client = useSelector(getActiveClient);
|
||||
const activeDevice = useSelector(getActiveDevice);
|
||||
const isDeviceConnected = useValue(activeDevice?.connected, false);
|
||||
const isAppConnected = useValue(client?.connected, false);
|
||||
const hasSelectableDevices = useSelector(connections.hasSelectableDevices);
|
||||
|
||||
return (
|
||||
<LeftSidebar>
|
||||
<Layout.Top>
|
||||
<Layout.Container borderBottom>
|
||||
<SidebarTitle actions={<InfoIcon>{appTooltip}</InfoIcon>}>
|
||||
App Inspect
|
||||
</SidebarTitle>
|
||||
<Layout.Container padv="small" padh="medium" gap={theme.space.large}>
|
||||
<AppSelector />
|
||||
{renderStatusMessage(
|
||||
isDeviceConnected,
|
||||
activeDevice,
|
||||
client,
|
||||
isAppConnected,
|
||||
hasSelectableDevices,
|
||||
)}
|
||||
{isDeviceConnected && isAppConnected && <BookmarkSection />}
|
||||
{isDeviceConnected && activeDevice && (
|
||||
<Toolbar gap>
|
||||
<MetroButton />
|
||||
<ScreenCaptureButtons />
|
||||
<div style={{flex: 1}} />
|
||||
<PluginActionsMenu />
|
||||
</Toolbar>
|
||||
)}
|
||||
</Layout.Container>
|
||||
</Layout.Container>
|
||||
<Layout.ScrollContainer vertical padv={theme.space.large}>
|
||||
{activeDevice ? (
|
||||
<PluginList
|
||||
activeDevice={activeDevice}
|
||||
metroDevice={metroDevice}
|
||||
client={client}
|
||||
/>
|
||||
) : null}
|
||||
</Layout.ScrollContainer>
|
||||
</Layout.Top>
|
||||
</LeftSidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const Toolbar = styled(Layout.Horizontal)({
|
||||
'.ant-btn': {
|
||||
border: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
function renderStatusMessage(
|
||||
isDeviceConnected: boolean,
|
||||
activeDevice: BaseDevice | null,
|
||||
client: Client | null,
|
||||
isAppConnected: boolean,
|
||||
hasSelectableDevices: boolean,
|
||||
): React.ReactNode {
|
||||
if (!activeDevice) {
|
||||
return;
|
||||
}
|
||||
return !isDeviceConnected ? (
|
||||
activeDevice.isArchived ? (
|
||||
<Layout.Horizontal gap center>
|
||||
<FieldTimeOutlined style={{color: theme.primaryColor}} />
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.8em',
|
||||
}}>
|
||||
Imported device
|
||||
</Text>
|
||||
</Layout.Horizontal>
|
||||
) : (
|
||||
<Layout.Horizontal gap center>
|
||||
<ExclamationCircleOutlined style={{color: theme.errorColor}} />
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.8em',
|
||||
color: theme.errorColor,
|
||||
}}>
|
||||
Device disconnected
|
||||
</Text>
|
||||
</Layout.Horizontal>
|
||||
)
|
||||
) : client ? (
|
||||
isAppConnected ? null /*connected*/ : (
|
||||
<Layout.Horizontal gap center>
|
||||
<ExclamationCircleOutlined style={{color: theme.errorColor}} />
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.8em',
|
||||
color: theme.errorColor,
|
||||
}}>
|
||||
Application disconnected
|
||||
</Text>
|
||||
</Layout.Horizontal>
|
||||
)
|
||||
) : hasSelectableDevices ? (
|
||||
<Layout.Horizontal gap center>
|
||||
<ExclamationCircleOutlined style={{color: theme.warningColor}} />
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.8em',
|
||||
color: theme.errorColor,
|
||||
}}>
|
||||
No application selected
|
||||
</Text>
|
||||
</Layout.Horizontal>
|
||||
) : null /* no selectable devices */;
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 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 {Button, Dropdown, Menu, Radio, Typography} from 'antd';
|
||||
import {
|
||||
AppleOutlined,
|
||||
AndroidOutlined,
|
||||
WindowsOutlined,
|
||||
CaretDownOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {Glyph, Layout, styled} from '../../ui';
|
||||
import {DeviceOS, theme, useTrackedCallback, useValue} from 'flipper-plugin';
|
||||
import {batch, useSelector} from 'react-redux';
|
||||
import {useDispatch, useStore} from '../../utils/useStore';
|
||||
import {
|
||||
getClientsByDevice,
|
||||
selectClient,
|
||||
selectDevice,
|
||||
} from '../../reducers/connections';
|
||||
import BaseDevice from '../../devices/BaseDevice';
|
||||
import Client from '../../Client';
|
||||
import {State} from '../../reducers';
|
||||
import {brandColors, brandIcons, colors} from '../../ui/components/colors';
|
||||
import {TroubleshootingGuide} from './fb-stubs/TroubleshootingGuide';
|
||||
import GK from '../../fb-stubs/GK';
|
||||
import {getSelectableDevices} from '../../selectors/connections';
|
||||
|
||||
const {Text} = Typography;
|
||||
|
||||
function getOsIcon(os?: DeviceOS) {
|
||||
switch (os) {
|
||||
case 'iOS':
|
||||
return <AppleOutlined />;
|
||||
case 'Android':
|
||||
return <AndroidOutlined />;
|
||||
case 'Windows':
|
||||
return <WindowsOutlined />;
|
||||
default:
|
||||
undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function AppSelector() {
|
||||
const dispatch = useDispatch();
|
||||
const selectableDevices = useSelector(getSelectableDevices);
|
||||
const {selectedDevice, clients, uninitializedClients, selectedAppId} =
|
||||
useStore((state) => state.connections);
|
||||
useValue(selectedDevice?.connected, false); // subscribe to future archived state changes
|
||||
|
||||
const onSelectDevice = useTrackedCallback(
|
||||
'select-device',
|
||||
(device: BaseDevice) => {
|
||||
batch(() => {
|
||||
dispatch(selectDevice(device));
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const onSelectApp = useTrackedCallback(
|
||||
'select-app',
|
||||
(_device: BaseDevice, client: Client) => {
|
||||
batch(() => {
|
||||
dispatch(selectClient(client.id));
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const entries = computeEntries(
|
||||
selectableDevices,
|
||||
clients,
|
||||
uninitializedClients,
|
||||
onSelectDevice,
|
||||
onSelectApp,
|
||||
);
|
||||
const client = clients.get(selectedAppId!);
|
||||
|
||||
return (
|
||||
<>
|
||||
{entries.length ? (
|
||||
<Radio.Group
|
||||
value={selectedAppId}
|
||||
size="small"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
}}>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
overlay={
|
||||
<Menu selectedKeys={selectedAppId ? [selectedAppId] : []}>
|
||||
{entries}
|
||||
</Menu>
|
||||
}>
|
||||
<AppInspectButton title="Select the device / app to inspect">
|
||||
<Layout.Horizontal gap center>
|
||||
<AppIcon appname={client?.query.app} device={selectedDevice} />
|
||||
<Layout.Container grow shrink>
|
||||
<Text strong>{client?.query.app ?? ''}</Text>
|
||||
<Text>{selectedDevice?.title || 'Available devices'}</Text>
|
||||
</Layout.Container>
|
||||
<CaretDownOutlined />
|
||||
</Layout.Horizontal>
|
||||
</AppInspectButton>
|
||||
</Dropdown>
|
||||
</Radio.Group>
|
||||
) : (
|
||||
<Layout.Horizontal gap center>
|
||||
<ExclamationCircleOutlined style={{color: theme.warningColor}} />
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.8em',
|
||||
color: theme.errorColor,
|
||||
}}>
|
||||
No devices found
|
||||
</Text>
|
||||
</Layout.Horizontal>
|
||||
)}
|
||||
<TroubleshootingGuide
|
||||
showGuide={GK.get('flipper_self_sufficiency')}
|
||||
devicesDetected={entries.length}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const AppInspectButton = styled(Button)({
|
||||
background: theme.backgroundTransparentHover,
|
||||
height: 52,
|
||||
border: 'none',
|
||||
fontWeight: 'normal',
|
||||
flex: `1 1 0`,
|
||||
overflow: 'hidden', // required for ellipsis
|
||||
paddingLeft: theme.space.small,
|
||||
paddingRight: theme.space.small,
|
||||
textAlign: 'left',
|
||||
'&:hover, &:focus, &:active': {
|
||||
background: theme.backgroundTransparentHover,
|
||||
},
|
||||
'.ant-typography': {
|
||||
lineHeight: '20px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
});
|
||||
|
||||
function AppIcon({
|
||||
appname,
|
||||
device,
|
||||
}: {
|
||||
appname?: string;
|
||||
device?: BaseDevice | null;
|
||||
}) {
|
||||
const invert = appname?.endsWith('Lite') ?? false;
|
||||
const brandName = appname?.replace(/ Lite$/, '');
|
||||
const color = brandName
|
||||
? getColorByApp(brandName)
|
||||
: theme.backgroundTransparentHover;
|
||||
const icon = (brandName && (brandIcons as any)[brandName]) ?? device?.icon;
|
||||
return (
|
||||
<AppIconContainer style={{background: invert ? 'white' : color}}>
|
||||
{icon && (
|
||||
<Glyph
|
||||
name={icon}
|
||||
size={24}
|
||||
variant="outline"
|
||||
color={invert ? color : 'white'}
|
||||
/>
|
||||
)}
|
||||
</AppIconContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const AppIconContainer = styled.div({
|
||||
borderRadius: 4,
|
||||
width: 36,
|
||||
height: 36,
|
||||
padding: 6,
|
||||
});
|
||||
|
||||
function computeEntries(
|
||||
selectableDevices: BaseDevice[],
|
||||
clients: Map<string, Client>,
|
||||
uninitializedClients: State['connections']['uninitializedClients'],
|
||||
onSelectDevice: (device: BaseDevice) => void,
|
||||
onSelectApp: (device: BaseDevice, client: Client) => void,
|
||||
) {
|
||||
const entries = selectableDevices.map((device) => {
|
||||
const deviceEntry = (
|
||||
<Menu.Item
|
||||
icon={getOsIcon(device.os)}
|
||||
key={device.serial}
|
||||
style={{fontWeight: 'bold'}}
|
||||
onClick={() => {
|
||||
onSelectDevice(device);
|
||||
}}>
|
||||
<DeviceTitle device={device} />
|
||||
</Menu.Item>
|
||||
);
|
||||
const clientEntries = getClientsByDevice(device, clients).map((client) => (
|
||||
<Menu.Item
|
||||
key={client.id}
|
||||
onClick={() => {
|
||||
onSelectApp(device, client);
|
||||
}}>
|
||||
<Radio value={client.id}>
|
||||
<ClientTitle client={client} />
|
||||
</Radio>
|
||||
</Menu.Item>
|
||||
));
|
||||
return [deviceEntry, ...clientEntries];
|
||||
});
|
||||
if (uninitializedClients.length) {
|
||||
entries.push([
|
||||
<Menu.Item key="connecting" style={{fontWeight: 'bold'}}>
|
||||
Currently connecting...
|
||||
</Menu.Item>,
|
||||
...uninitializedClients.map((client) => (
|
||||
<Menu.Item key={'connecting' + client.appName}>
|
||||
{`${client.appName} (${client.deviceName})`}
|
||||
</Menu.Item>
|
||||
)),
|
||||
]);
|
||||
}
|
||||
return entries.flat();
|
||||
}
|
||||
|
||||
function DeviceTitle({device}: {device: BaseDevice}) {
|
||||
const connected = useValue(device.connected);
|
||||
const isImported = device.isArchived;
|
||||
return (
|
||||
<span>
|
||||
<>{device.title} </>
|
||||
{!connected || isImported ? (
|
||||
<span
|
||||
style={{
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.6em',
|
||||
color: isImported ? theme.primaryColor : theme.errorColor,
|
||||
fontWeight: 'bold',
|
||||
}}>
|
||||
{isImported ? '(Imported)' : '(Offline)'}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientTitle({client}: {client: Client}) {
|
||||
const connected = useValue(client.connected);
|
||||
return (
|
||||
<span>
|
||||
<>{client.query.app} </>
|
||||
{!connected ? (
|
||||
<span
|
||||
style={{
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.6em',
|
||||
color: theme.errorColor,
|
||||
fontWeight: 'bold',
|
||||
}}>
|
||||
(Offline)
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getColorByApp(app?: string | null): string {
|
||||
let iconColor: string | undefined = (brandColors as any)[app!];
|
||||
|
||||
if (!iconColor) {
|
||||
if (!app) {
|
||||
// Device plugin
|
||||
iconColor = colors.macOSTitleBarIconBlur;
|
||||
} else {
|
||||
const pluginColors = [
|
||||
colors.seaFoam,
|
||||
colors.teal,
|
||||
colors.lime,
|
||||
colors.lemon,
|
||||
colors.orange,
|
||||
colors.tomato,
|
||||
colors.cherry,
|
||||
colors.pink,
|
||||
colors.grape,
|
||||
];
|
||||
|
||||
iconColor = pluginColors[parseInt(app, 36) % pluginColors.length];
|
||||
}
|
||||
}
|
||||
return iconColor;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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, {useMemo} from 'react';
|
||||
import {AutoComplete, Input, Typography} from 'antd';
|
||||
import {StarFilled, StarOutlined} from '@ant-design/icons';
|
||||
import {useStore} from '../../utils/useStore';
|
||||
import {
|
||||
Layout,
|
||||
NUX,
|
||||
TrackingScope,
|
||||
useTrackedCallback,
|
||||
useValue,
|
||||
} from 'flipper-plugin';
|
||||
import {State} from '../../reducers';
|
||||
|
||||
// TODO, based on: from '../../../../plugins/public/navigation/index';
|
||||
// TODO: this file should be typed again, and the navigation core logic moved to flipper-ui-common or devices or smth,
|
||||
// and removing the `any` types in this files
|
||||
type NavigationPlugin = any;
|
||||
|
||||
import {useMemoize} from 'flipper-plugin';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const {Text} = Typography;
|
||||
|
||||
export function BookmarkSection() {
|
||||
const navPlugin = useStore(navPluginStateSelector);
|
||||
|
||||
return navPlugin ? (
|
||||
<TrackingScope scope="bookmarks">
|
||||
<NUX
|
||||
title="Use bookmarks to directly navigate to a location in the app."
|
||||
placement="right">
|
||||
<BookmarkSectionInput navPlugin={navPlugin} />
|
||||
</NUX>
|
||||
</TrackingScope>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function BookmarkSectionInput({navPlugin}: {navPlugin: NavigationPlugin}) {
|
||||
const currentURI = useValue(navPlugin.currentURI) as any;
|
||||
const bookmarks = useValue(navPlugin.bookmarks) as any;
|
||||
const patterns = useValue(navPlugin.appMatchPatterns) as any;
|
||||
|
||||
const isBookmarked = useMemo(
|
||||
() => bookmarks.has(currentURI),
|
||||
[bookmarks, currentURI],
|
||||
);
|
||||
|
||||
const autoCompleteItems: any = useMemoize(
|
||||
navPlugin.getAutoCompleteAppMatchPatterns,
|
||||
[currentURI, bookmarks, patterns, 20],
|
||||
);
|
||||
|
||||
const handleBookmarkClick = useTrackedCallback(
|
||||
'bookmark',
|
||||
() => {
|
||||
if (isBookmarked) {
|
||||
navPlugin.removeBookmark(currentURI);
|
||||
} else if (currentURI) {
|
||||
navPlugin.addBookmark({
|
||||
uri: currentURI,
|
||||
commonName: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
[navPlugin, currentURI, isBookmarked],
|
||||
);
|
||||
|
||||
const navigate = useTrackedCallback('navigate', navPlugin.navigateTo, []);
|
||||
|
||||
const bookmarkButton = isBookmarked ? (
|
||||
<StarFilled onClick={handleBookmarkClick} />
|
||||
) : (
|
||||
<StarOutlined onClick={handleBookmarkClick} />
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledAutoComplete
|
||||
dropdownMatchSelectWidth={500}
|
||||
value={currentURI}
|
||||
onSelect={navigate}
|
||||
style={{flex: 1}}
|
||||
options={[
|
||||
{
|
||||
label: <Text strong>Bookmarks</Text>,
|
||||
options: Array.from(bookmarks.values()).map((bookmark: any) => ({
|
||||
value: bookmark.uri,
|
||||
label: (
|
||||
<NavigationEntry label={bookmark.commonName} uri={bookmark.uri} />
|
||||
),
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: <Text strong>Entry points</Text>,
|
||||
options: autoCompleteItems.map((value: any) => ({
|
||||
value: value.pattern,
|
||||
label: (
|
||||
<NavigationEntry label={value.className} uri={value.pattern} />
|
||||
),
|
||||
})),
|
||||
},
|
||||
]}>
|
||||
<Input
|
||||
addonAfter={bookmarkButton}
|
||||
defaultValue="<select a bookmark>"
|
||||
value={currentURI}
|
||||
onChange={(e) => {
|
||||
navPlugin.currentURI.set(e.target.value);
|
||||
}}
|
||||
onPressEnter={() => {
|
||||
navigate(currentURI);
|
||||
}}
|
||||
/>
|
||||
</StyledAutoComplete>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationEntry({label, uri}: {label: string | null; uri: string}) {
|
||||
return (
|
||||
<Layout.Container>
|
||||
<Text>{label ?? uri}</Text>
|
||||
<Text type="secondary">{uri}</Text>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledAutoComplete = styled(AutoComplete)({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
'.ant-select-selector': {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const NAVIGATION_PLUGIN_ID = 'Navigation';
|
||||
|
||||
function navPluginStateSelector(state: State) {
|
||||
const {selectedAppId, clients} = state.connections;
|
||||
if (!selectedAppId) return undefined;
|
||||
const client = clients.get(selectedAppId);
|
||||
if (!client) return undefined;
|
||||
return client.sandyPluginStates.get(NAVIGATION_PLUGIN_ID)?.instanceApi as
|
||||
| undefined
|
||||
| NavigationPlugin;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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, {useEffect, useState} from 'react';
|
||||
import {Modal, Button, message, Alert, Menu, Dropdown} from 'antd';
|
||||
import {
|
||||
AppleOutlined,
|
||||
PoweroffOutlined,
|
||||
MoreOutlined,
|
||||
AndroidOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {Store} from '../../reducers';
|
||||
import {useStore} from '../../utils/useStore';
|
||||
import {Layout, renderReactRoot, withTrackingScope} from 'flipper-plugin';
|
||||
import {Provider} from 'react-redux';
|
||||
import {IOSDeviceParams} from 'flipper-common';
|
||||
|
||||
const COLD_BOOT = 'cold-boot';
|
||||
|
||||
export function showEmulatorLauncher(store: Store) {
|
||||
renderReactRoot((unmount) => (
|
||||
<Provider store={store}>
|
||||
<LaunchEmulatorContainer onClose={unmount} />
|
||||
</Provider>
|
||||
));
|
||||
}
|
||||
|
||||
function LaunchEmulatorContainer({onClose}: {onClose: () => void}) {
|
||||
return <LaunchEmulatorDialog onClose={onClose} />;
|
||||
}
|
||||
|
||||
export const LaunchEmulatorDialog = withTrackingScope(
|
||||
function LaunchEmulatorDialog({onClose}: {onClose: () => void}) {
|
||||
const flipperServer = useStore((state) => state.connections.flipperServer);
|
||||
const iosEnabled = useStore((state) => state.settingsState.enableIOS);
|
||||
const androidEnabled = useStore(
|
||||
(state) => state.settingsState.enableAndroid,
|
||||
);
|
||||
const [iosEmulators, setIosEmulators] = useState<IOSDeviceParams[]>([]);
|
||||
const [androidEmulators, setAndroidEmulators] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iosEnabled) {
|
||||
return;
|
||||
}
|
||||
flipperServer!
|
||||
.exec('ios-get-simulators', false)
|
||||
.then((emulators) => {
|
||||
setIosEmulators(
|
||||
emulators.filter(
|
||||
(device) =>
|
||||
device.state === 'Shutdown' &&
|
||||
device.deviceTypeIdentifier?.match(/iPhone|iPad/i),
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('Failed to find simulators', e);
|
||||
});
|
||||
}, [iosEnabled, flipperServer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!androidEnabled) {
|
||||
return;
|
||||
}
|
||||
flipperServer!
|
||||
.exec('android-get-emulators')
|
||||
.then((emulators) => {
|
||||
setAndroidEmulators(emulators);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('Failed to find emulators', e);
|
||||
});
|
||||
}, [androidEnabled, flipperServer]);
|
||||
|
||||
const items = [
|
||||
...(androidEmulators.length > 0
|
||||
? [<AndroidOutlined key="android logo" />]
|
||||
: []),
|
||||
...androidEmulators.map((name) => {
|
||||
const launch = (coldBoot: boolean) => {
|
||||
flipperServer!
|
||||
.exec('android-launch-emulator', name, coldBoot)
|
||||
.then(onClose)
|
||||
.catch((e) => {
|
||||
console.error('Failed to start emulator: ', e);
|
||||
message.error('Failed to start emulator: ' + e);
|
||||
});
|
||||
};
|
||||
const menu = (
|
||||
<Menu
|
||||
onClick={({key}) => {
|
||||
switch (key) {
|
||||
case COLD_BOOT: {
|
||||
launch(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<Menu.Item key={COLD_BOOT} icon={<PoweroffOutlined />}>
|
||||
Cold Boot
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
return (
|
||||
<Dropdown.Button
|
||||
key={name}
|
||||
overlay={menu}
|
||||
icon={<MoreOutlined />}
|
||||
onClick={() => launch(false)}>
|
||||
{name}
|
||||
</Dropdown.Button>
|
||||
);
|
||||
}),
|
||||
...(iosEmulators.length > 0 ? [<AppleOutlined key="ios logo" />] : []),
|
||||
...iosEmulators.map((device) => (
|
||||
<Button
|
||||
key={device.udid}
|
||||
onClick={() =>
|
||||
flipperServer!
|
||||
.exec('ios-launch-simulator', device.udid)
|
||||
.catch((e) => {
|
||||
console.error('Failed to start simulator: ', e);
|
||||
message.error('Failed to start simulator: ' + e);
|
||||
})
|
||||
.then(onClose)
|
||||
}>
|
||||
{device.name}
|
||||
</Button>
|
||||
)),
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
onCancel={onClose}
|
||||
title="Launch Emulator"
|
||||
footer={null}
|
||||
bodyStyle={{maxHeight: 400, overflow: 'auto'}}>
|
||||
<Layout.Container gap>
|
||||
{items.length ? items : <Alert message="No emulators available" />}
|
||||
</Layout.Container>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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 {RocketOutlined} from '@ant-design/icons';
|
||||
import {Alert, Typography} from 'antd';
|
||||
import {useTrackedCallback} from 'flipper-plugin';
|
||||
import {showEmulatorLauncher} from './LaunchEmulator';
|
||||
import {useStore} from '../../utils/useStore';
|
||||
|
||||
const {Text, Link, Title} = Typography;
|
||||
|
||||
export function NoDevices() {
|
||||
const store = useStore();
|
||||
|
||||
const onLaunchEmulator = useTrackedCallback(
|
||||
'select-emulator',
|
||||
() => {
|
||||
showEmulatorLauncher(store);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert
|
||||
type="info"
|
||||
message={
|
||||
<>
|
||||
<Title level={4}>No devices found</Title>
|
||||
<Text>
|
||||
Start a fresh emulator <RocketOutlined onClick={onLaunchEmulator} />{' '}
|
||||
or check the{' '}
|
||||
<Link href="https://fbflipper.com/docs/troubleshooting">
|
||||
troubleshooting guide
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* 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, {memo, useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {Badge, Button, Menu, Tooltip, Typography} from 'antd';
|
||||
import {InfoIcon, SidebarTitle} from '../LeftSidebar';
|
||||
import {
|
||||
PlusOutlined,
|
||||
MinusOutlined,
|
||||
DeleteOutlined,
|
||||
LoadingOutlined,
|
||||
DownloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {Glyph, Layout, styled} from '../../ui';
|
||||
import {theme, NUX, Tracked, useValue, useMemoize} from 'flipper-plugin';
|
||||
import {useDispatch, useStore} from '../../utils/useStore';
|
||||
import {getPluginTitle, getPluginTooltip} from '../../utils/pluginUtils';
|
||||
import {selectPlugin} from '../../reducers/connections';
|
||||
import Client from '../../Client';
|
||||
import BaseDevice from '../../devices/BaseDevice';
|
||||
import {DownloadablePluginDetails} from 'flipper-plugin-lib';
|
||||
import {
|
||||
DownloadablePluginState,
|
||||
PluginDownloadStatus,
|
||||
startPluginDownload,
|
||||
} from '../../reducers/pluginDownloads';
|
||||
import {
|
||||
loadPlugin,
|
||||
switchPlugin,
|
||||
uninstallPlugin,
|
||||
} from '../../reducers/pluginManager';
|
||||
import {BundledPluginDetails} from 'flipper-plugin-lib';
|
||||
import {reportUsage} from 'flipper-common';
|
||||
import ConnectivityStatus from './fb-stubs/ConnectivityStatus';
|
||||
import {useSelector} from 'react-redux';
|
||||
import {getPluginLists} from '../../selectors/connections';
|
||||
|
||||
const {SubMenu} = Menu;
|
||||
const {Text} = Typography;
|
||||
|
||||
export const PluginList = memo(function PluginList({
|
||||
client,
|
||||
activeDevice,
|
||||
metroDevice,
|
||||
}: {
|
||||
client: Client | null;
|
||||
activeDevice: BaseDevice | null;
|
||||
metroDevice: BaseDevice | null;
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const connections = useStore((state) => state.connections);
|
||||
const plugins = useStore((state) => state.plugins);
|
||||
const pluginLists = useSelector(getPluginLists);
|
||||
const downloads = useStore((state) => state.pluginDownloads);
|
||||
const isConnected = useValue(activeDevice?.connected, false);
|
||||
const metroConnected = useValue(metroDevice?.connected, false);
|
||||
|
||||
const {
|
||||
devicePlugins,
|
||||
metroPlugins,
|
||||
enabledPlugins,
|
||||
disabledPlugins,
|
||||
unavailablePlugins,
|
||||
downloadablePlugins,
|
||||
} = pluginLists;
|
||||
|
||||
const isArchived = activeDevice?.isArchived;
|
||||
|
||||
const annotatedDownloadablePlugins = useMemoize<
|
||||
[
|
||||
Record<string, DownloadablePluginState>,
|
||||
(DownloadablePluginDetails | BundledPluginDetails)[],
|
||||
],
|
||||
[
|
||||
plugin: DownloadablePluginDetails | BundledPluginDetails,
|
||||
downloadStatus?: PluginDownloadStatus,
|
||||
][]
|
||||
>(
|
||||
(downloads, downloadablePlugins) => {
|
||||
const downloadMap = new Map(
|
||||
Object.values(downloads).map((x) => [x.plugin.id, x]),
|
||||
);
|
||||
return downloadablePlugins.map((plugin) => [
|
||||
plugin,
|
||||
downloadMap.get(plugin.id)?.status,
|
||||
]);
|
||||
},
|
||||
[downloads, downloadablePlugins],
|
||||
);
|
||||
|
||||
const handleAppPluginClick = useCallback(
|
||||
(pluginId) => {
|
||||
dispatch(
|
||||
selectPlugin({
|
||||
selectedPlugin: pluginId,
|
||||
selectedAppId: connections.selectedAppId,
|
||||
deepLinkPayload: null,
|
||||
selectedDevice: activeDevice,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch, activeDevice, connections.selectedAppId],
|
||||
);
|
||||
const handleMetroPluginClick = useCallback(
|
||||
(pluginId) => {
|
||||
dispatch(
|
||||
selectPlugin({
|
||||
selectedPlugin: pluginId,
|
||||
selectedAppId: connections.selectedAppId,
|
||||
deepLinkPayload: null,
|
||||
selectedDevice: metroDevice,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch, metroDevice, connections.selectedAppId],
|
||||
);
|
||||
const handleEnablePlugin = useCallback(
|
||||
(id: string) => {
|
||||
const plugin = (plugins.clientPlugins.get(id) ??
|
||||
plugins.devicePlugins.get(id))!;
|
||||
dispatch(
|
||||
switchPlugin({
|
||||
selectedApp: client?.query.app,
|
||||
plugin,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[client, plugins.clientPlugins, plugins.devicePlugins, dispatch],
|
||||
);
|
||||
const handleInstallPlugin = useCallback(
|
||||
(id: string) => {
|
||||
const plugin = downloadablePlugins.find((p) => p.id === id)!;
|
||||
reportUsage('plugin:install', {version: plugin.version}, plugin.id);
|
||||
if (plugin.isBundled) {
|
||||
dispatch(loadPlugin({plugin, enable: true, notifyIfFailed: true}));
|
||||
} else {
|
||||
dispatch(startPluginDownload({plugin, startedByUser: true}));
|
||||
}
|
||||
},
|
||||
[downloadablePlugins, dispatch],
|
||||
);
|
||||
const handleUninstallPlugin = useCallback(
|
||||
(id: string) => {
|
||||
const plugin = disabledPlugins.find((p) => p.id === id)!;
|
||||
reportUsage('plugin:uninstall', {version: plugin.version}, plugin.id);
|
||||
dispatch(uninstallPlugin({plugin}));
|
||||
},
|
||||
[disabledPlugins, dispatch],
|
||||
);
|
||||
return (
|
||||
<Layout.Container>
|
||||
<SidebarTitle actions={<ConnectivityStatus />}>Plugins</SidebarTitle>
|
||||
<Layout.Container padv={theme.space.small} padh={theme.space.tiny}>
|
||||
<PluginMenu
|
||||
inlineIndent={8}
|
||||
onClick={() => {}}
|
||||
defaultOpenKeys={['device', 'enabled', 'metro']}
|
||||
selectedKeys={
|
||||
connections.selectedPlugin
|
||||
? [
|
||||
(connections.selectedDevice === metroDevice ? 'metro:' : '') +
|
||||
connections.selectedPlugin,
|
||||
]
|
||||
: []
|
||||
}
|
||||
mode="inline">
|
||||
<PluginGroup key="device" title="Device">
|
||||
{devicePlugins.map((plugin) => (
|
||||
<PluginEntry
|
||||
key={plugin.id}
|
||||
plugin={plugin.details}
|
||||
scrollTo={
|
||||
plugin.id === connections.selectedPlugin &&
|
||||
connections.selectedDevice === activeDevice
|
||||
}
|
||||
onClick={handleAppPluginClick}
|
||||
tooltip={getPluginTooltip(plugin.details)}
|
||||
actions={
|
||||
isArchived ? null : (
|
||||
<ActionButton
|
||||
id={plugin.id}
|
||||
onClick={handleEnablePlugin}
|
||||
title="Disable plugin"
|
||||
icon={
|
||||
<MinusOutlined size={16} style={{marginRight: 0}} />
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</PluginGroup>
|
||||
|
||||
{!isArchived && metroConnected && (
|
||||
<PluginGroup
|
||||
key="metro"
|
||||
title="React Native"
|
||||
hint="The following plugins are exposed by the currently running Metro instance. Note that Metro might currently be connected to a different application or device than selected above.">
|
||||
{metroPlugins.map((plugin) => (
|
||||
<PluginEntry
|
||||
key={'metro:' + plugin.id}
|
||||
plugin={plugin.details}
|
||||
scrollTo={
|
||||
plugin.id === connections.selectedPlugin &&
|
||||
connections.selectedDevice === metroDevice
|
||||
}
|
||||
onClick={handleMetroPluginClick}
|
||||
tooltip={getPluginTooltip(plugin.details)}
|
||||
/>
|
||||
))}
|
||||
</PluginGroup>
|
||||
)}
|
||||
<PluginGroup key="enabled" title="Enabled">
|
||||
{enabledPlugins.map((plugin) => (
|
||||
<PluginEntry
|
||||
key={plugin.id}
|
||||
plugin={plugin.details}
|
||||
scrollTo={plugin.id === connections.selectedPlugin}
|
||||
onClick={handleAppPluginClick}
|
||||
tooltip={getPluginTooltip(plugin.details)}
|
||||
actions={
|
||||
isConnected ? (
|
||||
<ActionButton
|
||||
id={plugin.id}
|
||||
onClick={handleEnablePlugin}
|
||||
title="Disable plugin"
|
||||
icon={
|
||||
<MinusOutlined size={16} style={{marginRight: 0}} />
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</PluginGroup>
|
||||
{isConnected && (
|
||||
<PluginGroup
|
||||
key="disabled"
|
||||
title="Disabled"
|
||||
hint="This section shows the plugins that are currently disabled. If a plugin is enabled, you will be able to interact with it. If a plugin is disabled it won't consume resources in Flipper or in the connected application.">
|
||||
{disabledPlugins.map((plugin) => (
|
||||
<PluginEntry
|
||||
key={plugin.id}
|
||||
plugin={plugin.details}
|
||||
scrollTo={plugin.id === connections.selectedPlugin}
|
||||
tooltip={getPluginTooltip(plugin.details)}
|
||||
onClick={handleAppPluginClick}
|
||||
actions={
|
||||
<>
|
||||
<ActionButton
|
||||
id={plugin.id}
|
||||
title="Uninstall plugin"
|
||||
onClick={handleUninstallPlugin}
|
||||
icon={
|
||||
<DeleteOutlined size={16} style={{marginRight: 0}} />
|
||||
}
|
||||
/>
|
||||
<ActionButton
|
||||
id={plugin.id}
|
||||
title="Enable plugin"
|
||||
onClick={handleEnablePlugin}
|
||||
icon={
|
||||
<PlusOutlined size={16} style={{marginRight: 0}} />
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
disabled
|
||||
/>
|
||||
))}
|
||||
</PluginGroup>
|
||||
)}
|
||||
<PluginGroup
|
||||
key="uninstalled"
|
||||
title="Detected in App"
|
||||
hint="The plugins below are supported by the selected device / application, but not installed in Flipper.
|
||||
To install plugin, hover it and click to the 'Download' icon.">
|
||||
{annotatedDownloadablePlugins.map(([plugin, downloadStatus]) => (
|
||||
<PluginEntry
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
scrollTo={plugin.id === connections.selectedPlugin}
|
||||
tooltip={getPluginTooltip(plugin)}
|
||||
onClick={handleAppPluginClick}
|
||||
actions={
|
||||
<ActionButton
|
||||
id={plugin.id}
|
||||
title="Download and install plugin"
|
||||
onClick={handleInstallPlugin}
|
||||
icon={
|
||||
downloadStatus ? (
|
||||
<LoadingOutlined size={16} style={{marginRight: 0}} />
|
||||
) : (
|
||||
<DownloadOutlined size={16} style={{marginRight: 0}} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
disabled
|
||||
/>
|
||||
))}
|
||||
</PluginGroup>
|
||||
{isConnected && (
|
||||
<PluginGroup
|
||||
key="unavailable"
|
||||
title="Unavailable plugins"
|
||||
hint="The plugins below are installed in Flipper, but not available for the selected device / application. Hover the plugin info box to find out why.">
|
||||
{unavailablePlugins.map(([plugin, reason]) => (
|
||||
<PluginEntry
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
tooltip={`${getPluginTitle(plugin)} (${plugin.id}@${
|
||||
plugin.version
|
||||
}): ${reason}`}
|
||||
onClick={handleAppPluginClick}
|
||||
disabled
|
||||
actions={<InfoIcon>{reason}</InfoIcon>}
|
||||
/>
|
||||
))}
|
||||
</PluginGroup>
|
||||
)}
|
||||
</PluginMenu>
|
||||
</Layout.Container>
|
||||
</Layout.Container>
|
||||
);
|
||||
});
|
||||
|
||||
function ActionButton({
|
||||
icon,
|
||||
onClick,
|
||||
id,
|
||||
title,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: React.ReactElement;
|
||||
onClick: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
icon={icon}
|
||||
title={title}
|
||||
style={{border: 'none', color: theme.textColorPrimary}}
|
||||
onClick={(e) => {
|
||||
onClick(id);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const PluginEntry = function PluginEntry({
|
||||
plugin,
|
||||
disabled,
|
||||
tooltip,
|
||||
onClick,
|
||||
scrollTo,
|
||||
actions,
|
||||
...rest
|
||||
}: {
|
||||
plugin: {id: string; title: string; icon?: string; version: string};
|
||||
disabled?: boolean;
|
||||
scrollTo?: boolean;
|
||||
tooltip: string;
|
||||
onClick?: (id: string) => void;
|
||||
actions?: React.ReactElement | null;
|
||||
}) {
|
||||
const [hovering, setHovering] = useState(false);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setHovering(true);
|
||||
}, []);
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHovering(false);
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onClick?.(plugin.id);
|
||||
}, [onClick, plugin.id]);
|
||||
|
||||
const domRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (scrollTo) {
|
||||
domRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
}, [scrollTo]);
|
||||
|
||||
return (
|
||||
<Tracked action={`open:${plugin.id}`}>
|
||||
<Menu.Item
|
||||
{...rest}
|
||||
key={plugin.id}
|
||||
style={{cursor: 'pointer'}}
|
||||
onClick={handleClick}>
|
||||
<Layout.Horizontal
|
||||
center
|
||||
gap={10}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}>
|
||||
<PluginIconWrapper disabled={disabled} ref={domRef}>
|
||||
<Glyph size={16} name={plugin.icon || 'apps'} color="white" />
|
||||
</PluginIconWrapper>
|
||||
<Tooltip placement="right" title={tooltip} mouseEnterDelay={1}>
|
||||
<Text style={{flex: 1}}>{getPluginTitle(plugin)}</Text>
|
||||
</Tooltip>
|
||||
{hovering && actions}
|
||||
</Layout.Horizontal>
|
||||
</Menu.Item>
|
||||
</Tracked>
|
||||
);
|
||||
};
|
||||
|
||||
const PluginGroup = memo(function PluginGroup({
|
||||
title,
|
||||
children,
|
||||
hint,
|
||||
...rest
|
||||
}: {title: string; children: React.ReactElement[]; hint?: string} & Record<
|
||||
string,
|
||||
any
|
||||
>) {
|
||||
if (children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let badge = (
|
||||
<Badge
|
||||
count={children.length}
|
||||
style={{
|
||||
marginRight: 20,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
if (hint) {
|
||||
badge = (
|
||||
<NUX title={hint} placement="right">
|
||||
{badge}
|
||||
</NUX>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubMenu
|
||||
{...rest}
|
||||
title={
|
||||
<Layout.Right center>
|
||||
<Text strong>{title}</Text>
|
||||
|
||||
{badge}
|
||||
</Layout.Right>
|
||||
}>
|
||||
{children}
|
||||
</SubMenu>
|
||||
);
|
||||
});
|
||||
|
||||
// Dimensions are hardcoded as they correlate strongly
|
||||
const PluginMenu = styled(Menu)({
|
||||
userSelect: 'none',
|
||||
border: 'none',
|
||||
'.ant-typography': {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
'.ant-menu-sub.ant-menu-inline': {
|
||||
background: theme.backgroundDefault,
|
||||
},
|
||||
'.ant-menu-inline .ant-menu-item, .ant-menu-inline .ant-menu-submenu-title ':
|
||||
{
|
||||
width: '100%', // reset to remove weird bonus pixel from ANT
|
||||
},
|
||||
'.ant-menu-submenu > .ant-menu-submenu-title > .ant-menu-title-content': {
|
||||
overflow: 'visible',
|
||||
},
|
||||
'.ant-menu-submenu > .ant-menu-submenu-title, .ant-menu-sub.ant-menu-inline > .ant-menu-item':
|
||||
{
|
||||
borderRadius: theme.borderRadius,
|
||||
height: '32px',
|
||||
lineHeight: '24px',
|
||||
padding: `4px 8px !important`,
|
||||
'&:hover': {
|
||||
color: theme.textColorPrimary,
|
||||
background: theme.backgroundTransparentHover,
|
||||
},
|
||||
'&.ant-menu-item-selected::after': {
|
||||
border: 'none',
|
||||
},
|
||||
'&.ant-menu-item-selected': {
|
||||
color: theme.white,
|
||||
background: theme.primaryColor,
|
||||
border: 'none',
|
||||
},
|
||||
'&.ant-menu-item-selected .ant-typography': {
|
||||
color: theme.white,
|
||||
},
|
||||
},
|
||||
'.ant-menu-submenu-inline > .ant-menu-submenu-title .ant-menu-submenu-arrow':
|
||||
{
|
||||
right: 8,
|
||||
},
|
||||
'.ant-badge-count': {
|
||||
color: theme.textColorSecondary,
|
||||
// border: `1px solid ${theme.dividerColor}`,
|
||||
background: 'transparent',
|
||||
fontWeight: 'bold',
|
||||
padding: `0 10px`,
|
||||
boxShadow: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
const PluginIconWrapper = styled.div<{disabled?: boolean}>(({disabled}) => ({
|
||||
...iconStyle(!!disabled),
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}));
|
||||
|
||||
function iconStyle(disabled: boolean) {
|
||||
return {
|
||||
color: theme.white,
|
||||
background: disabled ? theme.disabledColor : theme.primaryColor,
|
||||
borderRadius: theme.borderRadius,
|
||||
width: 24,
|
||||
height: 24,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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 {fireEvent, render} from '@testing-library/react';
|
||||
import {Provider} from 'react-redux';
|
||||
import {createStore} from 'redux';
|
||||
import {LaunchEmulatorDialog} from '../LaunchEmulator';
|
||||
|
||||
import {createRootReducer} from '../../../reducers';
|
||||
import {sleep} from 'flipper-plugin';
|
||||
import {createFlipperServerMock} from '../../../test-utils/createFlipperServerMock';
|
||||
|
||||
test('Can render and launch android apps - empty', async () => {
|
||||
const store = createStore(createRootReducer());
|
||||
const mockServer = createFlipperServerMock({
|
||||
'ios-get-simulators': () => Promise.resolve([]),
|
||||
'android-get-emulators': () => Promise.resolve([]),
|
||||
});
|
||||
store.dispatch({
|
||||
type: 'SET_FLIPPER_SERVER',
|
||||
payload: mockServer,
|
||||
});
|
||||
const onClose = jest.fn();
|
||||
|
||||
const renderer = render(
|
||||
<Provider store={store}>
|
||||
<LaunchEmulatorDialog onClose={onClose} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(await renderer.findByText(/No emulators/)).toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="ant-alert-message"
|
||||
>
|
||||
No emulators available
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
test('Can render and launch android apps', async () => {
|
||||
let p: Promise<any> | undefined = undefined;
|
||||
|
||||
const store = createStore(createRootReducer());
|
||||
const launch = jest.fn().mockImplementation(() => Promise.resolve());
|
||||
const mockServer = createFlipperServerMock({
|
||||
'ios-get-simulators': () => Promise.resolve([]),
|
||||
'android-get-emulators': () =>
|
||||
(p = Promise.resolve(['emulator1', 'emulator2'])),
|
||||
'android-launch-emulator': launch,
|
||||
});
|
||||
store.dispatch({
|
||||
type: 'SET_FLIPPER_SERVER',
|
||||
payload: mockServer,
|
||||
});
|
||||
|
||||
store.dispatch({
|
||||
type: 'UPDATE_SETTINGS',
|
||||
payload: {
|
||||
...store.getState().settingsState,
|
||||
enableAndroid: true,
|
||||
},
|
||||
});
|
||||
const onClose = jest.fn();
|
||||
|
||||
const renderer = render(
|
||||
<Provider store={store}>
|
||||
<LaunchEmulatorDialog onClose={onClose} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await p!;
|
||||
|
||||
expect(await renderer.findAllByText(/emulator/)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
<span>
|
||||
emulator1
|
||||
</span>,
|
||||
<span>
|
||||
emulator2
|
||||
</span>,
|
||||
]
|
||||
`);
|
||||
|
||||
expect(onClose).not.toBeCalled();
|
||||
fireEvent.click(renderer.getByText('emulator2'));
|
||||
await sleep(1000);
|
||||
expect(onClose).toBeCalled();
|
||||
expect(launch).toBeCalledWith('emulator2', false);
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 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 {
|
||||
createMockFlipperWithPlugin,
|
||||
MockFlipperResult,
|
||||
} from '../../../test-utils/createMockFlipperWithPlugin';
|
||||
import {FlipperPlugin} from '../../../plugin';
|
||||
import BaseDevice from '../../../devices/BaseDevice';
|
||||
import {_SandyPluginDefinition} from 'flipper-plugin';
|
||||
import {TestUtils} from 'flipper-plugin';
|
||||
import {selectPlugin} from '../../../reducers/connections';
|
||||
import {
|
||||
addGatekeepedPlugins,
|
||||
registerMarketplacePlugins,
|
||||
registerPlugins,
|
||||
} from '../../../reducers/plugins';
|
||||
import {switchPlugin} from '../../../reducers/pluginManager';
|
||||
|
||||
// eslint-disable-next-line
|
||||
import * as LogsPluginModule from '../../../../../plugins/public/logs/index';
|
||||
import {createMockDownloadablePluginDetails} from '../../../utils/testUtils';
|
||||
import {
|
||||
getActiveClient,
|
||||
getActiveDevice,
|
||||
getMetroDevice,
|
||||
getPluginLists,
|
||||
} from '../../../selectors/connections';
|
||||
import {TestDevice} from '../../../test-utils/TestDevice';
|
||||
|
||||
const createMockPluginDetails = TestUtils.createMockPluginDetails;
|
||||
|
||||
const logsPlugin = new _SandyPluginDefinition(
|
||||
createMockPluginDetails({id: 'DeviceLogs'}),
|
||||
LogsPluginModule,
|
||||
);
|
||||
|
||||
class TestPlugin extends FlipperPlugin<any, any, any> {}
|
||||
|
||||
describe('basic getActiveDevice', () => {
|
||||
let flipper: MockFlipperResult;
|
||||
beforeEach(async () => {
|
||||
flipper = await createMockFlipperWithPlugin(TestPlugin);
|
||||
});
|
||||
|
||||
test('getActiveDevice prefers selected device', () => {
|
||||
const {device, store} = flipper;
|
||||
expect(getActiveDevice(store.getState())).toBe(device);
|
||||
});
|
||||
|
||||
test('getActiveDevice picks device of current client', () => {
|
||||
const {device, store} = flipper;
|
||||
expect(getActiveDevice(store.getState())).toBe(device);
|
||||
});
|
||||
|
||||
test('getActiveDevice picks preferred device if no client and device', () => {
|
||||
const {device, store} = flipper;
|
||||
expect(getActiveDevice(store.getState())).toBe(device);
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic getActiveDevice with metro present', () => {
|
||||
let flipper: MockFlipperResult;
|
||||
let metro: BaseDevice;
|
||||
let testDevice: BaseDevice;
|
||||
|
||||
beforeEach(async () => {
|
||||
flipper = await createMockFlipperWithPlugin(logsPlugin);
|
||||
flipper.device.supportsPlugin = (p) => {
|
||||
return p.id !== 'unsupportedDevicePlugin';
|
||||
};
|
||||
testDevice = flipper.device;
|
||||
// flipper.store.dispatch(registerPlugins([LogsPlugin]))
|
||||
flipper.store.dispatch({
|
||||
type: 'REGISTER_DEVICE',
|
||||
payload: new TestDevice(
|
||||
'http://localhost:8081',
|
||||
'physical',
|
||||
'metro',
|
||||
'Metro',
|
||||
),
|
||||
});
|
||||
metro = getMetroDevice(flipper.store.getState())!;
|
||||
metro.supportsPlugin = (p) => {
|
||||
return p.id !== 'unsupportedDevicePlugin';
|
||||
};
|
||||
});
|
||||
|
||||
test('findMetroDevice', () => {
|
||||
expect(metro.os).toBe('Metro');
|
||||
});
|
||||
|
||||
test('correct base selection state', () => {
|
||||
const state = flipper.store.getState();
|
||||
const {connections} = state;
|
||||
expect(connections).toMatchObject({
|
||||
devices: [testDevice, metro],
|
||||
selectedDevice: testDevice,
|
||||
selectedPlugin: 'DeviceLogs',
|
||||
userPreferredDevice: 'MockAndroidDevice',
|
||||
userPreferredPlugin: 'DeviceLogs',
|
||||
userPreferredApp: 'TestApp',
|
||||
});
|
||||
expect(getActiveClient(state)).toBe(flipper.client);
|
||||
});
|
||||
|
||||
test('selecting Metro Logs works but keeps normal device preferred', () => {
|
||||
expect(getActiveClient(flipper.store.getState())).toBe(flipper.client);
|
||||
flipper.store.dispatch(
|
||||
selectPlugin({
|
||||
selectedPlugin: logsPlugin.id,
|
||||
selectedAppId: flipper.client.id,
|
||||
selectedDevice: metro,
|
||||
deepLinkPayload: null,
|
||||
}),
|
||||
);
|
||||
expect(flipper.store.getState().connections).toMatchObject({
|
||||
devices: [testDevice, metro],
|
||||
selectedAppId: 'TestApp#Android#MockAndroidDevice#serial',
|
||||
selectedDevice: metro,
|
||||
selectedPlugin: 'DeviceLogs',
|
||||
userPreferredDevice: 'MockAndroidDevice', // Not metro!
|
||||
userPreferredPlugin: 'DeviceLogs',
|
||||
userPreferredApp: 'TestApp',
|
||||
});
|
||||
const state = flipper.store.getState();
|
||||
// find best device is still metro
|
||||
expect(getActiveDevice(state)).toBe(testDevice);
|
||||
// find best client still returns app
|
||||
expect(getActiveClient(state)).toBe(flipper.client);
|
||||
});
|
||||
|
||||
test('computePluginLists', () => {
|
||||
const state = flipper.store.getState();
|
||||
expect(getPluginLists(state)).toEqual({
|
||||
downloadablePlugins: [],
|
||||
devicePlugins: [logsPlugin],
|
||||
metroPlugins: [logsPlugin],
|
||||
enabledPlugins: [],
|
||||
disabledPlugins: [],
|
||||
unavailablePlugins: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('computePluginLists with problematic plugins', () => {
|
||||
const noopPlugin = {
|
||||
plugin() {},
|
||||
Component() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
const unsupportedDevicePlugin = new _SandyPluginDefinition(
|
||||
createMockPluginDetails({
|
||||
id: 'unsupportedDevicePlugin',
|
||||
title: 'Unsupported Device Plugin',
|
||||
}),
|
||||
{
|
||||
devicePlugin() {
|
||||
return {};
|
||||
},
|
||||
supportsDevice() {
|
||||
return false;
|
||||
},
|
||||
Component() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
const unsupportedPlugin = new _SandyPluginDefinition(
|
||||
createMockPluginDetails({
|
||||
id: 'unsupportedPlugin',
|
||||
title: 'Unsupported Plugin',
|
||||
}),
|
||||
noopPlugin,
|
||||
);
|
||||
|
||||
const gateKeepedPlugin = createMockPluginDetails({
|
||||
id: 'gateKeepedPlugin',
|
||||
title: 'Gatekeeped Plugin',
|
||||
gatekeeper: 'not for you',
|
||||
});
|
||||
|
||||
const plugin1 = new _SandyPluginDefinition(
|
||||
createMockPluginDetails({
|
||||
id: 'plugin1',
|
||||
title: 'Plugin 1',
|
||||
}),
|
||||
{
|
||||
plugin() {},
|
||||
Component() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const plugin2 = new _SandyPluginDefinition(
|
||||
createMockPluginDetails({
|
||||
id: 'plugin2',
|
||||
title: 'Plugin 2',
|
||||
}),
|
||||
noopPlugin,
|
||||
);
|
||||
|
||||
const supportedDownloadablePlugin = createMockDownloadablePluginDetails({
|
||||
id: 'supportedUninstalledPlugin',
|
||||
title: 'Supported Uninstalled Plugin',
|
||||
});
|
||||
|
||||
const unsupportedDownloadablePlugin = createMockDownloadablePluginDetails({
|
||||
id: 'unsupportedUninstalledPlugin',
|
||||
title: 'Unsupported Uninstalled Plugin',
|
||||
});
|
||||
|
||||
flipper.store.dispatch(
|
||||
registerPlugins([
|
||||
unsupportedDevicePlugin,
|
||||
unsupportedPlugin,
|
||||
plugin1,
|
||||
plugin2,
|
||||
]),
|
||||
);
|
||||
flipper.store.dispatch(addGatekeepedPlugins([gateKeepedPlugin]));
|
||||
flipper.store.dispatch(
|
||||
registerMarketplacePlugins([
|
||||
supportedDownloadablePlugin,
|
||||
unsupportedDownloadablePlugin,
|
||||
]),
|
||||
);
|
||||
|
||||
// ok, this is a little hackish
|
||||
flipper.client.plugins = new Set([
|
||||
'plugin1',
|
||||
'plugin2',
|
||||
'supportedUninstalledPlugin',
|
||||
]);
|
||||
|
||||
let state = flipper.store.getState();
|
||||
const pluginLists = getPluginLists(state);
|
||||
expect(pluginLists).toEqual({
|
||||
devicePlugins: [logsPlugin],
|
||||
metroPlugins: [logsPlugin],
|
||||
enabledPlugins: [],
|
||||
disabledPlugins: [plugin1, plugin2],
|
||||
unavailablePlugins: [
|
||||
[
|
||||
gateKeepedPlugin,
|
||||
"Plugin 'Gatekeeped Plugin' is only available to members of gatekeeper 'not for you'",
|
||||
],
|
||||
[
|
||||
unsupportedDevicePlugin.details,
|
||||
"Device plugin 'Unsupported Device Plugin' is not supported by the selected device 'MockAndroidDevice' (Android)",
|
||||
],
|
||||
[
|
||||
unsupportedPlugin.details,
|
||||
"Plugin 'Unsupported Plugin' is not supported by the selected application 'TestApp' (Android)",
|
||||
],
|
||||
[
|
||||
unsupportedDownloadablePlugin,
|
||||
"Plugin 'Unsupported Uninstalled Plugin' is not supported by the selected application 'TestApp' (Android) and not installed in Flipper",
|
||||
],
|
||||
],
|
||||
downloadablePlugins: [supportedDownloadablePlugin],
|
||||
});
|
||||
|
||||
flipper.store.dispatch(
|
||||
switchPlugin({
|
||||
plugin: plugin2,
|
||||
selectedApp: flipper.client.query.app,
|
||||
}),
|
||||
);
|
||||
state = flipper.store.getState();
|
||||
expect(getPluginLists(state)).toMatchObject({
|
||||
enabledPlugins: [plugin2],
|
||||
disabledPlugins: [plugin1],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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 default function ConnectivityStatus() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 {NoDevices} from '../NoDevices';
|
||||
|
||||
export function TroubleshootingGuide(_props: {
|
||||
showGuide: boolean;
|
||||
devicesDetected: number;
|
||||
}) {
|
||||
if (_props.devicesDetected == 0) return <NoDevices />;
|
||||
else {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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, {useState} from 'react';
|
||||
import {Layout, theme} from 'flipper-plugin';
|
||||
import {Button, Typography, Tag, Modal} from 'antd';
|
||||
import {SettingOutlined} from '@ant-design/icons';
|
||||
|
||||
const {Title, Text} = Typography;
|
||||
|
||||
export default function BlocklistSettingButton(props: {
|
||||
blocklistedPlugins: Array<string>;
|
||||
blocklistedCategories: Array<string>;
|
||||
onRemovePlugin: (pluginId: string) => void;
|
||||
onRemoveCategory: (category: string) => void;
|
||||
}) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => setShowModal(true)}
|
||||
/>
|
||||
<Modal
|
||||
title="Notification Setting"
|
||||
visible={showModal}
|
||||
width={650}
|
||||
footer={null}
|
||||
onCancel={() => setShowModal(false)}>
|
||||
<Layout.Container gap="small">
|
||||
<Layout.Container key="blocklisted_plugins" gap="small">
|
||||
<Title level={4}>Blocklisted Plugins</Title>
|
||||
{props.blocklistedPlugins.length > 0 ? (
|
||||
<div>
|
||||
{props.blocklistedPlugins.map((pluginId) => (
|
||||
<Tag
|
||||
key={pluginId}
|
||||
closable
|
||||
onClose={() => props.onRemovePlugin(pluginId)}>
|
||||
<Text style={{fontSize: theme.fontSize.small}} ellipsis>
|
||||
{pluginId}
|
||||
</Text>
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: theme.fontSize.small,
|
||||
color: theme.textColorSecondary,
|
||||
}}>
|
||||
No Blocklisted Plugin
|
||||
</Text>
|
||||
)}
|
||||
</Layout.Container>
|
||||
<Layout.Container key="blocklisted_categories" gap="small">
|
||||
<Title level={4}>Blocklisted Categories</Title>
|
||||
{props.blocklistedCategories.length > 0 ? (
|
||||
<div>
|
||||
{props.blocklistedCategories.map((category) => (
|
||||
<Tag
|
||||
key={category}
|
||||
closable
|
||||
onClose={() => props.onRemoveCategory(category)}>
|
||||
<Text style={{fontSize: theme.fontSize.small}} ellipsis>
|
||||
{category}
|
||||
</Text>
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: theme.fontSize.small,
|
||||
color: theme.textColorSecondary,
|
||||
}}>
|
||||
No Blocklisted Category
|
||||
</Text>
|
||||
)}
|
||||
</Layout.Container>
|
||||
</Layout.Container>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* 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, {useCallback, useMemo, useState} from 'react';
|
||||
import {Layout, theme, Notification as NotificationData} from 'flipper-plugin';
|
||||
import {styled, Glyph} from '../../ui';
|
||||
import {Input, Typography, Button, Collapse, Dropdown, Menu} from 'antd';
|
||||
import {
|
||||
DownOutlined,
|
||||
UpOutlined,
|
||||
SearchOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
DeleteOutlined,
|
||||
EllipsisOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {LeftSidebar, SidebarTitle} from '../LeftSidebar';
|
||||
import {useDispatch, useStore} from '../../utils/useStore';
|
||||
import {selectPlugin} from '../../reducers/connections';
|
||||
import {
|
||||
clearAllNotifications,
|
||||
GLOBAL_NOTIFICATION_PLUGIN_ID,
|
||||
PluginNotification as PluginNotificationOrig,
|
||||
updateCategoryBlocklist,
|
||||
updatePluginBlocklist,
|
||||
} from '../../reducers/notifications';
|
||||
import {filterNotifications} from './notificationUtils';
|
||||
import {useMemoize} from 'flipper-plugin';
|
||||
import BlocklistSettingButton from './BlocklistSettingButton';
|
||||
import {Store} from '../../reducers';
|
||||
|
||||
type NotificationExtra = {
|
||||
onOpen: () => void;
|
||||
onHideSimilar: (() => void) | null;
|
||||
onHidePlugin: () => void;
|
||||
clientName: string | undefined;
|
||||
appName: string | undefined;
|
||||
pluginName: string | null | undefined;
|
||||
iconName: string | null | undefined;
|
||||
};
|
||||
type PluginNotification = NotificationData & NotificationExtra;
|
||||
|
||||
const {Title, Text, Paragraph} = Typography;
|
||||
|
||||
const CollapseContainer = styled.div({
|
||||
'.ant-collapse-ghost .ant-collapse-item': {
|
||||
'& > .ant-collapse-header': {
|
||||
paddingLeft: '16px',
|
||||
},
|
||||
'& > .ant-collapse-content > .ant-collapse-content-box': {
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ItemContainer = styled(Layout.Container)({
|
||||
'.notification-item-action': {visibility: 'hidden'},
|
||||
':hover': {'.notification-item-action': {visibility: 'visible'}},
|
||||
});
|
||||
|
||||
function DetailCollapse({detail}: {detail: string | React.ReactNode}) {
|
||||
const detailView =
|
||||
typeof detail === 'string' ? (
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: theme.fontSize.small,
|
||||
marginBottom: 0,
|
||||
}}
|
||||
ellipsis={{rows: 3}}>
|
||||
{detail}
|
||||
</Paragraph>
|
||||
) : (
|
||||
detail
|
||||
);
|
||||
return (
|
||||
<CollapseContainer>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIcon={({isActive}) =>
|
||||
isActive ? (
|
||||
<UpOutlined style={{fontSize: 8, left: 0}} />
|
||||
) : (
|
||||
<DownOutlined style={{fontSize: 8, left: 0}} />
|
||||
)
|
||||
}>
|
||||
<Collapse.Panel
|
||||
key="detail"
|
||||
header={
|
||||
<Text type="secondary" style={{fontSize: theme.fontSize.small}}>
|
||||
View detail
|
||||
</Text>
|
||||
}>
|
||||
{detailView}
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</CollapseContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationEntry({notification}: {notification: PluginNotification}) {
|
||||
const {
|
||||
onOpen,
|
||||
onHideSimilar,
|
||||
onHidePlugin,
|
||||
message,
|
||||
title,
|
||||
clientName,
|
||||
appName,
|
||||
pluginName,
|
||||
iconName,
|
||||
} = notification;
|
||||
|
||||
const actions = useMemo(
|
||||
() => (
|
||||
<Layout.Horizontal className="notification-item-action">
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
{onHideSimilar && (
|
||||
<Menu.Item key="hide_similar" onClick={onHideSimilar}>
|
||||
Hide Similar
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key="hide_plugin" onClick={onHidePlugin}>
|
||||
Hide {pluginName}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
}>
|
||||
<Button type="text" size="small" icon={<EllipsisOutlined />} />
|
||||
</Dropdown>
|
||||
</Layout.Horizontal>
|
||||
),
|
||||
[onHideSimilar, onHidePlugin, pluginName],
|
||||
);
|
||||
|
||||
const icon = iconName ? (
|
||||
<Glyph name={iconName} size={16} color={theme.primaryColor} />
|
||||
) : (
|
||||
<ExclamationCircleOutlined style={{color: theme.primaryColor}} />
|
||||
);
|
||||
return (
|
||||
<ItemContainer gap="small" pad="medium">
|
||||
<Layout.Right center>
|
||||
<Layout.Horizontal gap="tiny" center>
|
||||
{icon}
|
||||
<Text style={{fontSize: theme.fontSize.small}}>{pluginName}</Text>
|
||||
</Layout.Horizontal>
|
||||
{actions}
|
||||
</Layout.Right>
|
||||
<Title level={4} ellipsis={{rows: 3}}>
|
||||
{title}
|
||||
</Title>
|
||||
{pluginName !== GLOBAL_NOTIFICATION_PLUGIN_ID && (
|
||||
<>
|
||||
<Text type="secondary" style={{fontSize: theme.fontSize.small}}>
|
||||
{clientName && appName
|
||||
? `${clientName}/${appName}`
|
||||
: clientName ?? appName ?? 'Not Connected'}
|
||||
</Text>
|
||||
<Button style={{width: 'fit-content'}} size="small" onClick={onOpen}>
|
||||
Open {pluginName}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<DetailCollapse detail={message} />
|
||||
</ItemContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationList({
|
||||
notifications,
|
||||
}: {
|
||||
notifications: Array<PluginNotification>;
|
||||
}) {
|
||||
return (
|
||||
<Layout.ScrollContainer vertical>
|
||||
<Layout.Container>
|
||||
{notifications.map((notification) => (
|
||||
<NotificationEntry
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
/>
|
||||
))}
|
||||
</Layout.Container>
|
||||
</Layout.ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function Notification() {
|
||||
const store = useStore();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [searchString, setSearchString] = useState('');
|
||||
|
||||
const clientPlugins = useStore((state) => state.plugins.clientPlugins);
|
||||
const devicePlugins = useStore((state) => state.plugins.devicePlugins);
|
||||
const getPlugin = useCallback(
|
||||
(id: string) => clientPlugins.get(id) || devicePlugins.get(id),
|
||||
[clientPlugins, devicePlugins],
|
||||
);
|
||||
|
||||
const notifications = useStore((state) => state.notifications);
|
||||
|
||||
const activeNotifications = useMemoize(filterNotifications, [
|
||||
notifications.activeNotifications,
|
||||
notifications.blocklistedPlugins,
|
||||
notifications.blocklistedCategories,
|
||||
]);
|
||||
const displayedNotifications: Array<PluginNotification> = useMemo(
|
||||
() =>
|
||||
activeNotifications.map((noti) => {
|
||||
const client = getClientById(store, noti.client);
|
||||
const device = client
|
||||
? client.device
|
||||
: getDeviceById(store, noti.client);
|
||||
const plugin = getPlugin(noti.pluginId);
|
||||
return {
|
||||
...noti.notification,
|
||||
onOpen: () => {
|
||||
openNotification(store, noti);
|
||||
},
|
||||
onHideSimilar: noti.notification.category
|
||||
? () =>
|
||||
store.dispatch(
|
||||
updateCategoryBlocklist([
|
||||
...notifications.blocklistedCategories,
|
||||
noti.notification.category!,
|
||||
]),
|
||||
)
|
||||
: null,
|
||||
onHidePlugin: () =>
|
||||
store.dispatch(
|
||||
updatePluginBlocklist([
|
||||
...notifications.blocklistedPlugins,
|
||||
noti.pluginId,
|
||||
]),
|
||||
),
|
||||
clientName: client?.query.device_id ?? device?.displayTitle(),
|
||||
appName: client?.query.app,
|
||||
pluginName: plugin?.title ?? noti.pluginId,
|
||||
iconName: plugin?.icon,
|
||||
};
|
||||
}),
|
||||
[activeNotifications, notifications, getPlugin, store],
|
||||
);
|
||||
|
||||
const actions = (
|
||||
<div>
|
||||
<Layout.Horizontal>
|
||||
<BlocklistSettingButton
|
||||
blocklistedPlugins={notifications.blocklistedPlugins}
|
||||
blocklistedCategories={notifications.blocklistedCategories}
|
||||
onRemovePlugin={(removedPluginId) =>
|
||||
dispatch(
|
||||
updatePluginBlocklist(
|
||||
notifications.blocklistedPlugins.filter(
|
||||
(pluginId) => pluginId !== removedPluginId,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
onRemoveCategory={(removedCategory) =>
|
||||
dispatch(
|
||||
updateCategoryBlocklist(
|
||||
notifications.blocklistedCategories.filter(
|
||||
(category) => category !== removedCategory,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => dispatch(clearAllNotifications())}
|
||||
/>
|
||||
</Layout.Horizontal>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<LeftSidebar>
|
||||
<Layout.Top>
|
||||
<Layout.Container gap="tiny" padv="tiny" borderBottom>
|
||||
<SidebarTitle actions={actions}>notifications</SidebarTitle>
|
||||
<Layout.Container padh="medium" padv="small">
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchString}
|
||||
onChange={(e) => setSearchString(e.target.value)}
|
||||
/>
|
||||
</Layout.Container>
|
||||
</Layout.Container>
|
||||
<NotificationList notifications={displayedNotifications} />
|
||||
</Layout.Top>
|
||||
</LeftSidebar>
|
||||
);
|
||||
}
|
||||
|
||||
export function openNotification(store: Store, noti: PluginNotificationOrig) {
|
||||
const client = getClientById(store, noti.client);
|
||||
if (client) {
|
||||
store.dispatch(
|
||||
selectPlugin({
|
||||
selectedPlugin: noti.pluginId,
|
||||
selectedAppId: client.id,
|
||||
selectedDevice: client.device,
|
||||
deepLinkPayload: noti.notification.action,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const device = getDeviceById(store, noti.client);
|
||||
if (device) {
|
||||
store.dispatch(
|
||||
selectPlugin({
|
||||
selectedPlugin: noti.pluginId,
|
||||
selectedDevice: device,
|
||||
deepLinkPayload: noti.notification.action,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getClientById(store: Store, identifier: string | null) {
|
||||
return store.getState().connections.clients.get(identifier!);
|
||||
}
|
||||
|
||||
function getDeviceById(store: Store, identifier: string | null) {
|
||||
return store
|
||||
.getState()
|
||||
.connections.devices.find((c) => c.serial === identifier);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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 {PluginNotification} from '../../../reducers/notifications';
|
||||
import {filterNotifications} from '../notificationUtils';
|
||||
|
||||
const PLUGIN_COUNT = 3;
|
||||
const CLIENT_COUNT = 2;
|
||||
const CATEGORY_LABEL_COUNT = 2 * PLUGIN_COUNT * CLIENT_COUNT;
|
||||
const CATEGORY_LABEL = 'some category';
|
||||
|
||||
const unfilteredNotifications: Array<PluginNotification> = [...Array(20)].map(
|
||||
(_, idx) => ({
|
||||
notification: {
|
||||
id: `${idx}`,
|
||||
title: `title ${idx}`,
|
||||
message: `message ${idx}`,
|
||||
severity: 'warning',
|
||||
category: idx % CATEGORY_LABEL_COUNT ? undefined : CATEGORY_LABEL,
|
||||
},
|
||||
pluginId: `plugin_${idx % PLUGIN_COUNT}`,
|
||||
client: `client_${idx % CLIENT_COUNT}`,
|
||||
}),
|
||||
);
|
||||
|
||||
test('Filter nothing', async () => {
|
||||
const filteredNotifications = filterNotifications(
|
||||
unfilteredNotifications.slice(),
|
||||
);
|
||||
expect(filteredNotifications.length).toBe(unfilteredNotifications.length);
|
||||
expect(filteredNotifications).toEqual(unfilteredNotifications);
|
||||
});
|
||||
|
||||
test('Filter by single pluginId', async () => {
|
||||
const blockedPluginId = 'plugin_0';
|
||||
const filteredNotifications = filterNotifications(
|
||||
unfilteredNotifications.slice(),
|
||||
[blockedPluginId],
|
||||
);
|
||||
const expectedNotification = unfilteredNotifications
|
||||
.slice()
|
||||
.filter((_, idx) => idx % PLUGIN_COUNT);
|
||||
|
||||
expect(filteredNotifications.length).toBe(expectedNotification.length);
|
||||
expect(filteredNotifications).toEqual(expectedNotification);
|
||||
});
|
||||
|
||||
test('Filter by multiple pluginId', async () => {
|
||||
const blockedPluginIds = ['plugin_1', 'plugin_2'];
|
||||
const filteredNotifications = filterNotifications(
|
||||
unfilteredNotifications.slice(),
|
||||
blockedPluginIds,
|
||||
);
|
||||
const expectedNotification = unfilteredNotifications
|
||||
.slice()
|
||||
.filter((_, idx) => !(idx % PLUGIN_COUNT));
|
||||
|
||||
expect(filteredNotifications.length).toBe(expectedNotification.length);
|
||||
expect(filteredNotifications).toEqual(expectedNotification);
|
||||
});
|
||||
|
||||
test('Filter by category', async () => {
|
||||
const blockedCategory = CATEGORY_LABEL;
|
||||
const filteredNotifications = filterNotifications(
|
||||
unfilteredNotifications.slice(),
|
||||
[],
|
||||
[blockedCategory],
|
||||
);
|
||||
const expectedNotification = unfilteredNotifications
|
||||
.slice()
|
||||
.filter((_, idx) => idx % CATEGORY_LABEL_COUNT);
|
||||
|
||||
expect(filteredNotifications.length).toBe(expectedNotification.length);
|
||||
expect(filteredNotifications).toEqual(expectedNotification);
|
||||
});
|
||||
|
||||
test('Filter by pluginId and category', async () => {
|
||||
const blockedCategory = CATEGORY_LABEL;
|
||||
const blockedPluginId = 'plugin_1';
|
||||
const filteredNotifications = filterNotifications(
|
||||
unfilteredNotifications.slice(),
|
||||
[blockedPluginId],
|
||||
[blockedCategory],
|
||||
);
|
||||
const expectedNotification = unfilteredNotifications
|
||||
.slice()
|
||||
.filter((_, idx) => idx % CATEGORY_LABEL_COUNT && idx % PLUGIN_COUNT !== 1);
|
||||
|
||||
expect(filteredNotifications.length).toBe(expectedNotification.length);
|
||||
expect(filteredNotifications).toEqual(expectedNotification);
|
||||
});
|
||||
|
||||
test('Filter by string searching', async () => {
|
||||
const searchString = 'age 5';
|
||||
const filteredNotifications = filterNotifications(
|
||||
unfilteredNotifications.slice(),
|
||||
[],
|
||||
[],
|
||||
searchString,
|
||||
);
|
||||
const expectedNotification = [unfilteredNotifications[5]];
|
||||
|
||||
expect(filteredNotifications.length).toBe(expectedNotification.length);
|
||||
expect(filteredNotifications).toEqual(expectedNotification);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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 {PluginNotification} from '../../reducers/notifications';
|
||||
|
||||
export function filterNotifications(
|
||||
notifications: Array<PluginNotification>,
|
||||
blocklistedPlugins?: Array<string>,
|
||||
blocklistedCategories?: Array<string>,
|
||||
searchString?: string,
|
||||
): Array<PluginNotification> {
|
||||
return notifications
|
||||
.filter((noti) =>
|
||||
blocklistedPlugins ? !blocklistedPlugins.includes(noti.pluginId) : true,
|
||||
)
|
||||
.filter((noti) =>
|
||||
blocklistedCategories && noti.notification.category
|
||||
? !blocklistedCategories?.includes(noti.notification.category)
|
||||
: true,
|
||||
)
|
||||
.filter((noti) =>
|
||||
searchString
|
||||
? noti.notification.title
|
||||
.toLocaleLowerCase()
|
||||
.includes(searchString.toLocaleLowerCase()) ||
|
||||
(typeof noti.notification.message === 'string'
|
||||
? noti.notification.message
|
||||
.toLocaleLowerCase()
|
||||
.includes(searchString.toLocaleLowerCase())
|
||||
: false)
|
||||
: true,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user