@cosmicdrift/kumiko-renderer 0.333.0 → 0.335.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.
Files changed (52) hide show
  1. package/dist/app/config-edit-shim.d.ts +7 -0
  2. package/dist/app/config-edit-shim.js +39 -0
  3. package/dist/app/content-preview.js +7 -9
  4. package/dist/app/extension-sections.d.ts +6 -0
  5. package/dist/app/form-schema.d.ts +2 -1
  6. package/dist/app/form-schema.js +11 -2
  7. package/dist/app/kumiko-screen.d.ts +5 -0
  8. package/dist/app/kumiko-screen.js +259 -101
  9. package/dist/app/nav.d.ts +5 -0
  10. package/dist/app/nav.js +17 -4
  11. package/dist/app/query-state-banners.d.ts +8 -0
  12. package/dist/app/query-state-banners.js +14 -0
  13. package/dist/app/reference-facet-bridge.js +8 -1
  14. package/dist/app/row-actions.d.ts +12 -0
  15. package/dist/app/row-actions.js +24 -7
  16. package/dist/app/screen-access.js +13 -9
  17. package/dist/app/secret-mint-body.js +41 -7
  18. package/dist/app/secrets-edit-body.js +89 -33
  19. package/dist/components/embedded-list-field.js +2 -4
  20. package/dist/components/grid-cell-for-field.d.ts +7 -2
  21. package/dist/components/grid-cell-for-field.js +2 -2
  22. package/dist/components/multi-select-checkboxes.js +4 -1
  23. package/dist/components/query-options-select.d.ts +12 -3
  24. package/dist/components/query-options-select.js +45 -2
  25. package/dist/components/reference-create-dialog.js +9 -4
  26. package/dist/components/related-list-section.js +8 -10
  27. package/dist/components/render-edit-action-button.d.ts +1 -1
  28. package/dist/components/render-edit-action-button.js +5 -1
  29. package/dist/components/render-edit-logic.d.ts +2 -1
  30. package/dist/components/render-edit-logic.js +8 -5
  31. package/dist/components/render-edit-types.d.ts +30 -1
  32. package/dist/components/render-edit.js +248 -76
  33. package/dist/components/render-field.d.ts +7 -1
  34. package/dist/components/render-field.js +40 -15
  35. package/dist/components/render-list.js +6 -6
  36. package/dist/components/write-form-section.d.ts +5 -1
  37. package/dist/components/write-form-section.js +29 -25
  38. package/dist/context/embedded-screen-context.d.ts +5 -0
  39. package/dist/context/embedded-screen-context.js +12 -0
  40. package/dist/hooks/use-reference-lookup.d.ts +7 -0
  41. package/dist/hooks/use-reference-lookup.js +10 -0
  42. package/dist/i18n-defaults.js +30 -5
  43. package/dist/i18n.js +8 -4
  44. package/dist/index.d.ts +5 -1
  45. package/dist/index.js +3 -0
  46. package/dist/primitives.d.ts +70 -19
  47. package/dist/session/session-ended.d.ts +16 -0
  48. package/dist/session/session-ended.js +73 -0
  49. package/dist/sort-by-accessor.d.ts +6 -2
  50. package/dist/sort-by-accessor.js +13 -3
  51. package/package.json +5 -5
  52. package/src/changes.json +40 -0
package/dist/app/nav.js CHANGED
@@ -9,8 +9,21 @@ import { lastSegment } from "./qn.js";
9
9
  // Alles nach den ersten 2/3 Segmenten wird ignoriert — kumikos
10
10
  // Navigation-Grammatik nested nicht weiter. Nested-Routes wäre eine
11
11
  // Spec-Änderung, nicht ein URL-Shape-Unfall.
12
+ function decodeSegments(segments) {
13
+ try {
14
+ return segments.map((segment) => decodeURIComponent(segment));
15
+ }
16
+ catch {
17
+ // Malformed percent-escape ("%zz") — treat the URL as no route.
18
+ return undefined;
19
+ }
20
+ }
12
21
  export function parsePath(pathname, hasWorkspaces) {
13
- const parts = pathname.split("/").filter((p) => p !== "");
22
+ const rawParts = pathname.split("/").filter((p) => p !== "");
23
+ // Only the segments the grammar uses are decoded; ignored trailing ones may be junk.
24
+ const parts = decodeSegments(rawParts.slice(0, hasWorkspaces === true ? 3 : 2));
25
+ if (parts === undefined)
26
+ return undefined;
14
27
  if (hasWorkspaces === true) {
15
28
  const [workspaceId, screenId, entityId] = parts;
16
29
  if (workspaceId === undefined || workspaceId === "")
@@ -41,17 +54,17 @@ export function formatPath(target) {
41
54
  segments.push(target.screenId);
42
55
  if (target.entityId !== undefined)
43
56
  segments.push(target.entityId);
44
- return `/${segments.join("/")}`;
57
+ return `/${segments.map((segment) => encodeURIComponent(segment)).join("/")}`;
45
58
  }
46
59
  // Shared entity→detailFor-screen lookup — resolveTarget's ObjectTarget
47
60
  // branch and hasDetailScreen (create-app's row-click default, fw#2164) both
48
61
  // need "is there a detail screen for this entity", one to resolve it, the
49
62
  // other to just check before falling back to a different default.
50
- function findDetailForScreen(features, entity) {
63
+ export function findDetailForScreen(features, entity) {
51
64
  for (const feature of features) {
52
65
  for (const screen of feature.screens) {
53
66
  if (screen.detailFor === entity)
54
- return { featureName: feature.featureName, screenId: screen.id };
67
+ return { featureName: feature.featureName, screenId: screen.id, screen };
55
68
  }
56
69
  }
57
70
  return undefined;
@@ -0,0 +1,8 @@
1
+ import type { DispatcherError, Translate } from "@cosmicdrift/kumiko-headless";
2
+ import type { ReactNode } from "react";
3
+ export declare function QueryLoadingBanner(): ReactNode;
4
+ export declare function QueryErrorBanner({ error, translate, onRetry, }: {
5
+ readonly error: DispatcherError;
6
+ readonly translate: Translate;
7
+ readonly onRetry: () => void | Promise<void>;
8
+ }): ReactNode;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useTranslation } from "../i18n.js";
3
+ import { usePrimitives } from "../primitives.js";
4
+ import { dispatcherErrorText } from "./write-failed-error.js";
5
+ export function QueryLoadingBanner() {
6
+ const { Banner } = usePrimitives();
7
+ const t = useTranslation();
8
+ return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: t("kumiko.widget.loading") }));
9
+ }
10
+ export function QueryErrorBanner({ error, translate, onRetry, }) {
11
+ const { Banner, Button } = usePrimitives();
12
+ const t = useTranslation();
13
+ return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", actions: _jsx(Button, { variant: "secondary", size: "sm", onClick: onRetry, testId: "kumiko-screen-retry", children: t("kumiko.actions.retry") }), children: dispatcherErrorText(error, translate) }));
14
+ }
@@ -2,14 +2,21 @@ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
2
  // One bridge component per reference facet, mirroring ReferenceLookupBridge —
