@nanobpm/urban 0.46.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -482,6 +482,10 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
|
|
|
482
482
|
.pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
|
|
483
483
|
.pc-msg.err { color:var(--nano-danger); }
|
|
484
484
|
.pc-msg.ok { color:var(--nano-ok); }
|
|
485
|
+
.pc-req { color:var(--nano-danger); font-weight:600; }
|
|
486
|
+
.pc-field-err { font-size:.75rem; color:var(--nano-danger); margin:.1rem 0 0; }
|
|
487
|
+
.pc-field-err:empty { display:none; }
|
|
488
|
+
.pc-field input.pc-invalid, .pc-field-check input.pc-invalid { border-color:var(--nano-danger); outline-color:var(--nano-danger); }
|
|
485
489
|
table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; table-layout:fixed; }
|
|
486
490
|
/* table-layout:fixed divides the available width across columns and wraps text
|
|
487
491
|
within each, so a grid with several long free-text columns (e.g. the epic
|
|
@@ -1031,9 +1035,38 @@ function renderActionForm(node) {
|
|
|
1031
1035
|
// pollute Object.prototype or shadow inherited props (prototype-pollution class).
|
|
1032
1036
|
const inputs = new Map();
|
|
1033
1037
|
const fieldTypes = new Map();
|
|
1038
|
+
// Client-side required-field validation state: the set of required keys, each
|
|
1039
|
+
// field's custom message, and its inline error node (populated on a blocked
|
|
1040
|
+
// submit, cleared as soon as the user edits the field).
|
|
1041
|
+
const required = new Set();
|
|
1042
|
+
const reqMsg = new Map();
|
|
1043
|
+
const errs = new Map();
|
|
1044
|
+
// A required field's label carries a danger-toned "*" so the requirement is
|
|
1045
|
+
// visible BEFORE submit; spread into el(...) — an empty array adds no child.
|
|
1046
|
+
const reqMark = (f) => (f.required === true ? [el("span", { class: "pc-req", title: "Required" }, " *")] : []);
|
|
1047
|
+
// Register a field's inline error node and, when required, mark the input
|
|
1048
|
+
// (aria-required) and wire a clear-on-edit handler so a hint disappears the
|
|
1049
|
+
// moment the user starts fixing it. Editing a required field also clears the
|
|
1050
|
+
// blocked-submit summary ("Please fill in the required fields.") so it never
|
|
1051
|
+
// lingers as a stale/misleading banner while the user is correcting input.
|
|
1052
|
+
const wireField = (f, input, ferr, evt) => {
|
|
1053
|
+
errs.set(f.key, ferr);
|
|
1054
|
+
if (f.required !== true) return;
|
|
1055
|
+
required.add(f.key);
|
|
1056
|
+
if (typeof f.requiredMessage === "string" && f.requiredMessage !== "") reqMsg.set(f.key, f.requiredMessage);
|
|
1057
|
+
input.setAttribute("aria-required", "true");
|
|
1058
|
+
input.addEventListener(evt, () => {
|
|
1059
|
+
input.classList.remove("pc-invalid");
|
|
1060
|
+
input.removeAttribute("aria-invalid");
|
|
1061
|
+
ferr.textContent = "";
|
|
1062
|
+
msg.textContent = "";
|
|
1063
|
+
msg.className = "pc-msg";
|
|
1064
|
+
});
|
|
1065
|
+
};
|
|
1034
1066
|
for (const f of p.fields || []) {
|
|
1035
1067
|
const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
|
|
1036
1068
|
fieldTypes.set(f.key, kind);
|
|
1069
|
+
const ferr = el("p", { class: "pc-field-err" });
|
|
1037
1070
|
if (kind === "checkbox") {
|
|
1038
1071
|
// A boolean field renders a real checkbox; its default checked state comes
|
|
1039
1072
|
// from default/checked on the field (unset -> unchecked). We record that on
|
|
@@ -1044,9 +1077,10 @@ function renderActionForm(node) {
|
|
|
1044
1077
|
input.defaultChecked = checked;
|
|
1045
1078
|
input.checked = checked;
|
|
1046
1079
|
inputs.set(f.key, input);
|
|
1080
|
+
wireField(f, input, ferr, "change");
|
|
1047
1081
|
// Input-first, wrapped in the label so the whole row is a click target.
|
|
1048
1082
|
card.append(
|
|
1049
|
-
el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key))),
|
|
1083
|
+
el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key), ...reqMark(f)), ferr),
|
|
1050
1084
|
);
|
|
1051
1085
|
continue;
|
|
1052
1086
|
}
|
|
@@ -1059,11 +1093,43 @@ function renderActionForm(node) {
|
|
|
1059
1093
|
}
|
|
1060
1094
|
const input = el("input", attrs);
|
|
1061
1095
|
inputs.set(f.key, input);
|
|
1062
|
-
|
|
1096
|
+
wireField(f, input, ferr, "input");
|
|
1097
|
+
card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key, ...reqMark(f)), input, ferr));
|
|
1063
1098
|
}
|
|
1064
1099
|
const msg = el("p", { class: "pc-msg" });
|
|
1065
1100
|
const btn = el("button", { class: "pc-btn" }, p.submitLabel || "Submit");
|
|
1066
1101
|
btn.addEventListener("click", async () => {
|
|
1102
|
+
// Required-field gate: block the submit and surface an inline hint on each
|
|
1103
|
+
// empty required field (a text/number left blank, a required checkbox left
|
|
1104
|
+
// unchecked), focusing the first offender — so a missing value never
|
|
1105
|
+
// round-trips to the server only to bounce back as a generic error.
|
|
1106
|
+
let firstInvalid = null;
|
|
1107
|
+
for (const [k, input] of inputs) {
|
|
1108
|
+
const ferr = errs.get(k);
|
|
1109
|
+
if (ferr) ferr.textContent = "";
|
|
1110
|
+
input.classList.remove("pc-invalid");
|
|
1111
|
+
input.removeAttribute("aria-invalid");
|
|
1112
|
+
if (!required.has(k)) continue;
|
|
1113
|
+
const type = fieldTypes.get(k);
|
|
1114
|
+
const val = String(input.value).trim();
|
|
1115
|
+
const missing = type === "checkbox"
|
|
1116
|
+
? input.checked !== true
|
|
1117
|
+
: type === "number"
|
|
1118
|
+
? (val === "" || !Number.isFinite(Number(val)))
|
|
1119
|
+
: val === "";
|
|
1120
|
+
if (missing) {
|
|
1121
|
+
if (ferr) ferr.textContent = reqMsg.get(k) || "Required";
|
|
1122
|
+
input.classList.add("pc-invalid");
|
|
1123
|
+
input.setAttribute("aria-invalid", "true");
|
|
1124
|
+
if (!firstInvalid) firstInvalid = input;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
if (firstInvalid) {
|
|
1128
|
+
msg.className = "pc-msg err";
|
|
1129
|
+
msg.textContent = "Please fill in the required fields.";
|
|
1130
|
+
firstInvalid.focus();
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1067
1133
|
const variables = Object.create(null);
|
|
1068
1134
|
for (const [k, input] of inputs) {
|
|
1069
1135
|
if (fieldTypes.get(k) === "checkbox") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -502,6 +502,48 @@ test("the renderer honours boolean (checkbox) actionForm fields", async () => {
|
|
|
502
502
|
assert.match(js, /input\.checked = input\.defaultChecked;/);
|
|
503
503
|
});
|
|
504
504
|
|
|
505
|
+
test("the renderer enforces required actionForm fields client-side, blocking submit with an inline hint", async () => {
|
|
506
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
507
|
+
const js = res.body ?? "";
|
|
508
|
+
// A field declared `required: true` is tracked in a Set and given an inline
|
|
509
|
+
// error node; the label carries a danger-toned "*" marker before submit.
|
|
510
|
+
assert.match(js, /required\.add\(f\.key\)/);
|
|
511
|
+
assert.match(js, /reqMark = \(f\) =>/);
|
|
512
|
+
assert.match(js, /class: "pc-req"/);
|
|
513
|
+
// The input is marked aria-required, and a clear-on-edit handler wipes the hint
|
|
514
|
+
// the moment the user starts fixing the field (input for text/number, change for
|
|
515
|
+
// a checkbox).
|
|
516
|
+
assert.match(js, /input\.setAttribute\("aria-required", "true"\)/);
|
|
517
|
+
assert.match(js, /wireField\(f, input, ferr, "input"\)/);
|
|
518
|
+
assert.match(js, /wireField\(f, input, ferr, "change"\)/);
|
|
519
|
+
// Submit is GATED: an empty required text/number or an unchecked required
|
|
520
|
+
// checkbox is "missing"; the first offender is focused and the submit RETURNS
|
|
521
|
+
// early (never round-trips to the server).
|
|
522
|
+
assert.match(js, /input\.checked !== true/);
|
|
523
|
+
assert.match(js, /!Number\.isFinite\(Number\(val\)\)/);
|
|
524
|
+
assert.match(js, /if \(firstInvalid\)/);
|
|
525
|
+
assert.match(js, /Please fill in the required fields\./);
|
|
526
|
+
assert.match(js, /firstInvalid\.focus\(\)/);
|
|
527
|
+
assert.match(js, /input\.classList\.add\("pc-invalid"\)/);
|
|
528
|
+
// Editing a required field after a blocked submit clears the summary banner too,
|
|
529
|
+
// so "Please fill in the required fields." never lingers stale while correcting.
|
|
530
|
+
assert.match(js, /msg\.textContent = "";\s*\n\s*msg\.className = "pc-msg";/);
|
|
531
|
+
// A per-field custom message (`requiredMessage`) overrides the default "Required".
|
|
532
|
+
assert.match(js, /reqMsg\.get\(k\) \|\| "Required"/);
|
|
533
|
+
assert.match(js, /f\.requiredMessage/);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("the shell styles required-field markers and inline field errors", async () => {
|
|
537
|
+
const res = await dispatch("GET", "/");
|
|
538
|
+
const html = res.body ?? "";
|
|
539
|
+
// The "*" required marker and the inline per-field error both resolve through
|
|
540
|
+
// the shared danger token, and an empty error node collapses (no reserved gap).
|
|
541
|
+
assert.match(html, /\.pc-req \{ color:var\(--nano-danger\)/);
|
|
542
|
+
assert.match(html, /\.pc-field-err \{ font-size:.75rem; color:var\(--nano-danger\)/);
|
|
543
|
+
assert.match(html, /\.pc-field-err:empty \{ display:none; \}/);
|
|
544
|
+
assert.match(html, /input\.pc-invalid/);
|
|
545
|
+
});
|
|
546
|
+
|
|
505
547
|
test("renderer makes collapsible nodes persist their state across sessions", async () => {
|
|
506
548
|
// A node with `collapsible` gets a clickable header that hides/shows the card
|
|
507
549
|
// body, and the collapsed state is written to localStorage keyed by the home
|
|
@@ -533,6 +533,10 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
|
|
|
533
533
|
.pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
|
|
534
534
|
.pc-msg.err { color:var(--nano-danger); }
|
|
535
535
|
.pc-msg.ok { color:var(--nano-ok); }
|
|
536
|
+
.pc-req { color:var(--nano-danger); font-weight:600; }
|
|
537
|
+
.pc-field-err { font-size:.75rem; color:var(--nano-danger); margin:.1rem 0 0; }
|
|
538
|
+
.pc-field-err:empty { display:none; }
|
|
539
|
+
.pc-field input.pc-invalid, .pc-field-check input.pc-invalid { border-color:var(--nano-danger); outline-color:var(--nano-danger); }
|
|
536
540
|
table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; table-layout:fixed; }
|
|
537
541
|
/* table-layout:fixed divides the available width across columns and wraps text
|
|
538
542
|
within each, so a grid with several long free-text columns (e.g. the epic
|
|
@@ -1083,9 +1087,38 @@ function renderActionForm(node) {
|
|
|
1083
1087
|
// pollute Object.prototype or shadow inherited props (prototype-pollution class).
|
|
1084
1088
|
const inputs = new Map();
|
|
1085
1089
|
const fieldTypes = new Map();
|
|
1090
|
+
// Client-side required-field validation state: the set of required keys, each
|
|
1091
|
+
// field's custom message, and its inline error node (populated on a blocked
|
|
1092
|
+
// submit, cleared as soon as the user edits the field).
|
|
1093
|
+
const required = new Set();
|
|
1094
|
+
const reqMsg = new Map();
|
|
1095
|
+
const errs = new Map();
|
|
1096
|
+
// A required field's label carries a danger-toned "*" so the requirement is
|
|
1097
|
+
// visible BEFORE submit; spread into el(...) — an empty array adds no child.
|
|
1098
|
+
const reqMark = (f) => (f.required === true ? [el("span", { class: "pc-req", title: "Required" }, " *")] : []);
|
|
1099
|
+
// Register a field's inline error node and, when required, mark the input
|
|
1100
|
+
// (aria-required) and wire a clear-on-edit handler so a hint disappears the
|
|
1101
|
+
// moment the user starts fixing it. Editing a required field also clears the
|
|
1102
|
+
// blocked-submit summary ("Please fill in the required fields.") so it never
|
|
1103
|
+
// lingers as a stale/misleading banner while the user is correcting input.
|
|
1104
|
+
const wireField = (f, input, ferr, evt) => {
|
|
1105
|
+
errs.set(f.key, ferr);
|
|
1106
|
+
if (f.required !== true) return;
|
|
1107
|
+
required.add(f.key);
|
|
1108
|
+
if (typeof f.requiredMessage === "string" && f.requiredMessage !== "") reqMsg.set(f.key, f.requiredMessage);
|
|
1109
|
+
input.setAttribute("aria-required", "true");
|
|
1110
|
+
input.addEventListener(evt, () => {
|
|
1111
|
+
input.classList.remove("pc-invalid");
|
|
1112
|
+
input.removeAttribute("aria-invalid");
|
|
1113
|
+
ferr.textContent = "";
|
|
1114
|
+
msg.textContent = "";
|
|
1115
|
+
msg.className = "pc-msg";
|
|
1116
|
+
});
|
|
1117
|
+
};
|
|
1086
1118
|
for (const f of p.fields || []) {
|
|
1087
1119
|
const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
|
|
1088
1120
|
fieldTypes.set(f.key, kind);
|
|
1121
|
+
const ferr = el("p", { class: "pc-field-err" });
|
|
1089
1122
|
if (kind === "checkbox") {
|
|
1090
1123
|
// A boolean field renders a real checkbox; its default checked state comes
|
|
1091
1124
|
// from default/checked on the field (unset -> unchecked). We record that on
|
|
@@ -1096,9 +1129,10 @@ function renderActionForm(node) {
|
|
|
1096
1129
|
input.defaultChecked = checked;
|
|
1097
1130
|
input.checked = checked;
|
|
1098
1131
|
inputs.set(f.key, input);
|
|
1132
|
+
wireField(f, input, ferr, "change");
|
|
1099
1133
|
// Input-first, wrapped in the label so the whole row is a click target.
|
|
1100
1134
|
card.append(
|
|
1101
|
-
el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key))),
|
|
1135
|
+
el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key), ...reqMark(f)), ferr),
|
|
1102
1136
|
);
|
|
1103
1137
|
continue;
|
|
1104
1138
|
}
|
|
@@ -1111,11 +1145,43 @@ function renderActionForm(node) {
|
|
|
1111
1145
|
}
|
|
1112
1146
|
const input = el("input", attrs);
|
|
1113
1147
|
inputs.set(f.key, input);
|
|
1114
|
-
|
|
1148
|
+
wireField(f, input, ferr, "input");
|
|
1149
|
+
card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key, ...reqMark(f)), input, ferr));
|
|
1115
1150
|
}
|
|
1116
1151
|
const msg = el("p", { class: "pc-msg" });
|
|
1117
1152
|
const btn = el("button", { class: "pc-btn" }, p.submitLabel || "Submit");
|
|
1118
1153
|
btn.addEventListener("click", async () => {
|
|
1154
|
+
// Required-field gate: block the submit and surface an inline hint on each
|
|
1155
|
+
// empty required field (a text/number left blank, a required checkbox left
|
|
1156
|
+
// unchecked), focusing the first offender — so a missing value never
|
|
1157
|
+
// round-trips to the server only to bounce back as a generic error.
|
|
1158
|
+
let firstInvalid = null;
|
|
1159
|
+
for (const [k, input] of inputs) {
|
|
1160
|
+
const ferr = errs.get(k);
|
|
1161
|
+
if (ferr) ferr.textContent = "";
|
|
1162
|
+
input.classList.remove("pc-invalid");
|
|
1163
|
+
input.removeAttribute("aria-invalid");
|
|
1164
|
+
if (!required.has(k)) continue;
|
|
1165
|
+
const type = fieldTypes.get(k);
|
|
1166
|
+
const val = String(input.value).trim();
|
|
1167
|
+
const missing = type === "checkbox"
|
|
1168
|
+
? input.checked !== true
|
|
1169
|
+
: type === "number"
|
|
1170
|
+
? (val === "" || !Number.isFinite(Number(val)))
|
|
1171
|
+
: val === "";
|
|
1172
|
+
if (missing) {
|
|
1173
|
+
if (ferr) ferr.textContent = reqMsg.get(k) || "Required";
|
|
1174
|
+
input.classList.add("pc-invalid");
|
|
1175
|
+
input.setAttribute("aria-invalid", "true");
|
|
1176
|
+
if (!firstInvalid) firstInvalid = input;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
if (firstInvalid) {
|
|
1180
|
+
msg.className = "pc-msg err";
|
|
1181
|
+
msg.textContent = "Please fill in the required fields.";
|
|
1182
|
+
firstInvalid.focus();
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1119
1185
|
const variables = Object.create(null);
|
|
1120
1186
|
for (const [k, input] of inputs) {
|
|
1121
1187
|
if (fieldTypes.get(k) === "checkbox") {
|