##// END OF EJS Templates
Added tag v1.10.3 for changeset 078eca3dc271
Added tag v1.10.3 for changeset 078eca3dc271

File last commit:

r140:515d1b83ebdf v1.9.0-rc4 default
r159:0b327f31e28f tip default
Show More
_FormMixin.ts
325 lines | 11.8 KiB | video/mp2t | TypeScriptLexer
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;