From 11be56cc424ac20ab1bd2dfc4715f5901342cd07 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 27 Nov 2023 13:41:04 +1100 Subject: [PATCH] UI plugin dependencies (#4307) * Add requires field to UI plugin config * Use defer instead of async for useScript * Load plugins based on dependency * Document new field --- graphql/documents/queries/plugins.graphql | 2 + graphql/schema/types/plugin.graphql | 6 ++ internal/api/resolver_model_plugin.go | 4 ++ pkg/plugin/config.go | 5 ++ pkg/plugin/plugins.go | 4 ++ ui/v2.5/src/App.tsx | 67 +++++++++++++++++++++-- ui/v2.5/src/docs/en/Manual/Plugins.md | 7 +++ ui/v2.5/src/hooks/useScript.tsx | 2 +- 8 files changed, 90 insertions(+), 7 deletions(-) diff --git a/graphql/documents/queries/plugins.graphql b/graphql/documents/queries/plugins.graphql index 1f8506c44ca..e571bd25a30 100644 --- a/graphql/documents/queries/plugins.graphql +++ b/graphql/documents/queries/plugins.graphql @@ -25,6 +25,8 @@ query Plugins { type } + requires + paths { css javascript diff --git a/graphql/schema/types/plugin.graphql b/graphql/schema/types/plugin.graphql index a18f6651981..14706e55e26 100644 --- a/graphql/schema/types/plugin.graphql +++ b/graphql/schema/types/plugin.graphql @@ -18,6 +18,12 @@ type Plugin { hooks: [PluginHook!] settings: [PluginSetting!] + """ + Plugin IDs of plugins that this plugin depends on. + Applies only for UI plugins to indicate css/javascript load order. + """ + requires: [ID!] + paths: PluginPaths! } diff --git a/internal/api/resolver_model_plugin.go b/internal/api/resolver_model_plugin.go index 644e5e004c9..aa34942ae90 100644 --- a/internal/api/resolver_model_plugin.go +++ b/internal/api/resolver_model_plugin.go @@ -55,3 +55,7 @@ func (r *pluginResolver) Paths(ctx context.Context, obj *plugin.Plugin) (*Plugin return b.paths(), nil } + +func (r *pluginResolver) Requires(ctx context.Context, obj *plugin.Plugin) ([]string, error) { + return obj.UI.Requires, nil +} diff --git a/pkg/plugin/config.go b/pkg/plugin/config.go index e4a993c11fb..497d6a87403 100644 --- a/pkg/plugin/config.go +++ b/pkg/plugin/config.go @@ -72,6 +72,10 @@ type PluginCSP struct { } type UIConfig struct { + // Requires is a list of plugin IDs that this plugin depends on. + // These plugins will be loaded before this plugin. + Requires []string `yaml:"requires"` + // Content Security Policy configuration for the plugin. CSP PluginCSP `yaml:"csp"` @@ -239,6 +243,7 @@ func (c Config) toPlugin() *Plugin { Tasks: c.getPluginTasks(false), Hooks: c.getPluginHooks(false), UI: PluginUI{ + Requires: c.UI.Requires, ExternalScript: c.UI.getExternalScripts(), ExternalCSS: c.UI.getExternalCSS(), Javascript: c.UI.getJavascriptFiles(c), diff --git a/pkg/plugin/plugins.go b/pkg/plugin/plugins.go index 2003ea5ff92..b809de93afd 100644 --- a/pkg/plugin/plugins.go +++ b/pkg/plugin/plugins.go @@ -42,6 +42,10 @@ type Plugin struct { } type PluginUI struct { + // Requires is a list of plugin IDs that this plugin depends on. + // These plugins will be loaded before this plugin. + Requires []string `json:"requires"` + // Content Security Policy configuration for the plugin. CSP PluginCSP `json:"csp"` diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index d3942b16674..ad834878b4e 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -90,6 +90,54 @@ function languageMessageString(language: string) { return language.replace(/-/, ""); } +type PluginList = NonNullable>; + +// sort plugins by their dependencies +function sortPlugins(plugins: PluginList) { + type Node = { id: string; afters: string[] }; + + let nodes: Record = {}; + let sorted: PluginList = []; + let visited: Record = {}; + + plugins.forEach((v) => { + let from = v.id; + + if (!nodes[from]) nodes[from] = { id: from, afters: [] }; + + v.requires?.forEach((to) => { + if (!nodes[to]) nodes[to] = { id: to, afters: [] }; + if (!nodes[to].afters.includes(from)) nodes[to].afters.push(from); + }); + }); + + function visit(idstr: string, ancestors: string[] = []) { + let node = nodes[idstr]; + const { id } = node; + + if (visited[idstr]) return; + + ancestors.push(id); + visited[idstr] = true; + node.afters.forEach(function (afterID) { + if (ancestors.indexOf(afterID) >= 0) + throw new Error("closed chain : " + afterID + " is in " + id); + visit(afterID.toString(), ancestors.slice()); + }); + + const plugin = plugins.find((v) => v.id === id); + if (plugin) { + sorted.unshift(plugin); + } + } + + Object.keys(nodes).forEach((n) => { + visit(n); + }); + + return sorted; +} + export const App: React.FC = () => { const config = useConfiguration(); const [saveUI] = useConfigureUI(); @@ -159,29 +207,36 @@ export const App: React.FC = () => { error: pluginsError, } = usePlugins(); + const sortedPlugins = useMemoOnce(() => { + return [ + sortPlugins(plugins?.plugins ?? []), + !pluginsLoading && !pluginsError, + ]; + }, [plugins?.plugins, pluginsLoading, pluginsError]); + const pluginJavascripts = useMemoOnce(() => { return [ uniq( - plugins?.plugins + sortedPlugins ?.filter((plugin) => plugin.enabled && plugin.paths.javascript) .map((plugin) => plugin.paths.javascript!) .flat() ?? [] ), - !pluginsLoading && !pluginsError, + !!sortedPlugins && !pluginsLoading && !pluginsError, ]; - }, [plugins?.plugins, pluginsLoading, pluginsError]); + }, [sortedPlugins, pluginsLoading, pluginsError]); const pluginCSS = useMemoOnce(() => { return [ uniq( - plugins?.plugins + sortedPlugins ?.filter((plugin) => plugin.enabled && plugin.paths.css) .map((plugin) => plugin.paths.css!) .flat() ?? [] ), - !pluginsLoading && !pluginsError, + !!sortedPlugins && !pluginsLoading && !pluginsError, ]; - }, [plugins, pluginsLoading, pluginsError]); + }, [sortedPlugins, pluginsLoading, pluginsError]); useScript(pluginJavascripts ?? [], !pluginsLoading && !pluginsError); useCSS(pluginCSS ?? [], !pluginsLoading && !pluginsError); diff --git a/ui/v2.5/src/docs/en/Manual/Plugins.md b/ui/v2.5/src/docs/en/Manual/Plugins.md index 20488cebf0d..591c3a62326 100644 --- a/ui/v2.5/src/docs/en/Manual/Plugins.md +++ b/ui/v2.5/src/docs/en/Manual/Plugins.md @@ -43,6 +43,10 @@ ui: javascript: - + # optional list of plugin IDs to load prior to this plugin + requires: + - + # optional list of assets assets: urlPrefix: fsLocation @@ -77,6 +81,9 @@ The `exec`, `interface`, `errLog` and `tasks` fields are used only for plugins w The `css` and `javascript` field values may be relative paths to the plugin configuration file, or may be full external URLs. +The `requires` field is a list of plugin IDs which must have their javascript/css files loaded +before this plugins javascript/css files. + The `assets` field is a map of URL prefixes to filesystem paths relative to the plugin configuration file. Assets are mounted to the `/plugin/{pluginID}/assets` path. diff --git a/ui/v2.5/src/hooks/useScript.tsx b/ui/v2.5/src/hooks/useScript.tsx index 12dee9ce2df..adcbc548894 100644 --- a/ui/v2.5/src/hooks/useScript.tsx +++ b/ui/v2.5/src/hooks/useScript.tsx @@ -14,7 +14,7 @@ const useScript = (urls: string | string[], condition?: boolean) => { const script = document.createElement("script"); script.src = url; - script.async = true; + script.defer = true; return script; });