import { id as mid } from "module"; import { djbase, djclass } from "../declare"; import { whenRendered } from "../tsx/render"; import { TraceSource } from "@implab/core-amd/log/TraceSource"; import _WidgetBase = require("dijit/_WidgetBase"); import _FormValueMixin = require("dijit/form/_FormValueMixin"); import { scrollIntoView } from "dojo/window"; import { get } from "@implab/core-amd/safe"; const trace = TraceSource.get(mid); const isValueWidget = (w: W): w is W & _FormValueMixin => typeof w === "object" && w !== null && "value" in w; const testMember = (member: keyof T, type?: "function" | "boolean" | "string" | "number" | "object") => type ? (v: V): v is V & T => typeof v === "object" && v !== null && (typeof (v as V & T)[member] === type) : (v: V): v is V & T => typeof v === "object" && v !== null && (member in v); // reset method support type Resettable = { reset(): void }; const isResettable = testMember("reset", "function"); // validation support type Validatable = { validate(): boolean }; const isValidatable = testMember("validate", "function"); // checkbox/toggle button/radio widgets support type CheckedProp = { checked: boolean }; const hasCheckedProp = testMember("checked", "boolean"); // multi-select support type MultipleProp = { multiple: unknown }; const hasMultipleProp = testMember("multiple"); // declared class type DeclaredClassProp = { declaredClass: string }; const hasDeclaredClassProp = testMember("declaredClass", "string"); // state type StateProp = { state: string }; const hasStateProp = testMember("state", "string"); // common type for form members type FormValueWidget = _WidgetBase & _FormValueMixin; /** Traverses child widgets collecting form inputs. * * @param children Widgets to traverse. * @returns The array of form inputs. */ const collectDescendantFormWidgets = (children: _WidgetBase[]): (FormValueWidget)[] => children .map(child => isValueWidget(child) ? [child] : collectDescendantFormWidgets(child.getChildren()) ) .reduce((res, part) => res.concat(part), []); // helper functions to manipulate the form value /** checks whether the value is and object and returns it otherwise returns * a new empty object. Useful to auto-crete a nested objects on demand. */ const _obj = (v: unknown) => (typeof v === "object" && v !== null ? v : {}) as Record; type MergeHint = "array" | "append" | "unset"; /** Combines the values * * @param prev The previous value, `undefined` if none * @param value The new value to store * @param hint The hint how to combine values */ const _combine = (prev: unknown, value: unknown, hint?: MergeHint) => // write the value as an array and append new values to it hint === "array" ? prev === undefined ? [value] : ([] as unknown[]).concat(prev, value) : // write the value as is and convert it the array when new values are appended hint === "append" ? prev === undefined ? value : ([] as unknown[]).concat(prev, value) : // write the value only if the previous one is undefined hint === "unset" ? prev === undefined ? value : prev : // overwrite value; /** Merges the specified value to the object. The function takes a path for the * new value and creates a nested objects if needed. The hint is used to control * how to store a new or update the existing value. */ const _merge = ( [prop, ...rest]: string[], obj: Record, value: unknown, hint?: MergeHint ): unknown => ({ ...obj, [prop]: rest.length > 0 ? _merge(rest, _obj(obj[prop]), value) : _combine(obj[prop], value, hint) }); /** Merges the specified value to the object. The function takes a path for the * new value and creates a nested objects if needed. The hint is used to control * how to store a new or update the existing value. * * @param name The path of the property to assign, `x.y.z` * @param value The value for the specified property * @param hint The hint how to store the value. The valid values are: * `array`, `append`, `unset`. */ const _assign = (name: string, obj: object, value: unknown, hint?: MergeHint) => _merge(name.split("."), _obj(obj), value, hint) as object; @djclass abstract class _FormMixin extends djbase<_WidgetBase>>() { /** The form value. When assigned in the constructor is considered as * initial (reset value) value of the form. */ value?: object | undefined; _resetValue?: object | undefined; private _pending: { value: object; priority?: boolean | null } | undefined; state: "Error" | "Incomplete" | "" = ""; _onChangeDelayTimer = { remove: () => { } }; /** Fill in form values from according to an Object (in the format returned * by get('value')) * * This method schedules updating form values after */ _setValueAttr(value: object, priority?: boolean | null) { if (!this._pending) { Promise.resolve() // delay value update .then(whenRendered) // await for the rendering to complete .then(() => { if (this._pending) { // double check const { value, priority } = this._pending; this._pending = undefined; this._setValueImpl(value, priority); } }).catch(e => trace.error(e)); } this._pending = { value, priority }; } getDescendantFormWidgets() { return collectDescendantFormWidgets(this.getChildren()); } /** * Resets contents of the form to initial value if any. If an input doesn't * have a corresponding initial value it is reset to its default value. */ reset() { this.getDescendantFormWidgets() .filter(isResettable) .forEach(w => w.reset()); if (this._resetValue) this.set("value", this._resetValue); } /** * returns if the form is valid - same as isValid - but * provides a few additional (ui-specific) features: * * 1. it will highlight any sub-widgets that are not valid * 2. it will call focus() on the first invalid sub-widget */ validate() { const [firstError] = this.getDescendantFormWidgets() .map(w => { w._hasBeenBlurred = true; const valid = w.disabled || !isValidatable(w) || w.validate(); return valid ? null : w; }) .filter(Boolean); if (firstError) { scrollIntoView(firstError.containerNode || firstError.domNode); firstError.focus(); return false; } else { return true; } } _setValueImpl(obj: object, priority?: boolean | null) { const map = this.getDescendantFormWidgets() .filter(w => w.name) .reduce((g, w) => { const entry = g[w.name]; return { ...g, [w.name]: entry ? entry.concat(w) : [w] }; }, {} as Record); Object.keys(map).forEach(name => { const widgets = map[name]; const _values = get(name, obj) as unknown; if (_values !== undefined) { const values = ([] as unknown[]).concat(_values); const [w] = widgets; // at least one widget per group if (hasCheckedProp(w)) { widgets.forEach(w => w.set("value", values.indexOf(w._get("value")) >= 0, priority) ); } else if (hasMultipleProp(w) && w.multiple) { w.set("value", values, priority); } else { widgets.forEach((w, i) => w.set("value", values[i], priority)); } } }); // Note: no need to call this._set("value", ...) as the child updates will trigger onChange events // which I am monitoring. } _getValueAttr() { return this.getDescendantFormWidgets() .map(widget => { const name = widget.name; if (name && !widget.disabled) { const value = widget.get("value") as unknown; if (hasCheckedProp(widget)) { if (hasDeclaredClassProp(widget) && /Radio/.test(widget.declaredClass)) { // radio button if (value !== false) { return { name, value }; } else { // give radio widgets a default of null return { name, value: null, hint: "unset" as const}; } } else { // checkbox/toggle button return value !== false ? { name, value, hint: "array" as const} : // empty array when no checkboxes are selected { name, value: [], hint: "unset" as const }; } } else { return { name, value, hint: "append" as const}; } } return {}; }) .reduce((obj, { name, value, hint }) => name ? _assign(name, obj, value, hint) : obj, {}); } /** Returns true if all of the widgets are valid. * @deprecated will be removed */ isValid() { return this.state === ""; } /** Triggered when the validity state changes */ // eslint-disable-next-line @typescript-eslint/no-unused-vars onValidStateChange(validity: boolean) { } _getState() { const states = this.getDescendantFormWidgets().filter(hasStateProp).map(w => w.get("state")); return states.indexOf("Error") >= 0 ? "Error" : states.indexOf("Incomplete") >= 0 ? "Incomplete" : ""; } _onChildChange(attr: string) { // summary: // Called when child's value or disabled state changes // The unit tests expect state update to be synchronous, so update it immediately. if (!attr || attr == "state" || attr == "disabled") { this._set("state", this._getState()); } // Use defer() to collapse value changes in multiple children into a single // update to my value. Multiple updates will occur on: // 1. Form.set() // 2. Form.reset() // 3. user selecting a radio button (which will de-select another radio button, // causing two onChange events) if (!attr || attr == "value" || attr == "disabled" || attr == "checked") { if (this._onChangeDelayTimer) { this._onChangeDelayTimer.remove(); } this._onChangeDelayTimer = this.defer(() => { this._onChangeDelayTimer = { remove: () => { } }; this._set("value", this._getValueAttr()); }, 10); } } postCreate() { super.postCreate(); this._resetValue = this.value; this.on("attrmodified-state, attrmodified-disabled, attrmodified-value, attrmodified-checked", this._onAttrModified); this.watch("state", (prop, ov, nv) => this.onValidStateChange(nv === "")); } private readonly _onAttrModified = ({ target, type }: Event) => { // ignore events that I fire on myself because my children changed if (target !== this.domNode) { this._onChildChange(type.replace("attrmodified-", "")); } }; } export default _FormMixin;