Update public docs to use Sandy
Summary: This updates the docs of Flipper to use Sandy, rather than `FlipperPlugin` class. Restructured the docs a bit as a result. Reviewed By: passy Differential Revision: D24991285 fbshipit-source-id: 66d5760c25cf9cf3983515433dfd64348d51db3d
This commit is contained in:
committed by
Facebook GitHub Bot
parent
da6d6593a5
commit
cc438e60ad
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: js-custom
|
||||
title: Building Custom UI
|
||||
sidebar_label: Custom UI
|
||||
title: Building A Custom Desktop Plugin
|
||||
sidebar_label: Custom Plugin
|
||||
---
|
||||
import useBaseUrl from '@docusaurus/useBaseUrl';
|
||||
import Link from '@docusaurus/Link';
|
||||
@@ -13,139 +13,348 @@ Displaying your data in a table might work for many use-cases. However, dependin
|
||||
For our sea mammals app, we might not only want to see them listed as image URLs in a table but render the actual images in nice little cards. When selecting one of the cards we still want to display all details in the sidebar.
|
||||
<img alt="Custom cards UI for our sea mammals plugin" src={useBaseUrl("img/js-custom.png")} />
|
||||
|
||||
Currently, the default export in our `index.tsx` is from `createTablePlugin`. Now we are going to replace this with a custom React component extending `FlipperPlugin`.
|
||||
Currently, the default export in our `index.tsx` is from `createTablePlugin`.
|
||||
Now we are going to replace this with a custom React component by using the more flexible APIs exposed by `flipper-plugin` .
|
||||
So first let's add `flipper-plugin` as dependency: `yarn add --peer flipper-plugin antd && yarn add --dev flipper-plugin antd`.
|
||||
|
||||
```js
|
||||
export default class SeaMammals extends FlipperPlugin<State, any, PersistedState> {
|
||||
static Container = styled(FlexRow)({
|
||||
backgroundColor: colors.macOSTitleBarBackgroundBlur,
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'flex-start',
|
||||
alignContent: 'flex-start',
|
||||
flexGrow: 1,
|
||||
overflow: 'scroll',
|
||||
After that, we replace our `createTablePlugin` with a `plugin` definition, and a `Component` definition which is used for rendering.
|
||||
Separating those two concepts helps with testing and maintaining state when the user switches plugins.
|
||||
|
||||
|
||||
```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;
|
||||
});
|
||||
});
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SeaMammals.Container>
|
||||
Hello custom plugin!
|
||||
</SeaMammals.Container>
|
||||
);
|
||||
// (7)
|
||||
function setSelection(id: number) {
|
||||
selectedID.set('' + id);
|
||||
}
|
||||
|
||||
// (4)
|
||||
return {
|
||||
rows,
|
||||
selectedID,
|
||||
setSelection,
|
||||
};
|
||||
}
|
||||
|
||||
export function Component() {
|
||||
return <h1>Sea Mammals plugin</h1>;
|
||||
}
|
||||
```
|
||||
|
||||
You can see how we are styling our components using [emotion](https://emotion.sh/). To learn more about this, make sure to read our guide on <Link to={useBaseUrl("/docs/extending/styling-components")}>styling components</Link>.
|
||||
## The `plugin` declaration
|
||||
|
||||
## Adding data handling
|
||||
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.
|
||||
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 plugin is quite useless when we don't display any actual data. We are adding two static properties to our plugin class for data handling. `defaultPersistedState` defines the default state before we received any data. In `persistedStateReducer` we define how new data is merged with the existing data.
|
||||
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.
|
||||
|
||||
For the default state we define an empty object because we don't have any data, yet. When receiving data, we simply add it to the existing object, using the ID as a key. Learn more about <Link to={useBaseUrl("/docs/extending/js-plugin-api#persistedstate")}>persistedState</Link> in our guide.
|
||||
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.
|
||||
|
||||
```js
|
||||
static defaultPersistedState: PersistedState = {
|
||||
data: [],
|
||||
};
|
||||
## Writing `plugin` logic
|
||||
|
||||
static persistedStateReducer<PersistedState>(
|
||||
persistedState: PersistedState,
|
||||
method: string,
|
||||
payload: Row,
|
||||
) {
|
||||
if (method === 'newRow') {
|
||||
return return Object.assign({}, persistedState, {,
|
||||
[payload.id]: payload,
|
||||
});
|
||||
}
|
||||
return persistedState;
|
||||
};
|
||||
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 the `useValue` hook.
|
||||
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",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
```
|
||||
|
||||
Note: The method name `newRow` is still the same that we defined on the native side.
|
||||
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)`).
|
||||
|
||||
## Rendering the data
|
||||
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](../extending/flipper-plugin#the-test-runner-object), but for this test we are only interested in `sendEvent`.
|
||||
|
||||
Now we can access the data from `this.props.persistedState.data` and render it. So let's update our `render` method using a custom `Card` component, which we will implement in a bit.
|
||||
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.
|
||||
|
||||
```js
|
||||
render() {
|
||||
const {selectedIndex} = this.state;
|
||||
const {persistedState} = this.props;
|
||||
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
|
||||
|
||||
_Note: For now, the plugin implementation as shown here uses the old Flipper component library `flipper`, expect nicer components in the future as part of `flipper-plugin`._
|
||||
|
||||
So far, in `index.tsx`, our `Component` didn't do anything useful yet. Time to build some nice UI.
|
||||
Flipper leverages Ant design, so any [official Ant component](https://ant.design/components/overview/) can be used in Flipper plugins.
|
||||
|
||||
The styling system used by Flipper can be found by starting Flipper, and opening `View > Flipper Style Guide`.
|
||||
The different `Layout` elements are documented there as well.
|
||||
|
||||
```typescript
|
||||
import React, {memo} from 'react';
|
||||
import {Typography, Card} from 'antd';
|
||||
import {
|
||||
Layout,
|
||||
PluginClient,
|
||||
usePlugin,
|
||||
createState,
|
||||
useValue,
|
||||
theme,
|
||||
styled,
|
||||
} from 'flipper-plugin';
|
||||
import {ManagedDataInspector, DetailSidebar} from 'flipper';
|
||||
|
||||
// (1)
|
||||
export function Component() {
|
||||
// (2)
|
||||
const instance = usePlugin(plugin);
|
||||
// (3)
|
||||
const rows = useValue(instance.rows);
|
||||
const selectedID = useValue(instance.selectedID);
|
||||
|
||||
// (4)
|
||||
return (
|
||||
<SeaMammals.Container>
|
||||
{Object.entries(persistedState).map(([id, row]) => <Card
|
||||
{...row}
|
||||
onSelect={() => this.setState({selectedID: id})}
|
||||
selected={id === selectedID}
|
||||
key={id}
|
||||
/>)}
|
||||
</SeaMammals.Container>
|
||||
<>
|
||||
<Layout.ScrollContainer
|
||||
vertical
|
||||
style={{background: theme.backgroundWash}}>
|
||||
<Layout.Horizontal gap pad style={{flexWrap: 'wrap'}}>
|
||||
{Object.entries(rows).map(([id, row]) => (
|
||||
<MammalCard
|
||||
row={row}
|
||||
onSelect={instance.setSelection}
|
||||
selected={id === selectedID}
|
||||
key={id}
|
||||
/>
|
||||
))}
|
||||
</Layout.Horizontal>
|
||||
</Layout.ScrollContainer>
|
||||
<DetailSidebar>
|
||||
{selectedID && renderSidebar(rows[selectedID])}
|
||||
</DetailSidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSidebar(row: Row) {
|
||||
return (
|
||||
<Layout.Container gap pad>
|
||||
<Typography.Title level={4}>Extras</Typography.Title>
|
||||
<ManagedDataInspector data={row} expandRoot={true} />
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Adding the sidebar
|
||||
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)`).
|
||||
|
||||
When clicking on a Card, we want to show all details in the sidebar as we did with the table before. We are using React's state to store the selected ID in our data. Flipper provides a `DetailSidebar` component which we can use to add information to the sidebar. It doesn't matter where this component is placed as long as it is returned somewhere in our `render` method. The `renderSidebar` method returning the sidebar's content is still the same we used with `createTablePlugin`.
|
||||
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.
|
||||
|
||||
```js
|
||||
<DetailSidebar>
|
||||
{typeof selectedID === 'string' && renderSidebar(persistedState[selectedID])}
|
||||
</DetailSidebar>
|
||||
```
|
||||
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.
|
||||
|
||||
## Creating a custom component
|
||||
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 `MammalCard` component can be found [here](https://github.com/facebook/flipper/blob/master/desktop/plugins/seamammals/src/index.tsx#L113-L165).
|
||||
|
||||
The `Card` component is responsible for rendering the actual image and title. This is not very specific to Flipper, but is using plain React. Note the usage of `styled` to adjust the style of existing UI components and `colors` which provides a library of colors used throughout the app.
|
||||
### Unit testing the User Interface
|
||||
|
||||
```js
|
||||
class Card extends React.Component<{
|
||||
onSelect: () => void,
|
||||
selected: boolean,
|
||||
} & Row> {
|
||||
static Container = styled(FlexColumn)(props => ({
|
||||
margin: 10,
|
||||
borderRadius: 5,
|
||||
border: '2px solid black',
|
||||
backgroundColor: colors.white,
|
||||
borderColor: props.selected
|
||||
? colors.macOSTitleBarIconSelected
|
||||
: colors.white,
|
||||
padding: 0,
|
||||
width: 150,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '1px 1px 4px rgba(0,0,0,0.1)',
|
||||
cursor: 'pointer',
|
||||
}));
|
||||
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:
|
||||
|
||||
static Image = styled.div({
|
||||
backgroundSize: 'cover',
|
||||
width: '100%',
|
||||
paddingTop: '100%',
|
||||
```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',
|
||||
});
|
||||
|
||||
static Title = styled(Text)({
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
padding: '10px 5px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
// (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);
|
||||
});
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Card.Container
|
||||
onClick={this.props.onSelect}
|
||||
selected={this.props.selected}>
|
||||
<Card.Image style={{backgroundImage: `url(${this.props.url || ''})`}} />
|
||||
<Card.Title>{this.props.title}</Card.Title>
|
||||
</Card.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
// 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 `<Card data-testid={row.title}` in our 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 classname, 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.
|
||||
|
||||
@@ -29,26 +29,14 @@ Your file will then look something like this:
|
||||
}
|
||||
```
|
||||
|
||||
## Installing flipper-pkg
|
||||
|
||||
`flipper-pkg` tool helps to define, validate and package Flipper desktop plugins. You can install it globally using:
|
||||
```
|
||||
yarn global add flipper-pkg
|
||||
```
|
||||
or
|
||||
```
|
||||
npm install flipper-pkg --global
|
||||
```
|
||||
|
||||
## Creating the Plugin Package
|
||||
|
||||
With the loading part out of the way, we can create the new plugin. For that, first create a new folder inside the custom plugins directory. Then use `flpper-pkg init` to initialise a new Flipper desktop plugin package:
|
||||
With the loading part out of the way, we can create the new plugin. For that, first create a new folder inside the custom plugins directory. Then use `flipper-pkg init` to initialise a new Flipper desktop plugin package.
|
||||
`flipper-pkg` is a NPM module, so we can run it directly using `npx` if Node and NPM are installed.
|
||||
|
||||
```bash
|
||||
$ cd ~/Flipper/custom-plugins/
|
||||
$ mkdir sea-mammals
|
||||
$ cd sea-mammals
|
||||
$ flipper-pkg init
|
||||
$ npx flipper-pkg init
|
||||
```
|
||||
|
||||
The tool will ask you to provide "id" and "title" for your plugin. Use "sea-mammals" as "id" and "Sea Mammals" as "title". After that the tool will create two files in the directory: `package.json` and `src/index.tsx`.
|
||||
|
||||
Reference in New Issue
Block a user