diff --git a/desktop/app/src/chrome/SettingsSheet.tsx b/desktop/app/src/chrome/SettingsSheet.tsx index b7d464048..0610932b7 100644 --- a/desktop/app/src/chrome/SettingsSheet.tsx +++ b/desktop/app/src/chrome/SettingsSheet.tsx @@ -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 { }} /> + + Reset all new user tooltips + + ); @@ -351,3 +356,16 @@ export default connect( }), {updateSettings, updateLauncherSettings}, )(SettingsSheet); + +function ResetTooltips() { + const nuxManager = useContext(NuxManagerContext); + + return ( + + ); +} diff --git a/desktop/app/src/init.tsx b/desktop/app/src/init.tsx index f5fcfd38d..2d702376d 100644 --- a/desktop/app/src/init.tsx +++ b/desktop/app/src/init.tsx @@ -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 = () => ( - + + + diff --git a/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx b/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx index 3925ee499..64952df96 100644 --- a/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx +++ b/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx @@ -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 = { type PreviewProps = { title: string; - description?: string; - props: [string, string, string][]; + description?: React.ReactNode; + props: [string, React.ReactNode, React.ReactNode][]; demos: Record; }; @@ -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{' '} + + docs + + , + '(optional) on which side to place the tooltip', + ], + ], + demos: { + 'NUX example': ( + + + + ), + }, + }, ]; function ComponentPreview({title, demos, description, props}: PreviewProps) { diff --git a/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx b/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx index ac01dec99..77fe67268 100644 --- a/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx +++ b/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx @@ -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" /> ) : ( - } defaultValue="mysite" /> + + } defaultValue="mysite" /> + )} {!isArchived && ( diff --git a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx index 4183b6fba..da6f04dcf 100644 --- a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx +++ b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx @@ -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() { {!isArchived && ( - + {metroPlugins.map((plugin) => ( {!isArchived && ( - + {disabledPlugins.map((plugin) => ( )} {!isArchived && ( - + {unavailablePlugins.map(([plugin, reason]) => ( ) { +}: {title: string; children: React.ReactElement[]; hint?: string} & Record< + string, + any +>) { if (children.length === 0) { return null; } + + let badge = ( + + ); + if (hint) { + badge = ( + + {badge} + + ); + } + return ( {title} - + + {badge} }> {children} diff --git a/desktop/flipper-plugin/package.json b/desktop/flipper-plugin/package.json index 836fa3927..a66972a8b 100644 --- a/desktop/flipper-plugin/package.json +++ b/desktop/flipper-plugin/package.json @@ -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", diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index 5cf7a5802..6103b5c2d 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -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. diff --git a/desktop/flipper-plugin/src/ui/Layout.tsx b/desktop/flipper-plugin/src/ui/Layout.tsx index 462a80e93..df20f0ff4 100644 --- a/desktop/flipper-plugin/src/ui/Layout.tsx +++ b/desktop/flipper-plugin/src/ui/Layout.tsx @@ -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, diff --git a/desktop/flipper-plugin/src/ui/NUX.tsx b/desktop/flipper-plugin/src/ui/NUX.tsx new file mode 100644 index 000000000..5e2cf9247 --- /dev/null +++ b/desktop/flipper-plugin/src/ui/NUX.tsx @@ -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; + +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 = 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(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 ( + + + {title} + + + }> + + + ) + }> + {children} + + ); +} + +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', + }, +}); diff --git a/desktop/flipper-plugin/src/ui/__tests__/NUX.node.tsx b/desktop/flipper-plugin/src/ui/__tests__/NUX.node.tsx new file mode 100644 index 000000000..4724280db --- /dev/null +++ b/desktop/flipper-plugin/src/ui/__tests__/NUX.node.tsx @@ -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(
bla
)).toMatchInlineSnapshot( + `"flipper:myN0Mqqzs3fPwYDKGEQVG9XD9togJNWYJiy1VNQOf18="`, + ); + expect(getNuxKey(
bla2
)).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="`, + ); +}); diff --git a/desktop/flipper-plugin/src/ui/theme.tsx b/desktop/flipper-plugin/src/ui/theme.tsx index aea99c9dc..d31357528 100644 --- a/desktop/flipper-plugin/src/ui/theme.tsx +++ b/desktop/flipper-plugin/src/ui/theme.tsx @@ -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)', diff --git a/desktop/yarn.lock b/desktop/yarn.lock index e773f206d..fa036240c 100644 --- a/desktop/yarn.lock +++ b/desktop/yarn.lock @@ -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"