diff --git a/desktop/app/src/Client.tsx b/desktop/app/src/Client.tsx index 8ced63073..7e8c492e0 100644 --- a/desktop/app/src/Client.tsx +++ b/desktop/app/src/Client.tsx @@ -40,6 +40,7 @@ import {debounce} from 'lodash'; import {batch} from 'react-redux'; import {SandyPluginInstance} from 'flipper-plugin'; import {flipperMessagesClientPlugin} from './utils/self-inspection/plugins/FlipperMessagesClientPlugin'; +import {getFlipperLibImplementation} from './utils/flipperLibImplementation'; type Plugins = Array; @@ -310,7 +311,12 @@ export default class Client extends EventEmitter { // TODO: needs to be wrapped in error tracking T68955280 this.sandyPluginStates.set( plugin.id, - new SandyPluginInstance(this, plugin, initialStates[pluginId]), + new SandyPluginInstance( + getFlipperLibImplementation(), + plugin, + this, + initialStates[pluginId], + ), ); } }); @@ -355,7 +361,7 @@ export default class Client extends EventEmitter { // TODO: needs to be wrapped in error tracking T68955280 this.sandyPluginStates.set( plugin.id, - new SandyPluginInstance(this, plugin), + new SandyPluginInstance(getFlipperLibImplementation(), plugin, this), ); } } diff --git a/desktop/app/src/MenuBar.tsx b/desktop/app/src/MenuBar.tsx index 82b5f4eeb..24bc7a1bd 100644 --- a/desktop/app/src/MenuBar.tsx +++ b/desktop/app/src/MenuBar.tsx @@ -26,8 +26,9 @@ import electron, {MenuItemConstructorOptions} from 'electron'; import {notNull} from './utils/typeUtils'; import constants from './fb-stubs/constants'; import {Logger} from './fb-interfaces/Logger'; +import {NormalizedMenuEntry, buildInMenuEntries} from 'flipper-plugin'; -export type DefaultKeyboardAction = 'clear' | 'goToBottom' | 'createPaste'; +export type DefaultKeyboardAction = keyof typeof buildInMenuEntries; export type TopLevelMenu = 'Edit' | 'View' | 'Window' | 'Help'; export type KeyboardAction = { @@ -37,26 +38,6 @@ export type KeyboardAction = { topLevelMenu: TopLevelMenu; }; -const defaultKeyboardActions: Array = [ - { - label: 'Clear', - accelerator: 'CmdOrCtrl+K', - topLevelMenu: 'View', - action: 'clear', - }, - { - label: 'Go To Bottom', - accelerator: 'CmdOrCtrl+B', - topLevelMenu: 'View', - action: 'goToBottom', - }, - { - label: 'Create Paste', - topLevelMenu: 'Edit', - action: 'createPaste', - }, -]; - export type KeyboardActions = Array; const menuItems: Map = new Map(); @@ -82,14 +63,12 @@ export function setupMenuBar( logger, ); // collect all keyboard actions from all plugins - const registeredActions: Set = new Set( + const registeredActions = new Set( plugins .map((plugin) => plugin.keyboardActions || []) - .reduce((acc: KeyboardActions, cv) => acc.concat(cv), []) + .flat() .map((action: DefaultKeyboardAction | KeyboardAction) => - typeof action === 'string' - ? defaultKeyboardActions.find((a) => a.action === action) - : action, + typeof action === 'string' ? buildInMenuEntries[action] : action, ) .filter(notNull), ); @@ -97,7 +76,7 @@ export function setupMenuBar( // add keyboard actions to registeredActions.forEach((keyboardAction) => { if (keyboardAction != null) { - appendMenuItem(template, actionHandler, keyboardAction); + appendMenuItem(template, keyboardAction); } }); @@ -126,7 +105,6 @@ export function setupMenuBar( function appendMenuItem( template: Array, - actionHandler: (action: string) => void, item: KeyboardAction, ) { const keyboardAction = item; @@ -183,6 +161,49 @@ export function activateMenuItems( ); } +export function addSandyPluginEntries(entries: NormalizedMenuEntry[]) { + if (!electron.remote.Menu) { + return; + } + + // disable all keyboard actions + for (const item of menuItems) { + item[1].enabled = false; + } + + pluginActionHandler = (action: string) => { + entries.find((e) => e.action === action)?.handler(); + }; + + let changedItems = false; + const currentMenu = electron.remote.Menu.getApplicationMenu(); + for (const entry of entries) { + const item = menuItems.get(entry.action!); + if (item) { + item.enabled = true; + item.accelerator = entry.accelerator; + } else { + const parent = currentMenu?.items.find( + (i) => i.label === entry.topLevelMenu, + ); + if (parent) { + const item = new electron.remote.MenuItem({ + enabled: true, + click: () => pluginActionHandler?.(entry.action!), + label: entry.label, + accelerator: entry.accelerator, + }); + parent.submenu!.append(item); + menuItems.set(entry.action!, item); + changedItems = true; + } + } + } + if (changedItems) { + electron.remote.Menu.setApplicationMenu(currentMenu); + } +} + function getTemplate( app: electron.App, shell: electron.Shell, diff --git a/desktop/app/src/devices/AndroidDevice.tsx b/desktop/app/src/devices/AndroidDevice.tsx index b069fdc0f..6d1063307 100644 --- a/desktop/app/src/devices/AndroidDevice.tsx +++ b/desktop/app/src/devices/AndroidDevice.tsx @@ -12,7 +12,7 @@ import adb, {Client as ADBClient} from 'adbkit'; import {Priority} from 'adbkit-logcat'; import ArchivedDevice from './ArchivedDevice'; import {createWriteStream} from 'fs'; -import {LogLevel, DeviceType} from 'flipper-plugin'; +import type {LogLevel, DeviceType} from 'flipper-plugin'; const DEVICE_RECORDING_DIR = '/sdcard/flipper_recorder'; diff --git a/desktop/app/src/devices/ArchivedDevice.tsx b/desktop/app/src/devices/ArchivedDevice.tsx index d8eb8495b..32ffdbc20 100644 --- a/desktop/app/src/devices/ArchivedDevice.tsx +++ b/desktop/app/src/devices/ArchivedDevice.tsx @@ -7,8 +7,8 @@ * @format */ -import {DeviceLogEntry, DeviceType} from 'flipper-plugin'; import BaseDevice from './BaseDevice'; +import type {DeviceLogEntry, DeviceType} from 'flipper-plugin'; import {OS, DeviceShell} from './BaseDevice'; import {SupportFormRequestDetailsState} from '../reducers/supportForm'; diff --git a/desktop/app/src/devices/BaseDevice.tsx b/desktop/app/src/devices/BaseDevice.tsx index dd7829907..73e4703c1 100644 --- a/desktop/app/src/devices/BaseDevice.tsx +++ b/desktop/app/src/devices/BaseDevice.tsx @@ -8,7 +8,7 @@ */ import stream from 'stream'; -import {DeviceLogListener} from 'flipper'; +import type {DeviceLogListener} from 'flipper'; import {sortPluginsByName} from '../utils/pluginUtils'; import { DeviceLogEntry, @@ -16,7 +16,8 @@ import { SandyPluginDefinition, DeviceType, } from 'flipper-plugin'; -import {DevicePluginMap, FlipperDevicePlugin} from '../plugin'; +import type {DevicePluginMap, FlipperDevicePlugin} from '../plugin'; +import {getFlipperLibImplementation} from '../utils/flipperLibImplementation'; export type DeviceShell = { stdout: stream.Readable; @@ -180,7 +181,11 @@ export default class BaseDevice { this.devicePlugins.push(plugin.id); this.sandyPluginStates.set( plugin.id, - new SandyDevicePluginInstance(this, plugin), + new SandyDevicePluginInstance( + getFlipperLibImplementation(), + plugin, + this, + ), ); // TODO T70582933: pass initial state if applicable } } else { diff --git a/desktop/app/src/devices/FlipperSelfInspectionDevice.tsx b/desktop/app/src/devices/FlipperSelfInspectionDevice.tsx index 61761c665..a1b7a7408 100644 --- a/desktop/app/src/devices/FlipperSelfInspectionDevice.tsx +++ b/desktop/app/src/devices/FlipperSelfInspectionDevice.tsx @@ -8,7 +8,7 @@ */ import BaseDevice, {OS} from './BaseDevice'; -import {DeviceType} from 'flipper-plugin'; +import type {DeviceType} from 'flipper-plugin'; export default class FlipperSelfInspectionDevice extends BaseDevice { constructor(serial: string, deviceType: DeviceType, title: string, os: OS) { diff --git a/desktop/app/src/devices/IOSDevice.tsx b/desktop/app/src/devices/IOSDevice.tsx index 103a2c52f..630b3fd19 100644 --- a/desktop/app/src/devices/IOSDevice.tsx +++ b/desktop/app/src/devices/IOSDevice.tsx @@ -7,7 +7,7 @@ * @format */ -import {LogLevel, DeviceLogEntry, DeviceType} from 'flipper-plugin'; +import type {LogLevel, DeviceLogEntry, DeviceType} from 'flipper-plugin'; import child_process, {ChildProcess} from 'child_process'; import BaseDevice from './BaseDevice'; import JSONStream from 'JSONStream'; diff --git a/desktop/app/src/dispatcher/iOSDevice.tsx b/desktop/app/src/dispatcher/iOSDevice.tsx index f2b173a1d..3ba3cf625 100644 --- a/desktop/app/src/dispatcher/iOSDevice.tsx +++ b/desktop/app/src/dispatcher/iOSDevice.tsx @@ -11,7 +11,7 @@ import {ChildProcess} from 'child_process'; import {Store} from '../reducers/index'; import {setXcodeDetected} from '../reducers/application'; import {Logger} from '../fb-interfaces/Logger'; -import {DeviceType} from 'flipper-plugin'; +import type {DeviceType} from 'flipper-plugin'; import {promisify} from 'util'; import path from 'path'; import child_process from 'child_process'; diff --git a/desktop/app/src/init.tsx b/desktop/app/src/init.tsx index e26ad76d9..711bb23cc 100644 --- a/desktop/app/src/init.tsx +++ b/desktop/app/src/init.tsx @@ -10,6 +10,7 @@ import {Provider} from 'react-redux'; import ReactDOM from 'react-dom'; import {useState, useEffect} from 'react'; + import ContextMenuProvider from './ui/components/ContextMenuProvider'; import GK from './fb-stubs/GK'; import {init as initLogger} from './fb-stubs/Logger'; @@ -36,6 +37,7 @@ import {enableMapSet} from 'immer'; import os from 'os'; import QuickPerformanceLogger, {FLIPPER_QPL_EVENTS} from './fb-stubs/QPL'; import {PopoverProvider} from './ui/components/PopoverProvider'; +import {initializeFlipperLibImplementation} from './utils/flipperLibImplementation'; if (process.env.NODE_ENV === 'development' && os.platform() === 'darwin') { // By default Node.JS has its internal certificate storage and doesn't use @@ -111,6 +113,7 @@ function setProcessState(store: Store) { } function init() { + initializeFlipperLibImplementation(store, logger); ReactDOM.render(, document.getElementById('root')); initLauncherHooks(config(), store); const sessionId = store.getState().application.sessionId; diff --git a/desktop/app/src/reducers/connections.tsx b/desktop/app/src/reducers/connections.tsx index f61a2a914..d68a420e5 100644 --- a/desktop/app/src/reducers/connections.tsx +++ b/desktop/app/src/reducers/connections.tsx @@ -25,7 +25,7 @@ import SupportRequestFormV2 from '../fb-stubs/SupportRequestFormV2'; import SupportRequestDetails from '../fb-stubs/SupportRequestDetails'; import {getPluginKey, isDevicePluginDefinition} from '../utils/pluginUtils'; import {deconstructClientId} from '../utils/clientUtils'; -import {FlipperDevicePlugin, PluginDefinition, isSandyPlugin} from '../plugin'; +import {PluginDefinition} from '../plugin'; import {RegisterPluginAction} from './plugins'; export type StaticView = diff --git a/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx b/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx index a2b33492d..04d796818 100644 --- a/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx +++ b/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx @@ -16,6 +16,7 @@ import { act as testingLibAct, } from '@testing-library/react'; import {queries} from '@testing-library/dom'; +import {TestUtils} from 'flipper-plugin'; import { selectPlugin, @@ -36,6 +37,7 @@ import {registerPlugins} from '../reducers/plugins'; import PluginContainer from '../PluginContainer'; import {getPluginKey, isDevicePluginDefinition} from '../utils/pluginUtils'; import {getInstance} from '../fb-stubs/Logger'; +import {setFlipperLibImplementation} from '../utils/flipperLibImplementation'; type MockFlipperResult = { client: Client; @@ -62,6 +64,8 @@ export async function createMockFlipperWithPlugin( ): Promise { const store = createStore(rootReducer); const logger = getInstance(); + setFlipperLibImplementation(TestUtils.createMockFlipperLib()); + store.dispatch(registerPlugins([pluginClazz])); function createDevice(serial: string): BaseDevice { diff --git a/desktop/app/src/utils/flipperLibImplementation.tsx b/desktop/app/src/utils/flipperLibImplementation.tsx new file mode 100644 index 000000000..01a9cd657 --- /dev/null +++ b/desktop/app/src/utils/flipperLibImplementation.tsx @@ -0,0 +1,38 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import type {FlipperLib} from 'flipper-plugin'; +import type {Logger} from '../fb-interfaces/Logger'; +import type {Store} from '../reducers'; + +let flipperLibInstance: FlipperLib | undefined; + +export function initializeFlipperLibImplementation( + _store: Store, + _logger: Logger, +) { + // late require to avoid cyclic dependency + const {addSandyPluginEntries} = require('../MenuBar'); + flipperLibInstance = { + enableMenuEntries(entries) { + addSandyPluginEntries(entries); + }, + }; +} + +export function getFlipperLibImplementation(): FlipperLib { + if (!flipperLibInstance) { + throw new Error('Flipper lib not instantiated'); + } + return flipperLibInstance; +} + +export function setFlipperLibImplementation(impl: FlipperLib) { + flipperLibInstance = impl; +} diff --git a/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx index 0aaa3e323..8f9145808 100644 --- a/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx @@ -286,3 +286,45 @@ test('device plugins can receive deeplinks', async () => { plugin.triggerDeepLink('test'); expect(plugin.instance.field1.get()).toBe('test'); }); + +test('plugins can register menu entries', async () => { + const plugin = TestUtils.startPlugin({ + plugin(client: PluginClient) { + const counter = createState(0); + client.addMenuEntry( + { + action: 'createPaste', + handler() { + counter.set(counter.get() + 1); + }, + }, + { + label: 'Custom Action', + topLevelMenu: 'Edit', + handler() { + counter.set(counter.get() + 3); + }, + }, + ); + return {counter}; + }, + Component() { + return null; + }, + }); + + expect(plugin.instance.counter.get()).toBe(0); + plugin.triggerDeepLink('test'); + plugin.triggerMenuEntry('createPaste'); + plugin.triggerMenuEntry('Custom Action'); + expect(plugin.instance.counter.get()).toBe(4); + expect(plugin.flipperLib.enableMenuEntries).toBeCalledTimes(1); + + plugin.deactivate(); + + expect(() => { + plugin.triggerMenuEntry('Non Existing'); + }).toThrowErrorMatchingInlineSnapshot( + `"No menu entry found with action: Non Existing"`, + ); +}); diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index bd85c070c..641ae5854 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -7,6 +7,7 @@ * @format */ +import './plugin/PluginBase'; import * as TestUtilites from './test-utils/test-utils'; export { @@ -26,6 +27,12 @@ export {SandyPluginDefinition} from './plugin/SandyPluginDefinition'; export {SandyPluginRenderer} from './plugin/PluginRenderer'; export {SandyPluginContext, usePlugin} from './plugin/PluginContext'; export {createState, useValue, Atom} from './state/atom'; +export {FlipperLib} from './plugin/FlipperLib'; +export { + MenuEntry, + NormalizedMenuEntry, + buildInMenuEntries, +} from './plugin/MenuEntry'; // It's not ideal that this exists in flipper-plugin sources directly, // but is the least pain for plugin authors. diff --git a/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx b/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx index 0f9ed440e..911a1ca73 100644 --- a/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx +++ b/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx @@ -9,6 +9,7 @@ import {SandyPluginDefinition} from './SandyPluginDefinition'; import {BasePluginInstance, BasePluginClient} from './PluginBase'; +import {FlipperLib} from './FlipperLib'; export type DeviceLogListener = (entry: DeviceLogEntry) => void; @@ -69,11 +70,12 @@ export class SandyDevicePluginInstance extends BasePluginInstance { client: DevicePluginClient; constructor( - realDevice: RealFlipperDevice, + flipperLib: FlipperLib, definition: SandyPluginDefinition, + realDevice: RealFlipperDevice, initialStates?: Record, ) { - super(definition, initialStates); + super(flipperLib, definition, initialStates); const device: Device = { // N.B. we model OS as string, not as enum, to make custom device types possible in the future os: realDevice.os, diff --git a/desktop/flipper-plugin/src/plugin/FlipperLib.tsx b/desktop/flipper-plugin/src/plugin/FlipperLib.tsx new file mode 100644 index 000000000..7c4c3e6e5 --- /dev/null +++ b/desktop/flipper-plugin/src/plugin/FlipperLib.tsx @@ -0,0 +1,17 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import {NormalizedMenuEntry} from './MenuEntry'; + +/** + * This interface exposes all global methods for which an implementation will be provided by Flipper itself + */ +export interface FlipperLib { + enableMenuEntries(menuEntries: NormalizedMenuEntry[]): void; +} diff --git a/desktop/flipper-plugin/src/plugin/MenuEntry.tsx b/desktop/flipper-plugin/src/plugin/MenuEntry.tsx new file mode 100644 index 000000000..85b22e915 --- /dev/null +++ b/desktop/flipper-plugin/src/plugin/MenuEntry.tsx @@ -0,0 +1,66 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +export type DefaultKeyboardAction = 'clear' | 'goToBottom' | 'createPaste'; +export type TopLevelMenu = 'Edit' | 'View' | 'Window' | 'Help'; + +export type MenuEntry = BuiltInMenuEntry | CustomMenuEntry; + +export type NormalizedMenuEntry = { + label: string; + accelerator?: string; + topLevelMenu: TopLevelMenu; + handler: () => void; + action: string; +}; + +export type CustomMenuEntry = { + label: string; + accelerator?: string; + topLevelMenu: TopLevelMenu; + handler: () => void; +}; + +export type BuiltInMenuEntry = { + action: keyof typeof buildInMenuEntries; + handler: () => void; +}; + +export const buildInMenuEntries = { + clear: { + label: 'Clear', + accelerator: 'CmdOrCtrl+K', + topLevelMenu: 'View', + action: 'clear', + }, + goToBottom: { + label: 'Go To Bottom', + accelerator: 'CmdOrCtrl+B', + topLevelMenu: 'View', + action: 'goToBottom', + }, + createPaste: { + label: 'Create Paste', + topLevelMenu: 'Edit', + action: 'createPaste', + }, +} as const; + +export function normalizeMenuEntry(entry: MenuEntry): NormalizedMenuEntry; +export function normalizeMenuEntry(entry: any): NormalizedMenuEntry { + const builtInEntry: + | NormalizedMenuEntry + | undefined = (buildInMenuEntries as any)[entry.action]; + return builtInEntry + ? {...builtInEntry, ...entry} + : { + ...entry, + action: entry.action || entry.label, + }; +} diff --git a/desktop/flipper-plugin/src/plugin/Plugin.tsx b/desktop/flipper-plugin/src/plugin/Plugin.tsx index 70194db43..99fad1260 100644 --- a/desktop/flipper-plugin/src/plugin/Plugin.tsx +++ b/desktop/flipper-plugin/src/plugin/Plugin.tsx @@ -9,6 +9,7 @@ import {SandyPluginDefinition} from './SandyPluginDefinition'; import {BasePluginInstance, BasePluginClient} from './PluginBase'; +import {FlipperLib} from './FlipperLib'; type EventsContract = Record; type MethodsContract = Record Promise>; @@ -96,11 +97,12 @@ export class SandyPluginInstance extends BasePluginInstance { connected = false; constructor( - realClient: RealFlipperClient, + flipperLib: FlipperLib, definition: SandyPluginDefinition, + realClient: RealFlipperClient, initialStates?: Record, ) { - super(definition, initialStates); + super(flipperLib, definition, initialStates); this.realClient = realClient; this.definition = definition; this.client = { diff --git a/desktop/flipper-plugin/src/plugin/PluginBase.tsx b/desktop/flipper-plugin/src/plugin/PluginBase.tsx index f5c0a46ef..40865489b 100644 --- a/desktop/flipper-plugin/src/plugin/PluginBase.tsx +++ b/desktop/flipper-plugin/src/plugin/PluginBase.tsx @@ -10,6 +10,8 @@ import {SandyPluginDefinition} from './SandyPluginDefinition'; import {EventEmitter} from 'events'; import {Atom} from '../state/atom'; +import {MenuEntry, NormalizedMenuEntry, normalizeMenuEntry} from './MenuEntry'; +import {FlipperLib} from './FlipperLib'; export interface BasePluginClient { /** @@ -31,6 +33,11 @@ export interface BasePluginClient { * Triggered when this plugin is opened through a deeplink */ onDeepLink(cb: (deepLink: unknown) => void): void; + + /** + * Register menu entries in the Flipper toolbar + */ + addMenuEntry(...entry: MenuEntry[]): void; } let currentPluginInstance: BasePluginInstance | undefined = undefined; @@ -46,6 +53,8 @@ export function getCurrentPluginInstance(): typeof currentPluginInstance { } export abstract class BasePluginInstance { + /** generally available Flipper APIs */ + flipperLib: FlipperLib; /** the original plugin definition */ definition: SandyPluginDefinition; /** the plugin instance api as used inside components and such */ @@ -62,10 +71,14 @@ export abstract class BasePluginInstance { // last seen deeplink lastDeeplink?: any; + menuEntries: NormalizedMenuEntry[] = []; + constructor( + flipperLib: FlipperLib, definition: SandyPluginDefinition, initialStates?: Record, ) { + this.flipperLib = flipperLib; this.definition = definition; this.initialStates = initialStates; } @@ -95,6 +108,21 @@ export abstract class BasePluginInstance { onDestroy: (cb) => { this.events.on('destroy', cb); }, + addMenuEntry: (...entries) => { + for (const entry of entries) { + const normalized = normalizeMenuEntry(entry); + if ( + this.menuEntries.find( + (existing) => + existing.label === normalized.label || + existing.action === normalized.action, + ) + ) { + throw new Error(`Duplicate menu entry: '${normalized.label}'`); + } + this.menuEntries.push(normalizeMenuEntry(entry)); + } + }, }; } @@ -103,6 +131,7 @@ export abstract class BasePluginInstance { this.assertNotDestroyed(); if (!this.activated) { this.activated = true; + this.flipperLib.enableMenuEntries(this.menuEntries); this.events.emit('activate'); } } @@ -112,8 +141,8 @@ export abstract class BasePluginInstance { return; } if (this.activated) { - this.lastDeeplink = undefined; this.activated = false; + this.lastDeeplink = undefined; this.events.emit('deactivate'); } } diff --git a/desktop/flipper-plugin/src/test-utils/test-utils.tsx b/desktop/flipper-plugin/src/test-utils/test-utils.tsx index 7c975f650..e789789cf 100644 --- a/desktop/flipper-plugin/src/test-utils/test-utils.tsx +++ b/desktop/flipper-plugin/src/test-utils/test-utils.tsx @@ -35,6 +35,7 @@ import { DeviceLogListener, } from '../plugin/DevicePlugin'; import {BasePluginInstance} from '../plugin/PluginBase'; +import {FlipperLib} from '../plugin/FlipperLib'; type Renderer = RenderResult; @@ -61,6 +62,11 @@ type ExtractEventsType< : never; interface BasePluginResult { + /** + * Mock for Flipper utilities + */ + flipperLib: FlipperLib; + /** * Emulates the 'onActivate' event */ @@ -84,6 +90,11 @@ interface BasePluginResult { * Grab all the persistable state */ exportState(): any; + + /** + * Trigger menu entry by label + */ + triggerMenuEntry(label: string): void; } interface StartPluginResult> @@ -165,8 +176,9 @@ export function startPlugin>( } const sendStub = jest.fn(); - const fakeFlipper: RealFlipperClient = { - isBackgroundPlugin() { + const flipperUtils = createMockFlipperLib(); + const fakeFlipperClient: RealFlipperClient = { + isBackgroundPlugin(_pluginId: string) { return !!options?.isBackgroundPlugin; }, initPlugin() { @@ -186,8 +198,9 @@ export function startPlugin>( }; const pluginInstance = new SandyPluginInstance( - fakeFlipper, + flipperUtils, definition, + fakeFlipperClient, options?.initialState, ); @@ -258,10 +271,12 @@ export function startDevicePlugin( ); } + const flipperLib = createMockFlipperLib(); const testDevice = createMockDevice(options); const pluginInstance = new SandyDevicePluginInstance( - testDevice, + flipperLib, definition, + testDevice, options?.initialState, ); @@ -306,10 +321,17 @@ export function renderDevicePlugin( }; } +export function createMockFlipperLib(): FlipperLib { + return { + enableMenuEntries: jest.fn(), + }; +} + function createBasePluginResult( pluginInstance: BasePluginInstance, ): BasePluginResult { return { + flipperLib: pluginInstance.flipperLib, activate: () => pluginInstance.activate(), deactivate: () => pluginInstance.deactivate(), exportState: () => pluginInstance.exportState(), @@ -317,6 +339,13 @@ function createBasePluginResult( pluginInstance.triggerDeepLink(deepLink); }, destroy: () => pluginInstance.destroy(), + triggerMenuEntry: (action: string) => { + const entry = pluginInstance.menuEntries.find((e) => e.action === action); + if (!entry) { + throw new Error('No menu entry found with action: ' + action); + } + entry.handler(); + }, }; } diff --git a/desktop/plugins/seamammals/src/index.tsx b/desktop/plugins/seamammals/src/index.tsx index ee92d2938..393298fec 100644 --- a/desktop/plugins/seamammals/src/index.tsx +++ b/desktop/plugins/seamammals/src/index.tsx @@ -49,6 +49,22 @@ export function plugin(client: FlipperClient) { const rows = createState({}, {persist: 'rows'}); const selectedID = createState(null, {persist: 'selection'}); + client.addMenuEntry( + { + label: 'Reset Selection', + topLevelMenu: 'Edit', + handler: () => { + selectedID.set(null); + }, + }, + { + action: 'createPaste', + handler: () => { + console.log('creating paste'); + }, + }, + ); + client.onMessage('newRow', (row) => { rows.update((draft) => { draft[row.id] = row;