@cosmicdrift/kumiko-renderer-web 0.34.2 → 0.36.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.34.2",
3
+ "version": "0.36.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.21.0",
20
- "@cosmicdrift/kumiko-headless": "0.21.0",
21
- "@cosmicdrift/kumiko-renderer": "0.21.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.35.0",
20
+ "@cosmicdrift/kumiko-headless": "0.35.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.35.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -57,20 +57,20 @@ describe("RenderList — column-renderer registry", () => {
57
57
  warnSpy.mockRestore();
58
58
  });
59
59
 
60
- test("function-renderer pfad bleibt unverändert (Bestand)", () => {
61
- const screenWithFn: EntityListScreenDefinition = {
60
+ test("FormatSpec-renderer: currency formatiert Wert + Symbol", () => {
61
+ const screenWithFmt: EntityListScreenDefinition = {
62
62
  ...baseScreen,
63
- columns: ["title", { field: "color", renderer: (v) => `[${String(v)}]` }],
63
+ columns: ["title", { field: "color", renderer: { format: "currency", symbol: "€" } }],
64
64
  };
65
65
  render(
66
66
  <RenderList
67
- screen={screenWithFn}
67
+ screen={screenWithFmt}
68
68
  entity={taskEntity}
69
- rows={[{ id: "r1", title: "Alpha", color: "#fff" }]}
69
+ rows={[{ id: "r1", title: "Alpha", color: "42" }]}
70
70
  featureName="tasks"
71
71
  />,
72
72
  );
73
- expect(screen.getByTestId("cell-r1-color").textContent).toBe("[#fff]");
73
+ expect(screen.getByTestId("cell-r1-color").textContent).toBe("42 €");
74
74
  });
75
75
 
76
76
  test("__component-renderer mit Provider → Component wird gemountet, value+row+column kommen an", () => {
@@ -23,7 +23,7 @@ const listScreen: EntityListScreenDefinition = {
23
23
  "title",
24
24
  "status",
25
25
  "isUrgent",
26
- { field: "priority", renderer: (v: unknown) => `P${v}` },
26
+ { field: "priority", renderer: { format: "priority" as const, prefix: "P" } },
27
27
  ],
28
28
  };
29
29
 
@@ -3,8 +3,13 @@
3
3
  // Reine Funktionen aus primitives/index.tsx (exportiert für Test, wie
4
4
  // money-input seine Pure-Logik exportiert). Kein DOM.
5
5
 
6
- import { describe, expect, test } from "bun:test";
7
- import { computeVisiblePages, defaultCellRender, isComponentRendererRef } from "../index";
6
+ import { describe, expect, spyOn, test } from "bun:test";
7
+ import {
8
+ applyFormatSpec,
9
+ computeVisiblePages,
10
+ defaultCellRender,
11
+ isComponentRendererRef,
12
+ } from "../index";
8
13
 
9
14
  describe("computeVisiblePages", () => {
10
15
  test("<= 7 Seiten: alle Seiten, keine Ellipsis", () => {
@@ -55,6 +60,56 @@ describe("isComponentRendererRef", () => {
55
60
  });
56
61
  });
57
62
 
63
+ describe("applyFormatSpec", () => {
64
+ test("null/undefined/leer → leerer String (alle Formate)", () => {
65
+ expect(applyFormatSpec({ format: "boolean" }, null)).toBe("");
66
+ expect(applyFormatSpec({ format: "currency", symbol: "€" }, undefined)).toBe("");
67
+ expect(applyFormatSpec({ format: "priority" }, "")).toBe("");
68
+ });
69
+
70
+ test("boolean: true → ✓, false → leer (defaults)", () => {
71
+ expect(applyFormatSpec({ format: "boolean" }, true)).toBe("✓");
72
+ expect(applyFormatSpec({ format: "boolean" }, false)).toBe("");
73
+ });
74
+
75
+ test("boolean: benutzerdefinierte Labels", () => {
76
+ expect(
77
+ applyFormatSpec({ format: "boolean", trueLabel: "Public", falseLabel: "Hidden" }, true),
78
+ ).toBe("Public");
79
+ expect(
80
+ applyFormatSpec({ format: "boolean", trueLabel: "Public", falseLabel: "Hidden" }, false),
81
+ ).toBe("Hidden");
82
+ });
83
+
84
+ test("currency: value + Leerzeichen + symbol", () => {
85
+ expect(applyFormatSpec({ format: "currency", symbol: "€" }, 42)).toBe("42 €");
86
+ expect(applyFormatSpec({ format: "currency", symbol: "$" }, "99.90")).toBe("99.90 $");
87
+ });
88
+
89
+ test("currency: kein symbol → reiner Wert", () => {
90
+ expect(applyFormatSpec({ format: "currency" }, 42)).toBe("42");
91
+ });
92
+
93
+ test("priority: 0 → Standard-Dash, Zahl → prefix+value", () => {
94
+ expect(applyFormatSpec({ format: "priority", prefix: "P" }, 0)).toBe("—");
95
+ expect(applyFormatSpec({ format: "priority", prefix: "P" }, 3)).toBe("P3");
96
+ });
97
+
98
+ test("priority: benutzerdefinierter emptyLabel + kein prefix", () => {
99
+ expect(applyFormatSpec({ format: "priority", emptyLabel: "none" }, 0)).toBe("none");
100
+ expect(applyFormatSpec({ format: "priority" }, 5)).toBe("5");
101
+ });
102
+
103
+ test("unbekanntes format → String(value) fallback + dev-warning", () => {
104
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
105
+ expect(applyFormatSpec({ format: "custom-app-format" }, 42)).toBe("42");
106
+ expect(applyFormatSpec({ format: "custom-app-format" }, "hello")).toBe("hello");
107
+ expect(warn).toHaveBeenCalledTimes(2);
108
+ expect(warn.mock.calls[0]?.[0]).toContain("custom-app-format");
109
+ warn.mockRestore();
110
+ });
111
+ });
112
+
58
113
  describe("defaultCellRender", () => {
59
114
  test("null/undefined/leerer String → leerer String", () => {
60
115
  expect(defaultCellRender(null, "text")).toBe("");
@@ -9,6 +9,7 @@
9
9
  // Dropdown etc. kommen später).
10
10
 
11
11
  import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
12
+ import { applyFormatSpec } from "@cosmicdrift/kumiko-headless";
12
13
  import type {
13
14
  DataTableRowAction,
14
15
  DataTableSort,
@@ -1056,8 +1057,11 @@ export function isComponentRendererRef(renderer: unknown): { readonly name: stri
1056
1057
  return { name: component };
1057
1058
  }
1058
1059
 
1060
+ // applyFormatSpec re-exported from headless (platform-agnostic).
1061
+ export { applyFormatSpec };
1062
+
1059
1063
  // Type-spezifische Default-Cell-Renderer. Author kann pro Spalte einen
1060
- // expliziten renderer setzen (Function oder PlatformComponent); ohne
1064
+ // expliziten renderer setzen (FormatSpec oder PlatformComponent); ohne
1061
1065
  // expliziten renderer fällt DataTableCell hier durch.
1062
1066
  //
1063
1067
  // - boolean → ✓ / leer
@@ -1071,7 +1075,7 @@ export function defaultCellRender(
1071
1075
  ): string {
1072
1076
  if (value === null || value === undefined || value === "") return "";
1073
1077
  if (type === "boolean") return value === true ? "✓" : "";
1074
- if (type === "timestamp" || type === "date") return formatDateCell(value, type);
1078
+ if (type === "timestamp" || type === "date") return applyFormatSpec({ format: type }, value);
1075
1079
  if (type === "select") {
1076
1080
  const raw = String(value);
1077
1081
  // Translated Label aus dem ViewModel-Builder (Convention-Key
@@ -1085,29 +1089,6 @@ export function defaultCellRender(
1085
1089
  return typeof value === "string" ? value : String(value);
1086
1090
  }
1087
1091
 
1088
- function formatDateCell(value: unknown, type: string): string {
1089
- // Server liefert ISO-String oder Temporal.Instant.toJSON() (gleicher
1090
- // ISO-shape). Für `type:"date"` zeigen wir nur das Datum, für
1091
- // `type:"timestamp"` Datum + Uhrzeit. Locale-Default = Browser.
1092
- try {
1093
- const raw = typeof value === "string" ? value : String(value);
1094
- const date = new Date(raw);
1095
- if (Number.isNaN(date.getTime())) return raw;
1096
- if (type === "date") {
1097
- return date.toLocaleDateString();
1098
- }
1099
- return date.toLocaleString(undefined, {
1100
- year: "numeric",
1101
- month: "short",
1102
- day: "numeric",
1103
- hour: "2-digit",
1104
- minute: "2-digit",
1105
- });
1106
- } catch {
1107
- return String(value);
1108
- }
1109
- }
1110
-
1111
1092
  function humanizeSlug(slug: string): string {
1112
1093
  // "degraded-performance" → "Degraded performance"
1113
1094
  if (slug.length === 0) return slug;
@@ -1135,14 +1116,14 @@ type DataTableCellProps = {
1135
1116
  };
1136
1117
 
1137
1118
  // Cell-Renderer als Component (statt reiner Funktion) damit der
1138
- // useColumnRenderer-Hook aus dem Provider lesen kann. Die drei Pfade
1139
- // sind:
1140
- // 1. Funktion → ruft fn(value) auf, returnt String. Bestand-Pfad,
1141
- // bleibt unverändert für alle bestehenden Schemas.
1142
- // 2. PlatformComponent (`{ react: { __component: "X" } }`) → schaut
1119
+ // useColumnRenderer-Hook aus dem Provider lesen kann. Die vier Pfade:
1120
+ // 1. FormatSpec (`{ format: "timestamp" }` etc.) → applyFormatSpec.
1121
+ // 2. RuntimeRenderer (Funktion)direkter Aufruf. Nur für render-list-
1122
+ // interne Reference-Lookup-Closures niemals aus dem serialisierten Schema.
1123
+ // 3. PlatformComponent (`{ react: { __component: "X" } }`) → schaut
1143
1124
  // "X" über useColumnRenderer auf und rendert `<X value row column/>`.
1144
1125
  // Nicht registriert → einmalige Warnung + Default-Fallback.
1145
- // 3. Sonst → defaultCellRender (Type-basierter String-Renderer).
1126
+ // 4. Sonst → defaultCellRender (Type-basierter String-Renderer).
1146
1127
  function DataTableCell({
1147
1128
  value,
1148
1129
  row,
@@ -1153,9 +1134,10 @@ function DataTableCell({
1153
1134
  }: DataTableCellProps): ReactNode {
1154
1135
  const componentRef = isComponentRendererRef(renderer);
1155
1136
  const ResolvedComponent = useColumnRenderer(componentRef?.name);
1137
+ if (typeof renderer === "object" && renderer !== null && "format" in renderer) {
1138
+ return applyFormatSpec(renderer as { format: string } & Record<string, unknown>, value);
1139
+ }
1156
1140
  if (typeof renderer === "function") {
1157
- // 2. Argument: ganze Row als read-only — function-Renderer können
1158
- // context-aware sein (Tier 2.7e-Eagerload nutzt das für _refs).
1159
1141
  const fn = renderer as (v: unknown, r?: Readonly<Record<string, unknown>>) => string;
1160
1142
  return fn(value, row);
1161
1143
  }
@@ -1163,7 +1145,7 @@ function DataTableCell({
1163
1145
  if (ResolvedComponent !== undefined) {
1164
1146
  return <ResolvedComponent value={value} row={row} column={{ field }} />;
1165
1147
  }
1166
- // Renderer im Schema referenziert, aber client-side keine Map-Eintrag —
1148
+ // Renderer im Schema referenziert, aber client-side kein Map-Eintrag —
1167
1149
  // typischer Fall: clientFeatures.columnRenderers vergessen oder
1168
1150
  // Tippfehler im __component-Key. Warnen statt crashen, damit ein
1169
1151
  // Schema-Boot trotzdem funktioniert (Default-Type-Renderer übernimmt).