@cosmicdrift/kumiko-renderer 0.363.0 → 0.364.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.
@@ -51,7 +51,7 @@ import { findEditScreenFor, navigateTargetAllows, screenAccessAllows } from "./s
51
51
  import { SecretMintBody } from "./secret-mint-body.js";
52
52
  import { SecretsEditBody } from "./secrets-edit-body.js";
53
53
  import { useEmbeddedScreen } from "./use-embedded-screen.js";
54
- import { evalVisibleWhen } from "./visible-when.js";
54
+ import { evalVisibleWhenAfterShown } from "./visible-when.js";
55
55
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error.js";
56
56
  export { qualifyScreenId };
57
57
  /** Mirrors qualifyScreenId for nav QNs (registry form `<feature>:nav:<short-id>`).
@@ -96,9 +96,13 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
96
96
  function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, children, }) {
97
97
  const { Banner, PageHeader } = usePrimitives();
98
98
  const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
99
- const t = useTranslation();
100
99
  const result = useQuery(visibleWhen.query, {}, { live: true });
101
- const verdict = evalVisibleWhen(visibleWhen, result);
100
+ const wasShown = useRef(false);
101
+ const verdict = evalVisibleWhenAfterShown(visibleWhen, result, wasShown.current);
102
+ useEffect(() => {
103
+ if (verdict === "visible")
104
+ wasShown.current = true;
105
+ }, [verdict]);
102
106
  if (verdict === "visible")
103
107
  return children;
104
108
  if (verdict === "loading") {
@@ -106,14 +110,19 @@ function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, c
106
110
  }
107
111
  // The route still names the locked screen; its breadcrumb must not label what stands in for it.
108
112
  const hideLockedBreadcrumb = PageHeader !== undefined && pageHeaderSlotAvailable ? _jsx(PageHeader, { hideBreadcrumb: true }) : null;
109
- 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") }))] }));
113
+ return (_jsxs(_Fragment, { children: [hideLockedBreadcrumb, fallback !== undefined ? (_jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate })) : (_jsx(ScreenUnavailableBanner, {}))] }));
114
+ }
115
+ function ScreenUnavailableBanner() {
116
+ const { Banner } = usePrimitives();
117
+ const t = useTranslation();
118
+ return (_jsx(Banner, { padded: true, variant: "info", testId: "kumiko-screen-unavailable", children: t("kumiko.screen.unavailable") }));
110
119
  }
111
120
  function FallbackScreen({ featureName, fallback, translate, }) {
112
121
  const target = useEmbeddedScreen(featureName, fallback);
113
- const { Banner, Text } = usePrimitives();
114
- if (target === undefined) {
115
- return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-not-found", children: ["Screen not found: ", _jsx(Text, { variant: "code", children: fallback })] }));
116
- }
122
+ // Also undefined when the user lacks access to the fallback screen, which is
123
+ // not a framework error.
124
+ if (target === undefined)
125
+ return _jsx(ScreenUnavailableBanner, {});
117
126
  return _jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate });
118
127
  }
119
128
  function renderScreenBody({ schema, screen, translate, entityId, onRowClick, onCopyLink, }) {
@@ -480,7 +489,11 @@ function isRepresentableAtScale(value, scale) {
480
489
  const tolerance = Math.abs(scaled) * 8 * Number.EPSILON + Number.EPSILON;
481
490
  return Math.abs(scaled - Math.round(scaled)) <= tolerance;
482
491
  }
483
- function isIsoInstant(value) {
492
+ // Server schema (`z.iso.datetime()`) accepts only a `Z` suffix, while
493
+ // Temporal.Instant.from also takes numeric offsets.
494
+ function isUtcIsoInstant(value) {
495
+ if (!value.endsWith("Z"))
496
+ return false;
484
497
  try {
485
498
  Temporal.Instant.from(value);
486
499
  return true;
@@ -490,6 +503,21 @@ function isIsoInstant(value) {
490
503
  }
491
504
  }
492
505
  const EMBEDDED_LIST_PREFILL_ROW_LIMIT = 500;
506
+ // The server schema (`z.string().date()`) rejects calendar-invalid days like 2020-02-31.
507
+ function isValidIsoDate(value) {
508
+ if (!ISO_DATE_PATTERN.test(value))
509
+ return false;
510
+ try {
511
+ Temporal.PlainDate.from(value, { overflow: "reject" });
512
+ return true;
513
+ }
514
+ catch {
515
+ return false;
516
+ }
517
+ }
518
+ function isDecimalWithinSafeRange(value, scale) {
519
+ return scale === undefined || Math.abs(value) <= Number.MAX_SAFE_INTEGER / 10 ** scale;
520
+ }
493
521
  function coerceEmbeddedCell(value, cell) {
494
522
  switch (cell.type) {
495
523
  case "number":
@@ -497,13 +525,15 @@ function coerceEmbeddedCell(value, cell) {
497
525
  case "decimal":
498
526
  return typeof value === "number" &&
499
527
  Number.isFinite(value) &&
500
- (cell.scale === undefined || isRepresentableAtScale(value, cell.scale))
528
+ (cell.scale === undefined ||
529
+ (isRepresentableAtScale(value, cell.scale) &&
530
+ isDecimalWithinSafeRange(value, cell.scale)))
501
531
  ? value
502
532
  : undefined;
503
533
  case "date":
504
- return typeof value === "string" && ISO_DATE_PATTERN.test(value) ? value : undefined;
534
+ return typeof value === "string" && isValidIsoDate(value) ? value : undefined;
505
535
  case "timestamp":
506
- return typeof value === "string" && isIsoInstant(value) ? value : undefined;
536
+ return typeof value === "string" && isUtcIsoInstant(value) ? value : undefined;
507
537
  case "money":
508
538
  // Row cells are signed minor units; the currency lives on the head, so a
509
539
  // top-level `{amount, currency}` in major units would land 100x off.
@@ -552,6 +582,8 @@ function coerceEmbeddedListRows(raw, fieldName, schema, maxItems) {
552
582
  }
553
583
  function coercePrefillString(raw, name, shape, fallback, defaultCurrency, moneyCurrencyOverrides) {
554
584
  if (shape.type === "number") {
585
+ if (raw.trim() === "")
586
+ return fallback;
555
587
  const parsed = Number(raw);
556
588
  return Number.isFinite(parsed) ? parsed : fallback;
557
589
  }
@@ -2198,8 +2230,12 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2198
2230
  (screen.detailFor !== undefined
2199
2231
  ? findEntityListScreen(schema, screen.detailFor)?.id
2200
2232
  : undefined);
2201
- return (_jsx(RecordNotFoundState, { translate: effectiveTranslate, ...(backListScreen !== undefined && {
2202
- onBack: () => nav.navigate({ screenId: lastSegment(backListScreen) }),
2233
+ return (_jsx(RecordNotFoundState, { translate: effectiveTranslate, ...((returnTarget !== undefined || backListScreen !== undefined) && {
2234
+ onBack: () => navigateToReturnOr(nav, returnTarget, () => {
2235
+ if (backListScreen !== undefined) {
2236
+ nav.navigate({ screenId: lastSegment(backListScreen) });
2237
+ }
2238
+ }),
2203
2239
  }) }));
2204
2240
  }
2205
2241
  // Grouped into the head Card alongside title/status/metrics (fw#2713):
@@ -2,3 +2,4 @@ import type { DashboardPanelVisibility } from "@cosmicdrift/kumiko-framework/ui-
2
2
  import type { UseQueryResult } from "../hooks/use-query.js";
3
3
  export type VisibleWhenVerdict = "loading" | "visible" | "hidden" | "error";
4
4
  export declare function evalVisibleWhen(visibleWhen: DashboardPanelVisibility, result: UseQueryResult<Readonly<Record<string, unknown>>> | undefined): VisibleWhenVerdict;
5
+ export declare function evalVisibleWhenAfterShown(visibleWhen: DashboardPanelVisibility, result: UseQueryResult<Readonly<Record<string, unknown>>> | undefined, wasShown: boolean): VisibleWhenVerdict;
@@ -9,3 +9,10 @@ export function evalVisibleWhen(visibleWhen, result) {
9
9
  return result.loading ? "loading" : "hidden";
10
10
  return result.data[visibleWhen.field] === visibleWhen.eq ? "visible" : "hidden";
11
11
  }
12
+ // Once the content was shown, a failed refetch must not unmount it (and drop unsaved
13
+ // form input) while the last good data still matches; the first evaluation stays fail-closed.
14
+ export function evalVisibleWhenAfterShown(visibleWhen, result, wasShown) {
15
+ const verdict = evalVisibleWhen(visibleWhen, result);
16
+ const lastGoodDataMatches = result?.data?.[visibleWhen.field] === visibleWhen.eq;
17
+ return verdict === "error" && wasShown && lastGoodDataMatches ? "visible" : verdict;
18
+ }
@@ -1,6 +1,6 @@
1
1
  import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
2
2
  import type { OptionsQueryPayload } from "@cosmicdrift/kumiko-types/fields";
3
- import type { ReactNode } from "react";
3
+ import { type ReactNode } from "react";
4
4
  export type SelectOptionEntry = {
5
5
  readonly value: string;
6
6
  readonly label: string;
@@ -9,14 +9,14 @@ export type SelectOptionEntry = {
9
9
  };
10
10
  export declare function staticSelectOptions(field: EditFieldViewModel): readonly SelectOptionEntry[];
11
11
  export declare function mergeOptionAvailability(options: readonly SelectOptionEntry[], rows: readonly unknown[], current: string): readonly SelectOptionEntry[];
12
- export declare function filterUnavailableOptions(options: readonly SelectOptionEntry[], rows: readonly unknown[], currentValues: readonly string[]): readonly SelectOptionEntry[];
12
+ export declare function filterUnavailableOptions(options: readonly SelectOptionEntry[], rows: readonly unknown[], heldValues: ReadonlySet<string>): readonly SelectOptionEntry[];
13
13
  type AvailabilityOptionsMultiSelectProps = {
14
14
  readonly field: EditFieldViewModel;
15
15
  readonly query: string;
16
- readonly currentValues: readonly string[];
16
+ readonly initialValues: readonly string[];
17
17
  readonly children: (options: readonly SelectOptionEntry[]) => ReactNode;
18
18
  };
19
- export declare function AvailabilityOptionsMultiSelect({ field, query, currentValues, children, }: AvailabilityOptionsMultiSelectProps): ReactNode;
19
+ export declare function AvailabilityOptionsMultiSelect({ field, query, initialValues, children, }: AvailabilityOptionsMultiSelectProps): ReactNode;
20
20
  type AvailabilityOptionsSelectProps = {
21
21
  readonly field: EditFieldViewModel;
22
22
  readonly query: string;
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useRef } from "react";
2
3
  import { useQuery } from "../hooks/use-query.js";
3
4
  import { usePrimitives } from "../primitives.js";
4
5
  import { resolveOptionsQueryPayload } from "./query-options-select.js";
@@ -36,21 +37,27 @@ export function mergeOptionAvailability(options, rows, current) {
36
37
  };
37
38
  });
38
39
  }
39
- // Combobox primitives cannot disable a single option, so unavailable ones are
40
- // dropped; an already selected value stays listed so it can still be removed.
41
- export function filterUnavailableOptions(options, rows, currentValues) {
40
+ // Unavailable options are dropped unless the target already held them when the
41
+ // form mounted; those stay listed but disabled, because the write path keeps
42
+ // values the caller cannot grant and an unchecked one could never be re-checked.
43
+ export function filterUnavailableOptions(options, rows, heldValues) {
42
44
  const unavailable = new Set();
43
45
  for (const row of rows) {
44
46
  if (isAvailabilityRow(row) && row.disabled === true)
45
47
  unavailable.add(row.value);
46
48
  }
47
- return options.filter((option) => !unavailable.has(option.value) || currentValues.includes(option.value));
49
+ return options.flatMap((option) => {
50
+ if (!unavailable.has(option.value))
51
+ return [option];
52
+ return heldValues.has(option.value) ? [{ ...option, disabled: true }] : [];
53
+ });
48
54
  }
49
55
  // Until the result arrives, and when the query fails, every static option is
50
56
  // shown; the write path enforces the rule either way.
51
- export function AvailabilityOptionsMultiSelect({ field, query, currentValues, children, }) {
57
+ export function AvailabilityOptionsMultiSelect({ field, query, initialValues, children, }) {
52
58
  const { data } = useQuery(query, {});
53
- return children(filterUnavailableOptions(staticSelectOptions(field), data?.rows ?? [], currentValues));
59
+ const heldAtMount = useRef(new Set(initialValues));
60
+ return children(filterUnavailableOptions(staticSelectOptions(field), data?.rows ?? [], heldAtMount.current));
54
61
  }
55
62
  // Select with static options whose availability (SelectFieldDef.optionsAvailabilityQuery)
56
63
  // comes from a query, so it has to be a mounted component for useQuery. Until the
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
3
3
  export type MultiSelectCheckboxOption = {
4
4
  readonly value: string;
5
5
  readonly label: string;
6
+ readonly disabled?: boolean;
6
7
  };
7
8
  export type MultiSelectCheckboxesProps = {
8
9
  readonly field: EditFieldViewModel;
@@ -14,8 +14,11 @@ export function MultiSelectCheckboxes({ field, id, options, value, onChange, })
14
14
  const allSelected = options.length > 0 && options.every((opt) => selected.has(opt.value));
15
15
  // Always emits in `field.options` order (MultiSelectFieldDef's documented
16
16
  // ordering guarantee), never in click/selection order.
17
+ // Disabled options are pinned: they keep their current state whatever is toggled.
17
18
  const emitOrdered = (next) => {
18
- onChange(options.filter((opt) => next.has(opt.value)).map((opt) => opt.value));
19
+ onChange(options
20
+ .filter((opt) => (opt.disabled === true ? selected.has(opt.value) : next.has(opt.value)))
21
+ .map((opt) => opt.value));
19
22
  };
20
23
  const toggleOption = (optionValue, checked) => {
21
24
  const next = new Set(selected);
@@ -35,5 +38,5 @@ export function MultiSelectCheckboxes({ field, id, options, value, onChange, })
35
38
  : t("kumiko.field.multiSelect.select-all") }), _jsx(Grid, { columns: field.columns ?? 2, testId: `${id}-checkboxes`,
36
39
  // Field's label targets `id`, which no checkbox carries here; point the
37
40
  // 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))) })] }));
41
+ 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.disabled === true }) }) }, opt.value))) })] }));
39
42
  }
@@ -352,9 +352,9 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
352
352
  return (_jsx(Input, { kind: "tz", ...common, value: stringValue(field.value), onChange: (v) => onChange(v) }));
353
353
  case "multiSelect": {
354
354
  const arrayValue = stringArrayOf(field.value);
355
- const renderMultiSelect = (options) => field.display === "checkboxes" ? (_jsx(MultiSelectCheckboxes, { field: field, id: id, options: options, value: arrayValue, onChange: (v) => onChange(v) })) : (_jsx(Input, { kind: "combobox", ...common, multiple: true, value: arrayValue, onChange: (v) => onChange(v), options: options }));
355
+ const renderMultiSelect = (options) => field.display === "checkboxes" ? (_jsx(MultiSelectCheckboxes, { field: field, id: id, options: options, value: arrayValue, onChange: (v) => onChange(v) })) : (_jsx(Input, { kind: "combobox", ...common, multiple: true, value: arrayValue, onChange: (v) => onChange(withPinnedValues(v, options, arrayValue)), options: options }));
356
356
  if (field.selectOptionsAvailabilityQuery !== undefined && field.readOnly !== true) {
357
- return (_jsx(AvailabilityOptionsMultiSelect, { field: field, query: field.selectOptionsAvailabilityQuery.query, currentValues: arrayValue, children: renderMultiSelect }));
357
+ return (_jsx(AvailabilityOptionsMultiSelect, { field: field, query: field.selectOptionsAvailabilityQuery.query, initialValues: arrayValue, children: renderMultiSelect }));
358
358
  }
359
359
  return renderMultiSelect(staticSelectOptions(field));
360
360
  }
@@ -449,8 +449,11 @@ const ISO_CURRENCY_CODE = /^[A-Z]{3}$/;
449
449
  function resolveMoneyCurrency(value, fieldCurrency) {
450
450
  if (typeof value === "object" && value !== null && "currency" in value) {
451
451
  const { currency } = value;
452
- if (typeof currency === "string" && ISO_CURRENCY_CODE.test(currency))
453
- return currency;
452
+ if (typeof currency === "string") {
453
+ const normalized = currency.toUpperCase();
454
+ if (ISO_CURRENCY_CODE.test(normalized))
455
+ return normalized;
456
+ }
454
457
  }
455
458
  return fieldCurrency ?? "EUR";
456
459
  }
@@ -472,7 +475,17 @@ function resolveNumberUnit(unit, row) {
472
475
  return trimmed.length > 0 && trimmed.length <= MAX_SIBLING_UNIT_LENGTH ? trimmed : undefined;
473
476
  }
474
477
  function stringArrayOf(value) {
475
- return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : [];
478
+ return Array.isArray(value)
479
+ ? value.filter((item) => typeof item === "string")
480
+ : [];
481
+ }
482
+ // The combobox cannot disable a single option, so removals of disabled options
483
+ // the form already holds are merged back.
484
+ function withPinnedValues(next, options, current) {
485
+ const missingPinned = options
486
+ .filter((opt) => opt.disabled === true && current.includes(opt.value) && !next.includes(opt.value))
487
+ .map((opt) => opt.value);
488
+ return missingPinned.length === 0 ? next : [...next, ...missingPinned];
476
489
  }
477
490
  function stringPropertyOf(value, key) {
478
491
  const property = propertyOf(value, key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.363.0",
3
+ "version": "0.364.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.363.0",
19
- "@cosmicdrift/kumiko-headless": "0.363.0",
20
- "@cosmicdrift/kumiko-types": "0.363.0",
18
+ "@cosmicdrift/kumiko-framework": "0.364.0",
19
+ "@cosmicdrift/kumiko-headless": "0.364.0",
20
+ "@cosmicdrift/kumiko-types": "0.364.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.363.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.364.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.364.0",
4
+ "type": "fix",
5
+ "title": "A failed refetch of a visibleWhen gate no longer unmounts a screen that is already showing",
6
+ "detail": "ScreenVisibilityGate used to fail closed on every error, so one failed live refetch (SSE reconnect, a short 5xx) removed the content and reset unsaved form input. Only the first evaluation fails closed now; after the content was shown, an error keeps it mounted as long as the last good data still matches the condition. The dashboard panel retry path is unchanged."
7
+ },
8
+ {
9
+ "version": "0.364.0",
10
+ "type": "fix",
11
+ "title": "A held multiSelect value that optionsAvailabilityQuery marks unavailable stays listed but cannot be removed",
12
+ "detail": "The keep-listed set is taken from the values present when the field mounted, not from the live value. In the checkbox grid the entry is rendered disabled and select-all/deselect-all leave it as is; in the combobox a removal is merged back into the value. Previously unchecking such a role hid it for good while the write path kept it, so the form reported a removal that never happened. A stored lower-case currency code such as usd is normalised to upper case instead of falling back to the field currency, and array values with a non-string element keep their string entries instead of being treated as empty."
13
+ },
14
+ {
15
+ "version": "0.364.0",
16
+ "type": "fix",
17
+ "title": "Screen fallback access, projectionDetail back navigation and stricter prefill",
18
+ "detail": "A visibleWhen fallback screen the user may not access now shows the standard unavailable notice instead of a \"Screen not found\" error. The not-found state of a projectionDetail honours returnTo for its back button. Embedded-list prefill rejects calendar-invalid dates, timestamps with numeric offsets and decimals beyond the safe range, and an empty number param keeps the field default instead of becoming 0."
19
+ },
2
20
  {
3
21
  "version": "0.363.0",
4
22
  "type": "improvement",