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:
committed by
Facebook GitHub Bot
parent
485b4c9827
commit
f0c54667e0
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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!']);
|
||||
});
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -190,7 +190,7 @@ type StateFromProps = {
|
||||
type SelectPlugin = (payload: {
|
||||
selectedPlugin: string | null;
|
||||
selectedApp?: string | null;
|
||||
deepLinkPayload: string | null;
|
||||
deepLinkPayload: unknown;
|
||||
selectedDevice: BaseDevice;
|
||||
}) => void;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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()]),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
Reference in New Issue
Block a user