@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
- card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key), input));
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.46.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
- card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key), input));
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") {