@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.
|
|
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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
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": {
|
package/src/__tests__/qn.test.ts
CHANGED
|
@@ -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.
|
|
73
|
-
// set, the edit screen loads the existing record via the detail
|
|
74
|
-
//
|
|
75
|
-
//
|
|
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
|
+
}
|