@cosmicdrift/kumiko-renderer 0.345.0 → 0.347.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.
@@ -13,8 +13,10 @@ import { RenderList } from "../components/render-list.js";
13
13
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context.js";
14
14
  import { useIsEmbeddedScreen } from "../context/embedded-screen-context.js";
15
15
  import { useUserRoles } from "../context/user-roles-context.js";
16
+ import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
16
17
  import { PAGE_SIZE_OPTIONS, useListUrlState } from "../hooks/use-list-url-state.js";
17
18
  import { useQuery } from "../hooks/use-query.js";
19
+ import { referenceLookupSource } from "../hooks/use-reference-lookup.js";
18
20
  import { useLocale, useOptionalTimeZone, useTranslation } from "../i18n.js";
19
21
  import { InsideDrawerProvider, useInsideDrawer } from "../inside-drawer.js";
20
22
  import { PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "../page-header-slot.js";
@@ -86,7 +88,8 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
86
88
  return _jsx(ReturnHostProvider, { value: ownHost, children: body });
87
89
  }
88
90
  function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, children, }) {
89
- const { Banner } = usePrimitives();
91
+ const { Banner, PageHeader } = usePrimitives();
92
+ const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
90
93
  const t = useTranslation();
91
94
  const result = useQuery(visibleWhen.query, {}, { live: true });
92
95
  const verdict = evalVisibleWhen(visibleWhen, result);
@@ -95,10 +98,9 @@ function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, c
95
98
  if (verdict === "loading") {
96
99
  return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
97
100
  }
98
- if (fallback !== undefined) {
99
- return _jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate });
100
- }
101
- return (_jsx(Banner, { padded: true, variant: "info", testId: "kumiko-screen-unavailable", children: t("kumiko.screen.unavailable") }));
101
+ // The route still names the locked screen; its breadcrumb must not label what stands in for it.
102
+ const hideLockedBreadcrumb = PageHeader !== undefined && pageHeaderSlotAvailable ? _jsx(PageHeader, { hideBreadcrumb: true }) : null;
103
+ return (_jsxs(_Fragment, { children: [hideLockedBreadcrumb, fallback !== undefined ? (_jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate })) : (_jsx(Banner, { padded: true, variant: "info", testId: "kumiko-screen-unavailable", children: t("kumiko.screen.unavailable") }))] }));
102
104
  }
