@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.
@@ -24,14 +24,19 @@ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
24
24
 
25
25
  const PROJECTION_PSEUDO_ENTITY = "__projection__";
26
26
 
27
- /** Minimale EntityDefinition aus den Column-Feldern: jedes Feld ein Text-Feld,
28
- * nicht sortierbar (eine Projection-Query hat keinen garantierten Server-Sort).
29
- * computeListViewModel liest nur `fields[<col>].type` → Text reicht; die
30
- * Präsentation kommt aus dem Column-Renderer + explizitem Label. */
31
- export function synthesizeProjectionEntity(columns: readonly ListColumnSpec[]): EntityDefinition {
32
- const fields: Record<string, { type: "text"; sortable: false }> = {};
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: false };
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 { NavApi, NavProviderProps, NavRoute, NavTarget } from "./app/nav";
68
- export { formatPath, NavProvider, parsePath, useNav } from "./app/nav";
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,