@cosmicdrift/kumiko-renderer-web 0.43.0 → 0.45.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 +1 -1
- package/src/__tests__/config-cascade.test.tsx +9 -9
- package/src/__tests__/config-edit.test.tsx +2 -2
- package/src/__tests__/primitives.test.tsx +35 -0
- package/src/__tests__/render-edit.test.tsx +81 -0
- package/src/__tests__/render-list.test.tsx +6 -1
- package/src/components/config-cascade.tsx +7 -4
- package/src/primitives/index.tsx +26 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.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>",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// obwohl er nur die Tenant-Ebene beeinflussen kann. Jetzt: Keys leben
|
|
6
6
|
// als kumiko.config.* in kumikoDefaultTranslations, und Nicht-System-
|
|
7
7
|
// Screens kollabieren alles oberhalb des Screen-Scopes zu EINER
|
|
8
|
-
// neutralen "
|
|
8
|
+
// neutralen "Standard"-Zeile (Bug-Bash 3 #11: ein durchgängiger Begriff).
|
|
9
9
|
|
|
10
10
|
import { describe, expect, test } from "bun:test";
|
|
11
11
|
import type { ConfigCascade, ConfigCascadeLevel } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -78,31 +78,31 @@ describe("ConfigCascadeView — i18n (Bug 7)", () => {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
describe("ConfigCascadeView — Scope-Filter (Bug 8)", () => {
|
|
81
|
-
test("screenScope=tenant: Operator-Ebenen sind unsichtbar, EIN
|
|
81
|
+
test("screenScope=tenant: Operator-Ebenen sind unsichtbar, EIN neutraler Standard-Fallback bleibt", async () => {
|
|
82
82
|
const user = userEvent.setup();
|
|
83
83
|
const view = render(<ConfigCascadeView cascade={tenantCascade()} screenScope="tenant" />);
|
|
84
84
|
await user.click(screen.getByRole("button"));
|
|
85
85
|
|
|
86
|
-
// Sichtbar: Tenant-Zeile + genau eine neutrale
|
|
86
|
+
// Sichtbar: Tenant-Zeile + genau eine neutrale "Standard"-Zeile (Bug-Bash 3
|
|
87
|
+
// #11: ein durchgängiger Begriff, EN-Locale → "Default").
|
|
87
88
|
expect(view.container.textContent).toContain("Tenant");
|
|
88
|
-
expect(view.container.textContent).toContain("
|
|
89
|
+
expect(view.container.textContent).toContain("Default");
|
|
89
90
|
// Unsichtbar: alles was nur der Operator steuert.
|
|
90
91
|
expect(view.container.textContent).not.toContain("System");
|
|
91
92
|
expect(view.container.textContent).not.toContain("App override");
|
|
92
93
|
expect(view.container.textContent).not.toContain("Computed");
|
|
93
|
-
// Der deklarierte Default erscheint als
|
|
94
|
-
// eigene
|
|
94
|
+
// Der deklarierte Default erscheint als Wert der neutralen Standard-Zeile,
|
|
95
|
+
// nicht als eigene Operator-Ebene.
|
|
95
96
|
expect(view.container.textContent).toContain("fallback");
|
|
96
|
-
expect(view.container.textContent).not.toContain("Default");
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
test("screenScope=tenant mit aktivem System-Wert:
|
|
99
|
+
test("screenScope=tenant mit aktivem System-Wert: Standard-Zeile zeigt den effektiven Wert, nicht die Quelle", async () => {
|
|
100
100
|
const user = userEvent.setup();
|
|
101
101
|
const view = render(
|
|
102
102
|
<ConfigCascadeView cascade={tenantCascade({ systemActive: true })} screenScope="tenant" />,
|
|
103
103
|
);
|
|
104
104
|
// Collapsed-Header leakt die Operator-Quelle nicht …
|
|
105
|
-
expect(view.container.textContent).toContain("
|
|
105
|
+
expect(view.container.textContent).toContain("Default");
|
|
106
106
|
expect(view.container.textContent).toContain("system-smtp");
|
|
107
107
|
expect(view.container.textContent).not.toContain("System");
|
|
108
108
|
|
|
@@ -169,9 +169,9 @@ describe("KumikoScreen / configEdit", () => {
|
|
|
169
169
|
|
|
170
170
|
// Regression Bug-Bash-2 (2026-06-08): RenderEdit reichte denselben
|
|
171
171
|
// Appendix-Callback als labelAppendix UND fieldAppendix durch —
|
|
172
|
-
// Badge +
|
|
172
|
+
// Badge + Standard-Disclosure erschienen doppelt (vor und nach dem
|
|
173
173
|
// Input) auf jedem Settings-Screen mit Default-Werten.
|
|
174
|
-
test("Source-Badge und
|
|
174
|
+
test("Source-Badge und Standard-Disclosure erscheinen genau einmal pro Feld", async () => {
|
|
175
175
|
const dispatcher: Dispatcher = createMockDispatcher({
|
|
176
176
|
query: (async (qn: string) => {
|
|
177
177
|
if (qn === "config:query:cascade") {
|
|
@@ -569,6 +569,41 @@ describe("DataTable", () => {
|
|
|
569
569
|
expect(screen.queryByTestId("row-r1-action-a")).toBeNull();
|
|
570
570
|
});
|
|
571
571
|
|
|
572
|
+
test("rowActionMode='inline': IMMER Inline-Buttons, kein Kebab auch bei >2 (#9)", () => {
|
|
573
|
+
render(
|
|
574
|
+
<DataTable
|
|
575
|
+
columns={cols}
|
|
576
|
+
rows={rows}
|
|
577
|
+
testId="dt"
|
|
578
|
+
rowActionMode="inline"
|
|
579
|
+
rowActions={[
|
|
580
|
+
{ id: "a", label: "A", onTrigger: mock() },
|
|
581
|
+
{ id: "b", label: "B", onTrigger: mock() },
|
|
582
|
+
{ id: "c", label: "C", onTrigger: mock() },
|
|
583
|
+
]}
|
|
584
|
+
/>,
|
|
585
|
+
);
|
|
586
|
+
expect(screen.queryByTestId("row-r1-actions-menu")).toBeNull();
|
|
587
|
+
expect(screen.queryByTestId("row-r1-action-a")).not.toBeNull();
|
|
588
|
+
expect(screen.queryByTestId("row-r1-action-b")).not.toBeNull();
|
|
589
|
+
expect(screen.queryByTestId("row-r1-action-c")).not.toBeNull();
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("rowActionMode='inline': Buttons linksbündig + w-full (alignt über Rows, #8)", () => {
|
|
593
|
+
render(
|
|
594
|
+
<DataTable
|
|
595
|
+
columns={cols}
|
|
596
|
+
rows={rows}
|
|
597
|
+
testId="dt"
|
|
598
|
+
rowActionMode="inline"
|
|
599
|
+
rowActions={[{ id: "edit", label: "Edit", onTrigger: mock() }]}
|
|
600
|
+
/>,
|
|
601
|
+
);
|
|
602
|
+
const group = screen.getByTestId("row-r1-action-edit").parentElement;
|
|
603
|
+
expect(group?.className).toContain("justify-start");
|
|
604
|
+
expect(group?.className).toContain("w-full");
|
|
605
|
+
});
|
|
606
|
+
|
|
572
607
|
test("Kebab: Click auf Trigger öffnet Dropdown mit allen Items", async () => {
|
|
573
608
|
const user = userEvent.setup();
|
|
574
609
|
render(
|
|
@@ -7,8 +7,11 @@ import type { Dispatcher, SubmitResult } from "@cosmicdrift/kumiko-headless";
|
|
|
7
7
|
import {
|
|
8
8
|
DispatcherProvider,
|
|
9
9
|
ExtensionSectionsProvider,
|
|
10
|
+
type ExtensionSubmitContext,
|
|
10
11
|
RenderEdit,
|
|
12
|
+
useExtensionFormSubmit,
|
|
11
13
|
} from "@cosmicdrift/kumiko-renderer";
|
|
14
|
+
import { useState } from "react";
|
|
12
15
|
import { act, createMockDispatcher, fireEvent, render, screen } from "./test-utils";
|
|
13
16
|
|
|
14
17
|
const orderEntity = {
|
|
@@ -320,3 +323,81 @@ describe("RenderEdit", () => {
|
|
|
320
323
|
expect(placeholder.textContent).toContain("UnregisteredComp");
|
|
321
324
|
});
|
|
322
325
|
});
|
|
326
|
+
|
|
327
|
+
describe("RenderEdit — composed extension save (Bug-Bash 3 #1)", () => {
|
|
328
|
+
const screenDef: EntityEditScreenDefinition = {
|
|
329
|
+
id: "orders:screen:order-edit",
|
|
330
|
+
type: "entityEdit",
|
|
331
|
+
entity: "order",
|
|
332
|
+
layout: {
|
|
333
|
+
sections: [
|
|
334
|
+
{ title: "Basics", columns: 2, fields: [{ field: "title", span: 2 }] },
|
|
335
|
+
{
|
|
336
|
+
kind: "extension",
|
|
337
|
+
title: "Custom Fields",
|
|
338
|
+
component: { react: { __component: "ComposedCF" } },
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
function renderWith(
|
|
345
|
+
submitSpy: (ctx: ExtensionSubmitContext) => void,
|
|
346
|
+
writeSpy: Dispatcher["write"],
|
|
347
|
+
): void {
|
|
348
|
+
const ComposedCF = (_: { entityName: string; entityId: string | null }) => {
|
|
349
|
+
const [touched, setTouched] = useState(false);
|
|
350
|
+
useExtensionFormSubmit({
|
|
351
|
+
dirty: touched,
|
|
352
|
+
onSubmit: async (ctx) => {
|
|
353
|
+
submitSpy(ctx);
|
|
354
|
+
return { isSuccess: true as const };
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
return (
|
|
358
|
+
<button type="button" data-testid="composed-touch" onClick={() => setTouched(true)}>
|
|
359
|
+
touch
|
|
360
|
+
</button>
|
|
361
|
+
);
|
|
362
|
+
};
|
|
363
|
+
render(
|
|
364
|
+
<DispatcherProvider dispatcher={makeDispatcher(writeSpy)}>
|
|
365
|
+
<ExtensionSectionsProvider value={{ ComposedCF }}>
|
|
366
|
+
<RenderEdit<TestValues>
|
|
367
|
+
screen={screenDef}
|
|
368
|
+
entity={orderEntity}
|
|
369
|
+
featureName="orders"
|
|
370
|
+
initial={{ title: "Existing", count: 0, isUrgent: false } as TestValues}
|
|
371
|
+
entityId="order-99"
|
|
372
|
+
writeCommand="order:update"
|
|
373
|
+
/>
|
|
374
|
+
</ExtensionSectionsProvider>
|
|
375
|
+
</DispatcherProvider>,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
test("Section-dirty aktiviert den Haupt-Save; CF-only-Save ruft den Handler ohne Main-Write", async () => {
|
|
380
|
+
const submitSpy = mock();
|
|
381
|
+
const writeSpy = mock(async () => ({
|
|
382
|
+
isSuccess: true,
|
|
383
|
+
data: { id: "order-99" },
|
|
384
|
+
})) as unknown as Dispatcher["write"];
|
|
385
|
+
renderWith(submitSpy, writeSpy);
|
|
386
|
+
|
|
387
|
+
// Main unverändert + Section nicht dirty → Save disabled.
|
|
388
|
+
expect((screen.getByTestId("render-edit-submit") as HTMLButtonElement).disabled).toBe(true);
|
|
389
|
+
|
|
390
|
+
// Section dirty machen → Save enabled (composed-dirty propagiert hoch).
|
|
391
|
+
act(() => {
|
|
392
|
+
fireEvent.click(screen.getByTestId("composed-touch"));
|
|
393
|
+
});
|
|
394
|
+
expect((screen.getByTestId("render-edit-submit") as HTMLButtonElement).disabled).toBe(false);
|
|
395
|
+
|
|
396
|
+
// Save → Section-Handler mit entityId; KEIN Main-Write (main unverändert).
|
|
397
|
+
await act(async () => {
|
|
398
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
399
|
+
});
|
|
400
|
+
expect(submitSpy).toHaveBeenCalledWith({ entityId: "order-99" });
|
|
401
|
+
expect(writeSpy).not.toHaveBeenCalled();
|
|
402
|
+
});
|
|
403
|
+
});
|
|
@@ -161,7 +161,7 @@ describe("RenderList — slots.header", () => {
|
|
|
161
161
|
slots: { header: { react: { __component: "list-cap-header" } } },
|
|
162
162
|
};
|
|
163
163
|
|
|
164
|
-
test("rendert die
|
|
164
|
+
test("rendert die header-Component in der Toolbar, nicht über dem Titel (#12)", () => {
|
|
165
165
|
render(
|
|
166
166
|
<ExtensionSectionsProvider value={{ "list-cap-header": ListHeader }}>
|
|
167
167
|
<RenderList screen={screenWithHeader} entity={taskEntity} rows={[]} featureName="tasks" />
|
|
@@ -171,6 +171,11 @@ describe("RenderList — slots.header", () => {
|
|
|
171
171
|
expect(header.textContent).toContain("header for task");
|
|
172
172
|
// Listen-Kontext → keine Row → entityId null.
|
|
173
173
|
expect(header.textContent).toContain("id=null");
|
|
174
|
+
// Placement-Regression (Bug-Bash 3 #12): der Header-Slot lebt IM
|
|
175
|
+
// Toolbar-Container (toolbarEnd), NICHT als loser Node über dem
|
|
176
|
+
// Screen-Titel.
|
|
177
|
+
const toolbar = screen.getByTestId("render-list-table-toolbar");
|
|
178
|
+
expect(toolbar.contains(header)).toBe(true);
|
|
174
179
|
});
|
|
175
180
|
|
|
176
181
|
test("ohne slots.header wird nichts gerendert (kein Crash)", () => {
|
|
@@ -102,10 +102,13 @@ function toDisplayLevels(
|
|
|
102
102
|
{
|
|
103
103
|
level: fallback,
|
|
104
104
|
badgeSource: "default",
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
105
|
+
// Ein durchgängiger Begriff "Standard" (DE) / "Default" (EN) — derselbe
|
|
106
|
+
// Key wie das Feld-Label-Badge (kumiko.config.source.default), damit
|
|
107
|
+
// Badge + Cascade-Disclosure NICHT zwei verschiedene Wörter zeigen
|
|
108
|
+
// (Bug-Bash 3 #11). Der Screen-Scope kann die Operator-Ebenen
|
|
109
|
+
// (System/Override/Computed) weder setzen noch zurücksetzen, deshalb
|
|
110
|
+
// erscheinen sie hier neutral als "Standard".
|
|
111
|
+
badgeLabelKey: "kumiko.config.source.default",
|
|
109
112
|
},
|
|
110
113
|
];
|
|
111
114
|
}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
|
|
|
12
12
|
import { applyFormatSpec } from "@cosmicdrift/kumiko-headless";
|
|
13
13
|
import type {
|
|
14
14
|
DataTableRowAction,
|
|
15
|
+
DataTableRowActionMode,
|
|
15
16
|
DataTableSort,
|
|
16
17
|
DataTableSortDir,
|
|
17
18
|
} from "@cosmicdrift/kumiko-renderer";
|
|
@@ -393,6 +394,7 @@ function DefaultDataTable({
|
|
|
393
394
|
loadingMore,
|
|
394
395
|
hasMore,
|
|
395
396
|
rowActions,
|
|
397
|
+
rowActionMode,
|
|
396
398
|
testId,
|
|
397
399
|
}: DataTableProps): ReactNode {
|
|
398
400
|
// Toolbar-Wrapper: gemeinsamer Container für Toolbar+Tabelle damit
|
|
@@ -410,7 +412,7 @@ function DefaultDataTable({
|
|
|
410
412
|
) : (
|
|
411
413
|
<div className="rounded-md border overflow-x-auto">
|
|
412
414
|
<table data-testid={testId} className="w-full caption-bottom text-sm">
|
|
413
|
-
{tableInner(columns, rows, onRowClick, sort, onSortChange, rowActions)}
|
|
415
|
+
{tableInner(columns, rows, onRowClick, sort, onSortChange, rowActions, rowActionMode)}
|
|
414
416
|
</table>
|
|
415
417
|
</div>
|
|
416
418
|
);
|
|
@@ -480,6 +482,7 @@ function tableInner(
|
|
|
480
482
|
sort?: DataTableProps["sort"],
|
|
481
483
|
onSortChange?: DataTableProps["onSortChange"],
|
|
482
484
|
rowActions?: DataTableProps["rowActions"],
|
|
485
|
+
rowActionMode?: DataTableProps["rowActionMode"],
|
|
483
486
|
): ReactNode {
|
|
484
487
|
const hasActions = rowActions !== undefined && rowActions.length > 0;
|
|
485
488
|
return (
|
|
@@ -556,7 +559,7 @@ function tableInner(
|
|
|
556
559
|
onClick={(e) => e.stopPropagation()}
|
|
557
560
|
onKeyDown={(e) => e.stopPropagation()}
|
|
558
561
|
>
|
|
559
|
-
<RowActionsCell row={row} actions={rowActions} />
|
|
562
|
+
<RowActionsCell row={row} actions={rowActions} mode={rowActionMode} />
|
|
560
563
|
</td>
|
|
561
564
|
)}
|
|
562
565
|
</tr>
|
|
@@ -566,20 +569,36 @@ function tableInner(
|
|
|
566
569
|
);
|
|
567
570
|
}
|
|
568
571
|
|
|
569
|
-
// RowActionsCell —
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
572
|
+
// RowActionsCell — rendert die Row-Actions je nach mode:
|
|
573
|
+
// - "adaptive" (Default): ≤2 sichtbare Actions inline (rechtsbündig),
|
|
574
|
+
// >2 als Kebab-Dropdown.
|
|
575
|
+
// - "inline": IMMER Inline-Buttons, linksbündig + full-width — auch bei
|
|
576
|
+
// >2 (kein Kebab). `w-full justify-start` heftet den ersten Button an
|
|
577
|
+
// die Spalten-Linkskante, damit er über alle Rows an derselben Position
|
|
578
|
+
// steht (sonst wandert er durch unterschiedlich breite Labels).
|
|
579
|
+
// isVisible-Filter wird hier ausgeführt; eine action die für eine Row
|
|
580
|
+
// unsichtbar ist, kommt nicht in den Render. Sind alle Actions hidden,
|
|
581
|
+
// bleibt die Cell leer (keine Phantom-Spalte).
|
|
574
582
|
function RowActionsCell({
|
|
575
583
|
row,
|
|
576
584
|
actions,
|
|
585
|
+
mode = "adaptive",
|
|
577
586
|
}: {
|
|
578
587
|
readonly row: ListRowViewModel;
|
|
579
588
|
readonly actions: readonly DataTableRowAction[];
|
|
589
|
+
readonly mode?: DataTableRowActionMode;
|
|
580
590
|
}): ReactNode {
|
|
581
591
|
const visible = actions.filter((a) => a.isVisible === undefined || a.isVisible(row));
|
|
582
592
|
if (visible.length === 0) return null;
|
|
593
|
+
if (mode === "inline") {
|
|
594
|
+
return (
|
|
595
|
+
<div className="flex w-full items-center gap-1 justify-start">
|
|
596
|
+
{visible.map((a) => (
|
|
597
|
+
<RowActionButton key={a.id} row={row} action={a} />
|
|
598
|
+
))}
|
|
599
|
+
</div>
|
|
600
|
+
);
|
|
601
|
+
}
|
|
583
602
|
if (visible.length <= 2) {
|
|
584
603
|
return (
|
|
585
604
|
<div className="inline-flex items-center gap-1 justify-end">
|