diff --git a/graphql/documents/queries/plugins.graphql b/graphql/documents/queries/plugins.graphql index 1f8506c44..e571bd25a 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 a18f66519..14706e55e 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 644e5e004..aa34942ae 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 e4a993c11..497d6a874 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 2003ea5ff..b809de93a 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 d3942b166..ad834878b 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 pluginJavascripts = useMemoOnce(() => { + const sortedPlugins = useMemoOnce(() => { return [ - uniq( - plugins?.plugins - ?.filter((plugin) => plugin.enabled && plugin.paths.javascript) - .map((plugin) => plugin.paths.javascript!) - .flat() ?? [] - ), + sortPlugins(plugins?.plugins ?? []), !pluginsLoading && !pluginsError, ]; }, [plugins?.plugins, pluginsLoading, pluginsError]); + const pluginJavascripts = useMemoOnce(() => { + return [ + uniq( + sortedPlugins + ?.filter((plugin) => plugin.enabled && plugin.paths.javascript) + .map((plugin) => plugin.paths.javascript!) + .flat() ?? [] + ), + !!sortedPlugins && !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 20488cebf..591c3a623 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 12dee9ce2..adcbc5488 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; });