release: WRNexusJS 0.5.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/validation",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -20,7 +20,7 @@ export type RuleDescriptor =
|
||||
| { kind: "integer"; message?: string };
|
||||
|
||||
export interface FieldDescriptor {
|
||||
type: "string" | "number" | "boolean";
|
||||
type: "string" | "number" | "boolean" | "unknown";
|
||||
optional?: boolean;
|
||||
/** Message used when a required field is empty. Defaults to "Required". */
|
||||
requiredMessage?: string;
|
||||
@@ -103,6 +103,14 @@ export function checkField(
|
||||
desc: FieldDescriptor,
|
||||
raw: unknown,
|
||||
): { value: unknown; error: string | null } {
|
||||
if (desc.type === "unknown") {
|
||||
const empty = raw === undefined || raw === null || raw === "";
|
||||
return {
|
||||
value: raw,
|
||||
error: empty && !desc.optional ? desc.requiredMessage || "Required" : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (desc.type === "boolean") {
|
||||
const value = raw === true || raw === "true" || raw === "on";
|
||||
if (!desc.optional && !value) {
|
||||
@@ -139,8 +147,8 @@ export function checkField(
|
||||
/** A server-only refinement (a predicate that can't be serialized to the client). */
|
||||
type Refinement = { fn: (value: unknown) => boolean | string; message?: string };
|
||||
|
||||
abstract class FieldSchema {
|
||||
abstract readonly type: "string" | "number" | "boolean";
|
||||
export abstract class FieldSchema {
|
||||
abstract readonly type: "string" | "number" | "boolean" | "unknown";
|
||||
protected _optional = false;
|
||||
protected _requiredMessage?: string;
|
||||
protected _label?: string;
|
||||
@@ -206,7 +214,7 @@ abstract class FieldSchema {
|
||||
}
|
||||
}
|
||||
|
||||
class StringSchema extends FieldSchema {
|
||||
export class StringSchema extends FieldSchema {
|
||||
readonly type = "string" as const;
|
||||
private _trim = false;
|
||||
email(message?: string): this {
|
||||
@@ -246,7 +254,7 @@ class StringSchema extends FieldSchema {
|
||||
}
|
||||
}
|
||||
|
||||
class NumberSchema extends FieldSchema {
|
||||
export class NumberSchema extends FieldSchema {
|
||||
readonly type = "number" as const;
|
||||
integer(message?: string): this {
|
||||
this.rules.push({ kind: "integer", message });
|
||||
@@ -262,13 +270,34 @@ class NumberSchema extends FieldSchema {
|
||||
}
|
||||
}
|
||||
|
||||
class BooleanSchema extends FieldSchema {
|
||||
export class BooleanSchema extends FieldSchema {
|
||||
readonly type = "boolean" as const;
|
||||
}
|
||||
|
||||
export class UnknownSchema extends FieldSchema {
|
||||
readonly type = "unknown" as const;
|
||||
}
|
||||
|
||||
export type AnyFieldSchema = StringSchema | NumberSchema | BooleanSchema | UnknownSchema;
|
||||
|
||||
export class ObjectSchema {
|
||||
constructor(private readonly fields: Record<string, FieldSchema>) {}
|
||||
|
||||
/** Return a defensive copy of the schema fields. */
|
||||
getFields(): Readonly<Record<string, FieldSchema>> {
|
||||
return { ...this.fields };
|
||||
}
|
||||
|
||||
/** Create a new schema with fields added or replaced. The original is unchanged. */
|
||||
extend(fields: Record<string, FieldSchema>): ObjectSchema {
|
||||
return new ObjectSchema({ ...this.fields, ...fields });
|
||||
}
|
||||
|
||||
/** Create a new schema containing fields from both schemas. */
|
||||
merge(schema: ObjectSchema): ObjectSchema {
|
||||
return new ObjectSchema({ ...this.fields, ...schema.getFields() });
|
||||
}
|
||||
|
||||
/** Validate an input object; returns coerced values + per-field errors. */
|
||||
parse(input: unknown): ParseResult {
|
||||
const source = (input ?? {}) as Record<string, unknown>;
|
||||
@@ -309,6 +338,7 @@ export const v = {
|
||||
string: () => new StringSchema(),
|
||||
number: () => new NumberSchema(),
|
||||
boolean: () => new BooleanSchema(),
|
||||
unknown: () => new UnknownSchema(),
|
||||
object: (fields: Record<string, FieldSchema>) => new ObjectSchema(fields),
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { SchemaDescriptor } from "./index.ts";
|
||||
|
||||
/** `window.__wireSchemas = { name: descriptor, ... }` for the client validator. */
|
||||
export function renderSchemasScript(descriptors: Record<string, SchemaDescriptor>): string {
|
||||
return `window.__wireSchemas=${JSON.stringify(descriptors)};`;
|
||||
return `window.__wireSchemas=Object.assign(window.__wireSchemas||{},${JSON.stringify(descriptors)});`;
|
||||
}
|
||||
|
||||
export const VALIDATE_RUNTIME = String.raw`
|
||||
@@ -40,6 +40,10 @@ export const VALIDATE_RUNTIME = String.raw`
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -70,9 +74,32 @@ export const VALIDATE_RUNTIME = String.raw`
|
||||
if (el && el.setAttribute) {
|
||||
if (err) { el.setAttribute("aria-invalid", "true"); if (el.classList) el.classList.add("wire-invalid"); }
|
||||
else { el.removeAttribute("aria-invalid"); if (el.classList) el.classList.remove("wire-invalid"); }
|
||||
var field = el.closest && el.closest(".wire-next--field, .wire-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) {
|
||||
@@ -95,18 +122,29 @@ export const VALIDATE_RUNTIME = String.raw`
|
||||
return out;
|
||||
}
|
||||
|
||||
function onSuccess(form, data) {
|
||||
var redirect = form.getAttribute("data-redirect") || (data && data.redirect);
|
||||
function onSuccess(form, data, responseRedirect) {
|
||||
showFormError(form, "");
|
||||
form.dispatchEvent(new CustomEvent("wire:success", { detail: data, bubbles: true }));
|
||||
var redirect = responseRedirect || form.getAttribute("data-redirect") || (data && data.redirect);
|
||||
if (redirect) {
|
||||
// Prefer client-side navigation (no full reload) when it is available.
|
||||
if (window.__wrnexusNavigate) window.__wrnexusNavigate(redirect);
|
||||
else location.assign(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) {
|
||||
box.textContent = box.getAttribute("data-success") || "Success";
|
||||
box.hidden = false;
|
||||
if (box.classList) box.classList.remove("hidden");
|
||||
}
|
||||
form.reset();
|
||||
form.dispatchEvent(new CustomEvent("wire:success", { detail: data, bubbles: true }));
|
||||
}
|
||||
|
||||
function csrfHeader() {
|
||||
@@ -118,6 +156,8 @@ export const VALIDATE_RUNTIME = String.raw`
|
||||
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();
|
||||
@@ -129,50 +169,145 @@ export const VALIDATE_RUNTIME = String.raw`
|
||||
body: JSON.stringify(collect(form)),
|
||||
})
|
||||
.then(function (res) {
|
||||
return res.json().catch(function () { return {}; }).then(function (data) { return { res: res, data: data }; });
|
||||
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) { onSuccess(form, r.data); return; }
|
||||
// Surface server-side field errors (e.g. "email already taken").
|
||||
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("wire:error", { detail: r.data, bubbles: true }));
|
||||
})
|
||||
.catch(function () {
|
||||
form.dispatchEvent(new CustomEvent("wire:error", { detail: { network: true }, 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("wire:error", { detail: detail, bubbles: true }));
|
||||
})
|
||||
.then(function () { btns.forEach(function (b) { b.disabled = false; }); });
|
||||
}
|
||||
|
||||
function bind(form) {
|
||||
if (form.__wireValidateBound) return;
|
||||
form.__wireValidateBound = 1;
|
||||
var name = form.getAttribute("data-schema");
|
||||
var schema = (window.__wireSchemas || {})[name];
|
||||
if (!schema) return;
|
||||
|
||||
// Package runtimes may register schemas later in the same page load.
|
||||
// Do not report an error during the first scan.
|
||||
if (!schema) {
|
||||
form.__wireValidatePending = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
form.__wireValidatePending = 0;
|
||||
form.__wireValidateMissingWarned = 0;
|
||||
form.__wireValidateBound = 1;
|
||||
form.__wireValidateMissingWarned = 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
|
||||
submitForm(form, schema);
|
||||
});
|
||||
form.addEventListener("blur", function (e) {
|
||||
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));
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
form.addEventListener("input", validateTarget, true);
|
||||
form.addEventListener("change", validateTarget, true);
|
||||
form.addEventListener("blur", validateTarget, true);
|
||||
}
|
||||
|
||||
function registerSchemas(descriptors, root) {
|
||||
window.__wireSchemas =
|
||||
window.__wireSchemas || {};
|
||||
|
||||
Object.keys(descriptors || {}).forEach(
|
||||
function (name) {
|
||||
window.__wireSchemas[name] =
|
||||
descriptors[name];
|
||||
}
|
||||
);
|
||||
|
||||
init(root || document);
|
||||
}
|
||||
|
||||
function init(root) {
|
||||
(root || document).querySelectorAll("form[data-schema]").forEach(bind);
|
||||
}
|
||||
|
||||
window.__wireValidate = { init: init };
|
||||
window.__wireValidate = {
|
||||
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.__wireValidateBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
var name =
|
||||
form.getAttribute("data-schema");
|
||||
|
||||
var schema =
|
||||
(window.__wireSchemas || {})[name];
|
||||
|
||||
if (schema) {
|
||||
bind(form);
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
showFormError(
|
||||
form,
|
||||
"Validation could not be loaded. Please refresh and try again."
|
||||
);
|
||||
|
||||
if (
|
||||
!form.__wireValidateMissingWarned &&
|
||||
window.console &&
|
||||
console.error
|
||||
) {
|
||||
form.__wireValidateMissingWarned = 1;
|
||||
|
||||
console.error(
|
||||
"[wrnexus:validation] Schema '" +
|
||||
name +
|
||||
"' is unavailable:",
|
||||
form
|
||||
);
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
})();
|
||||
`.trim();
|
||||
|
||||
@@ -212,3 +212,96 @@ test("parseEnv defaults to reading the ambient environment", () => {
|
||||
expect(env.WIRE_TEST_ENV_VAR).toBe("present");
|
||||
delete process.env.WIRE_TEST_ENV_VAR;
|
||||
});
|
||||
|
||||
test("object schemas can be extended without mutating package defaults", () => {
|
||||
const base = v.object({ identifier: v.string().required("Enter an identifier") });
|
||||
const extended = base.extend({
|
||||
identifier: v.string().email("Use an email address"),
|
||||
remember: v.boolean().optional(),
|
||||
});
|
||||
|
||||
expect(base.parse({ identifier: "username" }).ok).toBe(true);
|
||||
expect(extended.parse({ identifier: "username" }).errors.identifier).toBe("Use an email address");
|
||||
expect(extended.parse({ identifier: "person@example.com" }).ok).toBe(true);
|
||||
});
|
||||
|
||||
test("unknown schemas preserve structured request payloads", () => {
|
||||
const schema = v.object({ response: v.unknown().required("Response is required") });
|
||||
const payload = { id: "credential", nested: { ok: true } };
|
||||
const result = schema.parse({ response: payload });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.value.response).toEqual(payload);
|
||||
expect(schema.parse({}).errors.response).toBe("Response is required");
|
||||
});
|
||||
|
||||
test("validation can bind after a package registers a missing schema", () => {
|
||||
const win = new Window() as unknown as Window & Record<string, unknown>;
|
||||
win.document.body.innerHTML = `
|
||||
<form data-schema="late-schema">
|
||||
<input name="identifier">
|
||||
<span data-error="identifier"></span>
|
||||
</form>`;
|
||||
win.__wireSchemas = {};
|
||||
(globalThis as Record<string, unknown>).window = win;
|
||||
(globalThis as Record<string, unknown>).document = win.document;
|
||||
|
||||
try {
|
||||
(0, eval)(VALIDATE_RUNTIME);
|
||||
const runtime = win.__wireValidate as { init(root: Document): void };
|
||||
runtime.init(win.document as unknown as Document);
|
||||
win.__wireSchemas = {
|
||||
"late-schema": v
|
||||
.object({ identifier: v.string().required("Enter an identifier") })
|
||||
.describe(),
|
||||
};
|
||||
runtime.init(win.document as unknown as Document);
|
||||
win.document
|
||||
.querySelector("form")!
|
||||
.dispatchEvent(new win.Event("submit", { bubbles: true, cancelable: true }));
|
||||
expect(win.document.querySelector("[data-error=identifier]")!.textContent).toBe(
|
||||
"Enter an identifier",
|
||||
);
|
||||
} finally {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
delete (globalThis as Record<string, unknown>).document;
|
||||
}
|
||||
});
|
||||
|
||||
test("schema-backed forms validate on input, change, and blur and synchronize field state", () => {
|
||||
const win = new Window() as unknown as Window & Record<string, unknown>;
|
||||
win.document.body.innerHTML = `
|
||||
<form data-schema="profile">
|
||||
<div class="wire-next--field" data-invalid="false">
|
||||
<input name="email">
|
||||
<span data-error="email"></span>
|
||||
</div>
|
||||
</form>`;
|
||||
win.__wireSchemas = {
|
||||
profile: v
|
||||
.object({ email: v.string().required("Enter an email").email("Use a valid email") })
|
||||
.describe(),
|
||||
};
|
||||
(globalThis as Record<string, unknown>).window = win;
|
||||
(globalThis as Record<string, unknown>).document = win.document;
|
||||
|
||||
try {
|
||||
(0, eval)(VALIDATE_RUNTIME);
|
||||
const input = win.document.querySelector("input") as unknown as HTMLInputElement;
|
||||
const field = win.document.querySelector(".wire-next--field") as unknown as HTMLElement;
|
||||
const error = win.document.querySelector("[data-error=email]") as unknown as HTMLElement;
|
||||
|
||||
input.dispatchEvent(new win.Event("input", { bubbles: true }) as unknown as Event);
|
||||
expect(error.textContent).toBe("Enter an email");
|
||||
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(field.dataset.invalid).toBe("true");
|
||||
|
||||
input.value = "person@example.com";
|
||||
input.dispatchEvent(new win.Event("change", { bubbles: true }) as unknown as Event);
|
||||
expect(error.textContent).toBe("");
|
||||
expect(input.hasAttribute("aria-invalid")).toBe(false);
|
||||
expect(field.dataset.invalid).toBe("false");
|
||||
} finally {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
delete (globalThis as Record<string, unknown>).document;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user