Moved docs outside FB Internal

Summary: Moved docs outside of FBInternal docs. Will make Sandy APIs the default in the docs soon, and move the links to the proper location, but this at least makes sure we can already link to the correct place in WP announcements

Reviewed By: jknoxville

Differential Revision: D24829287

fbshipit-source-id: 913f2db65a7cdd04bd1be47aebc48ece7a6cef04
This commit is contained in:
Michel Weststrate
2020-11-09 08:14:54 -08:00
committed by Facebook GitHub Bot
parent 670be012b2
commit 29e3d80669
3 changed files with 819 additions and 3 deletions

View File

@@ -0,0 +1,423 @@
---
id: desktop-plugins
title: Creating Desktop Plugin (Sandy)
---
import useBaseUrl from '@docusaurus/useBaseUrl';
## What is new in Sandy plugins?
_(This section is only relevant if you authored Flipper plugins before)_
"Sandy" is the new standard for writing Flipper Desktop plugins.
It is a fresh way of writing plugins that comes in two parts:
1. New plugin APIs and a simplified plugin life-cycle
2. A standardized component library based on [Ant Design](https://ant.design/) [TBD]
The Sandy plan, goals and timelines are described in detail in this [quip](https://fb.quip.com/YHOGAnaPqAVJ) [FB-only].
But the high-level goal is to improve reliability, consistency and maintainability of Flipper plugins.
It offers the following benefits to plugin authors:
* Sandy plugins decouple the plugin state, logic and life-cycle from the UI life-cycle, which simplifies testing, and makes it trivial to maintain state over time.
* Sandy is designed with a testing-first approach in mind. It allows mocking all Device / Client ⟷ Plugin interactions.
* By leveraging Ant Design, all components are well documented, discoverable and consistent, leading to a much better user- and developer experience.
* Sandy plugins can be tested stand-alone, which is currently not the case for OSS plugins.
* Sandy plugins are entirely strongly typed, and generally offer one-way of doing things, unlike the current APIs.
* Sandy will be compatible with concurrent React.
* Sandy will make very common scenarios trivial, such as showing an append-only event log.
* Sandy fixes several performance bottlenecks that are rooted in our current API design.
## Opting in to Sandy
Since Sandy is still experimental, the public and FB plugin scaffolding tools still use the old APIs.
You can opt-in to using Sandy by adding `flipper-plugin` as peer dependency to your plugin.
`yarn add --peer flipper-plugin`
If your project is not hosted inside the Flipper project itself, you need to add `flipper-plugin` as developer dependency as well:
`yarn add --dev flipper-plugin`
## Anatomy of Sandy plugin
A sandy plugin always exposes two elements from its entry module (typically `src/index.tsx`): `plugin` and `Component`:
```typescript
import {PluginClient} from 'flipper-plugin';
export function plugin(client: PluginClient) {
return {}; // API exposed from this plugin
}
export function Component() {
// Plugin UI
return <h1>Welcome to my first plugin</h1>;
}
```
The concepts of `plugin` and `Component` are explained below. Note that these names have to be exact!
### Creating a Device Plugin
Flipper also supports so-called device plugins - plugins that are available for an entire device - but don't receive a connection to a running app,
so are a bit more limited in general.
Their entry module anatomy is:
```typescript
import {DevicePluginClient} from 'flipper-plugin';
export function supportsDevice(device: Device) {
// based on the device meta-data,
// determine whether this plugin should be enabled
return true;
}
export function devicePlugin(client: DevicePluginClient) {
return {}; // API exposed from this plugin
}
export function Component() {
// Plugin UI
return <h1>Welcome to my first plugin</h1>;
}
```
Device plugins work in general similar to normal client plugins, so aren't worked out in detail in this document.
The available APIs for device plugins are listed [here](./flipper-plugin#devicepluginclient).
## Creating a first plugin
_This tutorial is analogous to the original [Building a Desktop Plugin tutorial](../tutorial/js-setup), and the plugin scaffolding and loading steps from that tutorial should be followed first_
The sea-mammals plugin is a minimal plugin that shows some data fetched from the [Tutorial application](../tutorial/ios).
The end-state of our plugin can be found in the [Github Repo](https://github.com/facebook/flipper/tree/master/desktop/plugins/seamammals).
<img alt="Custom cards UI for our sea mammals plugin" src={useBaseUrl("img/js-custom.png")} />
Let's start by setting up our plugin initialization. We update the earlier generated `index.tsx` to:
```typescript
import React from 'react';
import {PluginClient, createState} from 'flipper-plugin';
// (3)
type Row = {
id: number;
title: string;
url: string;
};
// (2)
type Events = {
newRow: Row;
};
// (1)
export function plugin(client: PluginClient<Events, {}>) {
// (5)
const rows = createState<Record<string, Row>>({}, {persist: 'rows'});
const selectedID = createState<string | null>(null, {persist: 'selection'});
// (6)
client.onMessage('newRow', (row) => {
rows.update((draft) => {
draft[row.id] = row;
});
});
// (7)
function setSelection(id: number) {
selectedID.set('' + id);
}
// (4)
return {
rows,
selectedID,
setSelection,
};
}
export function Component() {
return <h1>Sea Mammals plugin</h1>;
}
```
## The `plugin` declaration
The implementation of our plugin is driven by the named, exported function `plugin` as defined at `(3)`.
The `plugin` method is called upon instantiating the plugin.
That is, whenever a client that uses this plugin connects to Flipper, `plugin` will be called, if the user has the plugin enabled.
The `plugin` method receives one argument, the `client`, which provides all APIs needed to both interact with Flipper desktop,
and the plugin loaded into the client application.
The `PluginClient` types all available APIs and takes two generic arguments.
The first, `Events`, describes all possible events that can be sent from the client plugin to the desktop plugin,
and determines the events available for `client.onMessage` (see below).
In our example, only one event can occur, `newRow`, as defined at `(2)`.
But typically there are more.
The data provided by this `newRow` event is described with the `Row` type, as defined at `(3)`.
The event names and data structures should correspond with the data that is send using [`connection.send`](../extending/create-plugin#push-data-to-the-desktop) from the client.
The second generic argument to `PluginClient` is typically called `Methods`, and describes the methods exposed by the client plugin,
which we can call directly from the desktop plugin.
These methods take zero or one argument, and return a Promise.
The name, argument and return type should correspond to the data structures we handle in [`connection.receive`](../extending/create-plugin#using-flipperconnection) in the client plugin.
In our example plugin, we aren't supporting any methods so the second generic of `PluginClient` is left empty: `{}`.
From our `plugin` function, as shown at `(4)`, we have to return an object that captures the entire API we want to expose from our plugin to our UI components and unit tests.
In this case, we return the state atoms `rows` and `selectedID`, and expose the `setSelection` method.
## Writing `plugin` logic
Since the `plugin` function will execute only once during the entire life-cycle of the plugin, we can use local variables in the function body to preserve state.
In our example, we create two pieces of state, the set of rows available, `rows`, and the current selection: `selectionID`. See `(5)`.
It is possible to store state directly in `let` declarations, but `createState` creates a storage container that gives us a few advantages.
Most importantly, state created using `createState` can be subscribed to by our UI components using `useValue`, as explained below.
Secondly, state created with `createState` can be made part of Flipper imports / exports.
We can enable this feature by providing a unique `persist` key.
The current value of a the container can be read using `.get()`, and `.set()` or `.update()` can be used to replace the current value.
The `client` can be used to receive and send information to the client plugin.
With `client.send`, we can invoke methods on the plugin.
With `client.onMessage` (`(6)`) we can subscribe to the specific events as specified with the `Events` type (`(2)`).
In the event handler, we can update some pieces of state, using the `.set` method to replace state, or the `.update` method to immutably update the state using [immer](https://immerjs.github.io/immer).
In this case, we add the received row to the `rows` state under its own `id`.
Finally, `(7)`, we create (and expose at `(4)`) a utility to update the selection, which we will user later in our UI.
Note that no state should be stored outside the `plugin` definition; multiple invocations of `plugin` can be 'alive' if multiple connected apps are using the plugin.
Storing the state inside the closure makes sure no state is mixed up.
### Testing `plugin` logic
Before we create the UI for our plugin, we are going to pretend that we always write unit tests first.
Unit tests will be picked automatically by Jest if they are named like `__tests__/*.spec.tsx`, so we create a file called `__tests__/seamammals.spec.tsx` and start the test runner by
running `yarn test --watch` in our plugin root.
Here is our initial unit test:
```typescript
// (1)
import {TestUtils} from 'flipper-plugin';
// (2)
import * as MammalsPlugin from '..';
test('It can store rows', () => {
// (3)
const {instance, sendEvent} = TestUtils.startPlugin(MammalsPlugin);
expect(instance.rows.get()).toEqual({});
expect(instance.selectedID.get()).toBeNull();
// (4)
sendEvent('newRow', {
id: 1,
title: 'Dolphin',
url: 'http://dolphin.png',
});
sendEvent('newRow', {
id: 2,
title: 'Turtle',
url: 'http://turtle.png',
});
// (5)
expect(instance.rows.get()).toMatchInlineSnapshot(`
Object {
"1": Object {
"id": 1,
"title": "Dolphin",
"url": "http://dolphin.png",
},
"2": Object {
"id": 2,
"title": "Turtle",
"url": "http://turtle.png",
},
}
`);
});
```
Testing utilities for plugins are shipped as part of `flipper-plugin`, so we import them (`(1)`).
Secondly, we directly import our above plugin implementation into our unit test.
Using `as`, we put the entire implementation into one object, which is the format in which our utilities expect them (`(2)`).
Using `TestUtils.startPlugin` (`(3)`) we can instantiate our plugin in a fully mocked environment,
in which our plugin can do everything except for actually rendering, which makes this operation really cheap.
From the `startPlugin`, we get back an `instance`, which corresponds to the object we returned from our `plugin` implementation (`(4)` in our previous listing).
Beyond that, we get a bunch of utilities to interact with our plugin.
The full list is documented [here](./flipper-plugin#the-test-runner-object), but for this test we are only interested in `sendEvent`.
Using `sendEvent`, we can mimic the client plugin sending events to our plugin `(4)`.
Similarly we can emulate all other possible events, such as the initial connection setup with (`.connect()`), the user (de)selecting the plugin (`.activate()` / `deactivate()`), or a deeplink being triggered (`.triggerDeepLink`) etc.
After the events have been sent, the internal state of our plugin should have been updated, so we assert this is the case at `(5)`.
The assertions are provided by [Jest](https://jestjs.io/), and `toMatchInlineSnapshot` is particularly useful, as it will generate the initial snapshot during the first run of the unit tests, which saves a lot of effort.
## Building a User Interface for the plugin
_For now, the plugin implementation as shown here uses the old Flipper component library, expect nicer components in the future._
So far, in `index.tsx`, our `Component` didn't do anything useful yet. Time to build some nice UI.
```typescript
import {usePlugin, useValue} from "flipper-plugin";
// (1)
export function Component() {
// (2)
const instance = usePlugin(plugin);
// (3)
const rows = useValue(instance.rows);
const selectedID = useValue(instance.selectedID);
// (4)
return (
<Container>
{Object.entries(rows).map(([id, row]) => (
<Card
row={row}
onSelect={instance.setSelection}
selected={id === selectedID}
key={id}
/>
))}
<DetailSidebar>
{selectedID && renderSidebar(rows[selectedID])}
</DetailSidebar>
</Container>
);
}
```
A plugin module can have many components, but it should always export one component named `Component` that is used as the root component for the plugin rendering.
The component mustn't take any props, and will be mounted by Flipper when the user selects the plugin (`(1)`).
Inside the component we can grab the relevant instance of the plugin by using the `usePlugin` (`(2)`) hook.
This returns the instance API we returned in the first listing at the end of the `plugin` function.
Our original `plugin` definition is passed to the `usePlugin` as argument.
This is done to get the typings of `instance` correct and should always be done.
With the `useValue` hook (`(3)`), we can grab the current value from the states we created earlier using `createState`.
The benefit of `useValue(instance.rows)` over using `rows.get()`, is that the first will automatically subscribe our component to any future updates to the state, causing the component to re-render when new rows arrive.
Since both `usePlugin` and `useValue` are hooks, they usual React rules for them apply; they need to be called unconditionally.
So it is recommended to put them at the top of your component body.
Both hooks can not only be used in the root `Component`, but also in any other component in your plugin component tree.
So it is not necessary to grab all the data at the root, or pass down the `instance` to all child components.
Finally (`(4)`) we render the data we have. The details have been left out here, as from here it is just idiomatic React code.
The source of the other components can be found [here](https://github.com/facebook/flipper/blob/95ae98d925e27e1c9ce0706e3c7ded78eba7c13c/desktop/plugins/seamammals/src/index.tsx#L113-L165).
### Unit testing the User Interface
At this moment the plugin is ready to be used in Flipper, and opening it should lead to sane results.
But let's verify with some tests that the UI works correctly, and doesn't regress in the future by adding another unit test to the `seamammals.spec.tsx` file and assert that the rendering is correct and interactive:
_Note: until the new component library is standardized, some additional mocks that are left out here are needed as shown in the [real test implementation](https://github.com/facebook/flipper/blob/a994683b2ecc602e9a7aff18fb72d552c6893283/desktop/plugins/seamammals/src/__tests__/seammammals.node.tsx#L15-L37)_
```typescript
test('It can have selection and render details', async () => {
// (1)
const {
instance,
renderer,
act,
sendEvent,
exportState,
} = TestUtils.renderPlugin(MammalsPlugin);
// (2)
sendEvent('newRow', {
id: 1,
title: 'Dolphin',
url: 'http://dolphin.png',
});
sendEvent('newRow', {
id: 2,
title: 'Turtle',
url: 'http://turtle.png',
});
// (3) Dolphin card should now be visible
expect(await renderer.findByTestId('Dolphin')).not.toBeNull();
// (4) Let's assert the structure of the Turtle card as well
expect(await renderer.findByTestId('Turtle')).toMatchInlineSnapshot(`
<div
class="css-ok7d66-View-FlexBox-FlexColumn"
data-testid="Turtle"
>
<div
class="css-vgz97s"
style="background-image: url(http://turtle.png);"
/>
<span
class="css-8j2gzl-Text"
>
Turtle
</span>
</div>
`);
// (5) Nothing selected, so we should not have a sidebar
expect(renderer.queryAllByText('Extras').length).toBe(0);
act(() => {
instance.setSelection(2);
});
// Sidebar should be visible now
expect(await renderer.findByText('Extras')).not.toBeNull();
// (6) Verify export
expect(exportState()).toEqual({
rows: {
'1': {
id: 1,
title: 'Dolphin',
url: 'http://dolphin.png',
},
'2': {
id: 2,
title: 'Turtle',
url: 'http://turtle.png',
},
},
selection: '2',
});
});
```
Like in our previous test, we use `TestUtils` to start our plugin.
But rather than using `startPlugin`, we now use `renderPlugin`.
Which does the same but also renders the component in memory, using [react-testing-library](https://testing-library.com/docs/react-testing-library/intro).
The `renderer` returned by `startPlugin` allows us to interact with the DOM.
Like in the previous test, we start by sending some events to the plugin (`(2)`).
After that (`(3)`), our new data should be reflected in the dom.
Since we used `<CardContainer data-testid={row.title}` in our `Card` component implementation (not shown above) we can search in the DOM based on that test-id to find the right element.
But it is also possible to search for a specific test etc.
The available queries are documented [here](https://testing-library.com/docs/dom-testing-library/api-queries#queries).
Rather than just checking that the rendering isn't `null`, we can also take a snapshot of the DOM, and assert that it doesn't change accidentally in the future.
Jest's `toMatchInlineSnapshot` (`(4)`) is quite useful for that.
But don't overuse it as large snapshots are pretty useless and just create a maintenance burden without catching much.
In the next section, `(5)`, we simulate updating the selection from code, and assert that the sidebar has become visible. Note that the update is wrapped in `act`, which is recommended as it makes sure that updates are flushed to the DOM before we make queries and assertions on the DOM (the earlier `sendEvent` does apply `act` automatically and doesn't need wrapping).
Alternatively, we could have emulated actually clicking a DOM element, by using `fireEvent.click(renderer.findByTestId('dolphin'))`. See [firing events](https://testing-library.com/docs/dom-testing-library/api-events) in the docs of React testing library for details.
Finally (`(6)`) we grab the final state of our plugin state by using the `exportState` utility.
It returns all the persistable state of our plugin, based on the `persist` keys we passed to `createState` in our first listing.
We can now assert that the plugin ends up in the desired state.
## Conclusion
That concludes the Sandy Desktop plugin tutorial.
The takeaway here is that logic and UI are very strictly separated in Sandy plugins,
making them very suitable for test-driven development.
Beyond that, the new plugin model enables the state of the plugin to be preserved even when it is not currently shown in the UI, simplifying the state and event processing logic.
The complete Sandy APIs are fleshed out in the [Flipper-plugin API Reference](./flipper-plugin.mdx).

View File

@@ -0,0 +1,394 @@
---
id: flipper-plugin
title: flipper-plugin API reference (Sandy)
---
## PluginClient
`PluginClient` is the type of the `client` passed into a standard Sandy plugin.
It takes two generic arguments `Event` and `Methods`.
* The `Event` generic is a mapping of an event name to the data structure of the payload, as explained [here](./desktop-plugins#the-plugin-declaration).
* The `Methods` generic is used to describe the methods that are offered by the plugin implementation on the device. `Methods` is a mapping of a method name to a function that describes the signature of a method. The first argument of that function describes the parameters that can be passed to the client. The return type of the function should describe what is returned from the client. Wrapped with a `Promise`.
Quick example on how those generics should be used:
```typescript
type LogEntry = {
message: string
}
// Events that can be send by the client implementation:
type Events = {
addLogEntry: LogEntry,
flushLogs: {},
}
// Methods we can invoken on the client:
type Methods = {
retrieveLogsSince(params: { since: number }): Promise<{ message: string }>,
}
export function plugin(client: PluginClient<Events, Methods>) {
// etc
}
```
The `PluginClient` received by the `plugin` exposes the following members:
### Events listeners
#### `onMessage`
Usage: `client.onMessage(event: string, callback: (object) => void)`
This subscribes the plugin to a specific event that is fired from the client plugin (using [`connection.send`](../extending/create-plugin#push-data-to-the-desktop)).
Typically used to update some of the [state](#createstate).
For background plugins that are currently not active in the UI, messages won't arrive immediately, but are queued until the user opens the plugin.
Example:
```typescript
type Events = {
newRow: {
id: number;
title: string;
url: string;
};
};
export function plugin(client: PluginClient<Events, {}>) {
const rows = createState<Record<string, Row>>({}, {persist: 'rows'});
client.onMessage('newRow', (row /* type will be inferred correctly */) => {
rows.update((draft) => {
draft[row.id] = row;
});
});
// etc
}
```
#### `onActivate`
Usage: `client.onActivate(callback: () => void)`
Called when the plugin is selected by the user and mounted into the Flipper Desktop UI. See also the closely related `onConnect` event.
#### `onDeactivate`
Usage: `client.onDeactivate(callback: () => void)`
Triggered when the plugin is unmounted from the Flipper Desktop UI, because the user navigates to some other plugin.
In the case the plugin is destroyed while being active, onDeactivate will still be called.
#### `onConnect`
Usage: `client.onConnect(callback: () => void)`
Triggered once the connection with the plugin on the client is established, and for example [`send`](#send) can be called safely.
Typically, this happens when the plugin is activated (opened) in the Flipper Desktop.
However, for [background plugins](create-plugin#background-plugins), this happens immediately after the plugin has been instantiated.
#### `onDisconnect`
Usage: `client.onDisconnect(callback: () => void)`
Triggered once the connection with the plugin on the client has been lost.
Typically, this happens when the user leaves the plugin in the Flipper Desktop, when the plugin is disabled, or when the app or device has disconnected.
However, for [background plugins](create-plugin#background-plugins), this event won't fire when the user merely navigates somewhere else.
In that case, [`onDeactivate`](#ondeactivate) can be used instead.
#### `onDestroy`
Usage: `client.onDestroy(callback: () => void)`
Called when the plugin is unloaded. This happens if the device or client has been disconnected, or when the user disables the plugin.
Note that there is no corresponding `onCreate` event, since the function body of the plugin definition acts already as 'what needs to be done when the plugin is loaded/enabled'.
#### `onDeepLink`
Usage: `client.onDeepLink(callback: (payload: unknown) => void)`
Trigger when the users navigates to this plugin using a deeplink, either from an external `flipper://` plugin URL, or because the user was linked here from another plugin.
### Methods
#### `send`
Usage: `client.send(method: string, params: object): Promise<object>`
If the plugin is connected, `send` can be used to invoke a [method](create-plugin#[background-plugins#using-flipperconnection) on the client implementation of the plugin.
Example:
```typescript
type Methods = {
currentLogs(params: {since: number}): Promise<string[]>;
};
export function plugin(client: PluginClient<{}, Methods>) {
const logs = createState<string[]>([])
client.onConnect(async () => {
try {
const currentLogs = await client.send('currentLogs', {
since: Date.now()
})
logs.set(currentLogs)
} catch (e) {
console.error("Failed to retrieve current logs: ", e)
}
})
//etc
}
```
#### `addMenuEntry`
Usage: `client.addMenuEntry(...entry: MenuEntry[])`
This method can be used to add menu entries to the Flipper main menu while this plugin is active.
It supports registering global keyboard shortcuts as well.
Example:
```typescript
client.addMenuEntry({
label: 'Reset Selection',
topLevelMenu: 'Edit',
accelerator: 'CmdOrCtrl+R'
handler: () => {
// Event handling
}
}
```
The `accelerator` argument is optional, but describes the keyboard shortcut. See the [Electron docs](https://www.electronjs.org/docs/api/accelerator) for their format. The `topLevelMenu` must be one of `"Edit"`, `"View"`, `"Window"` or `"Help"`.
It is possible to leave out the `label`, `topLevelMenu` and `accelerator` fields if a pre-defined `action` is set, which configures all three of them.
The currently pre-defined actions are `"Clear"`, `"Go To Bottom"` and `"Create Paste"`.
Example of using a pre-defined action:
```typescript
client.addMenuEntry({
action: 'createPaste',
handler: async () => {
// Event handling
}
})
```
#### `supportsMethod`
Usage; `client.supportsMethod(method: string): Promise<Boolean>`
Resolves to true if the client supports the specified method. Useful when adding functionality to existing plugins, when connectivity to older clients is still required. Also useful when client plugins are implemented on multitple platforms and don't all have feature parity.
#### `createPaste`
Facebook only API.
Usage: `client.createPaste(value: string): Promise<string|undefined>`
Creates a Facebook Paste (similar to a GitHub Gist) for the given `value`.
The returned promise either contains a string with the URL of the paste, or `undefined` if the process failed.
Details of the failure will be communicated back directly to the user through Flipper notifications.
For example if the user is currently not signed in.
## DevicePluginClient
### Properties
#### `device`
Returns the [`Device`](#device) this plugin is connected to.
### Events
#### `onDestroy`
See the similarly named event under [`PluginClient`](#pluginclient).
#### `onActivate`
See the similarly named event under [`PluginClient`](#pluginclient).
#### `onDeactivate`
See the similarly named event under [`PluginClient`](#pluginclient).
#### `onDeepLink`
See the similarly named event under [`PluginClient`](#pluginclient).
### Methods
#### `addMenuEntry`
See the similarly named method under [`PluginClient`](#pluginclient).
#### `createPaste`
See the similarly named method under [`PluginClient`](#pluginclient).
## Device
`Device` captures the metadata of the device the plugin is currently connected to.
Device objects are passed into the [`supportsDevice` method](./desktop-plugins#creating-a-device-plugin) of a device plugin, and available as `device` field on a [`DevicePluginClient`](#devicepluginclient).
### Properties
#### os
A `string` that describes the Operating System of the device. Typical values:
`'iOS'` | `'Android'` | `'Windows'` | `'MacOS'` | `'Metro'`
#### deviceType
A `string` that describes whether the device is a physical device or an emulator. Possible values: `'emulator'` and `'physical'`.
#### isArchived
This `boolean` flag is `true` if the current device is coming from an import Flipper snapshot, and not an actually connected device.
### Events
#### `onLogEntry`
Usage: `device.onLogEntry(callback: (logEntry: DeviceLogEntry) => void)`
Use this event to subscribe to the log stream that is emitted by the device.
For Android this is using `adb` behind the scenes, for iOS `idb`, for Metro it connects to the webserver for the Metro log output, etc.
The `DeviceLogEntry` exposes the following fields:
* `date: Date`
* `type: string`
* `message: string`
* `pid: number`
* `tid: number`
* `app?: string`
* `tag: string`
For `type`, the possible values are `'unknown'`, `'verbose'`, `'debug'`, `'info'`, `'warn'`, `'error'` and `'fatal'`.
## State Management
State in Sandy plugins is stored in small containers that hold immutable values, and can be consumed in React components using the [`useValue`](#usevalue) hook.
### createState
Usage: `createState<T>(initialValue: T, options?): StateAtom<T>`
The `createState` method can be used to create a small state container that lives inside a Sandy plugin.
Its value should be treated as immutable and is initialized by default using the `initialValue` parameter.
#### Options
Optionally, `options` can be provided when creating state. Supported options:
* `persist: string`. If the `persist` value is set, this state container will be serialized when n Flipper snapshot export is being made. When a snapshot is imported into Flipper, and plugins are initialized, this state container will load its initial value from the snapshot, rather than using the `initialValue` parameter. The `persist` key should be unique within the plugin and only be set if the state stored in this container is JSON serializable, and won't become unreasonably large. See also `exportState` and `initialState` in the [`TestUtils`](#testutils) section.
#### The state atom object
A state atom object is returned by `createState`, exposing the following methods:
* `get(): T`: Returns the current value stored. If you want to use the atom object in a React component, consider using the `useValue` hook instead, to make sure the component is notified about future updates of this atom.
* `set(newValue: T)`: Stores a new value into the atom. If the new value is not reference-equal to the previous one, all observing components will be notified.
* `update(updater: (draft: Draft<T>) => void)`: Updates the current state using an [Immer](https://immerjs.github.io/immer/docs/introduction) recipe. In the `updater`, the `draft` object can be safely (deeply) mutated. Once the `updater` finishes, Immer will compute a new immutable object based on the changes, and store that. This is often simpler than using a combination of `get` and `set` if deep updates need to be made to the stored object.
#### Example
```typescript
import {createState} from 'flipper-plugin'
const rows = createState<string[]>([], {persist: 'rows'});
const selectedID = createState<string | null>(null, {persist: 'selection'});
rows.set(["hello"])
console.log(rows.get().length) // 1
rows.update(draft => {
draft.push("world")
})
console.log(rows.get().length) // 2
```
## React Hooks
### usePlugin
Usage: `const instance = usePlugin(plugin)`
Can be used by any component in the plugin, and gives the current `instance` that corresponds with the currently loaded plugin.
The `plugin` parameter isn't actually used, but used to verify that a component is used correctly inside a mounted component, and helps with type inference.
The returned `instance` method corresponds to the object that is returned from the `plugin` / `devicePlugin` definition.
See the [tutorial](./desktop-plugins#building-an-user-interface-for-the-plugin) for how this hook is used in practice.
### useValue
Usage: `const currentValue = useValue(stateAtom)`
Returns the current value of a state atom, and also subscribes the current component to future changes of the atom (in contrast to using `stateAtom.get()` directly).
See the [tutorial](./desktop-plugins#building-an-user-interface-for-the-plugin) for how this hook is used in practice.
## TestUtils
The object `TestUtils` as exposed from `flipper-plugin` exposes utilities to write unit tests for Sandy plugins.
Different utilities are exposed depending on whether you want to test a client or device plugin, and whether or not the component should be rendered or only the logic itself is going to be tested.
It is recommended to follow the [tutorial](./desktop-plugins) first, as it explains how unit tests should be setup.
### Starting a plugin
Usage:
- `const runner = TestUtils.startPlugin(pluginModule, options?)`
- `const runner = TestUtils.renderPlugin(pluginModule, options?)`
- `const runner = TestUtils.startDevicePlugin(devicePluginModule, options?)`
- `const runner = TestUtils.renderDevicePlugin(devicePluginModule, options?)`
Starts a client plugin in a fully mocked environment, but without rendering support.
The pluginModule is an object that has a `plugin` (or `devicePlugin` and `supportsDevice`) and `Component` property.
Typically, it is invoked with `startPlugin(PluginUnderTest)`, where `PluginUnderTest` is loaded like `import * as PluginUnderTest from "../index.tsx"` (the path to the actual definition).
However, it doesn't have to be loaded with an entire module, and a local object with the same signature can be constructed as well.
#### startPlugin options
The `options` argument is optional, but can specify the following fields:
* `initialState`: Can be used to start the plugin in a certain state, rather than in the default state. `initialState` should be an object that specifies for all the state atoms that have the `persist` option set, their initial value. For example: `{ initialState: { rows: ["hello", "world"]}}`, where `rows` matches the `persist` key of an atom.
* `isArchived: boolean`: Setting this flag, will set the `isArchived` on the mocked device as well. Set it if you want to test the behavior of your plugin for imported devices (see also [`Device.isArchived`](#isarchived)). Defaults to `false`.
* `isBackgroundPlugin`: This makes sure the test runner emits life-cycle events in a way that is typical for background plugins. Defaults to `false`. The notable difference in behavior is that calling `.active()` on the test runner won't trigger the `connect` event to be fired, nor the `.deactivate()` the `disconnect` event.
* `startUnactivated`: This does not activate the plugin; `connect` needs to be explicitly called. This can be used in case setting mock implementation for `onSend` is required to make sure Client plugin works as expected. Defaults to `false`.
#### The test runner object
`startPlugin` returns an object that can be used to inspect and interact with your plugin instance.
Again, see the tutorial how to interact with this object in general.
The test runner is a bag full of utilities, but typically it is fine to just destructure the utilities relevant for the test.
Exposed members:
* `instance`: The object (public API) returned from your plugin definition. You will typically use this in most tests, either to trigger updates or to inspect the current state of the plugin.
* `exportState()`: Grabs the current state of all `persist` enabled state atoms. The object format returned here is the same as in the `initialState` option.
* `activate()`: Emulate the `onActivate` event. By default, `startPlugin` already starts the plugin in activated state, and calling `activate` to test the `onActivate` event should be preceded by a `deactivate()` call first.
* `deactivate()`: Emulates a user navigating away from the plugin.
* `destroy()`: Emulates the plugin being cleaned up, for example because the plugin is disabled by the user, or because the device / client has disconnected. After calling `destroy` the current `runner` is unusable.
* `triggerDeepLink(payload)`: Emulates a deepLink being triggered, and fires the `onDeepLink` event.
* `triggerMenuEntry(label)`: Emulates the user clicking a menu entry in the Flipper main menu.
* `flipperLib`: An object that exposed `jest.fn()` mocks for all built-in Flipper APIs that can be called by your plugin. So assertions can be made that the plugin did actually invoke those methods. For example: `expect(runner.flipperLib.createPaste).toBeCalledWith("test message")`. Currently supported mocks: `createPaste`, `enableMenuEntries`.
The following members are available when using the `render...` variant rather than the `start...` variant:
* `renderer`: This object can be used to query the DOM and further interact with it. It is provided by react-testing-library, and further documented [here](https://testing-library.com/docs/react-testing-library/api#render-result).
* `act`: Use this function to wrap interactions with the plugin under test into a transaction, after which the DOM updates will be flushed by React. See also the [`act`](https://reactjs.org/docs/test-utils.html#act) documentation.
The following members are only available for Client plugins:
* `sendEvent(event, params)`: Emulates an event being sent by the client plugin. Will trigger the corresponding `onMessage` handler in the plugin.
* `sendEvents({ method: string, params: object}[])`: Like `sendEvent`, but sends multiple events at once.
* `onSend`: A `jest.fn()` that can be used to assert that `client.send()` was called by the plugin under test. For example `expect(runner.onSend).toBeCalledWith('currentLogs`, { since: 0})`.
* `connect()`: Triggers the `onConnect()` event. (For non-background plugins `activate()` could as well be used for this).
* `disconnect()`: Triggers the `onDisconnect()` event. (For non-background plugins `deactivate()` could as well be used for this).
The following members are only available for Device plugins:
* `sendLogEntry(logEntry)`: Emulates a log message arriving from the device. Triggers the `client.device.onLogEntry` listener.

View File

@@ -77,11 +77,10 @@ module.exports = {
'extending/error-handling',
'extending/testing',
'extending/debugging',
'extending/desktop-plugins',
'extending/flipper-plugin',
...fbInternalOnly([
'extending/fb/desktop-plugin-releases',
// TODO: Remove once sandy is public T69061061
'extending/fb/sandy/sandy-plugins',
'extending/fb/sandy/flipper-plugin',
]),
],
// end-internal-sidebars-example