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: {
selectedPlugin: string | null;
selectedApp: string | null;
deepLinkPayload: string | null;
deepLinkPayload: unknown;
}) => any;
updatePluginBlacklist: (blacklist: Array<string>) => any;
updateCategoryBlacklist: (blacklist: Array<string>) => 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;

View File

@@ -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<Props, State> {
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<Props, State> {
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 (

View File

@@ -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 <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';
type StateFromProps = {
deepLinkPayload: string | null;
deepLinkPayload: unknown;
blacklistedPlugins: Array<string>;
blacklistedCategories: Array<string>;
};
@@ -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<Props, State> {
<Container>
<ConnectedNotificationsTable
onClear={clearAllNotifications}
selectedID={deepLinkPayload}
selectedID={
typeof deepLinkPayload === 'string' ? deepLinkPayload : null
}
onSelectPlugin={selectPlugin}
logger={logger}
defaultFilters={[

View File

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

View File

@@ -89,8 +89,8 @@ export type Props<T> = {
persistedState: T;
setPersistedState: (state: Partial<T>) => 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;

View File

@@ -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',

View File

@@ -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');
});

View File

@@ -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<string, any>;
// all the atoms that should be serialized when making an export / import
rootStates: Record<string, Atom<any>> = {};
// 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()]),

View File

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

View File

@@ -242,7 +242,8 @@ export default class LayoutPlugin extends FlipperPlugin<
this.setState({
init: true,
selectedElement: this.props.deepLinkPayload
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
}
/>
</Toolbar>
<Layout.Right>

View File

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

View File

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