@cosmicdrift/kumiko-renderer 1.0.0 → 2.0.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 +5 -4
- package/src/__tests__/error-i18n-defaults.test.ts +13 -0
- package/src/__tests__/format-when.test.ts +12 -0
- package/src/__tests__/i18n.test.tsx +59 -0
- package/src/__tests__/qn.test.ts +44 -1
- package/src/__tests__/sort-by-accessor.test.ts +48 -0
- package/src/app/__tests__/config-edit-shim.test.ts +40 -0
- package/src/app/__tests__/screen-access-allows.test.ts +24 -0
- package/src/app/dashboard-body.tsx +32 -0
- package/src/app/extension-sections.tsx +11 -4
- package/src/app/kumiko-screen.tsx +408 -20
- package/src/app/projection-detail-shim.ts +72 -0
- package/src/app/projection-list-shim.ts +62 -0
- package/src/app/qn.ts +13 -0
- package/src/components/__tests__/render-field-app-locale.test.tsx +2 -0
- package/src/components/render-edit-logic.ts +8 -5
- package/src/components/render-edit.tsx +25 -2
- package/src/components/render-field.tsx +2 -1
- package/src/components/render-list.tsx +24 -2
- package/src/context/user-roles-context.tsx +27 -0
- package/src/format-when.ts +11 -0
- package/src/hooks/__tests__/use-ai-text.test.tsx +177 -0
- package/src/hooks/__tests__/use-disclosure.test.tsx +25 -0
- package/src/hooks/__tests__/use-mutation.test.tsx +77 -0
- package/src/hooks/__tests__/use-stream-handler.test.tsx +107 -0
- package/src/hooks/use-ai-text.ts +172 -0
- package/src/hooks/use-disclosure.ts +20 -0
- package/src/hooks/use-mutation.ts +61 -0
- package/src/hooks/use-query.ts +5 -2
- package/src/hooks/use-reference-lookup.ts +2 -1
- package/src/hooks/use-stream-handler.ts +134 -0
- package/src/i18n-defaults.ts +56 -0
- package/src/i18n.tsx +72 -31
- package/src/index.ts +31 -0
- package/src/primitives.tsx +70 -4
- package/src/sort-by-accessor.ts +20 -0
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { ConfigCascade } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import type {
|
|
3
|
+
AccessRule,
|
|
3
4
|
ActionFormScreenDefinition,
|
|
4
5
|
ConfigEditScreenDefinition,
|
|
6
|
+
DashboardScreenDefinition,
|
|
5
7
|
EntityDefinition,
|
|
6
8
|
EntityEditScreenDefinition,
|
|
7
9
|
EntityListScreenDefinition,
|
|
10
|
+
ProjectionDetailScreenDefinition,
|
|
11
|
+
ProjectionListScreenDefinition,
|
|
8
12
|
RowAction,
|
|
9
13
|
RowActionNavigate,
|
|
10
14
|
RowActionWriteHandler,
|
|
@@ -26,6 +30,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
|
|
|
26
30
|
import { RenderEdit } from "../components/render-edit";
|
|
27
31
|
import { RenderList, type ToolbarActionButton } from "../components/render-list";
|
|
28
32
|
import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
|
|
33
|
+
import { useUserRoles } from "../context/user-roles-context";
|
|
29
34
|
import { useListUrlState } from "../hooks/use-list-url-state";
|
|
30
35
|
import { useQuery } from "../hooks/use-query";
|
|
31
36
|
import { useTranslation } from "../i18n";
|
|
@@ -33,9 +38,15 @@ import { type DataTableFacet, type DataTableRowAction, usePrimitives } from "../
|
|
|
33
38
|
import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim";
|
|
34
39
|
import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim";
|
|
35
40
|
import { useCustomScreenComponent } from "./custom-screens";
|
|
41
|
+
import { useDashboardBody } from "./dashboard-body";
|
|
36
42
|
import type { FeatureSchema } from "./feature-schema";
|
|
37
43
|
import { useNav } from "./nav";
|
|
38
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
synthesizeProjectionDetailEntity,
|
|
46
|
+
synthesizeProjectionDetailScreen,
|
|
47
|
+
} from "./projection-detail-shim";
|
|
48
|
+
import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim";
|
|
49
|
+
import { lastSegment, toKebab } from "./qn";
|
|
39
50
|
import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
|
|
40
51
|
|
|
41
52
|
function evalRowExtractor(
|
|
@@ -48,6 +59,10 @@ function evalRowExtractor(
|
|
|
48
59
|
return Object.fromEntries(Object.entries(extractor.map).map(([to, from]) => [to, row[from]]));
|
|
49
60
|
}
|
|
50
61
|
|
|
62
|
+
function isWriteHandlerRowAction(action: RowAction): action is RowActionWriteHandler {
|
|
63
|
+
return action.kind === "writeHandler" || action.kind === undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
51
66
|
// KumikoScreen picks up a ScreenDefinition from the schema by qn and
|
|
52
67
|
// routes it to the right renderer based on `screen.type`. Command
|
|
53
68
|
// qualification (`<feature>:write:<entity>:create` etc.) happens here
|
|
@@ -61,10 +76,11 @@ export type KumikoScreenProps = {
|
|
|
61
76
|
readonly schema: FeatureSchema;
|
|
62
77
|
readonly qn: string;
|
|
63
78
|
readonly translate?: Translate;
|
|
64
|
-
// Optional entity-id.
|
|
65
|
-
// set, the edit screen loads the existing record via the detail
|
|
66
|
-
//
|
|
67
|
-
//
|
|
79
|
+
// Optional entity-id / row-id. Meaningful for entityEdit screens — when
|
|
80
|
+
// set, the edit screen loads the existing record via the detail query and
|
|
81
|
+
// submits an update command (`write:<entity>:update`) instead of a create.
|
|
82
|
+
// Also required for projectionDetail screens (which row to fetch — no
|
|
83
|
+
// create mode exists there). For entityList/custom screens it's ignored.
|
|
68
84
|
readonly entityId?: string;
|
|
69
85
|
// Fires when the user clicks a row on an entityList screen. The
|
|
70
86
|
// second argument is the screen's entity name, threaded through so
|
|
@@ -73,6 +89,12 @@ export type KumikoScreenProps = {
|
|
|
73
89
|
// createKumikoApp does exactly that; override to open a drawer,
|
|
74
90
|
// inline-expand, etc.
|
|
75
91
|
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
92
|
+
// Copy-Link-Action auf entityEdit-Update-Screens (Issue #912). Bereits
|
|
93
|
+
// vollständig gebunden (URL-Bau + Clipboard) — createKumikoApp/RoutedScreen
|
|
94
|
+
// liefert die Default-Impl, dieses platform-neutrale Package baut selbst
|
|
95
|
+
// keine URLs/Clipboard-Calls (guard-renderer-boundaries). Nur in
|
|
96
|
+
// update-mode sichtbar (entityId muss gesetzt sein).
|
|
97
|
+
readonly onCopyLink?: () => Promise<void> | void;
|
|
76
98
|
};
|
|
77
99
|
|
|
78
100
|
// Build the qualified name the registry would stamp on screen ingest:
|
|
@@ -92,14 +114,32 @@ export function qualifyNavId(featureName: string, navId: string): string {
|
|
|
92
114
|
return `${featureName}:nav:${navId}`;
|
|
93
115
|
}
|
|
94
116
|
|
|
117
|
+
// Minimal role-gate for the screen-render path (#1203 — nav filtering via
|
|
118
|
+
// filterByAccess in workspace-shell.tsx hid role-gated screens from the
|
|
119
|
+
// menu, but a direct URL/screenQn hit reached KumikoScreen unchecked).
|
|
120
|
+
// Duplicated instead of imported from framework/engine's hasAccess (pulls
|
|
121
|
+
// server-side deps) — same bundle-purity reasoning as headless/nav's
|
|
122
|
+
// resolve.ts:userCanSee, which this mirrors.
|
|
123
|
+
export function screenAccessAllows(
|
|
124
|
+
access: AccessRule | undefined,
|
|
125
|
+
userRoles: readonly string[] | undefined,
|
|
126
|
+
): boolean {
|
|
127
|
+
if (!access) return true;
|
|
128
|
+
if ("openToAll" in access) return access.openToAll;
|
|
129
|
+
if (userRoles === undefined) return false;
|
|
130
|
+
return access.roles.some((role) => userRoles.includes(role));
|
|
131
|
+
}
|
|
132
|
+
|
|
95
133
|
export function KumikoScreen({
|
|
96
134
|
schema,
|
|
97
135
|
qn,
|
|
98
136
|
translate,
|
|
99
137
|
entityId,
|
|
100
138
|
onRowClick,
|
|
139
|
+
onCopyLink,
|
|
101
140
|
}: KumikoScreenProps): ReactNode {
|
|
102
141
|
const { Banner, Text } = usePrimitives();
|
|
142
|
+
const userRoles = useUserRoles();
|
|
103
143
|
const screen = useMemo(
|
|
104
144
|
() =>
|
|
105
145
|
schema.screens.find(
|
|
@@ -116,6 +156,14 @@ export function KumikoScreen({
|
|
|
116
156
|
);
|
|
117
157
|
}
|
|
118
158
|
|
|
159
|
+
if (!screenAccessAllows(screen.access, userRoles)) {
|
|
160
|
+
return (
|
|
161
|
+
<Banner padded variant="error" testId="kumiko-screen-access-denied">
|
|
162
|
+
Access denied: <Text variant="code">{qn}</Text>
|
|
163
|
+
</Banner>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
119
167
|
switch (screen.type) {
|
|
120
168
|
case "entityEdit":
|
|
121
169
|
return (
|
|
@@ -124,6 +172,7 @@ export function KumikoScreen({
|
|
|
124
172
|
screen={screen}
|
|
125
173
|
translate={translate}
|
|
126
174
|
{...(entityId !== undefined && { entityId })}
|
|
175
|
+
{...(onCopyLink !== undefined && { onCopyLink })}
|
|
127
176
|
/>
|
|
128
177
|
);
|
|
129
178
|
case "entityList":
|
|
@@ -135,6 +184,26 @@ export function KumikoScreen({
|
|
|
135
184
|
{...(onRowClick !== undefined && { onRowClick })}
|
|
136
185
|
/>
|
|
137
186
|
);
|
|
187
|
+
case "projectionList":
|
|
188
|
+
return (
|
|
189
|
+
<ProjectionListBody
|
|
190
|
+
schema={schema}
|
|
191
|
+
screen={screen}
|
|
192
|
+
translate={translate}
|
|
193
|
+
{...(onRowClick !== undefined && { onRowClick })}
|
|
194
|
+
/>
|
|
195
|
+
);
|
|
196
|
+
case "projectionDetail":
|
|
197
|
+
return (
|
|
198
|
+
<ProjectionDetailBody
|
|
199
|
+
schema={schema}
|
|
200
|
+
screen={screen}
|
|
201
|
+
translate={translate}
|
|
202
|
+
{...(entityId !== undefined && { entityId })}
|
|
203
|
+
/>
|
|
204
|
+
);
|
|
205
|
+
case "dashboard":
|
|
206
|
+
return <DashboardScreenBody screen={screen} translate={translate} />;
|
|
138
207
|
case "actionForm":
|
|
139
208
|
return <ActionFormBody schema={schema} screen={screen} translate={translate} />;
|
|
140
209
|
case "configEdit":
|
|
@@ -144,6 +213,29 @@ export function KumikoScreen({
|
|
|
144
213
|
}
|
|
145
214
|
}
|
|
146
215
|
|
|
216
|
+
// Injection-Body für dashboard-Screens: die Panel-Implementierung (StatCard,
|
|
217
|
+
// Charts) ist plattform-spezifisch und kommt aus dem DashboardBody-Context
|
|
218
|
+
// (renderer-web registriert die Web-Variante in createKumikoApp).
|
|
219
|
+
function DashboardScreenBody({
|
|
220
|
+
screen,
|
|
221
|
+
translate,
|
|
222
|
+
}: {
|
|
223
|
+
readonly screen: DashboardScreenDefinition;
|
|
224
|
+
readonly translate?: Translate;
|
|
225
|
+
}): ReactNode {
|
|
226
|
+
const { Banner, Text } = usePrimitives();
|
|
227
|
+
const Body = useDashboardBody();
|
|
228
|
+
if (Body === undefined) {
|
|
229
|
+
return (
|
|
230
|
+
<Banner padded variant="info" testId="kumiko-screen-dashboard-placeholder">
|
|
231
|
+
Dashboard screen <Text variant="code">{screen.id}</Text> — kein Dashboard-Body registriert
|
|
232
|
+
(createKumikoApp aus <Text variant="code">kumiko-renderer-web</Text> wired ihn automatisch).
|
|
233
|
+
</Banner>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return <Body screen={screen} {...(translate !== undefined && { translate })} />;
|
|
237
|
+
}
|
|
238
|
+
|
|
147
239
|
// Lookup-Body für custom-screens: schaut die Component aus dem
|
|
148
240
|
// CustomScreens-Context (gefüttert von clientFeatures.components in
|
|
149
241
|
// createKumikoApp). Wenn weder Provider gemounted noch screenId
|
|
@@ -166,15 +258,15 @@ function CustomScreenBody({ screenId }: { readonly screenId: string }): ReactNod
|
|
|
166
258
|
// ---- entity-edit ----
|
|
167
259
|
|
|
168
260
|
// Derives `<feature>:write:<entity>:<verb>` from the screen's entity
|
|
169
|
-
// and the schema's feature name. Matches
|
|
170
|
-
// packages/framework/src/engine/qualified-name.ts so
|
|
171
|
-
//
|
|
261
|
+
// and the schema's feature name. Matches qualifyEntityName / toKebab in
|
|
262
|
+
// packages/framework/src/engine/qualified-name.ts so camelCase entity
|
|
263
|
+
// ids (e.g. driverModel) resolve to server QNs (driver-model:create).
|
|
172
264
|
function entityWriteCommand(
|
|
173
265
|
featureName: string,
|
|
174
266
|
entity: string,
|
|
175
267
|
verb: "create" | "update" | "delete",
|
|
176
268
|
): string {
|
|
177
|
-
return `${featureName}:write:${entity}:${verb}`;
|
|
269
|
+
return `${toKebab(featureName)}:write:${toKebab(entity)}:${verb}`;
|
|
178
270
|
}
|
|
179
271
|
|
|
180
272
|
// Default "success → zurück zur Liste"-Navigation. Findet den ersten
|
|
@@ -249,11 +341,13 @@ function EntityEditScreen({
|
|
|
249
341
|
screen,
|
|
250
342
|
translate,
|
|
251
343
|
entityId,
|
|
344
|
+
onCopyLink,
|
|
252
345
|
}: {
|
|
253
346
|
readonly schema: FeatureSchema;
|
|
254
347
|
readonly screen: EntityEditScreenDefinition;
|
|
255
348
|
readonly translate?: Translate;
|
|
256
349
|
readonly entityId?: string;
|
|
350
|
+
readonly onCopyLink?: () => Promise<void> | void;
|
|
257
351
|
}): ReactNode {
|
|
258
352
|
const { Banner, Text } = usePrimitives();
|
|
259
353
|
const entity = schema.entities[screen.entity];
|
|
@@ -276,6 +370,7 @@ function EntityEditScreen({
|
|
|
276
370
|
entity={entity}
|
|
277
371
|
entityId={entityId}
|
|
278
372
|
{...(translate !== undefined && { translate })}
|
|
373
|
+
{...(onCopyLink !== undefined && { onCopyLink })}
|
|
279
374
|
/>
|
|
280
375
|
);
|
|
281
376
|
}
|
|
@@ -345,15 +440,19 @@ function EntityEditUpdateBody({
|
|
|
345
440
|
entity,
|
|
346
441
|
entityId,
|
|
347
442
|
translate,
|
|
443
|
+
onCopyLink,
|
|
348
444
|
}: {
|
|
349
445
|
readonly schema: FeatureSchema;
|
|
350
446
|
readonly screen: EntityEditScreenDefinition;
|
|
351
447
|
readonly entity: EntityDefinition;
|
|
352
448
|
readonly entityId: string;
|
|
353
449
|
readonly translate?: Translate;
|
|
450
|
+
readonly onCopyLink?: () => Promise<void> | void;
|
|
354
451
|
}): ReactNode {
|
|
355
452
|
const { Banner, Text } = usePrimitives();
|
|
356
|
-
const
|
|
453
|
+
const t = useTranslation();
|
|
454
|
+
const effectiveTranslate = translate ?? t;
|
|
455
|
+
const detailQn = `${toKebab(schema.featureName)}:query:${toKebab(screen.entity)}:detail`;
|
|
357
456
|
const detailQuery = useQuery<Readonly<Record<string, unknown>>>(detailQn, { id: entityId });
|
|
358
457
|
|
|
359
458
|
if (detailQuery.loading && detailQuery.data === null) {
|
|
@@ -366,7 +465,7 @@ function EntityEditUpdateBody({
|
|
|
366
465
|
if (detailQuery.error) {
|
|
367
466
|
return (
|
|
368
467
|
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
369
|
-
{detailQuery.error
|
|
468
|
+
{dispatcherErrorText(detailQuery.error, effectiveTranslate)}
|
|
370
469
|
</Banner>
|
|
371
470
|
);
|
|
372
471
|
}
|
|
@@ -395,6 +494,7 @@ function EntityEditUpdateBody({
|
|
|
395
494
|
record={record}
|
|
396
495
|
onReload={detailQuery.refetch}
|
|
397
496
|
{...(translate !== undefined && { translate })}
|
|
497
|
+
{...(onCopyLink !== undefined && { onCopyLink })}
|
|
398
498
|
/>
|
|
399
499
|
);
|
|
400
500
|
}
|
|
@@ -407,6 +507,7 @@ function EntityEditUpdateForm({
|
|
|
407
507
|
record,
|
|
408
508
|
onReload,
|
|
409
509
|
translate,
|
|
510
|
+
onCopyLink,
|
|
410
511
|
}: {
|
|
411
512
|
readonly schema: FeatureSchema;
|
|
412
513
|
readonly screen: EntityEditScreenDefinition;
|
|
@@ -415,6 +516,7 @@ function EntityEditUpdateForm({
|
|
|
415
516
|
readonly record: Readonly<Record<string, unknown>>;
|
|
416
517
|
readonly onReload: () => Promise<void> | void;
|
|
417
518
|
readonly translate?: Translate;
|
|
519
|
+
readonly onCopyLink?: () => Promise<void> | void;
|
|
418
520
|
}): ReactNode {
|
|
419
521
|
// Seed the form with the server values for the entity's declared
|
|
420
522
|
// fields; anything else (id, tenant_id, created_at…) stays out of
|
|
@@ -490,6 +592,7 @@ function EntityEditUpdateForm({
|
|
|
490
592
|
onReload={() => void onReload()}
|
|
491
593
|
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
492
594
|
{...(translate !== undefined && { translate })}
|
|
595
|
+
{...(onCopyLink !== undefined && { onCopyLink })}
|
|
493
596
|
/>
|
|
494
597
|
);
|
|
495
598
|
}
|
|
@@ -497,7 +600,7 @@ function EntityEditUpdateForm({
|
|
|
497
600
|
// ---- entity-list ----
|
|
498
601
|
|
|
499
602
|
function entityQueryCommand(featureName: string, entity: string, verb: "list"): string {
|
|
500
|
-
return `${featureName}:query:${entity}:${verb}`;
|
|
603
|
+
return `${toKebab(featureName)}:query:${toKebab(entity)}:${verb}`;
|
|
501
604
|
}
|
|
502
605
|
|
|
503
606
|
// Server-side entity-query-handlers return the paged envelope
|
|
@@ -741,10 +844,19 @@ function EntityListBody({
|
|
|
741
844
|
out.push({
|
|
742
845
|
field,
|
|
743
846
|
label,
|
|
744
|
-
// ponytail: Yes/No literal — boolean-Facet-Labels i18n'en wenn eine App es braucht
|
|
745
847
|
options: [
|
|
746
|
-
{
|
|
747
|
-
|
|
848
|
+
{
|
|
849
|
+
value: "true",
|
|
850
|
+
label: effectiveTranslate(
|
|
851
|
+
`${featureName}:entity:${screen.entity}:field:${field}:option:true`,
|
|
852
|
+
),
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
value: "false",
|
|
856
|
+
label: effectiveTranslate(
|
|
857
|
+
`${featureName}:entity:${screen.entity}:field:${field}:option:false`,
|
|
858
|
+
),
|
|
859
|
+
},
|
|
748
860
|
],
|
|
749
861
|
});
|
|
750
862
|
}
|
|
@@ -830,8 +942,8 @@ function EntityListBody({
|
|
|
830
942
|
};
|
|
831
943
|
}
|
|
832
944
|
if (dispatcher === undefined) return null;
|
|
833
|
-
if (action
|
|
834
|
-
const writeAction = action
|
|
945
|
+
if (!isWriteHandlerRowAction(action)) return null;
|
|
946
|
+
const writeAction = action;
|
|
835
947
|
const writeActionVisible = writeAction.visible;
|
|
836
948
|
return {
|
|
837
949
|
id: writeAction.id,
|
|
@@ -899,7 +1011,16 @@ function EntityListBody({
|
|
|
899
1011
|
}),
|
|
900
1012
|
onTrigger: async () => {
|
|
901
1013
|
const payload = action.payload ?? {};
|
|
902
|
-
await dispatcher.write(action.handler, payload);
|
|
1014
|
+
const result = await dispatcher.write(action.handler, payload);
|
|
1015
|
+
// Gleicher Surfacing-Zwang wie bei rowActions (Prod-Bug
|
|
1016
|
+
// 2026-06-07): ein verschlucktes Failure-Result sah wie
|
|
1017
|
+
// "nichts passiert" aus.
|
|
1018
|
+
if (!result.isSuccess) {
|
|
1019
|
+
throw new WriteFailedError(
|
|
1020
|
+
result.error,
|
|
1021
|
+
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
903
1024
|
},
|
|
904
1025
|
};
|
|
905
1026
|
})
|
|
@@ -916,7 +1037,7 @@ function EntityListBody({
|
|
|
916
1037
|
if (rowsQuery.error) {
|
|
917
1038
|
return (
|
|
918
1039
|
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
919
|
-
{rowsQuery.error
|
|
1040
|
+
{dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
|
|
920
1041
|
</Banner>
|
|
921
1042
|
);
|
|
922
1043
|
}
|
|
@@ -995,6 +1116,271 @@ function EntityListBody({
|
|
|
995
1116
|
);
|
|
996
1117
|
}
|
|
997
1118
|
|
|
1119
|
+
// ---- projection-list ----
|
|
1120
|
+
|
|
1121
|
+
// Wie entityList, aber die List-Query kommt DIREKT aus `screen.query` (statt aus
|
|
1122
|
+
// der Entity abgeleitet) — dadurch cross-feature-fähig. v1 bewusst schlank:
|
|
1123
|
+
// rendert die Query-Rows mit den (explizit gelabelten) Columns + navigate-
|
|
1124
|
+
// RowActions/Row-Klick. Kein Server-Sort/-Pagination/-Facetten (eine Projection-
|
|
1125
|
+
// Query hat dafür keinen garantierten Contract) — die kommen bei Bedarf später.
|
|
1126
|
+
function ProjectionListBody({
|
|
1127
|
+
schema,
|
|
1128
|
+
screen,
|
|
1129
|
+
translate,
|
|
1130
|
+
onRowClick,
|
|
1131
|
+
}: {
|
|
1132
|
+
readonly schema: FeatureSchema;
|
|
1133
|
+
readonly screen: ProjectionListScreenDefinition;
|
|
1134
|
+
readonly translate?: Translate;
|
|
1135
|
+
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
1136
|
+
}): ReactNode {
|
|
1137
|
+
const { Banner } = usePrimitives();
|
|
1138
|
+
const t = useTranslation();
|
|
1139
|
+
const nav = useNav();
|
|
1140
|
+
const dispatcher = useOptionalDispatcher();
|
|
1141
|
+
const effectiveTranslate = translate ?? t;
|
|
1142
|
+
const entity = useMemo(() => synthesizeProjectionEntity(screen.columns), [screen.columns]);
|
|
1143
|
+
const listScreen = useMemo(() => synthesizeProjectionScreen(screen), [screen]);
|
|
1144
|
+
const rowsQuery = useQuery<PagedRows>(screen.query, {}, { live: true });
|
|
1145
|
+
|
|
1146
|
+
const runNavigate = useCallback(
|
|
1147
|
+
(action: RowActionNavigate, row: ListRowViewModel) => {
|
|
1148
|
+
const entityId =
|
|
1149
|
+
action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
|
|
1150
|
+
nav.navigate({
|
|
1151
|
+
screenId: action.screen,
|
|
1152
|
+
...(entityId !== undefined && entityId !== "" && { entityId }),
|
|
1153
|
+
});
|
|
1154
|
+
const params =
|
|
1155
|
+
action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
|
|
1156
|
+
if (params !== undefined) {
|
|
1157
|
+
const stringified: Record<string, string | null> = {};
|
|
1158
|
+
for (const [k, v] of Object.entries(params)) {
|
|
1159
|
+
stringified[k] = v === null || v === undefined ? null : String(v);
|
|
1160
|
+
}
|
|
1161
|
+
nav.setSearchParams(stringified);
|
|
1162
|
+
}
|
|
1163
|
+
},
|
|
1164
|
+
[nav],
|
|
1165
|
+
);
|
|
1166
|
+
|
|
1167
|
+
const rowActions = useMemo((): readonly DataTableRowAction[] | undefined => {
|
|
1168
|
+
if (screen.rowActions === undefined) return undefined;
|
|
1169
|
+
const out: DataTableRowAction[] = [];
|
|
1170
|
+
for (const action of screen.rowActions) {
|
|
1171
|
+
if (action.kind === "navigate") {
|
|
1172
|
+
const navigateAction = action;
|
|
1173
|
+
const visible = action.visible;
|
|
1174
|
+
out.push({
|
|
1175
|
+
id: action.id,
|
|
1176
|
+
label: effectiveTranslate(action.label),
|
|
1177
|
+
...(action.style !== undefined && { style: action.style }),
|
|
1178
|
+
onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
|
|
1179
|
+
...(visible !== undefined && {
|
|
1180
|
+
isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
|
|
1181
|
+
}),
|
|
1182
|
+
});
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
// writeHandler (default-kind) — gleicher Dispatch-Pfad wie entityList:
|
|
1186
|
+
// Failure-Result MUSS zum Error werden (sonst schließt der Confirm-
|
|
1187
|
+
// Dialog kommentarlos).
|
|
1188
|
+
if (dispatcher === undefined) continue;
|
|
1189
|
+
const writeAction = action;
|
|
1190
|
+
const writeVisible = writeAction.visible;
|
|
1191
|
+
out.push({
|
|
1192
|
+
id: writeAction.id,
|
|
1193
|
+
label: effectiveTranslate(writeAction.label),
|
|
1194
|
+
...(writeAction.style !== undefined && { style: writeAction.style }),
|
|
1195
|
+
...(writeAction.confirm !== undefined && {
|
|
1196
|
+
confirm: effectiveTranslate(writeAction.confirm),
|
|
1197
|
+
}),
|
|
1198
|
+
...(writeAction.confirmLabel !== undefined && {
|
|
1199
|
+
confirmLabel: effectiveTranslate(writeAction.confirmLabel),
|
|
1200
|
+
}),
|
|
1201
|
+
onTrigger: async (row: ListRowViewModel) => {
|
|
1202
|
+
const payload =
|
|
1203
|
+
writeAction.payload !== undefined
|
|
1204
|
+
? evalRowExtractor(writeAction.payload, row.values)
|
|
1205
|
+
: { id: row.values["id"] };
|
|
1206
|
+
const result = await dispatcher.write(writeAction.handler, payload);
|
|
1207
|
+
if (!result.isSuccess) {
|
|
1208
|
+
throw new WriteFailedError(
|
|
1209
|
+
result.error,
|
|
1210
|
+
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
},
|
|
1214
|
+
...(writeVisible !== undefined && {
|
|
1215
|
+
isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
|
|
1216
|
+
}),
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
return out.length > 0 ? out : undefined;
|
|
1220
|
+
}, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher]);
|
|
1221
|
+
|
|
1222
|
+
const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
|
|
1223
|
+
if (screen.toolbarActions === undefined) return undefined;
|
|
1224
|
+
const out: ToolbarActionButton[] = [];
|
|
1225
|
+
for (const action of screen.toolbarActions) {
|
|
1226
|
+
if (action.kind === "navigate") {
|
|
1227
|
+
const target = action.screen;
|
|
1228
|
+
out.push({
|
|
1229
|
+
id: action.id,
|
|
1230
|
+
label: effectiveTranslate(action.label),
|
|
1231
|
+
...(action.style !== undefined && { style: action.style }),
|
|
1232
|
+
onTrigger: () => nav.navigate({ screenId: target }),
|
|
1233
|
+
});
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
// writeHandler — analog entityList; ohne Dispatcher skippen statt crashen.
|
|
1237
|
+
if (dispatcher === undefined) continue;
|
|
1238
|
+
out.push({
|
|
1239
|
+
id: action.id,
|
|
1240
|
+
label: effectiveTranslate(action.label),
|
|
1241
|
+
...(action.style !== undefined && { style: action.style }),
|
|
1242
|
+
...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
|
|
1243
|
+
...(action.confirmLabel !== undefined && {
|
|
1244
|
+
confirmLabel: effectiveTranslate(action.confirmLabel),
|
|
1245
|
+
}),
|
|
1246
|
+
onTrigger: async () => {
|
|
1247
|
+
const result = await dispatcher.write(action.handler, action.payload ?? {});
|
|
1248
|
+
// Gleicher Surfacing-Zwang wie bei rowActions (Prod-Bug
|
|
1249
|
+
// 2026-06-07): ein verschlucktes Failure-Result sah wie
|
|
1250
|
+
// "nichts passiert" aus.
|
|
1251
|
+
if (!result.isSuccess) {
|
|
1252
|
+
throw new WriteFailedError(
|
|
1253
|
+
result.error,
|
|
1254
|
+
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
},
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
return out.length > 0 ? out : undefined;
|
|
1261
|
+
}, [screen.toolbarActions, effectiveTranslate, nav, dispatcher]);
|
|
1262
|
+
|
|
1263
|
+
if (rowsQuery.loading && rowsQuery.data === null) {
|
|
1264
|
+
return (
|
|
1265
|
+
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
1266
|
+
Loading…
|
|
1267
|
+
</Banner>
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
if (rowsQuery.error) {
|
|
1271
|
+
return (
|
|
1272
|
+
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
1273
|
+
{dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
|
|
1274
|
+
</Banner>
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const rowClickAction = screen.rowActions?.find(
|
|
1279
|
+
(a): a is RowActionNavigate => a.kind === "navigate" && a.rowClick === true,
|
|
1280
|
+
);
|
|
1281
|
+
const wrappedOnRowClick = rowClickAction
|
|
1282
|
+
? (row: ListRowViewModel) => runNavigate(rowClickAction, row)
|
|
1283
|
+
: onRowClick !== undefined
|
|
1284
|
+
? (row: ListRowViewModel) => onRowClick(row, listScreen.entity)
|
|
1285
|
+
: undefined;
|
|
1286
|
+
|
|
1287
|
+
return (
|
|
1288
|
+
<RenderList
|
|
1289
|
+
screen={listScreen}
|
|
1290
|
+
entity={entity}
|
|
1291
|
+
rows={rowsQuery.data?.rows ?? []}
|
|
1292
|
+
featureName={schema.featureName}
|
|
1293
|
+
searchable={screen.searchable ?? false}
|
|
1294
|
+
sort={screen.defaultSort ?? null}
|
|
1295
|
+
{...(rowActions !== undefined && { rowActions })}
|
|
1296
|
+
{...(toolbarActions !== undefined && { toolbarActions })}
|
|
1297
|
+
{...(translate !== undefined && { translate })}
|
|
1298
|
+
{...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
|
|
1299
|
+
/>
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// Projection-Detail-Body — read-only single-row inspector über eine explizite
|
|
1304
|
+
// Query statt einer Entity (siehe projection-detail-shim.ts für die Schulden-
|
|
1305
|
+
// Doku). Fetcht selbst über `screen.query` + `idParam` (analog zu
|
|
1306
|
+
// EntityEditUpdateBody, das die Query-QN aus der Entity-Convention ableitet —
|
|
1307
|
+
// hier ist die QN Author-explizit, wie bei ProjectionListBody). Rendert über
|
|
1308
|
+
// RenderEdit MIT customSubmit-No-Op statt writeCommand: hasEditableSection()
|
|
1309
|
+
// blendet den Save-Button aus (jedes Feld ist readOnly), und selbst ein
|
|
1310
|
+
// natives Form-Submit (Enter-Keypress) würde ohne customSubmit gegen
|
|
1311
|
+
// controller.submit() ohne submit-config throwen — der No-Op macht diesen
|
|
1312
|
+
// Pfad harmlos statt ihn dem Zufall zu überlassen.
|
|
1313
|
+
function ProjectionDetailBody({
|
|
1314
|
+
schema,
|
|
1315
|
+
screen,
|
|
1316
|
+
translate,
|
|
1317
|
+
entityId,
|
|
1318
|
+
}: {
|
|
1319
|
+
readonly schema: FeatureSchema;
|
|
1320
|
+
readonly screen: ProjectionDetailScreenDefinition;
|
|
1321
|
+
readonly translate?: Translate;
|
|
1322
|
+
readonly entityId?: string;
|
|
1323
|
+
}): ReactNode {
|
|
1324
|
+
const { Banner, Text } = usePrimitives();
|
|
1325
|
+
const t = useTranslation();
|
|
1326
|
+
const effectiveTranslate = translate ?? t;
|
|
1327
|
+
const nav = useNav();
|
|
1328
|
+
const idParam = screen.idParam ?? "id";
|
|
1329
|
+
const entity = useMemo(() => synthesizeProjectionDetailEntity(screen.layout), [screen.layout]);
|
|
1330
|
+
const detailScreen = useMemo(() => synthesizeProjectionDetailScreen(screen), [screen]);
|
|
1331
|
+
const detailQuery = useQuery<Readonly<Record<string, unknown>>>(
|
|
1332
|
+
screen.query,
|
|
1333
|
+
entityId !== undefined ? { [idParam]: entityId } : {},
|
|
1334
|
+
);
|
|
1335
|
+
|
|
1336
|
+
const navigateToList = useCallback(() => {
|
|
1337
|
+
if (screen.listScreenId === undefined) return;
|
|
1338
|
+
nav.navigate({ screenId: screen.listScreenId });
|
|
1339
|
+
}, [nav, screen.listScreenId]);
|
|
1340
|
+
|
|
1341
|
+
if (entityId === undefined) {
|
|
1342
|
+
return (
|
|
1343
|
+
<Banner padded variant="error" testId="kumiko-screen-projection-detail-missing-id">
|
|
1344
|
+
Screen <Text variant="code">{screen.id}</Text> (projectionDetail) needs a row id in the path
|
|
1345
|
+
— open it from a row action.
|
|
1346
|
+
</Banner>
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
if (detailQuery.loading && detailQuery.data === null) {
|
|
1350
|
+
return (
|
|
1351
|
+
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
1352
|
+
Loading…
|
|
1353
|
+
</Banner>
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
if (detailQuery.error) {
|
|
1357
|
+
return (
|
|
1358
|
+
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
1359
|
+
{dispatcherErrorText(detailQuery.error, effectiveTranslate)}
|
|
1360
|
+
</Banner>
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
const record = detailQuery.data;
|
|
1364
|
+
if (!record) {
|
|
1365
|
+
return (
|
|
1366
|
+
<Banner padded variant="error" testId="kumiko-screen-record-missing">
|
|
1367
|
+
Record <Text variant="code">{entityId}</Text> not found.
|
|
1368
|
+
</Banner>
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
return (
|
|
1372
|
+
<RenderEdit
|
|
1373
|
+
screen={detailScreen}
|
|
1374
|
+
entity={entity}
|
|
1375
|
+
featureName={schema.featureName}
|
|
1376
|
+
initial={record as FormValues}
|
|
1377
|
+
entityId={entityId}
|
|
1378
|
+
customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
|
|
1379
|
+
onCancel={screen.listScreenId !== undefined ? navigateToList : undefined}
|
|
1380
|
+
{...(translate !== undefined && { translate })}
|
|
1381
|
+
/>
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
998
1384
|
// ---- actionForm (Tier 2.7d) ----
|
|
999
1385
|
|
|
1000
1386
|
// Action-Form-Body — non-CRUD Write-Handler-driven Form. Re-uses
|
|
@@ -1115,6 +1501,8 @@ function ConfigEditBody({
|
|
|
1115
1501
|
readonly translate?: Translate;
|
|
1116
1502
|
}): ReactNode {
|
|
1117
1503
|
const { Banner, ConfigCascadeView } = usePrimitives();
|
|
1504
|
+
const t = useTranslation();
|
|
1505
|
+
const effectiveTranslate = translate ?? t;
|
|
1118
1506
|
const dispatcher = useDispatcher();
|
|
1119
1507
|
|
|
1120
1508
|
// Detail-Load: config:query:values returnt ALLE Keys des Tenants.
|
|
@@ -1239,7 +1627,7 @@ function ConfigEditBody({
|
|
|
1239
1627
|
if (valuesQuery.error) {
|
|
1240
1628
|
return (
|
|
1241
1629
|
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
1242
|
-
{valuesQuery.error
|
|
1630
|
+
{dispatcherErrorText(valuesQuery.error, effectiveTranslate)}
|
|
1243
1631
|
</Banner>
|
|
1244
1632
|
);
|
|
1245
1633
|
}
|