@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.43.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 "Preset"-Zeile.
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 Preset-Fallback bleibt", async () => {
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 Preset-Zeile.
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("Preset");
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 Preset-Wert, nicht als
94
- // eigene "Default"-Ebene.
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: Preset zeigt den effektiven Wert, nicht die Quelle", async () => {
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("Preset");
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 + Vorgabe-Disclosure erschienen doppelt (vor und nach dem
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 Vorgabe-Disclosure erscheinen genau einmal pro Feld", async () => {
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 registrierte header-Component über der Tabelle (entityId null)", () => {
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
- // Neutral "Vorgabe" statt System/Override/Computedder Screen-
106
- // Scope kann diese Ebenen weder setzen noch zurücksetzen, die
107
- // Quelle ist für ihn Operator-Interna.
108
- badgeLabelKey: "kumiko.config.cascade.preset",
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
  }
@@ -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 — entscheidet zwischen Inline-Buttons (≤2 actions) und
570
- // Kebab-Dropdown (>2). isVisible-Filter wird hier ausgeführt; eine action
571
- // die für eine Row unsichtbar ist, kommt nicht in den Render. Wenn alle
572
- // Actions für eine Row hidden sind, bleibt die Cell leer (keine
573
- // Phantom-Spalte).
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">