Introduce NUX element

Summary:
allow-large-files

This diff introces the `NUX` element that can be wrapped around any other element to give a first-time usage hint.

Hint dismissal is stored by taking a hash of the hint contents, and scoped per plugin.

Users can reset the 'read' status in the settings page

Example usage:

```
<NUX
  title="Use bookmarks to directly navigate to a location in the app."
  placement="right">
  <Input addonAfter={<SettingOutlined />} defaultValue="mysite" />
</NUX>
```

Reviewed By: nikoant

Differential Revision: D24622276

fbshipit-source-id: 0265634f9ab50c32214b74f033f59482cd986f23
This commit is contained in:
Michel Weststrate
2020-11-06 07:31:19 -08:00
committed by Facebook GitHub Bot
parent b8b9c4296a
commit 2b0e93a063
12 changed files with 325 additions and 28 deletions

View File

@@ -8,7 +8,7 @@
*/
import {FlexColumn, Button, styled, Text, FlexRow, Spacer} from '../ui';
import React, {Component} from 'react';
import React, {Component, useContext} from 'react';
import {updateSettings, Action} from '../reducers/settings';
import {
Action as LauncherAction,
@@ -28,6 +28,7 @@ import LauncherSettingsPanel from '../fb-stubs/LauncherSettingsPanel';
import SandySettingsPanel from '../fb-stubs/SandySettingsPanel';
import {reportUsage} from '../utils/metrics';
import {Modal} from 'antd';
import {Layout, NuxManagerContext} from 'flipper-plugin';
const Container = styled(FlexColumn)({
padding: 20,
@@ -311,6 +312,10 @@ class SettingsSheet extends Component<Props, State> {
}}
/>
</ToggledSection>
<Layout.Right center>
<span>Reset all new user tooltips</span>
<ResetTooltips />
</Layout.Right>
</>
);
@@ -351,3 +356,16 @@ export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
}),
{updateSettings, updateLauncherSettings},
)(SettingsSheet);
function ResetTooltips() {
const nuxManager = useContext(NuxManagerContext);
return (
<Button
onClick={() => {
nuxManager.resetHints();
}}>
Reset
</Button>
);
}

View File

