Introduce createTablePlugin

Summary: This diff exposes the createTablePlugin from flipper-plugin, so that createTablePlugin based plugins can be converted to Sandy as well

Reviewed By: jknoxville

Differential Revision: D28031227

fbshipit-source-id: 8e9c82da08a83fddab740b46be9917b6a1023117
This commit is contained in:
Michel Weststrate
2021-04-28 12:27:31 -07:00
committed by Facebook GitHub Bot
parent cf2405a466
commit 05bf55419f
11 changed files with 422 additions and 43 deletions

View File

@@ -62,6 +62,8 @@ type State = {
* An optional resetMethod argument can be provided which will replace the current rows with the * 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 * data provided. This is useful when connecting to Flipper for this first time, or reconnecting to
* the client in an unknown state. * the client in an unknown state.
*
* @deprecated use createTablePlugin from flipper-plugin instead
*/ */
export function createTablePlugin<T extends RowData>(props: Props<T>) { export function createTablePlugin<T extends RowData>(props: Props<T>) {
return class extends FlipperPlugin<State, any, PersistedState<T>> { return class extends FlipperPlugin<State, any, PersistedState<T>> {

View File

@@ -49,6 +49,7 @@ test('Correct top level API exposed', () => {
"batch", "batch",
"createDataSource", "createDataSource",
"createState", "createState",
"createTablePlugin",
"produce", "produce",
"renderReactRoot", "renderReactRoot",
"sleep", "sleep",

View File

@@ -120,6 +120,8 @@ export {
} from './ui/elements-inspector/ElementsInspector'; } from './ui/elements-inspector/ElementsInspector';
export {useMemoize} from './utils/useMemoize'; export {useMemoize} from './utils/useMemoize';
export {createTablePlugin} from './utils/createTablePlugin';
// It's not ideal that this exists in flipper-plugin sources directly, // It's not ideal that this exists in flipper-plugin sources directly,
// but is the least pain for plugin authors. // 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) // Probably we should make sure that testing-library doesn't end up in our final Flipper bundle (which packages flipper-plugin)

View File

@@ -12,7 +12,7 @@ import {BasePluginInstance, BasePluginClient} from './PluginBase';
import {FlipperLib} from './FlipperLib'; import {FlipperLib} from './FlipperLib';
import {RealFlipperDevice} from './DevicePlugin'; import {RealFlipperDevice} from './DevicePlugin';
import {batched} from '../state/batch'; import {batched} from '../state/batch';
import {Atom, createState} from '../state/atom'; import {Atom, createState, ReadOnlyAtom} from '../state/atom';
type EventsContract = Record<string, any>; type EventsContract = Record<string, any>;
type MethodsContract = Record<string, (params: any) => Promise<any>>; type MethodsContract = Record<string, (params: any) => Promise<any>>;
@@ -40,6 +40,7 @@ export interface PluginClient<
readonly appName: string; readonly appName: string;
readonly isConnected: boolean; readonly isConnected: boolean;
readonly connected: ReadOnlyAtom<boolean>;
/** /**
* the onConnect event is fired whenever the plugin is connected to it's counter part on the device. * 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() { get appName() {
return realClient.query.app; return realClient.query.app;
}, },
connected: self.connected,
get isConnected() { get isConnected() {
return self.connected.get(); return self.connected.get();
}, },

View File

@@ -13,13 +13,16 @@ import {Persistable, registerStorageAtom} from '../plugin/PluginBase';
enableMapSet(); enableMapSet();
export type Atom<T> = { export interface ReadOnlyAtom<T> {
get(): T; get(): T;
set(newValue: T): void;
update(recipe: (draft: Draft<T>) => void): void;
subscribe(listener: (value: T, prevValue: T) => void): () => void; subscribe(listener: (value: T, prevValue: T) => void): () => void;
unsubscribe(listener: (value: T, prevValue: T) => void): void; unsubscribe(listener: (value: T, prevValue: T) => void): void;
}; }
export interface Atom<T> extends ReadOnlyAtom<T> {
set(newValue: T): void;
update(recipe: (draft: Draft<T>) => void): void;
}
class AtomValue<T> implements Atom<T>, Persistable { class AtomValue<T> implements Atom<T>, Persistable {
value: T; value: T;
@@ -93,9 +96,15 @@ export function createState(
return atom; return atom;
} }
export function useValue<T>(atom: Atom<T>): T; export function useValue<T>(atom: ReadOnlyAtom<T>): T;
export function useValue<T>(atom: Atom<T> | undefined, defaultValue: T): T; export function useValue<T>(
export function useValue<T>(atom: Atom<T> | undefined, defaultValue?: T): T { atom: ReadOnlyAtom<T> | undefined,
defaultValue: T,
): T;
export function useValue<T>(
atom: ReadOnlyAtom<T> | undefined,
defaultValue?: T,
): T {
const [localValue, setLocalValue] = useState<T>( const [localValue, setLocalValue] = useState<T>(
atom ? atom.get() : defaultValue!, atom ? atom.get() : defaultValue!,
); );

View File

@@ -19,6 +19,7 @@ import React from 'react';
import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib'; import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib';
import {DataTableColumn} from './DataTable'; import {DataTableColumn} from './DataTable';
import {DataSource} from '../../state/DataSource'; import {DataSource} from '../../state/DataSource';
import {toFirstUpper} from '../../utils/toFirstUpper';
const {Item, SubMenu} = Menu; const {Item, SubMenu} = Menu;
@@ -137,5 +138,5 @@ export function tableContextMenuFactory<T>(
function friendlyColumnTitle(column: DataTableColumn<any>): string { function friendlyColumnTitle(column: DataTableColumn<any>): string {
const name = column.title || column.key; const name = column.title || column.key;
return name[0].toUpperCase() + name.substr(1); return toFirstUpper(name);
} }

View File

@@ -25,6 +25,7 @@ import {CaretDownFilled, CaretUpFilled} from '@ant-design/icons';
import {Layout} from '../Layout'; import {Layout} from '../Layout';
import {Sorting, SortDirection, DataTableDispatch} from './DataTableManager'; import {Sorting, SortDirection, DataTableDispatch} from './DataTableManager';
import {FilterButton, FilterIcon} from './ColumnFilter'; import {FilterButton, FilterIcon} from './ColumnFilter';
import {toFirstUpper} from '../../utils/toFirstUpper';
const {Text} = Typography; const {Text} = Typography;
@@ -187,7 +188,13 @@ function TableHeadColumn({
role="button" role="button"
tabIndex={0}> tabIndex={0}>
<Text type="secondary"> <Text type="secondary">
{column.title ?? <>&nbsp;</>} {column.title === undefined ? (
toFirstUpper(column.key)
) : column.title === '' ? (
<>&nbsp;</>
) : (
column.title
)}
<SortIcons <SortIcons
direction={sorted} direction={sorted}
onSort={(dir) => onSort={(dir) =>

View File

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

View File

@@ -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<T>(record: T) {
return (
<Panel title="Payload" collapsible={false} pad>
<DataInspector data={record} expandRoot />
</Panel>
);
}
type PluginResult<Raw, Row> = {
plugin(
client: PluginClient<Record<string, Raw | {}>>,
): {
rows: DataSource<Row>;
};
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<Row extends object>(props: {
method: string;
resetMethod?: string;
columns: DataTableColumn<Row>[];
renderSidebar?: (record: Row) => any;
key?: keyof Row;
}): PluginResult<Row, Row>;
export function createTablePlugin<
Raw extends object,
Row extends object = Raw
>(props: {
buildRow: (record: Raw) => Row;
method: string;
resetMethod?: string;
columns: DataTableColumn<Row>[];
renderSidebar?: (record: Row) => any;
key?: keyof Raw;
}): PluginResult<Raw, Row>;
export function createTablePlugin<
Raw extends object,
Method extends string,
ResetMethod extends string,
Row extends object = Raw
>(props: {
method: Method;
resetMethod?: ResetMethod;
columns: DataTableColumn<Row>[];
renderSidebar?: (record: Row) => any;
buildRow?: (record: Raw) => Row;
key?: keyof Raw;
}) {
function plugin(
client: PluginClient<Record<Method, Raw> & Record<ResetMethod, {}>, {}>,
) {
const rows = createDataSource<Row>([], {
persist: 'rows',
key: props.key,
});
const selection = createState<undefined | Row>(undefined);
const isPaused = createState(false);
const tableManagerRef = createRef<undefined | DataTableManager<Row>>();
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<string>();
client.onUnhandledMessage((message, params) => {
if (unhandledMessagesSeen.has(message)) {
return;
}
unhandledMessagesSeen.add(message);
notification.warn({
message: 'Unhandled message: ' + message,
description: (
<Typography.Paragraph>
<pre>{JSON.stringify(params, null, 2)}</pre>
</Typography.Paragraph>
),
});
});
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 (
<Layout.Container grow>
<DataTable<Row>
columns={props.columns}
dataSource={instance.rows}
tableManagerRef={instance.tableManagerRef}
autoScroll
onSelect={handleSelect}
extraActions={
connected ? (
<>
<Button
title={`Click to ${paused ? 'resume' : 'pause'} the stream`}
danger={paused}
onClick={instance.resumePause}>
{paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
</Button>
<Button title="Clear records" onClick={instance.clear}>
<DeleteOutlined />
</Button>
</>
) : undefined
}
/>
<DetailSidebar>
{selection
? props.renderSidebar?.(selection) ??
defaultRenderSidebar(selection)
: null}
</DetailSidebar>
</Layout.Container>
);
}
return {plugin, Component};
}

View File

@@ -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);
}

View File

@@ -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. A key that uniquely identifies this plugin instance, captures the current device/client/plugin combination.
#### `connected`
#### `isConnected` #### `isConnected`
Returns whether there is currently an active connection. This is true if: 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 2. The client is still connected
3. The plugin is currently selected by the user _or_ the plugin is running in the background. 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 ### Events listeners
#### `onMessage` #### `onMessage`
@@ -99,7 +103,7 @@ export function plugin(client: PluginClient<Events, {}>) {
Usage: `client.onUnhandledMessage(callback: (event: string, params) => void)` 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. This handler is untyped, and onMessage should be favored over using onUnhandledMessage if the event name is known upfront.
#### `onActivate` #### `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<state>)` Usage: `client.onExport(callback: (idler, onStatusMessage) => Promise<state>)`
Overrides the default serialization behavior of this plugin. Should return a promise with persistable state that is to be stored, or nothing at all. 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. 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. 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<string[]>([], {persist: 'rows'});
const selectedID = createState<string | null>(null, {persist: 'selection'}); const selectedID = createState<string | null>(null, {persist: 'selection'});
// Listener will be called on each rows.set() and rows.update() call until unsubscribed. // 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}.`); console.log(`Rows state updated. New length: ${value.length}. Prev length: ${prevValue.length}.`);
}); });
rows.set(["hello"]) // Listener will be notified about the change rows.set(["hello"]) // Listener will be notified about the change
@@ -493,12 +497,12 @@ console.log(rows.get().length) // 2
Usage: `createDataSource<T>(initialSet?: T[], options?): DataSource<T>` Usage: `createDataSource<T>(initialSet?: T[], options?): DataSource<T>`
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`. 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`, `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. 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`. 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. 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 ### 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 #### limit
@@ -605,11 +609,11 @@ Usage: `fork(): DataSourceView`. Creates an additional materialized view on this
### DataSourceView ### 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. 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`. Note that the default window is empty, so after obtaining a `DataSourceView` one should typically call `setWindow`.
See [`createDataSource`](#createdatasource) for an introduction. 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. However, if a `DataSource` is visualized using a `DataTable`, there is typically no need to directly interact with this API.
#### datasource #### datasource
@@ -646,13 +650,13 @@ Usage: `output(): T[]` or `output(start, end): T[]`. Returns a defensive copy of
#### [Symbol.iterator] #### [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 #### 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. 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 #### 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. 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 #### toggleRevered
Usage: `toggleReversed()`. Toggles the output order between ascending and descending. Usage: `toggleReversed()`. Toggles the output order between ascending and descending.
#### setReversed #### setReversed
@@ -684,7 +688,7 @@ Usage: `get(index: number)`. Returns the record at the given position in the out
#### setListener #### 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`. 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. 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 index: number; // the position at which records were inserted or removed
location: 'before' | 'in' | 'after'; // relative to current window location: 'before' | 'in' | 'after'; // relative to current window
delta: number; // how many records were inserted (postive number) or removed (negative number) 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 // an item, inside the current window, was changed
@@ -758,9 +762,9 @@ interface Logger {
Usage: `const eventHandler = useTrackedCallback("Interaction description", handlerFunction, deps)` 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. 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 ### useMemoize
@@ -788,7 +792,7 @@ export function findMetroDevice(findMetroDevice, deviceList) {
### useLocalStorageState ### 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 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)). 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.*
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. See `View > Flipper Style Guide` inside the Flipper application for more details.
### DataTable ### DataTable
@@ -860,7 +864,7 @@ See `View > Flipper Style Guide` inside the Flipper application for more details
### DetailSidebar ### DetailSidebar
An element that can be passed children which will be shown in the right sidebar of Flipper. 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`. 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. 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 ### 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). 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. 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 ## 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 ### batch
Usage: `batch(() => { /* state updates */ })` Usage: `batch(() => { /* state updates */ })`
Low-level utility to batch state updates to reduce the amount of potential re-renders by React. Low-level utility to batch state updates to reduce the amount of potential re-renders by React.
Wraps React's `unstable_batchedUpdates`. 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. 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 ### produce
A convenience re-export of `produce` from [Immer](https://immerjs.github.io/immer/docs/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 ### renderReactRoot
Usage: `renderReactRoot(handler: (unmount: () => void) => React.ReactElement)` Usage: `renderReactRoot(handler: (unmount: () => void) => React.ReactElement)`
Renders an element outside the current DOM tree. Renders an element outside the current DOM tree.
This is a low-level utility that can be used to render for example Modal dialogs. 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. 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. Once the element can be removed from the DOM, the `unmount` callback should be called.
Example: Example:
@@ -934,7 +942,7 @@ Creates a promise that automatically resolves after the specified amount of mill
## styled ## 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 ## TestUtils