From f0c54667e0f9bfadb19cb5e63d55cbd61750b0f4 Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Wed, 22 Jul 2020 04:11:32 -0700 Subject: [PATCH] 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 --- desktop/app/src/NotificationsHub.tsx | 4 +- desktop/app/src/PluginContainer.tsx | 13 +- .../src/__tests__/PluginContainer.node.tsx | 143 +++++++++++++++++- desktop/app/src/chrome/NotificationScreen.tsx | 8 +- .../src/chrome/mainsidebar/MainSidebar2.tsx | 2 +- desktop/app/src/plugin.tsx | 4 +- desktop/app/src/reducers/connections.tsx | 15 +- .../src/__tests__/test-utils.node.tsx | 22 +++ desktop/flipper-plugin/src/plugin/Plugin.tsx | 19 +++ .../src/test-utils/test-utils.tsx | 5 + desktop/plugins/layout/index.tsx | 13 +- desktop/plugins/logs/index.tsx | 4 +- desktop/plugins/network/index.tsx | 4 +- 13 files changed, 225 insertions(+), 31 deletions(-) diff --git a/desktop/app/src/NotificationsHub.tsx b/desktop/app/src/NotificationsHub.tsx index ee7c9e1b9..049b59c80 100644 --- a/desktop/app/src/NotificationsHub.tsx +++ b/desktop/app/src/NotificationsHub.tsx @@ -55,7 +55,7 @@ type DispatchFromProps = { selectPlugin: (payload: { selectedPlugin: string | null; selectedApp: string | null; - deepLinkPayload: string | null; + deepLinkPayload: unknown; }) => any; updatePluginBlacklist: (blacklist: Array) => any; updateCategoryBlacklist: (blacklist: Array) => any; @@ -414,7 +414,7 @@ type ItemProps = { selectPlugin?: (payload: { selectedPlugin: string | null; selectedApp: string | null; - deepLinkPayload: string | null; + deepLinkPayload: unknown; }) => any; logger?: Logger; plugin: PluginDefinition | null | undefined; diff --git a/desktop/app/src/PluginContainer.tsx b/desktop/app/src/PluginContainer.tsx index 5026d1065..f52683975 100644 --- a/desktop/app/src/PluginContainer.tsx +++ b/desktop/app/src/PluginContainer.tsx @@ -101,7 +101,7 @@ type StateFromProps = { activePlugin: PluginDefinition | undefined; target: Client | BaseDevice | null; pluginKey: string | null; - deepLinkPayload: string | null; + deepLinkPayload: unknown; selectedApp: string | null; isArchivedDevice: boolean; pendingMessages: Message[] | undefined; @@ -113,7 +113,7 @@ type DispatchFromProps = { selectPlugin: (payload: { selectedPlugin: string | null; selectedApp?: string | null; - deepLinkPayload: string | null; + deepLinkPayload: unknown; }) => any; setPluginState: (payload: {pluginKey: string; state: any}) => void; setStaticView: (payload: StaticView) => void; @@ -178,6 +178,13 @@ class PluginContainer extends PureComponent { componentDidUpdate() { 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() { @@ -373,7 +380,7 @@ class PluginContainer extends PureComponent { setPersistedState: (state) => setPluginState({pluginKey, state}), target, deepLinkPayload: this.props.deepLinkPayload, - selectPlugin: (pluginID: string, deepLinkPayload: string | null) => { + selectPlugin: (pluginID: string, deepLinkPayload: unknown) => { const {target} = this.props; // check if plugin will be available if ( diff --git a/desktop/app/src/__tests__/PluginContainer.node.tsx b/desktop/app/src/__tests__/PluginContainer.node.tsx index a6e124dd9..429320622 100644 --- a/desktop/app/src/__tests__/PluginContainer.node.tsx +++ b/desktop/app/src/__tests__/PluginContainer.node.tsx @@ -16,8 +16,11 @@ import { FlipperClient, TestUtils, usePlugin, + createState, + useValue, } from 'flipper-plugin'; import {selectPlugin, starPlugin} from '../reducers/connections'; +import {updateSettings} from '../reducers/settings'; interface PersistedState { count: 1; @@ -121,9 +124,6 @@ test('PluginContainer can render Sandy plugins', async () => { 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 { renderer, act, @@ -230,3 +230,140 @@ test('PluginContainer can render Sandy plugins', async () => { ).toBeCalledTimes(1); 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

hello {linkState || 'world'}

; + }, + }, + ); + const {renderer, act, client, store} = await renderMockFlipperWithPlugin( + definition, + ); + + expect(client.rawSend).toBeCalledWith('init', {plugin: 'TestPlugin'}); + + expect(linksSeen).toEqual([]); + expect(renderer.baseElement).toMatchInlineSnapshot(` + +
+
+

+ hello + world +

+
+
+
+ + `); + + act(() => { + store.dispatch( + selectPlugin({ + selectedPlugin: definition.id, + deepLinkPayload: 'universe!', + selectedApp: client.query.app, + }), + ); + }); + + expect(linksSeen).toEqual(['universe!']); + expect(renderer.baseElement).toMatchInlineSnapshot(` + +
+
+

+ hello + universe! +

+
+
+
+ + `); + + // 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!']); +}); diff --git a/desktop/app/src/chrome/NotificationScreen.tsx b/desktop/app/src/chrome/NotificationScreen.tsx index f913670b4..c8243d758 100644 --- a/desktop/app/src/chrome/NotificationScreen.tsx +++ b/desktop/app/src/chrome/NotificationScreen.tsx @@ -18,7 +18,7 @@ import {selectPlugin} from '../reducers/connections'; import React from 'react'; type StateFromProps = { - deepLinkPayload: string | null; + deepLinkPayload: unknown; blacklistedPlugins: Array; blacklistedCategories: Array; }; @@ -28,7 +28,7 @@ type DispatchFromProps = { selectPlugin: (payload: { selectedPlugin: string | null; selectedApp: string | null | undefined; - deepLinkPayload: string | null; + deepLinkPayload: unknown; }) => any; }; @@ -62,7 +62,9 @@ class Notifications extends PureComponent { void; diff --git a/desktop/app/src/plugin.tsx b/desktop/app/src/plugin.tsx index 45d574260..0e5d43a92 100644 --- a/desktop/app/src/plugin.tsx +++ b/desktop/app/src/plugin.tsx @@ -89,8 +89,8 @@ export type Props = { persistedState: T; setPersistedState: (state: Partial) => void; target: PluginTarget; - deepLinkPayload: string | null; - selectPlugin: (pluginID: string, deepLinkPayload: string | null) => boolean; + deepLinkPayload: unknown; + selectPlugin: (pluginID: string, deepLinkPayload: unknown) => boolean; isArchivedDevice: boolean; selectedApp: string | null; setStaticView: (payload: StaticView) => void; diff --git a/desktop/app/src/reducers/connections.tsx b/desktop/app/src/reducers/connections.tsx index 188f05b2b..919860a37 100644 --- a/desktop/app/src/reducers/connections.tsx +++ b/desktop/app/src/reducers/connections.tsx @@ -23,10 +23,7 @@ const WelcomeScreen = isHeadless() import NotificationScreen from '../chrome/NotificationScreen'; import SupportRequestFormV2 from '../fb-stubs/SupportRequestFormV2'; import SupportRequestDetails from '../fb-stubs/SupportRequestDetails'; -import { - getPluginKey, - defaultEnabledBackgroundPlugins, -} from '../utils/pluginUtils'; +import {getPluginKey} from '../utils/pluginUtils'; import {deconstructClientId} from '../utils/clientUtils'; import {FlipperDevicePlugin, PluginDefinition, isSandyPlugin} from '../plugin'; import {RegisterPluginAction} from './plugins'; @@ -63,7 +60,7 @@ export type State = { deviceId?: string; errorMessage?: string; }>; - deepLinkPayload: string | null; + deepLinkPayload: unknown; staticView: StaticView; }; @@ -89,7 +86,7 @@ export type Action = payload: { selectedPlugin: null | string; selectedApp?: null | string; - deepLinkPayload: null | string; + deepLinkPayload: unknown; selectedDevice?: null | BaseDevice; time: number; }; @@ -245,8 +242,8 @@ export default (state: State = INITAL_STATE, action: Actions): State => { const {payload} = action; const {selectedPlugin, selectedApp, deepLinkPayload} = payload; let selectedDevice = payload.selectedDevice; - if (deepLinkPayload) { - const deepLinkParams = new URLSearchParams(deepLinkPayload || ''); + if (typeof deepLinkPayload === 'string') { + const deepLinkParams = new URLSearchParams(deepLinkPayload); const deviceParam = deepLinkParams.get('device'); const deviceMatch = state.devices.find((v) => v.title === deviceParam); if (deviceMatch) { @@ -460,7 +457,7 @@ export const selectPlugin = (payload: { selectedPlugin: null | string; selectedApp?: null | string; selectedDevice?: BaseDevice | null; - deepLinkPayload: null | string; + deepLinkPayload: unknown; time?: number; }): Action => ({ type: 'SELECT_PLUGIN', diff --git a/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx index cf8eeccac..bc97dee51 100644 --- a/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx @@ -10,6 +10,7 @@ import * as TestUtils from '../test-utils/test-utils'; import * as testPlugin from './TestPlugin'; import {createState} from '../state/atom'; +import {FlipperClient} from '../plugin/Plugin'; test('it can start a plugin and lifecycle events', () => { 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\\""`, ); }); + +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'); +}); diff --git a/desktop/flipper-plugin/src/plugin/Plugin.tsx b/desktop/flipper-plugin/src/plugin/Plugin.tsx index 2f0bf274b..4407717e4 100644 --- a/desktop/flipper-plugin/src/plugin/Plugin.tsx +++ b/desktop/flipper-plugin/src/plugin/Plugin.tsx @@ -46,6 +46,11 @@ export interface FlipperClient< */ 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 */ @@ -113,6 +118,8 @@ export class SandyPluginInstance { initialStates?: Record; // all the atoms that should be serialized when making an export / import rootStates: Record> = {}; + // last seen deeplink + lastDeeplink?: any; constructor( realClient: RealFlipperClient, @@ -143,6 +150,9 @@ export class SandyPluginInstance { onMessage: (event, callback) => { this.events.on('event-' + event, callback); }, + onDeepLink: (callback) => { + this.events.on('deeplink', callback); + }, }; currentPluginInstance = this; this.initialStates = initialStates; @@ -165,6 +175,7 @@ export class SandyPluginInstance { // the plugin is deselected in the UI deactivate() { + this.lastDeeplink = undefined; if (this.destroyed) { // 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 @@ -211,6 +222,14 @@ export class SandyPluginInstance { return '[SandyPluginInstance]'; } + triggerDeepLink(deepLink: unknown) { + this.assertNotDestroyed(); + if (deepLink !== this.lastDeeplink) { + this.lastDeeplink = deepLink; + this.events.emit('deeplink', deepLink); + } + } + exportState() { return Object.fromEntries( Object.entries(this.rootStates).map(([key, atom]) => [key, atom.get()]), diff --git a/desktop/flipper-plugin/src/test-utils/test-utils.tsx b/desktop/flipper-plugin/src/test-utils/test-utils.tsx index 980898fee..868d39116 100644 --- a/desktop/flipper-plugin/src/test-utils/test-utils.tsx +++ b/desktop/flipper-plugin/src/test-utils/test-utils.tsx @@ -100,6 +100,8 @@ interface StartPluginResult> { }[], ): void; + triggerDeepLink(deeplink: unknown): void; + exportState(): any; } @@ -164,6 +166,9 @@ export function startPlugin>( }); }, exportState: () => pluginInstance.exportState(), + triggerDeepLink: (deepLink: unknown) => { + pluginInstance.triggerDeepLink(deepLink); + }, }; // @ts-ignore res._backingInstance = pluginInstance; diff --git a/desktop/plugins/layout/index.tsx b/desktop/plugins/layout/index.tsx index 591f97581..15e75d0a5 100644 --- a/desktop/plugins/layout/index.tsx +++ b/desktop/plugins/layout/index.tsx @@ -242,9 +242,10 @@ export default class LayoutPlugin extends FlipperPlugin< this.setState({ init: true, - selectedElement: this.props.deepLinkPayload - ? this.props.deepLinkPayload - : null, + selectedElement: + typeof this.props.deepLinkPayload === 'string' + ? this.props.deepLinkPayload + : null, }); } @@ -458,7 +459,11 @@ export default class LayoutPlugin extends FlipperPlugin< this.setState({searchResults}) } inAXMode={this.state.inAXMode} - initialQuery={this.props.deepLinkPayload} + initialQuery={ + typeof this.props.deepLinkPayload === 'string' + ? this.props.deepLinkPayload + : null + } /> diff --git a/desktop/plugins/logs/index.tsx b/desktop/plugins/logs/index.tsx index 8ec2ce7df..506266d2b 100644 --- a/desktop/plugins/logs/index.tsx +++ b/desktop/plugins/logs/index.tsx @@ -438,11 +438,11 @@ export default class LogTable extends FlipperDevicePlugin< }; calculateHighlightedRows = ( - deepLinkPayload: string | null, + deepLinkPayload: unknown, rows: ReadonlyArray, ): Set => { const highlightedRows = new Set(); - if (!deepLinkPayload) { + if (typeof deepLinkPayload !== 'string') { return highlightedRows; } diff --git a/desktop/plugins/network/index.tsx b/desktop/plugins/network/index.tsx index bff84ed73..71d454e6e 100644 --- a/desktop/plugins/network/index.tsx +++ b/desktop/plugins/network/index.tsx @@ -273,10 +273,10 @@ export default class extends FlipperPlugin { }; parseDeepLinkPayload = ( - deepLinkPayload: string | null, + deepLinkPayload: unknown, ): Pick => { const searchTermDelim = 'searchTerm='; - if (deepLinkPayload === null) { + if (typeof deepLinkPayload !== 'string') { return { selectedIds: [], searchTerm: '',