323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
/**
|
|
* Client-side validation. `renderSchemasScript` bakes the discovered schema
|
|
* descriptors into `window.__wrnSchemas`; `VALIDATE_RUNTIME` is a generic,
|
|
* eval-free validator that reads them and validates every `form[data-schema]`
|
|
* on submit and blur, writing messages into `[data-error="<field>"]` elements.
|
|
* The rule logic mirrors `checkField`/`applyRule` in index.ts.
|
|
*/
|
|
|
|
import type { SchemaDescriptor } from "./index.ts";
|
|
|
|
/** `window.__wrnSchemas = { name: descriptor, ... }` for the client validator. */
|
|
export function renderSchemasScript(descriptors: Record<string, SchemaDescriptor>): string {
|
|
return `window.__wrnSchemas=Object.assign(window.__wrnSchemas||{},${JSON.stringify(descriptors)});`;
|
|
}
|
|
|
|
export const VALIDATE_RUNTIME = String.raw`
|
|
(function () {
|
|
var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
var URL_RE = /^https?:\/\/[^\s/$.?#][^\s]*$/i;
|
|
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
|
|
function applyRule(type, r, value) {
|
|
if (r.kind === "min") {
|
|
if (type === "string") return String(value).length < r.n ? (r.message || ("Must be at least " + r.n + " characters")) : null;
|
|
return value < r.n ? (r.message || ("Must be at least " + r.n)) : null;
|
|
}
|
|
if (r.kind === "max") {
|
|
if (type === "string") return String(value).length > r.n ? (r.message || ("Must be at most " + r.n + " characters")) : null;
|
|
return value > r.n ? (r.message || ("Must be at most " + r.n)) : null;
|
|
}
|
|
if (r.kind === "length") return String(value).length !== r.n ? (r.message || ("Must be exactly " + r.n + " characters")) : null;
|
|
if (r.kind === "email") return EMAIL.test(String(value)) ? null : (r.message || "Must be a valid email");
|
|
if (r.kind === "url") return URL_RE.test(String(value)) ? null : (r.message || "Must be a valid URL");
|
|
if (r.kind === "uuid") return UUID_RE.test(String(value)) ? null : (r.message || "Must be a valid UUID");
|
|
if (r.kind === "date") return isNaN(Date.parse(String(value))) ? (r.message || "Must be a valid date") : null;
|
|
if (r.kind === "oneOf") return r.values.indexOf(value) !== -1 ? null : (r.message || ("Must be one of: " + r.values.join(", ")));
|
|
if (r.kind === "pattern") { try { return new RegExp(r.source, r.flags || "").test(String(value)) ? null : (r.message || "Invalid format"); } catch (e) { return null; } }
|
|
if (r.kind === "integer") return Number.isInteger(value) ? null : (r.message || "Must be a whole number");
|
|
return null;
|
|
}
|
|
|
|
function checkField(desc, raw) {
|
|
if (desc.type === "unknown") {
|
|
var missing = raw === undefined || raw === null || raw === "";
|
|
return (missing && !desc.optional) ? (desc.requiredMessage || "Required") : null;
|
|
}
|
|
if (desc.type === "boolean") {
|
|
var b = raw === true || raw === "true" || raw === "on";
|
|
return (!desc.optional && !b) ? (desc.requiredMessage || "Required") : null;
|
|
}
|
|
var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
|
|
var empty = pre === undefined || pre === null || pre === "";
|
|
if (empty) return desc.optional ? null : (desc.requiredMessage || "Required");
|
|
var value;
|
|
if (desc.type === "number") { value = Number(pre); if (isNaN(value)) return desc.typeMessage || "Must be a number"; }
|
|
else value = String(pre);
|
|
for (var i = 0; i < desc.rules.length; i++) {
|
|
var err = applyRule(desc.type, desc.rules[i], value);
|
|
if (err) return err;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function rawValue(form, name) {
|
|
var el = form.elements[name];
|
|
if (!el) return undefined;
|
|
return el.type === "checkbox" ? el.checked : el.value;
|
|
}
|
|
|
|
function showError(form, name, err) {
|
|
var box = form.querySelector('[data-error="' + name + '"]');
|
|
if (box) box.textContent = err || "";
|
|
var el = form.elements[name];
|
|
if (el && el.setAttribute) {
|
|
if (err) { el.setAttribute("aria-invalid", "true"); if (el.classList) el.classList.add("wrn-invalid"); }
|
|
else { el.removeAttribute("aria-invalid"); if (el.classList) el.classList.remove("wrn-invalid"); }
|
|
var field = el.closest && el.closest(".wrn-next--field, .wrn-next--choice-field");
|
|
if (field) field.setAttribute("data-invalid", err ? "true" : "false");
|
|
}
|
|
}
|
|
|
|
function showFormError(form, message) {
|
|
var box = form.querySelector('[data-error="_form"]');
|
|
if (!box) return;
|
|
box.textContent = message || "";
|
|
box.hidden = !message;
|
|
if (box.classList) box.classList.toggle("hidden", !message);
|
|
}
|
|
|
|
|
|
function hideSuccess(form) {
|
|
var box = form.querySelector("[data-success]");
|
|
if (!box) return;
|
|
box.hidden = true;
|
|
if (box.classList) box.classList.add("hidden");
|
|
}
|
|
|
|
function clearErrors(form, schema) {
|
|
Object.keys(schema.fields).forEach(function (name) { showError(form, name, ""); });
|
|
showFormError(form, "");
|
|
}
|
|
|
|
function validateForm(form, schema) {
|
|
var errors = 0;
|
|
Object.keys(schema.fields).forEach(function (name) {
|
|
var err = checkField(schema.fields[name], rawValue(form, name));
|
|
if (err) errors++;
|
|
showError(form, name, err);
|
|
});
|
|
return errors;
|
|
}
|
|
|
|
function collect(form) {
|
|
var out = {};
|
|
for (var i = 0; i < form.elements.length; i++) {
|
|
var el = form.elements[i];
|
|
if (!el.name) continue;
|
|
if (el.type === "checkbox") out[el.name] = el.checked;
|
|
else if (el.type === "radio") { if (el.checked) out[el.name] = el.value; }
|
|
else out[el.name] = el.value;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function onSuccess(form, data, responseRedirect) {
|
|
showFormError(form, "");
|
|
form.dispatchEvent(new CustomEvent("wrn:success", { detail: data, bubbles: true }));
|
|
var redirect = responseRedirect || form.getAttribute("data-redirect") || (data && data.redirect);
|
|
if (redirect) {
|
|
// Prefer client-side navigation for same-origin application redirects.
|
|
try {
|
|
var target = new URL(redirect, location.href);
|
|
if (target.origin === location.origin && window.__wrnexusNavigate) {
|
|
window.__wrnexusNavigate(target.pathname + target.search + target.hash);
|
|
} else location.assign(target.href);
|
|
} catch (e) {
|
|
location.assign(redirect);
|
|
}
|
|
return;
|
|
}
|
|
var box = form.querySelector("[data-success]");
|
|
if (box) {
|
|
box.textContent = box.getAttribute("data-success") || "Success";
|
|
box.hidden = false;
|
|
if (box.classList) box.classList.remove("hidden");
|
|
}
|
|
form.reset();
|
|
}
|
|
|
|
function csrfHeader() {
|
|
var m = document.cookie.match(/(?:^|;\s*)wrn-csrf=([^;]+)/);
|
|
return m ? { "x-csrf-token": decodeURIComponent(m[1]) } : {};
|
|
}
|
|
|
|
function submitForm(form, schema) {
|
|
var method = (form.getAttribute("method") || "post").toUpperCase();
|
|
var action = form.getAttribute("action") || location.pathname;
|
|
var btns = form.querySelectorAll("[type=submit]");
|
|
clearErrors(form, schema);
|
|
hideSuccess(form);
|
|
btns.forEach(function (b) { b.disabled = true; });
|
|
var headers = { "content-type": "application/json", accept: "application/json" };
|
|
var csrf = csrfHeader();
|
|
for (var k in csrf) headers[k] = csrf[k];
|
|
fetch(action, {
|
|
method: method,
|
|
headers: headers,
|
|
credentials: "same-origin",
|
|
body: JSON.stringify(collect(form)),
|
|
})
|
|
.then(function (res) {
|
|
var responseRedirect = res.redirected && res.url ? res.url : "";
|
|
return res.json().catch(function () { return {}; }).then(function (data) {
|
|
return { res: res, data: data, responseRedirect: responseRedirect };
|
|
});
|
|
})
|
|
.then(function (r) {
|
|
if (r.res.ok && (!r.data || r.data.ok !== false)) {
|
|
onSuccess(form, r.data, r.responseRedirect);
|
|
return;
|
|
}
|
|
// Surface server-side field errors and a form-level API message.
|
|
var errors = r.data && r.data.errors;
|
|
if (errors) Object.keys(errors).forEach(function (f) { showError(form, f, errors[f]); });
|
|
var message = r.data && (r.data.message || r.data.error);
|
|
showFormError(form, message || (errors ? "" : ("Request failed (" + r.res.status + ")")));
|
|
form.dispatchEvent(new CustomEvent("wrn:error", { detail: r.data, bubbles: true }));
|
|
})
|
|
.catch(function (error) {
|
|
var detail = { network: true, message: error && error.message ? error.message : "Network request failed" };
|
|
showFormError(form, detail.message);
|
|
form.dispatchEvent(new CustomEvent("wrn:error", { detail: detail, bubbles: true }));
|
|
})
|
|
.then(function () { btns.forEach(function (b) { b.disabled = false; }); });
|
|
}
|
|
|
|
function runSubmitGuards(form, event) {
|
|
var guards = form.__wrnSubmitGuards || [];
|
|
for (var index = 0; index < guards.length; index += 1) {
|
|
if (guards[index](event) === false) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function bind(form) {
|
|
if (form.__wrnValidateBound) return;
|
|
var name = form.getAttribute("data-schema");
|
|
var schema = (window.__wrnSchemas || {})[name];
|
|
|
|
// Package runtimes may register schemas later in the same page load.
|
|
// Do not report an error during the first scan.
|
|
if (!schema) {
|
|
form.__wrnValidatePending = 1;
|
|
return;
|
|
}
|
|
|
|
form.__wrnValidatePending = 0;
|
|
form.__wrnValidateMissingWarned = 0;
|
|
form.__wrnValidateBound = 1;
|
|
form.__wrnValidateMissingWarned = 0;
|
|
// Schema-backed forms use WRNexus messages instead of the browser's
|
|
// non-themeable native validation bubbles.
|
|
form.noValidate = true;
|
|
form.setAttribute("novalidate", "");
|
|
form.addEventListener("submit", function (e) {
|
|
e.preventDefault();
|
|
clearErrors(form, schema);
|
|
hideSuccess(form);
|
|
if (validateForm(form, schema) > 0) return; // client-invalid: stay put, errors shown
|
|
if (!runSubmitGuards(form, e)) return;
|
|
submitForm(form, schema);
|
|
});
|
|
function validateTarget(e) {
|
|
var t = e.target;
|
|
if (t && t.name && schema.fields[t.name]) {
|
|
showError(form, t.name, checkField(schema.fields[t.name], t.type === "checkbox" ? t.checked : t.value));
|
|
}
|
|
}
|
|
form.addEventListener("input", validateTarget, true);
|
|
form.addEventListener("change", validateTarget, true);
|
|
form.addEventListener("blur", validateTarget, true);
|
|
}
|
|
|
|
function registerSchemas(descriptors, root) {
|
|
window.__wrnSchemas =
|
|
window.__wrnSchemas || {};
|
|
|
|
Object.keys(descriptors || {}).forEach(
|
|
function (name) {
|
|
window.__wrnSchemas[name] =
|
|
descriptors[name];
|
|
}
|
|
);
|
|
|
|
init(root || document);
|
|
}
|
|
|
|
function init(root) {
|
|
(root || document).querySelectorAll("form[data-schema]").forEach(bind);
|
|
}
|
|
|
|
window.__wrnValidate = {
|
|
init: init,
|
|
registerSchemas: registerSchemas
|
|
};
|
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { init(document); });
|
|
else init(document);
|
|
|
|
document.addEventListener(
|
|
"submit",
|
|
function (event) {
|
|
var form = event.target;
|
|
|
|
if (
|
|
!form ||
|
|
!form.matches ||
|
|
!form.matches("form[data-schema]")
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (form.__wrnValidateBound) {
|
|
return;
|
|
}
|
|
|
|
var name =
|
|
form.getAttribute("data-schema");
|
|
|
|
var schema =
|
|
(window.__wrnSchemas || {})[name];
|
|
|
|
if (schema) {
|
|
bind(form);
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
showFormError(
|
|
form,
|
|
"Validation could not be loaded. Please refresh and try again."
|
|
);
|
|
|
|
if (
|
|
!form.__wrnValidateMissingWarned &&
|
|
window.console &&
|
|
console.error
|
|
) {
|
|
form.__wrnValidateMissingWarned = 1;
|
|
|
|
console.error(
|
|
"[wrnexus:validation] Schema '" +
|
|
name +
|
|
"' is unavailable:",
|
|
form
|
|
);
|
|
}
|
|
},
|
|
true
|
|
);
|
|
})();
|
|
`.trim();
|