##// END OF EJS Templates
added form/Form widget and form/_FormMixin, this is a rewritten version of the corresponding dojo classed....
cin -
r134:f139e2153e0d v1.9.0-rc1 default
parent child
Show More
@@ -0,0 +1,134
1
2 import { djbase, djclass } from "../declare";
3 import { attach, createElement } from "../tsx";
4 import { DjxWidgetBase } from "../tsx/DjxWidgetBase";
5 import _FormMixin from "./_FormMixin";
6
7
8 /** This widget represents a document section containing interactive controls
9 * for submitting information.
10 */
11 @djclass
12 export default class Form<T extends object> extends djbase(DjxWidgetBase, _FormMixin) {
13
14 /** Name of form for scripting. */
15 name?: string;
16
17 /** The URL that processes the form submission. */
18 action?: string;
19
20 /** The HTTP method to submit the form with. */
21 method?: "POST" | "GET" | "DIALOG";
22
23 /** If the value of the method attribute is post, enctype is the MIME type
24 * of the form submission.
25 */
26 enctype?: string;
27
28 /** Comma-separated content types the server accepts.
29 *
30 * @deprecated This attribute has been deprecated and should not be used.
31 * Instead, use the accept attribute on <input type=file> elements.
32 */
33 accept?: string;
34
35 /** Space-separated character encodings the server accepts. The browser
36 * uses them in the order in which they are listed.
37 */
38 "accept-charset"?: string;
39
40 /** Indicates where to display the response after submitting the form. It
41 * is a name/keyword for a browsing context (for example, tab, window, or
42 * iframe).
43 */
44 target?: string;
45
46 /** Indicates whether input elements can by default have their values
47 * automatically completed by the browser. `autocomplete` attributes on form
48 * elements override it on `<form>`.
49 */
50 autocomplete?: "off" | "on";
51
52
53 /** This Boolean attribute indicates that the form shouldn't be validated
54 * when submitted. If this attribute is not set the form is validated, it
55 * can be overridden by a `formnovalidate` attribute on a `<button>`,
56 * `<input type="submit">`, or `<input type="image">` element belonging to
57 * the form.
58 */
59 novalidate?: boolean;
60
61 value?: T;
62
63 containerNode?: HTMLFormElement;
64
65 _resetting = false;
66
67 constructor(opts?: Partial<Form<T>>, srcRef?: string | Node) {
68 super(opts, srcRef);
69 }
70
71 render() {
72 return <form onreset={this._onReset} onsubmit={this._onSubmit} ref={attach(this, "containerNode")}>
73 </form>;
74 }
75
76 postCreate() {
77 super.postCreate();
78
79 this.watch("value", (_prop, _oldValue, newValue) => this.onValueChange(newValue));
80 }
81
82 private readonly _onReset = (evt: Event) => {
83 if (!this._resetting) {
84 // re-dispatch event to replace default reset behavior
85 try {
86 this._resetting = true;
87 evt.preventDefault();
88 if(this.containerNode?.dispatchEvent(new Event(evt.type, evt)))
89 super.reset();
90 } finally {
91 this._resetting = false;
92 }
93 } else {
94 this.onReset(evt);
95 }
96 };
97
98 private readonly _onSubmit = (evt: SubmitEvent) => {
99 if(!this.novalidate && !this.validate()) {
100 evt.preventDefault();
101 evt.stopPropagation();
102 } else {
103 this.onSubmit(evt);
104 }
105 };
106
107 // eslint-disable-next-line @typescript-eslint/no-unused-vars
108 onSubmit(evt: Event) {
109 }
110
111 // eslint-disable-next-line @typescript-eslint/no-unused-vars
112 onReset(evt: Event) {
113 }
114
115 // eslint-disable-next-line @typescript-eslint/no-unused-vars
116 onValueChange(value?: T) {
117 }
118
119 /** Resets the form. */
120 reset() {
121 if (!this.containerNode)
122 throw new Error("Can't reset the destroyed form");
123 this.containerNode.reset();
124 }
125
126 /** Programmatically submits form, no additional events emitted
127 * or checks made.
128 */
129 submit() {
130 if (!this.containerNode)
131 throw new Error("Can't submit the destroyed form");
132 this.containerNode.submit();
133 }
134 }
@@ -0,0 +1,319
1 import { id as mid } from "module";
2 import { djbase, djclass } from "../declare";
3 import { whenRendered } from "../tsx/render";
4 import { TraceSource } from "@implab/core-amd/log/TraceSource";
5 import _WidgetBase = require("dijit/_WidgetBase");
6 import _FormValueMixin = require("dijit/form/_FormValueMixin");
7 import { scrollIntoView } from "dojo/window";
8 import { get } from "@implab/core-amd/safe";
9
10 const trace = TraceSource.get(mid);
11
12 const isValueWidget = <W>(w: W): w is W & _FormValueMixin =>
13 typeof w === "object" && w !== null && "value" in w;
14
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) :
17 <V extends object>(v: V): v is V & T => typeof v === "object" && v !== null && (member in v);
18
19 // reset method support
20 type Resettable = { reset(): void };
21
22 const isResettable = testMember<Resettable>("reset", "function");
23
24 // validation support
25 type Validatable = { validate(): boolean };
26
27 const isValidatable = testMember<Validatable>("validate", "function");
28
29 // checkbox/toggle button/radio widgets support
30 type CheckedProp = { checked: boolean };
31
32 const hasCheckedProp = testMember<CheckedProp>("checked", "boolean");
33
34 // multi-select support
35 type MultipleProp = { multiple: unknown };
36
37 const hasMultipleProp = testMember<MultipleProp>("multiple");
38
39 // declared class
40 type DeclaredClassProp = { declaredClass: string };
41
42 const hasDeclaredClassProp = testMember<DeclaredClassProp>("declaredClass", "string");
43
44 // state
45
46 type StateProp = { state: string };
47
48 const hasStateProp = testMember<StateProp>("state", "string");
49
50 // common type for form members
51 type FormValueWidget = _WidgetBase & _FormValueMixin;
52
53 /** Traverses child widgets collecting form inputs.
54 *
55 * @param children Widgets to traverse.
56 * @returns The array of form inputs.
57 */
58 const collectDescendantFormWidgets = (children: _WidgetBase[]): (FormValueWidget)[] => children
59 .map(child =>
60 isValueWidget(child) ?
61 [child] :
62 collectDescendantFormWidgets(child.getChildren())
63 )
64 .reduce((res, part) => res.concat(part), []);
65
66 // helper functions to manipulate the form value
67
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.
70 */
71 const _obj = (v: unknown) => (typeof v === "object" && v !== null ? v : {}) as Record<string, unknown>;
72
73 /** Combines the values
74 *
75 * @param prev The previous value, `undefined` if none
76 * @param value The new value to store
77 * @param hint The hint how to combine values
78 */
79 const _combine = (prev: unknown, value: unknown, hint?: string) =>
80 // write the value as an array and append new values to it
81 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
83 hint === "append" ? prev === undefined ? value : ([] as unknown[]).concat(prev, value) :
84 // write the value only if the previous one is undefined
85 hint === "unset" ? prev === undefined ? value : prev :
86 // overwrite
87 value;
88
89 /** 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
91 * how to store a new or update the existing value.
92 */
93 const _merge = (
94 [prop, ...rest]: string[],
95 obj: Record<string, unknown>,
96 value: unknown,
97 hint?: string
98 ): unknown => ({
99 ...obj,
100 [prop]: rest.length > 0 ?
101 _merge(rest, _obj(obj[prop]), value) :
102 _combine(obj[prop], value, hint)
103 });
104
105 /** 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
107 * how to store a new or update the existing value.
108 *
109 * @param name The path of the property to assign, `x.y.z`
110 * @param value The value for the specified property
111 * @param hint The hint how to store the value. The valid values are:
112 * `array`, `append`, `unset`.
113 */
114 const _assign = (name: string, obj: object, value: unknown, hint?: string) =>
115 _merge(name.split("."), _obj(obj), value, hint) as object;
116
117 @djclass
118 abstract class _FormMixin extends djbase<_WidgetBase<Record<string, Event>>>() {
119
120 /** The form value. When assigned in the constructor is considered as
121 * initial (reset value) value of the form.
122 */
123 value?: object | undefined;
124
125 _resetValue?: object | undefined;
126
127 private _pending: { value: object; priority?: boolean | null } | undefined;
128
129 state: "Error" | "Incomplete" | "" = "";
130
131 _onChangeDelayTimer = { remove: () => { } };
132
133 /** Fill in form values from according to an Object (in the format returned
134 * by get('value'))
135 *
136 * This method schedules updating form values after
137 */
138 _setValueAttr(value: object, priority?: boolean | null) {
139 if (!this._pending) {
140 Promise.resolve() // delay value update
141 .then(whenRendered) // await for the rendering to complete
142 .then(() => {
143 if (this._pending) { // double check
144 const { value, priority } = this._pending;
145 this._pending = undefined;
146 this._setValueImpl(value, priority);
147 }
148 }).catch(e => trace.error(e));
149 }
150 this._pending = { value, priority };
151 }
152
153 getDescendantFormWidgets() {
154 return collectDescendantFormWidgets(this.getChildren());
155 }
156
157 /**
158 * 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.
160 */
161 reset() {
162 this.getDescendantFormWidgets()
163 .filter(isResettable)
164 .forEach(w => w.reset());
165 if (this._resetValue)
166 this.set("value", this._resetValue);
167 }
168
169 /**
170 * returns if the form is valid - same as isValid - but
171 * provides a few additional (ui-specific) features:
172 *
173 * 1. it will highlight any sub-widgets that are not valid
174 * 2. it will call focus() on the first invalid sub-widget
175 */
176 validate() {
177 const [firstError] = this.getDescendantFormWidgets()
178 .map(w => {
179 w._hasBeenBlurred = true;
180 const valid = w.disabled || !isValidatable(w) || w.validate();
181 return valid ? null : w;
182 })
183 .filter(Boolean);
184
185 if (firstError) {
186 scrollIntoView(firstError.containerNode || firstError.domNode);
187 firstError.focus();
188 return false;
189 } else {
190 return true;
191 }
192 }
193
194 _setValueImpl(obj: object, priority?: boolean | null) {
195 const map = this.getDescendantFormWidgets()
196 .filter(w => w.name)
197 .reduce((g, w) => {
198 const entry = g[w.name];
199 return {
200 ...g,
201 [w.name]: entry ? entry.concat(w) : [w]
202 };
203 }, {} as Record<string, FormValueWidget[]>);
204
205 Object.keys(map).forEach(name => {
206 const widgets = map[name];
207 const _values = get(name, obj) as unknown;
208
209 if (_values !== undefined) {
210 const values = ([] as unknown[]).concat(_values);
211
212 const [w] = widgets; // at least one widget per group
213 if (hasCheckedProp(w)) {
214 widgets.forEach(w =>
215 w.set("value", values.indexOf(w._get("value")) >= 0, priority)
216 );
217 } else if (hasMultipleProp(w) && w.multiple) {
218 w.set("value", values, priority);
219 } else {
220 widgets.forEach((w, i) => w.set("value", values[i], priority));
221 }
222 }
223 });
224 }
225
226 _getValueAttr() {
227 return this.getDescendantFormWidgets()
228 .map(widget => {
229 const name = widget.name;
230 if (name && !widget.disabled) {
231
232 const value = widget.get("value") as unknown;
233
234 if (hasCheckedProp(widget)) {
235 if (hasDeclaredClassProp(widget) && /Radio/.test(widget.declaredClass)) {
236 // radio button
237 if (value !== false) {
238 return { name, value };
239 } else {
240 // give radio widgets a default of null
241 return { name, value: null, hint: "unset" };
242 }
243 } else {
244 // checkbox/toggle button
245 if (value !== false)
246 return { name, value, hint: "array" };
247 }
248 } else {
249 return { name, value, hint: "append" };
250 }
251 }
252 return {};
253 })
254 .reduce((obj, { name, value, hint }) => name ? _assign(name, obj, value, hint) : obj, {});
255 }
256
257 /** Returns true if all of the widgets are valid.
258 * @deprecated will be removed
259 */
260 isValid() {
261 return this.state === "";
262 }
263
264 /** Triggered when the validity state changes */
265 // eslint-disable-next-line @typescript-eslint/no-unused-vars
266 onValidStateChange(validity: boolean) {
267 }
268
269 _getState() {
270 const states = this.getDescendantFormWidgets().filter(hasStateProp).map(w => w.get("state"));
271
272 return states.indexOf("Error") >= 0 ? "Error" :
273 states.indexOf("Incomplete") >= 0 ? "Incomplete" : "";
274 }
275
276 _onChildChange(attr: string) {
277 // summary:
278 // Called when child's value or disabled state changes
279
280 // The unit tests expect state update to be synchronous, so update it immediately.
281 if (!attr || attr == "state" || attr == "disabled") {
282 this._set("state", this._getState());
283 }
284
285 // Use defer() to collapse value changes in multiple children into a single
286 // update to my value. Multiple updates will occur on:
287 // 1. Form.set()
288 // 2. Form.reset()
289 // 3. user selecting a radio button (which will de-select another radio button,
290 // causing two onChange events)
291 if (!attr || attr == "value" || attr == "disabled" || attr == "checked") {
292 if (this._onChangeDelayTimer) {
293 this._onChangeDelayTimer.remove();
294 }
295 this._onChangeDelayTimer = this.defer(() => {
296 this._onChangeDelayTimer = { remove: () => { } };
297 this._set("value", this.get("value"));
298 }, 10);
299 }
300 }
301
302 postCreate() {
303 super.postCreate();
304
305 this._resetValue = this.value;
306
307 this.on("attrmodified-state, attrmodified-disabled, attrmodified-value, attrmodified-checked", this._onAttrModified);
308 this.watch("state", (prop, ov, nv) => this.onValidStateChange(nv === ""));
309 }
310
311 private readonly _onAttrModified = ({ target, type }: Event) => {
312 // ignore events that I fire on myself because my children changed
313 if (target !== this.domNode) {
314 this._onChildChange(type.replace("attrmodified-", ""));
315 }
316 };
317 }
318
319 export default _FormMixin; No newline at end of file
@@ -0,0 +1,60
1 import { id as mid } from "module";
2 import { djbase, djclass } from "@implab/djx/declare";
3 import { attach, bind, createElement, prop } from "@implab/djx/tsx";
4 import { DjxWidgetBase } from "@implab/djx/tsx/DjxWidgetBase";
5 import Form from "@implab/djx/form/Form";
6 import { LocalDateTime } from "@js-joda/core";
7 import { TraceSource } from "@implab/core-amd/log/TraceSource";
8 import DateTextBox = require("dijit/form/DateTextBox");
9 import Button = require("dijit/form/Button");
10 import ValidationTextBox = require("dijit/form/ValidationTextBox");
11
12 const trace = TraceSource.get(mid);
13
14 interface NewAppointmentProps {
15 title: string;
16
17 startAt?: Date;
18 }
19
20 @djclass
21 export default class NewAppointment extends djbase(DjxWidgetBase) {
22 value: NewAppointmentProps = {title: "Appointment 1"};
23
24 render() {
25 return <section>
26 <Form<NewAppointmentProps> ref={bind("value", prop(this, "value"))}
27 method="DIALOG"
28 onSubmit={this._onSubmit}
29 onReset={this._onReset}
30 onValidStateChange={this._onValidStateChange}
31 onValueChange={this._onValueChange}
32 >
33 <p><label>Title: <ValidationTextBox required name="title"/></label></p>
34 <p><label>Appointment date: <DateTextBox required name="startAt"/></label></p>
35 <footer>
36 <Button type="submit">Send</Button>
37 <Button type="reset">Reset</Button>
38 </footer>
39 </Form>
40 </section>;
41 }
42
43 private readonly _onSubmit = (evt: Event) => {
44 trace.debug("onSubmit");
45 };
46
47 private readonly _onValidStateChange = (isValid?: boolean) => {
48 trace.debug("isValid={0}", isValid);
49 };
50
51 private readonly _onValueChange = (value: NewAppointmentProps) => {
52 trace.debug("valueChange={0}", value);
53 };
54
55 private readonly _onReset = (evt: Event) => {
56 trace.debug("onReset");
57 //evt.preventDefault();
58 };
59
60 } No newline at end of file
@@ -7,10 +7,12
7 7 "**/.factorypath": true
8 8 },
9 9 "cSpell.words": [
10 "attrmodified",
10 11 "dijit",
11 12 "djbase",
12 13 "djclass",
13 14 "Unsubscribable",
15 "Validatable",
14 16 "wpos"
15 17 ]
16 18 } No newline at end of file
@@ -10,7 +10,7
10 10 "license": "BSD-2-Clause",
11 11 "devDependencies": {
12 12 "@implab/core-amd": "^1.4.6",
13 "@implab/dojo-typings": "1.0.3",
13 "@implab/dojo-typings": "1.0.7",
14 14 "@types/chai": "4.1.3",
15 15 "@types/requirejs": "2.1.31",
16 16 "@types/tap": "15.0.7",
@@ -452,9 +452,9
452 452 }
453 453 },
454 454 "node_modules/@implab/dojo-typings": {
455 "version": "1.0.3",
456 "resolved": "https://registry.npmjs.org/@implab/dojo-typings/-/dojo-typings-1.0.3.tgz",
457 "integrity": "sha512-oyCiuU5ay9MfvdQtZNJSeV30jKufdiLBAcq6rn360pww2hzdqvWEeoU9/New8fMzyNiaEumOlgbcS11EVIH+Jg==",
455 "version": "1.0.7",
456 "resolved": "https://registry.npmjs.org/@implab/dojo-typings/-/dojo-typings-1.0.7.tgz",
457 "integrity": "sha512-/d4gi5vWXyl7Y4aI08z24mrZKiKePgScFQUEGW8ko9fdL8yHj5CLIP6beQn5n4ZlIGFFx3vcu0MwxH3lJwY3oA==",
458 458 "dev": true
459 459 },
460 460 "node_modules/@istanbuljs/load-nyc-config": {
@@ -7056,9 +7056,9
7056 7056 "requires": {}
7057 7057 },
7058 7058 "@implab/dojo-typings": {
7059 "version": "1.0.3",
7060 "resolved": "https://registry.npmjs.org/@implab/dojo-typings/-/dojo-typings-1.0.3.tgz",
7061 "integrity": "sha512-oyCiuU5ay9MfvdQtZNJSeV30jKufdiLBAcq6rn360pww2hzdqvWEeoU9/New8fMzyNiaEumOlgbcS11EVIH+Jg==",
7059 "version": "1.0.7",
7060 "resolved": "https://registry.npmjs.org/@implab/dojo-typings/-/dojo-typings-1.0.7.tgz",
7061 "integrity": "sha512-/d4gi5vWXyl7Y4aI08z24mrZKiKePgScFQUEGW8ko9fdL8yHj5CLIP6beQn5n4ZlIGFFx3vcu0MwxH3lJwY3oA==",
7062 7062 "dev": true
7063 7063 },
7064 7064 "@istanbuljs/load-nyc-config": {
@@ -26,7 +26,7
26 26 "@types/tap": "15.0.7",
27 27 "rxjs": "7.5.6",
28 28 "dojo": "1.16.0",
29 "@implab/dojo-typings": "1.0.3",
29 "@implab/dojo-typings": "1.0.7",
30 30 "@typescript-eslint/eslint-plugin": "^5.23.0",
31 31 "@typescript-eslint/parser": "^5.23.0",
32 32 "eslint": "^8.28.0",
@@ -130,7 +130,7 export const attach = <W extends DjxWidg
130 130 export const bind = <K extends string, T>(attr: K, subj: Subscribable<T>) => {
131 131 let h = { unsubscribe() { } };
132 132
133 return (el: Element | { set(name: K, value: T): void; } | undefined) => {
133 return (el: Element | { set(name: K, value: T, priority?: boolean): void; } | undefined) => {
134 134 if (el) {
135 135 if (isElementNode(el)) {
136 136 h = subj.subscribe({
@@ -138,7 +138,7 export const bind = <K extends string, T
138 138 });
139 139 } else {
140 140 h = subj.subscribe({
141 next: value => el.set(attr, value)
141 next: value => el.set(attr, value, false)
142 142 });
143 143 }
144 144 } else {
@@ -28,6 +28,15
28 28 "typescript": "4.8.3"
29 29 }
30 30 },
31 "../djx/build/npm/package": {
32 "name": "@implab/djx",
33 "dev": true,
34 "license": "BSD-2-Clause",
35 "peerDependencies": {
36 "@implab/core-amd": "^1.4.6",
37 "dojo": "^1.10.0"
38 }
39 },
31 40 "node_modules/@eslint/eslintrc": {
32 41 "version": "1.3.1",
33 42 "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.1.tgz",
@@ -104,13 +113,8
104 113 }
105 114 },
106 115 "node_modules/@implab/djx": {
107 "resolved": "file:../djx/build/npm/package",
108 "dev": true,
109 "license": "BSD-2-Clause",
110 "peerDependencies": {
111 "@implab/core-amd": "^1.4.6",
112 "dojo": "^1.10.0"
113 }
116 "resolved": "../djx/build/npm/package",
117 "link": true
114 118 },
115 119 "node_modules/@implab/dojo-typings": {
116 120 "version": "1.0.2",
@@ -2779,7 +2783,7
2779 2783 "requires": {}
2780 2784 },
2781 2785 "@implab/djx": {
2782 "dev": true,
2786 "version": "file:../djx/build/npm/package",
2783 2787 "requires": {}
2784 2788 },
2785 2789 "@implab/dojo-typings": {
@@ -13,5 +13,7 TraceSource.on(source => {
13 13 });
14 14
15 15 const w = new MainWidget();
16 document.body.className = 'tundra';
16 17 w.placeAt(document.body);
18 w.startup();
17 19 w.load();
@@ -1,12 +1,13
1 1 import { djbase, djclass } from "@implab/djx/declare";
2 2 import { DjxWidgetBase } from "@implab/djx/tsx/DjxWidgetBase";
3 import { bind, createElement, prop, watch, watchFor } from "@implab/djx/tsx";
3 import { attach, bind, createElement, prop, watch, watchFor } from "@implab/djx/tsx";
4 4 import MainModel from "../model/MainModel";
5 5 import { Observable } from "@implab/djx/observable";
6 6 import { OrderedUpdate } from "@implab/djx/store";
7 7 import { Appointment } from "../model/Appointment";
8 8 import { LocalDate } from "@js-joda/core";
9 9 import Button = require("dijit/form/Button");
10 import NewAppointment from "./NewAppointment";
10 11
11 12 @djclass
12 13 export default class MainWidget extends djbase(DjxWidgetBase) {
@@ -19,6 +20,8 export default class MainWidget extends
19 20
20 21 dateFrom?: LocalDate;
21 22
23 toolbarNode?: HTMLDivElement;
24
22 25 constructor(opts?: Partial<MainWidget> & ThisType<MainWidget>, srcNode?: string | Node) {
23 26 super(opts, srcNode);
24 27
@@ -48,7 +51,8 export default class MainWidget extends
48 51 )}
49 52 </ul>
50 53 )}
51 <div>
54 <NewAppointment/>
55 <div ref={attach(this, "toolbarNode")}>
52 56 <Button onClick={this._onAddAppointmentClick}>Add new appointment</Button>
53 57 </div>
54 58 </div>;
General Comments 0
You need to be logged in to leave comments. Login now