@cosmicdrift/kumiko-renderer 0.201.0 → 0.203.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__/nav.test.ts +92 -1
- 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 +254 -45
- package/src/app/nav.tsx +43 -2
- package/src/app/projection-list-shim.ts +12 -7
- package/src/app/qualify-screen-id.ts +6 -0
- package/src/components/render-edit.tsx +102 -0
- package/src/index.ts +10 -2
|
@@ -24,14 +24,19 @@ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
|
24
24
|
|
|
25
25
|
const PROJECTION_PSEUDO_ENTITY = "__projection__";
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
/** Minimal EntityDefinition from the column list: every field is a text
|
|
28
|
+
* field. `sortable` applies uniformly to all columns — buildAppSchema
|
|
29
|
+
* derives it from the query's Zod schema (`screen.sortable`, fw#2165); the
|
|
30
|
+
* query itself has no per-field server-sort guarantee.
|
|
31
|
+
* computeListViewModel only reads `fields[<col>].type` — text is enough,
|
|
32
|
+
* presentation comes from the column renderer + explicit label. */
|
|
33
|
+
export function synthesizeProjectionEntity(
|
|
34
|
+
columns: readonly ListColumnSpec[],
|
|
35
|
+
sortable: boolean,
|
|
36
|
+
): EntityDefinition {
|
|
37
|
+
const fields: Record<string, { type: "text"; sortable: boolean }> = {};
|
|
33
38
|
for (const col of columns) {
|
|
34
|
-
fields[normalizeListColumn(col).field] = { type: "text", sortable
|
|
39
|
+
fields[normalizeListColumn(col).field] = { type: "text", sortable };
|
|
35
40
|
}
|
|
36
41
|
return { fields } as unknown as EntityDefinition;
|
|
37
42
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Dependency-free: kumiko-screen.tsx already imports useNav from nav.tsx, so
|
|
2
|
+
// this can't live in either without a cycle. Form must match the registry's
|
|
3
|
+
// qualification rule (packages/framework/src/engine/qualified-name.ts).
|
|
4
|
+
export function qualifyScreenId(featureName: string, screenId: string): string {
|
|
5
|
+
return `${featureName}:screen:${screenId}`;
|
|
6
|
+
}
|
|
@@ -136,6 +136,12 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
136
136
|
* platform-neutrale Package darf kein `navigator`/`window` anfassen,
|
|
137
137
|
* siehe guard-renderer-boundaries). undefined = kein Button. */
|
|
138
138
|
readonly onCopyLink?: () => Promise<void> | void;
|
|
139
|
+
/** Header action buttons, rendered before the built-in copy-link/
|
|
140
|
+
* delete/cancel/save controls. Each callback is already fully
|
|
141
|
+
* bound (screen-type/nav/dispatcher resolution happens in the caller,
|
|
142
|
+
* same split as `onCopyLink`) — RenderEdit only wires the button, its
|
|
143
|
+
* busy state and its confirm dialog. */
|
|
144
|
+
readonly actions?: readonly RenderEditAction[];
|
|
139
145
|
/** i18n-key für den Submit-Button. Default: "kumiko.actions.save".
|
|
140
146
|
* Action-Forms (Tier 2.7d) übergeben hier ihren screen.submitLabel,
|
|
141
147
|
* damit "Speichern" durch domain-spezifischere Strings ersetzt
|
|
@@ -205,6 +211,15 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
205
211
|
readonly hideActions?: boolean;
|
|
206
212
|
};
|
|
207
213
|
|
|
214
|
+
export type RenderEditAction = {
|
|
215
|
+
readonly id: string;
|
|
216
|
+
readonly label: string;
|
|
217
|
+
readonly onPress: () => void | Promise<void>;
|
|
218
|
+
readonly style?: "primary" | "secondary" | "danger";
|
|
219
|
+
readonly confirm?: string;
|
|
220
|
+
readonly confirmLabel?: string;
|
|
221
|
+
};
|
|
222
|
+
|
|
208
223
|
export type RenderEditChangeState<TValues extends FormValues> = {
|
|
209
224
|
readonly values: TValues;
|
|
210
225
|
readonly changes: Partial<TValues>;
|
|
@@ -314,6 +329,72 @@ function ExtensionSectionMount({
|
|
|
314
329
|
);
|
|
315
330
|
}
|
|
316
331
|
|
|
332
|
+
// One header action + its own busy/confirm state — same pattern as
|
|
333
|
+
// render-list.tsx's ToolbarActionView (each RenderEditAction is
|
|
334
|
+
// independently bound by the caller, there is no shared trigger pipeline
|
|
335
|
+
// to hook into like the built-in onDelete/onSubmit paths have).
|
|
336
|
+
function RenderEditActionButton({
|
|
337
|
+
action,
|
|
338
|
+
Button,
|
|
339
|
+
Dialog,
|
|
340
|
+
onError,
|
|
341
|
+
}: {
|
|
342
|
+
readonly action: RenderEditAction;
|
|
343
|
+
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
344
|
+
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
345
|
+
readonly onError: (text: string | null) => void;
|
|
346
|
+
}): ReactNode {
|
|
347
|
+
const [busy, setBusy] = useState(false);
|
|
348
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
349
|
+
|
|
350
|
+
const trigger = async (): Promise<void> => {
|
|
351
|
+
setBusy(true);
|
|
352
|
+
onError(null);
|
|
353
|
+
try {
|
|
354
|
+
await action.onPress();
|
|
355
|
+
} catch (e) {
|
|
356
|
+
onError(e instanceof Error ? e.message : String(e));
|
|
357
|
+
} finally {
|
|
358
|
+
setBusy(false);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const variant = action.style ?? "secondary";
|
|
363
|
+
// Same rule as RowActionWriteHandler: "danger" forces a confirm even
|
|
364
|
+
// without an explicit confirm key.
|
|
365
|
+
const needsConfirm = action.confirm !== undefined || action.style === "danger";
|
|
366
|
+
|
|
367
|
+
return (
|
|
368
|
+
<>
|
|
369
|
+
<Button
|
|
370
|
+
type="button"
|
|
371
|
+
variant={variant}
|
|
372
|
+
loading={busy}
|
|
373
|
+
onClick={() => {
|
|
374
|
+
if (needsConfirm) {
|
|
375
|
+
setConfirmOpen(true);
|
|
376
|
+
} else {
|
|
377
|
+
void trigger();
|
|
378
|
+
}
|
|
379
|
+
}}
|
|
380
|
+
testId={`render-edit-action-${action.id}`}
|
|
381
|
+
>
|
|
382
|
+
{action.label}
|
|
383
|
+
</Button>
|
|
384
|
+
<Dialog
|
|
385
|
+
open={confirmOpen}
|
|
386
|
+
onOpenChange={setConfirmOpen}
|
|
387
|
+
title={action.label}
|
|
388
|
+
{...(action.confirm !== undefined && { description: action.confirm })}
|
|
389
|
+
confirmLabel={action.confirmLabel ?? action.label}
|
|
390
|
+
{...(action.style === "danger" && { variant: "danger" as const })}
|
|
391
|
+
onConfirm={trigger}
|
|
392
|
+
testId={`render-edit-action-${action.id}-dialog`}
|
|
393
|
+
/>
|
|
394
|
+
</>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
317
398
|
export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
318
399
|
props: RenderEditProps<TValues, TCtx>,
|
|
319
400
|
): ReactNode {
|
|
@@ -333,6 +414,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
333
414
|
onCancel,
|
|
334
415
|
onReload,
|
|
335
416
|
onCopyLink,
|
|
417
|
+
actions,
|
|
336
418
|
submitLabel,
|
|
337
419
|
labelAppendix,
|
|
338
420
|
fieldAppendix,
|
|
@@ -362,6 +444,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
362
444
|
const [linkCopied, setLinkCopied] = useState(false);
|
|
363
445
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
364
446
|
const [formError, setFormError] = useState<DispatcherError | null>(null);
|
|
447
|
+
// One state for all header actions (actions?), not per-button — only one
|
|
448
|
+
// action can be in flight at a time, and this keeps the error surfaced in
|
|
449
|
+
// the shared formError-adjacent banner region instead of inside the
|
|
450
|
+
// button row (fw#2166 review: a full-width Banner there breaks the
|
|
451
|
+
// flex justify-end action bar).
|
|
452
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
365
453
|
const [rawStep, setRawStep] = useState(0);
|
|
366
454
|
// Create-mode draftId (issue #1913) — resumed from `sessionStorage` (web)
|
|
367
455
|
// on mount so a same-tab reload finds the right one of several parallel
|
|
@@ -985,6 +1073,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
985
1073
|
// gegen Fehlklicks. Save bleibt rechts (primary affordance).
|
|
986
1074
|
const formActions = (
|
|
987
1075
|
<>
|
|
1076
|
+
{actions?.map((action) => (
|
|
1077
|
+
<RenderEditActionButton
|
|
1078
|
+
key={action.id}
|
|
1079
|
+
action={action}
|
|
1080
|
+
Button={Button}
|
|
1081
|
+
Dialog={Dialog}
|
|
1082
|
+
onError={setActionError}
|
|
1083
|
+
/>
|
|
1084
|
+
))}
|
|
988
1085
|
{onCopyLink !== undefined && (
|
|
989
1086
|
<Button
|
|
990
1087
|
type="button"
|
|
@@ -1282,6 +1379,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1282
1379
|
<Text testId="render-edit-extension-error-key">{translate(extensionErrorKey)}</Text>
|
|
1283
1380
|
</Banner>
|
|
1284
1381
|
)}
|
|
1382
|
+
{actionError !== null && (
|
|
1383
|
+
<Banner variant="error" testId="render-edit-action-error">
|
|
1384
|
+
{actionError}
|
|
1385
|
+
</Banner>
|
|
1386
|
+
)}
|
|
1285
1387
|
{onDelete !== undefined && (
|
|
1286
1388
|
<Dialog
|
|
1287
1389
|
open={confirmDeleteOpen}
|
package/src/index.ts
CHANGED
|
@@ -64,13 +64,21 @@ export type {
|
|
|
64
64
|
export { isAppSchema, toAppSchema } from "./app/feature-schema";
|
|
65
65
|
export type { KumikoScreenProps } from "./app/kumiko-screen";
|
|
66
66
|
export { KumikoScreen, qualifyNavId, qualifyScreenId } from "./app/kumiko-screen";
|
|
67
|
-
export type {
|
|
68
|
-
|
|
67
|
+
export type {
|
|
68
|
+
NavApi,
|
|
69
|
+
NavProviderProps,
|
|
70
|
+
NavRoute,
|
|
71
|
+
NavTarget,
|
|
72
|
+
ObjectTarget,
|
|
73
|
+
ScreenTarget,
|
|
74
|
+
} from "./app/nav";
|
|
75
|
+
export { formatPath, NavProvider, parsePath, resolveTarget, useNav } from "./app/nav";
|
|
69
76
|
export { lastSegment } from "./app/qn";
|
|
70
77
|
export type { VariableChipsProps } from "./app/variable-chips";
|
|
71
78
|
export { VariableChips } from "./app/variable-chips";
|
|
72
79
|
export { dispatcherErrorText, WriteFailedError } from "./app/write-failed-error";
|
|
73
80
|
export type {
|
|
81
|
+
RenderEditAction,
|
|
74
82
|
RenderEditChangeState,
|
|
75
83
|
RenderEditControls,
|
|
76
84
|
RenderEditProps,
|