@cosmicdrift/kumiko-headless 0.37.0 → 0.39.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-headless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
}
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-framework": "0.38.0",
|
|
32
32
|
"zod": "^4.4.3"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
@@ -35,8 +35,8 @@ describe("escapeHtmlAttr", () => {
|
|
|
35
35
|
expect(escapeHtmlAttr(`& " < >`)).toBe("& " < >");
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
test("
|
|
39
|
-
expect(escapeHtmlAttr("it's")).toBe("it
|
|
38
|
+
test("escapes ' so single-quoted attributes cannot break out", () => {
|
|
39
|
+
expect(escapeHtmlAttr("it's")).toBe("it's");
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
test("plain attribute value passes through", () => {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { applyFormatSpec } from "../index";
|
|
3
|
+
|
|
4
|
+
describe("applyFormatSpec — priority", () => {
|
|
5
|
+
test("rendert emptyLabel für undefined/null/leer/0 (nicht den globalen ''-Collapse)", () => {
|
|
6
|
+
expect(applyFormatSpec({ format: "priority" }, undefined)).toBe("—");
|
|
7
|
+
expect(applyFormatSpec({ format: "priority" }, null)).toBe("—");
|
|
8
|
+
expect(applyFormatSpec({ format: "priority" }, "")).toBe("—");
|
|
9
|
+
expect(applyFormatSpec({ format: "priority" }, 0)).toBe("—");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("custom emptyLabel + prefix", () => {
|
|
13
|
+
expect(applyFormatSpec({ format: "priority", emptyLabel: "none" }, null)).toBe("none");
|
|
14
|
+
expect(applyFormatSpec({ format: "priority", prefix: "P" }, 2)).toBe("P2");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("applyFormatSpec — leere Werte anderer Formate", () => {
|
|
19
|
+
test("collapsen zu ''", () => {
|
|
20
|
+
expect(applyFormatSpec({ format: "boolean" }, undefined)).toBe("");
|
|
21
|
+
expect(applyFormatSpec({ format: "currency", symbol: "€" }, null)).toBe("");
|
|
22
|
+
expect(applyFormatSpec({ format: "timestamp" }, "")).toBe("");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("applyFormatSpec — boolean/currency", () => {
|
|
27
|
+
test("boolean mit Default- und Custom-Labels", () => {
|
|
28
|
+
expect(applyFormatSpec({ format: "boolean" }, true)).toBe("✓");
|
|
29
|
+
expect(applyFormatSpec({ format: "boolean" }, false)).toBe("");
|
|
30
|
+
expect(applyFormatSpec({ format: "boolean", trueLabel: "ja", falseLabel: "nein" }, false)).toBe(
|
|
31
|
+
"nein",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("currency hängt Symbol an", () => {
|
|
36
|
+
expect(applyFormatSpec({ format: "currency", symbol: "€" }, 12)).toBe("12 €");
|
|
37
|
+
expect(applyFormatSpec({ format: "currency" }, 12)).toBe("12");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("applyFormatSpec — timestamp/date (formatDateCell-Pfad)", () => {
|
|
42
|
+
// Mittag UTC: das Datum kippt in keiner Zeitzone UTC-11..UTC+11 —
|
|
43
|
+
// deterministisch auf CI (UTC) und lokal (CET).
|
|
44
|
+
const instant = "2026-01-15T12:00:00Z";
|
|
45
|
+
|
|
46
|
+
test("timestamp mit locale+dateStyle+timeStyle rendert lokalisiert", () => {
|
|
47
|
+
const out = applyFormatSpec(
|
|
48
|
+
{ format: "timestamp", locale: "en-US", dateStyle: "long", timeStyle: "short" },
|
|
49
|
+
instant,
|
|
50
|
+
);
|
|
51
|
+
expect(out).toContain("January");
|
|
52
|
+
expect(out).toContain("2026");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("date mit locale de-DE rendert deutschen Monatsnamen", () => {
|
|
56
|
+
const out = applyFormatSpec({ format: "date", locale: "de-DE", dateStyle: "long" }, instant);
|
|
57
|
+
expect(out).toContain("Januar");
|
|
58
|
+
expect(out).toContain("2026");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("timestamp ohne Optionen nutzt das kompakte Default-Format", () => {
|
|
62
|
+
const out = applyFormatSpec({ format: "timestamp", locale: "en-US" }, instant);
|
|
63
|
+
expect(out).toContain("2026");
|
|
64
|
+
expect(out).not.toBe(instant);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("unparsebarer Wert fällt auf den Rohstring zurück", () => {
|
|
68
|
+
expect(applyFormatSpec({ format: "timestamp" }, "kein-datum")).toBe("kein-datum");
|
|
69
|
+
expect(applyFormatSpec({ format: "date" }, "kein-datum")).toBe("kein-datum");
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/format/escape.ts
CHANGED
|
@@ -6,12 +6,10 @@ export function escapeHtml(s: string): string {
|
|
|
6
6
|
.replace(/"/g, """);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
// Superset of escapeHtml for attribute contexts: additionally escapes ' so
|
|
10
|
+
// single-quoted attributes cannot be broken out of.
|
|
9
11
|
export function escapeHtmlAttr(s: string): string {
|
|
10
|
-
return s
|
|
11
|
-
.replace(/&/g, "&")
|
|
12
|
-
.replace(/"/g, """)
|
|
13
|
-
.replace(/</g, "<")
|
|
14
|
-
.replace(/>/g, ">");
|
|
12
|
+
return escapeHtml(s).replace(/'/g, "'");
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
export function escapeXml(s: string): string {
|
package/src/format/index.ts
CHANGED
|
@@ -39,7 +39,10 @@ export function applyFormatSpec(
|
|
|
39
39
|
spec: { format: string } & Record<string, unknown>,
|
|
40
40
|
value: unknown,
|
|
41
41
|
): string {
|
|
42
|
-
|
|
42
|
+
const isEmpty = value === null || value === undefined || value === "";
|
|
43
|
+
// priority renders its emptyLabel for empty values — every other format
|
|
44
|
+
// collapses empty to "".
|
|
45
|
+
if (isEmpty && spec.format !== "priority") return "";
|
|
43
46
|
switch (spec.format) {
|
|
44
47
|
case "timestamp":
|
|
45
48
|
case "date":
|
|
@@ -63,14 +66,14 @@ export function applyFormatSpec(
|
|
|
63
66
|
case "priority": {
|
|
64
67
|
const emptyLabel = (spec["emptyLabel"] as string | undefined) ?? "—";
|
|
65
68
|
const prefix = (spec["prefix"] as string | undefined) ?? "";
|
|
66
|
-
if (
|
|
69
|
+
if (isEmpty || value === 0) return emptyLabel;
|
|
67
70
|
return `${prefix}${value}`;
|
|
68
71
|
}
|
|
69
72
|
default:
|
|
70
73
|
if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
|
71
74
|
// biome-ignore lint/suspicious/noConsole: dev-only warning
|
|
72
75
|
console.warn(
|
|
73
|
-
`[kumiko] applyFormatSpec:
|
|
76
|
+
`[kumiko] applyFormatSpec: unknown format key "${spec.format}" — registered via FieldFormatRegistry module augmentation?`,
|
|
74
77
|
);
|
|
75
78
|
}
|
|
76
79
|
return typeof value === "string" ? value : String(value);
|
package/src/view-model/edit.ts
CHANGED
|
@@ -77,6 +77,13 @@ export function computeEditViewModel<
|
|
|
77
77
|
fieldDef.type === "text"
|
|
78
78
|
? (fieldDef as unknown as { multiline?: boolean | { rows?: number } }).multiline
|
|
79
79
|
: undefined;
|
|
80
|
+
// Wall-Clock-Hint bei `type: "timestamp"` mit locatedBy — der
|
|
81
|
+
// Renderer emittiert dann lokale Zeit ohne `Z` statt UTC-Instant.
|
|
82
|
+
const wallClock =
|
|
83
|
+
fieldDef.type === "timestamp" &&
|
|
84
|
+
(fieldDef as unknown as { locatedBy?: string }).locatedBy !== undefined
|
|
85
|
+
? true
|
|
86
|
+
: undefined;
|
|
80
87
|
// Tier 2.7e-3: Reference-Field — refEntity + refLabelField in
|
|
81
88
|
// das ViewModel reichen damit der Renderer die Lookup-Query
|
|
82
89
|
// bauen kann ohne noch an EntityDefinition zu greifen.
|
|
@@ -112,6 +119,7 @@ export function computeEditViewModel<
|
|
|
112
119
|
...(options !== undefined && { options }),
|
|
113
120
|
...(optionLabels !== undefined && { optionLabels }),
|
|
114
121
|
...(multiline !== undefined && { multiline }),
|
|
122
|
+
...(wallClock !== undefined && { wallClock }),
|
|
115
123
|
...(refEntity !== undefined && { refEntity }),
|
|
116
124
|
...(refFeature !== undefined && { refFeature }),
|
|
117
125
|
...(refLabelField !== undefined && { refLabelField }),
|
package/src/view-model/types.ts
CHANGED
|
@@ -107,6 +107,10 @@ export type EditFieldViewModel = {
|
|
|
107
107
|
* ist — dann rendert der Renderer textarea statt single-line input.
|
|
108
108
|
* `true` = Default-Zeilen, `{ rows }` = explizite Höhe. */
|
|
109
109
|
readonly multiline?: boolean | { readonly rows?: number };
|
|
110
|
+
/** Nur bei `type: "timestamp"` gesetzt wenn TimestampFieldDef.locatedBy
|
|
111
|
+
* existiert — Wall-Clock-Zeit ohne Offset. Der Renderer emittiert
|
|
112
|
+
* dann lokale Zeit ohne `Z` statt eines UTC-Instants. */
|
|
113
|
+
readonly wallClock?: boolean;
|
|
110
114
|
/** Nur bei `type: "reference"` gesetzt — Tier 2.7e-3.
|
|
111
115
|
* Die referenced Entity (kurz, ohne feature-prefix). Der Renderer
|
|
112
116
|
* baut die Query-QN als `<refFeature>:query:<refEntity>:list`. */
|