@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.
|
|
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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "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("
|
|
61
|
-
const
|
|
60
|
+
test("FormatSpec-renderer: currency formatiert Wert + Symbol", () => {
|
|
61
|
+
const screenWithFmt: EntityListScreenDefinition = {
|
|
62
62
|
...baseScreen,
|
|
63
|
-
columns: ["title", { field: "color", renderer:
|
|
63
|
+
columns: ["title", { field: "color", renderer: { format: "currency", symbol: "€" } }],
|
|
64
64
|
};
|
|
65
65
|
render(
|
|
66
66
|
<RenderList
|
|
67
|
-
screen={
|
|
67
|
+
screen={screenWithFmt}
|
|
68
68
|
entity={taskEntity}
|
|
69
|
-
rows={[{ id: "r1", title: "Alpha", color: "
|
|
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("
|
|
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", () => {
|
|
@@ -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 {
|
|
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("");
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
1139
|
-
//
|
|
1140
|
-
//
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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).
|