_FormMixin.ts
325 lines
| 11.8 KiB
| video/mp2t
|
TypeScriptLexer
cin
|
r134 | 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>; | |||
cin
|
r140 | type MergeHint = "array" | "append" | "unset"; | |
cin
|
r134 | /** 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 | |||
*/ | |||
cin
|
r140 | const _combine = (prev: unknown, value: unknown, hint?: MergeHint) => | |
cin
|
r134 | // 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, | |||
cin
|
r140 | hint?: MergeHint | |
cin
|
r134 | ): 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`. | |||
*/ | |||
cin
|
r140 | const _assign = (name: string, obj: object, value: unknown, hint?: MergeHint) => | |
cin
|
r134 | _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)); | |||
} | |||
} | |||
}); | |||
cin
|
r140 | ||
// Note: no need to call this._set("value", ...) as the child updates will trigger onChange events | |||
// which I am monitoring. | |||
cin
|
r134 | } | |
_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 | |||
cin
|
r140 | return { name, value: null, hint: "unset" as const}; | |
cin
|
r134 | } | |
} else { | |||
// checkbox/toggle button | |||
cin
|
r140 | return value !== false ? | |
{ name, value, hint: "array" as const} : | |||
// empty array when no checkboxes are selected | |||
{ name, value: [], hint: "unset" as const }; | |||
cin
|
r134 | } | |
} else { | |||
cin
|
r140 | return { name, value, hint: "append" as const}; | |
cin
|
r134 | } | |
} | |||
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: () => { } }; | |||
cin
|
r140 | this._set("value", this._getValueAttr()); | |
cin
|
r134 | }, 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; |