@nanobpm/urban 0.58.0 → 0.59.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.
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/package.json +1 -1
- package/src/runtime/browser/runtime.browser.js +39 -3
- package/src/runtime/browser/runtime.browser.test.ts +43 -1
- package/src/runtime/core/modules/pages.test.ts +5 -3
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.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",
|
|
@@ -321,6 +321,36 @@ function interpTemplate(tpl, row) {
|
|
|
321
321
|
})
|
|
322
322
|
: tpl;
|
|
323
323
|
}
|
|
324
|
+
|
|
325
|
+
// Format a single-field cell value per a column's opt-in `col.format` (issue
|
|
326
|
+
// #327). This runs in the browser, so it is the ONLY place that can render a
|
|
327
|
+
// stored UTC ISO timestamp in the VIEWER'S local timezone — the server never
|
|
328
|
+
// knows the viewer's zone, so a server-formatted string would always be wrong
|
|
329
|
+
// for someone. Currently the sole format is "datetime": a parseable ISO/date
|
|
330
|
+
// value → "11:42am Aug 19" (h:mm + lowercase am/pm, then short-month day), both
|
|
331
|
+
// parts in local time. Defensive by construction: an empty value, an unparseable
|
|
332
|
+
// date, or any unknown format returns the raw text verbatim — a mis-set format
|
|
333
|
+
// never blanks a cell or throws, matching how an unrecognised link.kind degrades
|
|
334
|
+
// to plain text. Display-only: the untransformed rawText still drives the badge
|
|
335
|
+
// gate/tooltip and any sort/groupBy (those key off the real field).
|
|
336
|
+
/** @param {any} format
|
|
337
|
+
* @param {string} raw
|
|
338
|
+
* @returns {string} */
|
|
339
|
+
function fmtCellValue(format, raw) {
|
|
340
|
+
if (format !== "datetime" || raw === "") return raw;
|
|
341
|
+
const d = new Date(raw);
|
|
342
|
+
if (Number.isNaN(d.getTime())) return raw;
|
|
343
|
+
// Present in a stable en-US format ("11:42am Aug 19") regardless of the
|
|
344
|
+
// viewer's locale ordering (e.g. en-GB would emit "19 Aug"), while the ABSENCE
|
|
345
|
+
// of a `timeZone` option keeps both parts in the viewer's LOCAL zone. hour12
|
|
346
|
+
// forces am/pm; the replace lowercases "AM"/"a.m."/… to "am"/"pm" and drops the
|
|
347
|
+
// separating space so the output is exactly "11:42am".
|
|
348
|
+
const time = d
|
|
349
|
+
.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true })
|
|
350
|
+
.replace(/\s*([AaPp])\.?\s*[Mm]\.?$/, (_m, p) => p.toLowerCase() + "m");
|
|
351
|
+
const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
352
|
+
return time + " " + date;
|
|
353
|
+
}
|
|
324
354
|
/** @param {any} node */
|
|
325
355
|
function renderText(node) {
|
|
326
356
|
const v = node.props.variant;
|
|
@@ -641,9 +671,15 @@ function gridCell(col, row, role) {
|
|
|
641
671
|
// field-derived defaults. Kept separate from the (possibly templated) display
|
|
642
672
|
// text so a template never re-lights or blanks a badge.
|
|
643
673
|
const rawText = row[col.field] == null ? "" : String(row[col.field]);
|
|
674
|
+
// Per-column display format (issue #327): an opt-in `col.format` transforms the
|
|
675
|
+
// single-field value for display only (e.g. "datetime" → the viewer's local
|
|
676
|
+
// time). rawText above stays untransformed so the badge gate/tooltip and any
|
|
677
|
+
// sort/groupBy still key off the real field. A col.template composes its own
|
|
678
|
+
// {{field}} tokens and therefore wins over the formatted field text below.
|
|
679
|
+
const fieldText = col.format ? fmtCellValue(col.format, rawText) : rawText;
|
|
644
680
|
// Visible text: a col.template (interpolated from the row) wins over the
|
|
645
|
-
// raw field value, else the field value is shown verbatim.
|
|
646
|
-
const text = typeof col.template === "string" ? interpTemplate(col.template, row) :
|
|
681
|
+
// formatted/raw field value, else the field value is shown verbatim.
|
|
682
|
+
const text = typeof col.template === "string" ? interpTemplate(col.template, row) : fieldText;
|
|
647
683
|
// 1. badge — a compact status indicator. When the row's field value is
|
|
648
684
|
// non-empty (truthy after trimming) render a small circular badge (e.g. a
|
|
649
685
|
// red "1" dot flagging an incident) whose tooltip is the full field text;
|
|
@@ -2176,4 +2212,4 @@ function boot() {
|
|
|
2176
2212
|
// renderer functions / RENDERERS registry can be imported directly.
|
|
2177
2213
|
if (typeof document !== "undefined" && document.getElementById("page")) boot();
|
|
2178
2214
|
|
|
2179
|
-
export { RENDERERS, renderText, renderButton, renderProse, renderNav };
|
|
2215
|
+
export { RENDERERS, renderText, renderButton, renderProse, renderNav, fmtCellValue, gridCell };
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
9
|
import { PAGE_NODE_TYPES } from "@nanobpm/nano-app-schema";
|
|
10
|
-
import { RENDERERS, renderText } from "./runtime.browser.js";
|
|
10
|
+
import { RENDERERS, renderText, fmtCellValue, gridCell } from "./runtime.browser.js";
|
|
11
11
|
|
|
12
12
|
// ── Minimal fake DOM ────────────────────────────────────────────────────────
|
|
13
13
|
// Just enough of the Element/Document surface that el()/renderText touch:
|
|
@@ -116,3 +116,45 @@ test("#291: renderText degrades to empty text (never throws) on a missing text p
|
|
|
116
116
|
assert.equal(node.tagName, "P");
|
|
117
117
|
assert.equal(node.textContent, "");
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
test('#327: fmtCellValue "datetime" renders an ISO timestamp in the viewer\'s local time as "h:mmam Mon D"', () => {
|
|
121
|
+
const iso = "2026-08-18T23:42:47.788Z";
|
|
122
|
+
const out = fmtCellValue("datetime", iso);
|
|
123
|
+
// Shape: h:mm + lowercase am/pm (no separating space), then short-month day.
|
|
124
|
+
assert.match(out, /^\d{1,2}:\d{2}(am|pm) [A-Z][a-z]{2} \d{1,2}$/, `unexpected shape: ${out}`);
|
|
125
|
+
// Local, not UTC: the hour/minute/am-pm/day track the runner's local zone, so
|
|
126
|
+
// the assertion is timezone-independent (derives the expectation from the same
|
|
127
|
+
// Date in local time) yet still proves the value isn't rendered as raw UTC.
|
|
128
|
+
const d = new Date(iso);
|
|
129
|
+
let h = d.getHours() % 12;
|
|
130
|
+
if (h === 0) h = 12;
|
|
131
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
132
|
+
const ampm = d.getHours() < 12 ? "am" : "pm";
|
|
133
|
+
assert.ok(out.startsWith(`${h}:${mm}${ampm} `), `local time prefix wrong: ${out}`);
|
|
134
|
+
assert.ok(out.endsWith(` ${d.getDate()}`), `local day suffix wrong: ${out}`);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("#327: fmtCellValue passes through empty, unparseable, and unknown-format values verbatim (never blank/throw)", () => {
|
|
138
|
+
assert.equal(fmtCellValue("datetime", ""), "");
|
|
139
|
+
assert.equal(fmtCellValue("datetime", "not-a-date"), "not-a-date");
|
|
140
|
+
// An unknown format is a no-op — a mis-set column shows its raw value, not blank.
|
|
141
|
+
assert.equal(fmtCellValue("nope", "2026-08-18T23:42:47.788Z"), "2026-08-18T23:42:47.788Z");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("#327: gridCell applies col.format to the cell text, leaving an unformatted column verbatim", (t) => {
|
|
145
|
+
t.after(installFakeDom());
|
|
146
|
+
const iso = "2026-08-18T23:42:47.788Z";
|
|
147
|
+
const formatted = gridCell(
|
|
148
|
+
{ field: "updated_at", header: "Updated", format: "datetime" },
|
|
149
|
+
{ updated_at: iso },
|
|
150
|
+
"secondary",
|
|
151
|
+
);
|
|
152
|
+
assert.match(
|
|
153
|
+
formatted.textContent,
|
|
154
|
+
/^\d{1,2}:\d{2}(am|pm) [A-Z][a-z]{2} \d{1,2}$/,
|
|
155
|
+
`cell not local-formatted: ${formatted.textContent}`,
|
|
156
|
+
);
|
|
157
|
+
// No `format` → the field value renders exactly as stored (backward compatible).
|
|
158
|
+
const plain = gridCell({ field: "updated_at", header: "Updated" }, { updated_at: iso }, "secondary");
|
|
159
|
+
assert.equal(plain.textContent, iso);
|
|
160
|
+
});
|
|
@@ -378,10 +378,12 @@ test("renderer interpolates a column's per-cell template into DOM text (#214)",
|
|
|
378
378
|
// resolving to prototype cruft — guard the own-property gate explicitly.
|
|
379
379
|
assert.match(js, /Object\.prototype\.hasOwnProperty\.call\(row, key\) \? row\[key\] : null/);
|
|
380
380
|
assert.match(js, /return v == null \? "" : String\(v\);/);
|
|
381
|
-
// gridCell keeps the raw single-field value separate (drives the badge gate)
|
|
382
|
-
//
|
|
381
|
+
// gridCell keeps the raw single-field value separate (drives the badge gate),
|
|
382
|
+
// applies an opt-in col.format to the field text (#327), and lets a string
|
|
383
|
+
// template win for the rendered text over that formatted/raw field value.
|
|
383
384
|
assert.match(js, /const rawText = row\[col\.field\] == null \? "" : String\(row\[col\.field\]\);/);
|
|
384
|
-
assert.match(js, /const
|
|
385
|
+
assert.match(js, /const fieldText = col\.format \? fmtCellValue\(col\.format, rawText\) : rawText;/);
|
|
386
|
+
assert.match(js, /const text = typeof col\.template === "string" \? interpTemplate\(col\.template, row\) : fieldText;/);
|
|
385
387
|
});
|
|
386
388
|
|
|
387
389
|
test("renderer renders a column's subtitle as a muted second line, never an href (#257)", async () => {
|