@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
415 lines (389 loc) • 9.54 kB
JavaScript
/**
* 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();
});
},
};
}