Source RapidContext_Widget_Form.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2023 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 = MochiKit.DOM.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