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
This commit is contained in:
WithoutPants 2023-11-27 13:41:04 +11:00 committed by GitHub
parent 910ff27730
commit 11be56cc42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 94 additions and 11 deletions

View File

@ -25,6 +25,8 @@ query Plugins {
type
}
requires
paths {
css
javascript

View File

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

View File

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

View File

@ -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),

View File

@ -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"`

View File

@ -90,6 +90,54 @@ function languageMessageString(language: string) {
return language.replace(/-/, "");
}
type PluginList = NonNullable<Required<GQL.PluginsQuery["plugins"]>>;
// sort plugins by their dependencies
function sortPlugins(plugins: PluginList) {
type Node = { id: string; afters: string[] };
let nodes: Record<string, Node> = {};
let sorted: PluginList = [];
let visited: Record<string, boolean> = {};
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);

View File

@ -43,6 +43,10 @@ ui:
javascript:
- <path to javascript file>
# optional list of plugin IDs to load prior to this plugin
requires:
- <plugin ID>
# 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.

View File

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