@cosmicdrift/kumiko-renderer-web 0.205.0 → 0.206.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.205.0",
3
+ "version": "0.206.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.205.0",
20
- "@cosmicdrift/kumiko-headless": "0.205.0",
21
- "@cosmicdrift/kumiko-renderer": "0.205.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.206.0",
20
+ "@cosmicdrift/kumiko-headless": "0.206.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.206.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",
@@ -4,6 +4,7 @@ import type {
4
4
  EntityDefinition,
5
5
  EntityEditScreenDefinition,
6
6
  EntityListScreenDefinition,
7
+ ScreenDefinition,
7
8
  } from "@cosmicdrift/kumiko-framework/ui-types";
8
9
  import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
9
10
  import type {
@@ -14,7 +15,7 @@ import type {
14
15
  NavApi,
15
16
  } from "@cosmicdrift/kumiko-renderer";
16
17
  import { createStaticLocaleResolver, useContentEditor } from "@cosmicdrift/kumiko-renderer";
17
- import { act, screen, waitFor } from "@testing-library/react";
18
+ import { act, fireEvent, screen, waitFor } from "@testing-library/react";
18
19
  import type { ReactNode } from "react";
19
20
  import type { ClientFeatureDefinition } from "../app/client-plugin";
20
21
  import { type CreateKumikoAppOptions, createKumikoApp } from "../app/create-app";
@@ -594,4 +595,92 @@ describe("createKumikoApp", () => {
594
595
  // Und definitiv NICHT der Edit-Screen (der wäre die Default-Landing).
595
596
  expect(screen.queryByTestId("render-edit-form")).toBeNull();
596
597
  });
598
+
599
+ // fw#2164: default row-click target. A screen declaring `detailFor` for the
600
+ // entity is the "Akte" — the click should land there without anyone
601
+ // wiring rowActions/onRowClick, and it should win over both of the old
602
+ // defaults (entityEdit-search, app-wide onRowClick).
603
+ describe("default row click (fw#2164)", () => {
604
+ const taskDetailScreen: ScreenDefinition = {
605
+ id: "task-detail",
606
+ type: "custom",
607
+ detailFor: "task",
608
+ renderer: { react: { __component: "TaskDetail" } },
609
+ };
610
+
611
+ function TaskDetailStub(): ReactNode {
612
+ return <span data-testid="task-detail-mounted" />;
613
+ }
614
+
615
+ function makeRowDispatcher(): Dispatcher {
616
+ return createMockDispatcher({
617
+ query: (async () => ({
618
+ isSuccess: true,
619
+ data: { rows: [{ id: "r1", title: "Alpha" }], nextCursor: null },
620
+ })) as unknown as Dispatcher["query"],
621
+ });
622
+ }
623
+
624
+ test("declared detailFor screen wins over the entityEdit-search fallback", async () => {
625
+ window.history.replaceState(null, "", "/task-list");
626
+ mountRoot();
627
+ const schema: FeatureSchema = {
628
+ featureName: "tasks",
629
+ entities: { task: taskEntity },
630
+ screens: [editScreen, listScreen, taskDetailScreen],
631
+ };
632
+ await mountApp({
633
+ schema,
634
+ dispatcher: makeRowDispatcher(),
635
+ screenQn: "tasks:screen:task-list",
636
+ clientFeatures: [{ name: "tasks", components: { "task-detail": TaskDetailStub } }],
637
+ });
638
+ await waitFor(() => expect(screen.getByTestId("row-r1")).toBeTruthy());
639
+
640
+ fireEvent.click(screen.getByTestId("row-r1"));
641
+
642
+ await waitFor(() => expect(window.location.pathname).toBe("/task-detail/r1"));
643
+ expect(await screen.findByTestId("task-detail-mounted")).toBeTruthy();
644
+ });
645
+
646
+ test("no detailFor screen → unchanged fallback to the entity's entityEdit screen", async () => {
647
+ window.history.replaceState(null, "", "/task-list");
648
+ mountRoot();
649
+ await mountApp({
650
+ schema: baseSchema,
651
+ dispatcher: makeRowDispatcher(),
652
+ screenQn: "tasks:screen:task-list",
653
+ });
654
+ await waitFor(() => expect(screen.getByTestId("row-r1")).toBeTruthy());
655
+
656
+ fireEvent.click(screen.getByTestId("row-r1"));
657
+
658
+ await waitFor(() => expect(window.location.pathname).toBe("/task-edit/r1"));
659
+ });
660
+
661
+ test("declared detailFor screen wins even over an app-wide onRowClick option", async () => {
662
+ window.history.replaceState(null, "", "/task-list");
663
+ mountRoot();
664
+ const schema: FeatureSchema = {
665
+ featureName: "tasks",
666
+ entities: { task: taskEntity },
667
+ screens: [listScreen, taskDetailScreen],
668
+ };
669
+ const onRowClick = () => {
670
+ throw new Error("onRowClick must not fire when a detailFor screen resolves");
671
+ };
672
+ await mountApp({
673
+ schema,
674
+ dispatcher: makeRowDispatcher(),
675
+ screenQn: "tasks:screen:task-list",
676
+ onRowClick,
677
+ clientFeatures: [{ name: "tasks", components: { "task-detail": TaskDetailStub } }],
678
+ });
679
+ await waitFor(() => expect(screen.getByTestId("row-r1")).toBeTruthy());
680
+
681
+ fireEvent.click(screen.getByTestId("row-r1"));
682
+
683
+ await waitFor(() => expect(window.location.pathname).toBe("/task-detail/r1"));
684
+ });
685
+ });
597
686
  });
