|
|
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): w is W & _FormValueMixin =>
|
|
|
typeof w === "object" && w !== null && "value" in w;
|
|
|
|
|
|
const testMember = <T extends object>(member: keyof T, type?: "function" | "boolean" | "string" | "number" | "object") => type ?
|
|
|
<V extends object>(v: V): v is V & T => typeof v === "object" && v !== null && (typeof (v as V & T)[member] === type) :
|
|
|
<V extends object>(v: V): v is V & T => typeof v === "object" && v !== null && (member in v);
|
|
|
|
|
|
// reset method support
|
|
|
type Resettable = { reset(): void };
|
|
|
|
|
|
const isResettable = testMember<Resettable>("reset", "function");
|
|
|
|
|
|
// validation support
|
|
|
type Validatable = { validate(): boolean };
|
|
|
|
|
|
const isValidatable = testMember<Validatable>("validate", "function");
|
|
|
|
|
|
// checkbox/toggle button/radio widgets support
|
|
|
type CheckedProp = { checked: boolean };
|
|
|
|
|
|
const hasCheckedProp = testMember<CheckedProp>("checked", "boolean");
|
|
|
|
|
|
// multi-select support
|
|
|
type MultipleProp = { multiple: unknown };
|
|
|
|
|
|
const hasMultipleProp = testMember<MultipleProp>("multiple");
|
|
|
|
|
|
// declared class
|
|
|
type DeclaredClassProp = { declaredClass: string };
|
|
|
|
|
|
const hasDeclaredClassProp = testMember<DeclaredClassProp>("declaredClass", "string");
|
|
|
|
|
|
// state
|
|
|
|
|
|
type StateProp = { state: string };
|
|
|
|
|
|
const hasStateProp = testMember<StateProp>("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<string, unknown>;
|
|
|
|
|
|
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<string, unknown>,
|
|
|
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<Record<string, Event>>>() {
|
|
|
|
|
|
/** 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<string, FormValueWidget[]>);
|
|
|
|
|
|
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;
|