diff --git a/desktop/flipper-plugin/src/index.tsx b/desktop/flipper-plugin/src/index.tsx index 8749e961d..a5e919a1f 100644 --- a/desktop/flipper-plugin/src/index.tsx +++ b/desktop/flipper-plugin/src/index.tsx @@ -38,6 +38,7 @@ export {Sidebar as _Sidebar} from './ui/Sidebar'; export {DetailSidebar} from './ui/DetailSidebar'; export {Toolbar} from './ui/Toolbar'; export {MasterDetail} from './ui/MasterDetail'; +export {MasterDetailWithPowerSearch as _MasterDetailWithPowerSearch} from './ui/MasterDetailWithPowerSearch'; export {CodeBlock} from './ui/CodeBlock'; export {renderReactRoot, _PortalsManager} from './utils/renderReactRoot'; diff --git a/desktop/flipper-plugin/src/ui/MasterDetailWithPowerSearch.tsx b/desktop/flipper-plugin/src/ui/MasterDetailWithPowerSearch.tsx new file mode 100644 index 000000000..0bbd3e8ff --- /dev/null +++ b/desktop/flipper-plugin/src/ui/MasterDetailWithPowerSearch.tsx @@ -0,0 +1,276 @@ +/** + * Copyright (c) Meta Platforms, Inc. and 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 * as React from 'react'; +import { + createElement, + createRef, + useCallback, + useState, + useEffect, +} from 'react'; +import {DataInspector} from './data-inspector/DataInspector'; +import {DataTable, DataTableProps} from './data-table/DataTableWithPowerSearch'; +import {DataTableManager} from './data-table/DataTableWithPowerSearchManager'; +import {DetailSidebar} from './DetailSidebar'; +import {Layout} from './Layout'; +import {Panel} from './Panel'; + +import { + DeleteOutlined, + PauseCircleOutlined, + PlayCircleOutlined, +} from '@ant-design/icons'; +import {Button} from 'antd'; +import {usePluginInstance} from '../plugin/PluginContext'; +import {Atom, createState} from 'flipper-plugin-core'; +import {useAssertStableRef} from '../utils/useAssertStableRef'; +import {useValue} from '../state/atom'; + +type MasterDetailProps = { + /** + * Where to display the details of the currently selected record? + * 'main' (default): show the details in the standard, centrally controlled right sidebar + * 'right': show a resizable pane to the right + * 'bottom': show a resizable pane to the bottom + * 'none': don't show details at all + */ + sidebarPosition?: 'bottom' | 'right' | 'main' | 'overlay' | 'none'; + /** + * Component that accepts a 'record' prop that is used to render details. + * If none is provided, a standard `DataInspector` component will be used to display the entire record. + */ + sidebarComponent?: React.FC<{ + record: T; + tableManagerRef?: React.RefObject | undefined>; + }>; + /** + * Default size of the sidebar. + */ + sidebarSize?: number; + /** + * If provided, this atom will be used to store selection in. + */ + selection?: Atom; + /** + * If provided, this atom will be used to store pause/resume state in, and a pause/resume toggle will be shown + */ + isPaused?: Atom; + /** + * If set, a clear button will be shown. + * By default this will clear the dataSource (if applicable). + */ + enableClear?: boolean; + /** + * Callback to be called when clear action is used. + */ + onClear?: () => void; + /** + * If provided, standard menu entries will be created for clear, goToBottom and createPaste + */ + enableMenuEntries?: boolean; +}; + +export function MasterDetailWithPowerSearch({ + dataSource, + records, + sidebarComponent, + sidebarPosition, + sidebarSize, + onSelect, + extraActions, + enableMenuEntries, + enableClear, + isPaused, + selection, + onClear, + ...tableProps +}: MasterDetailProps & DataTableProps) { + useAssertStableRef(isPaused, 'isPaused'); + useAssertStableRef(selection, 'selection'); + + const pluginInstance = usePluginInstance(); + const {client} = pluginInstance; + const connected = useValue(pluginInstance.client.connected); + + const selectionAtom = + // if no selection atom is provided, the component is uncontrolled + // and we maintain our own selection atom + // eslint-disable-next-line + selection ?? useState(() => createState(undefined))[0]; + const selectedRecord = useValue(selectionAtom); + + // if a tableManagerRef is provided, we piggy back on that same ref + // eslint-disable-next-line + const tableManagerRef = + tableProps.tableManagerRef ?? createRef>(); + + const pausedState = useValue(isPaused, false); + + const sidebar = + sidebarPosition !== 'none' && selectedRecord && sidebarComponent + ? createElement(sidebarComponent, { + record: selectedRecord, + tableManagerRef, + }) + : null; + + const handleSelect = useCallback( + (record: T | undefined, records: T[]) => { + selectionAtom.set(record); + onSelect?.(record, records); + }, + [selectionAtom, onSelect], + ); + + const handleTogglePause = useCallback(() => { + isPaused?.set(!isPaused?.get()); + }, [isPaused]); + + const handleClear = useCallback(() => { + handleSelect(undefined, []); + if (dataSource) { + dataSource.clear(); + onClear?.(); + } else { + if (!onClear) { + throw new Error( + "onClear must be set when using 'enableClear' and 'records'", + ); + } + onClear(); + } + }, [dataSource, onClear, handleSelect]); + + const handleCreatePaste = useCallback(() => { + const selection = tableManagerRef.current?.getSelectedItems(); + switch (selection?.length) { + case undefined: + case 0: + return; + case 1: + client.createPaste(JSON.stringify(selection[0], null, 2)); + break; + default: + client.createPaste(JSON.stringify(selection, null, 2)); + } + }, [client, tableManagerRef]); + + const handleGoToBottom = useCallback(() => { + const size = dataSource ? dataSource.view.size : records!.length; + tableManagerRef?.current?.selectItem(size - 1); + }, [dataSource, records, tableManagerRef]); + + useEffect( + function setupMenuEntries() { + if (enableMenuEntries) { + if (enableClear) { + client.addMenuEntry({ + action: 'clear', + handler: handleClear, + }); + } + if (client.isFB) { + client.addMenuEntry({ + action: 'createPaste', + handler: handleCreatePaste, + }); + } + client.addMenuEntry({ + action: 'goToBottom', + handler: handleGoToBottom, + }); + } + }, + [ + client, + enableClear, + enableMenuEntries, + handleClear, + handleCreatePaste, + handleGoToBottom, + ], + ); + + const table = ( + + enableAutoScroll + {...tableProps} + dataSource={dataSource as any} + records={records!} + tableManagerRef={tableManagerRef} + onSelect={handleSelect} + extraActions={ + <> + {connected && isPaused && ( + + )} + {connected && enableClear && ( + + )} + {extraActions} + + } + /> + ); + + switch (sidebarPosition!) { + case 'main': + return ( + + {table} + {sidebar} + + ); + case 'right': + return ( + + {table} + {sidebar} + + ); + case 'bottom': + return ( + + {table} + {sidebar} + + ); + case 'overlay': + return ( + + {table} + {sidebar} + + ); + case 'none': + return table; + } +} + +MasterDetailWithPowerSearch.defaultProps = { + sidebarPosition: 'main', + sidebarSize: 400, + sidebarComponent: DefaultRenderSidebar, +} as Partial>; + +function DefaultRenderSidebar({record}: {record: T}) { + return ( + + + + ); +}