@@ -19,6 +19,7 @@ import {
19
19
  DraftStorageProvider,
20
20
  ExtensionSectionsProvider,
21
21
  type FeatureSchema,
22
+ hasDetailScreen,
22
23
  KumikoScreen,
23
24
  kumikoDefaultTranslations,
24
25
  LiveEventsProvider,
@@ -143,6 +144,8 @@ export type CreateKumikoAppOptions = {
143
144
  readonly draftStorage?: DraftStorage;
144
145
  readonly screenQn?: string;
145
146
  readonly translate?: Translate;
147
+ /** Row-click fallback for entities with no declared `detailFor` screen
148
+ * (fw#2164) — a `detailFor` screen always wins over this option. */
146
149
  readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
147
150
  /** App-Shell. Bekommt das resolved `schema` als Prop — so können
148
151
  * AppShell-Komponenten an WorkspaceShell/DefaultAppShell durchreichen
@@ -568,19 +571,31 @@ function RoutedScreen({
568
571
  const effectiveOnRowClick = useMemo<
569
572
  ((row: ListRowViewModel, entityName: string) => void) | undefined
570
573
  >(() => {
571
- if (onRowClick !== undefined) return onRowClick;
572
574
  return (row, entityName) => {
573
- // Search for the edit screen for the entity across all features —
574
- // in a single-feature setup that's the same feature as the active
575
- // one, in multi-feature the edit could theoretically live in a
576
- // different feature (one that shares the entity).
575
+ // Precedence (fw#2164): detailFor screen, then onRowClick, then the
576
+ // entityEdit-search fallback below. An explicit rowActions
577
+ // rowClick:true already wins over all of this kumiko-screen.tsx
578
+ // handles it before onRowClick is ever called.
579
+ if (hasDetailScreen(app.features, entityName)) {
580
+ nav.navigate({ entity: entityName, id: row.id });
581
+ return;
582
+ }
583
+ if (onRowClick !== undefined) {
584
+ onRowClick(row, entityName);
585
+ return;
586
+ }
587
+ // No declared detail screen and no app-wide onRowClick — search for
588
+ // the edit screen for the entity across all features — in a
589
+ // single-feature setup that's the same feature as the active one, in
590
+ // multi-feature the edit could theoretically live in a different
591
+ // feature (one that shares the entity).
577
592
  for (const f of app.features) {
578
593
  const editScreen = f.screens.find(
579
594
  (s) => s.type === "entityEdit" && s.entity === entityName,
580
595
  );
581
596
  if (editScreen) {
582
- // editScreen.id is QN form (registry-stamped); nav.navigate
583
- // expects the short form. Otherwise the URL gets double-qualified.
597
+ // editScreen.id is already short form; lastSegment is a no-op
598
+ // safety net here, kept for symmetry with the other call sites.
584
599
  nav.navigate({ screenId: lastSegment(editScreen.id), entityId: row.id });
585
600
  return;
586
601
  }