3
3
  // facet count varies per screen, so the hook can't be called in a loop.
4
4
  import { useEffect } from "react";
5
+ import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
5
6
  import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
6
7
  function ReferenceFacetOptionsBridge({ field, refEntity, refFeature, labelField, onOptions, }) {
7
8
  const lookup = useReferenceLookup(refFeature, refEntity, labelField);
8
9
  useEffect(() => {
9
10
  if (lookup.loading)
10
11
  return;
12
+ // The lookup is one capped list fetch; a full page means the facet may be
13
+ // missing values the user wants to filter by.
14
+ if (lookup.map.size >= REFERENCE_LIST_LOOKUP_LIMIT) {
15
+ // biome-ignore lint/suspicious/noConsole: dev-warning, facet options are silently truncated
16
+ console.warn(`[kumiko] reference facet "${field}" shows only the first ${REFERENCE_LIST_LOOKUP_LIMIT} ${refEntity} rows; further values cannot be filtered by`);
17
+ }
11
18
  onOptions(field, [...lookup.map.entries()].map(([value, label]) => ({ value, label })));
12
- }, [lookup.loading, lookup.map, field, onOptions]);
19
+ }, [lookup.loading, lookup.map, field, refEntity, onOptions]);
13
20
  return null;
14
21
  }
15
22
  // One invisible bridge per "reference" facet spec, each publishing its
