From 3d0160463c39f429dec32c695131c51ac4f64426 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 16 Jun 2024 11:11:26 -0400 Subject: [PATCH] chore: Refactor client plugin management (#7053) * Update clientside plugin management to work as server * docs * tsc * Rebase main --- app/components/PluginIcon.tsx | 22 ++- app/hooks/useComputed.ts | 16 ++ app/hooks/useSettingsConfig.ts | 50 ++---- app/index.tsx | 4 + .../components/AuthenticationProvider.tsx | 20 ++- app/utils/Logger.ts | 3 +- app/utils/PluginLoader.ts | 54 ------- app/utils/PluginManager.ts | 145 ++++++++++++++++++ plugins/azure/client/index.tsx | 11 ++ plugins/discord/client/index.tsx | 11 ++ plugins/github/client/index.tsx | 16 ++ plugins/google/client/index.tsx | 11 ++ plugins/googleanalytics/client/index.tsx | 16 ++ plugins/matomo/client/index.tsx | 16 ++ plugins/slack/client/index.tsx | 21 +++ plugins/webhooks/client/index.tsx | 16 ++ server/utils/PluginManager.ts | 7 +- 17 files changed, 331 insertions(+), 108 deletions(-) create mode 100644 app/hooks/useComputed.ts delete mode 100644 app/utils/PluginLoader.ts create mode 100644 app/utils/PluginManager.ts create mode 100644 plugins/azure/client/index.tsx create mode 100644 plugins/discord/client/index.tsx create mode 100644 plugins/github/client/index.tsx create mode 100644 plugins/google/client/index.tsx create mode 100644 plugins/googleanalytics/client/index.tsx create mode 100644 plugins/matomo/client/index.tsx create mode 100644 plugins/slack/client/index.tsx create mode 100644 plugins/webhooks/client/index.tsx diff --git a/app/components/PluginIcon.tsx b/app/components/PluginIcon.tsx index 53dec09aee..bfffb2b031 100644 --- a/app/components/PluginIcon.tsx +++ b/app/components/PluginIcon.tsx @@ -1,29 +1,37 @@ +import { observer } from "mobx-react"; import * as React from "react"; import styled from "styled-components"; -import PluginLoader from "~/utils/PluginLoader"; +import Logger from "~/utils/Logger"; +import { Hook, usePluginValue } from "~/utils/PluginManager"; type Props = { + /** The ID of the plugin to render an Icon for. */ id: string; + /** The size of the icon. */ size?: number; + /** The color of the icon. */ color?: string; }; +/** + * Renders an icon defined in a plugin (Hook.Icon). + */ function PluginIcon({ id, color, size = 24 }: Props) { - const plugin = PluginLoader.plugins[id]; - const Icon = plugin?.icon; + const Icon = usePluginValue(Hook.Icon, id); if (Icon) { return ( - + - + ); } + Logger.warn("No Icon registered for plugin", { id }); return null; } -const Wrapper = styled.div` +const IconPosition = styled.div` display: flex; align-items: center; justify-content: center; @@ -32,4 +40,4 @@ const Wrapper = styled.div` height: 24px; `; -export default PluginIcon; +export default observer(PluginIcon); diff --git a/app/hooks/useComputed.ts b/app/hooks/useComputed.ts new file mode 100644 index 0000000000..187fb8742b --- /dev/null +++ b/app/hooks/useComputed.ts @@ -0,0 +1,16 @@ +import { computed } from "mobx"; +import { type DependencyList, useMemo } from "react"; + +/** + * Hook around MobX computed function that runs computation whenever observable values change. + * + * @param callback Function which returns a memorized value. + * @param inputs Dependency list for useMemo. + */ +export function useComputed( + callback: () => T, + inputs: DependencyList = [] +): T { + const value = useMemo(() => computed(callback), inputs); + return value.get(); +} diff --git a/app/hooks/useSettingsConfig.ts b/app/hooks/useSettingsConfig.ts index c97dfdfc9f..85a244720b 100644 --- a/app/hooks/useSettingsConfig.ts +++ b/app/hooks/useSettingsConfig.ts @@ -1,4 +1,3 @@ -import sortBy from "lodash/sortBy"; import { EmailIcon, ProfileIcon, @@ -20,10 +19,11 @@ import React, { ComponentProps } from "react"; import { useTranslation } from "react-i18next"; import { integrationSettingsPath } from "@shared/utils/routeHelpers"; import ZapierIcon from "~/components/Icons/ZapierIcon"; -import PluginLoader from "~/utils/PluginLoader"; +import { Hook, PluginManager } from "~/utils/PluginManager"; import isCloudHosted from "~/utils/isCloudHosted"; import lazy from "~/utils/lazyWithRetry"; import { settingsPath } from "~/utils/routeHelpers"; +import { useComputed } from "./useComputed"; import useCurrentTeam from "./useCurrentTeam"; import useCurrentUser from "./useCurrentUser"; import usePolicy from "./usePolicy"; @@ -59,7 +59,7 @@ const useSettingsConfig = () => { const can = usePolicy(team); const { t } = useTranslation(); - const config = React.useMemo(() => { + const config = useComputed(() => { const items: ConfigItem[] = [ // Account { @@ -187,37 +187,19 @@ const useSettingsConfig = () => { ]; // Plugins - const insertIndex = items.findIndex((i) => i.group === t("Integrations")); - items.splice( - insertIndex, - 0, - ...(sortBy( - Object.values(PluginLoader.plugins), - (plugin) => plugin.config?.priority ?? 0 - ).map((plugin) => { - const hasSettings = !!plugin.settings; - const enabledInDeployment = - !plugin.config?.deployments || - plugin.config.deployments.length === 0 || - (plugin.config.deployments.includes("community") && !isCloudHosted) || - (plugin.config.deployments.includes("cloud") && isCloudHosted) || - (plugin.config.deployments.includes("enterprise") && !isCloudHosted); - - return { - name: t(plugin.config.name), - path: integrationSettingsPath(plugin.id), - // TODO: Remove hardcoding of plugin id here - group: - plugin.id === "collections" ? t("Workspace") : t("Integrations"), - component: plugin.settings, - enabled: - enabledInDeployment && - hasSettings && - (plugin.config.roles?.includes(user.role) || can.update), - icon: plugin.icon, - }; - }) as ConfigItem[]) - ); + PluginManager.getHooks(Hook.Settings).forEach((plugin) => { + const insertIndex = items.findIndex( + (i) => i.group === t(plugin.value.group ?? "Integrations") + ); + items.splice(insertIndex, 0, { + name: t(plugin.name), + path: integrationSettingsPath(plugin.id), + group: t(plugin.value.group), + component: plugin.value.component, + enabled: plugin.roles?.includes(user.role) || can.update, + icon: plugin.value.icon, + } as ConfigItem); + }); return items; }, [t, can.createApiKey, can.update, can.createImport, can.createExport]); diff --git a/app/index.tsx b/app/index.tsx index 406b37d6b3..2f8c8c5d23 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -23,9 +23,13 @@ import LazyPolyfill from "./components/LazyPolyfills"; import PageScroll from "./components/PageScroll"; import Routes from "./routes"; import Logger from "./utils/Logger"; +import { PluginManager } from "./utils/PluginManager"; import history from "./utils/history"; import { initSentry } from "./utils/sentry"; +// Load plugins as soon as possible +void PluginManager.loadPlugins(); + initI18n(env.DEFAULT_LANGUAGE); const element = window.document.getElementById("root"); diff --git a/app/scenes/Login/components/AuthenticationProvider.tsx b/app/scenes/Login/components/AuthenticationProvider.tsx index 0eede84bd0..17ec432919 100644 --- a/app/scenes/Login/components/AuthenticationProvider.tsx +++ b/app/scenes/Login/components/AuthenticationProvider.tsx @@ -93,17 +93,15 @@ function AuthenticationProvider(props: Props) { } return ( - - (window.location.href = href)} - icon={} - fullwidth - > - {t("Continue with {{ authProviderName }}", { - authProviderName: name, - })} - - + (window.location.href = href)} + icon={} + fullwidth + > + {t("Continue with {{ authProviderName }}", { + authProviderName: name, + })} + ); } diff --git a/app/utils/Logger.ts b/app/utils/Logger.ts index ef371ff379..a3b6ba26d4 100644 --- a/app/utils/Logger.ts +++ b/app/utils/Logger.ts @@ -8,7 +8,8 @@ type LogCategory = | "editor" | "router" | "collaboration" - | "misc"; + | "misc" + | "plugins"; type Extra = Record; diff --git a/app/utils/PluginLoader.ts b/app/utils/PluginLoader.ts deleted file mode 100644 index 6593a40792..0000000000 --- a/app/utils/PluginLoader.ts +++ /dev/null @@ -1,54 +0,0 @@ -import React from "react"; -import { UserRole } from "@shared/types"; - -interface Plugin { - id: string; - config: { - name: string; - description: string; - roles?: UserRole[]; - deployments?: string[]; - priority?: number; - }; - settings: React.FC; - icon: React.FC<{ size?: number; fill?: string }>; -} - -export default class PluginLoader { - private static pluginsCache: { [id: string]: Plugin }; - - public static get plugins(): { [id: string]: Plugin } { - if (this.pluginsCache) { - return this.pluginsCache; - } - const plugins = {}; - - function importAll(r: any, property: string) { - Object.keys(r).forEach((key: string) => { - const id = key.split("/")[3]; - plugins[id] = plugins[id] || { - id, - }; - plugins[id][property] = r[key].default ?? React.lazy(r[key]); - }); - } - - importAll( - import.meta.glob("../../plugins/*/client/Settings.{ts,js,tsx,jsx}"), - "settings" - ); - importAll( - import.meta.glob("../../plugins/*/client/Icon.{ts,js,tsx,jsx}", { - eager: true, - }), - "icon" - ); - importAll( - import.meta.glob("../../plugins/*/plugin.json", { eager: true }), - "config" - ); - - this.pluginsCache = plugins; - return plugins; - } -} diff --git a/app/utils/PluginManager.ts b/app/utils/PluginManager.ts new file mode 100644 index 0000000000..9df44c8468 --- /dev/null +++ b/app/utils/PluginManager.ts @@ -0,0 +1,145 @@ +import isArray from "lodash/isArray"; +import sortBy from "lodash/sortBy"; +import { action, observable } from "mobx"; +import { useComputed } from "~/hooks/useComputed"; +import Logger from "./Logger"; +import isCloudHosted from "./isCloudHosted"; + +/** + * The different types of client plugins that can be registered. + */ +export enum Hook { + Settings = "settings", + Icon = "icon", +} + +/** + * A map of plugin types to their values, each plugin type has a different shape of value. + */ +type PluginValueMap = { + [Hook.Settings]: { + /** The group in settings sidebar this plugin belongs to. */ + group: string; + /** The displayed icon of the plugin. */ + icon: React.ElementType; + /** The settings screen somponent, should be lazy loaded. */ + component: React.LazyExoticComponent; + }; + [Hook.Icon]: React.ElementType; +}; + +export type Plugin = { + /** A unique identifier for the plugin */ + id: string; + /** Plugin type */ + type: T; + /** The plugin's display name */ + name: string; + /** A brief description of the plugin */ + description?: string; + /** The plugin content */ + value: PluginValueMap[T]; + /** Priority will affect order in menus and execution. Lower is earlier. */ + priority?: number; + /** The deployments this plugin is enabled for (default: all) */ + deployments?: string[]; + /** The roles this plugin is enabled for. (default: admin) */ + roles?: string[]; +}; + +/** + * Client plugin manager. + */ +export class PluginManager { + /** + * Add plugins to the manager. + * + * @param plugins + */ + public static add(plugins: Array> | Plugin) { + if (isArray(plugins)) { + return plugins.forEach((plugin) => this.register(plugin)); + } + + this.register(plugins); + } + + @action + private static register(plugin: Plugin) { + const enabledInDeployment = + !plugin?.deployments || + plugin.deployments.length === 0 || + (plugin.deployments.includes("cloud") && isCloudHosted) || + (plugin.deployments.includes("community") && !isCloudHosted) || + (plugin.deployments.includes("enterprise") && !isCloudHosted); + if (!enabledInDeployment) { + return; + } + + if (!this.plugins.has(plugin.type)) { + this.plugins.set(plugin.type, observable.array([])); + } + + this.plugins + .get(plugin.type)! + .push({ ...plugin, priority: plugin.priority ?? 0 }); + + Logger.debug( + "plugins", + `Plugin(type=${plugin.type}) registered ${plugin.name} ${ + plugin.description ? `(${plugin.description})` : "" + }` + ); + } + + /** + * Returns all the plugins of a given type in order of priority. + * + * @param type The type of plugin to filter by + * @returns A list of plugins + */ + public static getHooks(type: T) { + return sortBy(this.plugins.get(type) || [], "priority") as Plugin[]; + } + + /** + * Returns a plugin of a given type by its id. + * + * @param type The type of plugin to filter by + * @param id The id of the plugin + * @returns A plugin + */ + public static getHook(type: T, id: string) { + return this.plugins.get(type)?.find((hook) => hook.id === id) as + | Plugin + | undefined; + } + + /** + * Load plugin client components, must be in `//client/index.ts(x)` + */ + public static async loadPlugins() { + if (this.loaded) { + return; + } + + const r = import.meta.glob("../../plugins/*/client/index.{ts,js,tsx,jsx}"); + await Promise.all(Object.keys(r).map((key: string) => r[key]())); + this.loaded = true; + } + + private static plugins = observable.map[]>(); + + @observable + private static loaded = false; +} + +/** + * Convenience hook to get the value for a specific plugin and type. + */ +export function usePluginValue(type: T, id: string) { + return useComputed( + () => PluginManager.getHook(type, id)?.value, + [type, id] + ); +} diff --git a/plugins/azure/client/index.tsx b/plugins/azure/client/index.tsx new file mode 100644 index 0000000000..b3dbea4a24 --- /dev/null +++ b/plugins/azure/client/index.tsx @@ -0,0 +1,11 @@ +import { Hook, PluginManager } from "~/utils/PluginManager"; +import config from "../plugin.json"; +import Icon from "./Icon"; + +PluginManager.add([ + { + ...config, + type: Hook.Icon, + value: Icon, + }, +]); diff --git a/plugins/discord/client/index.tsx b/plugins/discord/client/index.tsx new file mode 100644 index 0000000000..b3dbea4a24 --- /dev/null +++ b/plugins/discord/client/index.tsx @@ -0,0 +1,11 @@ +import { Hook, PluginManager } from "~/utils/PluginManager"; +import config from "../plugin.json"; +import Icon from "./Icon"; + +PluginManager.add([ + { + ...config, + type: Hook.Icon, + value: Icon, + }, +]); diff --git a/plugins/github/client/index.tsx b/plugins/github/client/index.tsx new file mode 100644 index 0000000000..fbdc2d7824 --- /dev/null +++ b/plugins/github/client/index.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; +import { Hook, PluginManager } from "~/utils/PluginManager"; +import config from "../plugin.json"; +import Icon from "./Icon"; + +PluginManager.add([ + { + ...config, + type: Hook.Settings, + value: { + group: "Integrations", + icon: Icon, + component: React.lazy(() => import("./Settings")), + }, + }, +]); diff --git a/plugins/google/client/index.tsx b/plugins/google/client/index.tsx new file mode 100644 index 0000000000..b3dbea4a24 --- /dev/null +++ b/plugins/google/client/index.tsx @@ -0,0 +1,11 @@ +import { Hook, PluginManager } from "~/utils/PluginManager"; +import config from "../plugin.json"; +import Icon from "./Icon"; + +PluginManager.add([ + { + ...config, + type: Hook.Icon, + value: Icon, + }, +]); diff --git a/plugins/googleanalytics/client/index.tsx b/plugins/googleanalytics/client/index.tsx new file mode 100644 index 0000000000..fbdc2d7824 --- /dev/null +++ b/plugins/googleanalytics/client/index.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; +import { Hook, PluginManager } from "~/utils/PluginManager"; +import config from "../plugin.json"; +import Icon from "./Icon"; + +PluginManager.add([ + { + ...config, + type: Hook.Settings, + value: { + group: "Integrations", + icon: Icon, + component: React.lazy(() => import("./Settings")), + }, + }, +]); diff --git a/plugins/matomo/client/index.tsx b/plugins/matomo/client/index.tsx new file mode 100644 index 0000000000..fbdc2d7824 --- /dev/null +++ b/plugins/matomo/client/index.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; +import { Hook, PluginManager } from "~/utils/PluginManager"; +import config from "../plugin.json"; +import Icon from "./Icon"; + +PluginManager.add([ + { + ...config, + type: Hook.Settings, + value: { + group: "Integrations", + icon: Icon, + component: React.lazy(() => import("./Settings")), + }, + }, +]); diff --git a/plugins/slack/client/index.tsx b/plugins/slack/client/index.tsx new file mode 100644 index 0000000000..6f1797b65f --- /dev/null +++ b/plugins/slack/client/index.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import { Hook, PluginManager } from "~/utils/PluginManager"; +import config from "../plugin.json"; +import Icon from "./Icon"; + +PluginManager.add([ + { + ...config, + type: Hook.Settings, + value: { + group: "Integrations", + icon: Icon, + component: React.lazy(() => import("./Settings")), + }, + }, + { + ...config, + type: Hook.Icon, + value: Icon, + }, +]); diff --git a/plugins/webhooks/client/index.tsx b/plugins/webhooks/client/index.tsx new file mode 100644 index 0000000000..fbdc2d7824 --- /dev/null +++ b/plugins/webhooks/client/index.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; +import { Hook, PluginManager } from "~/utils/PluginManager"; +import config from "../plugin.json"; +import Icon from "./Icon"; + +PluginManager.add([ + { + ...config, + type: Hook.Settings, + value: { + group: "Integrations", + icon: Icon, + component: React.lazy(() => import("./Settings")), + }, + }, +]); diff --git a/server/utils/PluginManager.ts b/server/utils/PluginManager.ts index 4634e3ae08..2db8d7010a 100644 --- a/server/utils/PluginManager.ts +++ b/server/utils/PluginManager.ts @@ -58,10 +58,15 @@ export type Plugin = { priority?: number; }; +/** + * Server plugin manager. + */ export class PluginManager { private static plugins = new Map[]>(); + /** - * Add plugins + * Add plugins to the manager. + * * @param plugins */ public static add(plugins: Array> | Plugin) {