UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

415 lines (389 loc) 9.54 kB
/** * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { Pathfinder } from "../data/pathfinder.mjs"; import { ProxyObserver } from "../types/proxyobserver.mjs"; import { Observer } from "../types/observer.mjs"; import { isArray, isFunction, isObject, isString } from "../types/is.mjs"; import { getDocument } from "./util.mjs"; export { ControlFlow }; /** * Lightweight control flow for event-driven control dependencies. * Config is defined in JS and supports callbacks per event. */ class ControlFlow { constructor({ stateObserver, state = {}, controls = {}, datasources = {}, rules = [], } = {}) { this[internalSymbol] = { stateObserver: stateObserver instanceof ProxyObserver ? stateObserver : new ProxyObserver(state), controls: resolveControls(controls), datasources: resolveDatasources(datasources), rules: isArray(rules) ? rules : [], listeners: [], }; this.state = this[internalSymbol].stateObserver.getSubject(); this[internalSymbol].stateObserver.attachObserver( new Observer(() => { this.emit({ source: "state", id: "root", event: "changed", value: this.state, }); }), ); bindControlEvents.call(this); bindDatasourceEvents.call(this); } /** * Create an API helper for rule handlers. * @return {object} */ createApi() { return createApi(this); } /** * Add a rule at runtime. * @param {object} rule */ addRule(rule) { if (isObject(rule)) { this[internalSymbol].rules.push(rule); } } /** * Remove all listeners. */ destroy() { for (const entry of this[internalSymbol].listeners) { entry.element.removeEventListener(entry.type, entry.handler); } this[internalSymbol].listeners = []; } /** * Emit a synthetic event into the rule system. * @param {object} ctx */ emit(ctx) { const context = normalizeContext(ctx, this); for (const rule of this[internalSymbol].rules) { if (!matchRule(rule, context)) { continue; } const run = rule?.run; if (isFunction(run)) { run(context, createApi(this)); } } } /** * Rule helper: control events. * @param {string} id * @param {string|string[]} events * @param {function} run * @return {object} */ static onControl(id, events, run) { return { on: (ctx) => ctx.source === "control" && ctx.id === id && matchEvent(ctx.event, events), run, }; } /** * Rule helper: datasource events. * @param {string} id * @param {string|string[]} events * @param {function} run * @return {object} */ static onDatasource(id, events, run) { return { on: (ctx) => ctx.source === "datasource" && ctx.id === id && matchEvent(ctx.event, events), run, }; } /** * Rule helper: state events. * @param {string|string[]} events * @param {function} run * @return {object} */ static onState(events, run) { return { on: (ctx) => ctx.source === "state" && matchEvent(ctx.event, events), run, }; } /** * Rule helper: custom app events. * @param {string|string[]} events * @param {function} run * @return {object} */ static onApp(events, run) { return { on: (ctx) => ctx.source === "app" && matchEvent(ctx.event, events), run, }; } } /** * @private */ const internalSymbol = Symbol("controlFlowInternal"); /** * @private */ function resolveControls(controls) { const resolved = {}; for (const [id, def] of Object.entries(controls || {})) { const element = resolveElement(def?.element || def); if (!element) continue; resolved[id] = { element, events: def?.events || ["monster-changed", "change"], }; } return resolved; } /** * @private */ function resolveDatasources(datasources) { const resolved = {}; for (const [id, def] of Object.entries(datasources || {})) { const element = resolveElement(def?.element || def); if (!element) continue; resolved[id] = { element, events: def?.events || [ "monster-datasource-fetched", "monster-datasource-error", ], }; } return resolved; } /** * @private */ function resolveElement(elementOrSelector) { if (elementOrSelector instanceof HTMLElement) { return elementOrSelector; } if (isString(elementOrSelector)) { return getDocument().querySelector(elementOrSelector); } return null; } /** * @private */ function bindControlEvents() { for (const [id, control] of Object.entries(this[internalSymbol].controls)) { for (const type of control.events) { const handler = (event) => { this.emit({ source: "control", id, event: type, value: readControlValue(control.element), rawEvent: event, }); }; control.element.addEventListener(type, handler); this[internalSymbol].listeners.push({ element: control.element, type, handler, }); } } } /** * @private */ function bindDatasourceEvents() { for (const [id, datasource] of Object.entries( this[internalSymbol].datasources, )) { for (const type of datasource.events) { const handler = (event) => { this.emit({ source: "datasource", id, event: type, value: datasource.element?.data, rawEvent: event, }); }; datasource.element.addEventListener(type, handler); this[internalSymbol].listeners.push({ element: datasource.element, type, handler, }); } } } /** * @private */ function readControlValue(element) { if (element && "value" in element) { return element.value; } if (isFunction(element?.getOption)) { return element.getOption("value"); } return undefined; } /** * @private */ function matchRule(rule, ctx) { if (!rule) return false; if (isFunction(rule.on)) { return !!rule.on(ctx); } if (isString(rule.on)) { return rule.on === ctx.key || rule.on === ctx.topic; } if (isArray(rule.on)) { return rule.on.includes(ctx.key) || rule.on.includes(ctx.topic); } return false; } /** * @private */ function normalizeContext(ctx, flow) { const context = Object.assign({}, ctx || {}); context.key = `${context.source}:${context.id}:${context.event}`; context.topic = `${context.source}:${context.id}`; context.state = flow.state; context.controls = flow[internalSymbol].controls; context.datasources = flow[internalSymbol].datasources; return context; } /** * @private */ function matchEvent(event, events) { if (!events) return true; if (isString(events)) { return event === events; } if (isArray(events)) { return events.includes(event); } return false; } /** * @private */ function createApi(flow) { return { state: flow.state, getState(path, fallback) { try { return new Pathfinder(flow.state).getVia(path); } catch (e) { return fallback; } }, setState(path, value) { new Pathfinder(flow.state).setVia(path, value); }, getControl(id) { return flow[internalSymbol].controls?.[id]?.element; }, setControlOption(id, path, value) { const control = flow[internalSymbol].controls?.[id]?.element; if (isFunction(control?.setOption)) { control.setOption(path, value); } }, setControlOptions(id, options) { const control = flow[internalSymbol].controls?.[id]?.element; if (isFunction(control?.setOptions)) { control.setOptions(options); } else if (isFunction(control?.setOption)) { control.setOption("options", options); } }, setControlValue(id, value) { const control = flow[internalSymbol].controls?.[id]?.element; if (control && "value" in control) { control.value = value; } else if (isFunction(control?.setOption)) { control.setOption("value", value); } }, clearControl(id) { const control = flow[internalSymbol].controls?.[id]?.element; if (control && "value" in control) { control.value = ""; } else if (isFunction(control?.setOption)) { control.setOption("value", ""); } }, disableControl(id, disabled) { const control = flow[internalSymbol].controls?.[id]?.element; if (isFunction(control?.setOption)) { control.setOption("disabled", !!disabled); } }, fetchDatasource(id, { url, transform } = {}) { const datasource = flow[internalSymbol].datasources?.[id]?.element; if (!datasource) { return Promise.reject(new Error("datasource not found")); } return new Promise((resolve, reject) => { const onFetched = () => resolve(datasource.data); const onError = (event) => { reject(event?.detail?.error || new Error("Datasource error")); }; datasource.addEventListener("monster-datasource-fetched", onFetched, { once: true, }); datasource.addEventListener("monster-datasource-error", onError, { once: true, }); if (isFunction(transform)) { datasource.setOption("read.responseCallback", (payload) => { datasource.data = transform(payload); }); } if (url) { datasource.setOption("read.url", url); } datasource.read(); }); }, }; }