@@ -15,6 +15,7 @@ export declare function runProjectionRowNavigate(nav: NavApi, action: RowActionN
15
15
  type OpenDrawer = (action: RowActionDrawer, initialValues: Readonly<Record<string, unknown>> | undefined) => void;
16
16
  /** @internal test-only */
17
17
  export declare function resetDrawerActionWarningsForTests(): void;
18
+ export declare function buildDrawerRowAction(action: RowActionDrawer, translate: Translate, openDrawer: OpenDrawer): DataTableRowAction;
18
19
  export declare function buildProjectionRowActions(options: {
19
20
  readonly rowActions: readonly RowAction[] | undefined;
20
21
  readonly translate: Translate;
@@ -52,6 +53,8 @@ export declare function buildProjectionToolbarActions(options: {
52
53
  readonly record?: Readonly<Record<string, unknown>>;
53
54
  readonly host: ReturnHost | undefined;
54
55
  }): readonly ToolbarActionButton[] | undefined;
56
+ export declare function deletesShownRecord(action: RowActionWriteHandler, payload: Readonly<Record<string, unknown>>, shownRecordId: unknown): boolean;
57
+ export type RecordLeftHandler = (action: RowActionWriteHandler, resultData: unknown, deletedShownRecord: boolean) => void;
55
58
  export declare function buildRecordActions(options: {
56
59
  readonly actions: readonly RowAction[];
57
60
  readonly record: Readonly<Record<string, unknown>>;
@@ -62,6 +65,11 @@ export declare function buildRecordActions(options: {
62
65
  readonly openDrawer: (action: RowActionDrawer, initialValues: Readonly<Record<string, unknown>> | undefined) => void;
63
66
  /** Called after a writeHandler action succeeds (refetch/reload). */
64
67
  readonly onWriteSuccess: () => void | Promise<void>;
68
+ /** Called INSTEAD of `onWriteSuccess` when a writeHandler action succeeds
69
+ * and leaves the shown record (`redirect` set, or it deletes the record).
70
+ * Callers whose actions run on the shown record pass it; callers whose
71
+ * records are list rows omit it. */
72
+ readonly onRecordLeft?: RecordLeftHandler;
65
73
  /** Screen-target navigate default entityId when the action declares no
66
74
  * explicit `entityId` field-source. A caller editing a fixed entity can
67
75
  * pass its own `entityId` here directly (the record it already is). A
@@ -73,5 +81,9 @@ export declare function buildRecordActions(options: {
73
81
  * belongs to (cross-feature lookup) — undefined otherwise. Omitted by
74
82
  * callers with no such cross-screen lookup. */
75
83
  readonly sameEntityScreenId?: (targetScreen: string) => string | undefined;
84
+ /** Default `{ id }` for a writeHandler action declaring no `payload`. A
85
+ * caller editing a fixed entity passes its route id so the payload does
86
+ * not depend on the loaded record carrying an `id` field. */
87
+ readonly defaultWritePayloadId?: string;
76
88
  }): readonly RenderEditAction[] | undefined;
77
89
  export {};
@@ -119,7 +119,7 @@ function warnDrawerActionDropped(actionKind, actionId) {
119
119
  // biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
120
120
  console.warn(`[kumiko] ${actionKind} "${actionId}" is kind:"drawer", but the host did not wire openDrawer (RelatedListSection: pass onOpenDrawer) — it will not render.`);
121
121
  }
122
- function buildDrawerRowAction(action, translate, openDrawer) {
122
+ export function buildDrawerRowAction(action, translate, openDrawer) {
123
123
  const { visible, params } = action;
124
124
  const actionIcon = resolveActionIcon(action.id, action.icon);
125
125
  return {
@@ -269,9 +269,9 @@ export function buildProjectionToolbarActions(options) {
269
269
  return undefined;
270
270
  const out = [];
271
271
  for (const action of toolbarActions) {
272
+ // Fail closed: a conditional action without a record to evaluate against stays hidden.
272
273
  if (action.visible !== undefined &&
273
- record !== undefined &&
274
- !evalFieldCondition(action.visible, record)) {
274
+ (record === undefined || !evalFieldCondition(action.visible, record))) {
275
275
  continue;
276
276
  }
277
277
  if (action.kind === "navigate") {
@@ -356,8 +356,17 @@ function buildDrawerRecordAction(action, options) {
356
356
  },
357
357
  };
358
358
  }
359
+ // Entity delete handler QN: `<feature>:write:<entity>:delete` (see
360
+ // entityWriteCommand in kumiko-screen.tsx).
361
+ const ENTITY_DELETE_HANDLER_PATTERN = /:write:[^:]+:delete$/;
362
+ export function deletesShownRecord(action, payload, shownRecordId) {
363
+ return (ENTITY_DELETE_HANDLER_PATTERN.test(action.handler) &&
364
+ payload["id"] !== undefined &&
365
+ payload["id"] === shownRecordId);
366
+ }
359
367
  function buildWriteHandlerRecordAction(action, options) {
360
- const { record, translate, actionIcon, dispatcher, onWriteSuccess } = options;
368
+ const { record, translate, actionIcon, dispatcher, onWriteSuccess, defaultWritePayloadId, onRecordLeft, } = options;
369
+ const shownRecordId = defaultWritePayloadId ?? record["id"];
361
370
  return {
362
371
  id: action.id,
363
372
  label: translate(action.label),
@@ -371,12 +380,18 @@ function buildWriteHandlerRecordAction(action, options) {
371
380
  onPress: async () => {
372
381
  const payload = action.payload !== undefined
373
382
  ? evalRowExtractor(action.payload, record)
374
- : { id: record["id"] };
383
+ : { id: shownRecordId };
375
384
  const result = await dispatcher.write(action.handler, payload);
376
385
  if (!result.isSuccess) {
377
386
  throw new WriteFailedError(result.error, dispatcherErrorText(result.error, translate));
378
387
  }
379
- await onWriteSuccess();
388
+ const deletedShownRecord = deletesShownRecord(action, payload, shownRecordId);
389
+ if (onRecordLeft !== undefined && (action.redirect !== undefined || deletedShownRecord)) {
390
+ onRecordLeft(action, result.data, deletedShownRecord);
391
+ }
392
+ else {
393
+ await onWriteSuccess();
394
+ }
380
395
  },
381
396
  };
382
397
  }
@@ -387,7 +402,7 @@ function buildWriteHandlerRecordAction(action, options) {
387
402
  // resolved against the record currently being viewed/edited, independent of
388
403
  // any table row.
389
404
  export function buildRecordActions(options) {
390
- const { actions, record, translate, nav, host, dispatcher, openDrawer, onWriteSuccess, defaultScreenTargetEntityId, sameEntityScreenId, } = options;
405
+ const { actions, record, translate, nav, host, dispatcher, openDrawer, onWriteSuccess, onRecordLeft, defaultScreenTargetEntityId, sameEntityScreenId, defaultWritePayloadId, } = options;
391
406
  const out = [];
392
407
  for (const action of actions) {
393
408
  if (action.visible !== undefined && !evalFieldCondition(action.visible, record))
@@ -420,6 +435,8 @@ export function buildRecordActions(options) {
420
435
  actionIcon,
421
436
  dispatcher,
422
437
  onWriteSuccess,
438
+ defaultWritePayloadId,
439
+ onRecordLeft,
423
440
  }));
424
441
  }
425
442
  return out.length > 0 ? out : undefined;
@@ -1,4 +1,5 @@
1
1
  import { isUiAccessGranted } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { findDetailForScreen } from "./nav.js";
2
3
  import { lastSegment } from "./qn.js";
3
4
  // Shared UI-visibility predicate for the screen-render path (#1203 — nav
4
5
  // filtering via filterByAccess in workspace-shell.tsx hid role-gated
@@ -20,15 +21,18 @@ export function navigateTargetAllows(navigate, appFeatures, userRoles) {
20
21
  // forbidden ones.
21
22
  if (appFeatures.length === 0)
22
23
  return true;
23
- for (const feature of appFeatures) {
24
- for (const candidate of feature.screens) {
25
- const isTarget = entity !== undefined
26
- ? candidate.detailFor === entity
27
- : lastSegment(candidate.id) === screen;
28
- // First match IS the destination: short ids are globally unique
29
- // (validateScreenShortIdCollisions) and both the router and
30
- // resolveTarget take the first hit.
31
- if (isTarget)
24
+ // Entity targets share nav's lookup so the gate and resolveTarget can't
25
+ // pick different screens. Short ids are globally unique
26
+ // (validateScreenShortIdCollisions), so the first screen hit is the one.
27
+ if (entity !== undefined) {
28
+ const found = findDetailForScreen(appFeatures, entity);
29
+ if (found !== undefined)
30
+ return isUiAccessGranted(found.screen.access, userRoles);
31
+ }
32
+ else {
33
+ for (const feature of appFeatures) {
34
+ const candidate = feature.screens.find((s) => lastSegment(s.id) === screen);
35
+ if (candidate !== undefined)
32
36
  return isUiAccessGranted(candidate.access, userRoles);
33
37
  }
34
38
  }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useMemo, useRef, useState } from "react";
3
3
  import { RenderEdit } from "../components/render-edit.js";
4
4
  import { useTranslation } from "../i18n.js";
@@ -44,7 +44,7 @@ function isBlank(value) {
44
44
  export function SecretMintBody({ schema, screen, translate }) {
45
45
  const nav = useNav();
46
46
  const returnTarget = useReturnTarget(screen.id);
47
- const { Card, Heading, Banner, Button, Text, Grid, GridCell, SecretReveal } = usePrimitives();
47
+ const { Card, Heading, Banner, Button, Text, Grid, GridCell, Section, SecretReveal } = usePrimitives();
48
48
  const t = useTranslation();
49
49
  const effectiveTranslate = translate ?? t;
50
50
  const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
@@ -63,6 +63,9 @@ export function SecretMintBody({ schema, screen, translate }) {
63
63
  }), [screen.fields, screen.urlPrefillFields, nav.searchParams, synthScreen, handoffValues]);
64
64
  const [revealed, setRevealed] = useState(null);
65
65
  const [done, setDone] = useState(false);
66
+ // The secret was minted server-side but none of the declared reveal fields
67
+ // came back; the mint form must not return (a re-mint would invalidate it).
68
+ const [revealMissing, setRevealMissing] = useState(false);
66
69
  // Never rendered, never merged into `revealed` or the confirm form's
67
70
  // initial values — read only from `buildPayload` at confirm-submit time.
68
71
  const carriedRef = useRef({});
@@ -82,16 +85,36 @@ export function SecretMintBody({ schema, screen, translate }) {
82
85
  continue;
83
86
  values[revealField.field] = value;
84
87
  }
88
+ if (Object.values(values).every(isBlank)) {
89
+ // biome-ignore lint/suspicious/noConsole: the user-facing banner carries no detail; this names the screen and fields for the developer
90
+ console.error(`secretMint "${screen.id}": write result carried no value for reveal fields [${screen.reveal.fields
91
+ .map((revealField) => revealField.field)
92
+ .join(", ")}]`);
93
+ setRevealMissing(true);
94
+ return;
95
+ }
85
96
  setRevealed(values);
86
97
  carriedRef.current =
87
98
  confirm?.carry !== undefined ? extractCarriedValues(result.data, confirm.carry) : {};
88
- }, [screen.reveal.fields, confirm?.carry]);
99
+ }, [screen.id, screen.reveal.fields, confirm?.carry]);
89
100
  const handleCancel = useMemo(() => {
90
101
  const target = screen.cancelTarget ?? screen.redirect;
91
102
  if (target === undefined || target === false)
92
103
  return undefined;
93
- return () => navigateToReturnOr(nav, returnTarget, () => nav.navigate({ screenId: lastSegment(target) }));
104
+ return () => {
105
+ // A native stack keeps this screen mounted after navigate; drop the secret first.
106
+ setRevealed(null);
107
+ carriedRef.current = {};
108
+ navigateToReturnOr(nav, returnTarget, () => nav.navigate({ screenId: lastSegment(target) }));
109
+ };
94
110
  }, [nav, screen.redirect, screen.cancelTarget, returnTarget]);
111
+ // The confirm step must always offer a way out: a failed confirm (e.g. an
112
+ // expired setup token) otherwise leaves a form that can only keep failing.
113
+ // Restarting re-mints, which issues a fresh secret and invalidates this one.
114
+ const restartMint = useCallback(() => {
115
+ setRevealed(null);
116
+ carriedRef.current = {};
117
+ }, []);
95
118
  // Ends the reveal phase for both paths (the bare acknowledge button, and a
96
119
  // successful confirm submit): clears the secret and the carried values, then
97
120
  // either navigates (screen.redirect) or shows a done-state — never falls
@@ -113,7 +136,10 @@ export function SecretMintBody({ schema, screen, translate }) {
113
136
  finishMint();
114
137
  }, [finishMint]);
115
138
  if (done) {
116
- return (_jsx(Card, { children: _jsx(Banner, { variant: "info", testId: "kumiko-screen-secret-mint-done", children: effectiveTranslate(confirm?.doneMessage ?? "kumiko.secretMint.done") }) }));
139
+ return (_jsx(Card, { options: { screenBody: true }, children: _jsx(Banner, { variant: "info", testId: "kumiko-screen-secret-mint-done", children: effectiveTranslate(confirm?.doneMessage ?? "kumiko.secretMint.done") }) }));
140
+ }
141
+ if (revealMissing) {
142
+ return (_jsx(Card, { options: { screenBody: true }, children: _jsx(Banner, { variant: "error", testId: "kumiko-screen-secret-mint-missing", children: effectiveTranslate("kumiko.form.error.generic") }) }));
117
143
  }
118
144
  if (revealed !== null) {
119
145
  const values = screen.reveal.fields.flatMap((revealField) => {
@@ -134,7 +160,15 @@ export function SecretMintBody({ schema, screen, translate }) {
134
160
  },
135
161
  ];
136
162
  });
137
- return (_jsxs(Card, { testId: "kumiko-screen-secret-mint-card", children: [_jsx(Heading, { variant: "page", children: effectiveTranslate(screen.reveal.title ?? "kumiko.secretMint.title") }), _jsx(Banner, { variant: "warning", testId: "kumiko-screen-secret-mint-warning", children: effectiveTranslate(screen.reveal.warning ?? "kumiko.secretMint.warning") }), SecretReveal !== undefined ? (_jsx(SecretReveal, { values: values, copyLabel: effectiveTranslate("kumiko.secretMint.copy"), copiedLabel: effectiveTranslate("kumiko.secretMint.copied"), testId: "kumiko-screen-secret-mint-reveal" })) : (_jsx(Grid, { columns: 1, testId: "kumiko-screen-secret-mint-reveal", children: values.map((v) => (_jsxs(GridCell, { children: [_jsx(Text, { children: v.label }), _jsx(Text, { variant: "code", children: v.value })] }, v.label))) })), confirm !== undefined && confirmEntity !== undefined && confirmScreen !== undefined ? (_jsx(RenderEdit, { screen: confirmScreen, entity: confirmEntity, featureName: schema.featureName, initial: confirmInitial ?? {}, writeCommand: confirm.handler, payloadMode: "values", buildPayload: (snapshot) => ({ ...snapshot.values, ...carriedRef.current }), onSubmit: handleConfirmSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(translate !== undefined && { translate }), ...(confirm.submitLabel !== undefined && { submitLabel: confirm.submitLabel }) })) : (_jsx(Button, { type: "button", variant: "primary", onClick: finishMint, testId: "kumiko-screen-secret-mint-confirm", children: effectiveTranslate(screen.reveal.confirmLabel ?? "kumiko.secretMint.confirm") }))] }));
163
+ const revealContent = (_jsxs(_Fragment, { children: [_jsx(Heading, { variant: "page", children: effectiveTranslate(screen.reveal.title ?? "kumiko.secretMint.title") }), _jsx(Banner, { variant: "warning", testId: "kumiko-screen-secret-mint-warning", children: effectiveTranslate(screen.reveal.warning ?? "kumiko.secretMint.warning") }), SecretReveal !== undefined ? (_jsx(SecretReveal, { values: values, copyLabel: effectiveTranslate("kumiko.secretMint.copy"), copiedLabel: effectiveTranslate("kumiko.secretMint.copied"), testId: "kumiko-screen-secret-mint-reveal" })) : (_jsx(Grid, { columns: 1, testId: "kumiko-screen-secret-mint-reveal", children: values.map((v) => (_jsxs(GridCell, { children: [_jsx(Text, { children: v.label }), _jsx(Text, { variant: "code", children: v.value })] }, v.label))) }))] }));
164
+ // One screen form like the mint phase: the reveal leads the form column
165
+ // instead of the confirm form nesting a second card inside a card. The
166
+ // keys stop the mint and confirm forms sharing one instance (and its
167
+ // submit state) across phases.
168
+ if (confirm !== undefined && confirmEntity !== undefined && confirmScreen !== undefined) {
169
+ return (_jsx(RenderEdit, { screen: confirmScreen, i18nScreenId: screen.id, entity: confirmEntity, featureName: schema.featureName, initial: confirmInitial ?? {}, writeCommand: confirm.handler, payloadMode: "values", buildPayload: (snapshot) => ({ ...snapshot.values, ...carriedRef.current }), onSubmit: handleConfirmSubmitted, onCancel: handleCancel ?? restartMint, fillScreenHeight: true, leadContent: _jsx(Section, { testId: "kumiko-screen-secret-mint-card", children: revealContent }), ...(translate !== undefined && { translate }), ...(confirm.submitLabel !== undefined && { submitLabel: confirm.submitLabel }) }, "confirm"));
170
+ }
171
+ return (_jsxs(Card, { options: { screenBody: true }, testId: "kumiko-screen-secret-mint-card", children: [revealContent, _jsx(Button, { type: "button", variant: "primary", onClick: finishMint, testId: "kumiko-screen-secret-mint-confirm", children: effectiveTranslate(screen.reveal.confirmLabel ?? "kumiko.secretMint.confirm") })] }));
138
172
  }
139
- return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, extensionInitialValues: initial, writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(translate !== undefined && { translate }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) }));
173
+ return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, extensionInitialValues: initial, writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, fillScreenHeight: true, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(translate !== undefined && { translate }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) }, "mint"));
140
174
  }
