@cosmicdrift/kumiko-renderer 0.202.0 → 0.204.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 +3 -3
- package/src/app/__tests__/build-list-query-payload.test.ts +107 -0
- package/src/app/__tests__/projection-detail-actions.test.tsx +449 -0
- package/src/app/__tests__/projection-list-search-sort.test.tsx +212 -0
- package/src/app/kumiko-screen.tsx +250 -33
- package/src/app/layout-fields.ts +5 -5
- package/src/app/projection-detail-shim.ts +5 -3
- package/src/app/projection-list-shim.ts +12 -7
- package/src/components/related-list-section.tsx +116 -0
- package/src/components/render-edit-logic.ts +4 -4
- package/src/components/render-edit.tsx +133 -8
- package/src/index.ts +2 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EntityDefinition,
|
|
3
|
+
EntityListScreenDefinition,
|
|
4
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
5
|
+
import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
+
import type {
|
|
7
|
+
EditRelatedListSectionViewModel,
|
|
8
|
+
ListRowViewModel,
|
|
9
|
+
Translate,
|
|
10
|
+
} from "@cosmicdrift/kumiko-headless";
|
|
11
|
+
import { type ReactNode, useMemo } from "react";
|
|
12
|
+
import { useNav } from "../app/nav";
|
|
13
|
+
import { dispatcherErrorText } from "../app/write-failed-error";
|
|
14
|
+
import { useQuery } from "../hooks/use-query";
|
|
15
|
+
import { useTranslation } from "../i18n";
|
|
16
|
+
import { usePrimitives } from "../primitives";
|
|
17
|
+
import { RenderList } from "./render-list";
|
|
18
|
+
|
|
19
|
+
const RELATED_LIST_PSEUDO_ENTITY = "__related-list__";
|
|
20
|
+
|
|
21
|
+
// Same paged envelope as ProjectionListBody's PagedRows (kumiko-screen.tsx) —
|
|
22
|
+
// duplicated locally because that type isn't exported (projectionList and
|
|
23
|
+
// relatedList are independent query call-sites, not a shared abstraction).
|
|
24
|
+
type PagedRows = {
|
|
25
|
+
readonly rows: Readonly<Record<string, unknown>>[];
|
|
26
|
+
readonly nextCursor: string | null;
|
|
27
|
+
readonly total?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Minimal EntityDefinition from the section's own columns — same shape as
|
|
31
|
+
// projection-list-shim's synthesizeProjectionEntity, but relatedList columns
|
|
32
|
+
// aren't sortable (no sort UI on this section, see RelatedListSection below).
|
|
33
|
+
function synthesizeRelatedListEntity(
|
|
34
|
+
columns: EditRelatedListSectionViewModel["columns"],
|
|
35
|
+
): EntityDefinition {
|
|
36
|
+
const fields: Record<string, { type: "text" }> = {};
|
|
37
|
+
for (const col of columns) {
|
|
38
|
+
fields[normalizeListColumn(col).field] = { type: "text" };
|
|
39
|
+
}
|
|
40
|
+
return { fields } as unknown as EntityDefinition;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function RelatedListSection({
|
|
44
|
+
section,
|
|
45
|
+
parentId,
|
|
46
|
+
featureName,
|
|
47
|
+
translate,
|
|
48
|
+
}: {
|
|
49
|
+
readonly section: EditRelatedListSectionViewModel;
|
|
50
|
+
readonly parentId: string;
|
|
51
|
+
readonly featureName: string;
|
|
52
|
+
readonly translate?: Translate;
|
|
53
|
+
}): ReactNode {
|
|
54
|
+
const { Banner, Section } = usePrimitives();
|
|
55
|
+
const t = useTranslation();
|
|
56
|
+
const effectiveTranslate = translate ?? t;
|
|
57
|
+
const nav = useNav();
|
|
58
|
+
|
|
59
|
+
const entity = useMemo(() => synthesizeRelatedListEntity(section.columns), [section.columns]);
|
|
60
|
+
const listScreen = useMemo(
|
|
61
|
+
(): EntityListScreenDefinition => ({
|
|
62
|
+
// Empty id → RenderList's own toolbarTitle resolves to "" (its
|
|
63
|
+
// `screen:${id}.title` lookup misses and falls back to `id`) — this
|
|
64
|
+
// component renders the visible heading itself via `Section` below,
|
|
65
|
+
// so RenderList's toolbar carries none.
|
|
66
|
+
id: "",
|
|
67
|
+
type: "entityList",
|
|
68
|
+
entity: RELATED_LIST_PSEUDO_ENTITY,
|
|
69
|
+
columns: section.columns,
|
|
70
|
+
}),
|
|
71
|
+
[section.columns],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const payload = useMemo(
|
|
75
|
+
() => ({
|
|
76
|
+
[section.parentParam ?? "id"]: parentId,
|
|
77
|
+
...(section.pageSize !== undefined && { limit: section.pageSize }),
|
|
78
|
+
}),
|
|
79
|
+
[section.parentParam, section.pageSize, parentId],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const rowsQuery = useQuery<PagedRows>(section.query, payload);
|
|
83
|
+
|
|
84
|
+
const rowClick = section.rowClick;
|
|
85
|
+
const onRowClick =
|
|
86
|
+
rowClick !== undefined
|
|
87
|
+
? (row: ListRowViewModel) => {
|
|
88
|
+
const id = String(row.values[rowClick.idColumn ?? "id"] ?? "");
|
|
89
|
+
if (id === "") return;
|
|
90
|
+
nav.navigate({ entity: rowClick.entity, id });
|
|
91
|
+
}
|
|
92
|
+
: undefined;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Section title={section.title} testId={`related-list-${section.title}`}>
|
|
96
|
+
{rowsQuery.loading && rowsQuery.data === null ? (
|
|
97
|
+
<Banner padded variant="loading" testId="related-list-loading">
|
|
98
|
+
Loading…
|
|
99
|
+
</Banner>
|
|
100
|
+
) : rowsQuery.error ? (
|
|
101
|
+
<Banner padded variant="error" testId="related-list-error">
|
|
102
|
+
{dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
|
|
103
|
+
</Banner>
|
|
104
|
+
) : (
|
|
105
|
+
<RenderList
|
|
106
|
+
screen={listScreen}
|
|
107
|
+
entity={entity}
|
|
108
|
+
rows={rowsQuery.data?.rows ?? []}
|
|
109
|
+
featureName={featureName}
|
|
110
|
+
translate={effectiveTranslate}
|
|
111
|
+
{...(onRowClick !== undefined && { onRowClick })}
|
|
112
|
+
/>
|
|
113
|
+
)}
|
|
114
|
+
</Section>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -40,9 +40,9 @@ export function shouldNotifyCaller(
|
|
|
40
40
|
return !(result.isSuccess && !extensionsPersisted);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// Extension sections skip the `fields` filter (
|
|
44
|
-
//
|
|
45
|
-
// filtering is dropped, not rendered empty.
|
|
43
|
+
// Extension and relatedList sections skip the `fields` filter (neither has a
|
|
44
|
+
// `field`-name set that filtering applies to); a `fields` section left with
|
|
45
|
+
// zero fields after filtering is dropped, not rendered empty.
|
|
46
46
|
export function filterEditSections(
|
|
47
47
|
sections: readonly EditSectionViewModel[],
|
|
48
48
|
fieldsFilter: readonly string[] | undefined,
|
|
@@ -51,7 +51,7 @@ export function filterEditSections(
|
|
|
51
51
|
const filterSet = new Set(fieldsFilter);
|
|
52
52
|
const result: EditSectionViewModel[] = [];
|
|
53
53
|
for (const section of sections) {
|
|
54
|
-
if (section.kind === "extension") {
|
|
54
|
+
if (section.kind === "extension" || section.kind === "relatedList") {
|
|
55
55
|
result.push(section);
|
|
56
56
|
continue;
|
|
57
57
|
}
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
6
|
import {
|
|
7
7
|
evalFieldCondition,
|
|
8
|
-
|
|
8
|
+
isFieldsEditSection,
|
|
9
9
|
normalizeEditField,
|
|
10
10
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
11
11
|
import type {
|
|
@@ -31,6 +31,7 @@ import { formatWhen } from "../format-when";
|
|
|
31
31
|
import { useForm } from "../hooks/use-form";
|
|
32
32
|
import { useTranslation } from "../i18n";
|
|
33
33
|
import { usePrimitives } from "../primitives";
|
|
34
|
+
import { RelatedListSection } from "./related-list-section";
|
|
34
35
|
import {
|
|
35
36
|
filterEditSections,
|
|
36
37
|
hasEditableSection,
|
|
@@ -136,6 +137,12 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
136
137
|
* platform-neutrale Package darf kein `navigator`/`window` anfassen,
|
|
137
138
|
* siehe guard-renderer-boundaries). undefined = kein Button. */
|
|
138
139
|
readonly onCopyLink?: () => Promise<void> | void;
|
|
140
|
+
/** Header action buttons, rendered before the built-in copy-link/
|
|
141
|
+
* delete/cancel/save controls. Each callback is already fully
|
|
142
|
+
* bound (screen-type/nav/dispatcher resolution happens in the caller,
|
|
143
|
+
* same split as `onCopyLink`) — RenderEdit only wires the button, its
|
|
144
|
+
* busy state and its confirm dialog. */
|
|
145
|
+
readonly actions?: readonly RenderEditAction[];
|
|
139
146
|
/** i18n-key für den Submit-Button. Default: "kumiko.actions.save".
|
|
140
147
|
* Action-Forms (Tier 2.7d) übergeben hier ihren screen.submitLabel,
|
|
141
148
|
* damit "Speichern" durch domain-spezifischere Strings ersetzt
|
|
@@ -205,6 +212,15 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
205
212
|
readonly hideActions?: boolean;
|
|
206
213
|
};
|
|
207
214
|
|
|
215
|
+
export type RenderEditAction = {
|
|
216
|
+
readonly id: string;
|
|
217
|
+
readonly label: string;
|
|
218
|
+
readonly onPress: () => void | Promise<void>;
|
|
219
|
+
readonly style?: "primary" | "secondary" | "danger";
|
|
220
|
+
readonly confirm?: string;
|
|
221
|
+
readonly confirmLabel?: string;
|
|
222
|
+
};
|
|
223
|
+
|
|
208
224
|
export type RenderEditChangeState<TValues extends FormValues> = {
|
|
209
225
|
readonly values: TValues;
|
|
210
226
|
readonly changes: Partial<TValues>;
|
|
@@ -238,7 +254,8 @@ function deriveFormFields<TValues extends FormValues, TCtx>(
|
|
|
238
254
|
): Record<string, FieldConditions<TValues, TCtx>> {
|
|
239
255
|
const out: Record<string, FieldConditions<TValues, TCtx>> = {};
|
|
240
256
|
for (const section of screen.layout.sections) {
|
|
241
|
-
|
|
257
|
+
// relatedList carries no `fields` for the form-condition map either.
|
|
258
|
+
if (!isFieldsEditSection(section)) continue;
|
|
242
259
|
for (const spec of section.fields) {
|
|
243
260
|
const normalized = normalizeEditField(spec);
|
|
244
261
|
out[normalized.field] = {
|
|
@@ -314,6 +331,72 @@ function ExtensionSectionMount({
|
|
|
314
331
|
);
|
|
315
332
|
}
|
|
316
333
|
|
|
334
|
+
// One header action + its own busy/confirm state — same pattern as
|
|
335
|
+
// render-list.tsx's ToolbarActionView (each RenderEditAction is
|
|
336
|
+
// independently bound by the caller, there is no shared trigger pipeline
|
|
337
|
+
// to hook into like the built-in onDelete/onSubmit paths have).
|
|
338
|
+
function RenderEditActionButton({
|
|
339
|
+
action,
|
|
340
|
+
Button,
|
|
341
|
+
Dialog,
|
|
342
|
+
onError,
|
|
343
|
+
}: {
|
|
344
|
+
readonly action: RenderEditAction;
|
|
345
|
+
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
346
|
+
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
347
|
+
readonly onError: (text: string | null) => void;
|
|
348
|
+
}): ReactNode {
|
|
349
|
+
const [busy, setBusy] = useState(false);
|
|
350
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
351
|
+
|
|
352
|
+
const trigger = async (): Promise<void> => {
|
|
353
|
+
setBusy(true);
|
|
354
|
+
onError(null);
|
|
355
|
+
try {
|
|
356
|
+
await action.onPress();
|
|
357
|
+
} catch (e) {
|
|
358
|
+
onError(e instanceof Error ? e.message : String(e));
|
|
359
|
+
} finally {
|
|
360
|
+
setBusy(false);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const variant = action.style ?? "secondary";
|
|
365
|
+
// Same rule as RowActionWriteHandler: "danger" forces a confirm even
|
|
366
|
+
// without an explicit confirm key.
|
|
367
|
+
const needsConfirm = action.confirm !== undefined || action.style === "danger";
|
|
368
|
+
|
|
369
|
+
return (
|
|
370
|
+
<>
|
|
371
|
+
<Button
|
|
372
|
+
type="button"
|
|
373
|
+
variant={variant}
|
|
374
|
+
loading={busy}
|
|
375
|
+
onClick={() => {
|
|
376
|
+
if (needsConfirm) {
|
|
377
|
+
setConfirmOpen(true);
|
|
378
|
+
} else {
|
|
379
|
+
void trigger();
|
|
380
|
+
}
|
|
381
|
+
}}
|
|
382
|
+
testId={`render-edit-action-${action.id}`}
|
|
383
|
+
>
|
|
384
|
+
{action.label}
|
|
385
|
+
</Button>
|
|
386
|
+
<Dialog
|
|
387
|
+
open={confirmOpen}
|
|
388
|
+
onOpenChange={setConfirmOpen}
|
|
389
|
+
title={action.label}
|
|
390
|
+
{...(action.confirm !== undefined && { description: action.confirm })}
|
|
391
|
+
confirmLabel={action.confirmLabel ?? action.label}
|
|
392
|
+
{...(action.style === "danger" && { variant: "danger" as const })}
|
|
393
|
+
onConfirm={trigger}
|
|
394
|
+
testId={`render-edit-action-${action.id}-dialog`}
|
|
395
|
+
/>
|
|
396
|
+
</>
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
317
400
|
export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
318
401
|
props: RenderEditProps<TValues, TCtx>,
|
|
319
402
|
): ReactNode {
|
|
@@ -333,6 +416,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
333
416
|
onCancel,
|
|
334
417
|
onReload,
|
|
335
418
|
onCopyLink,
|
|
419
|
+
actions,
|
|
336
420
|
submitLabel,
|
|
337
421
|
labelAppendix,
|
|
338
422
|
fieldAppendix,
|
|
@@ -362,6 +446,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
362
446
|
const [linkCopied, setLinkCopied] = useState(false);
|
|
363
447
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
364
448
|
const [formError, setFormError] = useState<DispatcherError | null>(null);
|
|
449
|
+
// One state for all header actions (actions?), not per-button — only one
|
|
450
|
+
// action can be in flight at a time, and this keeps the error surfaced in
|
|
451
|
+
// the shared formError-adjacent banner region instead of inside the
|
|
452
|
+
// button row (fw#2166 review: a full-width Banner there breaks the
|
|
453
|
+
// flex justify-end action bar).
|
|
454
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
365
455
|
const [rawStep, setRawStep] = useState(0);
|
|
366
456
|
// Create-mode draftId (issue #1913) — resumed from `sessionStorage` (web)
|
|
367
457
|
// on mount so a same-tab reload finds the right one of several parallel
|
|
@@ -698,14 +788,19 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
698
788
|
const filteredSections = useMemo(
|
|
699
789
|
// A fully-hidden "fields" section (every field in it currently
|
|
700
790
|
// condition-hidden) must not occupy a wizard step; it would render
|
|
701
|
-
// empty and block Back/Next on nothing. Extension
|
|
702
|
-
// `visible` (they own their own
|
|
703
|
-
// pass through. A section with no
|
|
704
|
-
// step) has `visible: fields.some(...)`
|
|
705
|
-
// fields to hide", not "hidden", so it
|
|
791
|
+
// empty and block Back/Next on nothing. Extension and relatedList
|
|
792
|
+
// sections carry no `visible` (they own their own lifecycle / run
|
|
793
|
+
// their own query), so they always pass through. A section with no
|
|
794
|
+
// fields at all (e.g. a review-only step) has `visible: fields.some(...)`
|
|
795
|
+
// = false vacuously; that's "no fields to hide", not "hidden", so it
|
|
796
|
+
// stays too (fw#1901).
|
|
706
797
|
() =>
|
|
707
798
|
filterEditSections(vm.sections, fieldsFilter).filter(
|
|
708
|
-
(section) =>
|
|
799
|
+
(section) =>
|
|
800
|
+
section.kind === "extension" ||
|
|
801
|
+
section.kind === "relatedList" ||
|
|
802
|
+
section.fields.length === 0 ||
|
|
803
|
+
section.visible,
|
|
709
804
|
),
|
|
710
805
|
[vm.sections, fieldsFilter],
|
|
711
806
|
);
|
|
@@ -985,6 +1080,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
985
1080
|
// gegen Fehlklicks. Save bleibt rechts (primary affordance).
|
|
986
1081
|
const formActions = (
|
|
987
1082
|
<>
|
|
1083
|
+
{actions?.map((action) => (
|
|
1084
|
+
<RenderEditActionButton
|
|
1085
|
+
key={action.id}
|
|
1086
|
+
action={action}
|
|
1087
|
+
Button={Button}
|
|
1088
|
+
Dialog={Dialog}
|
|
1089
|
+
onError={setActionError}
|
|
1090
|
+
/>
|
|
1091
|
+
))}
|
|
988
1092
|
{onCopyLink !== undefined && (
|
|
989
1093
|
<Button
|
|
990
1094
|
type="button"
|
|
@@ -1205,6 +1309,22 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1205
1309
|
</WizardStepGroup>
|
|
1206
1310
|
);
|
|
1207
1311
|
}
|
|
1312
|
+
if (section.kind === "relatedList") {
|
|
1313
|
+
// parentId is the displayed record's id — without it there's no
|
|
1314
|
+
// parent row whose related rows could be queried (fw#2166).
|
|
1315
|
+
// Rejected at boot in wizard layouts, so no WizardStepGroup here.
|
|
1316
|
+
const parentId = resolveExtensionEntityId(entityIdProp, vm.id);
|
|
1317
|
+
if (parentId === null) return null;
|
|
1318
|
+
return (
|
|
1319
|
+
<RelatedListSection
|
|
1320
|
+
key={section.title}
|
|
1321
|
+
section={section}
|
|
1322
|
+
parentId={parentId}
|
|
1323
|
+
featureName={featureName}
|
|
1324
|
+
translate={translate}
|
|
1325
|
+
/>
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1208
1328
|
if (!section.visible) return null;
|
|
1209
1329
|
// Section-Header unterdrücken wenn er den Form-Titel der
|
|
1210
1330
|
// Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
|
|
@@ -1282,6 +1402,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1282
1402
|
<Text testId="render-edit-extension-error-key">{translate(extensionErrorKey)}</Text>
|
|
1283
1403
|
</Banner>
|
|
1284
1404
|
)}
|
|
1405
|
+
{actionError !== null && (
|
|
1406
|
+
<Banner variant="error" testId="render-edit-action-error">
|
|
1407
|
+
{actionError}
|
|
1408
|
+
</Banner>
|
|
1409
|
+
)}
|
|
1285
1410
|
{onDelete !== undefined && (
|
|
1286
1411
|
<Dialog
|
|
1287
1412
|
open={confirmDeleteOpen}
|
package/src/index.ts
CHANGED
|
@@ -77,7 +77,9 @@ export { lastSegment } from "./app/qn";
|
|
|
77
77
|
export type { VariableChipsProps } from "./app/variable-chips";
|
|
78
78
|
export { VariableChips } from "./app/variable-chips";
|
|
79
79
|
export { dispatcherErrorText, WriteFailedError } from "./app/write-failed-error";
|
|
80
|
+
export { RelatedListSection } from "./components/related-list-section";
|
|
80
81
|
export type {
|
|
82
|
+
RenderEditAction,
|
|
81
83
|
RenderEditChangeState,
|
|
82
84
|
RenderEditControls,
|
|
83
85
|
RenderEditProps,
|