diff --git a/desktop/app/src/createTablePlugin.tsx b/desktop/app/src/createTablePlugin.tsx index 261ebacbb..2e4979150 100644 --- a/desktop/app/src/createTablePlugin.tsx +++ b/desktop/app/src/createTablePlugin.tsx @@ -62,6 +62,8 @@ type State = { * An optional resetMethod argument can be provided which will replace the current rows with the * data provided. This is useful when connecting to Flipper for this first time, or reconnecting to * the client in an unknown state. + * + * @deprecated use createTablePlugin from flipper-plugin instead */ export function createTablePlugin(props: Props) { return class extends FlipperPlugin> { diff --git a/desktop/flipper-plugin/src/__tests__/api.node.tsx b/desktop/flipper-plugin/src/__tests__/api.node.tsx index 480f25969..fc8a531b5 100644 --- a/desktop/flipper-plugin/src/__tests__/api.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/api.node.tsx @@ -49,6 +49,7 @@ test('Correct top level API exposed', () => { "batch", "createDataSource", "createState", + "createTablePlugin", "produce", "renderReactRoot", "sleep", diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index d3359178f..68e6d20e5 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -120,6 +120,8 @@ export { } from './ui/elements-inspector/ElementsInspector'; export {useMemoize} from './utils/useMemoize'; +export {createTablePlugin} from './utils/createTablePlugin'; + // It's not ideal that this exists in flipper-plugin sources directly, // but is the least pain for plugin authors. // Probably we should make sure that testing-library doesn't end up in our final Flipper bundle (which packages flipper-plugin) diff --git a/desktop/flipper-plugin/src/plugin/Plugin.tsx b/desktop/flipper-plugin/src/plugin/Plugin.tsx index edfa7df83..dc7ada1cb 100644 --- a/desktop/flipper-plugin/src/plugin/Plugin.tsx +++ b/desktop/flipper-plugin/src/plugin/Plugin.tsx @@ -12,7 +12,7 @@ import {BasePluginInstance, BasePluginClient} from './PluginBase'; import {FlipperLib} from './FlipperLib'; import {RealFlipperDevice} from './DevicePlugin'; import {batched} from '../state/batch'; -import {Atom, createState} from '../state/atom'; +import {Atom, createState, ReadOnlyAtom} from '../state/atom'; type EventsContract = Record; type MethodsContract = Record Promise>; @@ -40,6 +40,7 @@ export interface PluginClient< readonly appName: string; readonly isConnected: boolean; + readonly connected: ReadOnlyAtom; /** * the onConnect event is fired whenever the plugin is connected to it's counter part on the device. @@ -169,6 +170,7 @@ export class SandyPluginInstance extends BasePluginInstance { get appName() { return realClient.query.app; }, + connected: self.connected, get isConnected() { return self.connected.get(); }, diff --git a/desktop/flipper-plugin/src/state/atom.tsx b/desktop/flipper-plugin/src/state/atom.tsx index ed5cafaae..6f41bfc31 100644 --- a/desktop/flipper-plugin/src/state/atom.tsx +++ b/desktop/flipper-plugin/src/state/atom.tsx @@ -13,13 +13,16 @@ import {Persistable, registerStorageAtom} from '../plugin/PluginBase'; enableMapSet(); -export type Atom = { +export interface ReadOnlyAtom { get(): T; - set(newValue: T): void; - update(recipe: (draft: Draft) => void): void; subscribe(listener: (value: T, prevValue: T) => void): () => void; unsubscribe(listener: (value: T, prevValue: T) => void): void; -}; +} + +export interface Atom extends ReadOnlyAtom { + set(newValue: T): void; + update(recipe: (draft: Draft) => void): void; +} class AtomValue implements Atom, Persistable { value: T; @@ -93,9 +96,15 @@ export function createState( return atom; } -export function useValue(atom: Atom): T; -export function useValue(atom: Atom | undefined, defaultValue: T): T; -export function useValue(atom: Atom | undefined, defaultValue?: T): T { +export function useValue(atom: ReadOnlyAtom): T; +export function useValue( + atom: ReadOnlyAtom | undefined, + defaultValue: T, +): T; +export function useValue( + atom: ReadOnlyAtom | undefined, + defaultValue?: T, +): T { const [localValue, setLocalValue] = useState( atom ? atom.get() : defaultValue!, ); diff --git a/desktop/flipper-plugin/src/ui/data-table/TableContextMenu.tsx b/desktop/flipper-plugin/src/ui/data-table/TableContextMenu.tsx index 914e9ae2b..39bb17a8e 100644 --- a/desktop/flipper-plugin/src/ui/data-table/TableContextMenu.tsx +++ b/desktop/flipper-plugin/src/ui/data-table/TableContextMenu.tsx @@ -19,6 +19,7 @@ import React from 'react'; import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib'; import {DataTableColumn} from './DataTable'; import {DataSource} from '../../state/DataSource'; +import {toFirstUpper} from '../../utils/toFirstUpper'; const {Item, SubMenu} = Menu; @@ -137,5 +138,5 @@ export function tableContextMenuFactory( function friendlyColumnTitle(column: DataTableColumn): string { const name = column.title || column.key; - return name[0].toUpperCase() + name.substr(1); + return toFirstUpper(name); } diff --git a/desktop/flipper-plugin/src/ui/data-table/TableHead.tsx b/desktop/flipper-plugin/src/ui/data-table/TableHead.tsx index ed5311ec9..0259d7d73 100644 --- a/desktop/flipper-plugin/src/ui/data-table/TableHead.tsx +++ b/desktop/flipper-plugin/src/ui/data-table/TableHead.tsx @@ -25,6 +25,7 @@ import {CaretDownFilled, CaretUpFilled} from '@ant-design/icons'; import {Layout} from '../Layout'; import {Sorting, SortDirection, DataTableDispatch} from './DataTableManager'; import {FilterButton, FilterIcon} from './ColumnFilter'; +import {toFirstUpper} from '../../utils/toFirstUpper'; const {Text} = Typography; @@ -187,7 +188,13 @@ function TableHeadColumn({ role="button" tabIndex={0}> - {column.title ?? <> } + {column.title === undefined ? ( + toFirstUpper(column.key) + ) : column.title === '' ? ( + <>  + ) : ( + column.title + )} diff --git a/desktop/flipper-plugin/src/utils/__tests__/createTablePlugin.node.tsx b/desktop/flipper-plugin/src/utils/__tests__/createTablePlugin.node.tsx new file mode 100644 index 000000000..190a2b360 --- /dev/null +++ b/desktop/flipper-plugin/src/utils/__tests__/createTablePlugin.node.tsx @@ -0,0 +1,100 @@ +/** + * 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 {createTablePlugin} from '../createTablePlugin'; +import {startPlugin} from '../../test-utils/test-utils'; + +const PROPS = { + method: 'method', + resetMethod: 'resetMethod', + columns: [], + renderSidebar: (_row: RowData) => {}, +}; + +type RowData = { + id: string; + value?: string; +}; + +test('createTablePlugin returns FlipperPlugin', () => { + const tablePlugin = createTablePlugin(PROPS); + const p = startPlugin(tablePlugin); + expect(Object.keys(p.instance)).toMatchInlineSnapshot(` + Array [ + "selection", + "rows", + "clear", + "tableManagerRef", + "connected", + "isPaused", + "resumePause", + ] + `); +}); + +test('createTablePlugin can add and reset data', () => { + const resetMethod = 'resetMethod'; + const tablePlugin = createTablePlugin({...PROPS, resetMethod}); + + const initialSnapshot = [{id: '1'}]; + + const p = startPlugin(tablePlugin, {initialState: {rows: initialSnapshot}}); + + expect(p.instance.rows.records()).toEqual(initialSnapshot); + + p.sendEvent('method', {id: '2'}); + expect(p.instance.rows.records()).toEqual([{id: '1'}, {id: '2'}]); + expect(p.exportState()).toEqual({rows: [{id: '1'}, {id: '2'}]}); + + p.sendEvent('resetMethod', {}); + expect(p.instance.rows.records()).toEqual([]); +}); + +test('createTablePlugin can add and reset data', () => { + const resetMethod = 'resetMethod'; + const tablePlugin = createTablePlugin({...PROPS, resetMethod}); + + const initialSnapshot = [{id: '1'}]; + + const p = startPlugin(tablePlugin, {initialState: {rows: initialSnapshot}}); + + expect(p.instance.rows.records()).toEqual(initialSnapshot); + + p.sendEvent('method', {id: '2'}); + expect(p.instance.rows.records()).toEqual([{id: '1'}, {id: '2'}]); + expect(p.exportState()).toEqual({rows: [{id: '1'}, {id: '2'}]}); + + // without key, this will append + p.sendEvent('method', {id: '1'}); + expect(p.instance.rows.records()).toEqual([{id: '1'}, {id: '2'}, {id: '1'}]); + + p.sendEvent('resetMethod', {}); + expect(p.instance.rows.records()).toEqual([]); +}); + +test('createTablePlugin can upsert data if key is set', () => { + const tablePlugin = createTablePlugin({...PROPS, key: 'id'}); + + const initialSnapshot = [{id: '1'}]; + + const p = startPlugin(tablePlugin, {initialState: {rows: initialSnapshot}}); + + expect(p.instance.rows.records()).toEqual(initialSnapshot); + + p.sendEvent('method', {id: '2'}); + expect(p.instance.rows.records()).toEqual([{id: '1'}, {id: '2'}]); + expect(p.exportState()).toEqual({rows: [{id: '1'}, {id: '2'}]}); + + // key set, so we can upsert + p.sendEvent('method', {id: '1', value: 'hi'}); + expect(p.instance.rows.records()).toEqual([ + {id: '1', value: 'hi'}, + {id: '2'}, + ]); +}); diff --git a/desktop/flipper-plugin/src/utils/createTablePlugin.tsx b/desktop/flipper-plugin/src/utils/createTablePlugin.tsx new file mode 100644 index 000000000..4456b7c36 --- /dev/null +++ b/desktop/flipper-plugin/src/utils/createTablePlugin.tsx @@ -0,0 +1,229 @@ +/** + * 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 { + DeleteOutlined, + PauseCircleOutlined, + PlayCircleOutlined, +} from '@ant-design/icons'; +import {Button, notification, Typography} from 'antd'; +import React, {createRef, useCallback} from 'react'; +import {PluginClient} from '../plugin/Plugin'; +import {usePlugin} from '../plugin/PluginContext'; +import {createState, useValue} from '../state/atom'; +import {createDataSource, DataSource} from '../state/DataSource'; +import {DataInspector} from '../ui/data-inspector/DataInspector'; +import {DataTable, DataTableColumn} from '../ui/data-table/DataTable'; +import {DataTableManager} from '../ui/data-table/DataTableManager'; +import {DetailSidebar} from '../ui/DetailSidebar'; +import {Layout} from '../ui/Layout'; +import {Panel} from '../ui/Panel'; + +function defaultRenderSidebar(record: T) { + return ( + + + + ); +} + +type PluginResult = { + plugin( + client: PluginClient>, + ): { + rows: DataSource; + }; + Component(): React.ReactElement; +}; + +/** + * createTablePlugin creates a Plugin class which handles fetching data from the client and + * displaying in in a table. The table handles selection of items and rendering a sidebar where + * more detailed information can be presented about the selected row. + * + * The plugin expects the be able to subscribe to the `method` argument and recieve either an array + * of data objects or a single data object. Each data object represents a row in the table which is + * build by calling the `buildRow` function argument. + * + * An optional resetMethod argument can be provided which will replace the current rows with the + * data provided. This is useful when connecting to Flipper for this first time, or reconnecting to + * the client in an unknown state. + */ +export function createTablePlugin(props: { + method: string; + resetMethod?: string; + columns: DataTableColumn[]; + renderSidebar?: (record: Row) => any; + key?: keyof Row; +}): PluginResult; +export function createTablePlugin< + Raw extends object, + Row extends object = Raw +>(props: { + buildRow: (record: Raw) => Row; + method: string; + resetMethod?: string; + columns: DataTableColumn[]; + renderSidebar?: (record: Row) => any; + key?: keyof Raw; +}): PluginResult; +export function createTablePlugin< + Raw extends object, + Method extends string, + ResetMethod extends string, + Row extends object = Raw +>(props: { + method: Method; + resetMethod?: ResetMethod; + columns: DataTableColumn[]; + renderSidebar?: (record: Row) => any; + buildRow?: (record: Raw) => Row; + key?: keyof Raw; +}) { + function plugin( + client: PluginClient & Record, {}>, + ) { + const rows = createDataSource([], { + persist: 'rows', + key: props.key, + }); + const selection = createState(undefined); + const isPaused = createState(false); + const tableManagerRef = createRef>(); + + client.onMessage(props.method, (event) => { + if (isPaused.get()) { + return; + } + const record = props.buildRow + ? props.buildRow(event) + : ((event as any) as Row); + if (props.key) { + rows.upsert(record); + } else { + rows.append(record); + } + }); + + if (props.resetMethod) { + client.onMessage(props.resetMethod, () => { + clear(); + }); + } + + // help plugin authors with finding out what the events and data shape is from the plugin + const unhandledMessagesSeen = new Set(); + client.onUnhandledMessage((message, params) => { + if (unhandledMessagesSeen.has(message)) { + return; + } + unhandledMessagesSeen.add(message); + notification.warn({ + message: 'Unhandled message: ' + message, + description: ( + +
{JSON.stringify(params, null, 2)}
+
+ ), + }); + }); + + client.addMenuEntry( + { + action: 'clear', + handler: clear, + }, + { + action: 'createPaste', + handler: createPaste, + }, + { + action: 'goToBottom', + handler: goToBottom, + }, + ); + + function clear() { + rows.clear(); + tableManagerRef.current?.clearSelection(); + } + + function createPaste() { + let selection = tableManagerRef.current?.getSelectedItems(); + if (!selection?.length) { + selection = rows.view.output(0, rows.view.size); + } + if (selection?.length) { + client.createPaste(JSON.stringify(selection, null, 2)); + } + } + + function goToBottom() { + tableManagerRef?.current?.selectItem(rows.view.size - 1); + } + + return { + selection, + rows, + clear, + tableManagerRef, + connected: client.connected, + isPaused, + resumePause() { + isPaused.update((v) => !v); + }, + }; + } + + function Component() { + const instance = usePlugin(plugin); + const paused = useValue(instance.isPaused); + const selection = useValue(instance.selection); + const connected = useValue(instance.connected); + + const handleSelect = useCallback((v) => instance.selection.set(v), [ + instance, + ]); + + return ( + + + columns={props.columns} + dataSource={instance.rows} + tableManagerRef={instance.tableManagerRef} + autoScroll + onSelect={handleSelect} + extraActions={ + connected ? ( + <> + + + + ) : undefined + } + /> + + {selection + ? props.renderSidebar?.(selection) ?? + defaultRenderSidebar(selection) + : null} + + + ); + } + + return {plugin, Component}; +} diff --git a/desktop/flipper-plugin/src/utils/toFirstUpper.tsx b/desktop/flipper-plugin/src/utils/toFirstUpper.tsx new file mode 100644 index 000000000..d2745fe07 --- /dev/null +++ b/desktop/flipper-plugin/src/utils/toFirstUpper.tsx @@ -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 + */ + +export function toFirstUpper(name: string): string { + if (!name) { + return name; + } + if (name.length === 1) { + return name.toUpperCase(); + } + return name[0].toUpperCase() + name.substr(1); +} diff --git a/docs/extending/flipper-plugin.mdx b/docs/extending/flipper-plugin.mdx index d488a98df..adaba46ac 100644 --- a/docs/extending/flipper-plugin.mdx +++ b/docs/extending/flipper-plugin.mdx @@ -54,6 +54,7 @@ A string that uniquely identifies the current application, is based on a combina A key that uniquely identifies this plugin instance, captures the current device/client/plugin combination. +#### `connected` #### `isConnected` Returns whether there is currently an active connection. This is true if: @@ -61,6 +62,9 @@ Returns whether there is currently an active connection. This is true if: 2. The client is still connected 3. The plugin is currently selected by the user _or_ the plugin is running in the background. +The `connected` field provides the atom, that can be used in combination with `useValue` to subscribe to future updates in a component. +In contrast, `isConnected` returns a boolean that merely captures the current state. + ### Events listeners #### `onMessage` @@ -99,7 +103,7 @@ export function plugin(client: PluginClient) { Usage: `client.onUnhandledMessage(callback: (event: string, params) => void)` -This method subscribe to all messages arriving from the devices which is not handled by an `onMessage` handler. +This method subscribe to all messages arriving from the devices which is not handled by an `onMessage` handler. This handler is untyped, and onMessage should be favored over using onUnhandledMessage if the event name is known upfront. #### `onActivate` @@ -149,8 +153,8 @@ Trigger when the users navigates to this plugin using a deeplink, either from an Usage: `client.onExport(callback: (idler, onStatusMessage) => Promise)` -Overrides the default serialization behavior of this plugin. Should return a promise with persistable state that is to be stored, or nothing at all. -This process is async, so it is possible to first fetch some additional state from the device. +Overrides the default serialization behavior of this plugin. Should return a promise with persistable state that is to be stored, or nothing at all. +This process is async, so it is possible to first fetch some additional state from the device. Serializable is defined as: non-cyclic data, consisting purely of primitive values, plain objects, arrays or Date, Set or Map objects. @@ -477,7 +481,7 @@ const rows = createState([], {persist: 'rows'}); const selectedID = createState(null, {persist: 'selection'}); // Listener will be called on each rows.set() and rows.update() call until unsubscribed. -const unsubscribe = rows.subscribe((value, prevValue) => { +const unsubscribe = rows.subscribe((value, prevValue) => { console.log(`Rows state updated. New length: ${value.length}. Prev length: ${prevValue.length}.`); }); rows.set(["hello"]) // Listener will be notified about the change @@ -493,12 +497,12 @@ console.log(rows.get().length) // 2 Usage: `createDataSource(initialSet?: T[], options?): DataSource` -Most Flipper plugins follow the basic concept of receiving events from the device, store them, and being able to tail, filter and search them. +Most Flipper plugins follow the basic concept of receiving events from the device, store them, and being able to tail, filter and search them. To optimise for this situation, there is a dedicated `createDataSource` abstraction which creates a `DataSource`. -`DataSource` is a data collection that is heavily optimized for `append` and `update`, -which stores items based on insertion order, but also allows for efficient by-id lookups. +`DataSource` is a data collection that is heavily optimized for `append` and `update`, +which stores items based on insertion order, but also allows for efficient by-id lookups. -Each `DataSource` exposes a `view` property, which contains a `DataSourceView`. +Each `DataSource` exposes a `view` property, which contains a `DataSourceView`. A `DataSourceView` is a materialized view which can be sorted, filtered and windowed, and will be kept incrementally up to date with the underlying `DataSource`. When using the `DataTable` component, this `view` will be managed by the table automatically, giving plugin users the capability to freely sort, filter, search and tail your datasource. @@ -532,7 +536,7 @@ export function devicePlugin(client: DevicePluginClient) { ### DataSource -Stores large amounts of records efficiently. See [`createDataSource`](#createdatasource) for an introduction. +Stores large amounts of records efficiently. See [`createDataSource`](#createdatasource) for an introduction. #### limit @@ -605,11 +609,11 @@ Usage: `fork(): DataSourceView`. Creates an additional materialized view on this ### DataSourceView -A materialized view on a DataSource, which can apply windowing, sorting and filtering and will be kept incrementally up to date with the underlying datasource. -Note that the default window is empty, so after obtaining a `DataSourceView` one should typically call `setWindow`. -See [`createDataSource`](#createdatasource) for an introduction. +A materialized view on a DataSource, which can apply windowing, sorting and filtering and will be kept incrementally up to date with the underlying datasource. +Note that the default window is empty, so after obtaining a `DataSourceView` one should typically call `setWindow`. +See [`createDataSource`](#createdatasource) for an introduction. -The DataSourceView API is important if are creating your own visualization of a `DataSource`. +The DataSourceView API is important if are creating your own visualization of a `DataSource`. However, if a `DataSource` is visualized using a `DataTable`, there is typically no need to directly interact with this API. #### datasource @@ -646,13 +650,13 @@ Usage: `output(): T[]` or `output(start, end): T[]`. Returns a defensive copy of #### [Symbol.iterator] -`DataSourceView` supports the iterator protocol, so the currently visible output can be iterated using for example `for (const user in users.view) { ... }`. The iterator will always apply the current window. +`DataSourceView` supports the iterator protocol, so the currently visible output can be iterated using for example `for (const user in users.view) { ... }`. The iterator will always apply the current window. #### setWindow -Usage: `setWindow(start, end)`. This method sets the current visible window to the specified range (which will include `start`, but not `end`, so `[start, end)`). +Usage: `setWindow(start, end)`. This method sets the current visible window to the specified range (which will include `start`, but not `end`, so `[start, end)`). -Setting a window impacts the default behavior of `output` and `iterator` and, more importantly, the behavior of any listener: `update` events that happen outside the window will not be propagated to any listeners, and `shift` events will describe whether the happened `in`, `before`, or `after` the current window. +Setting a window impacts the default behavior of `output` and `iterator` and, more importantly, the behavior of any listener: `update` events that happen outside the window will not be propagated to any listeners, and `shift` events will describe whether the happened `in`, `before`, or `after` the current window. Windowing will always be applied only after applying any filters, sorting and reversing. @@ -662,13 +666,13 @@ Usage: `setFilter(filter: (record: T) => boolean)`. Applies a filter to the curr #### setSortBy -Usage: `setSortBy(field: string)` or `setSortBy(sortBy: (irecord: T) => primitive)`. For example: `users.view.setSortBy("age")` or `users.viewSetSortBy(user => `${user.lastName} ${user.firstName}`)`. `setSortBy` will cause the data source to be sorted by the given field or criterium function. Sort is implemented efficiently by using a binary search to insert / remove newly arriving records, rather than performing a full sort. But this means that the sort function should be stable and pure. +Usage: `setSortBy(field: string)` or `setSortBy(sortBy: (irecord: T) => primitive)`. For example: `users.view.setSortBy("age")` or `users.viewSetSortBy(user => `${user.lastName} ${user.firstName}`)`. `setSortBy` will cause the data source to be sorted by the given field or criterium function. Sort is implemented efficiently by using a binary search to insert / remove newly arriving records, rather than performing a full sort. But this means that the sort function should be stable and pure. Sorting will always happen in ascending order, and if duplicate sort values appear, the insertion order will take precedence. To sort in descending order, use `setReversed`. If a view doesn't have sorting specified, it will always show records in insertion order. #### toggleRevered -Usage: `toggleReversed()`. Toggles the output order between ascending and descending. +Usage: `toggleReversed()`. Toggles the output order between ascending and descending. #### setReversed @@ -684,7 +688,7 @@ Usage: `get(index: number)`. Returns the record at the given position in the out #### setListener -Usage: `setListener(callback: undefined | (event: OutputChange) => void)`. Sets up a listener that will get notified whenever the `output` or `size` of this view changes. This can be used to, for example, update the UI and is used by `DataTable` under the hood. +Usage: `setListener(callback: undefined | (event: OutputChange) => void)`. Sets up a listener that will get notified whenever the `output` or `size` of this view changes. This can be used to, for example, update the UI and is used by `DataTable` under the hood. The following events can be emitted. These events respect the current sorting, filtering and reversing. The shift `location` is expressed relatively to the current window. Now `update` events that are outside the current window will be emitted. `reset` events are typically emitted if a change happened that cannot be expressed in a limited amount of shifts / updates. Such as changing sorting or filtering, calling `clear()` or `reset()`, or doing a large `shift`. Currently only one listener is allowed at a time. Please contact the Flipper oncall if that doesn't suffice. @@ -696,7 +700,7 @@ type OutputChange = index: number; // the position at which records were inserted or removed location: 'before' | 'in' | 'after'; // relative to current window delta: number; // how many records were inserted (postive number) or removed (negative number) - newCount: number; // the new .size of the DataSourceView + newCount: number; // the new .size of the DataSourceView } | { // an item, inside the current window, was changed @@ -758,9 +762,9 @@ interface Logger { Usage: `const eventHandler = useTrackedCallback("Interaction description", handlerFunction, deps)` -Utility that wraps React's `useCallback` with tracking capabilities. +Utility that wraps React's `useCallback` with tracking capabilities. The API is similar, except that the first argument describes the interaction handled by the given event handler. -See [Tracked](#tracked) for more info. +See [Tracked](#tracked) for more info. ### useMemoize @@ -788,7 +792,7 @@ export function findMetroDevice(findMetroDevice, deviceList) { ### useLocalStorageState -Like `useState`, but the value will be stored in local storage under the given key, and read back upon initialization. +Like `useState`, but the value will be stored in local storage under the given key, and read back upon initialization. The hook signature is similar to `useState`, except that the first argument is the storage key. The storage key will be scoped automatically to the current plugin and any additional tracking scopes. (See [`TrackingScope`](#trackingscope)). @@ -803,7 +807,7 @@ const [showWhitespace, setShowWhitespace] = useLocalStorageState( ### Layout.* -Layout elements can be used to organize the screen layout. +Layout elements can be used to organize the screen layout. See `View > Flipper Style Guide` inside the Flipper application for more details. ### DataTable @@ -860,7 +864,7 @@ See `View > Flipper Style Guide` inside the Flipper application for more details ### DetailSidebar An element that can be passed children which will be shown in the right sidebar of Flipper. -Horizontal scrolling will be enabled by default. +Horizontal scrolling will be enabled by default. To fine-tune the default dimensions use `width` and `minWidth`. It doesn't really matter where exactly this component is used in your layout, as the contents will be moved to the main Flipper chrome, rather than being rendered in place. @@ -868,7 +872,7 @@ It doesn't really matter where exactly this component is used in your layout, as ### Tracked -An element that can be used to track user interactions. +An element that can be used to track user interactions. An example scuba query can be found [here](https://fburl.com/scuba/infinity_analytics_events/xryoq5j7). See `View > Flipper Style Guide` inside the Flipper application for more details. @@ -892,25 +896,29 @@ See `View > Flipper Style Guide` inside the Flipper application for more details ## Utilities +### createTablePlugin + +Utility to create a plugin that consists of a master table and details json view with minimal effort. See [../tutorial/js-table.mdx](Showing a table) for more details. + ### batch Usage: `batch(() => { /* state updates */ })` -Low-level utility to batch state updates to reduce the amount of potential re-renders by React. -Wraps React's `unstable_batchedUpdates`. -Event handlers provided by React or `flipper-plugin` already apply `batch` automatically, so using this utility is only recommended when updating plugin state in an asynchronous process. +Low-level utility to batch state updates to reduce the amount of potential re-renders by React. +Wraps React's `unstable_batchedUpdates`. +Event handlers provided by React or `flipper-plugin` already apply `batch` automatically, so using this utility is only recommended when updating plugin state in an asynchronous process. ### produce A convenience re-export of `produce` from [Immer](https://immerjs.github.io/immer/docs/produce). -The `update` method of the state atoms returned by `createState` automatically applies `produce` to its updater function. +The `update` method of the state atoms returned by `createState` automatically applies `produce` to its updater function. ### renderReactRoot Usage: `renderReactRoot(handler: (unmount: () => void) => React.ReactElement)` -Renders an element outside the current DOM tree. -This is a low-level utility that can be used to render for example Modal dialogs. +Renders an element outside the current DOM tree. +This is a low-level utility that can be used to render for example Modal dialogs. The provided `handler` function should return the root element to be rendered. Once the element can be removed from the DOM, the `unmount` callback should be called. Example: @@ -934,7 +942,7 @@ Creates a promise that automatically resolves after the specified amount of mill ## styled -A convenience re-export of `styled` from [emotion](https://emotion.sh/docs/styled). +A convenience re-export of `styled` from [emotion](https://emotion.sh/docs/styled). ## TestUtils