##// END OF EJS Templates
fixed undefined value when no checkboxes are selected in _FormMixin.
cin -
r140:515d1b83ebdf v1.9.0-rc4 default
parent child
Show More
@@ -1,319 +1,326
1 import { id as mid } from "module";
1 import { id as mid } from "module";
2 import { djbase, djclass } from "../declare";
2 import { djbase, djclass } from "../declare";
3 import { whenRendered } from "../tsx/render";
3 import { whenRendered } from "../tsx/render";
4 import { TraceSource } from "@implab/core-amd/log/TraceSource";
4 import { TraceSource } from "@implab/core-amd/log/TraceSource";
5 import _WidgetBase = require("dijit/_WidgetBase");
5 import _WidgetBase = require("dijit/_WidgetBase");
6 import _FormValueMixin = require("dijit/form/_FormValueMixin");
6 import _FormValueMixin = require("dijit/form/_FormValueMixin");
7 import { scrollIntoView } from "dojo/window";
7 import { scrollIntoView } from "dojo/window";
8 import { get } from "@implab/core-amd/safe";
8 import { get } from "@implab/core-amd/safe";
9
9
10 const trace = TraceSource.get(mid);
10 const trace = TraceSource.get(mid);
11
11
12 const isValueWidget = <W>(w: W): w is W & _FormValueMixin =>
12 const isValueWidget = <W>(w: W): w is W & _FormValueMixin =>
13 typeof w === "object" && w !== null && "value" in w;
13 typeof w === "object" && w !== null && "value" in w;
14
14
15 const testMember = <T extends object>(member: keyof T, type?: "function" | "boolean" | "string" | "number" | "object") => type ?
15 const testMember = <T extends object>(member: keyof T, type?: "function" | "boolean" | "string" | "number" | "object") => type ?
16 <V extends object>(v: V): v is V & T => typeof v === "object" && v !== null && (typeof (v as V & T)[member] === type) :
16 <V extends object>(v: V): v is V & T => typeof v === "object" && v !== null && (typeof (v as V & T)[member] === type) :
17 <V extends object>(v: V): v is V & T => typeof v === "object" && v !== null && (member in v);
17 <V extends object>(v: V): v is V & T => typeof v === "object" && v !== null && (member in v);
18
18
19 // reset method support
19 // reset method support
20 type Resettable = { reset(): void };
20 type Resettable = { reset(): void };
21
21
22 const isResettable = testMember<Resettable>("reset", "function");
22 const isResettable = testMember<Resettable>("reset", "function");
23
23
24 // validation support
24 // validation support
25 type Validatable = { validate(): boolean };
25 type Validatable = { validate(): boolean };
26
26
27 const isValidatable = testMember<Validatable>("validate", "function");
27 const isValidatable = testMember<Validatable>("validate", "function");
28
28
29 // checkbox/toggle button/radio widgets support
29 // checkbox/toggle button/radio widgets support
30 type CheckedProp = { checked: boolean };
30 type CheckedProp = { checked: boolean };
31
31
32 const hasCheckedProp = testMember<CheckedProp>("checked", "boolean");
32 const hasCheckedProp = testMember<CheckedProp>("checked", "boolean");
33
33
34 // multi-select support
34 // multi-select support
35 type MultipleProp = { multiple: unknown };
35 type MultipleProp = { multiple: unknown };
36
36
37 const hasMultipleProp = testMember<MultipleProp>("multiple");
37 const hasMultipleProp = testMember<MultipleProp>("multiple");
38
38
39 // declared class
39 // declared class
40 type DeclaredClassProp = { declaredClass: string };
40 type DeclaredClassProp = { declaredClass: string };
41
41
42 const hasDeclaredClassProp = testMember<DeclaredClassProp>("declaredClass", "string");
42 const hasDeclaredClassProp = testMember<DeclaredClassProp>("declaredClass", "string");
43
43
44 // state
44 // state
45
45
46 type StateProp = { state: string };
46 type StateProp = { state: string };
47
47
48 const hasStateProp = testMember<StateProp>("state", "string");
48 const hasStateProp = testMember<StateProp>("state", "string");
49
49
50 // common type for form members
50 // common type for form members
51 type FormValueWidget = _WidgetBase & _FormValueMixin;
51 type FormValueWidget = _WidgetBase & _FormValueMixin;
52
52
53 /** Traverses child widgets collecting form inputs.
53 /** Traverses child widgets collecting form inputs.
54 *
54 *
55 * @param children Widgets to traverse.
55 * @param children Widgets to traverse.
56 * @returns The array of form inputs.
56 * @returns The array of form inputs.
57 */
57 */
58 const collectDescendantFormWidgets = (children: _WidgetBase[]): (FormValueWidget)[] => children
58 const collectDescendantFormWidgets = (children: _WidgetBase[]): (FormValueWidget)[] => children
59 .map(child =>
59 .map(child =>
60 isValueWidget(child) ?
60 isValueWidget(child) ?
61 [child] :
61 [child] :
62 collectDescendantFormWidgets(child.getChildren())
62 collectDescendantFormWidgets(child.getChildren())
63 )
63 )
64 .reduce((res, part) => res.concat(part), []);
64 .reduce((res, part) => res.concat(part), []);
65
65
66 // helper functions to manipulate the form value
66 // helper functions to manipulate the form value
67
67
68 /** checks whether the value is and object and returns it otherwise returns
68 /** checks whether the value is and object and returns it otherwise returns
69 * a new empty object. Useful to auto-crete a nested objects on demand.
69 * a new empty object. Useful to auto-crete a nested objects on demand.
70 */
70 */
71 const _obj = (v: unknown) => (typeof v === "object" && v !== null ? v : {}) as Record<string, unknown>;
71 const _obj = (v: unknown) => (typeof v === "object" && v !== null ? v : {}) as Record<string, unknown>;
72
72
73 type MergeHint = "array" | "append" | "unset";
74
73 /** Combines the values
75 /** Combines the values
74 *
76 *
75 * @param prev The previous value, `undefined` if none
77 * @param prev The previous value, `undefined` if none
76 * @param value The new value to store
78 * @param value The new value to store
77 * @param hint The hint how to combine values
79 * @param hint The hint how to combine values
78 */
80 */
79 const _combine = (prev: unknown, value: unknown, hint?: string) =>
81 const _combine = (prev: unknown, value: unknown, hint?: MergeHint) =>
80 // write the value as an array and append new values to it
82 // write the value as an array and append new values to it
81 hint === "array" ? prev === undefined ? [value] : ([] as unknown[]).concat(prev, value) :
83 hint === "array" ? prev === undefined ? [value] : ([] as unknown[]).concat(prev, value) :
82 // write the value as is and convert it the array when new values are appended
84 // write the value as is and convert it the array when new values are appended
83 hint === "append" ? prev === undefined ? value : ([] as unknown[]).concat(prev, value) :
85 hint === "append" ? prev === undefined ? value : ([] as unknown[]).concat(prev, value) :
84 // write the value only if the previous one is undefined
86 // write the value only if the previous one is undefined
85 hint === "unset" ? prev === undefined ? value : prev :
87 hint === "unset" ? prev === undefined ? value : prev :
86 // overwrite
88 // overwrite
87 value;
89 value;
88
90
89 /** Merges the specified value to the object. The function takes a path for the
91 /** Merges the specified value to the object. The function takes a path for the
90 * new value and creates a nested objects if needed. The hint is used to control
92 * new value and creates a nested objects if needed. The hint is used to control
91 * how to store a new or update the existing value.
93 * how to store a new or update the existing value.
92 */
94 */
93 const _merge = (
95 const _merge = (
94 [prop, ...rest]: string[],
96 [prop, ...rest]: string[],
95 obj: Record<string, unknown>,
97 obj: Record<string, unknown>,
96 value: unknown,
98 value: unknown,
97 hint?: string
99 hint?: MergeHint
98 ): unknown => ({
100 ): unknown => ({
99 ...obj,
101 ...obj,
100 [prop]: rest.length > 0 ?
102 [prop]: rest.length > 0 ?
101 _merge(rest, _obj(obj[prop]), value) :
103 _merge(rest, _obj(obj[prop]), value) :
102 _combine(obj[prop], value, hint)
104 _combine(obj[prop], value, hint)
103 });
105 });
104
106
105 /** Merges the specified value to the object. The function takes a path for the
107 /** Merges the specified value to the object. The function takes a path for the
106 * new value and creates a nested objects if needed. The hint is used to control
108 * new value and creates a nested objects if needed. The hint is used to control
107 * how to store a new or update the existing value.
109 * how to store a new or update the existing value.
108 *
110 *
109 * @param name The path of the property to assign, `x.y.z`
111 * @param name The path of the property to assign, `x.y.z`
110 * @param value The value for the specified property
112 * @param value The value for the specified property
111 * @param hint The hint how to store the value. The valid values are:
113 * @param hint The hint how to store the value. The valid values are:
112 * `array`, `append`, `unset`.
114 * `array`, `append`, `unset`.
113 */
115 */
114 const _assign = (name: string, obj: object, value: unknown, hint?: string) =>
116 const _assign = (name: string, obj: object, value: unknown, hint?: MergeHint) =>
115 _merge(name.split("."), _obj(obj), value, hint) as object;
117 _merge(name.split("."), _obj(obj), value, hint) as object;
116
118
117 @djclass
119 @djclass
118 abstract class _FormMixin extends djbase<_WidgetBase<Record<string, Event>>>() {
120 abstract class _FormMixin extends djbase<_WidgetBase<Record<string, Event>>>() {
119
121
120 /** The form value. When assigned in the constructor is considered as
122 /** The form value. When assigned in the constructor is considered as
121 * initial (reset value) value of the form.
123 * initial (reset value) value of the form.
122 */
124 */
123 value?: object | undefined;
125 value?: object | undefined;
124
126
125 _resetValue?: object | undefined;
127 _resetValue?: object | undefined;
126
128
127 private _pending: { value: object; priority?: boolean | null } | undefined;
129 private _pending: { value: object; priority?: boolean | null } | undefined;
128
130
129 state: "Error" | "Incomplete" | "" = "";
131 state: "Error" | "Incomplete" | "" = "";
130
132
131 _onChangeDelayTimer = { remove: () => { } };
133 _onChangeDelayTimer = { remove: () => { } };
132
134
133 /** Fill in form values from according to an Object (in the format returned
135 /** Fill in form values from according to an Object (in the format returned
134 * by get('value'))
136 * by get('value'))
135 *
137 *
136 * This method schedules updating form values after
138 * This method schedules updating form values after
137 */
139 */
138 _setValueAttr(value: object, priority?: boolean | null) {
140 _setValueAttr(value: object, priority?: boolean | null) {
139 if (!this._pending) {
141 if (!this._pending) {
140 Promise.resolve() // delay value update
142 Promise.resolve() // delay value update
141 .then(whenRendered) // await for the rendering to complete
143 .then(whenRendered) // await for the rendering to complete
142 .then(() => {
144 .then(() => {
143 if (this._pending) { // double check
145 if (this._pending) { // double check
144 const { value, priority } = this._pending;
146 const { value, priority } = this._pending;
145 this._pending = undefined;
147 this._pending = undefined;
146 this._setValueImpl(value, priority);
148 this._setValueImpl(value, priority);
147 }
149 }
148 }).catch(e => trace.error(e));
150 }).catch(e => trace.error(e));
149 }
151 }
150 this._pending = { value, priority };
152 this._pending = { value, priority };
151 }
153 }
152
154
153 getDescendantFormWidgets() {
155 getDescendantFormWidgets() {
154 return collectDescendantFormWidgets(this.getChildren());
156 return collectDescendantFormWidgets(this.getChildren());
155 }
157 }
156
158
157 /**
159 /**
158 * Resets contents of the form to initial value if any. If an input doesn't
160 * Resets contents of the form to initial value if any. If an input doesn't
159 * have a corresponding initial value it is reset to its default value.
161 * have a corresponding initial value it is reset to its default value.
160 */
162 */
161 reset() {
163 reset() {
162 this.getDescendantFormWidgets()
164 this.getDescendantFormWidgets()
163 .filter(isResettable)
165 .filter(isResettable)
164 .forEach(w => w.reset());
166 .forEach(w => w.reset());
165 if (this._resetValue)
167 if (this._resetValue)
166 this.set("value", this._resetValue);
168 this.set("value", this._resetValue);
167 }
169 }
168
170
169 /**
171 /**
170 * returns if the form is valid - same as isValid - but
172 * returns if the form is valid - same as isValid - but
171 * provides a few additional (ui-specific) features:
173 * provides a few additional (ui-specific) features:
172 *
174 *
173 * 1. it will highlight any sub-widgets that are not valid
175 * 1. it will highlight any sub-widgets that are not valid
174 * 2. it will call focus() on the first invalid sub-widget
176 * 2. it will call focus() on the first invalid sub-widget
175 */
177 */
176 validate() {
178 validate() {
177 const [firstError] = this.getDescendantFormWidgets()
179 const [firstError] = this.getDescendantFormWidgets()
178 .map(w => {
180 .map(w => {
179 w._hasBeenBlurred = true;
181 w._hasBeenBlurred = true;
180 const valid = w.disabled || !isValidatable(w) || w.validate();
182 const valid = w.disabled || !isValidatable(w) || w.validate();
181 return valid ? null : w;
183 return valid ? null : w;
182 })
184 })
183 .filter(Boolean);
185 .filter(Boolean);
184
186
185 if (firstError) {
187 if (firstError) {
186 scrollIntoView(firstError.containerNode || firstError.domNode);
188 scrollIntoView(firstError.containerNode || firstError.domNode);
187 firstError.focus();
189 firstError.focus();
188 return false;
190 return false;
189 } else {
191 } else {
190 return true;
192 return true;
191 }
193 }
192 }
194 }
193
195
194 _setValueImpl(obj: object, priority?: boolean | null) {
196 _setValueImpl(obj: object, priority?: boolean | null) {
195 const map = this.getDescendantFormWidgets()
197 const map = this.getDescendantFormWidgets()
196 .filter(w => w.name)
198 .filter(w => w.name)
197 .reduce((g, w) => {
199 .reduce((g, w) => {
198 const entry = g[w.name];
200 const entry = g[w.name];
199 return {
201 return {
200 ...g,
202 ...g,
201 [w.name]: entry ? entry.concat(w) : [w]
203 [w.name]: entry ? entry.concat(w) : [w]
202 };
204 };
203 }, {} as Record<string, FormValueWidget[]>);
205 }, {} as Record<string, FormValueWidget[]>);
204
206
205 Object.keys(map).forEach(name => {
207 Object.keys(map).forEach(name => {
206 const widgets = map[name];
208 const widgets = map[name];
207 const _values = get(name, obj) as unknown;
209 const _values = get(name, obj) as unknown;
208
210
209 if (_values !== undefined) {
211 if (_values !== undefined) {
210 const values = ([] as unknown[]).concat(_values);
212 const values = ([] as unknown[]).concat(_values);
211
213
212 const [w] = widgets; // at least one widget per group
214 const [w] = widgets; // at least one widget per group
213 if (hasCheckedProp(w)) {
215 if (hasCheckedProp(w)) {
214 widgets.forEach(w =>
216 widgets.forEach(w =>
215 w.set("value", values.indexOf(w._get("value")) >= 0, priority)
217 w.set("value", values.indexOf(w._get("value")) >= 0, priority)
216 );
218 );
217 } else if (hasMultipleProp(w) && w.multiple) {
219 } else if (hasMultipleProp(w) && w.multiple) {
218 w.set("value", values, priority);
220 w.set("value", values, priority);
219 } else {
221 } else {
220 widgets.forEach((w, i) => w.set("value", values[i], priority));
222 widgets.forEach((w, i) => w.set("value", values[i], priority));
221 }
223 }
222 }
224 }
223 });
225 });
226
227 // Note: no need to call this._set("value", ...) as the child updates will trigger onChange events
228 // which I am monitoring.
224 }
229 }
225
230
226 _getValueAttr() {
231 _getValueAttr() {
227 return this.getDescendantFormWidgets()
232 return this.getDescendantFormWidgets()
228 .map(widget => {
233 .map(widget => {
229 const name = widget.name;
234 const name = widget.name;
230 if (name && !widget.disabled) {
235 if (name && !widget.disabled) {
231
236
232 const value = widget.get("value") as unknown;
237 const value = widget.get("value") as unknown;
233
238
234 if (hasCheckedProp(widget)) {
239 if (hasCheckedProp(widget)) {
235 if (hasDeclaredClassProp(widget) && /Radio/.test(widget.declaredClass)) {
240 if (hasDeclaredClassProp(widget) && /Radio/.test(widget.declaredClass)) {
236 // radio button
241 // radio button
237 if (value !== false) {
242 if (value !== false) {
238 return { name, value };
243 return { name, value };
239 } else {
244 } else {
240 // give radio widgets a default of null
245 // give radio widgets a default of null
241 return { name, value: null, hint: "unset" };
246 return { name, value: null, hint: "unset" as const};
242 }
247 }
243 } else {
248 } else {
244 // checkbox/toggle button
249 // checkbox/toggle button
245 if (value !== false)
250 return value !== false ?
246 return { name, value, hint: "array" };
251 { name, value, hint: "array" as const} :
252 // empty array when no checkboxes are selected
253 { name, value: [], hint: "unset" as const };
247 }
254 }
248 } else {
255 } else {
249 return { name, value, hint: "append" };
256 return { name, value, hint: "append" as const};
250 }
257 }
251 }
258 }
252 return {};
259 return {};
253 })
260 })
254 .reduce((obj, { name, value, hint }) => name ? _assign(name, obj, value, hint) : obj, {});
261 .reduce((obj, { name, value, hint }) => name ? _assign(name, obj, value, hint) : obj, {});
255 }
262 }
256
263
257 /** Returns true if all of the widgets are valid.
264 /** Returns true if all of the widgets are valid.
258 * @deprecated will be removed
265 * @deprecated will be removed
259 */
266 */
260 isValid() {
267 isValid() {
261 return this.state === "";
268 return this.state === "";
262 }
269 }
263
270
264 /** Triggered when the validity state changes */
271 /** Triggered when the validity state changes */
265 // eslint-disable-next-line @typescript-eslint/no-unused-vars
272 // eslint-disable-next-line @typescript-eslint/no-unused-vars
266 onValidStateChange(validity: boolean) {
273 onValidStateChange(validity: boolean) {
267 }
274 }
268
275
269 _getState() {
276 _getState() {
270 const states = this.getDescendantFormWidgets().filter(hasStateProp).map(w => w.get("state"));
277 const states = this.getDescendantFormWidgets().filter(hasStateProp).map(w => w.get("state"));
271
278
272 return states.indexOf("Error") >= 0 ? "Error" :
279 return states.indexOf("Error") >= 0 ? "Error" :
273 states.indexOf("Incomplete") >= 0 ? "Incomplete" : "";
280 states.indexOf("Incomplete") >= 0 ? "Incomplete" : "";
274 }
281 }
275
282
276 _onChildChange(attr: string) {
283 _onChildChange(attr: string) {
277 // summary:
284 // summary:
278 // Called when child's value or disabled state changes
285 // Called when child's value or disabled state changes
279
286
280 // The unit tests expect state update to be synchronous, so update it immediately.
287 // The unit tests expect state update to be synchronous, so update it immediately.
281 if (!attr || attr == "state" || attr == "disabled") {
288 if (!attr || attr == "state" || attr == "disabled") {
282 this._set("state", this._getState());
289 this._set("state", this._getState());
283 }
290 }
284
291
285 // Use defer() to collapse value changes in multiple children into a single
292 // Use defer() to collapse value changes in multiple children into a single
286 // update to my value. Multiple updates will occur on:
293 // update to my value. Multiple updates will occur on:
287 // 1. Form.set()
294 // 1. Form.set()
288 // 2. Form.reset()
295 // 2. Form.reset()
289 // 3. user selecting a radio button (which will de-select another radio button,
296 // 3. user selecting a radio button (which will de-select another radio button,
290 // causing two onChange events)
297 // causing two onChange events)
291 if (!attr || attr == "value" || attr == "disabled" || attr == "checked") {
298 if (!attr || attr == "value" || attr == "disabled" || attr == "checked") {
292 if (this._onChangeDelayTimer) {
299 if (this._onChangeDelayTimer) {
293 this._onChangeDelayTimer.remove();
300 this._onChangeDelayTimer.remove();
294 }
301 }
295 this._onChangeDelayTimer = this.defer(() => {
302 this._onChangeDelayTimer = this.defer(() => {
296 this._onChangeDelayTimer = { remove: () => { } };
303 this._onChangeDelayTimer = { remove: () => { } };
297 this._set("value", this.get("value"));
304 this._set("value", this._getValueAttr());
298 }, 10);
305 }, 10);
299 }
306 }
300 }
307 }
301
308
302 postCreate() {
309 postCreate() {
303 super.postCreate();
310 super.postCreate();
304
311
305 this._resetValue = this.value;
312 this._resetValue = this.value;
306
313
307 this.on("attrmodified-state, attrmodified-disabled, attrmodified-value, attrmodified-checked", this._onAttrModified);
314 this.on("attrmodified-state, attrmodified-disabled, attrmodified-value, attrmodified-checked", this._onAttrModified);
308 this.watch("state", (prop, ov, nv) => this.onValidStateChange(nv === ""));
315 this.watch("state", (prop, ov, nv) => this.onValidStateChange(nv === ""));
309 }
316 }
310
317
311 private readonly _onAttrModified = ({ target, type }: Event) => {
318 private readonly _onAttrModified = ({ target, type }: Event) => {
312 // ignore events that I fire on myself because my children changed
319 // ignore events that I fire on myself because my children changed
313 if (target !== this.domNode) {
320 if (target !== this.domNode) {
314 this._onChildChange(type.replace("attrmodified-", ""));
321 this._onChildChange(type.replace("attrmodified-", ""));
315 }
322 }
316 };
323 };
317 }
324 }
318
325
319 export default _FormMixin; No newline at end of file
326 export default _FormMixin;
General Comments 0
You need to be logged in to leave comments. Login now