diff --git a/app/components/PluginIcon.tsx b/app/components/PluginIcon.tsx
index 53dec09ae..bfffb2b03 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 000000000..187fb8742
--- /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 c97dfdfc9..85a244720 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 406b37d6b..2f8c8c5d2 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 0eede84bd..17ec43291 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 ef371ff37..a3b6ba26d 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 6593a4079..000000000
--- 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 000000000..9df44c846
--- /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 000000000..b3dbea4a2
--- /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 000000000..b3dbea4a2
--- /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 000000000..fbdc2d782
--- /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 000000000..b3dbea4a2
--- /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 000000000..fbdc2d782
--- /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 000000000..fbdc2d782
--- /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 000000000..6f1797b65
--- /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 000000000..fbdc2d782
--- /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 4634e3ae0..2db8d7010 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) {