@cosmicdrift/kumiko-renderer 0.43.0 → 0.45.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 +1 -1
- package/src/app/extension-form-submit.tsx +118 -0
- package/src/components/render-edit.tsx +125 -85
- package/src/components/render-list.tsx +11 -8
- package/src/i18n-defaults.ts +2 -2
- package/src/index.ts +8 -0
- package/src/primitives.tsx +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.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>",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Extension-Form-Submit-Registry — erlaubt einer Extension-Section (z.B.
|
|
3
|
+
// Custom-Fields), beim Submit des umgebenden entityEdit-Forms mitzuschreiben,
|
|
4
|
+
// statt einen eigenen Save-Button zu führen ("composed form, ein Save",
|
|
5
|
+
// Bug-Bash 3 #1). Die Section meldet (a) einen Submit-Handler und (b) ihren
|
|
6
|
+
// dirty-State an die Form; die Form aktiviert ihren Save-Button auch wenn nur
|
|
7
|
+
// eine Section dirty ist und ruft nach dem Entity-Write alle Section-Handler
|
|
8
|
+
// mit der entityId.
|
|
9
|
+
//
|
|
10
|
+
// Ohne umgebende Form (Context === null) fällt die Section auf ihren eigenen
|
|
11
|
+
// Save-Button zurück (Standalone-Backward-Compat).
|
|
12
|
+
|
|
13
|
+
import { createContext, useContext, useEffect, useId, useMemo, useRef } from "react";
|
|
14
|
+
|
|
15
|
+
export type ExtensionSubmitContext = { readonly entityId: string };
|
|
16
|
+
|
|
17
|
+
export type ExtensionSubmitResult = {
|
|
18
|
+
readonly isSuccess: boolean;
|
|
19
|
+
/** i18n-Key für die Fehlermeldung, die die Form als Banner zeigt. */
|
|
20
|
+
readonly errorKey?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ExtensionFormSubmitHandler = (
|
|
24
|
+
ctx: ExtensionSubmitContext,
|
|
25
|
+
) => Promise<ExtensionSubmitResult>;
|
|
26
|
+
|
|
27
|
+
type Registration = {
|
|
28
|
+
readonly key: string;
|
|
29
|
+
readonly dirty: boolean;
|
|
30
|
+
readonly handler: ExtensionFormSubmitHandler;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ExtensionFormRegistry = {
|
|
34
|
+
readonly upsert: (reg: Registration) => void;
|
|
35
|
+
readonly remove: (key: string) => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const ExtensionFormRegistryContext = createContext<ExtensionFormRegistry | null>(null);
|
|
39
|
+
|
|
40
|
+
export const ExtensionFormRegistryProvider = ExtensionFormRegistryContext.Provider;
|
|
41
|
+
|
|
42
|
+
// Host-Seite (render-edit): hält die Registrierungen in einem ref, meldet den
|
|
43
|
+
// aggregierten dirty-State via onDirtyChange hoch (damit der Save-Button
|
|
44
|
+
// re-rendert) und liefert runAll() zum Ausführen aller Handler beim Submit.
|
|
45
|
+
export function useExtensionFormHost(onDirtyChange: (anyDirty: boolean) => void): {
|
|
46
|
+
readonly registry: ExtensionFormRegistry;
|
|
47
|
+
readonly runAll: (ctx: ExtensionSubmitContext) => Promise<readonly ExtensionSubmitResult[]>;
|
|
48
|
+
} {
|
|
49
|
+
const regsRef = useRef<Map<string, Registration>>(new Map());
|
|
50
|
+
const onDirtyRef = useRef(onDirtyChange);
|
|
51
|
+
onDirtyRef.current = onDirtyChange;
|
|
52
|
+
|
|
53
|
+
const registry = useMemo<ExtensionFormRegistry>(() => {
|
|
54
|
+
const emitDirty = (): void => {
|
|
55
|
+
let any = false;
|
|
56
|
+
for (const r of regsRef.current.values()) {
|
|
57
|
+
if (r.dirty) {
|
|
58
|
+
any = true;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
onDirtyRef.current(any);
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
upsert: (reg) => {
|
|
66
|
+
regsRef.current.set(reg.key, reg);
|
|
67
|
+
emitDirty();
|
|
68
|
+
},
|
|
69
|
+
remove: (key) => {
|
|
70
|
+
regsRef.current.delete(key);
|
|
71
|
+
emitDirty();
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
const runAll = useMemo(
|
|
77
|
+
() =>
|
|
78
|
+
async (ctx: ExtensionSubmitContext): Promise<readonly ExtensionSubmitResult[]> => {
|
|
79
|
+
const results: ExtensionSubmitResult[] = [];
|
|
80
|
+
// Insertion-Order (Map) = Section-Reihenfolge im Form.
|
|
81
|
+
for (const reg of regsRef.current.values()) {
|
|
82
|
+
results.push(await reg.handler(ctx));
|
|
83
|
+
}
|
|
84
|
+
return results;
|
|
85
|
+
},
|
|
86
|
+
[],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return { registry, runAll };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Section-Seite: meldet Handler + dirty an die umgebende Form. Returnt true
|
|
93
|
+
// wenn eine composed-Form vorhanden ist (Section blendet dann ihren eigenen
|
|
94
|
+
// Save-Button aus); false = standalone (eigener Save-Button bleibt).
|
|
95
|
+
export function useExtensionFormSubmit(opts: {
|
|
96
|
+
readonly dirty: boolean;
|
|
97
|
+
readonly onSubmit: ExtensionFormSubmitHandler;
|
|
98
|
+
}): boolean {
|
|
99
|
+
const registry = useContext(ExtensionFormRegistryContext);
|
|
100
|
+
const key = useId();
|
|
101
|
+
// Handler über ref, damit das Re-Registrieren NICHT an jeder Handler-
|
|
102
|
+
// Identity hängt (nur an dirty); der Handler liest immer den frischen
|
|
103
|
+
// Closure-State (pending) zur Aufruf-Zeit.
|
|
104
|
+
const handlerRef = useRef(opts.onSubmit);
|
|
105
|
+
handlerRef.current = opts.onSubmit;
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (registry === null) return;
|
|
109
|
+
registry.upsert({
|
|
110
|
+
key,
|
|
111
|
+
dirty: opts.dirty,
|
|
112
|
+
handler: (ctx) => handlerRef.current(ctx),
|
|
113
|
+
});
|
|
114
|
+
return () => registry.remove(key);
|
|
115
|
+
}, [registry, key, opts.dirty]);
|
|
116
|
+
|
|
117
|
+
return registry !== null;
|
|
118
|
+
}
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
import { computeEditViewModel } from "@cosmicdrift/kumiko-headless";
|
|
24
24
|
import { type ReactNode, useMemo, useState } from "react";
|
|
25
25
|
import type { z } from "zod";
|
|
26
|
+
import { ExtensionFormRegistryProvider, useExtensionFormHost } from "../app/extension-form-submit";
|
|
26
27
|
import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections";
|
|
27
28
|
import { useForm } from "../hooks/use-form";
|
|
28
29
|
import { useTranslation } from "../i18n";
|
|
@@ -196,6 +197,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
196
197
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
197
198
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
198
199
|
const [formError, setFormError] = useState<DispatcherError | null>(null);
|
|
200
|
+
// Composed-Save: Extension-Sections melden hier ihren dirty-State (damit der
|
|
201
|
+
// Save-Button aktiv wird wenn NUR eine Section geändert wurde) + ihren
|
|
202
|
+
// Submit-Handler (läuft nach dem Entity-Write). extensionErrorKey hält den
|
|
203
|
+
// i18n-Key einer fehlgeschlagenen Section-Persistierung.
|
|
204
|
+
const [extensionDirty, setExtensionDirty] = useState(false);
|
|
205
|
+
const [extensionErrorKey, setExtensionErrorKey] = useState<string | null>(null);
|
|
206
|
+
const { registry: extensionFormRegistry, runAll: runExtensionSubmits } =
|
|
207
|
+
useExtensionFormHost(setExtensionDirty);
|
|
199
208
|
const { Button, Banner, Dialog, Form, Section, Grid, GridCell, Text } = usePrimitives();
|
|
200
209
|
|
|
201
210
|
const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
|
|
@@ -232,9 +241,32 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
232
241
|
[screen, entity, snapshot.values, translate, featureName],
|
|
233
242
|
);
|
|
234
243
|
|
|
244
|
+
// Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
|
|
245
|
+
// false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
|
|
246
|
+
// Entity-Kontext (create-mode ohne route-id) gibt es nichts zu schreiben.
|
|
247
|
+
async function persistExtensions(): Promise<boolean> {
|
|
248
|
+
const entityId = entityIdProp ?? null;
|
|
249
|
+
if (entityId === null) return true;
|
|
250
|
+
const results = await runExtensionSubmits({ entityId });
|
|
251
|
+
const failed = results.find((r) => !r.isSuccess);
|
|
252
|
+
if (failed !== undefined) {
|
|
253
|
+
setExtensionErrorKey(failed.errorKey ?? "kumiko.form.extension.save-failed");
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
|
|
235
259
|
async function handleSubmit(): Promise<void> {
|
|
236
260
|
setIsSubmitting(true);
|
|
261
|
+
setExtensionErrorKey(null);
|
|
237
262
|
try {
|
|
263
|
+
// Extension-only: nur eine Section ist dirty, das Haupt-Form unverändert.
|
|
264
|
+
// Kein Entity-Write (würde einen leeren changes-Payload schreiben) — nur
|
|
265
|
+
// die Section-Handler laufen lassen.
|
|
266
|
+
if (snapshot.isUnchanged && extensionDirty) {
|
|
267
|
+
await persistExtensions();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
238
270
|
let result: SubmitResult<unknown>;
|
|
239
271
|
if (customSubmit !== undefined) {
|
|
240
272
|
// customSubmit-Pfad (z.B. configEdit, das pro Field einen
|
|
@@ -274,6 +306,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
274
306
|
// Field-Errors fließen über snapshot.errors in die einzelnen Fields.
|
|
275
307
|
if (result.isSuccess) {
|
|
276
308
|
setFormError(null);
|
|
309
|
+
await persistExtensions();
|
|
277
310
|
} else if (!result.validationBlocked) {
|
|
278
311
|
const fieldIssues = result.error.details?.fields ?? [];
|
|
279
312
|
setFormError(fieldIssues.length === 0 ? result.error : null);
|
|
@@ -312,7 +345,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
312
345
|
)}
|
|
313
346
|
<Button
|
|
314
347
|
type="submit"
|
|
315
|
-
disabled={snapshot.isUnchanged || isSubmitting}
|
|
348
|
+
disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting}
|
|
316
349
|
loading={isSubmitting}
|
|
317
350
|
variant="primary"
|
|
318
351
|
testId="render-edit-submit"
|
|
@@ -329,94 +362,101 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
329
362
|
const formTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
|
|
330
363
|
|
|
331
364
|
return (
|
|
332
|
-
<
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
365
|
+
<ExtensionFormRegistryProvider value={extensionFormRegistry}>
|
|
366
|
+
<Form
|
|
367
|
+
onSubmit={() => void handleSubmit()}
|
|
368
|
+
title={formTitle}
|
|
369
|
+
actions={formActions}
|
|
370
|
+
testId="render-edit-form"
|
|
371
|
+
>
|
|
372
|
+
{vm.sections.map((section: EditSectionViewModel) => {
|
|
373
|
+
if (section.kind === "extension") {
|
|
374
|
+
return (
|
|
375
|
+
<ExtensionSectionMount
|
|
376
|
+
key={section.title}
|
|
377
|
+
section={section}
|
|
378
|
+
entityName={vm.entityName}
|
|
379
|
+
entityId={entityIdProp !== undefined ? entityIdProp : vm.id}
|
|
380
|
+
initialValues={extensionInitialValues}
|
|
381
|
+
/>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
// Section-Header unterdrücken wenn er den Form-Titel der
|
|
385
|
+
// Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
|
|
386
|
+
// ActionForms, deren Section-Label = Screen-Titel ist).
|
|
387
|
+
const sectionTitle = section.title === formTitle ? undefined : section.title;
|
|
340
388
|
return (
|
|
341
|
-
<
|
|
389
|
+
<Section
|
|
342
390
|
key={section.title}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
391
|
+
{...(sectionTitle !== undefined && { title: sectionTitle })}
|
|
392
|
+
testId={`section-${section.title}`}
|
|
393
|
+
>
|
|
394
|
+
<Grid columns={section.columns}>
|
|
395
|
+
{section.fields.map((field: EditFieldViewModel) => (
|
|
396
|
+
<GridCellForField
|
|
397
|
+
key={field.field}
|
|
398
|
+
field={field}
|
|
399
|
+
columns={section.columns}
|
|
400
|
+
issues={snapshot.errors[field.field]}
|
|
401
|
+
onChange={(v) => {
|
|
402
|
+
(controller.setField as (k: string, v: unknown) => void)(field.field, v);
|
|
403
|
+
}}
|
|
404
|
+
GridCell={GridCell}
|
|
405
|
+
featureName={featureName}
|
|
406
|
+
{...(labelAppendix !== undefined && {
|
|
407
|
+
labelAppendix: labelAppendix(field.field),
|
|
408
|
+
})}
|
|
409
|
+
{...(fieldAppendix !== undefined && {
|
|
410
|
+
fieldAppendix: fieldAppendix(field.field),
|
|
411
|
+
})}
|
|
412
|
+
/>
|
|
413
|
+
))}
|
|
414
|
+
</Grid>
|
|
415
|
+
</Section>
|
|
348
416
|
);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
{section.fields.map((field: EditFieldViewModel) => (
|
|
362
|
-
<GridCellForField
|
|
363
|
-
key={field.field}
|
|
364
|
-
field={field}
|
|
365
|
-
columns={section.columns}
|
|
366
|
-
issues={snapshot.errors[field.field]}
|
|
367
|
-
onChange={(v) => {
|
|
368
|
-
(controller.setField as (k: string, v: unknown) => void)(field.field, v);
|
|
417
|
+
})}
|
|
418
|
+
{formError !== null && (
|
|
419
|
+
<Banner
|
|
420
|
+
variant="error"
|
|
421
|
+
testId="render-edit-form-error"
|
|
422
|
+
actions={
|
|
423
|
+
formError.code === "version_conflict" && onReload !== undefined ? (
|
|
424
|
+
<Button
|
|
425
|
+
variant="secondary"
|
|
426
|
+
onClick={() => {
|
|
427
|
+
onReload();
|
|
428
|
+
setFormError(null);
|
|
369
429
|
}}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
{
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
) : undefined
|
|
401
|
-
}
|
|
402
|
-
>
|
|
403
|
-
<Text testId="render-edit-form-error-key">{translate(formError.i18nKey)}</Text>
|
|
404
|
-
</Banner>
|
|
405
|
-
)}
|
|
406
|
-
{onDelete !== undefined && (
|
|
407
|
-
<Dialog
|
|
408
|
-
open={confirmDeleteOpen}
|
|
409
|
-
onOpenChange={setConfirmDeleteOpen}
|
|
410
|
-
title={translate("kumiko.actions.delete-confirm")}
|
|
411
|
-
confirmLabel={translate("kumiko.actions.delete")}
|
|
412
|
-
variant="danger"
|
|
413
|
-
onConfirm={async () => {
|
|
414
|
-
await onDelete();
|
|
415
|
-
}}
|
|
416
|
-
testId="render-edit-delete-dialog"
|
|
417
|
-
/>
|
|
418
|
-
)}
|
|
419
|
-
</Form>
|
|
430
|
+
testId="render-edit-form-error-reload"
|
|
431
|
+
>
|
|
432
|
+
{translate("kumiko.actions.reload")}
|
|
433
|
+
</Button>
|
|
434
|
+
) : undefined
|
|
435
|
+
}
|
|
436
|
+
>
|
|
437
|
+
<Text testId="render-edit-form-error-key">{translate(formError.i18nKey)}</Text>
|
|
438
|
+
</Banner>
|
|
439
|
+
)}
|
|
440
|
+
{extensionErrorKey !== null && (
|
|
441
|
+
<Banner variant="error" testId="render-edit-extension-error">
|
|
442
|
+
<Text testId="render-edit-extension-error-key">{translate(extensionErrorKey)}</Text>
|
|
443
|
+
</Banner>
|
|
444
|
+
)}
|
|
445
|
+
{onDelete !== undefined && (
|
|
446
|
+
<Dialog
|
|
447
|
+
open={confirmDeleteOpen}
|
|
448
|
+
onOpenChange={setConfirmDeleteOpen}
|
|
449
|
+
title={translate("kumiko.actions.delete-confirm")}
|
|
450
|
+
confirmLabel={translate("kumiko.actions.delete")}
|
|
451
|
+
variant="danger"
|
|
452
|
+
onConfirm={async () => {
|
|
453
|
+
await onDelete();
|
|
454
|
+
}}
|
|
455
|
+
testId="render-edit-delete-dialog"
|
|
456
|
+
/>
|
|
457
|
+
)}
|
|
458
|
+
</Form>
|
|
459
|
+
</ExtensionFormRegistryProvider>
|
|
420
460
|
);
|
|
421
461
|
}
|
|
422
462
|
|
|
@@ -253,14 +253,18 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
253
253
|
/>
|
|
254
254
|
) : undefined;
|
|
255
255
|
|
|
256
|
-
// Toolbar-End-Slot:
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
256
|
+
// Toolbar-End-Slot: optionaler Header-Slot (z.B. Cap-Counter, links im
|
|
257
|
+
// rechten Cluster) + Toolbar-Actions (List-Header-Buttons aus dem Schema)
|
|
258
|
+
// + optional "+ Neu" am rechten Edge. Der Header-Slot lebt IN der Toolbar-
|
|
259
|
+
// Zeile (gleicher Card-Rahmen), nicht als loser Text über dem Screen-Titel
|
|
260
|
+
// (Bug-Bash 3 #12). Reihenfolge = Array-Reihenfolge (Schema-deklariert),
|
|
261
|
+
// "+ Neu" zuletzt weil das die häufigste/auffälligste CTA ist.
|
|
260
262
|
const hasToolbarActions = toolbarActions !== undefined && toolbarActions.length > 0;
|
|
263
|
+
const hasHeaderSlot = screen.slots?.header !== undefined;
|
|
261
264
|
const toolbarEnd =
|
|
262
|
-
hasToolbarActions || onCreate !== undefined ? (
|
|
265
|
+
hasHeaderSlot || hasToolbarActions || onCreate !== undefined ? (
|
|
263
266
|
<>
|
|
267
|
+
{hasHeaderSlot && <ListHeaderSlotMount screen={screen} />}
|
|
264
268
|
{hasToolbarActions &&
|
|
265
269
|
toolbarActions.map((a) => (
|
|
266
270
|
<ToolbarActionView key={a.id} action={a} Button={Button} Dialog={Dialog} />
|
|
@@ -300,7 +304,6 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
300
304
|
// ListSort = DataTableSort (use-list-url-state aliased) — kein Cast nötig.
|
|
301
305
|
return (
|
|
302
306
|
<>
|
|
303
|
-
<ListHeaderSlotMount screen={screen} />
|
|
304
307
|
{referenceColumns.map(
|
|
305
308
|
(rc: { field: string; refEntity: string; refFeature: string; labelField: string }) => (
|
|
306
309
|
<ReferenceLookupBridge
|
|
@@ -334,8 +337,8 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
334
337
|
);
|
|
335
338
|
}
|
|
336
339
|
|
|
337
|
-
// Header-Slot
|
|
338
|
-
// PlatformComponent (`{ react: { __component: "X" } }`), aufgelöst über
|
|
340
|
+
// Header-Slot in der Listen-Toolbar (toolbarEnd): `screen.slots.header` ist
|
|
341
|
+
// eine PlatformComponent (`{ react: { __component: "X" } }`), aufgelöst über
|
|
339
342
|
// dieselbe ExtensionSectionsProvider-Registry wie entityEdit-Sections.
|
|
340
343
|
// entityId ist null (Listen-Kontext, keine Row); die Component lädt ihre
|
|
341
344
|
// Daten selbst (z.B. ein Cap-Counter aus einer usage-Query). Eigene
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -52,7 +52,6 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
52
52
|
"kumiko.config.source.computed": "Berechnet",
|
|
53
53
|
"kumiko.config.source.default": "Standard",
|
|
54
54
|
"kumiko.config.source.missing": "Fehlt",
|
|
55
|
-
"kumiko.config.cascade.preset": "Vorgabe",
|
|
56
55
|
"kumiko.config.cascade.noValue": "Kein Wert gesetzt",
|
|
57
56
|
"kumiko.config.cascade.activeMarker": "aktiv",
|
|
58
57
|
"kumiko.config.cascade.resetTo": "Überschreibung zurücksetzen ({scope})",
|
|
@@ -62,6 +61,7 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
62
61
|
"kumiko.form.error.generic": "Etwas ist schiefgegangen.",
|
|
63
62
|
"kumiko.form.error.version-conflict":
|
|
64
63
|
"Datensatz wurde zwischenzeitlich geändert. Lade neu und versuche es erneut.",
|
|
64
|
+
"kumiko.form.extension.save-failed": "Ein Zusatzfeld konnte nicht gespeichert werden.",
|
|
65
65
|
|
|
66
66
|
// Validation — Default-Reason-Codes aus dem Framework. App-Code
|
|
67
67
|
// kann eigene Codes via Validation-Hooks reinwerfen; die hier sind
|
|
@@ -125,7 +125,6 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
125
125
|
"kumiko.config.source.computed": "Computed",
|
|
126
126
|
"kumiko.config.source.default": "Default",
|
|
127
127
|
"kumiko.config.source.missing": "Missing",
|
|
128
|
-
"kumiko.config.cascade.preset": "Preset",
|
|
129
128
|
"kumiko.config.cascade.noValue": "No value set",
|
|
130
129
|
"kumiko.config.cascade.activeMarker": "active",
|
|
131
130
|
"kumiko.config.cascade.resetTo": "Reset override ({scope})",
|
|
@@ -133,6 +132,7 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
133
132
|
"kumiko.form.error.generic": "Something went wrong.",
|
|
134
133
|
"kumiko.form.error.version-conflict":
|
|
135
134
|
"Record was modified in the meantime. Reload and try again.",
|
|
135
|
+
"kumiko.form.extension.save-failed": "A custom field could not be saved.",
|
|
136
136
|
|
|
137
137
|
"kumiko.validation.required": "Required.",
|
|
138
138
|
"kumiko.validation.invalid": "Invalid value.",
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,13 @@ export type {
|
|
|
18
18
|
export { ColumnRenderersProvider, useColumnRenderer } from "./app/column-renderers";
|
|
19
19
|
export type { CustomScreensMap, CustomScreensProviderProps } from "./app/custom-screens";
|
|
20
20
|
export { CustomScreensProvider, useCustomScreenComponent } from "./app/custom-screens";
|
|
21
|
+
export type {
|
|
22
|
+
ExtensionFormRegistry,
|
|
23
|
+
ExtensionFormSubmitHandler,
|
|
24
|
+
ExtensionSubmitContext,
|
|
25
|
+
ExtensionSubmitResult,
|
|
26
|
+
} from "./app/extension-form-submit";
|
|
27
|
+
export { ExtensionFormRegistryProvider, useExtensionFormSubmit } from "./app/extension-form-submit";
|
|
21
28
|
export type {
|
|
22
29
|
ExtensionSectionComponent,
|
|
23
30
|
ExtensionSectionProps,
|
|
@@ -87,6 +94,7 @@ export type {
|
|
|
87
94
|
CorePrimitives,
|
|
88
95
|
DataTableProps,
|
|
89
96
|
DataTableRowAction,
|
|
97
|
+
DataTableRowActionMode,
|
|
90
98
|
DataTableSort,
|
|
91
99
|
DataTableSortDir,
|
|
92
100
|
DialogProps,
|
package/src/primitives.tsx
CHANGED
|
@@ -302,6 +302,9 @@ export type DataTableSort = {
|
|
|
302
302
|
readonly dir: DataTableSortDir;
|
|
303
303
|
};
|
|
304
304
|
|
|
305
|
+
// Render-Modus der Row-Action-Spalte (siehe DataTableProps.rowActionMode).
|
|
306
|
+
export type DataTableRowActionMode = "adaptive" | "inline";
|
|
307
|
+
|
|
305
308
|
// Resolved-Form einer Row-Action (KumikoScreen baut das aus
|
|
306
309
|
// EntityListScreenDefinition.rowActions): Labels schon translated,
|
|
307
310
|
// handler-QN aufgelöst zu einer onTrigger-Function die den dispatcher
|
|
@@ -348,6 +351,14 @@ export type DataTableProps = {
|
|
|
348
351
|
* Form (Labels + onTrigger schon verdrahtet); DataTable kümmert
|
|
349
352
|
* sich nur um Render + Confirm-Dialog. */
|
|
350
353
|
readonly rowActions?: readonly DataTableRowAction[];
|
|
354
|
+
/** Wie die Row-Action-Spalte rendert:
|
|
355
|
+
* - `"adaptive"` (Default): ≤2 sichtbare Actions inline (rechtsbündig),
|
|
356
|
+
* >2 als Kebab-Dropdown. Passt die Optik automatisch an die Anzahl an.
|
|
357
|
+
* - `"inline"`: IMMER Inline-Buttons, linksbündig — auch bei >2 (kein
|
|
358
|
+
* Kebab). So stehen die Aktionen über alle Rows an derselben x-Position
|
|
359
|
+
* (kein Wandern durch unterschiedlich breite Labels) und alle Listen
|
|
360
|
+
* einer App sehen gleich aus. */
|
|
361
|
+
readonly rowActionMode?: DataTableRowActionMode;
|
|
351
362
|
/** Custom Empty-State-Inhalt (z. B. Icon + Heading + CTA-Button).
|
|
352
363
|
* Default-Renderer rahmt ihn in einer dashed-border Box. */
|
|
353
364
|
readonly emptyState?: ReactNode;
|