@cosmicdrift/kumiko-renderer-web 0.34.1 → 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.1",
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>",
@@ -197,20 +197,31 @@ describe("KumikoScreen", () => {
197
197
  const TaskCustomFields = ({
198
198
  entityName,
199
199
  entityId,
200
+ initialValues,
200
201
  }: {
201
202
  entityName: string;
202
203
  entityId: string | null;
204
+ initialValues?: Readonly<Record<string, unknown>>;
203
205
  }) => (
204
206
  <div data-testid="task-custom-fields">
205
- {entityName}:{entityId ?? "(create)"}
207
+ {entityName}:{entityId ?? "(create)"}:{String(initialValues?.["vendor"] ?? "(no-value)")}
206
208
  </div>
207
209
  );
208
210
  const dispatcher = makeDispatcher({
209
- // detail liefert die row MIT id aber der Update-Form filtert id aus
210
- // den Form-values; die Section darf trotzdem nicht in create-mode fallen.
211
+ // detail liefert die row MIT id + customFields-jsonb. Update-Form
212
+ // filtert id aus den Form-values (Section darf nicht create-mode
213
+ // sein) UND die Section muss den gespeicherten customFields-Bestand
214
+ // bekommen (sonst write-only → Read-Back leer).
211
215
  query: (async () => ({
212
216
  isSuccess: true,
213
- data: { id: "task-1", version: 7, title: "loaded", count: 0, done: false },
217
+ data: {
218
+ id: "task-1",
219
+ version: 7,
220
+ title: "loaded",
221
+ count: 0,
222
+ done: false,
223
+ customFields: { vendor: "Hetzner" },
224
+ },
214
225
  })) as unknown as Dispatcher["query"],
215
226
  });
216
227
 
@@ -224,8 +235,9 @@ describe("KumikoScreen", () => {
224
235
 
225
236
  await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
226
237
  const section = screen.getByTestId("task-custom-fields");
227
- // Der Anker: echte route-id, NICHT "(create)". Vor dem Fix: "task:(create)".
228
- expect(section.textContent).toBe("task:task-1");
238
+ // Anker: (1) echte route-id statt "(create)" [create-mode-Bug],
239
+ // (2) gespeicherter customFields-Wert statt "(no-value)" [write-only-Bug].
240
+ expect(section.textContent).toBe("task:task-1:Hetzner");
229
241
  });
230
242
 
231
243
  test("entityList onRowClick → Callback feuert mit Row-Viewmodel", async () => {
@@ -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).