@cosmicdrift/kumiko-renderer 0.149.1 → 0.150.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",
3
- "version": "0.149.1",
3
+ "version": "0.150.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.149.1",
19
- "@cosmicdrift/kumiko-headless": "0.149.1",
18
+ "@cosmicdrift/kumiko-framework": "0.150.0",
19
+ "@cosmicdrift/kumiko-headless": "0.150.0",
20
20
  "react": "^19.2.6"
21
21
  },
22
22
  "devDependencies": {
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { toKebab as serverToKebab } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { lastSegment, toKebab } from "../app/qn";
3
4
 
4
5
  describe("lastSegment", () => {
@@ -52,4 +53,31 @@ describe("toKebab", () => {
52
53
  test("preserves colon segments", () => {
53
54
  expect(toKebab("driverModel:list")).toBe("driver-model:list");
54
55
  });
56
+
57
+ test("consecutive uppercase (acronym boundary)", () => {
58
+ expect(toKebab("SSEBroadcast")).toBe("sse-broadcast");
59
+ });
60
+
61
+ test("dot separators become dashes", () => {
62
+ expect(toKebab("billing-period.create")).toBe("billing-period-create");
63
+ });
64
+
65
+ // Drift guard: this file is a byte-identical copy of the server's toKebab
66
+ // (packages/framework/src/engine/qualified-name.ts) kept in sync only by
67
+ // convention/comment, not by import (avoids pulling server deps into the
68
+ // browser bundle). Table-driven against the server's own doc examples so a
69
+ // future edit to either copy that breaks parity fails loudly here.
70
+ test("matches the server implementation for its documented examples", () => {
71
+ const cases = [
72
+ "task.create",
73
+ "ticketAssigned",
74
+ "billing-period.create",
75
+ "monthlyReport",
76
+ "SSEBroadcast",
77
+ "driverModel:list",
78
+ ];
79
+ for (const input of cases) {
80
+ expect(toKebab(input)).toBe(serverToKebab(input));
81
+ }
82
+ });
55
83
  });
@@ -6,6 +6,7 @@ import type {
6
6
  EntityDefinition,
7
7
  EntityEditScreenDefinition,
8
8
  EntityListScreenDefinition,
9
+ ProjectionDetailScreenDefinition,
9
10
  ProjectionListScreenDefinition,
10
11
  RowAction,
11
12
  RowActionNavigate,
@@ -38,6 +39,10 @@ import { useCustomScreenComponent } from "./custom-screens";
38
39
  import { useDashboardBody } from "./dashboard-body";
39
40
  import type { FeatureSchema } from "./feature-schema";
40
41
  import { useNav } from "./nav";
42
+ import {
43
+ synthesizeProjectionDetailEntity,
44
+ synthesizeProjectionDetailScreen,
45
+ } from "./projection-detail-shim";
41
46
  import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim";
42
47
  import { lastSegment, toKebab } from "./qn";
43
48
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
@@ -69,10 +74,11 @@ export type KumikoScreenProps = {
69
74
  readonly schema: FeatureSchema;
70
75
  readonly qn: string;
71
76
  readonly translate?: Translate;
72
- // Optional entity-id. Only meaningful for entityEdit screens — when
73
- // set, the edit screen loads the existing record via the detail
74
- // query and submits an update command (`write:<entity>:update`)
75
- // instead of a create. For entityList/custom screens it's ignored.
77
+ // Optional entity-id / row-id. Meaningful for entityEdit screens — when
78
+ // set, the edit screen loads the existing record via the detail query and
79
+ // submits an update command (`write:<entity>:update`) instead of a create.
80
+ // Also required for projectionDetail screens (which row to fetch — no
81
+ // create mode exists there). For entityList/custom screens it's ignored.
76
82
  readonly entityId?: string;
77
83
  // Fires when the user clicks a row on an entityList screen. The
78
84
  // second argument is the screen's entity name, threaded through so
@@ -152,6 +158,15 @@ export function KumikoScreen({
152
158
  {...(onRowClick !== undefined && { onRowClick })}
153
159
  />
154
160
  );
161
+ case "projectionDetail":
162
+ return (
163
+ <ProjectionDetailBody
164
+ schema={schema}
165
+ screen={screen}
166
+ translate={translate}
167
+ {...(entityId !== undefined && { entityId })}
168
+ />
169
+ );
155
170
  case "dashboard":
156
171
  return <DashboardScreenBody screen={screen} translate={translate} />;
157
172
  case "actionForm":
@@ -1239,6 +1254,85 @@ function ProjectionListBody({
1239
1254
  );
1240
1255
  }
1241
1256
 
1257
+ // Projection-Detail-Body — read-only single-row inspector über eine explizite
1258
+ // Query statt einer Entity (siehe projection-detail-shim.ts für die Schulden-
1259
+ // Doku). Fetcht selbst über `screen.query` + `idParam` (analog zu
1260
+ // EntityEditUpdateBody, das die Query-QN aus der Entity-Convention ableitet —
1261
+ // hier ist die QN Author-explizit, wie bei ProjectionListBody). Rendert über
1262
+ // RenderEdit MIT customSubmit-No-Op statt writeCommand: hasEditableSection()
1263
+ // blendet den Save-Button aus (jedes Feld ist readOnly), und selbst ein
1264
+ // natives Form-Submit (Enter-Keypress) würde ohne customSubmit gegen
1265
+ // controller.submit() ohne submit-config throwen — der No-Op macht diesen
1266
+ // Pfad harmlos statt ihn dem Zufall zu überlassen.
1267
+ function ProjectionDetailBody({
1268
+ schema,
1269
+ screen,
1270
+ translate,
1271
+ entityId,
1272
+ }: {
1273
+ readonly schema: FeatureSchema;
1274
+ readonly screen: ProjectionDetailScreenDefinition;
1275
+ readonly translate?: Translate;
1276
+ readonly entityId?: string;
1277
+ }): ReactNode {
1278
+ const { Banner, Text } = usePrimitives();
1279
+ const nav = useNav();
1280
+ const idParam = screen.idParam ?? "id";
1281
+ const entity = useMemo(() => synthesizeProjectionDetailEntity(screen.layout), [screen.layout]);
1282
+ const detailScreen = useMemo(() => synthesizeProjectionDetailScreen(screen), [screen]);
1283
+ const detailQuery = useQuery<Readonly<Record<string, unknown>>>(
1284
+ screen.query,
1285
+ entityId !== undefined ? { [idParam]: entityId } : {},
1286
+ );
1287
+
1288
+ const navigateToList = useCallback(() => {
1289
+ if (screen.listScreenId === undefined) return;
1290
+ nav.navigate({ screenId: screen.listScreenId });
1291
+ }, [nav, screen.listScreenId]);
1292
+
1293
+ if (entityId === undefined) {
1294
+ return (
1295
+ <Banner padded variant="error" testId="kumiko-screen-projection-detail-missing-id">
1296
+ Screen <Text variant="code">{screen.id}</Text> (projectionDetail) needs a row id in the path
1297
+ — open it from a row action.
1298
+ </Banner>
1299
+ );
1300
+ }
1301
+ if (detailQuery.loading && detailQuery.data === null) {
1302
+ return (
1303
+ <Banner padded variant="loading" testId="kumiko-screen-loading">
1304
+ Loading…
1305
+ </Banner>
1306
+ );
1307
+ }
1308
+ if (detailQuery.error) {
1309
+ return (
1310
+ <Banner padded variant="error" testId="kumiko-screen-error">
1311
+ {detailQuery.error.i18nKey}
1312
+ </Banner>
1313
+ );
1314
+ }
1315
+ const record = detailQuery.data;
1316
+ if (!record) {
1317
+ return (
1318
+ <Banner padded variant="error" testId="kumiko-screen-record-missing">
1319
+ Record <Text variant="code">{entityId}</Text> not found.
1320
+ </Banner>
1321
+ );
1322
+ }
1323
+ return (
1324
+ <RenderEdit
1325
+ screen={detailScreen}
1326
+ entity={entity}
1327
+ featureName={schema.featureName}
1328
+ initial={record as FormValues}
1329
+ entityId={entityId}
1330
+ customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
1331
+ onCancel={screen.listScreenId !== undefined ? navigateToList : undefined}
1332
+ {...(translate !== undefined && { translate })}
1333
+ />
1334
+ );
1335
+ }
1242
1336
  // ---- actionForm (Tier 2.7d) ----
1243
1337
 
1244
1338
  // Action-Form-Body — non-CRUD Write-Handler-driven Form. Re-uses
@@ -0,0 +1,70 @@
1
+ // Shim für ProjectionDetail-Renderer (analog zu projection-list-shim.ts /
2
+ // config-edit-shim.ts).
3
+ //
4
+ // RenderEdit verlangt ein `entity: EntityDefinition` + ein
5
+ // `screen: EntityEditScreenDefinition`-Pair, nutzt davon aber nur
6
+ // `entity.fields` (Feld-Typ für den Input-Renderer) und `screen.layout`
7
+ // (welche Felder in welchen Sections). Ein projectionDetail-Screen ist
8
+ // query-getrieben und hat KEINE Entity — die zwei Helper hier shapen den
9
+ // Input ad-hoc um, damit RenderEdit reused werden kann.
10
+ //
11
+ // Der strukturelle Read-Only-Beweis liegt in synthesizeProjectionDetailScreen:
12
+ // JEDES Feld wird hart auf readOnly:true gesetzt (nicht nur was der Author im
13
+ // Layout gesetzt hat) — hasEditableSection() liest genau dieses Flag und
14
+ // blendet den Save-Button aus, wenn kein Feld editierbar ist. Der Author kann
15
+ // diese Garantie nicht versehentlich umgehen.
16
+ //
17
+ // Selbe Schulden-Reservation wie bei den anderen Shims: greift RenderEdit
18
+ // künftig auf weitere EntityDefinition-Felder (transitions, idType) zu, oder
19
+ // cross-referenziert ein Boot-Validator die schema.entities-Map, brechen die
20
+ // Type-Lies hier silent — dann ist Zeit für eine echte RenderProjectionDetail-
21
+ // Komponente.
22
+
23
+ import type {
24
+ EditLayout,
25
+ EntityDefinition,
26
+ EntityEditScreenDefinition,
27
+ ProjectionDetailScreenDefinition,
28
+ } from "@cosmicdrift/kumiko-framework/ui-types";
29
+ import { isExtensionEditSection, normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
30
+
31
+ const PROJECTION_DETAIL_PSEUDO_ENTITY = "__projection-detail__";
32
+
33
+ /** Minimale EntityDefinition aus den Layout-Feldern: jedes Feld ein Text-
34
+ * Feld — computeEditViewModel liest nur `fields[<f>].type`, Text reicht für
35
+ * eine reine Anzeige (kein Select/Number-spezifisches Rendering nötig). */
36
+ export function synthesizeProjectionDetailEntity(layout: EditLayout): EntityDefinition {
37
+ const fields: Record<string, { type: "text" }> = {};
38
+ for (const section of layout.sections) {
39
+ if (isExtensionEditSection(section)) continue; // rejected at boot, defensive here
40
+ for (const spec of section.fields) {
41
+ fields[normalizeEditField(spec).field] = { type: "text" };
42
+ }
43
+ }
44
+ return { fields } as unknown as EntityDefinition;
45
+ }
46
+
47
+ /** Wandelt ein ProjectionDetailScreenDefinition in die EntityEditScreen-Shape
48
+ * die RenderEdit erwartet — mit jedem Feld hart auf readOnly:true erzwungen. */
49
+ export function synthesizeProjectionDetailScreen(
50
+ screen: ProjectionDetailScreenDefinition,
51
+ ): EntityEditScreenDefinition {
52
+ const sections = screen.layout.sections.map((section) => {
53
+ if (isExtensionEditSection(section)) return section; // rejected at boot, defensive here
54
+ return {
55
+ ...section,
56
+ fields: section.fields.map((spec) => ({ ...normalizeEditField(spec), readOnly: true })),
57
+ };
58
+ });
59
+ return {
60
+ id: screen.id,
61
+ type: "entityEdit",
62
+ entity: PROJECTION_DETAIL_PSEUDO_ENTITY,
63
+ layout: { sections },
64
+ allowCreate: false,
65
+ allowDelete: false,
66
+ ...(screen.fieldLabels !== undefined && { fieldLabels: screen.fieldLabels }),
67
+ ...(screen.slots !== undefined && { slots: screen.slots }),
68
+ ...(screen.access !== undefined && { access: screen.access }),
69
+ };
70
+ }