@cosmicdrift/kumiko-renderer 0.362.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.
@@ -24,5 +24,8 @@ export function ContentPreview({ content, variables, contentFormat, }) {
24
24
  const safeVariables = contentFormat === "rich"
25
25
  ? Object.fromEntries(Object.entries(variables).map(([name, value]) => [name, escapeHtmlAttr(value)]))
26
26
  : variables;
27
- return (_jsx(Editor, { id: id, value: substituteVariables(content, safeVariables), onChange: noop, variables: [], readOnly: true }));
27
+ // "document" keeps `{{x}}` as-is: substituting it into a button/link href would make the
28
+ // schema drop that node; variable chips stay visible.
29
+ const value = contentFormat === "document" ? content : substituteVariables(content, safeVariables);
30
+ return _jsx(Editor, { id: id, value: value, onChange: noop, variables: [], readOnly: true });
28
31
  }
@@ -1,5 +1,5 @@
1
1
  import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
- import type { ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
2
+ import type { FormValues, ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
3
3
  import { type ReactNode } from "react";
4
4
  import { type ListSort } from "../hooks/use-list-url-state.js";
5
5
  import type { FeatureSchema } from "./feature-schema.js";
@@ -19,7 +19,7 @@ export { qualifyScreenId };
19
19
  export declare function qualifyNavId(featureName: string, navId: string): string;
20
20
  export { screenAccessAllows };
21
21
  export declare function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCopyLink, }: KumikoScreenProps): ReactNode;
22
- export declare function buildInitialValues(fields: Readonly<Record<string, unknown>>, defaultCurrency?: string, moneyCurrencyOverrides?: Readonly<Record<string, string>>): Readonly<Record<string, unknown>>;
22
+ export declare function buildInitialValues(fields: Readonly<Record<string, unknown>>, defaultCurrency?: string, moneyCurrencyOverrides?: Readonly<Record<string, string>>): FormValues;
23
23
  export declare function tenantCurrencyMoneyFieldNames(fields: Readonly<Record<string, unknown>>): readonly string[];
24
24
  export declare function literalCurrencyOverrides(fields: Readonly<Record<string, unknown>>): Readonly<Record<string, string>>;