@@ -3,13 +3,23 @@ import { useCallback, useMemo, useState } from "react";
3
3
  import { useDispatcher } from "../context/dispatcher-context.js";
4
4
  import { useQuery } from "../hooks/use-query.js";
5
5
  import { useTranslation } from "../i18n.js";
6
- import { usePrimitives } from "../primitives.js";
6
+ import { STICKY_PRIMARY_ACTION_PROP, usePrimitives } from "../primitives.js";
7
+ import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
7
8
  import { dispatcherErrorText } from "./write-failed-error.js";
9
+ function groupSectionsByTitle(sections) {
10
+ const byTitle = new Map();
11
+ for (const section of sections) {
12
+ const title = section.title ?? "config.secrets.section";
13
+ // kumiko-lint-ignore section-fields-raw secretsEdit section fields are field-id strings, not EditFieldSpec
14
+ byTitle.set(title, [...(byTitle.get(title) ?? []), ...section.fields]);
15
+ }
16
+ return [...byTitle].map(([title, fields]) => ({ title, fields }));
17
+ }
8
18
  // secrets:query:list never returns plaintext (only a redacted preview), so
9
19
  // unlike ConfigEditBody there is no server value to pre-fill a draft with —
10
20
  // every input starts at "" and stays that way unless the user types into it.
