@cosmicdrift/kumiko-renderer-web 0.34.2 → 0.35.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.35.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>",
@@ -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
 
@@ -4,7 +4,12 @@
4
4
  // money-input seine Pure-Logik exportiert). Kein DOM.
5
5
 
6
6
  import { describe, expect, test } from "bun:test";
7
- import { computeVisiblePages, defaultCellRender, isComponentRendererRef } from "../index";
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,52 @@ 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", () => {
104
+ expect(applyFormatSpec({ format: "custom-app-format" }, 42)).toBe("42");
105
+ expect(applyFormatSpec({ format: "custom-app-format" }, "hello")).toBe("hello");
106
+ });
107
+ });
108
+
58
109
  describe("defaultCellRender", () => {
59
110
  test("null/undefined/leerer String → leerer String", () => {
60
111
  expect(defaultCellRender(null, "text")).toBe("");
@@ -1056,8 +1056,40 @@ export function isComponentRendererRef(renderer: unknown): { readonly name: stri
1056
1056
  return { name: component };
1057
1057
  }
1058
1058
 
1059
+ // Applies a FormatSpec ({ format: "timestamp" } etc.) to a value.
1060
+ // Handles all built-in FieldFormatRegistry keys; unknown app-specific keys
1061
+ // fall back to String(value). Exported for unit tests.
1062
+ export function applyFormatSpec(
1063
+ spec: { format: string } & Record<string, unknown>,
1064
+ value: unknown,
1065
+ ): string {
1066
+ if (value === null || value === undefined || value === "") return "";
1067
+ switch (spec.format) {
1068
+ case "timestamp":
1069
+ case "date":
1070
+ return formatDateCell(value, spec.format);
1071
+ case "boolean": {
1072
+ if (value === true) return (spec["trueLabel"] as string | undefined) ?? "✓";
1073
+ if (value === false) return (spec["falseLabel"] as string | undefined) ?? "";
1074
+ return "";
1075
+ }
1076
+ case "currency": {
1077
+ const sym = (spec["symbol"] as string | undefined) ?? "";
1078
+ return sym.length > 0 ? `${value} ${sym}` : String(value);
1079
+ }
1080
+ case "priority": {
1081
+ const emptyLabel = (spec["emptyLabel"] as string | undefined) ?? "—";
1082
+ const prefix = (spec["prefix"] as string | undefined) ?? "";
1083
+ if (value === 0 || value === undefined || value === null) return emptyLabel;
1084
+ return `${prefix}${value}`;
1085
+ }
1086
+ default:
1087
+ return typeof value === "string" ? value : String(value);
1088
+ }
1089
+ }
1090
+
1059
1091
  // Type-spezifische Default-Cell-Renderer. Author kann pro Spalte einen
1060
- // expliziten renderer setzen (Function oder PlatformComponent); ohne
1092
+ // expliziten renderer setzen (FormatSpec oder PlatformComponent); ohne
1061
1093
  // expliziten renderer fällt DataTableCell hier durch.
1062
1094
  //
1063
1095
  // - boolean → ✓ / leer
@@ -1135,14 +1167,14 @@ type DataTableCellProps = {
1135
1167
  };
1136
1168
 
1137
1169
  // 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
1170
+ // useColumnRenderer-Hook aus dem Provider lesen kann. Die vier Pfade:
1171
+ // 1. FormatSpec (`{ format: "timestamp" }` etc.) → applyFormatSpec.
1172
+ // 2. RuntimeRenderer (Funktion)direkter Aufruf. Nur für render-list-
1173
+ // interne Reference-Lookup-Closures niemals aus dem serialisierten Schema.
1174
+ // 3. PlatformComponent (`{ react: { __component: "X" } }`) → schaut
1143
1175
  // "X" über useColumnRenderer auf und rendert `<X value row column/>`.
1144
1176
  // Nicht registriert → einmalige Warnung + Default-Fallback.
1145
- // 3. Sonst → defaultCellRender (Type-basierter String-Renderer).
1177
+ // 4. Sonst → defaultCellRender (Type-basierter String-Renderer).
1146
1178
  function DataTableCell({
1147
1179
  value,
1148
1180
  row,
@@ -1153,9 +1185,10 @@ function DataTableCell({
1153
1185
  }: DataTableCellProps): ReactNode {
1154
1186
  const componentRef = isComponentRendererRef(renderer);
1155
1187
  const ResolvedComponent = useColumnRenderer(componentRef?.name);
1188
+ if (typeof renderer === "object" && renderer !== null && "format" in renderer) {
1189
+ return applyFormatSpec(renderer as { format: string } & Record<string, unknown>, value);
1190
+ }
1156
1191
  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
1192
  const fn = renderer as (v: unknown, r?: Readonly<Record<string, unknown>>) => string;
1160
1193
  return fn(value, row);
1161
1194
  }
@@ -1163,7 +1196,7 @@ function DataTableCell({
1163
1196
  if (ResolvedComponent !== undefined) {
1164
1197
  return <ResolvedComponent value={value} row={row} column={{ field }} />;
1165
1198
  }
1166
- // Renderer im Schema referenziert, aber client-side keine Map-Eintrag —
1199
+ // Renderer im Schema referenziert, aber client-side kein Map-Eintrag —
1167
1200
  // typischer Fall: clientFeatures.columnRenderers vergessen oder
1168
1201
  // Tippfehler im __component-Key. Warnen statt crashen, damit ein
1169
1202
  // Schema-Boot trotzdem funktioniert (Default-Type-Renderer übernimmt).