@cosmicdrift/kumiko-renderer 0.1.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 +42 -0
- package/src/__tests__/i18n.test.tsx +127 -0
- package/src/__tests__/qn.test.ts +40 -0
- package/src/__tests__/use-list-url-state.test.tsx +161 -0
- package/src/app/action-form-shim.ts +50 -0
- package/src/app/column-renderers.tsx +64 -0
- package/src/app/config-edit-shim.ts +48 -0
- package/src/app/custom-screens.tsx +29 -0
- package/src/app/feature-schema.ts +59 -0
- package/src/app/kumiko-screen.tsx +1050 -0
- package/src/app/nav.tsx +124 -0
- package/src/app/qn.ts +23 -0
- package/src/components/render-edit.tsx +346 -0
- package/src/components/render-field.tsx +299 -0
- package/src/components/render-list.tsx +402 -0
- package/src/context/dispatcher-context.tsx +59 -0
- package/src/hooks/reference-limits.ts +18 -0
- package/src/hooks/use-form.ts +88 -0
- package/src/hooks/use-list-url-state.ts +113 -0
- package/src/hooks/use-query.ts +129 -0
- package/src/hooks/use-reference-lookup.ts +54 -0
- package/src/hooks/use-store.ts +47 -0
- package/src/i18n-defaults.ts +94 -0
- package/src/i18n.tsx +158 -0
- package/src/index.ts +104 -0
- package/src/primitives.tsx +528 -0
- package/src/sse/live-events.tsx +56 -0
- package/src/tokens.tsx +142 -0
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActionFormScreenDefinition,
|
|
3
|
+
ConfigEditScreenDefinition,
|
|
4
|
+
EntityDefinition,
|
|
5
|
+
EntityEditScreenDefinition,
|
|
6
|
+
EntityListScreenDefinition,
|
|
7
|
+
ScreenDefinition,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
9
|
+
import type {
|
|
10
|
+
Command,
|
|
11
|
+
FormSnapshot,
|
|
12
|
+
FormValues,
|
|
13
|
+
ListRowViewModel,
|
|
14
|
+
SubmitResult,
|
|
15
|
+
Translate,
|
|
16
|
+
} from "@cosmicdrift/kumiko-headless";
|
|
17
|
+
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
18
|
+
import { RenderEdit } from "../components/render-edit";
|
|
19
|
+
import { RenderList } from "../components/render-list";
|
|
20
|
+
import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
|
|
21
|
+
import { useListUrlState } from "../hooks/use-list-url-state";
|
|
22
|
+
import { useQuery } from "../hooks/use-query";
|
|
23
|
+
import { useTranslation } from "../i18n";
|
|
24
|
+
import { usePrimitives } from "../primitives";
|
|
25
|
+
import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim";
|
|
26
|
+
import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim";
|
|
27
|
+
import { useCustomScreenComponent } from "./custom-screens";
|
|
28
|
+
import type { FeatureSchema } from "./feature-schema";
|
|
29
|
+
import { useNav } from "./nav";
|
|
30
|
+
import { lastSegment } from "./qn";
|
|
31
|
+
|
|
32
|
+
// KumikoScreen picks up a ScreenDefinition from the schema by qn and
|
|
33
|
+
// routes it to the right renderer based on `screen.type`. Command
|
|
34
|
+
// qualification (`<feature>:write:<entity>:create` etc.) happens here
|
|
35
|
+
// so the renderers stay command-agnostic — consistent with how the
|
|
36
|
+
// server-side dispatcher resolves QNs from ScreenDefinition + feature.
|
|
37
|
+
//
|
|
38
|
+
// The discriminator is `screen.type`, not a component-registry lookup
|
|
39
|
+
// with feature-provided overrides — that's M4's r.uiComponent feature.
|
|
40
|
+
|
|
41
|
+
export type KumikoScreenProps = {
|
|
42
|
+
readonly schema: FeatureSchema;
|
|
43
|
+
readonly qn: string;
|
|
44
|
+
readonly translate?: Translate;
|
|
45
|
+
// Optional entity-id. Only meaningful for entityEdit screens — when
|
|
46
|
+
// set, the edit screen loads the existing record via the detail
|
|
47
|
+
// query and submits an update command (`write:<entity>:update`)
|
|
48
|
+
// instead of a create. For entityList/custom screens it's ignored.
|
|
49
|
+
readonly entityId?: string;
|
|
50
|
+
// Fires when the user clicks a row on an entityList screen. The
|
|
51
|
+
// second argument is the screen's entity name, threaded through so
|
|
52
|
+
// the caller's handler can navigate to `<edit screen for this
|
|
53
|
+
// entity>/{row.id}` without re-deriving it. Default wiring in
|
|
54
|
+
// createKumikoApp does exactly that; override to open a drawer,
|
|
55
|
+
// inline-expand, etc.
|
|
56
|
+
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Build the qualified name the registry would stamp on screen ingest:
|
|
60
|
+
// <feature>:screen:<short-id>. Matches the rule in
|
|
61
|
+
// packages/framework/src/engine/qualified-name.ts so client lookups
|
|
62
|
+
// line up with server-side registry state.
|
|
63
|
+
export function qualifyScreenId(featureName: string, screenId: string): string {
|
|
64
|
+
return `${featureName}:screen:${screenId}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Symmetrisch zu qualifyScreenId für Nav-QNs. NavDefinition-IDs in der
|
|
68
|
+
* Registry haben die Form `<feature>:nav:<short-id>`; Code der QNs
|
|
69
|
+
* baut (z.B. WorkspaceShell-Resolver) sollte das hier durchreichen statt
|
|
70
|
+
* String-Concat damit ein zukünftiger QN-Schema-Wechsel an einer Stelle
|
|
71
|
+
* greift. */
|
|
72
|
+
export function qualifyNavId(featureName: string, navId: string): string {
|
|
73
|
+
return `${featureName}:nav:${navId}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function KumikoScreen({
|
|
77
|
+
schema,
|
|
78
|
+
qn,
|
|
79
|
+
translate,
|
|
80
|
+
entityId,
|
|
81
|
+
onRowClick,
|
|
82
|
+
}: KumikoScreenProps): ReactNode {
|
|
83
|
+
const { Banner, Text } = usePrimitives();
|
|
84
|
+
const screen = useMemo(
|
|
85
|
+
() => schema.screens.find((s) => qualifyScreenId(schema.featureName, s.id) === qn),
|
|
86
|
+
[schema.featureName, schema.screens, qn],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (!screen) {
|
|
90
|
+
return (
|
|
91
|
+
<Banner padded variant="error" testId="kumiko-screen-not-found">
|
|
92
|
+
Screen not found: <Text variant="code">{qn}</Text>
|
|
93
|
+
</Banner>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
switch (screen.type) {
|
|
98
|
+
case "entityEdit":
|
|
99
|
+
return (
|
|
100
|
+
<EntityEditScreen
|
|
101
|
+
schema={schema}
|
|
102
|
+
screen={screen}
|
|
103
|
+
translate={translate}
|
|
104
|
+
{...(entityId !== undefined && { entityId })}
|
|
105
|
+
/>
|
|
106
|
+
);
|
|
107
|
+
case "entityList":
|
|
108
|
+
return (
|
|
109
|
+
<EntityListScreen
|
|
110
|
+
schema={schema}
|
|
111
|
+
screen={screen}
|
|
112
|
+
translate={translate}
|
|
113
|
+
{...(onRowClick !== undefined && { onRowClick })}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
case "actionForm":
|
|
117
|
+
return <ActionFormBody schema={schema} screen={screen} translate={translate} />;
|
|
118
|
+
case "configEdit":
|
|
119
|
+
return <ConfigEditBody schema={schema} screen={screen} translate={translate} />;
|
|
120
|
+
case "custom":
|
|
121
|
+
return <CustomScreenBody screenId={screen.id} />;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Lookup-Body für custom-screens: schaut die Component aus dem
|
|
126
|
+
// CustomScreens-Context (gefüttert von clientFeatures.components in
|
|
127
|
+
// createKumikoApp). Wenn weder Provider gemounted noch screenId
|
|
128
|
+
// registriert ist, fällt es auf einen Banner zurück — Apps die das
|
|
129
|
+
// sehen wissen sofort: "Component fehlt im clientFeatures.components".
|
|
130
|
+
function CustomScreenBody({ screenId }: { readonly screenId: string }): ReactNode {
|
|
131
|
+
const { Banner, Text } = usePrimitives();
|
|
132
|
+
const Component = useCustomScreenComponent(screenId);
|
|
133
|
+
if (Component === undefined) {
|
|
134
|
+
return (
|
|
135
|
+
<Banner padded variant="info" testId="kumiko-screen-custom-placeholder">
|
|
136
|
+
Custom screen <Text variant="code">{screenId}</Text> hat keine Component im{" "}
|
|
137
|
+
<Text variant="code">clientFeatures.components</Text>.
|
|
138
|
+
</Banner>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return <Component />;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- entity-edit ----
|
|
145
|
+
|
|
146
|
+
// Derives `<feature>:write:<entity>:<verb>` from the screen's entity
|
|
147
|
+
// and the schema's feature name. Matches the qualification rule in
|
|
148
|
+
// packages/framework/src/engine/qualified-name.ts so the server-side
|
|
149
|
+
// handler resolves without extra wiring.
|
|
150
|
+
function entityWriteCommand(
|
|
151
|
+
featureName: string,
|
|
152
|
+
entity: string,
|
|
153
|
+
verb: "create" | "update" | "delete",
|
|
154
|
+
): string {
|
|
155
|
+
return `${featureName}:write:${entity}:${verb}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Default "success → zurück zur Liste"-Navigation. Findet den ersten
|
|
159
|
+
// entityList-Screen für die Entity und navigiert dahin. Wird von
|
|
160
|
+
// Create/Update/Delete genauso verwendet — alle drei haben "fertig
|
|
161
|
+
// editiert, raus hier" als sinnvolles Default-Verhalten.
|
|
162
|
+
function useNavigateToListAfter(schema: FeatureSchema, entityName: string): () => void {
|
|
163
|
+
const nav = useNav();
|
|
164
|
+
return useCallback(() => {
|
|
165
|
+
const list = schema.screens.find((s) => s.type === "entityList" && s.entity === entityName);
|
|
166
|
+
if (!list) return;
|
|
167
|
+
// schema.screens.id ist QN-form (registry-stamped); nav.navigate
|
|
168
|
+
// erwartet Short-Form. Sonst landet die URL doppelt-qualifiziert.
|
|
169
|
+
nav.navigate({ screenId: lastSegment(list.id) });
|
|
170
|
+
}, [nav, schema.screens, entityName]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Default "+ Neu"-Navigation für die List-Toolbar: findet den ersten
|
|
174
|
+
// entityEdit-Screen ohne entityId-Anhang und navigiert dorthin.
|
|
175
|
+
// Returns undefined wenn kein Edit-Screen registriert ist — RenderList
|
|
176
|
+
// rendert dann keinen + Neu Button.
|
|
177
|
+
//
|
|
178
|
+
// Schema-Screens kommen mit qualifizierten ids ("publicstatus:screen:
|
|
179
|
+
// component-edit") aus der Registry; lastSegment strippt den Prefix
|
|
180
|
+
// für nav.navigate (siehe ./qn.ts für Doku).
|
|
181
|
+
function useNavigateToCreateFor(
|
|
182
|
+
schema: FeatureSchema,
|
|
183
|
+
entityName: string,
|
|
184
|
+
): (() => void) | undefined {
|
|
185
|
+
const nav = useNav();
|
|
186
|
+
const editScreenId = useMemo(() => {
|
|
187
|
+
const edit = schema.screens.find((s) => s.type === "entityEdit" && s.entity === entityName);
|
|
188
|
+
return edit !== undefined ? lastSegment(edit.id) : undefined;
|
|
189
|
+
}, [schema.screens, entityName]);
|
|
190
|
+
const navigate = useCallback(() => {
|
|
191
|
+
if (editScreenId !== undefined) nav.navigate({ screenId: editScreenId });
|
|
192
|
+
}, [nav, editScreenId]);
|
|
193
|
+
return editScreenId !== undefined ? navigate : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Initial form values — respect field.default when the entity declares
|
|
197
|
+
// one, otherwise fall back to a type-sane empty value so controlled
|
|
198
|
+
// inputs have something to render. Missing this on booleans/numbers
|
|
199
|
+
// with a `default: true`/`default: 5` would show the form in a state
|
|
200
|
+
// the entity didn't ask for — subtle and easy to miss until a user
|
|
201
|
+
// submits and is surprised.
|
|
202
|
+
function buildInitialValues(
|
|
203
|
+
fields: Readonly<Record<string, unknown>>,
|
|
204
|
+
): Readonly<Record<string, unknown>> {
|
|
205
|
+
const out: Record<string, unknown> = {};
|
|
206
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
207
|
+
const shape = def as { type?: string; default?: unknown };
|
|
208
|
+
if (shape.default !== undefined) {
|
|
209
|
+
out[name] = shape.default;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
out[name] =
|
|
213
|
+
shape.type === "boolean" ? false : shape.type === "number" || shape.type === "money" ? 0 : "";
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function EntityEditScreen({
|
|
219
|
+
schema,
|
|
220
|
+
screen,
|
|
221
|
+
translate,
|
|
222
|
+
entityId,
|
|
223
|
+
}: {
|
|
224
|
+
readonly schema: FeatureSchema;
|
|
225
|
+
readonly screen: EntityEditScreenDefinition;
|
|
226
|
+
readonly translate?: Translate;
|
|
227
|
+
readonly entityId?: string;
|
|
228
|
+
}): ReactNode {
|
|
229
|
+
const { Banner, Text } = usePrimitives();
|
|
230
|
+
const entity = schema.entities[screen.entity];
|
|
231
|
+
if (!entity) {
|
|
232
|
+
return (
|
|
233
|
+
<Banner padded variant="error" testId="kumiko-screen-entity-missing">
|
|
234
|
+
Entity <Text variant="code">{screen.entity}</Text> referenced by screen{" "}
|
|
235
|
+
<Text variant="code">{screen.id}</Text> not registered in the schema.
|
|
236
|
+
</Banner>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
// Split into create-body / update-body so the update-only hooks
|
|
240
|
+
// (useQuery(detail)) don't fire in create mode, and vice versa.
|
|
241
|
+
// Same shape as EntityListScreen.
|
|
242
|
+
if (entityId !== undefined) {
|
|
243
|
+
return (
|
|
244
|
+
<EntityEditUpdateBody
|
|
245
|
+
schema={schema}
|
|
246
|
+
screen={screen}
|
|
247
|
+
entity={entity}
|
|
248
|
+
entityId={entityId}
|
|
249
|
+
{...(translate !== undefined && { translate })}
|
|
250
|
+
/>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return (
|
|
254
|
+
<EntityEditCreateBody
|
|
255
|
+
schema={schema}
|
|
256
|
+
screen={screen}
|
|
257
|
+
entity={entity}
|
|
258
|
+
{...(translate !== undefined && { translate })}
|
|
259
|
+
/>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function EntityEditCreateBody({
|
|
264
|
+
schema,
|
|
265
|
+
screen,
|
|
266
|
+
entity,
|
|
267
|
+
translate,
|
|
268
|
+
}: {
|
|
269
|
+
readonly schema: FeatureSchema;
|
|
270
|
+
readonly screen: EntityEditScreenDefinition;
|
|
271
|
+
readonly entity: EntityDefinition;
|
|
272
|
+
readonly translate?: Translate;
|
|
273
|
+
}): ReactNode {
|
|
274
|
+
const initial = useMemo(() => buildInitialValues(entity.fields) as FormValues, [entity.fields]);
|
|
275
|
+
const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
|
|
276
|
+
const navigateToList = useNavigateToListAfter(schema, screen.entity);
|
|
277
|
+
const handleSubmitted = useCallback(
|
|
278
|
+
(result: SubmitResult<unknown>) => {
|
|
279
|
+
if (result.isSuccess) navigateToList();
|
|
280
|
+
},
|
|
281
|
+
[navigateToList],
|
|
282
|
+
);
|
|
283
|
+
return (
|
|
284
|
+
<RenderEdit
|
|
285
|
+
screen={screen}
|
|
286
|
+
entity={entity}
|
|
287
|
+
featureName={schema.featureName}
|
|
288
|
+
initial={initial}
|
|
289
|
+
writeCommand={writeCommand}
|
|
290
|
+
onSubmit={handleSubmitted}
|
|
291
|
+
onCancel={navigateToList}
|
|
292
|
+
{...(translate !== undefined && { translate })}
|
|
293
|
+
/>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Update body: loads the existing record via `<feature>:query:<entity>:detail`,
|
|
298
|
+
// then mounts a form pre-filled with the server values and dispatches
|
|
299
|
+
// `<feature>:write:<entity>:update` on submit. `buildPayload` shapes the
|
|
300
|
+
// snapshot into Kumiko's update-event envelope `{ id, version, changes }`.
|
|
301
|
+
function EntityEditUpdateBody({
|
|
302
|
+
schema,
|
|
303
|
+
screen,
|
|
304
|
+
entity,
|
|
305
|
+
entityId,
|
|
306
|
+
translate,
|
|
307
|
+
}: {
|
|
308
|
+
readonly schema: FeatureSchema;
|
|
309
|
+
readonly screen: EntityEditScreenDefinition;
|
|
310
|
+
readonly entity: EntityDefinition;
|
|
311
|
+
readonly entityId: string;
|
|
312
|
+
readonly translate?: Translate;
|
|
313
|
+
}): ReactNode {
|
|
314
|
+
const { Banner, Text } = usePrimitives();
|
|
315
|
+
const detailQn = `${schema.featureName}:query:${screen.entity}:detail`;
|
|
316
|
+
const detailQuery = useQuery<Readonly<Record<string, unknown>>>(detailQn, { id: entityId });
|
|
317
|
+
|
|
318
|
+
if (detailQuery.loading && detailQuery.data === null) {
|
|
319
|
+
return (
|
|
320
|
+
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
321
|
+
Loading…
|
|
322
|
+
</Banner>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (detailQuery.error) {
|
|
326
|
+
return (
|
|
327
|
+
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
328
|
+
{detailQuery.error.i18nKey}
|
|
329
|
+
</Banner>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const record = detailQuery.data;
|
|
333
|
+
if (!record) {
|
|
334
|
+
return (
|
|
335
|
+
<Banner padded variant="error" testId="kumiko-screen-record-missing">
|
|
336
|
+
Record <Text variant="code">{entityId}</Text> not found.
|
|
337
|
+
</Banner>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
// Record-version als React-key: bei "Neu laden" refetched detail,
|
|
341
|
+
// liefert neue version, und das Form remountet komplett. Ohne den
|
|
342
|
+
// Key-Wechsel bliebe der useForm-Controller lifetime-scoped auf
|
|
343
|
+
// der ursprünglichen version sitzen — der buildPayload würde
|
|
344
|
+
// weiter die stale version stampen und der Konflikt-Recovery wäre
|
|
345
|
+
// kaputt.
|
|
346
|
+
const recordKey = (record as { version?: number }).version ?? 1;
|
|
347
|
+
return (
|
|
348
|
+
<EntityEditUpdateForm
|
|
349
|
+
key={`${entityId}:${recordKey}`}
|
|
350
|
+
schema={schema}
|
|
351
|
+
screen={screen}
|
|
352
|
+
entity={entity}
|
|
353
|
+
entityId={entityId}
|
|
354
|
+
record={record}
|
|
355
|
+
onReload={detailQuery.refetch}
|
|
356
|
+
{...(translate !== undefined && { translate })}
|
|
357
|
+
/>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function EntityEditUpdateForm({
|
|
362
|
+
schema,
|
|
363
|
+
screen,
|
|
364
|
+
entity,
|
|
365
|
+
entityId,
|
|
366
|
+
record,
|
|
367
|
+
onReload,
|
|
368
|
+
translate,
|
|
369
|
+
}: {
|
|
370
|
+
readonly schema: FeatureSchema;
|
|
371
|
+
readonly screen: EntityEditScreenDefinition;
|
|
372
|
+
readonly entity: EntityDefinition;
|
|
373
|
+
readonly entityId: string;
|
|
374
|
+
readonly record: Readonly<Record<string, unknown>>;
|
|
375
|
+
readonly onReload: () => Promise<void> | void;
|
|
376
|
+
readonly translate?: Translate;
|
|
377
|
+
}): ReactNode {
|
|
378
|
+
// Seed the form with the server values for the entity's declared
|
|
379
|
+
// fields; anything else (id, tenant_id, created_at…) stays out of
|
|
380
|
+
// the form and lives in the closure. The record's `version` is
|
|
381
|
+
// captured once and stamped into every update payload — if a
|
|
382
|
+
// concurrent writer bumps it, the server returns a version-conflict
|
|
383
|
+
// error and the user reloads.
|
|
384
|
+
const recordVersion = (record as { version?: number }).version ?? 1;
|
|
385
|
+
const initial = useMemo(() => {
|
|
386
|
+
const out: Record<string, unknown> = {};
|
|
387
|
+
for (const name of Object.keys(entity.fields)) {
|
|
388
|
+
out[name] = record[name] ?? buildInitialValues({ [name]: entity.fields[name] })[name];
|
|
389
|
+
}
|
|
390
|
+
return out as FormValues;
|
|
391
|
+
}, [entity.fields, record]);
|
|
392
|
+
|
|
393
|
+
const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "update");
|
|
394
|
+
const deleteCommand = entityWriteCommand(schema.featureName, screen.entity, "delete");
|
|
395
|
+
const buildPayload = useMemo(
|
|
396
|
+
() =>
|
|
397
|
+
(snap: { readonly changes: Readonly<Record<string, unknown>> }): unknown => ({
|
|
398
|
+
id: entityId,
|
|
399
|
+
version: recordVersion,
|
|
400
|
+
changes: snap.changes,
|
|
401
|
+
}),
|
|
402
|
+
[entityId, recordVersion],
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const dispatcher = useDispatcher();
|
|
406
|
+
const navigateToList = useNavigateToListAfter(schema, screen.entity);
|
|
407
|
+
const handleSubmitted = useCallback(
|
|
408
|
+
(result: SubmitResult<unknown>) => {
|
|
409
|
+
if (result.isSuccess) navigateToList();
|
|
410
|
+
},
|
|
411
|
+
[navigateToList],
|
|
412
|
+
);
|
|
413
|
+
const handleDelete = useCallback(async () => {
|
|
414
|
+
const res = await dispatcher.write(deleteCommand, { id: entityId });
|
|
415
|
+
if (res.isSuccess) navigateToList();
|
|
416
|
+
}, [dispatcher, deleteCommand, entityId, navigateToList]);
|
|
417
|
+
|
|
418
|
+
return (
|
|
419
|
+
<RenderEdit
|
|
420
|
+
screen={screen}
|
|
421
|
+
entity={entity}
|
|
422
|
+
featureName={schema.featureName}
|
|
423
|
+
initial={initial}
|
|
424
|
+
writeCommand={writeCommand}
|
|
425
|
+
payloadMode="changes"
|
|
426
|
+
buildPayload={buildPayload}
|
|
427
|
+
onSubmit={handleSubmitted}
|
|
428
|
+
onDelete={handleDelete}
|
|
429
|
+
onCancel={navigateToList}
|
|
430
|
+
onReload={() => void onReload()}
|
|
431
|
+
{...(translate !== undefined && { translate })}
|
|
432
|
+
/>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---- entity-list ----
|
|
437
|
+
|
|
438
|
+
function entityQueryCommand(featureName: string, entity: string, verb: "list"): string {
|
|
439
|
+
return `${featureName}:query:${entity}:${verb}`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Server-side entity-query-handlers return the paged envelope
|
|
443
|
+
// `{ rows, nextCursor, total? }`. Narrow the useQuery generic to that
|
|
444
|
+
// shape so RenderList gets plain rows. `total` ist optional — Server
|
|
445
|
+
// liefert es nur wenn der Caller `totalCount: true` setzt.
|
|
446
|
+
type PagedRows = {
|
|
447
|
+
readonly rows: Readonly<Record<string, unknown>>[];
|
|
448
|
+
readonly nextCursor: string | null;
|
|
449
|
+
readonly total?: number;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
function EntityListScreen({
|
|
453
|
+
schema,
|
|
454
|
+
screen,
|
|
455
|
+
translate,
|
|
456
|
+
onRowClick,
|
|
457
|
+
}: {
|
|
458
|
+
readonly schema: FeatureSchema;
|
|
459
|
+
readonly screen: EntityListScreenDefinition;
|
|
460
|
+
readonly translate?: Translate;
|
|
461
|
+
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
462
|
+
}): ReactNode {
|
|
463
|
+
const { Banner, Text } = usePrimitives();
|
|
464
|
+
const entity = schema.entities[screen.entity];
|
|
465
|
+
if (!entity) {
|
|
466
|
+
return (
|
|
467
|
+
<Banner padded variant="error" testId="kumiko-screen-entity-missing">
|
|
468
|
+
Entity <Text variant="code">{screen.entity}</Text> referenced by screen{" "}
|
|
469
|
+
<Text variant="code">{screen.id}</Text> not registered in the schema.
|
|
470
|
+
</Banner>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
// Entity resolved — mount the inner component so useQuery only fires
|
|
474
|
+
// when there's actually something to render. A missing entity is a
|
|
475
|
+
// dev-error state; no point hitting the server for it.
|
|
476
|
+
return (
|
|
477
|
+
<EntityListBody
|
|
478
|
+
schema={schema}
|
|
479
|
+
screen={screen}
|
|
480
|
+
entity={entity}
|
|
481
|
+
{...(translate !== undefined && { translate })}
|
|
482
|
+
{...(onRowClick !== undefined && { onRowClick })}
|
|
483
|
+
/>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function EntityListBody({
|
|
488
|
+
schema,
|
|
489
|
+
screen,
|
|
490
|
+
entity,
|
|
491
|
+
translate,
|
|
492
|
+
onRowClick,
|
|
493
|
+
}: {
|
|
494
|
+
readonly schema: FeatureSchema;
|
|
495
|
+
readonly screen: EntityListScreenDefinition;
|
|
496
|
+
readonly entity: EntityDefinition;
|
|
497
|
+
readonly translate?: Translate;
|
|
498
|
+
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
499
|
+
}): ReactNode {
|
|
500
|
+
const featureName = schema.featureName;
|
|
501
|
+
const onCreate = useNavigateToCreateFor(schema, screen.entity);
|
|
502
|
+
const { Banner } = usePrimitives();
|
|
503
|
+
const queryType = entityQueryCommand(featureName, screen.entity, "list");
|
|
504
|
+
const nav = useNav();
|
|
505
|
+
|
|
506
|
+
// URL-State: sort/dir/q/page leben unter dem screen.id-Namespace
|
|
507
|
+
// (`/orders?orders.sort=createdAt&orders.dir=desc&orders.q=acme`),
|
|
508
|
+
// damit zwei Lists auf derselben Route nicht über dieselben
|
|
509
|
+
// Query-Keys streiten. Default-Sort aus der Screen-Def gewinnt nur
|
|
510
|
+
// wenn URL keinen sort hat — Author-Default vs User-Choice.
|
|
511
|
+
const urlState = useListUrlState(screen.id);
|
|
512
|
+
const effectiveSort = urlState.sort ?? screen.defaultSort ?? null;
|
|
513
|
+
const limit = screen.pageSize ?? 50;
|
|
514
|
+
const paginationMode = screen.pagination ?? "pages";
|
|
515
|
+
const usePager = paginationMode === "pages";
|
|
516
|
+
const useInfinite = paginationMode === "infinite";
|
|
517
|
+
|
|
518
|
+
// Infinite-Scroll: lokaler State akkumuliert rows über mehrere
|
|
519
|
+
// useQuery-Aufrufe (statt replacen wie bei Pager). cursor wechselt
|
|
520
|
+
// wenn der User den Bottom-Sentinel erreicht; bei sort/q-Change
|
|
521
|
+
// resetten wir alles, da die Insert-Order der akkumulierten Rows
|
|
522
|
+
// sonst inkonsistent ist mit der neuen Sortierung.
|
|
523
|
+
const [accumulated, setAccumulated] = useState<readonly Readonly<Record<string, unknown>>[]>([]);
|
|
524
|
+
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
|
525
|
+
const [hasMore, setHasMore] = useState(true);
|
|
526
|
+
// Ref damit der useEffect-Cleanup nicht alten state sieht.
|
|
527
|
+
const sortQRef = useRef<string>("");
|
|
528
|
+
const sortQKey = `${urlState.q}|${effectiveSort?.field ?? ""}|${effectiveSort?.dir ?? ""}`;
|
|
529
|
+
// Bei sort/q-Wechsel rows + cursor reseten — vor dem nächsten useQuery
|
|
530
|
+
// damit der Reload mit cursor=undefined startet.
|
|
531
|
+
useEffect(() => {
|
|
532
|
+
if (!useInfinite) return;
|
|
533
|
+
if (sortQRef.current === sortQKey) return;
|
|
534
|
+
sortQRef.current = sortQKey;
|
|
535
|
+
setAccumulated([]);
|
|
536
|
+
setCursor(undefined);
|
|
537
|
+
setHasMore(true);
|
|
538
|
+
}, [useInfinite, sortQKey]);
|
|
539
|
+
|
|
540
|
+
// Payload für den Server-Query-Handler (LIST_PAYLOAD_SCHEMA):
|
|
541
|
+
// search/sort/sortDirection/limit + offset/totalCount für Pager-Mode
|
|
542
|
+
// ODER cursor für Infinite-Scroll.
|
|
543
|
+
const queryPayload = useMemo(() => {
|
|
544
|
+
const payload: Record<string, unknown> = { limit };
|
|
545
|
+
if (urlState.q !== "") payload["search"] = urlState.q;
|
|
546
|
+
if (effectiveSort !== null) {
|
|
547
|
+
payload["sort"] = effectiveSort.field;
|
|
548
|
+
payload["sortDirection"] = effectiveSort.dir;
|
|
549
|
+
}
|
|
550
|
+
// Screen-Filter (Tier 2.7c) — vom Author am Schema deklariert,
|
|
551
|
+
// unabhängig vom User-q-Search. Mehrere Buckets derselben Entity
|
|
552
|
+
// ("Upcoming" / "Active" / "Past") nutzen unterschiedliche filter
|
|
553
|
+
// bei gleichem Query-Handler.
|
|
554
|
+
if (screen.filter !== undefined) {
|
|
555
|
+
payload["filter"] = screen.filter;
|
|
556
|
+
}
|
|
557
|
+
if (usePager) {
|
|
558
|
+
// page=1 → offset=0, page=2 → offset=limit, etc. Server
|
|
559
|
+
// clampt selbst wenn offset >= total.
|
|
560
|
+
const offset = (urlState.page - 1) * limit;
|
|
561
|
+
if (offset > 0) payload["offset"] = offset;
|
|
562
|
+
// totalCount: extra COUNT(*) damit der Pager "Page X of Y"
|
|
563
|
+
// rendern kann. Bei pagination=false oder "infinite" sparen wir
|
|
564
|
+
// den Roundtrip.
|
|
565
|
+
payload["totalCount"] = true;
|
|
566
|
+
} else if (useInfinite && cursor !== undefined) {
|
|
567
|
+
payload["cursor"] = cursor;
|
|
568
|
+
}
|
|
569
|
+
return payload;
|
|
570
|
+
}, [
|
|
571
|
+
limit,
|
|
572
|
+
urlState.q,
|
|
573
|
+
effectiveSort,
|
|
574
|
+
screen.filter,
|
|
575
|
+
usePager,
|
|
576
|
+
urlState.page,
|
|
577
|
+
useInfinite,
|
|
578
|
+
cursor,
|
|
579
|
+
]);
|
|
580
|
+
|
|
581
|
+
const rowsQuery = useQuery<PagedRows>(queryType, queryPayload, { live: true });
|
|
582
|
+
|
|
583
|
+
// Infinite-Scroll: bei jedem erfolgreichen Result die rows appenden +
|
|
584
|
+
// hasMore aus nextCursor ableiten. Live-Updates (postgres NOTIFY) und
|
|
585
|
+
// initiale Loads laufen beide hier durch — das useEffect-Dep-Array
|
|
586
|
+
// pinnt die Dedup auf dem letzten verarbeiteten data-Pointer.
|
|
587
|
+
const lastDataRef = useRef<PagedRows | null>(null);
|
|
588
|
+
useEffect(() => {
|
|
589
|
+
if (!useInfinite) return;
|
|
590
|
+
const data = rowsQuery.data;
|
|
591
|
+
if (data === null) return;
|
|
592
|
+
if (data === lastDataRef.current) return;
|
|
593
|
+
lastDataRef.current = data;
|
|
594
|
+
setAccumulated((prev) => {
|
|
595
|
+
// Wenn cursor undefined ist, ist das die erste Page nach
|
|
596
|
+
// sort/q-Change → komplett ersetzen statt anhängen. Sonst dedupe
|
|
597
|
+
// auf id falls Live-Updates einen Eintrag in der nächsten Page
|
|
598
|
+
// bringen die schon angehängt war.
|
|
599
|
+
if (cursor === undefined) return data.rows;
|
|
600
|
+
const seen = new Set(prev.map((r) => r["id"] as string));
|
|
601
|
+
const fresh = data.rows.filter((r) => !seen.has(r["id"] as string));
|
|
602
|
+
return [...prev, ...fresh];
|
|
603
|
+
});
|
|
604
|
+
setHasMore(data.nextCursor !== null);
|
|
605
|
+
}, [rowsQuery.data, useInfinite, cursor]);
|
|
606
|
+
|
|
607
|
+
const loadMore = useCallback(() => {
|
|
608
|
+
if (!useInfinite) return;
|
|
609
|
+
if (rowsQuery.loading) return;
|
|
610
|
+
const data = rowsQuery.data;
|
|
611
|
+
if (data?.nextCursor === undefined || data.nextCursor === null) return;
|
|
612
|
+
setCursor(data.nextCursor);
|
|
613
|
+
}, [useInfinite, rowsQuery.loading, rowsQuery.data]);
|
|
614
|
+
|
|
615
|
+
// RowActions: Schema-Form (handler-QN + i18n-Key) → Resolved-Form
|
|
616
|
+
// (dispatcher-Call + translated Strings). dispatcher.write kennt den
|
|
617
|
+
// User intern (JWT-Cookie). Schema kann sowohl raw-Strings als auch
|
|
618
|
+
// i18n-Keys enthalten — translate() returnt den Key wenn das Bundle
|
|
619
|
+
// ihn nicht kennt (Convention überall im Renderer).
|
|
620
|
+
// Hooks-Reihenfolge: ALLE Hooks vor early-return für loading/error,
|
|
621
|
+
// sonst kollidieren die Hook-Slots zwischen Renders.
|
|
622
|
+
const t = useTranslation();
|
|
623
|
+
const effectiveTranslate = translate ?? t;
|
|
624
|
+
// Soft-Dispatcher: in Tests die ohne DispatcherProvider mounten,
|
|
625
|
+
// bleibt rowActions undefined statt zu crashen. Echte Apps haben
|
|
626
|
+
// den Provider via createKumikoApp — wenn nicht, ist es vermutlich
|
|
627
|
+
// ein Setup-Fehler, also einmal warnen damit der Author das findet
|
|
628
|
+
// (sonst rendert die Action-Spalte still nichts und der "warum sind
|
|
629
|
+
// meine Buttons weg?"-Debug ist teuer).
|
|
630
|
+
const dispatcher = useOptionalDispatcher();
|
|
631
|
+
const hasRowActions = screen.rowActions !== undefined && screen.rowActions.length > 0;
|
|
632
|
+
useEffect(() => {
|
|
633
|
+
if (hasRowActions && dispatcher === undefined) {
|
|
634
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
|
|
635
|
+
console.warn(
|
|
636
|
+
`[kumiko] Screen "${screen.id}" deklariert rowActions, aber kein <DispatcherProvider> ist mounted — die Action-Spalte wird nicht gerendert. createKumikoApp() wired den Provider automatisch.`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
}, [hasRowActions, dispatcher, screen.id]);
|
|
640
|
+
// Discriminated Union: writeHandler (default) dispatched einen Server-
|
|
641
|
+
// Handler, navigate ruft nav.navigate() ggf. mit URL-Search-Params aus
|
|
642
|
+
// params(row). nav lebt schon weiter unten in EntityListBody-Scope.
|
|
643
|
+
const rowActions = useMemo(() => {
|
|
644
|
+
if (screen.rowActions === undefined) return undefined;
|
|
645
|
+
return screen.rowActions
|
|
646
|
+
.map((action) => {
|
|
647
|
+
// navigate-Variante braucht keinen Dispatcher; nav ist
|
|
648
|
+
// immer da (Provider von createKumikoApp).
|
|
649
|
+
if (action.kind === "navigate") {
|
|
650
|
+
return {
|
|
651
|
+
id: action.id,
|
|
652
|
+
label: effectiveTranslate(action.label),
|
|
653
|
+
...(action.style !== undefined && { style: action.style }),
|
|
654
|
+
onTrigger: (row: ListRowViewModel) => {
|
|
655
|
+
const params = action.params?.(row.values);
|
|
656
|
+
if (params !== undefined) {
|
|
657
|
+
// setSearchParams nimmt string|null. Komplexe Werte
|
|
658
|
+
// (number/boolean) wandeln wir zu String — der Reader
|
|
659
|
+
// (use-list-url-state / actionForm-init) kennt nur
|
|
660
|
+
// Strings via URL.
|
|
661
|
+
const stringified: Record<string, string | null> = {};
|
|
662
|
+
for (const [k, v] of Object.entries(params)) {
|
|
663
|
+
stringified[k] = v === null || v === undefined ? null : String(v);
|
|
664
|
+
}
|
|
665
|
+
nav.setSearchParams(stringified);
|
|
666
|
+
}
|
|
667
|
+
nav.navigate({ screenId: action.screen });
|
|
668
|
+
},
|
|
669
|
+
...(action.visible !== undefined && {
|
|
670
|
+
isVisible: (row: ListRowViewModel) => action.visible?.(row.values, undefined) ?? true,
|
|
671
|
+
}),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
// writeHandler-Variante (default kind, Backwards-Compat).
|
|
675
|
+
// Braucht Dispatcher — null returnen → filter unten dropt es,
|
|
676
|
+
// damit das useEffect-Warning oben einmal feuert + die Action
|
|
677
|
+
// einfach nicht rendert (statt Crash).
|
|
678
|
+
if (dispatcher === undefined) return null;
|
|
679
|
+
return {
|
|
680
|
+
id: action.id,
|
|
681
|
+
label: effectiveTranslate(action.label),
|
|
682
|
+
...(action.style !== undefined && { style: action.style }),
|
|
683
|
+
...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
|
|
684
|
+
...(action.confirmLabel !== undefined && {
|
|
685
|
+
confirmLabel: effectiveTranslate(action.confirmLabel),
|
|
686
|
+
}),
|
|
687
|
+
onTrigger: async (row: ListRowViewModel) => {
|
|
688
|
+
const buildPayload = action.payload;
|
|
689
|
+
const payload =
|
|
690
|
+
buildPayload !== undefined ? buildPayload(row.values) : { id: row.values["id"] };
|
|
691
|
+
await dispatcher.write(action.handler, payload);
|
|
692
|
+
},
|
|
693
|
+
...(action.visible !== undefined && {
|
|
694
|
+
isVisible: (row: ListRowViewModel) => action.visible?.(row.values, undefined) ?? true,
|
|
695
|
+
}),
|
|
696
|
+
};
|
|
697
|
+
})
|
|
698
|
+
.filter((a): a is NonNullable<typeof a> => a !== null);
|
|
699
|
+
}, [screen.rowActions, effectiveTranslate, dispatcher, nav]);
|
|
700
|
+
|
|
701
|
+
// ToolbarActions: Schema → Resolved-Form (analog rowActions).
|
|
702
|
+
// navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
|
|
703
|
+
// → dispatcher.write(handler, payload?()). KumikoScreen kennt schon
|
|
704
|
+
// useNav (aus dem normalen Routing-Stack).
|
|
705
|
+
const toolbarActions = useMemo(() => {
|
|
706
|
+
if (screen.toolbarActions === undefined) return undefined;
|
|
707
|
+
return screen.toolbarActions
|
|
708
|
+
.map(
|
|
709
|
+
(
|
|
710
|
+
action,
|
|
711
|
+
): {
|
|
712
|
+
id: string;
|
|
713
|
+
label: string;
|
|
714
|
+
style?: "primary" | "secondary" | "danger";
|
|
715
|
+
confirm?: string;
|
|
716
|
+
confirmLabel?: string;
|
|
717
|
+
onTrigger: () => Promise<void> | void;
|
|
718
|
+
} | null => {
|
|
719
|
+
if (action.kind === "navigate") {
|
|
720
|
+
return {
|
|
721
|
+
id: action.id,
|
|
722
|
+
label: effectiveTranslate(action.label),
|
|
723
|
+
...(action.style !== undefined && { style: action.style }),
|
|
724
|
+
onTrigger: () => nav.navigate({ screenId: action.screen }),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
// writeHandler — braucht Dispatcher. Wenn keiner mounted ist,
|
|
728
|
+
// skippen wir die Action statt zu crashen (gleiche Logik wie
|
|
729
|
+
// bei rowActions; einmaliger Warn-Log dort reicht).
|
|
730
|
+
if (dispatcher === undefined) return null;
|
|
731
|
+
return {
|
|
732
|
+
id: action.id,
|
|
733
|
+
label: effectiveTranslate(action.label),
|
|
734
|
+
...(action.style !== undefined && { style: action.style }),
|
|
735
|
+
...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
|
|
736
|
+
...(action.confirmLabel !== undefined && {
|
|
737
|
+
confirmLabel: effectiveTranslate(action.confirmLabel),
|
|
738
|
+
}),
|
|
739
|
+
onTrigger: async () => {
|
|
740
|
+
const payload = action.payload?.() ?? {};
|
|
741
|
+
await dispatcher.write(action.handler, payload);
|
|
742
|
+
},
|
|
743
|
+
};
|
|
744
|
+
},
|
|
745
|
+
)
|
|
746
|
+
.filter((a): a is NonNullable<typeof a> => a !== null);
|
|
747
|
+
}, [screen.toolbarActions, effectiveTranslate, nav, dispatcher]);
|
|
748
|
+
|
|
749
|
+
if (rowsQuery.loading && rowsQuery.data === null) {
|
|
750
|
+
return (
|
|
751
|
+
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
752
|
+
Loading…
|
|
753
|
+
</Banner>
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
if (rowsQuery.error) {
|
|
757
|
+
return (
|
|
758
|
+
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
759
|
+
{rowsQuery.error.i18nKey}
|
|
760
|
+
</Banner>
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// RenderList's onRowClick is a 1-arg callback; KumikoScreen's
|
|
765
|
+
// 2-arg shape (row, entityName) is the public surface — thread the
|
|
766
|
+
// screen's entity through here so callers don't have to re-derive
|
|
767
|
+
// it.
|
|
768
|
+
const wrappedOnRowClick =
|
|
769
|
+
onRowClick !== undefined
|
|
770
|
+
? (row: ListRowViewModel) => onRowClick(row, screen.entity)
|
|
771
|
+
: undefined;
|
|
772
|
+
|
|
773
|
+
// Searchable-Default: explizite Author-Wahl gewinnt, sonst auto-on
|
|
774
|
+
// wenn die Entity searchable Felder hat (sonst wäre die Toolbar-Bar
|
|
775
|
+
// ein toter Slot — Server-Search-Index hat eh nichts zum Filtern).
|
|
776
|
+
const searchable =
|
|
777
|
+
screen.searchable ??
|
|
778
|
+
Object.values(entity.fields).some((f) => "searchable" in f && f.searchable === true);
|
|
779
|
+
|
|
780
|
+
// Pager-Props nur bei pagination="pages" zusammenstellen. Server-
|
|
781
|
+
// total kommt async — bis es da ist, rendert RenderList die Tabelle
|
|
782
|
+
// ohne Pager (Pager hat eine eigene total>0-Guard).
|
|
783
|
+
const total = rowsQuery.data?.total;
|
|
784
|
+
const pager =
|
|
785
|
+
usePager && total !== undefined
|
|
786
|
+
? {
|
|
787
|
+
page: urlState.page,
|
|
788
|
+
limit,
|
|
789
|
+
total,
|
|
790
|
+
onPageChange: urlState.setPage,
|
|
791
|
+
}
|
|
792
|
+
: undefined;
|
|
793
|
+
|
|
794
|
+
// Bei Infinite-Mode: rows kommen aus accumulated (über mehrere
|
|
795
|
+
// useQuery-Calls gesammelt), bei pages/false aus dem aktuellen Result.
|
|
796
|
+
const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
|
|
797
|
+
|
|
798
|
+
return (
|
|
799
|
+
<RenderList
|
|
800
|
+
screen={screen}
|
|
801
|
+
entity={entity}
|
|
802
|
+
rows={renderRows}
|
|
803
|
+
featureName={featureName}
|
|
804
|
+
searchable={searchable}
|
|
805
|
+
searchValue={urlState.q}
|
|
806
|
+
onSearchChange={urlState.setQ}
|
|
807
|
+
sort={effectiveSort}
|
|
808
|
+
onSortChange={urlState.setSort}
|
|
809
|
+
{...(pager !== undefined && { pager })}
|
|
810
|
+
{...(rowActions !== undefined && { rowActions })}
|
|
811
|
+
{...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions })}
|
|
812
|
+
{...(useInfinite && {
|
|
813
|
+
onReachEnd: loadMore,
|
|
814
|
+
loadingMore: rowsQuery.loading,
|
|
815
|
+
hasMore,
|
|
816
|
+
})}
|
|
817
|
+
{...(onCreate !== undefined && { onCreate })}
|
|
818
|
+
{...(translate !== undefined && { translate })}
|
|
819
|
+
{...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
|
|
820
|
+
/>
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ---- actionForm (Tier 2.7d) ----
|
|
825
|
+
|
|
826
|
+
// Action-Form-Body — non-CRUD Write-Handler-driven Form. Re-uses
|
|
827
|
+
// RenderEdit über synthetisierte EntityDefinition + EntityEditScreen-
|
|
828
|
+
// Definition (siehe action-form-shim.ts für die Schulden-Doku). Die
|
|
829
|
+
// Form-Mechanik (useForm, RenderEdit, DefaultInput, Banner, Submit-
|
|
830
|
+
// Button) ist identisch zu entityEdit, nur der Submit-Pfad wechselt
|
|
831
|
+
// vom CRUD-verb auf den Author-deklarierten handler-QN +
|
|
832
|
+
// payloadMode="values" (alle Form-Werte schicken statt nur Changes).
|
|
833
|
+
function ActionFormBody({
|
|
834
|
+
schema,
|
|
835
|
+
screen,
|
|
836
|
+
translate,
|
|
837
|
+
}: {
|
|
838
|
+
readonly schema: FeatureSchema;
|
|
839
|
+
readonly screen: ActionFormScreenDefinition;
|
|
840
|
+
readonly translate?: Translate;
|
|
841
|
+
}): ReactNode {
|
|
842
|
+
const nav = useNav();
|
|
843
|
+
const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
|
|
844
|
+
const synthScreen = useMemo(() => synthesizeActionFormScreen(screen), [screen]);
|
|
845
|
+
// Tier 2.7e-2: URL-Search-Params überschreiben Field-Defaults bei
|
|
846
|
+
// initial values. Use-case: rowAction kind=navigate setzt
|
|
847
|
+
// `?customerId=row-uuid` und der actionForm liest das pre-filled.
|
|
848
|
+
// String-Coercion auf Field-Type: URL kennt nur Strings, aber
|
|
849
|
+
// ein Field mit type:"number" erwartet eine Zahl. Boolean-Strings
|
|
850
|
+
// ("true"/"false") und Number-Strings werden hier coerced; sonst
|
|
851
|
+
// bleibt der String — der Field-Validator beim Submit fängt einen
|
|
852
|
+
// Type-Mismatch ab.
|
|
853
|
+
const initial = useMemo(() => {
|
|
854
|
+
const defaults = buildInitialValues(screen.fields) as Record<string, unknown>; // @cast-boundary render-helper
|
|
855
|
+
const merged: Record<string, unknown> = { ...defaults };
|
|
856
|
+
for (const [name, fieldDef] of Object.entries(screen.fields)) {
|
|
857
|
+
const raw = nav.searchParams[name];
|
|
858
|
+
if (raw === undefined) continue;
|
|
859
|
+
const ftype = (fieldDef as { type?: string }).type;
|
|
860
|
+
if (ftype === "number" || ftype === "money") {
|
|
861
|
+
const parsed = Number(raw);
|
|
862
|
+
merged[name] = Number.isNaN(parsed) ? defaults[name] : parsed;
|
|
863
|
+
} else if (ftype === "boolean") {
|
|
864
|
+
merged[name] = raw === "true";
|
|
865
|
+
} else {
|
|
866
|
+
merged[name] = raw;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return merged as FormValues;
|
|
870
|
+
}, [screen.fields, nav.searchParams]);
|
|
871
|
+
const handleSubmitted = useCallback(
|
|
872
|
+
(result: SubmitResult<unknown>) => {
|
|
873
|
+
// Redirect ist optional. Bei isSuccess + redirect → nav.navigate.
|
|
874
|
+
// Author entscheidet bewusst ob "stay on form" (default) oder
|
|
875
|
+
// "back to list" (typisch bei Create-style Aktionen).
|
|
876
|
+
if (result.isSuccess && screen.redirect !== undefined) {
|
|
877
|
+
nav.navigate({ screenId: screen.redirect });
|
|
878
|
+
}
|
|
879
|
+
},
|
|
880
|
+
[nav, screen.redirect],
|
|
881
|
+
);
|
|
882
|
+
// Cancel ist nur sinnvoll wenn ein Redirect-Target gesetzt ist —
|
|
883
|
+
// sonst hätte der Button nirgendwo hin zu navigieren. Bei Forms
|
|
884
|
+
// ohne redirect bleibt der User per Sidebar/Browser-Back im Flow,
|
|
885
|
+
// analog zu Settings-Pages.
|
|
886
|
+
const handleCancel = useMemo<(() => void) | undefined>(() => {
|
|
887
|
+
if (screen.redirect === undefined) return undefined;
|
|
888
|
+
const target = screen.redirect;
|
|
889
|
+
return () => nav.navigate({ screenId: target });
|
|
890
|
+
}, [nav, screen.redirect]);
|
|
891
|
+
return (
|
|
892
|
+
<RenderEdit
|
|
893
|
+
screen={synthScreen}
|
|
894
|
+
entity={synthEntity}
|
|
895
|
+
featureName={schema.featureName}
|
|
896
|
+
initial={initial}
|
|
897
|
+
writeCommand={screen.handler}
|
|
898
|
+
payloadMode="values"
|
|
899
|
+
onSubmit={handleSubmitted}
|
|
900
|
+
{...(handleCancel !== undefined && { onCancel: handleCancel })}
|
|
901
|
+
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
902
|
+
{...(translate !== undefined && { translate })}
|
|
903
|
+
/>
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// ---- config-edit ----
|
|
908
|
+
//
|
|
909
|
+
// Settings-Form gegen das bundled config-Feature. Liest beim Mount
|
|
910
|
+
// `config:query:values` (returned ALLE Keys die der User lesen darf
|
|
911
|
+
// als `{ [qualifiedKey]: { value, scope } }`); schreibt beim Save
|
|
912
|
+
// pro geändertem Feld einen `config:write:set` Call. Singleton-pro-
|
|
913
|
+
// Tenant kommt by-design vom config-feature (key+tenantId Unique-
|
|
914
|
+
// Constraint) — kein Bridge-Hack, keine extra Aggregate.
|
|
915
|
+
//
|
|
916
|
+
// Parallel-Aufbau zu ActionFormBody: synthesisierte Entity + EntityEdit-
|
|
917
|
+
// Screen damit RenderEdit reused werden kann; Layout/Field-Rendering/
|
|
918
|
+
// Banner/Submit-Button-State sind identisch zu entityEdit. Der einzige
|
|
919
|
+
// Pfad-Unterschied ist customSubmit das mehrere config:write:set-Calls
|
|
920
|
+
// orchestriert.
|
|
921
|
+
type ConfigValueResponse = Readonly<
|
|
922
|
+
Record<string, { value: string | number | boolean | undefined; scope: string }>
|
|
923
|
+
>;
|
|
924
|
+
|
|
925
|
+
function ConfigEditBody({
|
|
926
|
+
schema,
|
|
927
|
+
screen,
|
|
928
|
+
translate,
|
|
929
|
+
}: {
|
|
930
|
+
readonly schema: FeatureSchema;
|
|
931
|
+
readonly screen: ConfigEditScreenDefinition;
|
|
932
|
+
readonly translate?: Translate;
|
|
933
|
+
}): ReactNode {
|
|
934
|
+
const { Banner } = usePrimitives();
|
|
935
|
+
const dispatcher = useDispatcher();
|
|
936
|
+
|
|
937
|
+
// Detail-Load: config:query:values returnt ALLE Keys des Tenants.
|
|
938
|
+
// Wir mappen via screen.configKeys von short → qualified-name auf
|
|
939
|
+
// unsere Form-Field-Werte.
|
|
940
|
+
const valuesQuery = useQuery<ConfigValueResponse>("config:query:values", {});
|
|
941
|
+
|
|
942
|
+
const synthEntity = useMemo(() => synthesizeConfigEditEntity(screen.fields), [screen.fields]);
|
|
943
|
+
const synthScreen = useMemo(() => synthesizeConfigEditScreen(screen), [screen]);
|
|
944
|
+
|
|
945
|
+
// Initial-Values: pro Field-Name den Wert aus `values[qualifiedKey]`
|
|
946
|
+
// abholen. Fehlt der Key auf dem Server (= noch nie gesetzt), nutzen
|
|
947
|
+
// wir den Field-Default (createTextField/createNumberField/...).
|
|
948
|
+
// String-coerce nur für Text-Fields; andere Types sollten in der
|
|
949
|
+
// Response bereits Native-Type sein.
|
|
950
|
+
const initial = useMemo<FormValues | null>(() => {
|
|
951
|
+
if (valuesQuery.data === null) return null;
|
|
952
|
+
const out: Record<string, unknown> = {};
|
|
953
|
+
const defaults = buildInitialValues(screen.fields) as Record<string, unknown>; // @cast-boundary render-helper
|
|
954
|
+
for (const [shortName, fieldDef] of Object.entries(screen.fields)) {
|
|
955
|
+
const qualified = screen.configKeys[shortName];
|
|
956
|
+
if (qualified === undefined) {
|
|
957
|
+
// Author hat ein Field deklariert ohne Mapping — nimm Default.
|
|
958
|
+
// Boot-Validator pinnt das, sollte nie zur Runtime greifen.
|
|
959
|
+
out[shortName] = defaults[shortName];
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
const stored = valuesQuery.data[qualified]?.value;
|
|
963
|
+
if (stored === undefined) {
|
|
964
|
+
out[shortName] = defaults[shortName];
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
const ftype = (fieldDef as { type?: string }).type;
|
|
968
|
+
// Field-Type-Coercion: config-Werte sind string|number|boolean,
|
|
969
|
+
// aber der Form-State erwartet das passende Field-Native-Type.
|
|
970
|
+
if (ftype === "number" || ftype === "money") {
|
|
971
|
+
out[shortName] = typeof stored === "number" ? stored : Number(stored);
|
|
972
|
+
} else if (ftype === "boolean") {
|
|
973
|
+
out[shortName] = typeof stored === "boolean" ? stored : stored === "true";
|
|
974
|
+
} else {
|
|
975
|
+
out[shortName] = typeof stored === "string" ? stored : String(stored);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
return out as FormValues;
|
|
979
|
+
}, [valuesQuery.data, screen.fields, screen.configKeys]);
|
|
980
|
+
|
|
981
|
+
// Multi-Write Submit: ein einzelner /api/batch Call mit N
|
|
982
|
+
// config:write:set Commands. Server-side ist batch atomic
|
|
983
|
+
// (transaktional: alle Writes in einer DB-TX, all-or-nothing) und
|
|
984
|
+
// browser-side ist es genau eine HTTP-Roundtrip — kein Race zwischen
|
|
985
|
+
// mehreren in-flight fetches die der Browser bei page.reload mid-
|
|
986
|
+
// submit aborten könnte. Promise.all von N separaten dispatcher.write-
|
|
987
|
+
// Calls war fragil: server bekommt + commited alle N, aber das
|
|
988
|
+
// Browser-Connection-Pool gibt sporadisch "Failed to fetch" für
|
|
989
|
+
// einzelne Responses zurück, customSubmit returnt failure obwohl der
|
|
990
|
+
// Write durch ist, das Form bleibt dirty.
|
|
991
|
+
const customSubmit = useCallback(
|
|
992
|
+
async (snapshot: FormSnapshot<FormValues>): Promise<SubmitResult<unknown>> => {
|
|
993
|
+
const commands: Command[] = [];
|
|
994
|
+
for (const [shortName, value] of Object.entries(snapshot.changes)) {
|
|
995
|
+
const qualified = screen.configKeys[shortName];
|
|
996
|
+
if (qualified === undefined) continue;
|
|
997
|
+
commands.push({
|
|
998
|
+
type: "config:write:set",
|
|
999
|
+
payload: { key: qualified, value, scope: screen.scope },
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
if (commands.length === 0) {
|
|
1003
|
+
return { validationBlocked: false, isSuccess: true, data: undefined };
|
|
1004
|
+
}
|
|
1005
|
+
const result = await dispatcher.batch(commands);
|
|
1006
|
+
if (!result.isSuccess) {
|
|
1007
|
+
return { validationBlocked: false, isSuccess: false, error: result.error };
|
|
1008
|
+
}
|
|
1009
|
+
return { validationBlocked: false, isSuccess: true, data: undefined };
|
|
1010
|
+
},
|
|
1011
|
+
[dispatcher, screen.configKeys, screen.scope],
|
|
1012
|
+
);
|
|
1013
|
+
|
|
1014
|
+
if (valuesQuery.loading && valuesQuery.data === null) {
|
|
1015
|
+
return (
|
|
1016
|
+
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
1017
|
+
Loading…
|
|
1018
|
+
</Banner>
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
if (valuesQuery.error) {
|
|
1022
|
+
return (
|
|
1023
|
+
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
1024
|
+
{valuesQuery.error.i18nKey}
|
|
1025
|
+
</Banner>
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
if (initial === null) {
|
|
1029
|
+
return (
|
|
1030
|
+
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
1031
|
+
Loading…
|
|
1032
|
+
</Banner>
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
return (
|
|
1036
|
+
<RenderEdit
|
|
1037
|
+
screen={synthScreen}
|
|
1038
|
+
entity={synthEntity}
|
|
1039
|
+
featureName={schema.featureName}
|
|
1040
|
+
initial={initial}
|
|
1041
|
+
customSubmit={customSubmit}
|
|
1042
|
+
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
1043
|
+
{...(translate !== undefined && { translate })}
|
|
1044
|
+
/>
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// Re-export the ScreenDefinition type so callers don't reach into
|
|
1049
|
+
// framework/ui-types for a prop-type they need to narrow on.
|
|
1050
|
+
export type { ScreenDefinition };
|