11
21
  export function SecretsEditBody({ screen, translate }) {
12
- const { Banner, Form, Section, Field, Input, Button, Text } = usePrimitives();
22
+ const { Banner, Dialog, Form, Section, Field, Input, Button, Text, Grid } = usePrimitives();
13
23
  const t = useTranslation();
14
24
  const effectiveTranslate = translate ?? t;
15
25
  const dispatcher = useDispatcher();
@@ -17,6 +27,8 @@ export function SecretsEditBody({ screen, translate }) {
17
27
  const [drafts, setDrafts] = useState({});
18
28
  const [submitting, setSubmitting] = useState(false);
19
29
  const [submitError, setSubmitError] = useState(null);
30
+ const [pendingDeleteKey, setPendingDeleteKey] = useState(null);
31
+ const [deleting, setDeleting] = useState(false);
20
32
  const rowsByQualifiedKey = useMemo(() => {
21
33
  const out = new Map();
22
34
  for (const row of listQuery.data ?? [])
@@ -35,42 +47,86 @@ export function SecretsEditBody({ screen, translate }) {
35
47
  return;
36
48
  setSubmitting(true);
37
49
  setSubmitError(null);
38
- const result = await dispatcher.batch(commands);
39
- setSubmitting(false);
40
- if (!result.isSuccess) {
41
- setSubmitError(dispatcherErrorText(result.error, effectiveTranslate));
42
- return;
50
+ try {
51
+ const result = await dispatcher.batch(commands);
52
+ if (!result.isSuccess) {
53
+ setSubmitError(dispatcherErrorText(result.error, effectiveTranslate));
54
+ return;
55
+ }
56
+ setDrafts({});
57
+ await listQuery.refetch();
58
+ }
59
+ catch {
60
+ // Transport failures reject instead of returning a result; drafts stay
61
+ // so the user can retry without retyping the secrets.
62
+ setSubmitError(effectiveTranslate("kumiko.form.error.generic"));
63
+ }
64
+ finally {
65
+ setSubmitting(false);
43
66
  }
44
- setDrafts({});
45
- await listQuery.refetch();
46
67
  }, [dispatcher, drafts, screen.secretKeys, listQuery.refetch, effectiveTranslate]);
47
68
  const handleDelete = useCallback(async (qualified) => {
48
- const result = await dispatcher.write("secrets:write:delete", { key: qualified });
49
- if (!result.isSuccess) {
50
- setSubmitError(dispatcherErrorText(result.error, effectiveTranslate));
51
- return;
69
+ setDeleting(true);
70
+ try {
71
+ const result = await dispatcher.write("secrets:write:delete", { key: qualified });
72
+ if (!result.isSuccess) {
73
+ setSubmitError(dispatcherErrorText(result.error, effectiveTranslate));
74
+ return;
75
+ }
76
+ setSubmitError(null);
77
+ await listQuery.refetch();
78
+ }
79
+ catch {
80
+ setSubmitError(effectiveTranslate("kumiko.form.error.generic"));
81
+ }
82
+ finally {
83
+ setDeleting(false);
52
84
  }
53
- await listQuery.refetch();
54
85
  }, [dispatcher, listQuery.refetch, effectiveTranslate]);
55
- if (listQuery.loading && listQuery.data === null) {
56
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
57
- }
86
+ if (listQuery.loading && listQuery.data === null)
87
+ return _jsx(QueryLoadingBanner, {});
58
88
  if (listQuery.error) {
59
- return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(listQuery.error, effectiveTranslate) }));
89
+ return (_jsx(QueryErrorBanner, { error: listQuery.error, translate: effectiveTranslate, onRetry: listQuery.refetch }));
60
90
  }
61
- return (_jsxs(Form, { onSubmit: () => {
62
- void handleSubmit();
63
- }, testId: "secrets-edit-form", ...(screen.description !== undefined && {
64
- subtitle: effectiveTranslate(screen.description),
65
- }), actions: _jsx(Button, { type: "submit", variant: "primary", loading: submitting, disabled: submitting, testId: "secrets-edit-submit", children: effectiveTranslate("kumiko.actions.save") }), children: [submitError !== null && (_jsx(Banner, { variant: "error", testId: "secrets-edit-error", children: submitError })), screen.sections.map((section, index) => (_jsx(Section, { ...(section.title !== undefined && { title: effectiveTranslate(section.title) }), children: section.fields.map((fieldId) => {
66
- const qualified = screen.secretKeys[fieldId];
67
- if (qualified === undefined)
68
- return null;
69
- const row = rowsByQualifiedKey.get(qualified);
70
- const hintKey = screen.fieldHints?.[fieldId];
71
- const isRequired = screen.requiredFields?.includes(fieldId) ?? false;
72
- return (_jsxs(Field, { id: fieldId, label: effectiveTranslate(screen.fieldLabels[fieldId] ?? fieldId), required: isRequired, testId: `field-${fieldId}`, fieldAppendix: _jsxs(_Fragment, { children: [hintKey !== undefined && (_jsx(Text, { variant: "small", children: effectiveTranslate(hintKey) })), row !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "small", testId: `secret-preview-${fieldId}`, children: row.redactedPreview ?? effectiveTranslate("config.secrets.set") }), _jsx(Button, { type: "button", variant: "danger-ghost", size: "sm", onClick: () => handleDelete(qualified), testId: `secret-delete-${fieldId}`, children: effectiveTranslate("config.secrets.delete") })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "small", testId: `secret-not-set-${fieldId}`, children: effectiveTranslate("config.secrets.notSet") }), isRequired && (_jsx(Text, { variant: "small", testId: `required-marker-${fieldId}`, children: effectiveTranslate("config.secrets.required") }))] }))] }), children: [_jsx(Text, { variant: "small", children: effectiveTranslate(row !== undefined
73
- ? "config.secrets.replacePlaceholder"
74
- : "config.secrets.placeholder") }), _jsx(Input, { kind: "password", id: fieldId, name: fieldId, value: drafts[fieldId] ?? "", onChange: (v) => setDraft(fieldId, v), autoComplete: "new-password", disabled: submitting, testId: `secret-input-${fieldId}` })] }, fieldId));
75
- }) }, section.title ?? `section-${index}`)))] }));
91
+ // The generator emits one section per feature; features without a declared
92
+ // title all fall under the generic "Secrets" heading and share one band.
93
+ const sectionGroups = groupSectionsByTitle(screen.sections);
94
+ const unsavedCount = Object.values(drafts).filter((value) => value.trim() !== "").length;
95
+ return (_jsxs(_Fragment, { children: [_jsxs(Form, { onSubmit: () => {
96
+ void handleSubmit();
97
+ }, testId: "secrets-edit-form", width: "full", fillHeight: true, stickyActions: true, screenForm: true, unsavedCount: unsavedCount, ...(screen.description !== undefined && {
98
+ subtitle: effectiveTranslate(screen.description),
99
+ }), actions: _jsxs(_Fragment, { children: [unsavedCount > 0 && (_jsx(Button, { type: "button", variant: "ghost", disabled: submitting, onClick: () => setDrafts({}), testId: "secrets-edit-discard", [STICKY_PRIMARY_ACTION_PROP]: true, children: effectiveTranslate("kumiko.form.discard") })), _jsx(Button, { type: "submit", variant: "primary", loading: submitting, disabled: submitting || unsavedCount === 0, testId: "secrets-edit-submit", children: effectiveTranslate("kumiko.form.saveChanges") })] }), children: [submitError !== null && (_jsx(Banner, { variant: "error", testId: "secrets-edit-error", children: submitError })), sectionGroups.map((section) => (_jsx(Section, { layout: "settings-list", title: effectiveTranslate(section.title), subtitle: effectiveTranslate("config.secrets.description"), children: _jsx(Grid, { columns: 1, list: true, children: section.fields.map((fieldId) => {
100
+ const qualified = screen.secretKeys[fieldId];
101
+ if (qualified === undefined)
102
+ return null;
103
+ const row = rowsByQualifiedKey.get(qualified);
104
+ const hintKey = screen.fieldHints?.[fieldId];
105
+ const isRequired = screen.requiredFields?.includes(fieldId) ?? false;
106
+ return (_jsx(Field, { id: fieldId, layout: "row", label: effectiveTranslate(screen.fieldLabels[fieldId] ?? fieldId), required: isRequired, testId: `field-${fieldId}`, status: row !== undefined
107
+ ? {
108
+ tone: "ok",
109
+ label: effectiveTranslate("config.secrets.saved"),
110
+ testId: `secret-saved-${fieldId}`,
111
+ }
112
+ : {
113
+ tone: isRequired ? "bad" : "muted",
114
+ label: effectiveTranslate("config.secrets.notSet"),
115
+ testId: `secret-not-set-${fieldId}`,
116
+ }, ...(hintKey !== undefined && { description: effectiveTranslate(hintKey) }), ...(row !== undefined && {
117
+ fieldAppendix: (_jsxs(Grid, { columns: "auto", children: [_jsx(Text, { variant: "muted", testId: `secret-preview-${fieldId}`, children: row.redactedPreview !== null
118
+ ? effectiveTranslate("config.secrets.stored", {
119
+ preview: row.redactedPreview,
120
+ })
121
+ : effectiveTranslate("config.secrets.set") }), _jsx(Button, { type: "button", variant: "danger-ghost", size: "sm", disabled: submitting || deleting, onClick: () => setPendingDeleteKey(qualified), testId: `secret-delete-${fieldId}`, children: effectiveTranslate("config.secrets.delete") })] })),
122
+ }), children: _jsx(Input, { kind: "password", id: fieldId, name: fieldId, value: drafts[fieldId] ?? "", onChange: (v) => setDraft(fieldId, v), placeholder: effectiveTranslate(row !== undefined
123
+ ? "config.secrets.replacePlaceholder"
124
+ : "config.secrets.placeholder"), autoComplete: "new-password", disabled: submitting, testId: `secret-input-${fieldId}` }) }, fieldId));
125
+ }) }) }, section.title)))] }), _jsx(Dialog, { open: pendingDeleteKey !== null, onOpenChange: (open) => {
126
+ if (!open)
127
+ setPendingDeleteKey(null);
128
+ }, title: effectiveTranslate("config.secrets.delete"), description: effectiveTranslate("config.secrets.deleteConfirm"), confirmLabel: effectiveTranslate("config.secrets.delete"), variant: "danger", initialFocus: "cancel", onConfirm: async () => {
129
+ if (pendingDeleteKey !== null)
130
+ await handleDelete(pendingDeleteKey);
131
+ }, testId: "secrets-delete-dialog" })] }));
76
132
  }
