1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2024 Per Cederberg. All rights reserved.
4 *
5 * This program is free software: you can redistribute it and/or
6 * modify it under the terms of the BSD license.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11 *
12 * See the RapidContext LICENSE for more details.
13 */
14
15// Namespace initialization
16if (typeof(RapidContext) == "undefined") {
17 RapidContext = {};
18}
19RapidContext.Widget = RapidContext.Widget || { Classes: {} };
20
21/**
22 * Creates a new form widget.
23 *
24 * @constructor
25 * @param {Object} attrs the widget and node attributes
26 * @param {boolean} [attrs.hidden] the hidden widget flag, defaults to false
27 * @param {...(string|Node|Array)} [child] the child widgets or DOM nodes
28 *
29 * @return {Widget} the widget DOM node
30 *
31 * @class The form widget class. Provides a grouping for form fields, using the
32 * `<form>` HTML element. The form widget supports form reset, validation
33 * and data retrieval.
34 * @extends RapidContext.Widget
35 *
36 * @example <caption>JavaScript</caption>
37 * var field = RapidContext.Widget.TextField({ name: "name", placeholder: "Your Name Here" });
38 * var attrs = { name: "name", message: "Please enter your name to proceed." };
39 * var valid = RapidContext.Widget.FormValidator(attrs);
40 * var exampleForm = RapidContext.Widget.Form({}, field, valid);
41 *
42 * @example <caption>User Interface XML</caption>
43 * <Form id="exampleForm">
44 * <TextField name="name" placeholder="Your Name Here" />
45 * <FormValidator name="name" message="Please enter your name to proceed." />
46 * </Form>
47 */
48RapidContext.Widget.Form = function (attrs/*, ...*/) {
49 var o = RapidContext.UI.FORM(attrs);
50 o._validators = {};
51 o._originalReset = o.reset;
52 RapidContext.Widget._widgetMixin(o, RapidContext.Widget.Form);
53 o.addClass("widgetForm");
54 o.setAttrs(attrs);
55 o.addAll(Array.from(arguments).slice(1));
56 o.on("input", o._handleInput);
57 o.on("invalid", o._handleInvalid, { capture: true });
58 o.on("submit", o._handleSubmit);
59 return o;
60};
61
62// Register widget class
63RapidContext.Widget.Classes.Form = RapidContext.Widget.Form;
64
65/**
66 * Applies custom validators on field input.
67 *
68 * @param {Event} evt the DOM event object
69 */
70RapidContext.Widget.Form.prototype._handleInput = function (evt) {
71 this._callValidators(evt.target);
72};
73
74/**
75 * Debounces the input invalid events and validates the form.
76 */
77RapidContext.Widget.Form.prototype._handleInvalid = function () {
78 if (this._validationTimer !== false) {
79 this._validationTimer && clearTimeout(this._validationTimer);
80 this._validationTimer = setTimeout(() => this.validate(), 10);
81 }
82};
83
84/**
85 * Prevents the default submit action and validates the form.
86 *
87 * @param {Event} evt the DOM event object
88 */
89RapidContext.Widget.Form.prototype._handleSubmit = function (evt) {
90 evt.preventDefault();
91 if (!this.validate()) {
92 evt.stopImmediatePropagation();
93 }
94};
95
96RapidContext.Widget.Form.prototype._fieldValue = function (field) {
97 if (field.disabled) {
98 return null;
99 } else if (field.type === "radio" || field.type === "checkbox") {
100 return field.checked ? (field.value || true) : null;
101 } else if (typeof(field.getValue) == "function") {
102 return field.getValue();
103 } else {
104 return field.value;
105 }
106};
107
108/**
109 * Returns an array with all child DOM nodes containing form fields.
110 *
111 * @return {Array} the array of form field elements
112 *
113 * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements
114 */
115RapidContext.Widget.Form.prototype.fields = function () {
116 var basics = Array.from(this.elements);
117 var extras = Array.from(this.querySelectorAll(".widgetField"));
118 return basics.concat(extras);
119};
120
121/**
122 * Returns a map with all child DOM nodes containing form fields with
123 * a name attribute. If multiple fields have the same name, the
124 * returned map will contain an array with all matching fields.
125 *
126 * @return {Object} the map of form field elements
127 */
128RapidContext.Widget.Form.prototype.fieldMap = function () {
129 function update(o, field) {
130 var k = field.name;
131 if (k && k != "*") {
132 o[k] = (k in o) ? [].concat(o[k], field) : field;
133 }
134 return o;
135 }
136 return this.fields().reduce(update, {});
137};
138
139/**
140 * Resets all fields and validations to their original state.
141 */
142RapidContext.Widget.Form.prototype.reset = function () {
143 this._originalReset();
144 Array.from(this.querySelectorAll(".widgetField")).forEach(function (field) {
145 field.reset();
146 });
147 this.validateReset();
148};
149
150/**
151 * Returns a map with all form field values. If multiple fields have
152 * the same name, the value will be set to an array of all values.
153 * Disabled fields and unchecked checkboxes or radiobuttons will be
154 * ignored.
155 *
156 * @return {Object} the map of form field values
157 */
158RapidContext.Widget.Form.prototype.valueMap = function () {
159 function update(o, field) {
160 var k = field.name;
161 var v = getValue(field);
162 if (k && k != "*" && v != null) {
163 o[k] = (k in o) ? [].concat(o[k], v) : v;
164 }
165 return o;
166 }
167 var getValue = this._fieldValue;
168 return this.fields().reduce(update, {});
169};
170
171/**
172 * Updates the fields in this form with a specified map of values.
173 * If multiple fields have the same name, the value will be set to
174 * all of them.
175 *
176 * @param {Object} values the map of form field values
177 */
178RapidContext.Widget.Form.prototype.update = function (values) {
179 function setValue(field) {
180 var v = values[field.name];
181 if (field.name == "*" && typeof(field.setAttrs) == "function") {
182 field.setAttrs({ value: values });
183 } else if (!(field.name in values)) {
184 // Don't change omitted fields
185 } else if (field.type === "radio" || field.type === "checkbox") {
186 var found = Array.isArray(v) && v.includes(field.value);
187 field.checked = found || v === field.value || v === true;
188 } else if (typeof(field.setAttrs) == "function") {
189 field.setAttrs({ value: v });
190 } else {
191 field.value = MochiKit.Base.isArrayLike(v) ? v.join(", ") : v;
192 }
193 }
194 this.fields().forEach(setValue);
195};
196
197/**
198 * Adds a custom form validator for a named form field. The function will be
199 * called as `[field].validator([value], [field], [form])` and should return
200 * `true`, `false` or a validation error message. The validator will be called
201 * on each `input` event and before form submission for enabled fields.
202 *
203 * Note: Checkbox validators will be called once for each `<input>` element,
204 * regardless of checked state. Radio validators will only be called with
205 * either the first or the checked `<input>` element.
206 *
207 * @param {string|Element} field the form field or name
208 * @param {function} validator the validator function
209 */
210RapidContext.Widget.Form.prototype.addValidator = function (field, validator) {
211 var name = String(field.name || field);
212 var arr = [].concat(this._validators[name], validator).filter(Boolean);
213 this._validators[name] = arr;
214};
215
216/**
217 * Removes all custom form validators for a named form field.
218 *
219 * @param {string|Element} [field] the form field, name, or null for all
220 */
221RapidContext.Widget.Form.prototype.removeValidators = function (field) {
222 if (field) {
223 var name = String(field.name || field);
224 delete this._validators[name];
225 } else {
226 this._validators = {};
227 }
228};
229
230/**
231 * Calls all custom validators for a form field. The validation result will
232 * update the `setCustomValidity()` on the field.
233 *
234 * @param {Element} field the form field
235 */
236RapidContext.Widget.Form.prototype._callValidators = function (field) {
237 var validators = this._validators[field.name];
238 if (!field.disabled && validators) {
239 var self = this;
240 var res = true;
241 validators.forEach(function (validator) {
242 if (res === true) {
243 res = validator.call(field, self._fieldValue(field), field, self);
244 }
245 });
246 field.setCustomValidity((res === true) ? "" : (res || "Validation failed"));
247 }
248};
249
250/**
251 * Returns an array with all child DOM nodes containing form validator widgets.
252 *
253 * @return {Array} the array of form validator widgets
254 */
255RapidContext.Widget.Form.prototype.validators = function () {
256 return Array.from(this.querySelectorAll(".widgetFormValidator"));
257};
258
259/**
260 * Validates this form using the form validators found.
261 *
262 * @return {boolean} `true` if the form validated successfully, or
263 * `false` if the validation failed
264 */
265RapidContext.Widget.Form.prototype.validate = function () {
266 this._validationTimer && clearTimeout(this._validationTimer);
267 this._validationTimer = false;
268 var self = this;
269 var fields = this.fieldMap();
270 var values = this.valueMap();
271 var success = true;
272 this.validateReset();
273 Object.keys(this._validators).forEach(function (name) {
274 [].concat(fields[name]).filter(Boolean).forEach(function (f) {
275 if (f.type !== "radio" || f.checked) {
276 self._callValidators(f);
277 }
278 });
279 });
280 this.validators().forEach(function (validator) {
281 [].concat(fields[validator.name]).filter(Boolean).forEach(function (f) {
282 success = validator.verify(f, values[f.name] || "") && success;
283 });
284 });
285 success = this.checkValidity() && success;
286 success || this.addClass("invalid");
287 delete this._validationTimer;
288 this.emit("validate", { detail: success, bubbles: true });
289 return success;
290};
291
292/**
293 * Resets all form validations. This method is automatically called when form
294 * is reset.
295 *
296 * @see #reset
297 */
298RapidContext.Widget.Form.prototype.validateReset = function () {
299 var fields = this.fieldMap();
300 Object.keys(fields).forEach(function (name) {
301 [].concat(fields[name]).filter(Boolean).forEach(function (f) {
302 f.setCustomValidity && f.setCustomValidity("");
303 });
304 });
305 [this, ...this.querySelectorAll(".invalid")].forEach((el) => {
306 el.classList.remove("invalid");
307 });
308 this.validators().forEach(function (validator) {
309 validator.reset();
310 });
311};
312
RapidContext
Access · Discovery · Insight
www.rapidcontext.com