@@ -34,6 +34,7 @@ import {PopoverProvider} from './ui/components/PopoverProvider';
import {initializeFlipperLibImplementation} from './utils/flipperLibImplementation';
import {enableConsoleHook} from './chrome/ConsoleLogs';
import {sideEffect} from './utils/sideEffect';
import {NuxManagerContext, createNuxManager} from 'flipper-plugin';
if (process.env.NODE_ENV === 'development' && os.platform() === 'darwin') {
// By default Node.JS has its internal certificate storage and doesn't use
@@ -56,7 +57,9 @@ const AppFrame = () => (
<ContextMenuProvider>
<Provider store={store}>
<CacheProvider value={cache}>
<NuxManagerContext.Provider value={createNuxManager()}>
<App logger={logger} />
</NuxManagerContext.Provider>
</CacheProvider>
</Provider>
</ContextMenuProvider>

View File

@@ -9,8 +9,8 @@
import React from 'react';
import {Typography, Card, Table, Collapse, Button, Tabs} from 'antd';
import {Layout} from '../ui';
import {theme} from 'flipper-plugin';
import {Layout, Link} from '../ui';
import {NUX, theme} from 'flipper-plugin';
import reactElementToJSXString from 'react-element-to-jsx-string';
import {CodeOutlined} from '@ant-design/icons';
@@ -29,8 +29,8 @@ const demoStyle: Record<string, React.CSSProperties> = {
type PreviewProps = {
title: string;
description?: string;
props: [string, string, string][];
description?: React.ReactNode;
props: [string, React.ReactNode, React.ReactNode][];
demos: Record<string, React.ReactNode>;
};
@@ -285,6 +285,31 @@ const demos: PreviewProps[] = [
),
},
},
{
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>
),
},
},
];
function ComponentPreview({title, demos, description, props}: PreviewProps) {

View File

@@ -12,7 +12,7 @@ import {Alert, Input} from 'antd';
import {LeftSidebar, SidebarTitle, InfoIcon} from '../LeftSidebar';
import {SettingOutlined} from '@ant-design/icons';
import {Layout, Link, styled} from '../../ui';
import {theme} from 'flipper-plugin';
import {NUX, theme} from 'flipper-plugin';
import {AppSelector} from './AppSelector';
import {useStore} from '../../utils/useStore';
import {PluginList} from './PluginList';
@@ -49,7 +49,11 @@ export function AppInspect() {
type="info"
/>
) : (
<NUX
title="Use bookmarks to directly navigate to a location in the app."
placement="right">
<Input addonAfter={<SettingOutlined />} defaultValue="mysite" />
</NUX>
)}
{!isArchived && (
<Toolbar gap>

View File

@@ -12,7 +12,7 @@ import {Badge, Button, Menu, Tooltip, Typography} from 'antd';
import {InfoIcon, SidebarTitle} from '../LeftSidebar';
import {PlusOutlined, MinusOutlined} from '@ant-design/icons';
import {Glyph, Layout, styled} from '../../ui';
import {theme} from 'flipper-plugin';
import {theme, NUX} from 'flipper-plugin';
import {useDispatch, useStore} from '../../utils/useStore';
import {getPluginTitle, sortPluginsByName} from '../../utils/pluginUtils';
import {ClientPluginDefinition, DevicePluginDefinition} from '../../plugin';
@@ -124,7 +124,10 @@ export const PluginList = memo(function PluginList() {
</PluginGroup>
{!isArchived && (
<PluginGroup key="metro" title="React Native">
<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}
@@ -163,7 +166,10 @@ export const PluginList = memo(function PluginList() {
))}
</PluginGroup>
{!isArchived && (
<PluginGroup key="disabled" title="Disabled">
<PluginGroup
key="disabled"
title="Disabled"
hint="This section shows the plugins that are currently disabled. If a pluign 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}
@@ -184,7 +190,10 @@ export const PluginList = memo(function PluginList() {
</PluginGroup>
)}
{!isArchived && (
<PluginGroup key="unavailable" title="Unavailable plugins">
<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}
@@ -295,23 +304,40 @@ const PluginEntry = memo(function PluginEntry({
const PluginGroup = memo(function PluginGroup({
title,
children,
hint,
...rest
}: {title: string; children: React.ReactElement[]} & Record<string, any>) {
}: {title: string; children: React.ReactElement[]; hint?: string} & Record<
string,
any
>) {
if (children.length === 0) {
return null;
}
return (
<SubMenu
{...rest}
title={
<Layout.Right center>
<Text strong>{title}</Text>
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}

View File

@@ -9,14 +9,20 @@
"license": "MIT",
"bugs": "https://github.com/facebook/flipper/issues",
"dependencies": {
"@testing-library/dom": "^7.26.3",
"@testing-library/react": "^11.1.0",
"immer": "^7.0.5"
"react-element-to-jsx-string": "^14.3.2"
},
"devDependencies": {
"@types/jest": "^26.0.3",
"typescript": "^4.0.3"
},
"peerDependencies": {
"@ant-design/icons": "^4.2.2",
"@testing-library/dom": "^7.26.3",
"@testing-library/react": "^11.1.0",
"antd": "^4.8.0",
"emotion": "^10.0.27",
"immer": "^7.0.5"
},
"scripts": {
"reset": "rimraf lib *.tsbuildinfo",
"build": "tsc -b",

View File

@@ -34,6 +34,7 @@ export {
export {theme} from './ui/theme';
export {Layout} from './ui/Layout';
export {NUX, NuxManagerContext, createNuxManager} from './ui/NUX';
// It's not ideal that this exists in flipper-plugin sources directly,
// but is the least pain for plugin authors.

View File

@@ -198,7 +198,7 @@ const SandySplitContainer = styled.div<{
flex: 1,
flexDirection: props.flexDirection,
alignItems: props.center ? 'center' : 'stretch',
overflow: 'hidden',
overflow: props.center ? undefined : 'hidden', // only use overflow hidden in container mode, to avoid weird resizing issues
'> :nth-child(1)': {
flex: props.grow === 1 ? splitGrowStyle : splitFixedStyle,
minWidth: props.grow === 1 ? 0 : undefined,

View File

@@ -0,0 +1,158 @@
/**
* 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, {createContext, useCallback, useContext} from 'react';
import {Badge, Tooltip, Typography, Button} from 'antd';
import styled from '@emotion/styled';
import {SandyPluginInstance, theme} from 'flipper-plugin';
import {keyframes} from 'emotion';
import reactElementToJSXString from 'react-element-to-jsx-string';
import {SandyPluginContext} from '../plugin/PluginContext';
import {createState, useValue} from '../state/atom';
import {SandyDevicePluginInstance} from '../plugin/DevicePlugin';
import {Layout} from './Layout';
import {BulbTwoTone} from '@ant-design/icons';
import {createHash} from 'crypto';
import type {TooltipPlacement} from 'antd/lib/tooltip';
const {Text} = Typography;
type NuxManager = ReturnType<typeof createNuxManager>;
const storageKey = `FLIPPER_NUX_STATE`;
export function getNuxKey(
elem: React.ReactNode,
currentPlugin?: SandyPluginInstance | SandyDevicePluginInstance,
) {
return `${currentPlugin?.definition.id ?? 'flipper'}:${createHash('sha256')
.update(reactElementToJSXString(elem))
.digest('base64')}`;
}
export function createNuxManager() {
const ticker = createState(0);
let readMap: Record<string, boolean> = JSON.parse(
window.localStorage.getItem(storageKey) || '{}',
);
function save() {
// trigger all Nux Elements to re-compute state
ticker.set(ticker.get() + 1);
window.localStorage.setItem(storageKey, JSON.stringify(readMap, null, 2));
}
return {
markRead(
elem: React.ReactNode,
currentPlugin?: SandyPluginInstance | SandyDevicePluginInstance,
): void {
readMap[getNuxKey(elem, currentPlugin)] = true;
save();
},
isRead(
elem: React.ReactNode,
currentPlugin?: SandyPluginInstance | SandyDevicePluginInstance,
): boolean {
return !!readMap[getNuxKey(elem, currentPlugin)];
},
resetHints(): void {
readMap = {};
save();
},
ticker,
};
}
const stubManager: NuxManager = {
markRead() {},
isRead() {
return true;
},
resetHints() {},
ticker: createState(0),
};
export const NuxManagerContext = createContext<NuxManager>(stubManager);
/**
* Creates a New-User-eXperience element; a lightbulb that will show the user new features
*/
export function NUX({
children,
title,
placement,
}: {
children: React.ReactNode;
title: string;
placement?: TooltipPlacement;
}) {
const manager = useContext(NuxManagerContext);
const pluginInstance = useContext(SandyPluginContext);
// changing the ticker will force `isRead` to be recomputed
const _tick = useValue(manager.ticker);
const isRead = manager.isRead(title, pluginInstance);
const dismiss = useCallback(() => {
manager.markRead(title, pluginInstance);
}, [title, manager, pluginInstance]);
return (
<Badge
count={
isRead ? (
0
) : (
<Tooltip
placement={placement}
color={theme.backgroundWash}
title={
<Layout.Container
center
gap
pad
style={{color: theme.textColorPrimary}}>
<BulbTwoTone style={{fontSize: 24}} />
<Text>{title}</Text>
<Button size="small" type="default" onClick={dismiss}>
Dismiss
</Button>
</Layout.Container>
}>
<Pulse />
</Tooltip>
)
}>
{children}
</Badge>
);
}
const pulse = keyframes({
'0%': {
opacity: 0.2,
},
'100%': {
opacity: 1,
},
});
const Pulse = styled.div({
cursor: 'pointer',
background: theme.warningColor,
animation: `${pulse} 2s infinite alternate`,
borderRadius: 20,
height: 12,
width: 12,
':hover': {
opacity: `1 !important`,
background: theme.errorColor,
animationPlayState: 'paused',
},
});

View File

@@ -0,0 +1,47 @@
/**
* 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 {TestUtils} from 'flipper-plugin';
import React from 'react';
import {getNuxKey} from '../NUX';
test('nuxkey computation', () => {
expect(getNuxKey('test')).toMatchInlineSnapshot(
`"flipper:n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="`,
);
expect(getNuxKey('test')).toMatchInlineSnapshot(
`"flipper:n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="`,
);
expect(getNuxKey('test2')).toMatchInlineSnapshot(
`"flipper:YDA64iuZiGG847KPM+7BvnWKITyGyTwHbb6fVYwRx1I="`,
);
expect(getNuxKey(<div>bla</div>)).toMatchInlineSnapshot(
`"flipper:myN0Mqqzs3fPwYDKGEQVG9XD9togJNWYJiy1VNQOf18="`,
);
expect(getNuxKey(<div>bla2</div>)).toMatchInlineSnapshot(
`"flipper:B6kICeYCJMWeUThs5TWCLuiwCqzr5cWn67xXA4ET0bU="`,
);
});
test('nuxkey computation with plugin', () => {
const res = TestUtils.startPlugin({
Component() {
return null;
},
plugin() {
return {};
},
});
expect(
getNuxKey('test', (res as any)._backingInstance),
).toMatchInlineSnapshot(
`"TestPlugin:n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="`,
);
});

View File

@@ -11,6 +11,7 @@
export const theme = {
white: 'white', // use as counter color for primary
black: 'black',
primaryColor: 'var(--flipper-primary-color)',
successColor: 'var(--flipper-success-color)',
errorColor: 'var(--flipper-error-color)',

View File

@@ -6803,11 +6803,6 @@ immer@^6.0.0:
resolved "https://registry.yarnpkg.com/immer/-/immer-6.0.9.tgz#b9dd69b8e69b3a12391e87db1e3ff535d1b26485"
integrity sha512-SyCYnAuiRf67Lvk0VkwFvwtDoEiCMjeamnHvRfnVDyc7re1/rQrNxuL+jJ7lA3WvdC4uznrvbmm+clJ9+XXatg==
immer@^7.0.5:
version "7.0.9"
resolved "https://registry.yarnpkg.com/immer/-/immer-7.0.9.tgz#28e7552c21d39dd76feccd2b800b7bc86ee4a62e"
integrity sha512-Vs/gxoM4DqNAYR7pugIxi0Xc8XAun/uy7AQu4fLLqaTBHxjOP9pJ266Q9MWA/ly4z6rAFZbvViOtihxUZ7O28A==
immutable@^4.0.0-rc.12:
version "4.0.0-rc.12"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0-rc.12.tgz#ca59a7e4c19ae8d9bf74a97bdf0f6e2f2a5d0217"
@@ -7220,6 +7215,11 @@ is-plain-object@3.0.0:
dependencies:
isobject "^4.0.0"
is-plain-object@3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-3.0.1.tgz#662d92d24c0aa4302407b0d45d21f2251c85f85b"
integrity sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==
is-plain-object@^2.0.3, is-plain-object@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
@@ -10694,6 +10694,14 @@ react-element-to-jsx-string@^14.3.1:
"@base2/pretty-print-object" "1.0.0"
is-plain-object "3.0.0"
react-element-to-jsx-string@^14.3.2:
version "14.3.2"
resolved "https://registry.yarnpkg.com/react-element-to-jsx-string/-/react-element-to-jsx-string-14.3.2.tgz#c0000ed54d1f8b4371731b669613f2d4e0f63d5c"
integrity sha512-WZbvG72cjLXAxV7VOuSzuHEaI3RHj10DZu8EcKQpkKcAj7+qAkG5XUeSdX5FXrA0vPrlx0QsnAzZEBJwzV0e+w==
dependencies:
"@base2/pretty-print-object" "1.0.0"
is-plain-object "3.0.1"
react-inspector@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-5.1.0.tgz#45a325e15f33e595be5356ca2d3ceffb7d6b8c3a"