@@ -3,7 +3,7 @@ import { computeDerivedCellValue, currencyDecimals, groupEmbeddedListIssues, rou
3
3
  import { useState } from "react";
4
4
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
5
5
  import { useQuery } from "../hooks/use-query.js";
6
- import { referenceLookupSource } from "../hooks/use-reference-lookup.js";
6
+ import { referenceOptionSource } from "../hooks/use-reference-lookup.js";
7
7
  import { useTranslation } from "../i18n.js";
8
8
  import { usePrimitives } from "../primitives.js";
9
9
  function withRecomputedDerived(row, derived, cells) {
@@ -107,9 +107,7 @@ export function EmbeddedListField({ field, id, onChange, allIssues, featureName,
107
107
  const rows = Array.isArray(field.value) ? field.value : [];
108
108
  const derived = field.embeddedListDerived;
109
109
  const referenceCells = cells.filter((c) => c.type === "reference");
110
- const referenceSources = referenceCells.map((cell) => cell.refOptionsQuery !== undefined
111
- ? { queryQn: cell.refOptionsQuery, labelKey: "label" }
112
- : referenceLookupSource(cell.refFeature ?? featureName, cell.refEntity ?? "", cell.refLabelField ?? "id"));
110
+ const referenceSources = referenceCells.map((cell) => referenceOptionSource(cell, cell.refFeature ?? featureName, cell.refEntity ?? ""));
113
111
  const referenceQueries = referenceSources.map((source) =>
114
112
  // biome-ignore lint/correctness/useHookAtTopLevel: referenceCells comes from the entity-schema definition — fixed for the screen's lifetime, not a real conditional-hook risk.
115
113
  useQuery(source.queryQn, {
@@ -1,6 +1,6 @@
1
1
  import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
2
2
  import type { ReactNode } from "react";
3
- import type { FieldCellWidth, usePrimitives } from "../primitives.js";
3
+ import type { FieldCellWidth, FieldProps, usePrimitives } from "../primitives.js";
4
4
  export type GridCellForFieldProps = {
5
5
  readonly field: EditFieldViewModel;
6
6
  readonly columns: number;
@@ -12,6 +12,9 @@ export type GridCellForFieldProps = {
12
12
  readonly featureName: string;
13
13
  readonly labelAppendix?: ReactNode;
14
14
  readonly fieldAppendix?: ReactNode;
15
+ readonly appendixPlacement?: FieldProps["appendixPlacement"];
16
+ readonly description?: ReactNode;
17
+ readonly status?: FieldProps["status"];
15
18
  /** Full issues-by-path map (FormSnapshot.errors) — passed through for
16
19
  * embedded-list fields, which bucket row-/cell-level issues themselves. */
17
20
  readonly allIssues: Readonly<Record<string, readonly FieldIssue[]>>;
@@ -21,6 +24,8 @@ export type GridCellForFieldProps = {
21
24
  readonly row: Readonly<Record<string, unknown>>;
22
25
  readonly changed?: boolean;
23
26
  readonly flow?: boolean;
27
+ readonly layout?: FieldProps["layout"];
28
+ readonly accent?: boolean;
24
29
  };
25
30
  export declare function fieldCellWidth(field: EditFieldViewModel): FieldCellWidth;
26
- export declare function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, allIssues, valueDisplay, row, changed, flow, }: GridCellForFieldProps): ReactNode;
31
+ export declare function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, }: GridCellForFieldProps): ReactNode;
@@ -29,10 +29,10 @@ export function fieldCellWidth(field) {
29
29
  return "full";
30
30
  return FIELD_CELL_WIDTH_BY_TYPE[field.type] ?? "text";
31
31
  }
32
- export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, allIssues, valueDisplay, row, changed, flow, }) {
32
+ export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, }) {
33
33
  // RenderField renders nothing for a hidden field, but the GridCell around it still claims the row.
34
34
  if (!field.visible)
35
35
  return null;
36
36
  const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
37
- return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }) }) }));
37
+ return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }) }) }));
38
38
  }
