Skip to content

Commit

Permalink
UI plugin dependencies (#4307)
Browse files Browse the repository at this point in the history
* Add requires field to UI plugin config
* Use defer instead of async for useScript
* Load plugins based on dependency
* Document new field
  • Loading branch information
WithoutPants authored Nov 27, 2023
1 parent 910ff27 commit 11be56c
Show file tree
Hide file tree
Showing 8 changed files with 90 additions and 7 deletions.
2 changes: 2 additions & 0 deletions graphql/documents/queries/plugins.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ query Plugins {
type
}

requires

paths {
css
javascript
Expand Down
6 changes: 6 additions & 0 deletions graphql/schema/types/plugin.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
}

Expand Down
4 changes: 4 additions & 0 deletions internal/api/resolver_model_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
5 changes: 5 additions & 0 deletions pkg/plugin/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions pkg/plugin/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
67 changes: 61 additions & 6 deletions ui/v2.5/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions ui/v2.5/src/docs/en/Manual/Plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion ui/v2.5/src/hooks/useScript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});

Expand Down

0 comments on commit 11be56c

Please sign in to comment.