@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.
- package/dist/app/kumiko-screen.js +50 -14
- package/dist/app/visible-when.d.ts +1 -0
- package/dist/app/visible-when.js +7 -0
- package/dist/components/availability-options-select.d.ts +4 -4
- package/dist/components/availability-options-select.js +13 -6
- package/dist/components/multi-select-checkboxes.d.ts +1 -0
- package/dist/components/multi-select-checkboxes.js +5 -2
- package/dist/components/render-field.js +18 -5
- package/package.json +5 -5
- package/src/changes.json +18 -0
|
@@ -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 {
|
|
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
|
|
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(
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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 ||
|
|
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" &&
|
|
534
|
+
return typeof value === "string" && isValidIsoDate(value) ? value : undefined;
|
|
505
535
|
case "timestamp":
|
|
506
|
-
return typeof value === "string" &&
|
|
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
|
|
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;
|
package/dist/app/visible-when.js
CHANGED
|
@@ -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
|
|
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[],
|
|
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
|
|
16
|
+
readonly initialValues: readonly string[];
|
|
17
17
|
readonly children: (options: readonly SelectOptionEntry[]) => ReactNode;
|
|
18
18
|
};
|
|
19
|
-
export declare function AvailabilityOptionsMultiSelect({ field, query,
|
|
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
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
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.
|
|
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,
|
|
57
|
+
export function AvailabilityOptionsMultiSelect({ field, query, initialValues, children, }) {
|
|
52
58
|
const { data } = useQuery(query, {});
|
|
53
|
-
|
|
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
|
|
@@ -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
|
|
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,
|
|
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"
|
|
453
|
-
|
|
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)
|
|
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.
|
|
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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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",
|