25
25
  export declare function useMoneyCurrencyOverrides(fields: Readonly<Record<string, unknown>>, tenantFieldNames: readonly string[], fallback: string | undefined): {
@@ -4,6 +4,7 @@ import { applyFormatSpec, buildOptionLabels, computeRelatedListSectionViewModel,
4
4
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
5
5
  import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
6
6
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
7
+ import { isPlainObject, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
7
8
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
8
9
  import { QueryOptionLabel, resolveOptionsQueryPayload, } from "../components/query-options-select.js";
9
10
  import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
@@ -50,7 +51,7 @@ import { findEditScreenFor, navigateTargetAllows, screenAccessAllows } from "./s
50
51
  import { SecretMintBody } from "./secret-mint-body.js";
51
52
  import { SecretsEditBody } from "./secrets-edit-body.js";
52
53
  import { useEmbeddedScreen } from "./use-embedded-screen.js";
53
- import { evalVisibleWhen } from "./visible-when.js";
54
+ import { evalVisibleWhenAfterShown } from "./visible-when.js";
54
55
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error.js";
55
56
  export { qualifyScreenId };
56
57
  /** Mirrors qualifyScreenId for nav QNs (registry form `<feature>:nav:<short-id>`).
@@ -95,9 +96,13 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
95
96
  function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, children, }) {
96
97
  const { Banner, PageHeader } = usePrimitives();
97
98
  const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
98
- const t = useTranslation();
99
99
  const result = useQuery(visibleWhen.query, {}, { live: true });
100
- 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]);
101
106
  if (verdict === "visible")
102
107
  return children;
103
108
  if (verdict === "loading") {
@@ -105,14 +110,19 @@ function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, c
105
110
  }
106
111
  // The route still names the locked screen; its breadcrumb must not label what stands in for it.
107
112
  const hideLockedBreadcrumb = PageHeader !== undefined && pageHeaderSlotAvailable ? _jsx(PageHeader, { hideBreadcrumb: true }) : null;
108
- 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") }));
109
119
  }
110
120
  function FallbackScreen({ featureName, fallback, translate, }) {
111
121
  const target = useEmbeddedScreen(featureName, fallback);
112
- const { Banner, Text } = usePrimitives();
113
- if (target === undefined) {
114
- return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-not-found", children: ["Screen not found: ", _jsx(Text, { variant: "code", children: fallback })] }));
115
- }
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, {});
116
126
  return _jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate });
117
127
  }
118
128
  function renderScreenBody({ schema, screen, translate, entityId, onRowClick, onCopyLink, }) {
@@ -246,24 +256,29 @@ function useNavigateToCreateFor(schema, entityName, createScreen, createUnavaila
246
256
  // — used for a `money` field that declares `currency: { kind: "tenant" }`, so
247
257
  // its empty initial value carries the tenant's own currency instead of the
248
258
  // entity-wide default.
259
+ function versionOf(record) {
260
+ const version = record["version"];
261
+ return typeof version === "number" ? version : 1;
262
+ }
249
263
  export function buildInitialValues(fields, defaultCurrency, moneyCurrencyOverrides) {
250
264
  const out = {};
251
265
  for (const [name, def] of Object.entries(fields)) {
252
- const shape = def;
253
- if (shape.default !== undefined) {
254
- out[name] = shape.default;
266
+ const type = propertyOf(def, "type");
267
+ const defaultValue = propertyOf(def, "default");
268
+ if (defaultValue !== undefined) {
269
+ out[name] = defaultValue;
255
270
  continue;
256
271
  }
257
272
  // An untouched optional number must not persist as a deliberate 0.
258
- if (shape.type === "number" && shape.required !== true) {
273
+ if (type === "number" && propertyOf(def, "required") !== true) {
259
274
  out[name] = undefined;
260
275
  continue;
261
276
  }
262
- if (shape.type === "embedded" && shape.multiple === true) {
277
+ if (type === "embedded" && propertyOf(def, "multiple") === true) {
263
278
  out[name] = [];
264
279
  continue;
265
280
  }
266
- if (shape.type === "money") {
281
+ if (type === "money") {
267
282
  const currency = moneyCurrencyOverrides?.[name] ?? defaultCurrency;
268
283
  if (currency !== undefined) {
269
284
  out[name] = { amount: 0, currency };
@@ -271,11 +286,11 @@ export function buildInitialValues(fields, defaultCurrency, moneyCurrencyOverrid
271
286
  }
272
287
  }
273
288
  out[name] =
274
- shape.type === "boolean"
289
+ type === "boolean"
275
290
  ? false
276
- : shape.type === "number" || shape.type === "money"
291
+ : type === "number" || type === "money"
277
292
  ? 0
278
- : shape.type === "multiSelect"
293
+ : type === "multiSelect"
279
294
  ? []
280
295
  : "";
281
296
  }
@@ -290,9 +305,10 @@ export function buildInitialValues(fields, defaultCurrency, moneyCurrencyOverrid
290
305
  export function tenantCurrencyMoneyFieldNames(fields) {
291
306
  const names = [];
292
307
  for (const [name, def] of Object.entries(fields)) {
293
- const shape = def;
294
- if (shape.type === "money" && shape.currency?.kind === "tenant")
308
+ if (propertyOf(def, "type") === "money" &&
309
+ propertyOf(propertyOf(def, "currency"), "kind") === "tenant") {
295
310
  names.push(name);
311
+ }
296
312
  }
297
313
  return names;
298
314
  }
@@ -327,9 +343,11 @@ const UPDATE_CHANGES_KEY = "changes";
327
343
  export function literalCurrencyOverrides(fields) {
328
344
  const out = {};
329
345
  for (const [name, def] of Object.entries(fields)) {
330
- const shape = def;
331
- const code = shape.currency?.code;
332
- if (shape.type === "money" && shape.currency?.kind === "literal" && code !== undefined) {
346
+ const currency = propertyOf(def, "currency");
347
+ const code = propertyOf(currency, "code");
348
+ if (propertyOf(def, "type") === "money" &&
349
+ propertyOf(currency, "kind") === "literal" &&
350
+ typeof code === "string") {
333
351
  out[name] = code;
334
352
  }
335
353
  }
@@ -375,7 +393,8 @@ const CURRENCY_CODE_PATTERN = /^[A-Za-z]{3}$/;
375
393
  function isMoneyValue(value) {
376
394
  if (typeof value !== "object" || value === null)
377
395
  return false;
378
- const { amount, currency } = value;
396
+ const amount = propertyOf(value, "amount");
397
+ const currency = propertyOf(value, "currency");
379
398
  return (typeof amount === "number" &&
380
399
  Number.isFinite(amount) &&
381
400
  typeof currency === "string" &&
@@ -470,7 +489,11 @@ function isRepresentableAtScale(value, scale) {
470
489
  const tolerance = Math.abs(scaled) * 8 * Number.EPSILON + Number.EPSILON;
471
490
  return Math.abs(scaled - Math.round(scaled)) <= tolerance;
472
491
  }
473
- 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;
474
497
  try {
475
498
  Temporal.Instant.from(value);
476
499
  return true;
@@ -480,6 +503,21 @@ function isIsoInstant(value) {
480
503
  }
481
504
  }
482
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
+ }
483
521
  function coerceEmbeddedCell(value, cell) {
484
522
  switch (cell.type) {
485
523
  case "number":
@@ -487,13 +525,15 @@ function coerceEmbeddedCell(value, cell) {
487
525
  case "decimal":
488
526
  return typeof value === "number" &&
489
527
  Number.isFinite(value) &&
490
- (cell.scale === undefined || isRepresentableAtScale(value, cell.scale))
528
+ (cell.scale === undefined ||
529
+ (isRepresentableAtScale(value, cell.scale) &&
530
+ isDecimalWithinSafeRange(value, cell.scale)))
491
531
  ? value
492
532
  : undefined;
493
533
  case "date":
494
- return typeof value === "string" && ISO_DATE_PATTERN.test(value) ? value : undefined;
534
+ return typeof value === "string" && isValidIsoDate(value) ? value : undefined;
495
535
  case "timestamp":
496
- return typeof value === "string" && isIsoInstant(value) ? value : undefined;
536
+ return typeof value === "string" && isUtcIsoInstant(value) ? value : undefined;
497
537
  case "money":
498
538
  // Row cells are signed minor units; the currency lives on the head, so a
499
539
  // top-level `{amount, currency}` in major units would land 100x off.
@@ -526,7 +566,7 @@ function coerceEmbeddedListRows(raw, fieldName, schema, maxItems) {
526
566
  }
527
567
  const cellEntries = [];
528
568
  for (const [cellName, cell] of Object.entries(schema)) {
529
- const cellValue = candidate[cellName];
569
+ const cellValue = propertyOf(candidate, cellName);
530
570
  if (cellValue === undefined || cellValue === null)
531
571
  continue;
532
572
  const coerced = coerceEmbeddedCell(cellValue, cell);
@@ -542,6 +582,8 @@ function coerceEmbeddedListRows(raw, fieldName, schema, maxItems) {
542
582
  }
543
583
  function coercePrefillString(raw, name, shape, fallback, defaultCurrency, moneyCurrencyOverrides) {
544
584
  if (shape.type === "number") {
585
+ if (raw.trim() === "")
586
+ return fallback;
545
587
  const parsed = Number(raw);
546
588
  return Number.isFinite(parsed) ? parsed : fallback;
547
589
  }
@@ -762,7 +804,7 @@ function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onC
762
804
  // der ursprünglichen version sitzen — der buildPayload würde
763
805
  // weiter die stale version stampen und der Konflikt-Recovery wäre
764
806
  // kaputt.
765
- const recordKey = record.version ?? 1;
807
+ const recordKey = versionOf(record);
766
808
  return (_jsx(EntityEditUpdateForm, { schema: schema, screen: screen, entity: entity, entityId: entityId, record: record, onReload: detailQuery.refetch, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(autoReloadOnSave === true && { onSaved: () => void detailQuery.refetch() }), ...(onDeleted !== undefined && { onDeleted }) }, `${entityId}:${recordKey}`));
767
809
  }
768
810
  function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onReload, translate, onCopyLink, onSaved, onDeleted, }) {
@@ -773,7 +815,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
773
815
  // captured once and stamped into every update payload — if a
774
816
  // concurrent writer bumps it, the server returns a version-conflict
775
817
  // error and the user reloads.
776
- const recordVersion = record.version ?? 1;
818
+ const recordVersion = versionOf(record);
777
819
  const entityDefaultCurrency = entity.defaultCurrency ?? "EUR";
778
820
  // A money field declaring `currency: { kind: "tenant" }` (fw#2933) resolves
779
821
  // its empty-value currency from the tenant-settings config key instead of
@@ -800,9 +842,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
800
842
  // damit sie beim Edit den Bestand zeigen statt write-only zu sein.
801
843
  const extensionInitialValues = useMemo(() => {
802
844
  const cf = record["customFields"];
803
- return cf !== null && typeof cf === "object" && !Array.isArray(cf)
804
- ? cf
805
- : undefined;
845
+ return isPlainObject(cf) ? cf : undefined;
806
846
  }, [record]);
807
847
  const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "update");
808
848
  const deleteCommand = entityWriteCommand(schema.featureName, screen.entity, "delete");
@@ -1217,8 +1257,8 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1217
1257
  const filterPayload = useMemo(() => buildFilterPayload(urlState.filters, (field) => {
1218
1258
  // entity.fields ist am Renderer-Layer schwach getypt (vom Schema
1219
1259
  // deserialisiert) — Boundary-Cast wie buildInitialValues.
1220
- const def = entity.fields[field];
1221
- return def?.type;
1260
+ const type = propertyOf(entity.fields[field], "type");
1261
+ return typeof type === "string" ? type : undefined;
1222
1262
  }), [urlState.filters, entity.fields]);
1223
1263
  // Entity-only additions (screen.filter, faceted filters) layer on top of
1224
1264
  // the shared buildListQueryPayload — projectionList has neither.
@@ -2190,8 +2230,12 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2190
2230
  (screen.detailFor !== undefined
2191
2231
  ? findEntityListScreen(schema, screen.detailFor)?.id
2192
2232
  : undefined);
2193
- return (_jsx(RecordNotFoundState, { translate: effectiveTranslate, ...(backListScreen !== undefined && {
2194
- 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
+ }),
2195
2239
  }) }));
2196
2240
  }
2197
2241
  // Grouped into the head Card alongside title/status/metrics (fw#2713):
@@ -2379,7 +2423,7 @@ function navigateAfterRedirect(options) {
2379
2423
  function overlayUpdatedProjection(record, resultData) {
2380
2424
  if (typeof resultData !== "object" || resultData === null)
2381
2425
  return record;
2382
- const projection = resultData.data;
2426
+ const projection = propertyOf(resultData, "data");
2383
2427
  if (typeof projection !== "object" || projection === null)
2384
2428
  return record;
2385
2429
  return { ...record, ...projection };
@@ -2628,7 +2672,7 @@ function ConfigEditBody({ schema, screen, translate, }) {
2628
2672
  out[shortName] = defaults[shortName];
2629
2673
  continue;
2630
2674
  }
2631
- const ftype = fieldDef.type;
2675
+ const ftype = propertyOf(fieldDef, "type");
2632
2676
  // Field-Type-Coercion: config-Werte sind string|number|boolean,
2633
2677
  // aber der Form-State erwartet das passende Field-Native-Type.
2634
2678
  if (ftype === "number" || ftype === "money") {
@@ -1,4 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
2
3
  import { useCallback, useMemo, useRef, useState } from "react";
3
4
  import { RenderEdit } from "../components/render-edit.js";
4
5
  import { useTranslation } from "../i18n.js";
@@ -14,7 +15,7 @@ function extractRevealValue(data, field) {
14
15
  return undefined;
15
16
  if (!Object.hasOwn(data, field))
16
17
  return undefined;
17
- return data[field];
18
+ return propertyOf(data, field);
18
19
  }
19
20
  function extractCarriedValues(data, carry) {
20
21
  const carried = {};
@@ -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
  }
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
2
3
  import { useMemo } from "react";
3
4
  import { buildInitialValues, tenantCurrencyMoneyFieldNames, useMoneyCurrencyOverrides, } from "../app/kumiko-screen.js";
4
5
  import { toKebab } from "../app/qn.js";
@@ -24,7 +25,7 @@ export function extractIdField(data, field) {
24
25
  return undefined;
25
26
  if (!Object.hasOwn(data, field))
26
27
  return undefined;
27
- const value = data[field];
28
+ const value = propertyOf(data, field);
28
29
  return typeof value === "string" ? value : undefined;
29
30
  }
30
31
  export function extractCreatedId(data) {
@@ -1,6 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION_DETAIL_ENTITY, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { computeEditViewModel, groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues, } from "@cosmicdrift/kumiko-headless";
4
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
4
5
  import { RenderEditActionButton } from "./render-edit-action-button.js";
5
6
  import { AllFieldsRequiredProvider } from "./render-field.js";
6
7
  import { useActionMenuItems } from "./use-action-menu-items.js";
@@ -49,9 +50,13 @@ function newDraftPrefix(screenId) {
49
50
  // `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
50
51
  // without a polyfill — guard like dispatcher-live.ts's generateRequestId.
51
52
  function mintDraftId() {
52
- const c = globalThis.crypto;
53
- if (typeof c?.randomUUID === "function")
54
- return c.randomUUID();
53
+ const webCrypto = propertyOf(globalThis, "crypto");
54
+ const randomUUID = propertyOf(webCrypto, "randomUUID");
55
+ if (typeof randomUUID === "function") {
56
+ const id = Reflect.apply(randomUUID, webCrypto, []);
57
+ if (typeof id === "string")
58
+ return id;
59
+ }
55
60
  return `draft-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
56
61
  }
57
62
  // End-to-end renderer für einen entityEdit screen. Rendert aus-
@@ -551,7 +556,7 @@ export function RenderEdit(props) {
551
556
  // was in flight — their input wins over the stored draft.
552
557
  if (controller.getSnapshot().isDirty || hasNavigatedRef.current)
553
558
  return;
554
- // @cast-boundary form-draft blob: `values` round-trips through an opaque
559
+ // @cast-boundary form-values draft blob: `values` round-trips through an opaque
555
560
  // jsonb column and comes back untyped.
556
561
  controller.setValues(draft.values);
557
562
  setRawStep(Math.max(draft.stepIndex, 0));
@@ -1,6 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { isFormatSpec, SYSTEM_REFERENCE_LABELS, } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { applyFormatSpec, currencyDecimals, } from "@cosmicdrift/kumiko-headless";
4
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
4
5
  import { createContext, useCallback, useContext, useMemo, useState } from "react";
5
6
  import { useAppFeatures } from "../app/app-features-context.js";
6
7
  import { useColumnRenderer } from "../app/column-renderers.js";
@@ -159,7 +160,7 @@ function ReferenceInput({ field, id, hasError, onChange, Input, featureName, })
159
160
  }
160
161
  setCreatedWithoutIdWarning(false);
161
162
  if (isMultiple) {
162
- const current = Array.isArray(field.value) ? field.value : [];
163
+ const current = stringArrayOf(field.value);
163
164
  onChange([...current, newId]);
164
165
  }
165
166
  else {
@@ -194,9 +195,7 @@ function ReferenceInput({ field, id, hasError, onChange, Input, featureName, })
194
195
  };
195
196
  const createDialog = (_jsxs(_Fragment, { children: [canCreate && refCreateScreen && refEntityDef && (_jsx(ReferenceCreateDialog, { open: createOpen, onClose: () => setCreateOpen(false), onCreated: handleCreated, featureName: refFeature, screen: refCreateScreen, entity: refEntityDef })), createdWithoutIdWarning && (_jsx(Banner, { variant: "error", children: t("kumiko.field.reference-created-no-id") }))] }));
196
197
  if (isMultiple) {
197
- const arrayValue = Array.isArray(field.value)
198
- ? field.value
199
- : [];
198
+ const arrayValue = stringArrayOf(field.value);
200
199
  return (_jsxs(_Fragment, { children: [_jsx(Input, { kind: "combobox", ...baseInputProps, multiple: true, value: arrayValue, onChange: (v) => onChange(v) }), createDialog] }));
201
200
  }
202
201
  const stringValue = field.value === undefined || field.value === null ? "" : String(field.value);
@@ -228,7 +227,7 @@ function FieldRendererOutput({ field, renderer, row, appLocale, }) {
228
227
  // when set; coalesce undefined (spread override) back to appLocale (#2332).
229
228
  return (_jsx(Text, { testId: `field-value-${field.field}`, children: applyFormatSpec({
230
229
  ...renderer,
231
- locale: renderer.locale ?? appLocale,
230
+ locale: stringPropertyOf(renderer, "locale") ?? appLocale,
232
231
  }, field.value, t) }));
233
232
  }
234
233
  if (componentName !== undefined) {
@@ -254,9 +253,7 @@ function ReadOnlyReferenceValue({ field, featureName, }) {
254
253
  limit: REFERENCE_COMBOBOX_LIMIT,
255
254
  });
256
255
  const ids = isMultiple
257
- ? Array.isArray(field.value)
258
- ? field.value
259
- : []
256
+ ? stringArrayOf(field.value)
260
257
  : typeof field.value === "string" && field.value !== ""
261
258
  ? [field.value]
262
259
  : [];
@@ -354,10 +351,10 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
354
351
  case "tz":
355
352
  return (_jsx(Input, { kind: "tz", ...common, value: stringValue(field.value), onChange: (v) => onChange(v) }));
356
353
  case "multiSelect": {
357
- const arrayValue = Array.isArray(field.value) ? field.value : [];
358
- 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 }));
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(withPinnedValues(v, options, arrayValue)), options: options }));
359
356
  if (field.selectOptionsAvailabilityQuery !== undefined && field.readOnly !== true) {
360
- 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 }));
361
358
  }
362
359
  return renderMultiSelect(staticSelectOptions(field));
363
360
  }
@@ -452,8 +449,11 @@ const ISO_CURRENCY_CODE = /^[A-Z]{3}$/;
452
449
  function resolveMoneyCurrency(value, fieldCurrency) {
453
450
  if (typeof value === "object" && value !== null && "currency" in value) {
454
451
  const { currency } = value;
455
- if (typeof currency === "string" && ISO_CURRENCY_CODE.test(currency))
456
- return currency;
452
+ if (typeof currency === "string") {
453
+ const normalized = currency.toUpperCase();
454
+ if (ISO_CURRENCY_CODE.test(normalized))
455
+ return normalized;
456
+ }
457
457
  }
458
458
  return fieldCurrency ?? "EUR";
459
459
  }
@@ -474,13 +474,30 @@ function resolveNumberUnit(unit, row) {
474
474
  const trimmed = sibling.trim();
475
475
  return trimmed.length > 0 && trimmed.length <= MAX_SIBLING_UNIT_LENGTH ? trimmed : undefined;
476
476
  }
477
+ function stringArrayOf(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];
489
+ }
490
+ function stringPropertyOf(value, key) {
491
+ const property = propertyOf(value, key);
492
+ return typeof property === "string" ? property : undefined;
493
+ }
477
494
  function moneyMinorValue(v, currency) {
478
495
  if (v === undefined || v === null || v === "")
479
496
  return "";
480
497
  if (typeof v === "number")
481
498
  return Math.round(v * 10 ** currencyDecimals(currency));
482
499
  if (typeof v === "object") {
483
- const amount = v.amount;
500
+ const amount = propertyOf(v, "amount");
484
501
  if (typeof amount === "number")
485
502
  return Math.round(amount * 10 ** currencyDecimals(currency));
486
503
  }
@@ -498,11 +515,11 @@ function moneyPayload(minorUnits, currency) {
498
515
  // (noch nicht gesetzt) → "" als Empty-Sentinel, analog money/timestamp.
499
516
  function locatedValue(v) {
500
517
  if (v !== null && typeof v === "object" && "at" in v && "tz" in v) {
501
- const o = v;
518
+ const utc = propertyOf(v, "utc");
502
519
  return {
503
- at: typeof o.at === "string" ? o.at : "",
504
- tz: typeof o.tz === "string" ? o.tz : "",
505
- ...(typeof o.utc === "string" && { utc: o.utc }),
520
+ at: typeof v.at === "string" ? v.at : "",
521
+ tz: typeof v.tz === "string" ? v.tz : "",
522
+ ...(typeof utc === "string" && { utc }),
506
523
  };
507
524
  }
508
525
  return "";
@@ -14,11 +14,10 @@ function buildWriteFormSchema(fields) {
14
14
  .object({})
15
15
  .passthrough()
16
16
  .superRefine((values, ctx) => {
17
- const record = values;
18
17
  for (const field of fields) {
19
18
  if (field.readOnly || !field.visible || !field.required)
20
19
  continue;
21
- if (isPresent(record[field.field]))
20
+ if (isPresent(values[field.field]))
22
21
  continue;
23
22
  ctx.addIssue({
24
23
  code: "custom",
@@ -261,6 +261,7 @@ export const kumikoDefaultTranslations = {
261
261
  "errors.internal": "Something went wrong. Please try again later.",
262
262
  "errors.rate_limited": "Too many requests. Please try again shortly.",
263
263
  "errors.rate_limit_unavailable": "Rate limiting is temporarily unavailable. Please try again shortly.",
264
+ "errors.puppeteer_unavailable": "The PDF service is not reachable. PDFs cannot be created right now.",
264
265
  "errors.cap.exceeded": "Limit reached. Upgrade your plan or wait for the next period.",
265
266
  "errors.download.urlMissing": "Download unavailable — please try again.",
266
267
  "auth.errors.originNotAllowed": "Requests from this origin are not allowed.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.362.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.362.0",
19
- "@cosmicdrift/kumiko-headless": "0.362.0",
20
- "@cosmicdrift/kumiko-types": "0.362.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.362.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,28 @@
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
+ },
20
+ {
21
+ "version": "0.363.0",
22
+ "type": "improvement",
23
+ "title": "propertyOf type guard and fewer unchecked casts in the UI packages",
24
+ "detail": "\"@cosmicdrift/kumiko-types/type-guards\" exports propertyOf, which reads one property off an unknown value. Headless, renderer, renderer-web and dispatcher-live replace unchecked as-casts and globalThis probes with it and with narrowing. Behavior stays the same for well-formed input; a format spec, upload response or stream event with a wrongly typed property is now ignored instead of passed through."
25
+ },
2
26
  {
3
27
  "version": "0.362.0",
4
28
  "type": "improvement",