103
105
  function FallbackScreen({ featureName, fallback, translate, }) {
104
106
  const target = useEmbeddedScreen(featureName, fallback);
@@ -125,9 +127,12 @@ function renderScreenBody({ schema, screen, translate, entityId, onRowClick, onC
125
127
  case "secretMint":
126
128
  return _jsx(SecretMintBody, { schema: schema, screen: screen, translate: translate });
127
129
  case "configEdit":
128
- return _jsx(ConfigEditBody, { schema: schema, screen: screen, translate: translate });
130
+ // Keyed by screen id: the form state is seeded once at mount, so switching
131
+ // between two settings screens must remount instead of carrying the
132
+ // previous screen's (empty) field state over.
133
+ return (_jsx(ConfigEditBody, { schema: schema, screen: screen, translate: translate }, screen.id));
129
134
  case "secretsEdit":
130
- return _jsx(SecretsEditBody, { screen: screen, translate: translate });
135
+ return _jsx(SecretsEditBody, { screen: screen, translate: translate }, screen.id);
131
136
  case "custom":
132
137
  return _jsx(CustomScreenBody, { screenId: screen.id, featureName: schema.featureName });
133
138
  }
@@ -360,6 +365,27 @@ function formatSummaryValue(value, locale) {
360
365
  function formatSummaryParams(prefill, locale) {
361
366
  return Object.fromEntries(Object.entries(prefill ?? {}).map(([name, value]) => [name, formatSummaryValue(value, locale)]));
362
367
  }
368
+ // Placeholders of `successMessage`: formatted submitted values, with a
369
+ // reference field resolved to the chosen record's label. A lookup that fails or
370
+ // misses the row (the lookup list is capped) falls back to the raw id.
371
+ async function resolveSuccessMessageParams(screen, featureName, values, dispatcher, locale) {
372
+ const params = formatSummaryParams(values, locale);
373
+ await Promise.all(Object.entries(screen.fields).map(async ([name, def]) => {
374
+ const raw = values[name];
375
+ if (def.type !== "reference" || typeof raw !== "string" || raw === "")
376
+ return;
377
+ const target = parseRefTarget(def.entity, featureName);
378
+ const { queryQn, labelKey } = referenceLookupSource(target.featureName, target.entityName, def.labelField ?? "id");
379
+ const lookup = await dispatcher.query(queryQn, { limit: REFERENCE_LIST_LOOKUP_LIMIT });
380
+ if (!lookup.isSuccess)
381
+ return;
382
+ const row = lookup.data.rows.find((candidate) => candidate["id"] === raw);
383
+ const label = row?.[labelKey];
384
+ if (label !== undefined && label !== null)
385
+ params[name] = String(label);
386
+ }));
387
+ return params;
388
+ }
363
389
  function resolveActionFormSummary(summary, prefill, translate, locale) {
364
390
  if (summary === undefined)
365
391
  return undefined;
@@ -2323,13 +2349,34 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2323
2349
  const t = useTranslation();
2324
2350
  const effectiveTranslate = translate ?? t;
2325
2351
  const summary = useMemo(() => resolveActionFormSummary(screen.summary, initialOverrides, effectiveTranslate, locale), [screen.summary, initialOverrides, effectiveTranslate, locale]);
2326
- const handleSubmitted = useCallback((result) => {
2352
+ const dispatcher = useDispatcher();
2353
+ const [successText, setSuccessText] = useState(undefined);
2354
+ // A slow label lookup of an earlier submit must not overwrite the banner of a later one.
2355
+ const successSequence = useRef(0);
2356
+ useEffect(() => () => {
2357
+ successSequence.current += 1;
2358
+ }, []);
2359
+ const handleSubmitted = useCallback((result, values) => {
2360
+ successSequence.current += 1;
2361
+ const sequence = successSequence.current;
2362
+ setSuccessText(undefined);
2327
2363
  if (!result.isSuccess)
2328
2364
  return;
2329
2365
  if (onSuccess !== undefined) {
2330
2366
  onSuccess();
2331
2367
  return;
2332
2368
  }
2369
+ if (screen.successMessage !== undefined && screen.redirect === undefined) {
2370
+ const messageKey = screen.successMessage;
2371
+ const show = (params) => {
2372
+ if (successSequence.current === sequence) {
2373
+ setSuccessText(effectiveTranslate(messageKey, params));
2374
+ }
2375
+ };
2376
+ void resolveSuccessMessageParams(screen, schema.featureName, values, dispatcher, locale)
2377
+ .then(show)
2378
+ .catch(() => show(formatSummaryParams(values, locale)));
2379
+ }
2333
2380
  // Without a redirect the form stays put; returnTo only replaces an existing
2334
2381
  // navigation, and never one to a record screen (see redirectTargetsRecord).
2335
2382
  if (screen.redirect !== undefined) {
@@ -2341,7 +2388,17 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2341
2388
  nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
2342
2389
  }
2343
2390
  }
2344
- }, [nav, screen.redirect, onSuccess, schema, appFeatures, returnTarget]);
2391
+ }, [
2392
+ nav,
2393
+ screen,
2394
+ onSuccess,
2395
+ schema,
2396
+ appFeatures,
2397
+ returnTarget,
2398
+ dispatcher,
2399
+ locale,
2400
+ effectiveTranslate,
2401
+ ]);
2345
2402
  // Cancel ist nur sinnvoll wenn ein Navigations-Ziel existiert —
2346
2403
  // sonst hätte der Button nirgendwo hin zu navigieren. cancelTarget
2347
2404
  // gewinnt über redirect; `false` schaltet den Button explizit ab
@@ -2366,7 +2423,9 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2366
2423
  // (inkl. searchParams-Prefill, z.B. ?incidentId=…) als initialValues
2367
2424
  // — anders als im entityEdit gibt es hier keinen record, aus dem
2368
2425
  // sie Kontext ziehen könnten.
2369
- extensionInitialValues: initial, writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(onDirtyChange !== undefined && { onDirtyChange }), ...((insideDrawer || screenFillsHeight(screen)) && { fillScreenHeight: true }), ...((screen.submitLabel ?? submitLabelFallback) !== undefined && {
2426
+ extensionInitialValues: initial, ...(successText !== undefined && {
2427
+ leadContent: (_jsx(Banner, { variant: "info", testId: "action-form-success", children: successText })),
2428
+ }), writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(onDirtyChange !== undefined && { onDirtyChange }), ...((insideDrawer || screenFillsHeight(screen)) && { fillScreenHeight: true }), ...((screen.submitLabel ?? submitLabelFallback) !== undefined && {
2370
2429
  submitLabel: screen.submitLabel ?? submitLabelFallback,
2371
2430
  }), ...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle }), ...(screen.footerActions !== undefined && { footerActions: screen.footerActions }), ...(summary !== undefined && { summary }), ...(translate !== undefined && { translate }) }));
