Files
flipper/docs/extending/desktop-plugins.mdx
Michel Weststrate 29e3d80669 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
2020-11-09 08:24:28 -08:00

424 lines
18 KiB
Plaintext

---
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).