diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 463f75b..938955a 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -12,7 +12,7 @@ - [Object field](#object-field) - [Restrictions](#restrictions) - [Json field](#json-field) - + - [Hooks](#hooks) Each plugin should be created handle a specific part of the data. Avoid creating a single plugin to handle all the data, as this will make the code harder to maintain and understand. @@ -389,3 +389,91 @@ The `json` field is an escape-hatch that allows the user to input any JSON data. ``` ![JSON](images/field-json.png) + +## Hooks + +> [!WARNING] +> The hooks are an advanced feature and should be used with caution. + +Hooks are functions that can be added to the plugins to perform actions at specific points in their lifecycle. + +Available hooks: +- `onAfterInit(targetInstance: Plugin, data: any) => void` - Executed after the target plugin's `init` function. Can be a promise. +- `onAfterEditSchema(targetInstance: Plugin, formData: any, schema: PluginEditSchema) => PluginEditSchema` - Allows changing a plugin's schema. Receives the schema returned by the target plugin's `editSchema` function and the data entered in the form. Should return the modified schema. + +Hooks should be registered in the plugin's constructor: + +```ts +import { Plugin } from '@stac-manager/data-core'; + +export class PluginName extends Plugin { + name = 'Plugin Name'; + + constructor() { + super(); + this.registerHook( + 'Target plugin name', + 'onAfterInit', + (targetInstance: Plugin, data: any) => { + // Do something. + } + ); + + this.registerHook( + 'Target plugin name', + 'onAfterEditSchema', + (targetInstance: Plugin, formData: any, schema: PluginEditSchema) => { + // Do something. + return schema; + } + ); + } +} +``` + +**Real world example** +Whenever an STAC extension plugin is added, it should add the extension to the `stac_extensions` field of the CollectionsCore plugin. This will allow the interface to show the extension as an option in the dropdown. + +This can be achieved with th `onAfterEditSchema` hook: +```ts +import { Plugin } from '@stac-manager/data-core'; + +export class PluginName extends Plugin { + name = 'Plugin Name'; + + constructor() { + super(); + this.registerHook( + 'CollectionsCore', + 'onAfterEditSchema', + (targetInstance: Plugin, formData: any, schema: PluginEditSchema) => { + const stac_extensions = schema.properties + .stac_extensions as SchemaFieldArray; + + // Set the new extension value in the schema. + stac_extensions.items.enum!.push([value, label]); + + return schema; + } + ); + } +} +``` + +> [!TIP] +> Given that this is a common enough use-case there is a helper function (`addStacExtensionOption`) that can be used. +> ```ts +>import { Plugin } from '@stac-manager/data-core'; +>import { addStacExtensionOption } from '@stac-manager/data-plugins'; +> +>class PluginName extends Plugin { +> constructor() { +> super(); +> +> addStacExtensionOption( +> this, +> 'Item Assets Definition', +> 'https://stac-extensions.github.io/item-assets/v1.0.0/schema.json' +> ); +> } +>} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 6a80343..fa5015f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,10 +10,10 @@ STAC-Manager is a react app designed for managing the values of a STAC (SpatioTe The ecosystem is composed of a web app (the client) and a plugin system that powers it. The different parts of the project are located in the `packages` directory structured as follows: -- [`@stac-manager/client`](./packages/client) - STAC-Manager web app. -- [`@stac-manager/data-core`](./packages/data-core) - Core functionality of the form builder plugin system. -- [`@stac-manager/data-widgets`](./packages/data-widgets) - Form components to be used by the form builder plugin system, when custom ones are not provided. -- [`@stac-manager/data-plugins`](./packages/data-plugins) - Data plugins for the forms. Each plugin defines how a section of the data structure is displayed and edited. +- [`@stac-manager/client`](../packages/client) - STAC-Manager web app. +- [`@stac-manager/data-core`](../packages/data-core) - Core functionality of the form builder plugin system. +- [`@stac-manager/data-widgets`](../packages/data-widgets) - Form components to be used by the form builder plugin system, when custom ones are not provided. +- [`@stac-manager/data-plugins`](../packages/data-plugins) - Data plugins for the forms. Each plugin defines how a section of the data structure is displayed and edited. The `@stac-manager/data-*` packages contain the default implementation of the plugin system, the widgets used to render the forms and some core functions to allow the system to be extended. @@ -22,12 +22,18 @@ Each plugin handles a specific part of the data and is responsible for defining ## Configuration +### Client + +See the client-specific instructions, such as configuring the server's STAC API, in the [README of the client package](../packages/client#client-specific-instructions). + +### Plugins + STAC-Manager's [config file](/packages/client/src/plugin-system/config.ts) specifies the plugins that the app uses for Collections and Items while extending the `Default Plugin Widget Configuration` which defines the widgets for the basic field types. _See the [data-widgets/config](/packages/data-widgets/lib/config/index.ts) for a list of existent widgets._ When creating a new plugin or a new widget, the configuration should be updated with the new plugin/widget. -### Example config +#### Example config The config object should contain a list of plugins to use for the collections and items, as well as the widget configuration (which widgets to use for which field types). diff --git a/packages/client/README.md b/packages/client/README.md index 8f75f1f..d920b12 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -10,10 +10,20 @@ See root README.md for instructions on how to install and run the project. Some client options are controlled by environment variables. These are: ``` +# App config +## Title and description of the app for metadata APP_TITLE APP_DESCRIPTION + +# API +## If the app is being served in from a subfolder, the domain url must be set. +PUBLIC_URL REACT_APP_STAC_BROWSER REACT_APP_STAC_API + +# Theming +REACT_APP_THEME_PRIMARY_COLOR +REACT_APP_THEME_SECONDARY_COLOR ``` You must provide a value for the `REACT_APP_STAC_API` environment variable. This should be the URL of the STAC API you wish to interact with. diff --git a/packages/data-core/lib/context/plugin-config.tsx b/packages/data-core/lib/context/plugin-config.tsx index 5bf273f..7ac8958 100644 --- a/packages/data-core/lib/context/plugin-config.tsx +++ b/packages/data-core/lib/context/plugin-config.tsx @@ -13,7 +13,7 @@ const PluginConfigContext = createContext( /** * Global context provider for the configuration - * + * * @param props.config Configuration object * @param props.children Child components */ diff --git a/packages/data-core/lib/plugin-utils/plugin.ts b/packages/data-core/lib/plugin-utils/plugin.ts index fcff2d0..d16c4dd 100644 --- a/packages/data-core/lib/plugin-utils/plugin.ts +++ b/packages/data-core/lib/plugin-utils/plugin.ts @@ -7,8 +7,31 @@ export type PluginEditSchema = | undefined | symbol; +export interface PluginHook { + // Name of the target plugin. + name: string; + + // The `onAfterInit` hook is executed after the target plugin's `init` + // function. + onAfterInit?: (pluginInstance: Plugin, data: any) => void; + + // The `onAfterEditSchema` hook is composed with the target plugin's + // `editSchema` function. + onAfterEditSchema?: ( + pluginInstance: Plugin, + formData: any, + schema: PluginEditSchema + ) => PluginEditSchema; +} + +const HIDDEN: unique symbol = Symbol('hidden'); +const HOOKS: unique symbol = Symbol('hooks'); + export abstract class Plugin { - static HIDDEN = Symbol('hidden'); + static readonly HIDDEN: typeof HIDDEN = HIDDEN; + static readonly HOOKS: typeof HOOKS = HOOKS; + + [HOOKS]: PluginHook[] = []; name: string = 'Plugin'; @@ -25,6 +48,28 @@ export abstract class Plugin { exitData(data: any): Record { throw new Error(`Plugin [${this.name}] must implement exitData`); } + + /** + * Registers a hook to be applied on a given plugin. + * + * @param targetName - The name of the target plugin to which the hook will be + * applied. + * @param hookName - The name of the hook. + * @param hook - The hook function. + */ + registerHook>( + targetName: string, + hookName: K, + hook: PluginHook[K] + ) { + const hookEntry = this[Plugin.HOOKS].find((h) => h.name === targetName); + + if (hookEntry) { + hookEntry[hookName] = hook; + } else { + this[Plugin.HOOKS].push({ name: targetName, [hookName]: hook }); + } + } } export type PluginConfigResolved = Plugin | Plugin[] | undefined | null; diff --git a/packages/data-core/lib/plugin-utils/resolve.ts b/packages/data-core/lib/plugin-utils/resolve.ts index b89ce74..075b9ca 100644 --- a/packages/data-core/lib/plugin-utils/resolve.ts +++ b/packages/data-core/lib/plugin-utils/resolve.ts @@ -1,7 +1,17 @@ +import { cloneDeep } from 'lodash-es'; import { Plugin, PluginConfigItem } from './plugin'; +/** + * Resolves the plugin config. + * + * @param plugins - An array of plugin configuration items which can be + * instances of `Plugin` or functions that return a `Plugin`. + * @param data - The data to be passed to plugin functions if they are not + * instances of `Plugin`. + * @returns An array of processed `Plugin` instances after applying hooks. + */ export const resolvePlugins = (plugins: PluginConfigItem[], data: any) => { - return plugins + const p = plugins .flatMap((pl) => { if (pl instanceof Plugin) { return pl; @@ -11,4 +21,84 @@ export const resolvePlugins = (plugins: PluginConfigItem[], data: any) => { return; }) .filter((p) => p instanceof Plugin); + + return applyHooks(p); +}; + +/** + * Applies hooks from the provided plugins to their respective targets. + * + * This function iterates over each plugin and applies hooks such as + * `onAfterInit` and `onAfterEditSchema` to the corresponding target plugins. + * The hooks are executed in the context of the source plugin. + * + * @param plugins - List of plugins. All the source and target plugins must be + * on the list. + * + * + * @remarks + * - The `onAfterInit` hook is executed after the target plugin's `init` + * function. + * - The `onAfterEditSchema` hook is composed with the target plugin's + * `editSchema` function. + * + * @example + * ```typescript + * class MyPlugin extends Plugin { + * name = 'MyPlugin'; + * + * [Plugin.HOOKS]: [ + * { + * name: 'pluginA', // Target plugin + * onAfterInit: async (targetInstance, data) => { }, // Executes after pluginA's init function. + * onAfterEditSchema: (targetInstance, formData, origEditSchema) => { } // Composes with pluginA's editSchema function and returns a new one. + * }, + * { + * name: 'pluginB', // Target plugin + * onAfterInit: async (targetInstance, data) => { }, // Executes after pluginB's init function. + * } + * ]; + * } + * + * applyHooks(plugins); + * ``` + */ +export const applyHooks = (plugins: Plugin[]) => { + const pluginsCopy = cloneDeep(plugins); + + for (const plSource of pluginsCopy) { + for (const hook of plSource[Plugin.HOOKS]) { + // Target where to apply the hook + const plTarget = plugins.find((p) => p.name === hook.name); + if (!plTarget) { + continue; + } + + // The onAfterInit hook is made by executing one function after another. + if (hook.onAfterInit) { + const fn = hook.onAfterInit; + const origInit = plTarget.init; + plTarget.init = async (data: any) => { + await origInit.call(plTarget, data); + await fn.call(plSource, plTarget, data); + }; + } + + // The onAfterEditSchema hook is made by composing functions. + if (hook.onAfterEditSchema) { + const fn = hook.onAfterEditSchema; + const origEditSchema = plTarget.editSchema; + plTarget.editSchema = (formData?: any) => { + return fn.call( + plSource, + plTarget, + formData, + origEditSchema.call(plTarget, formData) + ); + }; + } + } + } + + return pluginsCopy; }; diff --git a/packages/data-plugins/lib/collections/core.ts b/packages/data-plugins/lib/collections/core.ts index a5e2526..060a141 100644 --- a/packages/data-plugins/lib/collections/core.ts +++ b/packages/data-plugins/lib/collections/core.ts @@ -99,16 +99,7 @@ export class PluginCore extends Plugin { allowOther: { type: 'string' }, - enum: [ - [ - 'https://stac-extensions.github.io/item-assets/v1.0.0/schema.json', - 'Item Assets Defenition' - ], - [ - 'https://stac-extensions.github.io/render/v2.0.0/schema.json', - 'Render' - ] - ] + enum: [] } }, spatial: { diff --git a/packages/data-plugins/lib/collections/ext-item-assets.ts b/packages/data-plugins/lib/collections/ext-item-assets.ts index 653a3f8..e209002 100644 --- a/packages/data-plugins/lib/collections/ext-item-assets.ts +++ b/packages/data-plugins/lib/collections/ext-item-assets.ts @@ -1,11 +1,26 @@ import { Plugin, PluginEditSchema } from '@stac-manager/data-core'; -import { array2Object, hasExtension, object2Array } from '../utils'; +import { + addStacExtensionOption, + array2Object, + hasStacExtension, + object2Array +} from '../utils'; export class PluginItemAssets extends Plugin { name = 'Item Assets Extension'; + constructor() { + super(); + + addStacExtensionOption( + this, + 'Item Assets Definition', + 'https://stac-extensions.github.io/item-assets/v1.0.0/schema.json' + ); + } + editSchema(data: any): PluginEditSchema { - if (!hasExtension(data, 'item-assets')) { + if (!hasStacExtension(data, 'item-assets')) { return Plugin.HIDDEN; } diff --git a/packages/data-plugins/lib/collections/ext-render.ts b/packages/data-plugins/lib/collections/ext-render.ts index 4eb4819..7b16bc9 100644 --- a/packages/data-plugins/lib/collections/ext-render.ts +++ b/packages/data-plugins/lib/collections/ext-render.ts @@ -1,7 +1,8 @@ import { Plugin, PluginEditSchema } from '@stac-manager/data-core'; import { + addStacExtensionOption, array2Object, - hasExtension, + hasStacExtension, object2Array, object2Tuple, tuple2Object @@ -10,8 +11,18 @@ import { export class PluginRender extends Plugin { name = 'Render Extension'; + constructor() { + super(); + + addStacExtensionOption( + this, + 'Render', + 'https://stac-extensions.github.io/render/v2.0.0/schema.json' + ); + } + editSchema(data: any): PluginEditSchema { - if (!hasExtension(data, 'render')) { + if (!hasStacExtension(data, 'render')) { return Plugin.HIDDEN; } diff --git a/packages/data-plugins/lib/utils.ts b/packages/data-plugins/lib/utils.ts index 468deca..8a59eca 100644 --- a/packages/data-plugins/lib/utils.ts +++ b/packages/data-plugins/lib/utils.ts @@ -1,4 +1,10 @@ -import { SchemaField } from '@stac-manager/data-core'; +import { + Plugin, + SchemaField, + SchemaFieldArray, + SchemaFieldString +} from '@stac-manager/data-core'; +import { PluginCore } from './collections/core'; /** * @@ -182,7 +188,7 @@ export function tuple2Object(stack: string[][]) { * @returns A boolean indicating whether the specified extension (and version, * if provided) is present in the data. */ -export function hasExtension( +export function hasStacExtension( data: any, extension: string, version?: (v: string) => boolean @@ -193,3 +199,34 @@ export function hasExtension( return match && (!version || version(match[1])); }); } + +/** + * Adds a STAC extension option to the stac_extensions field of the + * CollectionsCore plugin. + * + * @param $this - The plugin instance on which the hook will be registered. Must + * be `this`. + * @param label - The label for the new STAC extension option. + * @param value - The value for the new STAC extension option. + */ +export function addStacExtensionOption( + $this: Plugin, + label: string, + value: string +) { + // Quick way to get the name. + const name = new PluginCore().name; + $this.registerHook(name, 'onAfterEditSchema', (pl, formData, schema) => { + if (!schema || typeof schema === 'symbol') { + return schema; + } + + const stac_extensions = schema.properties + .stac_extensions as SchemaFieldArray; + + // Set the new extension value in the schema. + stac_extensions.items.enum!.push([value, label]); + + return schema; + }); +}