@cosmicdrift/kumiko-renderer-web 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 +4 -4
- package/src/__tests__/config-edit.test.tsx +59 -0
- package/src/__tests__/field-validation-i18n.test.tsx +69 -0
- package/src/__tests__/form-action-bar.test.tsx +94 -0
- package/src/__tests__/timestamp-input.test.tsx +95 -0
- package/src/components/config-cascade.tsx +1 -1
- package/src/components/config-source-badge.tsx +1 -0
- package/src/layout/__tests__/workspace-switcher.test.tsx +26 -1
- package/src/layout/avatar.tsx +1 -1
- package/src/primitives/__tests__/data-table-logic.test.ts +33 -7
- package/src/primitives/index.tsx +27 -17
- package/src/primitives/timestamp-input.tsx +88 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.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.38.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.38.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.38.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",
|
|
@@ -166,4 +166,63 @@ describe("KumikoScreen / configEdit", () => {
|
|
|
166
166
|
// dieser State stale (regression-guard).
|
|
167
167
|
await waitFor(() => expect(submit.disabled).toBe(true));
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
// Regression Bug-Bash-2 (2026-06-08): RenderEdit reichte denselben
|
|
171
|
+
// Appendix-Callback als labelAppendix UND fieldAppendix durch —
|
|
172
|
+
// Badge + Vorgabe-Disclosure erschienen doppelt (vor und nach dem
|
|
173
|
+
// Input) auf jedem Settings-Screen mit Default-Werten.
|
|
174
|
+
test("Source-Badge und Vorgabe-Disclosure erscheinen genau einmal pro Feld", async () => {
|
|
175
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
176
|
+
query: (async (qn: string) => {
|
|
177
|
+
if (qn === "config:query:cascade") {
|
|
178
|
+
return {
|
|
179
|
+
isSuccess: true,
|
|
180
|
+
data: {
|
|
181
|
+
"demo:config:site-name": {
|
|
182
|
+
value: "Acme",
|
|
183
|
+
source: "tenant-row",
|
|
184
|
+
levels: [
|
|
185
|
+
{
|
|
186
|
+
source: "tenant-row",
|
|
187
|
+
label: "tenant-row",
|
|
188
|
+
value: "Acme",
|
|
189
|
+
isActive: true,
|
|
190
|
+
hasValue: true,
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
source: "default",
|
|
194
|
+
label: "default",
|
|
195
|
+
value: "fallback",
|
|
196
|
+
isActive: false,
|
|
197
|
+
hasValue: true,
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
isSuccess: true,
|
|
206
|
+
data: {
|
|
207
|
+
"demo:config:site-name": { value: "Acme", scope: "tenant", source: "tenant-row" },
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}) as unknown as Dispatcher["query"],
|
|
211
|
+
});
|
|
212
|
+
render(
|
|
213
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
214
|
+
<KumikoScreen schema={schema} qn="demo:screen:settings" />
|
|
215
|
+
</DispatcherProvider>,
|
|
216
|
+
);
|
|
217
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
218
|
+
const field = screen.getByTestId("field-siteName");
|
|
219
|
+
await waitFor(() =>
|
|
220
|
+
expect(field.querySelectorAll('[data-testid="config-cascade"]')).toHaveLength(1),
|
|
221
|
+
);
|
|
222
|
+
expect(field.querySelectorAll('[data-testid="config-source-badge"]')).toHaveLength(1);
|
|
223
|
+
// Badge inline am Label, Disclosure unter dem Input — nicht umgekehrt.
|
|
224
|
+
const label = field.querySelector("label");
|
|
225
|
+
expect(label?.querySelector('[data-testid="config-source-badge"]')).toBeTruthy();
|
|
226
|
+
expect(label?.querySelector('[data-testid="config-cascade"]')).toBeNull();
|
|
227
|
+
});
|
|
169
228
|
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Regression Bug-Bash-2 (2026-06-08): Validierungsfehler zeigten ROHE
|
|
2
|
+
// Keys ("errors.validation.invalid_format") — der Namespace den Server
|
|
3
|
+
// (ValidationError) und Client (zod-bridge) erzeugen war in keinem
|
|
4
|
+
// Default-Bundle definiert, und DefaultField reichte issue.params nicht
|
|
5
|
+
// an t() durch (Platzhalter wie {minimum} blieben uninterpoliert).
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import type { FieldIssue } from "@cosmicdrift/kumiko-headless";
|
|
8
|
+
import { defaultPrimitives } from "../primitives";
|
|
9
|
+
import { render } from "./test-utils";
|
|
10
|
+
|
|
11
|
+
function renderFieldWithIssues(issues: readonly FieldIssue[]) {
|
|
12
|
+
const { Field } = defaultPrimitives;
|
|
13
|
+
return render(
|
|
14
|
+
<Field id="f" label="Feld" issues={issues} testId="field-under-test">
|
|
15
|
+
<input id="f" />
|
|
16
|
+
</Field>,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("DefaultField / errors.validation.* i18n", () => {
|
|
21
|
+
test("invalid_format zeigt übersetzten Text statt rohem Key", () => {
|
|
22
|
+
const view = renderFieldWithIssues([
|
|
23
|
+
{ path: "startsAt", code: "invalid_format", i18nKey: "errors.validation.invalid_format" },
|
|
24
|
+
]);
|
|
25
|
+
expect(view.container.textContent).not.toContain("errors.validation");
|
|
26
|
+
expect(view.container.textContent).toContain("Invalid format.");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("too_small interpoliert {minimum} aus issue.params", () => {
|
|
30
|
+
const view = renderFieldWithIssues([
|
|
31
|
+
{
|
|
32
|
+
path: "name",
|
|
33
|
+
code: "too_small",
|
|
34
|
+
i18nKey: "errors.validation.too_small",
|
|
35
|
+
params: { minimum: 3 },
|
|
36
|
+
},
|
|
37
|
+
]);
|
|
38
|
+
expect(view.container.textContent).not.toContain("errors.validation");
|
|
39
|
+
expect(view.container.textContent).not.toContain("{minimum}");
|
|
40
|
+
expect(view.container.textContent).toContain("minimum: 3");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("alle Server-/Zod-Codes sind in beiden Default-Bundles übersetzt", async () => {
|
|
44
|
+
const { kumikoDefaultTranslations } = await import("@cosmicdrift/kumiko-renderer");
|
|
45
|
+
const codes = [
|
|
46
|
+
"invalid_type",
|
|
47
|
+
"too_small",
|
|
48
|
+
"too_big",
|
|
49
|
+
"invalid_format",
|
|
50
|
+
"not_multiple_of",
|
|
51
|
+
"unrecognized_keys",
|
|
52
|
+
"invalid_union",
|
|
53
|
+
"invalid_key",
|
|
54
|
+
"invalid_element",
|
|
55
|
+
"invalid_value",
|
|
56
|
+
"custom",
|
|
57
|
+
"unexpected_field",
|
|
58
|
+
"out_of_bounds",
|
|
59
|
+
"invalid_option",
|
|
60
|
+
"failed",
|
|
61
|
+
];
|
|
62
|
+
for (const locale of ["de", "en"] as const) {
|
|
63
|
+
const bundle = kumikoDefaultTranslations[locale];
|
|
64
|
+
for (const code of codes) {
|
|
65
|
+
expect(bundle?.[`errors.validation.${code}`]).toBeString();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// F2.4 (Bug-Bash-2): Die Sticky-Action-Bar spannte die volle Breite,
|
|
2
|
+
// während der Form-Body auf max-w-2xl begrenzt ist — die Buttons
|
|
3
|
+
// klebten am Fensterrand, optisch abgekoppelt vom Formular. Außerdem
|
|
4
|
+
// wiederholte der Section-Header bei Single-Section-ActionForms den
|
|
5
|
+
// Screen-Titel 1:1. Strukturelle Assertions (Klassen/DOM) — der
|
|
6
|
+
// visuelle Beweis läuft über die publicstatus-Screens nach dem Bump.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type {
|
|
10
|
+
EntityDefinition,
|
|
11
|
+
EntityEditScreenDefinition,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
13
|
+
import { DispatcherProvider, RenderEdit } from "@cosmicdrift/kumiko-renderer";
|
|
14
|
+
import { defaultPrimitives } from "../primitives";
|
|
15
|
+
import { createMockDispatcher, render, screen } from "./test-utils";
|
|
16
|
+
|
|
17
|
+
const { Form, Section, Button } = defaultPrimitives;
|
|
18
|
+
|
|
19
|
+
describe("DefaultForm Action-Bar", () => {
|
|
20
|
+
test("Bar-Inhalt aligned mit dem Form-Body (max-w-Container in der Bar)", () => {
|
|
21
|
+
render(
|
|
22
|
+
<Form onSubmit={() => {}} title="Titel" actions={<Button>Save</Button>} testId="f">
|
|
23
|
+
<div>body</div>
|
|
24
|
+
</Form>,
|
|
25
|
+
);
|
|
26
|
+
const bar = screen.getByTestId("f-actions");
|
|
27
|
+
const inner = bar.firstElementChild;
|
|
28
|
+
expect(inner).toBeTruthy();
|
|
29
|
+
// Gleiche Breiten-Constraint wie der Body (max-w-2xl + px-6) — die
|
|
30
|
+
// Buttons enden damit an derselben Linie wie die Formularfelder.
|
|
31
|
+
expect(inner?.className).toContain("max-w-2xl");
|
|
32
|
+
expect(inner?.className).toContain("px-6");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("DefaultSection ohne Titel", () => {
|
|
37
|
+
test("rendert keinen leeren Header", () => {
|
|
38
|
+
render(
|
|
39
|
+
<Section testId="s">
|
|
40
|
+
<div>content</div>
|
|
41
|
+
</Section>,
|
|
42
|
+
);
|
|
43
|
+
expect(screen.getByTestId("s").querySelector("h3")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const orderEntity = {
|
|
48
|
+
fields: { title: { type: "text", required: true } },
|
|
49
|
+
} as unknown as EntityDefinition; // @cast-boundary test-fixture
|
|
50
|
+
|
|
51
|
+
function makeScreen(sectionTitle: string): EntityEditScreenDefinition {
|
|
52
|
+
return {
|
|
53
|
+
id: "orders:screen:order-edit",
|
|
54
|
+
type: "entityEdit",
|
|
55
|
+
entity: "order",
|
|
56
|
+
layout: { sections: [{ title: sectionTitle, columns: 1, fields: ["title"] }] },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("RenderEdit Section-Titel-Dopplung", () => {
|
|
61
|
+
test("Section-Titel == Form-Titel → Header unterdrückt", () => {
|
|
62
|
+
// Ohne translate-Bundle fällt der Form-Titel auf screen.id zurück —
|
|
63
|
+
// ein gleichlautender Section-Titel reproduziert die Dopplung.
|
|
64
|
+
render(
|
|
65
|
+
<DispatcherProvider dispatcher={createMockDispatcher()}>
|
|
66
|
+
<RenderEdit
|
|
67
|
+
screen={makeScreen("orders:screen:order-edit")}
|
|
68
|
+
entity={orderEntity}
|
|
69
|
+
featureName="orders"
|
|
70
|
+
initial={{ title: "" }}
|
|
71
|
+
writeCommand="order:create"
|
|
72
|
+
/>
|
|
73
|
+
</DispatcherProvider>,
|
|
74
|
+
);
|
|
75
|
+
const section = screen.getByTestId("section-orders:screen:order-edit");
|
|
76
|
+
expect(section.querySelector("h3")).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("abweichender Section-Titel bleibt sichtbar", () => {
|
|
80
|
+
render(
|
|
81
|
+
<DispatcherProvider dispatcher={createMockDispatcher()}>
|
|
82
|
+
<RenderEdit
|
|
83
|
+
screen={makeScreen("Basics")}
|
|
84
|
+
entity={orderEntity}
|
|
85
|
+
featureName="orders"
|
|
86
|
+
initial={{ title: "" }}
|
|
87
|
+
writeCommand="order:create"
|
|
88
|
+
/>
|
|
89
|
+
</DispatcherProvider>,
|
|
90
|
+
);
|
|
91
|
+
const section = screen.getByTestId("section-Basics");
|
|
92
|
+
expect(section.querySelector("h3")?.textContent).toBe("Basics");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Regression Bug-Bash-2 (2026-06-08): timestamp-Felder ohne locatedBy
|
|
2
|
+
// werden server-seitig als z.iso.datetime() (UTC mit `Z`) validiert,
|
|
3
|
+
// das native datetime-local-Input emittierte aber offset-lose lokale
|
|
4
|
+
// Zeit ("2026-06-08T21:09") → jeder Save endete in 422 invalid_format.
|
|
5
|
+
// Die Assertions laufen gegen die ECHTEN Zod-Schemas aus
|
|
6
|
+
// schema-builder.ts (z.iso.datetime / z.iso.datetime({local:true})).
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { fireEvent } from "@testing-library/react";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { defaultPrimitives } from "../primitives";
|
|
11
|
+
import { inputValueToTimestamp, timestampToInputValue } from "../primitives/timestamp-input";
|
|
12
|
+
import { render } from "./test-utils";
|
|
13
|
+
|
|
14
|
+
const utcSchema = z.iso.datetime();
|
|
15
|
+
const wallClockSchema = z.iso.datetime({ local: true });
|
|
16
|
+
const DATETIME_LOCAL = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
|
|
17
|
+
|
|
18
|
+
describe("timestamp Konvertierung (Helpers)", () => {
|
|
19
|
+
test("UTC: lokale Eingabe wird als Z-Instant emittiert, Instant bleibt erhalten", () => {
|
|
20
|
+
const emitted = inputValueToTimestamp("2026-06-08T21:09", false);
|
|
21
|
+
if (emitted === undefined) throw new Error("expected emitted value");
|
|
22
|
+
expect(utcSchema.safeParse(emitted).success).toBe(true);
|
|
23
|
+
expect(emitted.endsWith("Z")).toBe(true);
|
|
24
|
+
expect(new Date(emitted).getTime()).toBe(new Date("2026-06-08T21:09").getTime());
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("wallClock: Eingabe geht offset-los durch und passt das local-Schema", () => {
|
|
28
|
+
const emitted = inputValueToTimestamp("2026-06-08T21:09", true);
|
|
29
|
+
expect(emitted).toBe("2026-06-08T21:09");
|
|
30
|
+
expect(wallClockSchema.safeParse(emitted).success).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("leere Eingabe → undefined (Feld geleert)", () => {
|
|
34
|
+
expect(inputValueToTimestamp("", false)).toBeUndefined();
|
|
35
|
+
expect(inputValueToTimestamp("", true)).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("UTC-Instant aus dem Server wird als lokale Wall-Clock angezeigt", () => {
|
|
39
|
+
const display = timestampToInputValue("2026-06-08T19:09:00.000Z");
|
|
40
|
+
expect(display).toMatch(DATETIME_LOCAL);
|
|
41
|
+
expect(new Date(`${display}`).getTime()).toBe(new Date("2026-06-08T19:09:00.000Z").getTime());
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("offset-loser Wert wird nur auf Minuten gekürzt", () => {
|
|
45
|
+
expect(timestampToInputValue("2026-06-08T21:09:33")).toBe("2026-06-08T21:09");
|
|
46
|
+
expect(timestampToInputValue("")).toBe("");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("Input kind=timestamp (Primitive)", () => {
|
|
51
|
+
test("rendert UTC-Wert als valides datetime-local und emittiert Z-Instant", () => {
|
|
52
|
+
const { Input } = defaultPrimitives;
|
|
53
|
+
const emitted: (string | undefined)[] = [];
|
|
54
|
+
const view = render(
|
|
55
|
+
<Input
|
|
56
|
+
kind="timestamp"
|
|
57
|
+
id="ts"
|
|
58
|
+
name="ts"
|
|
59
|
+
value="2026-06-08T19:09:00Z"
|
|
60
|
+
onChange={(v) => emitted.push(v)}
|
|
61
|
+
/>,
|
|
62
|
+
);
|
|
63
|
+
const input = view.container.querySelector("input");
|
|
64
|
+
if (!input) throw new Error("expected input");
|
|
65
|
+
// datetime-local akzeptiert keine Z-Suffixe — der angezeigte Wert
|
|
66
|
+
// muss konvertiert sein, sonst zeigt der Browser ein leeres Feld.
|
|
67
|
+
expect(input.value).toMatch(DATETIME_LOCAL);
|
|
68
|
+
|
|
69
|
+
fireEvent.change(input, { target: { value: "2026-06-08T21:09" } });
|
|
70
|
+
expect(emitted).toHaveLength(1);
|
|
71
|
+
const value = emitted[0];
|
|
72
|
+
if (value === undefined) throw new Error("expected emitted value");
|
|
73
|
+
expect(utcSchema.safeParse(value).success).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("wallClock-Variante emittiert offset-lose Wall-Clock", () => {
|
|
77
|
+
const { Input } = defaultPrimitives;
|
|
78
|
+
const emitted: (string | undefined)[] = [];
|
|
79
|
+
const view = render(
|
|
80
|
+
<Input
|
|
81
|
+
kind="timestamp"
|
|
82
|
+
id="ts"
|
|
83
|
+
name="ts"
|
|
84
|
+
value=""
|
|
85
|
+
wallClock
|
|
86
|
+
onChange={(v) => emitted.push(v)}
|
|
87
|
+
/>,
|
|
88
|
+
);
|
|
89
|
+
const input = view.container.querySelector("input");
|
|
90
|
+
if (!input) throw new Error("expected input");
|
|
91
|
+
fireEvent.change(input, { target: { value: "2026-06-08T10:00" } });
|
|
92
|
+
expect(emitted).toEqual(["2026-06-08T10:00"]);
|
|
93
|
+
expect(wallClockSchema.safeParse(emitted[0]).success).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -137,7 +137,7 @@ export function ConfigCascadeView({
|
|
|
137
137
|
const hasOverride = activeDisplay?.level.source === screenScopeSource;
|
|
138
138
|
|
|
139
139
|
return (
|
|
140
|
-
<div className="mt-1 text-xs">
|
|
140
|
+
<div className="mt-1 text-xs" data-testid="config-cascade">
|
|
141
141
|
<button
|
|
142
142
|
type="button"
|
|
143
143
|
onClick={() => setExpanded(!expanded)}
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
// Nutzt useTranslation → über test-utils mit LocaleProvider gerendert.
|
|
6
6
|
|
|
7
7
|
import { describe, expect, mock, test } from "bun:test";
|
|
8
|
-
import
|
|
8
|
+
import {
|
|
9
|
+
createStaticLocaleResolver,
|
|
10
|
+
LocaleProvider,
|
|
11
|
+
type WorkspaceSchema,
|
|
12
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
9
13
|
import { fireEvent, render, screen } from "../../__tests__/test-utils";
|
|
10
14
|
import { WorkspaceSwitcher } from "../workspace-switcher";
|
|
11
15
|
|
|
@@ -48,3 +52,24 @@ describe("WorkspaceSwitcher — Render", () => {
|
|
|
48
52
|
expect(onSelect).toHaveBeenCalledWith("b");
|
|
49
53
|
});
|
|
50
54
|
});
|
|
55
|
+
|
|
56
|
+
describe("WorkspaceSwitcher — i18n-Labels (Punkt-Konvention)", () => {
|
|
57
|
+
test("Label mit Punkt geht durch t() und rendert die Übersetzung", () => {
|
|
58
|
+
const bundle = { en: { "nav.adminArea": "Admin Area" } };
|
|
59
|
+
const { getByTestId } = render(
|
|
60
|
+
<LocaleProvider
|
|
61
|
+
resolver={createStaticLocaleResolver({ locale: "en" })}
|
|
62
|
+
fallbackBundles={[bundle]}
|
|
63
|
+
>
|
|
64
|
+
<WorkspaceSwitcher
|
|
65
|
+
workspaces={[ws("a", "nav.adminArea"), ws("b", "Plain Label")]}
|
|
66
|
+
activeId="a"
|
|
67
|
+
onSelect={mock()}
|
|
68
|
+
/>
|
|
69
|
+
</LocaleProvider>,
|
|
70
|
+
);
|
|
71
|
+
expect(getByTestId("workspace-tab-a").textContent).toBe("Admin Area");
|
|
72
|
+
// ohne Punkt: verbatim, kein t()-Roundtrip
|
|
73
|
+
expect(getByTestId("workspace-tab-b").textContent).toBe("Plain Label");
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/layout/avatar.tsx
CHANGED
|
@@ -61,7 +61,7 @@ function pickColor(id: string): string {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function extractInitials(label: string): string {
|
|
64
|
-
// "Daniel Hennig" → "DH". "alice@example.com" → "
|
|
64
|
+
// "Daniel Hennig" → "DH". "alice@example.com" → "AL". Single-word
|
|
65
65
|
// fällt auf erste 2 Buchstaben zurück ("Daniel" → "DA"). Alles
|
|
66
66
|
// upper-case.
|
|
67
67
|
const trimmed = label.trim();
|
|
@@ -38,6 +38,24 @@ describe("computeVisiblePages", () => {
|
|
|
38
38
|
test("page=5/20: Übergang Rand→Mitte (Ellipsis links erscheint)", () => {
|
|
39
39
|
expect(computeVisiblePages(5, 20)).toEqual([1, "ellipsis", 3, 4, 5, 6, 7, "ellipsis", 20]);
|
|
40
40
|
});
|
|
41
|
+
|
|
42
|
+
test("page=16/20: letzte Mitte-Position (Ellipsis rechts noch da)", () => {
|
|
43
|
+
expect(computeVisiblePages(16, 20)).toEqual([
|
|
44
|
+
1,
|
|
45
|
+
"ellipsis",
|
|
46
|
+
14,
|
|
47
|
+
15,
|
|
48
|
+
16,
|
|
49
|
+
17,
|
|
50
|
+
18,
|
|
51
|
+
"ellipsis",
|
|
52
|
+
20,
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("page=17/20: Übergang Mitte→Rand (Ellipsis rechts verschwindet, 5er-Tail)", () => {
|
|
57
|
+
expect(computeVisiblePages(17, 20)).toEqual([1, "ellipsis", 16, 17, 18, 19, 20]);
|
|
58
|
+
});
|
|
41
59
|
});
|
|
42
60
|
|
|
43
61
|
describe("isComponentRendererRef", () => {
|
|
@@ -61,10 +79,10 @@ describe("isComponentRendererRef", () => {
|
|
|
61
79
|
});
|
|
62
80
|
|
|
63
81
|
describe("applyFormatSpec", () => {
|
|
64
|
-
test("null/undefined/leer → leerer String (
|
|
82
|
+
test("null/undefined/leer → leerer String (priority rendert stattdessen emptyLabel)", () => {
|
|
65
83
|
expect(applyFormatSpec({ format: "boolean" }, null)).toBe("");
|
|
66
84
|
expect(applyFormatSpec({ format: "currency", symbol: "€" }, undefined)).toBe("");
|
|
67
|
-
expect(applyFormatSpec({ format: "priority" }, "")).toBe("");
|
|
85
|
+
expect(applyFormatSpec({ format: "priority" }, "")).toBe("—");
|
|
68
86
|
});
|
|
69
87
|
|
|
70
88
|
test("boolean: true → ✓, false → leer (defaults)", () => {
|
|
@@ -101,12 +119,20 @@ describe("applyFormatSpec", () => {
|
|
|
101
119
|
});
|
|
102
120
|
|
|
103
121
|
test("unbekanntes format → String(value) fallback + dev-warning", () => {
|
|
122
|
+
// Warnung feuert nur bei NODE_ENV !== "production" — explizit setzen
|
|
123
|
+
// statt sich aufs Test-Runner-Preset zu verlassen.
|
|
124
|
+
const prevNodeEnv = process.env.NODE_ENV;
|
|
125
|
+
process.env.NODE_ENV = "development";
|
|
104
126
|
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
127
|
+
try {
|
|
128
|
+
expect(applyFormatSpec({ format: "custom-app-format" }, 42)).toBe("42");
|
|
129
|
+
expect(applyFormatSpec({ format: "custom-app-format" }, "hello")).toBe("hello");
|
|
130
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
131
|
+
expect(warn.mock.calls[0]?.[0]).toContain("custom-app-format");
|
|
132
|
+
} finally {
|
|
133
|
+
warn.mockRestore();
|
|
134
|
+
process.env.NODE_ENV = prevNodeEnv;
|
|
135
|
+
}
|
|
110
136
|
});
|
|
111
137
|
});
|
|
112
138
|
|
package/src/primitives/index.tsx
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
DropdownMenuTrigger,
|
|
56
56
|
} from "./dropdown-menu";
|
|
57
57
|
import { MoneyInput } from "./money-input";
|
|
58
|
+
import { TimestampInput } from "./timestamp-input";
|
|
58
59
|
import { useToast } from "./toast";
|
|
59
60
|
|
|
60
61
|
// ---- Button ----
|
|
@@ -172,7 +173,7 @@ function DefaultField({
|
|
|
172
173
|
className="text-xs text-destructive"
|
|
173
174
|
>
|
|
174
175
|
{issues.map((issue) => (
|
|
175
|
-
<div key={`${issue.path}:${issue.code}`}>{t(issue.i18nKey)}</div>
|
|
176
|
+
<div key={`${issue.path}:${issue.code}`}>{t(issue.i18nKey, issue.params)}</div>
|
|
176
177
|
))}
|
|
177
178
|
</div>
|
|
178
179
|
)}
|
|
@@ -340,13 +341,15 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
340
341
|
);
|
|
341
342
|
case "timestamp":
|
|
342
343
|
return (
|
|
343
|
-
<
|
|
344
|
-
|
|
345
|
-
{
|
|
344
|
+
<TimestampInput
|
|
345
|
+
id={props.id}
|
|
346
|
+
name={props.name}
|
|
346
347
|
value={props.value}
|
|
347
|
-
onChange={
|
|
348
|
-
|
|
349
|
-
}
|
|
348
|
+
onChange={props.onChange}
|
|
349
|
+
{...(props.wallClock !== undefined && { wallClock: props.wallClock })}
|
|
350
|
+
{...(props.disabled !== undefined && { disabled: props.disabled })}
|
|
351
|
+
{...(props.required !== undefined && { required: props.required })}
|
|
352
|
+
{...(props.hasError !== undefined && { hasError: props.hasError })}
|
|
350
353
|
className={cn(inputClassBase, errorClass)}
|
|
351
354
|
/>
|
|
352
355
|
);
|
|
@@ -1176,14 +1179,19 @@ function DefaultForm({ onSubmit, children, title, actions, testId }: FormProps):
|
|
|
1176
1179
|
{(title !== undefined || actions !== undefined) && (
|
|
1177
1180
|
<div
|
|
1178
1181
|
data-testid={testId !== undefined ? `${testId}-actions` : undefined}
|
|
1179
|
-
className="sticky top-0 z-10 h-12
|
|
1182
|
+
className="sticky top-0 z-10 h-12 bg-muted/30 border-b"
|
|
1180
1183
|
>
|
|
1181
|
-
{
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1184
|
+
{/* Bar-Hintergrund spannt die volle Breite, der Inhalt aligned
|
|
1185
|
+
aber mit dem max-w-2xl-Form-Body — sonst kleben die Buttons
|
|
1186
|
+
am Fensterrand, optisch abgekoppelt vom Formular. */}
|
|
1187
|
+
<div className="h-full px-6 max-w-2xl w-full flex items-center gap-3">
|
|
1188
|
+
{title !== undefined && (
|
|
1189
|
+
<div className="text-lg font-semibold tracking-tight truncate">{title}</div>
|
|
1190
|
+
)}
|
|
1191
|
+
{actions !== undefined && (
|
|
1192
|
+
<div className="flex items-center gap-2 ml-auto">{actions}</div>
|
|
1193
|
+
)}
|
|
1194
|
+
</div>
|
|
1187
1195
|
</div>
|
|
1188
1196
|
)}
|
|
1189
1197
|
<div className="px-6 pt-6 pb-12 max-w-2xl w-full flex flex-col gap-8">{children}</div>
|
|
@@ -1198,9 +1206,11 @@ function DefaultSection({ title, children, testId }: SectionProps): ReactNode {
|
|
|
1198
1206
|
// aus Border + Shadow. Spart Chrome und sieht weniger "boxy" aus.
|
|
1199
1207
|
return (
|
|
1200
1208
|
<section data-testid={testId} className="flex flex-col gap-4">
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1209
|
+
{title !== undefined && (
|
|
1210
|
+
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
1211
|
+
{title}
|
|
1212
|
+
</h3>
|
|
1213
|
+
)}
|
|
1204
1214
|
<div className="flex flex-col gap-4">{children}</div>
|
|
1205
1215
|
</section>
|
|
1206
1216
|
);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// TimestampInput — natives <input type="datetime-local"> mit
|
|
2
|
+
// Wert-Konvertierung. Der Server validiert timestamp-Felder als
|
|
3
|
+
// ISO-UTC mit `Z` (z.iso.datetime()) bzw. als Wall-Clock ohne Offset
|
|
4
|
+
// bei locatedTimestamps (z.iso.datetime({ local: true })). Das native
|
|
5
|
+
// datetime-local-Input spricht aber IMMER lokale Wall-Clock ohne
|
|
6
|
+
// Offset — ohne Konvertierung ging jeder UTC-Timestamp als
|
|
7
|
+
// offset-loser String raus und der Server lehnte mit invalid_format
|
|
8
|
+
// ab (Bug-Bash-2, 2026-06-08).
|
|
9
|
+
|
|
10
|
+
import type { ChangeEvent, ReactNode } from "react";
|
|
11
|
+
import { cn } from "../lib/cn";
|
|
12
|
+
|
|
13
|
+
const LOCAL_MINUTES = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
|
|
14
|
+
const HAS_OFFSET = /(?:Z|[+-]\d{2}:\d{2})$/;
|
|
15
|
+
|
|
16
|
+
function pad(n: number): string {
|
|
17
|
+
return String(n).padStart(2, "0");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Form-State → datetime-local-Format (`yyyy-MM-ddTHH:mm`).
|
|
21
|
+
* UTC-Instants (mit `Z`/Offset) werden in lokale Wall-Clock
|
|
22
|
+
* umgerechnet; offset-lose Werte nur auf Minuten gekürzt. */
|
|
23
|
+
export function timestampToInputValue(value: string): string {
|
|
24
|
+
if (value === "") return "";
|
|
25
|
+
if (HAS_OFFSET.test(value)) {
|
|
26
|
+
const d = new Date(value);
|
|
27
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
28
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
29
|
+
}
|
|
30
|
+
const m = LOCAL_MINUTES.exec(value);
|
|
31
|
+
return m !== null ? m[0] : "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** datetime-local-Wert → Wire-Format. wallClock=true reicht die
|
|
35
|
+
* Wall-Clock unverändert durch; sonst wird die lokale Zeit als
|
|
36
|
+
* UTC-Instant mit `Z`-Suffix emittiert. */
|
|
37
|
+
export function inputValueToTimestamp(raw: string, wallClock: boolean): string | undefined {
|
|
38
|
+
if (raw === "") return undefined;
|
|
39
|
+
if (wallClock) return raw;
|
|
40
|
+
// Offset-loser Datetime-String wird von Date als LOKALE Zeit geparst
|
|
41
|
+
// (ES2020+) — genau die Semantik die datetime-local liefert.
|
|
42
|
+
const d = new Date(raw);
|
|
43
|
+
if (Number.isNaN(d.getTime())) return undefined;
|
|
44
|
+
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}Z`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type TimestampInputProps = {
|
|
48
|
+
readonly id: string;
|
|
49
|
+
readonly name: string;
|
|
50
|
+
readonly value: string;
|
|
51
|
+
readonly onChange: (v: string | undefined) => void;
|
|
52
|
+
readonly wallClock?: boolean;
|
|
53
|
+
readonly disabled?: boolean;
|
|
54
|
+
readonly required?: boolean;
|
|
55
|
+
readonly hasError?: boolean;
|
|
56
|
+
readonly className?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function TimestampInput({
|
|
60
|
+
id,
|
|
61
|
+
name,
|
|
62
|
+
value,
|
|
63
|
+
onChange,
|
|
64
|
+
wallClock,
|
|
65
|
+
disabled,
|
|
66
|
+
required,
|
|
67
|
+
hasError,
|
|
68
|
+
className,
|
|
69
|
+
}: TimestampInputProps): ReactNode {
|
|
70
|
+
return (
|
|
71
|
+
<input
|
|
72
|
+
type="datetime-local"
|
|
73
|
+
id={id}
|
|
74
|
+
name={name}
|
|
75
|
+
disabled={disabled}
|
|
76
|
+
required={required}
|
|
77
|
+
aria-invalid={hasError === true ? true : undefined}
|
|
78
|
+
value={timestampToInputValue(value)}
|
|
79
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
|
80
|
+
onChange(inputValueToTimestamp(e.target.value, wallClock === true))
|
|
81
|
+
}
|
|
82
|
+
// Das `flex` der Input-Basisklasse macht die Shadow-DOM-Teile des
|
|
83
|
+
// datetime-local zu Flex-Items — der Picker-Indicator klebt dann
|
|
84
|
+
// direkt am Text statt am rechten Rand. ml-auto schiebt ihn zurück.
|
|
85
|
+
className={cn("[&::-webkit-calendar-picker-indicator]:ml-auto", className)}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
}
|