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