Source RapidContext_Widget_Form.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2025 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    const 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    const basics = Array.from(this.elements);
117    const 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        const 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((f) => f.reset());
145    this.validateReset();
146};
147
148/**
149 * Returns a map with all form field values. If multiple fields have
150 * the same name, the value will be set to an array of all values.
151 * Disabled fields and unchecked checkboxes or radiobuttons will be
152 * ignored.
153 *
154 * @return {Object} the map of form field values
155 */
156RapidContext.Widget.Form.prototype.valueMap = function () {
157    const update = (o, field) => {
158        const k = field.name;
159        const v = this._fieldValue(field);
160        if (k && k != "*" && v != null) {
161            o[k] = (k in o) ? [].concat(o[k], v) : v;
162        }
163        return o;
164    };
165    return this.fields().reduce(update, {});
166};
167
168/**
169 * Updates the fields in this form with a specified map of values.
170 * If multiple fields have the same name, the value will be set to
171 * all of them.
172 *
173 * @param {Object} values the map of form field values
174 */
175RapidContext.Widget.Form.prototype.update = function (values) {
176    function setValue(field) {
177        const v = values[field.name];
178        if (field.name == "*" && typeof(field.setAttrs) == "function") {
179            field.setAttrs({ value: values });
180        } else if (!(field.name in values)) {
181            // Don't change omitted fields
182        } else if (field.type === "radio" || field.type === "checkbox") {
183            const found = Array.isArray(v) && v.includes(field.value);
184            field.checked = found || v === field.value || v === true;
185        } else if (typeof(field.setAttrs) == "function") {
186            field.setAttrs({ value: v });
187        } else {
188            field.value = MochiKit.Base.isArrayLike(v) ? v.join(", ") : v;
189        }
190    }
191    this.fields().forEach(setValue);
192};
193
194/**
195 * Adds a custom form validator for a named form field. The function will be
196 * called as `[field].validator([value], [field], [form])` and should return
197 * `true`, `false` or a validation error message. The validator will be called
198 * on each `input` event and before form submission for enabled fields.
199 *
200 * Note: Checkbox validators will be called once for each `<input>` element,
201 * regardless of checked state. Radio validators will only be called with
202 * either the first or the checked `<input>` element.
203 *
204 * @param {string|Element} field the form field or name
205 * @param {function} validator the validator function
206 */
207RapidContext.Widget.Form.prototype.addValidator = function (field, validator) {
208    const name = String(field.name || field);
209    const arr = [].concat(this._validators[name], validator).filter(Boolean);
210    this._validators[name] = arr;
211};
212
213/**
214 * Removes all custom form validators for a named form field.
215 *
216 * @param {string|Element} [field] the form field, name, or null for all
217 */
218RapidContext.Widget.Form.prototype.removeValidators = function (field) {
219    if (field) {
220        const name = String(field.name || field);
221        delete this._validators[name];
222    } else {
223        this._validators = {};
224    }
225};
226
227/**
228 * Calls all custom validators for a form field. The validation result will
229 * update the `setCustomValidity()` on the field.
230 *
231 * @param {Element} field the form field
232 */
233RapidContext.Widget.Form.prototype._callValidators = function (field) {
234    const validators = this._validators[field.name];
235    if (!field.disabled && validators) {
236        let res = true;
237        validators.forEach((validator) => {
238            if (res === true) {
239                res = validator.call(field, this._fieldValue(field), field, self);
240            }
241        });
242        field.setCustomValidity((res === true) ? "" : (res || "Validation failed"));
243    }
244};
245
246/**
247 * Returns an array with all child DOM nodes containing form validator widgets.
248 *
249 * @return {Array} the array of form validator widgets
250 */
251RapidContext.Widget.Form.prototype.validators = function () {
252    return Array.from(this.querySelectorAll(".widgetFormValidator"));
253};
254
255/**
256 * Validates this form using the form validators found.
257 *
258 * @return {boolean} `true` if the form validated successfully, or
259 *         `false` if the validation failed
260 */
261RapidContext.Widget.Form.prototype.validate = function () {
262    this._validationTimer && clearTimeout(this._validationTimer);
263    this._validationTimer = false;
264    const fields = this.fieldMap();
265    const values = this.valueMap();
266    let success = true;
267    this.validateReset();
268    Object.keys(this._validators).forEach((name) => {
269        [].concat(fields[name]).filter(Boolean).forEach((f) => {
270            if (f.type !== "radio" || f.checked) {
271                this._callValidators(f);
272            }
273        });
274    });
275    this.validators().forEach((validator) => {
276        [].concat(fields[validator.name]).filter(Boolean).forEach((f) => {
277            success = validator.verify(f, values[f.name] || "") && success;
278        });
279    });
280    success = this.checkValidity() && success;
281    success || this.addClass("invalid");
282    delete this._validationTimer;
283    this.emit("validate", { detail: success, bubbles: true });
284    return success;
285};
286
287/**
288 * Resets all form validations. This method is automatically called when form
289 * is reset.
290 *
291 * @see #reset
292 */
293RapidContext.Widget.Form.prototype.validateReset = function () {
294    const fields = this.fieldMap();
295    Object.keys(fields).forEach((name) => {
296        [].concat(fields[name]).filter(Boolean).forEach((f) => {
297            f.setCustomValidity && f.setCustomValidity("");
298        });
299    });
300    [this, ...this.querySelectorAll(".invalid")].forEach((el) => {
301        el.classList.remove("invalid");
302    });
303    this.validators().forEach((validator) => validator.reset());
304};
305