@@ -32,5 +32,8 @@ export function MultiSelectCheckboxes({ field, id, options, value, onChange, })
32
32
  };
33
33
  return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: "secondary", size: "sm", disabled: disabled, onClick: toggleAll, testId: `${id}-select-all`, children: allSelected
34
34
  ? t("kumiko.field.multiSelect.deselect-all")
35
- : t("kumiko.field.multiSelect.select-all") }), _jsx(Grid, { columns: field.columns ?? 2, testId: `${id}-checkboxes`, ...(field.maxRows !== undefined && { maxRows: field.maxRows }), children: options.map((opt) => (_jsx(GridCell, { children: _jsx(Field, { id: `${id}-${opt.value}`, label: opt.label, layout: "inline", children: _jsx(Input, { kind: "boolean", id: `${id}-${opt.value}`, name: `${id}-${opt.value}`, value: selected.has(opt.value), onChange: (checked) => toggleOption(opt.value, checked), disabled: disabled }) }) }, opt.value))) })] }));
35
+ : t("kumiko.field.multiSelect.select-all") }), _jsx(Grid, { columns: field.columns ?? 2, testId: `${id}-checkboxes`,
36
+ // Field's label targets `id`, which no checkbox carries here; point the
37
+ // group at Field's `${id}-label` element instead.
38
+ ariaLabelledBy: `${id}-label`, ...(field.maxRows !== undefined && { maxRows: field.maxRows }), children: options.map((opt) => (_jsx(GridCell, { children: _jsx(Field, { id: `${id}-${opt.value}`, label: opt.label, layout: "inline", children: _jsx(Input, { kind: "boolean", id: `${id}-${opt.value}`, name: `${id}-${opt.value}`, value: selected.has(opt.value), onChange: (checked) => toggleOption(opt.value, checked), disabled: disabled }) }) }, opt.value))) })] }));
36
39
  }
