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:
Michel Weststrate
2020-11-16 13:08:05 -08:00
committed by Facebook GitHub Bot
parent da6d6593a5
commit cc438e60ad
12 changed files with 418 additions and 592 deletions

View File

@@ -94,8 +94,8 @@ test('It generates the correct files', async () => {
"/dev/null/src/__tests__/test.spec.tsx": "import {TestUtils} from 'flipper-plugin';
import * as Plugin from '..';
// Read more: https://fbflipper.com/docs/extending/desktop-plugins#testing-plugin-logic
// API: https://fbflipper.com/docs/extending/desktop-plugins#testing-plugin-logic
// Read more: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
// API: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
test('It can store data', () => {
const {instance, sendEvent} = TestUtils.startPlugin(Plugin);
@@ -116,8 +116,8 @@ test('It generates the correct files', async () => {
\`);
});
// Read more: https://fbflipper.com/docs/extending/desktop-plugins#testing-plugin-logic
// API: https://fbflipper.com/docs/extending/desktop-plugins#testing-plugin-logic
// Read more: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
// API: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
test('It can render data', async () => {
const {instance, renderer, sendEvent} = TestUtils.renderPlugin(Plugin);
@@ -148,7 +148,7 @@ test('It generates the correct files', async () => {
newData: Data;
};
// Read more: https://fbflipper.com/docs/extending/desktop-plugins#creating-a-first-plugin
// Read more: https://fbflipper.com/docs/tutorial/js-custom#creating-a-first-plugin
// API: https://fbflipper.com/docs/extending/flipper-plugin#pluginclient
export function plugin(client: PluginClient<Events, {}>) {
const data = createState<Record<string, Data>>({}, {persist: 'data'});
@@ -169,7 +169,7 @@ test('It generates the correct files', async () => {
return {data};
}
// Read more: https://fbflipper.com/docs/extending/desktop-plugins#building-a-user-interface-for-the-plugin
// Read more: https://fbflipper.com/docs/tutorial/js-custom#building-a-user-interface-for-the-plugin
// API: https://fbflipper.com/docs/extending/flipper-plugin#react-hooks
export function Component() {
const instance = usePlugin(plugin);

View File

@@ -1,8 +1,8 @@
import {TestUtils} from 'flipper-plugin';
import * as Plugin from '..';
// Read more: https://fbflipper.com/docs/extending/desktop-plugins#testing-plugin-logic
// API: https://fbflipper.com/docs/extending/desktop-plugins#testing-plugin-logic
// Read more: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
// API: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
test('It can store data', () => {
const {instance, sendEvent} = TestUtils.startPlugin(Plugin);
@@ -23,8 +23,8 @@ test('It can store data', () => {
`);
});
// Read more: https://fbflipper.com/docs/extending/desktop-plugins#testing-plugin-logic
// API: https://fbflipper.com/docs/extending/desktop-plugins#testing-plugin-logic
// Read more: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
// API: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
test('It can render data', async () => {
const {instance, renderer, sendEvent} = TestUtils.renderPlugin(Plugin);

View File

@@ -10,7 +10,7 @@ type Events = {
newData: Data;
};
// Read more: https://fbflipper.com/docs/extending/desktop-plugins#creating-a-first-plugin
// Read more: https://fbflipper.com/docs/tutorial/js-custom#creating-a-first-plugin
// API: https://fbflipper.com/docs/extending/flipper-plugin#pluginclient
export function plugin(client: PluginClient<Events, {}>) {
const data = createState<Record<string, Data>>({}, {persist: 'data'});
@@ -31,7 +31,7 @@ export function plugin(client: PluginClient<Events, {}>) {
return {data};
}
// Read more: https://fbflipper.com/docs/extending/desktop-plugins#building-a-user-interface-for-the-plugin
// Read more: https://fbflipper.com/docs/tutorial/js-custom#building-a-user-interface-for-the-plugin
// API: https://fbflipper.com/docs/extending/flipper-plugin#react-hooks
export function Component() {
const instance = usePlugin(plugin);

View File

@@ -1,423 +0,0 @@
---
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 antd`
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

@@ -1,6 +1,6 @@
---
id: flipper-plugin
title: flipper-plugin API reference (Sandy)
title: Desktop Plugin API
---
## PluginClient
@@ -8,7 +8,7 @@ title: flipper-plugin API reference (Sandy)
`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 `Event` generic is a mapping of an event name to the data structure of the payload, as explained [here](../tutorial/js-custom#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:
@@ -54,7 +54,7 @@ A string that uniquely identifies the current application, is based on a combina
#### `onMessage`
Usage: `client.onMessage(event: string, callback: (object) => void)`
Usage: `client.onMessage(event: string, callback: (params) => 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).
@@ -84,6 +84,13 @@ export function plugin(client: PluginClient<Events, {}>) {
}
```
#### `onUnhandledMessage`
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 handler is untyped, and onMessage should be favored over using onUnhandledMessage if the event name is known upfront.
#### `onActivate`
Usage: `client.onActivate(callback: () => void)`
@@ -250,7 +257,7 @@ 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).
Device objects are passed into the [`supportsDevice` method](../tutorial/js-custom#creating-a-device-plugin) of a device plugin, and available as `device` field on a [`DevicePluginClient`](#devicepluginclient).
### Properties
@@ -337,20 +344,20 @@ 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.
See the [tutorial](../tutorial/js-custom#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.
See the [tutorial](../tutorial/js-custom#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.
It is recommended to follow the [tutorial](../tutorial/js-custom) first, as it explains how unit tests should be setup.
### Starting a plugin

View File

@@ -3,6 +3,10 @@ id: js-plugin-api
title: Desktop Plugin API
---
<div class="warning">
The APIs shown here are deprecated. The APIs exposed from [`flipper-plugin`](./flipper-plugin.mdx) should be preferred instead.
</div>
<div class="warning">
This page describes the JavaScript API that is used to implement plugins inside the Flipper Desktop application. For the JavaScript API that can be used inside React Native to communicate with the Flipper Desktop, see [Client Plugin API](create-plugin).

View File

@@ -79,10 +79,12 @@ After `flipper-pkg init` finished, you should have files `package.json` and `src
"prepack": "flipper-pkg lint && flipper-pkg bundle"
}
"peerDependencies": {
"flipper": "latest"
"flipper": "latest",
"flipper-plugin": "latest"
},
"devDependencies": {
"flipper": "latest",
"flipper-plugin": "latest",
"flipper-pkg": "latest"
}
}
@@ -108,19 +110,70 @@ Important attributes of `package.json`:
- `bugs` Specify an email and/or url, where plugin bugs should be reported.
In `index.tsx` you will define the plugin in JavaScript. This file must export a default class that extends `FlipperPlugin`. Browse our [JS API docs](js-plugin-api) to see what you can do, and make sure to check out our [UI Component Library](ui-components) for lots of pre-made components.
In `index.tsx` you will define the plugin in JavaScript.
Example `index.tsx`:
```js
import {FlipperPlugin} from 'flipper';
export default class extends FlipperPlugin {
render() {
```js
export function plugin(client) {
return {};
}
export function Component() {
return 'hello world';
}
```
Some public plugins will use a `FlipperPlugin` base class. This format is deprecated but the documentation can still be found [here](./js-plugin-api.mdx).
## Anatomy of a Desktop 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>;
}
```
A further guide on how to write custom Flipper plugins can be found here: [tutorial](../tutorial/js-custom).
### 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).
### Validation
Plugin definition can be validated using command `flipper-pkg lint`. The command shows all the mismatches which should be fixed to make plugin definition valid.

View File

@@ -10,7 +10,7 @@ We are using [emotion](https://emotion.sh) to style our components. For more det
For basic building blocks (views, texts, ...) you can use the styled object.
```javascript
import {styled} from 'flipper';
import {styled} from 'flipper-plugin';
const MyView = styled.div({
fontSize: 10,
@@ -26,9 +26,9 @@ const MyInput = styled.input({ ... });
It's very common for components to require customizing Flipper's components in some way. For example changing colors, alignment, or wrapping behavior. Flippers components can be wrapped using the `styled` function which allows adding or overwriting existing style rules.
```javascript
import {FlexRow, styled} from 'flipper';
import {Layout, styled} from 'flipper-plugin';
const Container = styled(FlexRow)({
const Container = styled(Layout.Container)({
alignItems: 'center',
});
@@ -65,8 +65,9 @@ Pseudo-classes can be used like this:
## Colors
The colors module contains all standard colors used by Flipper. All the available colors are defined in [`desktop/src/ui/components/colors.tsx`](https://github.com/facebook/flipper/blob/master/desktop/app/src/ui/components/colors.tsx) with comments about suggested usage of them. And we strongly encourage to use them. They can be required like this:
The `theme` module contains all standard colors used by Flipper. All available colors can be previewed by starting Flipper and opening `View > Flipper Style Guide`.
The colors exposed here will handle dark mode automatically, so it is recommended to use those colors over hardcoded ones.
```javascript
import {colors} from 'flipper'
import {theme} from 'flipper'
```

View File

@@ -96,17 +96,4 @@ TEST(MyFlipperPluginTests, testDummy) {
## Testing the Flipper Desktop Plugin
Tests should be put in the `__tests__` directory of your plugin sources, and be created using Jest.
An example test suite can be found [here](https://github.com/facebook/flipper/blob/master/desktop/plugins/layout/__tests__/ProxyArchiveClient.node.tsx).
Flipper exposes an API to generate unit tests that can verify _regressions_ and real life scenarios.
To generate a unit test:
1. Start flipper.
2. Open your plugin.
3. Open the Developer Tools (`View > Open Developer Tools`).
4. In the console, call the function `flipperStartPluginRecording()`.
5. Interact with your application, in such a way that new events are created for your plugin.
6. Once you generated an interesting amount and diversity of events, call `flipperStopPluginRecording()` from the Flipper console.
7. This process will have generated a unit test and a snapshot file with the data (the console will report where). Move those files to your `__tests__` directory.
8. The unit test should succeed if it is run using Jest. Feel free to modify the unit test to your needs. In the future you might want to record new snapshot data files if the plugin changes.
See the [tutorial](../tutorial/js-custom#testing-plugin-logic)

View File

@@ -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,
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",
},
}
return persistedState;
};
`);
});
```
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})}
<>
<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}
/>)}
</SeaMammals.Container>
);
}
```
## Adding the sidebar
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`.
```js
/>
))}
</Layout.Horizontal>
</Layout.ScrollContainer>
<DetailSidebar>
{typeof selectedID === 'string' && renderSidebar(persistedState[selectedID])}
{selectedID && renderSidebar(rows[selectedID])}
</DetailSidebar>
```
## Creating a custom component
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.
```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',
}));
static Image = styled.div({
backgroundSize: 'cover',
width: '100%',
paddingTop: '100%',
});
static Title = styled(Text)({
fontSize: 14,
fontWeight: 'bold',
padding: '10px 5px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
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>
</>
);
}
function renderSidebar(row: Row) {
return (
<Layout.Container gap pad>
<Typography.Title level={4}>Extras</Typography.Title>
<ManagedDataInspector data={row} expandRoot={true} />
</Layout.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 `MammalCard` component can be found [here](https://github.com/facebook/flipper/blob/master/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:
```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 `<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.

View File

@@ -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`.

View File

@@ -63,12 +63,10 @@ module.exports = {
'tutorial/js-custom',
'tutorial/js-publishing',
],
// start-internal-sidebars-example
'Plugin Development': [
'extending/js-setup',
'extending/js-plugin-api',
'extending/flipper-plugin',
'extending/create-table-plugin',
'extending/ui-components',
'extending/styling-components',
'extending/search-and-filter',
'extending/create-plugin',
@@ -77,12 +75,14 @@ module.exports = {
'extending/error-handling',
'extending/testing',
'extending/debugging',
'extending/desktop-plugins',
'extending/flipper-plugin',
...fbInternalOnly([
'extending/fb/desktop-plugin-releases',
]),
],
'Deprecated APIs': [
'extending/ui-components',
'extending/js-plugin-api',
],
// end-internal-sidebars-example
'Other Platforms': [
'extending/new-clients',