2372
2431
  }
@@ -35,7 +35,8 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
35
35
  readonly translate?: Translate;
36
36
  readonly ctx?: TCtx;
37
37
  readonly schema?: z.ZodType;
38
- readonly onSubmit?: (result: SubmitResult<unknown>) => void;
38
+ /** `values` are the form values at the moment the submit succeeded. */
39
+ readonly onSubmit?: (result: SubmitResult<unknown>, values: TValues) => void;
39
40
  readonly payloadMode?: "values" | "changes";
40
41
  readonly buildPayload?: (snapshot: FormSnapshot<TValues>) => unknown;
41
42
  /** Prefix to strip from server validation issue paths before mapping them
@@ -1001,8 +1001,9 @@ export function RenderEdit(props) {
1001
1001
  // skip: on entity-success-but-extension-failure the extension-error
1002
1002
  // banner is showing — don't notify (the caller navigates away on success
1003
1003
  // and would unmount it before the user sees the failure).
1004
- if (shouldNotifyCaller(result, extensionsPersisted))
1005
- onSubmit?.(result);
1004
+ if (shouldNotifyCaller(result, extensionsPersisted)) {
1005
+ onSubmit?.(result, controller.getSnapshot().values);
1006
+ }
1006
1007
  }
1007
1008
  finally {
1008
1009
  isSubmittingRef.current = false;
@@ -1305,6 +1305,9 @@ export type PageHeaderProps = {
1305
1305
  readonly actions?: ReactNode;
1306
1306
  /** Land in the shell header's overflow menu at phone width. */
1307
1307
  readonly overflowItems?: readonly ActionMenuItemSpec[];
1308
+ /** Hide the shell header's breadcrumb while mounted, for a screen that stands in
1309
+ * for the one the route names (a locked screen's fallback). */
1310
+ readonly hideBreadcrumb?: boolean;
1308
1311
  };
1309
1312
  /** One item in an `ActionOverflowMenu` (A7: header/row actions beyond the
1310
1313
  * primary one collapse into this menu instead of a wall of buttons). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.345.0",
3
+ "version": "0.347.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>",
@@ -15,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.345.0",
19
- "@cosmicdrift/kumiko-headless": "0.345.0",
20
- "@cosmicdrift/kumiko-types": "0.345.0",
18
+ "@cosmicdrift/kumiko-framework": "0.347.0",
19
+ "@cosmicdrift/kumiko-headless": "0.347.0",
20
+ "@cosmicdrift/kumiko-types": "0.347.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.345.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.347.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.347.0",
4
+ "type": "improvement",
5
+ "title": "actionForm successMessage names the chosen record's label after a successful submit",
6
+ "detail": "actionForm screens can confirm a submit with the chosen record\nA new `successMessage` i18n key on an `actionForm` screen shows a confirmation above the form after a successful submit, for screens that stay put (no `redirect`, not in a drawer). `{field}` placeholders take the submitted values, and a `reference` field shows the chosen record's label instead of its id. The tier-engine admin form uses it: \"Tier assigned: Acme → Pro\", so choosing the wrong tenant is visible right away. New i18n key: `tier-admin.success` (en/de/es). `RenderEdit`'s `onSubmit` receives the submitted values as a second argument."
7
+ },
8
+ {
9
+ "version": "0.347.0",
10
+ "type": "fix",
11
+ "title": "Switching between generated config screens no longer shows empty fields for stored values",
12
+ "detail": "Settings screens show their stored values after switching screens\nMoving from one generated settings screen to another kept the previous screen's form state, so the fields of the new screen rendered empty and a select showed no active option even though a tenant value was set. Each config and secrets screen now mounts its own form."
13
+ },
14
+ {
15
+ "version": "0.347.0",
16
+ "type": "fix",
17
+ "title": "The shell header drops the locked screen's breadcrumb while a visibleWhen fallback or notice is shown",
18
+ "detail": "A locked screen's fallback shows no breadcrumb\nWhen `visibleWhen` is unmet, the route still names the locked screen, so the shell header kept that screen's breadcrumb above the fallback or the unavailable notice. The gate now mounts `<PageHeader hideBreadcrumb />`, and the shell header drops the breadcrumb while it is mounted. Embedded screens (dashboard panels, drawers) are not affected."
19
+ },
2
20
  {
3
21
  "version": "0.343.0",
4
22
  "type": "improvement",