Support handling deeplinks in plugins

Summary:
This adds support for handling incoming deeplinks in a Sandy plugin, which can be done by using a `client.onDeepLink(deepLink => { } )` listener

Also generalized deeplinks to not just support strings, but also richer objects, which is beneficial to plugin to plugin linking.

Reviewed By: jknoxville

Differential Revision: D22524749

fbshipit-source-id: 2cbe8d52f6eac91a1c1c8c8494706952920b9181
This commit is contained in:
Michel Weststrate
2020-07-22 04:11:32 -07:00
committed by Facebook GitHub Bot
parent 485b4c9827
commit f0c54667e0
13 changed files with 225 additions and 31 deletions

View File

@@ -55,7 +55,7 @@ type DispatchFromProps = {
selectPlugin: (payload: { selectPlugin: (payload: {
selectedPlugin: string | null; selectedPlugin: string | null;
selectedApp: string | null; selectedApp: string | null;
deepLinkPayload: string | null; deepLinkPayload: unknown;
}) => any; }) => any;
updatePluginBlacklist: (blacklist: Array<string>) => any; updatePluginBlacklist: (blacklist: Array<string>) => any;
updateCategoryBlacklist: (blacklist: Array<string>) => any; updateCategoryBlacklist: (blacklist: Array<string>) => any;
@@ -414,7 +414,7 @@ type ItemProps = {
selectPlugin?: (payload: { selectPlugin?: (payload: {
selectedPlugin: string | null; selectedPlugin: string | null;
selectedApp: string | null; selectedApp: string | null;
deepLinkPayload: string | null; deepLinkPayload: unknown;
}) => any; }) => any;
logger?: Logger; logger?: Logger;
plugin: PluginDefinition | null | undefined; plugin: PluginDefinition | null | undefined;

View File

@@ -101,7 +101,7 @@ type StateFromProps = {
activePlugin: PluginDefinition | undefined; activePlugin: PluginDefinition | undefined;
target: Client | BaseDevice | null; target: Client | BaseDevice | null;
pluginKey: string | null; pluginKey: string | null;
deepLinkPayload: string | null; deepLinkPayload: unknown;
selectedApp: string | null; selectedApp: string | null;
isArchivedDevice: boolean; isArchivedDevice: boolean;
pendingMessages: Message[] | undefined; pendingMessages: Message[] | undefined;
@@ -113,7 +113,7 @@ type DispatchFromProps = {
selectPlugin: (payload: { selectPlugin: (payload: {
selectedPlugin: string | null; selectedPlugin: string | null;
selectedApp?: string | null; selectedApp?: string | null;
deepLinkPayload: string | null; deepLinkPayload: unknown;
}) => any; }) => any;
setPluginState: (payload: {pluginKey: string; state: any}) => void; setPluginState: (payload: {pluginKey: string; state: any}) => void;
setStaticView: (payload: StaticView) => void; setStaticView: (payload: StaticView) => void;
@@ -178,6 +178,13 @@ class PluginContainer extends PureComponent<Props, State> {
componentDidUpdate() { componentDidUpdate() {
this.processMessageQueue(); this.processMessageQueue();
// make sure deeplinks are propagated
const {deepLinkPayload, target, activePlugin} = this.props;
if (deepLinkPayload && target instanceof Client && activePlugin) {
target.sandyPluginStates
.get(activePlugin.id)
?.triggerDeepLink(deepLinkPayload);
}
} }
processMessageQueue() { processMessageQueue() {
@@ -373,7 +380,7 @@ class PluginContainer extends PureComponent<Props, State> {
setPersistedState: (state) => setPluginState({pluginKey, state}), setPersistedState: (state) => setPluginState({pluginKey, state}),
target, target,
deepLinkPayload: this.props.deepLinkPayload, deepLinkPayload: this.props.deepLinkPayload,
selectPlugin: (pluginID: string, deepLinkPayload: string | null) => { selectPlugin: (pluginID: string, deepLinkPayload: unknown) => {
const {target} = this.props; const {target} = this.props;
// check if plugin will be available // check if plugin will be available
if ( if (

View File

@@ -16,8 +16,11 @@ import {
FlipperClient, FlipperClient,
TestUtils, TestUtils,
usePlugin, usePlugin,
createState,
useValue,
} from 'flipper-plugin'; } from 'flipper-plugin';
import {selectPlugin, starPlugin} from '../reducers/connections'; import {selectPlugin, starPlugin} from '../reducers/connections';
import {updateSettings} from '../reducers/settings';
interface PersistedState { interface PersistedState {
count: 1; count: 1;
@@ -121,9 +124,6 @@ test('PluginContainer can render Sandy plugins', async () => {
Component: MySandyPlugin, Component: MySandyPlugin,
}, },
); );
// any cast because this plugin is not enriched with the meta data that the plugin loader
// normally adds. Our further sandy plugin test infra won't need this, but
// for this test we do need to act a s a loaded plugin, to make sure PluginContainer itself can handle it
const { const {
renderer, renderer,
act, act,
@@ -230,3 +230,140 @@ test('PluginContainer can render Sandy plugins', async () => {
).toBeCalledTimes(1); ).toBeCalledTimes(1);
expect(client.rawSend).toBeCalledWith('init', {plugin: 'TestPlugin'}); expect(client.rawSend).toBeCalledWith('init', {plugin: 'TestPlugin'});
}); });
test('PluginContainer + Sandy plugin supports deeplink', async () => {
const linksSeen: any[] = [];
const plugin = (client: FlipperClient) => {
const linkState = createState('');
client.onDeepLink((link) => {
linksSeen.push(link);
linkState.set(String(link));
});
return {
linkState,
};
};
const definition = new SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
plugin,
Component() {
const instance = usePlugin(plugin);
const linkState = useValue(instance.linkState);
return <h1>hello {linkState || 'world'}</h1>;
},
},
);
const {renderer, act, client, store} = await renderMockFlipperWithPlugin(
definition,
);
expect(client.rawSend).toBeCalledWith('init', {plugin: 'TestPlugin'});
expect(linksSeen).toEqual([]);
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
<div
class="css-1orvm1g-View-FlexBox-FlexColumn"
>
<h1>
hello
world
</h1>
</div>
<div
class="css-bxcvv9-View-FlexBox-FlexRow"
id="detailsSidebar"
/>
</div>
</body>
`);
act(() => {
store.dispatch(
selectPlugin({
selectedPlugin: definition.id,
deepLinkPayload: 'universe!',
selectedApp: client.query.app,
}),
);
});
expect(linksSeen).toEqual(['universe!']);
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
<div
class="css-1orvm1g-View-FlexBox-FlexColumn"
>
<h1>
hello
universe!
</h1>
</div>
<div
class="css-bxcvv9-View-FlexBox-FlexRow"
id="detailsSidebar"
/>
</div>
</body>
`);
// Sending same link doesn't trigger again
act(() => {
store.dispatch(
selectPlugin({
selectedPlugin: definition.id,
deepLinkPayload: 'universe!',
selectedApp: client.query.app,
}),
);
});
expect(linksSeen).toEqual(['universe!']);
// ...nor does a random other store update that does trigger a plugin container render
act(() => {
store.dispatch(
updateSettings({
...store.getState().settingsState,
}),
);
});
expect(linksSeen).toEqual(['universe!']);
// Different link does trigger again
act(() => {
store.dispatch(
selectPlugin({
selectedPlugin: definition.id,
deepLinkPayload: 'london!',
selectedApp: client.query.app,
}),
);
});
expect(linksSeen).toEqual(['universe!', 'london!']);
// and same link does trigger if something else was selected in the mean time
act(() => {
store.dispatch(
selectPlugin({
selectedPlugin: 'Logs',
deepLinkPayload: 'london!',
selectedApp: client.query.app,
}),
);
});
act(() => {
store.dispatch(
selectPlugin({
selectedPlugin: definition.id,
deepLinkPayload: 'london!',
selectedApp: client.query.app,
}),
);
});
expect(linksSeen).toEqual(['universe!', 'london!', 'london!']);
});

View File

@@ -18,7 +18,7 @@ import {selectPlugin} from '../reducers/connections';
import React from 'react'; import React from 'react';
type StateFromProps = { type StateFromProps = {
deepLinkPayload: string | null; deepLinkPayload: unknown;
blacklistedPlugins: Array<string>; blacklistedPlugins: Array<string>;
blacklistedCategories: Array<string>; blacklistedCategories: Array<string>;
}; };
@@ -28,7 +28,7 @@ type DispatchFromProps = {
selectPlugin: (payload: { selectPlugin: (payload: {
selectedPlugin: string | null; selectedPlugin: string | null;
selectedApp: string | null | undefined; selectedApp: string | null | undefined;
deepLinkPayload: string | null; deepLinkPayload: unknown;
}) => any; }) => any;
}; };
@@ -62,7 +62,9 @@ class Notifications extends PureComponent<Props, State> {
<Container> <Container>
<ConnectedNotificationsTable <ConnectedNotificationsTable
onClear={clearAllNotifications} onClear={clearAllNotifications}
selectedID={deepLinkPayload} selectedID={
typeof deepLinkPayload === 'string' ? deepLinkPayload : null
}
onSelectPlugin={selectPlugin} onSelectPlugin={selectPlugin}
logger={logger} logger={logger}
defaultFilters={[ defaultFilters={[

View File

@@ -190,7 +190,7 @@ type StateFromProps = {
type SelectPlugin = (payload: { type SelectPlugin = (payload: {
selectedPlugin: string | null; selectedPlugin: string | null;
selectedApp?: string | null; selectedApp?: string | null;
deepLinkPayload: string | null; deepLinkPayload: unknown;
selectedDevice: BaseDevice; selectedDevice: BaseDevice;
}) => void; }) => void;

View File

@@ -89,8 +89,8 @@ export type Props<T> = {
persistedState: T; persistedState: T;
setPersistedState: (state: Partial<T>) => void; setPersistedState: (state: Partial<T>) => void;
target: PluginTarget; target: PluginTarget;
deepLinkPayload: string | null; deepLinkPayload: unknown;
selectPlugin: (pluginID: string, deepLinkPayload: string | null) => boolean; selectPlugin: (pluginID: string, deepLinkPayload: unknown) => boolean;
isArchivedDevice: boolean; isArchivedDevice: boolean;
selectedApp: string | null; selectedApp: string | null;
setStaticView: (payload: StaticView) => void; setStaticView: (payload: StaticView) => void;

View File

@@ -23,10 +23,7 @@ const WelcomeScreen = isHeadless()
import NotificationScreen from '../chrome/NotificationScreen'; import NotificationScreen from '../chrome/NotificationScreen';
import SupportRequestFormV2 from '../fb-stubs/SupportRequestFormV2'; import SupportRequestFormV2 from '../fb-stubs/SupportRequestFormV2';
import SupportRequestDetails from '../fb-stubs/SupportRequestDetails'; import SupportRequestDetails from '../fb-stubs/SupportRequestDetails';
import { import {getPluginKey} from '../utils/pluginUtils';
getPluginKey,
defaultEnabledBackgroundPlugins,
} from '../utils/pluginUtils';
import {deconstructClientId} from '../utils/clientUtils'; import {deconstructClientId} from '../utils/clientUtils';
import {FlipperDevicePlugin, PluginDefinition, isSandyPlugin} from '../plugin'; import {FlipperDevicePlugin, PluginDefinition, isSandyPlugin} from '../plugin';
import {RegisterPluginAction} from './plugins'; import {RegisterPluginAction} from './plugins';
@@ -63,7 +60,7 @@ export type State = {
deviceId?: string; deviceId?: string;
errorMessage?: string; errorMessage?: string;
}>; }>;
deepLinkPayload: string | null; deepLinkPayload: unknown;
staticView: StaticView; staticView: StaticView;
}; };
@@ -89,7 +86,7 @@ export type Action =
payload: { payload: {
selectedPlugin: null | string; selectedPlugin: null | string;
selectedApp?: null | string; selectedApp?: null | string;
deepLinkPayload: null | string; deepLinkPayload: unknown;
selectedDevice?: null | BaseDevice; selectedDevice?: null | BaseDevice;
time: number; time: number;
}; };
@@ -245,8 +242,8 @@ export default (state: State = INITAL_STATE, action: Actions): State => {
const {payload} = action; const {payload} = action;
const {selectedPlugin, selectedApp, deepLinkPayload} = payload; const {selectedPlugin, selectedApp, deepLinkPayload} = payload;
let selectedDevice = payload.selectedDevice; let selectedDevice = payload.selectedDevice;
if (deepLinkPayload) { if (typeof deepLinkPayload === 'string') {
const deepLinkParams = new URLSearchParams(deepLinkPayload || ''); const deepLinkParams = new URLSearchParams(deepLinkPayload);
const deviceParam = deepLinkParams.get('device'); const deviceParam = deepLinkParams.get('device');
const deviceMatch = state.devices.find((v) => v.title === deviceParam); const deviceMatch = state.devices.find((v) => v.title === deviceParam);
if (deviceMatch) { if (deviceMatch) {
@@ -460,7 +457,7 @@ export const selectPlugin = (payload: {
selectedPlugin: null | string; selectedPlugin: null | string;
selectedApp?: null | string; selectedApp?: null | string;
selectedDevice?: BaseDevice | null; selectedDevice?: BaseDevice | null;
deepLinkPayload: null | string; deepLinkPayload: unknown;
time?: number; time?: number;
}): Action => ({ }): Action => ({
type: 'SELECT_PLUGIN', type: 'SELECT_PLUGIN',

View File

@@ -10,6 +10,7 @@
import * as TestUtils from '../test-utils/test-utils'; import * as TestUtils from '../test-utils/test-utils';
import * as testPlugin from './TestPlugin'; import * as testPlugin from './TestPlugin';
import {createState} from '../state/atom'; import {createState} from '../state/atom';
import {FlipperClient} from '../plugin/Plugin';
test('it can start a plugin and lifecycle events', () => { test('it can start a plugin and lifecycle events', () => {
const {instance, ...p} = TestUtils.startPlugin(testPlugin); const {instance, ...p} = TestUtils.startPlugin(testPlugin);
@@ -193,3 +194,24 @@ test('plugins cannot use a persist key twice', async () => {
`"Some other state is already persisting with key \\"test\\""`, `"Some other state is already persisting with key \\"test\\""`,
); );
}); });
test('plugins can receive deeplinks', async () => {
const plugin = TestUtils.startPlugin({
plugin(client: FlipperClient) {
client.onDeepLink((deepLink) => {
if (typeof deepLink === 'string') {
field1.set(deepLink);
}
});
const field1 = createState('', {persist: 'test'});
return {field1};
},
Component() {
return null;
},
});
expect(plugin.instance.field1.get()).toBe('');
plugin.triggerDeepLink('test');
expect(plugin.instance.field1.get()).toBe('test');
});

View File

@@ -46,6 +46,11 @@ export interface FlipperClient<
*/ */
onDisconnect(cb: () => void): void; onDisconnect(cb: () => void): void;
/**
* Triggered when this plugin is opened through a deeplink
*/
onDeepLink(cb: (deepLink: unknown) => void): void;
/** /**
* Send a message to the connected client * Send a message to the connected client
*/ */
@@ -113,6 +118,8 @@ export class SandyPluginInstance {
initialStates?: Record<string, any>; initialStates?: Record<string, any>;
// all the atoms that should be serialized when making an export / import // all the atoms that should be serialized when making an export / import
rootStates: Record<string, Atom<any>> = {}; rootStates: Record<string, Atom<any>> = {};
// last seen deeplink
lastDeeplink?: any;
constructor( constructor(
realClient: RealFlipperClient, realClient: RealFlipperClient,
@@ -143,6 +150,9 @@ export class SandyPluginInstance {
onMessage: (event, callback) => { onMessage: (event, callback) => {
this.events.on('event-' + event, callback); this.events.on('event-' + event, callback);
}, },
onDeepLink: (callback) => {
this.events.on('deeplink', callback);
},
}; };
currentPluginInstance = this; currentPluginInstance = this;
this.initialStates = initialStates; this.initialStates = initialStates;
@@ -165,6 +175,7 @@ export class SandyPluginInstance {
// the plugin is deselected in the UI // the plugin is deselected in the UI
deactivate() { deactivate() {
this.lastDeeplink = undefined;
if (this.destroyed) { if (this.destroyed) {
// this can happen if the plugin is disabled while active in the UI. // this can happen if the plugin is disabled while active in the UI.
// In that case deinit & destroy is already triggered from the STAR_PLUGIN action // In that case deinit & destroy is already triggered from the STAR_PLUGIN action
@@ -211,6 +222,14 @@ export class SandyPluginInstance {
return '[SandyPluginInstance]'; return '[SandyPluginInstance]';
} }
triggerDeepLink(deepLink: unknown) {
this.assertNotDestroyed();
if (deepLink !== this.lastDeeplink) {
this.lastDeeplink = deepLink;
this.events.emit('deeplink', deepLink);
}
}
exportState() { exportState() {
return Object.fromEntries( return Object.fromEntries(
Object.entries(this.rootStates).map(([key, atom]) => [key, atom.get()]), Object.entries(this.rootStates).map(([key, atom]) => [key, atom.get()]),

View File

@@ -100,6 +100,8 @@ interface StartPluginResult<Module extends FlipperPluginModule<any>> {
}[], }[],
): void; ): void;
triggerDeepLink(deeplink: unknown): void;
exportState(): any; exportState(): any;
} }
@@ -164,6 +166,9 @@ export function startPlugin<Module extends FlipperPluginModule<any>>(
}); });
}, },
exportState: () => pluginInstance.exportState(), exportState: () => pluginInstance.exportState(),
triggerDeepLink: (deepLink: unknown) => {
pluginInstance.triggerDeepLink(deepLink);
},
}; };
// @ts-ignore // @ts-ignore
res._backingInstance = pluginInstance; res._backingInstance = pluginInstance;

View File

@@ -242,7 +242,8 @@ export default class LayoutPlugin extends FlipperPlugin<
this.setState({ this.setState({
init: true, init: true,
selectedElement: this.props.deepLinkPayload selectedElement:
typeof this.props.deepLinkPayload === 'string'
? this.props.deepLinkPayload ? this.props.deepLinkPayload
: null, : null,
}); });
@@ -458,7 +459,11 @@ export default class LayoutPlugin extends FlipperPlugin<
this.setState({searchResults}) this.setState({searchResults})
} }
inAXMode={this.state.inAXMode} inAXMode={this.state.inAXMode}
initialQuery={this.props.deepLinkPayload} initialQuery={
typeof this.props.deepLinkPayload === 'string'
? this.props.deepLinkPayload
: null
}
/> />
</Toolbar> </Toolbar>
<Layout.Right> <Layout.Right>

View File

@@ -438,11 +438,11 @@ export default class LogTable extends FlipperDevicePlugin<
}; };
calculateHighlightedRows = ( calculateHighlightedRows = (
deepLinkPayload: string | null, deepLinkPayload: unknown,
rows: ReadonlyArray<TableBodyRow>, rows: ReadonlyArray<TableBodyRow>,
): Set<string> => { ): Set<string> => {
const highlightedRows = new Set<string>(); const highlightedRows = new Set<string>();
if (!deepLinkPayload) { if (typeof deepLinkPayload !== 'string') {
return highlightedRows; return highlightedRows;
} }

View File

@@ -273,10 +273,10 @@ export default class extends FlipperPlugin<State, any, PersistedState> {
}; };
parseDeepLinkPayload = ( parseDeepLinkPayload = (
deepLinkPayload: string | null, deepLinkPayload: unknown,
): Pick<State, 'selectedIds' | 'searchTerm'> => { ): Pick<State, 'selectedIds' | 'searchTerm'> => {
const searchTermDelim = 'searchTerm='; const searchTermDelim = 'searchTerm=';
if (deepLinkPayload === null) { if (typeof deepLinkPayload !== 'string') {
return { return {
selectedIds: [], selectedIds: [],
searchTerm: '', searchTerm: '',