@cosmicdrift/kumiko-renderer-web 0.226.0 → 0.228.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.228.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.228.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.228.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.228.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",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
65
|
"jsdom": "^29.1.1",
|
|
66
66
|
"tailwindcss": "^4.3.0",
|
|
67
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
67
|
+
"@cosmicdrift/kumiko-locale-de": "0.228.0"
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
@@ -6,7 +6,14 @@ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
|
6
6
|
import type { FeatureSchema, NavApi } from "@cosmicdrift/kumiko-renderer";
|
|
7
7
|
import { DispatcherProvider, KumikoScreen, NavProvider } from "@cosmicdrift/kumiko-renderer";
|
|
8
8
|
import userEvent from "@testing-library/user-event";
|
|
9
|
-
import {
|
|
9
|
+
import { type ReactNode, useState } from "react";
|
|
10
|
+
import {
|
|
11
|
+
createMockDispatcher,
|
|
12
|
+
render,
|
|
13
|
+
renderWithPrimitivesOverride,
|
|
14
|
+
screen,
|
|
15
|
+
waitFor,
|
|
16
|
+
} from "./test-utils";
|
|
10
17
|
|
|
11
18
|
const baseScreen: ProjectionDetailScreenDefinition = {
|
|
12
19
|
id: "rent-detail",
|
|
@@ -124,6 +131,57 @@ describe("KumikoScreen / projectionDetail — record header + metrics band", ()
|
|
|
124
131
|
});
|
|
125
132
|
});
|
|
126
133
|
|
|
134
|
+
describe("KumikoScreen / projectionDetail — metric tiles render through the Metric primitive", () => {
|
|
135
|
+
const metricsScreen: ProjectionDetailScreenDefinition = {
|
|
136
|
+
...baseScreen,
|
|
137
|
+
metrics: ["balance"],
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
test("goes through the Metric primitive, not naked Text, when one is registered", async () => {
|
|
141
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
142
|
+
|
|
143
|
+
render(
|
|
144
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
145
|
+
<KumikoScreen
|
|
146
|
+
schema={schemaFor(metricsScreen)}
|
|
147
|
+
qn="rentals:screen:rent-detail"
|
|
148
|
+
entityId="rent-1"
|
|
149
|
+
/>
|
|
150
|
+
</DispatcherProvider>,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
154
|
+
// Only the Metric primitive assigns a testid to the tile itself — the
|
|
155
|
+
// naked-Text fallback below only labels the -label/-value nodes. This
|
|
156
|
+
// is red against the pre-fix code (naked Text, no tile testid).
|
|
157
|
+
expect(screen.getByTestId("kumiko-screen-projection-detail-metric-balance")).toBeTruthy();
|
|
158
|
+
expect(
|
|
159
|
+
screen.getByTestId("kumiko-screen-projection-detail-metric-balance-value").textContent,
|
|
160
|
+
).toBe("120");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("falls back to plain label/value Text when no Metric primitive is registered", async () => {
|
|
164
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
165
|
+
|
|
166
|
+
renderWithPrimitivesOverride(
|
|
167
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
168
|
+
<KumikoScreen
|
|
169
|
+
schema={schemaFor(metricsScreen)}
|
|
170
|
+
qn="rentals:screen:rent-detail"
|
|
171
|
+
entityId="rent-1"
|
|
172
|
+
/>
|
|
173
|
+
</DispatcherProvider>,
|
|
174
|
+
{ Metric: undefined },
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
178
|
+
expect(
|
|
179
|
+
screen.getByTestId("kumiko-screen-projection-detail-metric-balance-value").textContent,
|
|
180
|
+
).toBe("120");
|
|
181
|
+
expect(screen.queryByTestId("kumiko-screen-projection-detail-metric-balance")).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
127
185
|
describe("KumikoScreen / projectionDetail — layout.mode: 'tabs'", () => {
|
|
128
186
|
const tabsScreen: ProjectionDetailScreenDefinition = {
|
|
129
187
|
...baseScreen,
|
|
@@ -167,6 +225,92 @@ describe("KumikoScreen / projectionDetail — layout.mode: 'tabs'", () => {
|
|
|
167
225
|
};
|
|
168
226
|
}
|
|
169
227
|
|
|
228
|
+
// Reactive nav stub — navWithTab's searchParams is fixed at mount, so it
|
|
229
|
+
// can't exercise a real "click a tab, watch the active section change"
|
|
230
|
+
// flow. Only used by the tab-switch test below.
|
|
231
|
+
function StatefulTabNav({ children }: { readonly children: ReactNode }): ReactNode {
|
|
232
|
+
const [tab, setTab] = useState<string | undefined>(undefined);
|
|
233
|
+
const navApi: NavApi = {
|
|
234
|
+
route: undefined,
|
|
235
|
+
navigate: () => {},
|
|
236
|
+
replace: () => {},
|
|
237
|
+
hrefFor: () => "",
|
|
238
|
+
searchParams: tab !== undefined ? { tab } : {},
|
|
239
|
+
setSearchParams: (updates) => {
|
|
240
|
+
const next = updates["tab"];
|
|
241
|
+
setTab(next === null || next === undefined ? undefined : next);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
return <NavProvider value={navApi}>{children}</NavProvider>;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
test("switching tabs does not remount the body or refire the detail query", async () => {
|
|
248
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
249
|
+
const user = userEvent.setup();
|
|
250
|
+
|
|
251
|
+
render(
|
|
252
|
+
<StatefulTabNav>
|
|
253
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
254
|
+
<KumikoScreen
|
|
255
|
+
schema={schemaFor(tabsScreen)}
|
|
256
|
+
qn="rentals:screen:rent-detail"
|
|
257
|
+
entityId="rent-1"
|
|
258
|
+
/>
|
|
259
|
+
</DispatcherProvider>
|
|
260
|
+
</StatefulTabNav>,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
264
|
+
const detailCallsBefore = dispatcher.calls.filter(
|
|
265
|
+
(c) => c.type === "rentals:query:rent:detail",
|
|
266
|
+
).length;
|
|
267
|
+
expect(detailCallsBefore).toBe(1);
|
|
268
|
+
|
|
269
|
+
const trigger = await waitFor(() =>
|
|
270
|
+
screen.getByTestId("kumiko-screen-projection-detail-tabs-payments"),
|
|
271
|
+
);
|
|
272
|
+
await user.click(trigger);
|
|
273
|
+
await waitFor(() =>
|
|
274
|
+
expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:payments")).toBe(true),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// A `key` on the record identifier alone (fw#2518 fix) must not also
|
|
278
|
+
// depend on the active tab — folding tab into the key would remount the
|
|
279
|
+
// body on every tab click and refire this query. The pre-fix "fires
|
|
280
|
+
// exactly one query" test above only checks first render and would not
|
|
281
|
+
// have caught that regression.
|
|
282
|
+
const detailCallsAfter = dispatcher.calls.filter(
|
|
283
|
+
(c) => c.type === "rentals:query:rent:detail",
|
|
284
|
+
).length;
|
|
285
|
+
expect(detailCallsAfter).toBe(detailCallsBefore);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("tabs mode suppresses the form's own redundant title, not just section titles", async () => {
|
|
289
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
290
|
+
const { navApi } = navWithTab(undefined);
|
|
291
|
+
|
|
292
|
+
render(
|
|
293
|
+
<NavProvider value={navApi}>
|
|
294
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
295
|
+
<KumikoScreen
|
|
296
|
+
schema={schemaFor(tabsScreen)}
|
|
297
|
+
qn="rentals:screen:rent-detail"
|
|
298
|
+
entityId="rent-1"
|
|
299
|
+
/>
|
|
300
|
+
</DispatcherProvider>
|
|
301
|
+
</NavProvider>,
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
305
|
+
// hideSectionTitles used to only blank out each Section's own title —
|
|
306
|
+
// RenderEdit's own Form-level title (screen id/i18n fallback) rendered
|
|
307
|
+
// unconditionally regardless, duplicating the active tab's label
|
|
308
|
+
// whenever the two happened to read the same ("Mietvertrag" above the
|
|
309
|
+
// table, matching the already-visible tab). This asserts the Form-level
|
|
310
|
+
// title is gone too, not just the section ones.
|
|
311
|
+
expect(screen.queryByTestId("render-edit-form-title")).toBeNull();
|
|
312
|
+
});
|
|
313
|
+
|
|
170
314
|
test("fires exactly one query on first render — the inactive tabs' relatedList queries never fire", async () => {
|
|
171
315
|
const dispatcher = dispatcherReturning(rowData);
|
|
172
316
|
const { navApi } = navWithTab(undefined);
|
|
@@ -301,5 +445,55 @@ describe("KumikoScreen / projectionDetail — unchanged for a solon-shaped scree
|
|
|
301
445
|
expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:invoices")).toBe(true),
|
|
302
446
|
);
|
|
303
447
|
expect(screen.queryByTestId("kumiko-screen-projection-detail-tabs")).toBeNull();
|
|
448
|
+
// Contrast for the tabs-mode "form title suppressed" test above — outside
|
|
449
|
+
// tabs mode (hideSectionTitles unset) the form's own title still renders.
|
|
450
|
+
expect(screen.getByTestId("render-edit-form-title")).toBeTruthy();
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
describe("KumikoScreen / projectionDetail — switching records remounts the body (fw#2518)", () => {
|
|
455
|
+
test("record A's fields are gone from the DOM after switching to record B", async () => {
|
|
456
|
+
const dataById: Record<string, Readonly<Record<string, unknown>>> = {
|
|
457
|
+
"rent-1": { description: "Loft 4B" },
|
|
458
|
+
"rent-2": { description: "Warehouse 9" },
|
|
459
|
+
};
|
|
460
|
+
const query = (async (type: string, payload: unknown) => {
|
|
461
|
+
if (type === "rentals:query:rent:detail") {
|
|
462
|
+
const id = (payload as { id?: string }).id ?? "";
|
|
463
|
+
return { isSuccess: true, data: dataById[id] ?? {} };
|
|
464
|
+
}
|
|
465
|
+
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
466
|
+
}) as unknown as Dispatcher["query"];
|
|
467
|
+
const dispatcher = createMockDispatcher({ query });
|
|
468
|
+
|
|
469
|
+
const { rerender } = render(
|
|
470
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
471
|
+
<KumikoScreen
|
|
472
|
+
schema={schemaFor(baseScreen)}
|
|
473
|
+
qn="rentals:screen:rent-detail"
|
|
474
|
+
entityId="rent-1"
|
|
475
|
+
/>
|
|
476
|
+
</DispatcherProvider>,
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
await waitFor(() => screen.getByText("Loft 4B"));
|
|
480
|
+
|
|
481
|
+
rerender(
|
|
482
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
483
|
+
<KumikoScreen
|
|
484
|
+
schema={schemaFor(baseScreen)}
|
|
485
|
+
qn="rentals:screen:rent-detail"
|
|
486
|
+
entityId="rent-2"
|
|
487
|
+
/>
|
|
488
|
+
</DispatcherProvider>,
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
// Without the key fix, React keeps the old ProjectionDetailBody instance
|
|
492
|
+
// mounted and briefly renders record A's fields until the new query
|
|
493
|
+
// resolves. Asserting only the ABSENCE of "Loft 4B" would pass on flaky
|
|
494
|
+
// timing even without the fix, so this also waits for record B's own
|
|
495
|
+
// value to confirm the remount actually completed.
|
|
496
|
+
await waitFor(() => screen.getByText("Warehouse 9"));
|
|
497
|
+
expect(screen.queryByText("Loft 4B")).toBeNull();
|
|
304
498
|
});
|
|
305
499
|
});
|
|
@@ -9,7 +9,12 @@ import { describe, expect, test } from "bun:test";
|
|
|
9
9
|
import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
10
10
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
11
11
|
import type { FeatureSchema, NavApi, NavTarget } from "@cosmicdrift/kumiko-renderer";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
DispatcherProvider,
|
|
14
|
+
ExtensionSectionsProvider,
|
|
15
|
+
KumikoScreen,
|
|
16
|
+
NavProvider,
|
|
17
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
13
18
|
import { act, createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
|
|
14
19
|
|
|
15
20
|
const detailScreen: ProjectionDetailScreenDefinition = {
|
|
@@ -470,3 +475,141 @@ describe("KumikoScreen / projectionDetail relatedList section (fw#2166)", () =>
|
|
|
470
475
|
expect(navigated).toBeUndefined();
|
|
471
476
|
});
|
|
472
477
|
});
|
|
478
|
+
|
|
479
|
+
// solon#264: a hand-written NotesSection block had no declarative equivalent
|
|
480
|
+
// on projectionDetail. Extension sections without contributesToFormSubmit
|
|
481
|
+
// (self-persisting, e.g. via their own dispatcher writes) are now allowed.
|
|
482
|
+
describe("KumikoScreen / projectionDetail extension section (solon#264)", () => {
|
|
483
|
+
const SessionNotes = ({
|
|
484
|
+
entityName,
|
|
485
|
+
entityId,
|
|
486
|
+
}: {
|
|
487
|
+
entityName: string;
|
|
488
|
+
entityId: string | null;
|
|
489
|
+
}) => (
|
|
490
|
+
<div data-testid="session-notes">
|
|
491
|
+
{entityName}:{entityId ?? "(none)"}
|
|
492
|
+
</div>
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
test("mounts and receives the route entityId and the section's declared entityName, no submit button", async () => {
|
|
496
|
+
const extensionScreen: ProjectionDetailScreenDefinition = {
|
|
497
|
+
...detailScreen,
|
|
498
|
+
layout: {
|
|
499
|
+
sections: [
|
|
500
|
+
...detailScreen.layout.sections,
|
|
501
|
+
{
|
|
502
|
+
kind: "extension",
|
|
503
|
+
title: "Notes",
|
|
504
|
+
component: { react: { __component: "SessionNotes" } },
|
|
505
|
+
entityName: "user-session",
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
const extensionSchema: FeatureSchema = {
|
|
511
|
+
featureName: "sessions",
|
|
512
|
+
entities: {},
|
|
513
|
+
screens: [extensionScreen],
|
|
514
|
+
};
|
|
515
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
516
|
+
query: (async () => ({
|
|
517
|
+
isSuccess: true,
|
|
518
|
+
data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
|
|
519
|
+
})) as unknown as Dispatcher["query"],
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
render(
|
|
523
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
524
|
+
<ExtensionSectionsProvider value={{ SessionNotes }}>
|
|
525
|
+
<KumikoScreen
|
|
526
|
+
schema={extensionSchema}
|
|
527
|
+
qn="sessions:screen:session-detail"
|
|
528
|
+
entityId="sess-1"
|
|
529
|
+
/>
|
|
530
|
+
</ExtensionSectionsProvider>
|
|
531
|
+
</DispatcherProvider>,
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
535
|
+
// Declared entityName ("user-session") must win over the shim's internal
|
|
536
|
+
// placeholder entity name — otherwise a self-persisting section like
|
|
537
|
+
// NotesSection would filter/write against the wrong domain entity.
|
|
538
|
+
expect(screen.getByTestId("session-notes").textContent).toBe("user-session:sess-1");
|
|
539
|
+
expect(screen.queryByTestId("render-edit-submit")).toBeNull();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("layout.mode: 'tabs' — mounts only when its tab is active, not on first render of another tab", async () => {
|
|
543
|
+
const tabsExtensionScreen: ProjectionDetailScreenDefinition = {
|
|
544
|
+
...detailScreen,
|
|
545
|
+
layout: {
|
|
546
|
+
mode: "tabs",
|
|
547
|
+
sections: [
|
|
548
|
+
{ id: "overview", title: "Session", fields: ["userId"] },
|
|
549
|
+
{
|
|
550
|
+
id: "notes",
|
|
551
|
+
kind: "extension",
|
|
552
|
+
title: "Notes",
|
|
553
|
+
component: { react: { __component: "SessionNotes" } },
|
|
554
|
+
entityName: "user-session",
|
|
555
|
+
},
|
|
556
|
+
],
|
|
557
|
+
},
|
|
558
|
+
};
|
|
559
|
+
const tabsExtensionSchema: FeatureSchema = {
|
|
560
|
+
featureName: "sessions",
|
|
561
|
+
entities: {},
|
|
562
|
+
screens: [tabsExtensionScreen],
|
|
563
|
+
};
|
|
564
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
565
|
+
query: (async () => ({
|
|
566
|
+
isSuccess: true,
|
|
567
|
+
data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
|
|
568
|
+
})) as unknown as Dispatcher["query"],
|
|
569
|
+
});
|
|
570
|
+
function navWithTab(tab: string | undefined): NavApi {
|
|
571
|
+
return {
|
|
572
|
+
route: undefined,
|
|
573
|
+
navigate: () => {},
|
|
574
|
+
replace: () => {},
|
|
575
|
+
hrefFor: () => "",
|
|
576
|
+
searchParams: tab !== undefined ? { tab } : {},
|
|
577
|
+
setSearchParams: () => {},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const { unmount } = render(
|
|
582
|
+
<NavProvider value={navWithTab(undefined)}>
|
|
583
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
584
|
+
<ExtensionSectionsProvider value={{ SessionNotes }}>
|
|
585
|
+
<KumikoScreen
|
|
586
|
+
schema={tabsExtensionSchema}
|
|
587
|
+
qn="sessions:screen:session-detail"
|
|
588
|
+
entityId="sess-1"
|
|
589
|
+
/>
|
|
590
|
+
</ExtensionSectionsProvider>
|
|
591
|
+
</DispatcherProvider>
|
|
592
|
+
</NavProvider>,
|
|
593
|
+
);
|
|
594
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
595
|
+
expect(screen.queryByTestId("session-notes")).toBeNull();
|
|
596
|
+
unmount();
|
|
597
|
+
|
|
598
|
+
render(
|
|
599
|
+
<NavProvider value={navWithTab("notes")}>
|
|
600
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
601
|
+
<ExtensionSectionsProvider value={{ SessionNotes }}>
|
|
602
|
+
<KumikoScreen
|
|
603
|
+
schema={tabsExtensionSchema}
|
|
604
|
+
qn="sessions:screen:session-detail"
|
|
605
|
+
entityId="sess-1"
|
|
606
|
+
/>
|
|
607
|
+
</ExtensionSectionsProvider>
|
|
608
|
+
</DispatcherProvider>
|
|
609
|
+
</NavProvider>,
|
|
610
|
+
);
|
|
611
|
+
await waitFor(() => screen.getByTestId("session-notes"));
|
|
612
|
+
expect(screen.getByTestId("session-notes").textContent).toBe("user-session:sess-1");
|
|
613
|
+
expect(screen.queryByTestId("field-userId")).toBeNull();
|
|
614
|
+
});
|
|
615
|
+
});
|
package/src/primitives/index.tsx
CHANGED
|
@@ -105,6 +105,7 @@ import { EmbeddedListInput } from "./embedded-list-input";
|
|
|
105
105
|
import { FileUploadInput } from "./file-upload";
|
|
106
106
|
import { DefaultLightbox } from "./lightbox";
|
|
107
107
|
import { LocatedTimestampInput } from "./located-timestamp-input";
|
|
108
|
+
import { DefaultMetric } from "./metric";
|
|
108
109
|
import { DefaultModal } from "./modal";
|
|
109
110
|
import { currencyDecimals, formatMoney, MoneyInput } from "./money-input";
|
|
110
111
|
import { DefaultStatusBadge } from "./status-badge";
|
|
@@ -1625,6 +1626,7 @@ function DefaultForm({
|
|
|
1625
1626
|
testId,
|
|
1626
1627
|
width,
|
|
1627
1628
|
stickyActions,
|
|
1629
|
+
headerRegion,
|
|
1628
1630
|
}: FormProps): ReactNode {
|
|
1629
1631
|
// Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
|
|
1630
1632
|
// der Container trägt Card/Titel selbst, sonst Card-in-Card.
|
|
@@ -1664,6 +1666,9 @@ function DefaultForm({
|
|
|
1664
1666
|
className="flex flex-col w-full"
|
|
1665
1667
|
>
|
|
1666
1668
|
<FormScreenShell {...(width !== undefined && { maxWidth: width })}>
|
|
1669
|
+
{headerRegion !== undefined && (
|
|
1670
|
+
<div className="flex flex-col gap-6 mb-8">{headerRegion}</div>
|
|
1671
|
+
)}
|
|
1667
1672
|
<div className={cn(cardSurface(), "overflow-hidden")}>
|
|
1668
1673
|
{(title !== undefined || subtitle !== undefined) && (
|
|
1669
1674
|
<div className="px-6 pb-2 pt-5">
|
|
@@ -1856,6 +1861,16 @@ function DefaultSection({
|
|
|
1856
1861
|
}
|
|
1857
1862
|
|
|
1858
1863
|
function DefaultGrid({ columns, children, testId, maxRows }: GridProps): ReactNode {
|
|
1864
|
+
// "auto": content-sized items in a wrapping row (e.g. a metrics band of
|
|
1865
|
+
// self-sized tiles) instead of N equal-width, container-stretched tracks.
|
|
1866
|
+
// maxRows/scrolling don't apply — the row just wraps.
|
|
1867
|
+
if (columns === "auto") {
|
|
1868
|
+
return (
|
|
1869
|
+
<div data-testid={testId} className="flex flex-wrap gap-4">
|
|
1870
|
+
{children}
|
|
1871
|
+
</div>
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1859
1874
|
// Responsive: Mobile (< sm = 640px) bleibt 1-spaltig, ab sm: greift
|
|
1860
1875
|
// die Author-deklarierte Spaltenzahl. Inline-style schreibt
|
|
1861
1876
|
// CSS-Variable; Tailwind-Klasse liest die Variable mit
|
|
@@ -2098,4 +2113,5 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
2098
2113
|
WizardStepGroup: DefaultWizardStepGroup,
|
|
2099
2114
|
Tabs: DefaultTabs,
|
|
2100
2115
|
StatusBadge: DefaultStatusBadge,
|
|
2116
|
+
Metric: DefaultMetric,
|
|
2101
2117
|
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { MetricProps } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { MiniStat } from "../widgets/stat";
|
|
4
|
+
|
|
5
|
+
export function DefaultMetric({ label, value, testId }: MetricProps): ReactNode {
|
|
6
|
+
return (
|
|
7
|
+
<MiniStat
|
|
8
|
+
label={label}
|
|
9
|
+
value={value}
|
|
10
|
+
testId={testId}
|
|
11
|
+
{...(testId !== undefined && {
|
|
12
|
+
labelTestId: `${testId}-label`,
|
|
13
|
+
valueTestId: `${testId}-value`,
|
|
14
|
+
})}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
package/src/widgets/stat.tsx
CHANGED
|
@@ -157,12 +157,16 @@ export function MiniStat({
|
|
|
157
157
|
tone = "default",
|
|
158
158
|
emphasize = false,
|
|
159
159
|
testId,
|
|
160
|
+
labelTestId,
|
|
161
|
+
valueTestId,
|
|
160
162
|
}: {
|
|
161
163
|
readonly label: string;
|
|
162
164
|
readonly value: string;
|
|
163
165
|
readonly tone?: StatTone;
|
|
164
166
|
readonly emphasize?: boolean;
|
|
165
167
|
readonly testId?: string;
|
|
168
|
+
readonly labelTestId?: string;
|
|
169
|
+
readonly valueTestId?: string;
|
|
166
170
|
}): ReactNode {
|
|
167
171
|
const { Card } = usePrimitives();
|
|
168
172
|
return (
|
|
@@ -171,13 +175,16 @@ export function MiniStat({
|
|
|
171
175
|
className={cn("p-3", emphasize && "ring-1 ring-primary/30")}
|
|
172
176
|
testId={testId}
|
|
173
177
|
>
|
|
174
|
-
<div className="text-xs text-muted-foreground"
|
|
178
|
+
<div className="text-xs text-muted-foreground" data-testid={labelTestId}>
|
|
179
|
+
{label}
|
|
180
|
+
</div>
|
|
175
181
|
<div
|
|
176
182
|
className={cn(
|
|
177
183
|
"mt-0.5 font-semibold tabular-nums",
|
|
178
184
|
TONE_VALUE[tone],
|
|
179
185
|
emphasize ? "text-lg" : "text-sm",
|
|
180
186
|
)}
|
|
187
|
+
data-testid={valueTestId}
|
|
181
188
|
>
|
|
182
189
|
{value}
|
|
183
190
|
</div>
|