@@ -1,12 +1,21 @@
1
1
  import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
2
- import type { ReactNode } from "react";
2
+ import { type OptionsQueryPayload } from "@cosmicdrift/kumiko-types/fields";
3
+ import { type ReactNode } from "react";
4
+ type ResolvedOptionsQueryPayload = Readonly<Record<string, string | number | boolean>>;
5
+ export declare function resolveOptionsQueryPayload(payload: OptionsQueryPayload, row: Readonly<Record<string, unknown>>): ResolvedOptionsQueryPayload;
3
6
  type QueryOptionsSelectProps = {
4
7
  readonly field: EditFieldViewModel;
5
8
  readonly query: string;
6
- readonly payload: Readonly<Record<string, string | number | boolean>>;
9
+ readonly payload: OptionsQueryPayload;
10
+ readonly row: Readonly<Record<string, unknown>>;
7
11
  readonly id: string;
8
12
  readonly hasError: boolean;
9
13
  readonly onChange: (value: unknown) => void;
10
14
  };
11
- export declare function QueryOptionsSelect({ field, query, payload, id, hasError, onChange, }: QueryOptionsSelectProps): ReactNode;
15
+ export declare function QueryOptionsSelect({ field, query, payload, row, id, hasError, onChange, }: QueryOptionsSelectProps): ReactNode;
16
+ export declare function QueryOptionLabel({ query, payload, value, }: {
17
+ readonly query: string;
18
+ readonly payload: ResolvedOptionsQueryPayload;
19
+ readonly value: string | number | boolean;
20
+ }): ReactNode;
12
21
  export {};