chore: Refactor client plugin management (#7053)

* Update clientside plugin management to work as server

* docs

* tsc

* Rebase main
This commit is contained in:
Tom Moor
2024-06-16 11:11:26 -04:00
committed by GitHub
parent a9f1086422
commit 3d0160463c
17 changed files with 331 additions and 108 deletions

View File

@@ -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 (
<Wrapper>
<IconPosition>
<Icon size={size} fill={color} />
</Wrapper>
</IconPosition>
);
}
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);

16
app/hooks/useComputed.ts Normal file
View File

@@ -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<T>(
callback: () => T,
inputs: DependencyList = []
): T {
const value = useMemo(() => computed(callback), inputs);
return value.get();
}

View File

@@ -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]);

View File

@@ -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");

View File

@@ -93,7 +93,6 @@ function AuthenticationProvider(props: Props) {
}
return (
<Wrapper>
<ButtonLarge
onClick={() => (window.location.href = href)}
icon={<PluginIcon id={id} />}
@@ -103,7 +102,6 @@ function AuthenticationProvider(props: Props) {
authProviderName: name,
})}
</ButtonLarge>
</Wrapper>
);
}

View File

@@ -8,7 +8,8 @@ type LogCategory =
| "editor"
| "router"
| "collaboration"
| "misc";
| "misc"
| "plugins";
type Extra = Record<string, any>;

View File

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

145
app/utils/PluginManager.ts Normal file
View File

@@ -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<React.ComponentType>;
};
[Hook.Icon]: React.ElementType;
};
export type Plugin<T extends Hook> = {
/** 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<Hook>> | Plugin<Hook>) {
if (isArray(plugins)) {
return plugins.forEach((plugin) => this.register(plugin));
}
this.register(plugins);
}
@action
private static register<T extends Hook>(plugin: Plugin<T>) {
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<T extends Hook>(type: T) {
return sortBy(this.plugins.get(type) || [], "priority") as Plugin<T>[];
}
/**
* 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<T extends Hook>(type: T, id: string) {
return this.plugins.get(type)?.find((hook) => hook.id === id) as
| Plugin<T>
| undefined;
}
/**
* Load plugin client components, must be in `/<plugin>/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<Hook, Plugin<Hook>[]>();
@observable
private static loaded = false;
}
/**
* Convenience hook to get the value for a specific plugin and type.
*/
export function usePluginValue<T extends Hook>(type: T, id: string) {
return useComputed(
() => PluginManager.getHook<T>(type, id)?.value,
[type, id]
);
}

View File

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

View File

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

View File

@@ -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")),
},
},
]);

View File

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

View File

@@ -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")),
},
},
]);

View File

@@ -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")),
},
},
]);

View File

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

View File

@@ -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")),
},
},
]);

View File

@@ -58,10 +58,15 @@ export type Plugin<T extends Hook> = {
priority?: number;
};
/**
* Server plugin manager.
*/
export class PluginManager {
private static plugins = new Map<Hook, Plugin<Hook>[]>();
/**
* Add plugins
* Add plugins to the manager.
*
* @param plugins
*/
public static add(plugins: Array<Plugin<Hook>> | Plugin<Hook>) {