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

@@ -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)',