@cosmicdrift/kumiko-renderer 0.363.0 → 0.365.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/form-schema.js +18 -8
- package/dist/app/kumiko-screen.js +50 -14
- package/dist/app/list-facets.js +13 -6
- package/dist/app/nav.d.ts +3 -1
- 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/file-list-field.js +3 -1
- 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/dist/components/render-list.js +12 -6
- package/package.json +5 -5
- package/src/changes.json +24 -0
package/dist/app/form-schema.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { evalFieldCondition, findUnavailableSelectOptions, NO_WIDGET_FIELD_TYPES, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
1
|
+
import { evalFieldCondition, findUnavailableSelectOptions, isUnchangedValue, NO_WIDGET_FIELD_TYPES, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { layoutEditFields } from "./layout-fields.js";
|
|
@@ -34,8 +34,7 @@ function isRequiredValueMissing(spec, field, record, unchangedFrom) {
|
|
|
34
34
|
return false;
|
|
35
35
|
if (isPresent(record[spec.field]))
|
|
36
36
|
return false;
|
|
37
|
-
return !(unchangedFrom !== undefined &&
|
|
38
|
-
JSON.stringify(record[spec.field]) === JSON.stringify(unchangedFrom[spec.field]));
|
|
37
|
+
return !(unchangedFrom !== undefined && isUnchangedValue(record[spec.field], unchangedFrom[spec.field]));
|
|
39
38
|
}
|
|
40
39
|
// Client-side presence validation for the auto-wired entityEdit path —
|
|
41
40
|
// checks that every rendered required field HAS a value, not that the
|
|
@@ -79,17 +78,24 @@ export function buildFormSchema(entity, screen, verb, unchangedFrom) {
|
|
|
79
78
|
// @cast-boundary form-values
|
|
80
79
|
const record = values;
|
|
81
80
|
const selectFieldsToCheck = {};
|
|
81
|
+
// The server validates the merged row, so a locked select still fails there; the
|
|
82
|
+
// client reports it on the condition field the user can actually change.
|
|
83
|
+
const lockedSelectFields = {};
|
|
82
84
|
for (const spec of fields) {
|
|
83
85
|
const field = entity.fields[spec.field];
|
|
84
86
|
if (!field)
|
|
85
87
|
continue;
|
|
86
88
|
// Hidden on create, read-only on update: the user cannot resolve a presence error.
|
|
87
|
-
if (excludedFromWrite.has(spec.field))
|
|
89
|
+
if (excludedFromWrite.has(spec.field)) {
|
|
90
|
+
lockedSelectFields[spec.field] = field;
|
|
88
91
|
continue;
|
|
92
|
+
}
|
|
89
93
|
// Not operable by the user — a presence error would be unresolvable,
|
|
90
94
|
// same reason as the NO_WIDGET_FIELD_TYPES check below.
|
|
91
|
-
if (spec.readOnly !== undefined && evalFieldCondition(spec.readOnly, record))
|
|
95
|
+
if (spec.readOnly !== undefined && evalFieldCondition(spec.readOnly, record)) {
|
|
96
|
+
lockedSelectFields[spec.field] = field;
|
|
92
97
|
continue;
|
|
98
|
+
}
|
|
93
99
|
selectFieldsToCheck[spec.field] = field;
|
|
94
100
|
if (!isRequiredValueMissing(spec, field, record, unchangedFrom))
|
|
95
101
|
continue;
|
|
@@ -103,11 +109,15 @@ export function buildFormSchema(entity, screen, verb, unchangedFrom) {
|
|
|
103
109
|
}
|
|
104
110
|
// Against `unchangedFrom` the helper still checks a stored value whose
|
|
105
111
|
// discriminator changed — the server validates the merged row.
|
|
106
|
-
for (const issue of findUnavailableSelectOptions(selectFieldsToCheck, record, unchangedFrom)) {
|
|
112
|
+
for (const issue of findUnavailableSelectOptions({ ...lockedSelectFields, ...selectFieldsToCheck }, record, unchangedFrom)) {
|
|
113
|
+
const lockedDef = lockedSelectFields[issue.field];
|
|
114
|
+
const blamedField = lockedDef !== undefined && lockedDef.type === "select"
|
|
115
|
+
? (lockedDef.conditionalOptions?.find((rule) => rule.options.includes(issue.value) && !evalFieldCondition(rule.when, record))?.when.field ?? issue.field)
|
|
116
|
+
: issue.field;
|
|
107
117
|
ctx.addIssue({
|
|
108
118
|
code: "custom",
|
|
109
|
-
path: [
|
|
110
|
-
message: `"${
|
|
119
|
+
path: [blamedField],
|
|
120
|
+
message: `"${blamedField}" is not available for the current selection.`,
|
|
111
121
|
params: { [I18N_KEY_PARAM]: OPTION_NOT_AVAILABLE_I18N_KEY, allowed: issue.allowed },
|
|
112
122
|
});
|
|
113
123
|
}
|
|
@@ -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):
|
package/dist/app/list-facets.js
CHANGED
|
@@ -217,16 +217,20 @@ export function clampDateRange(current, bound, value) {
|
|
|
217
217
|
// starts at that day's first instant, "to the 14th" includes everything up to
|
|
218
218
|
// its last. Going through the next day's start keeps both ends right across a
|
|
219
219
|
// DST boundary, where the day is not 24h long.
|
|
220
|
-
function
|
|
221
|
-
let plainDate;
|
|
220
|
+
function parsePlainDate(date) {
|
|
222
221
|
try {
|
|
223
|
-
|
|
222
|
+
return Temporal.PlainDate.from(date);
|
|
224
223
|
}
|
|
225
224
|
catch {
|
|
226
|
-
// Half-typed or hand-crafted URL value — drop the bound rather than send
|
|
227
|
-
// something the handler's ISO-datetime schema would reject.
|
|
228
225
|
return null;
|
|
229
226
|
}
|
|
227
|
+
}
|
|
228
|
+
function dayBoundInstant(date, bound, timeZone) {
|
|
229
|
+
// Half-typed or hand-crafted URL value — drop the bound rather than send
|
|
230
|
+
// something the handler's ISO-datetime schema would reject.
|
|
231
|
+
const plainDate = parsePlainDate(date);
|
|
232
|
+
if (plainDate === null)
|
|
233
|
+
return null;
|
|
230
234
|
const start = plainDate.toZonedDateTime(timeZone).toInstant();
|
|
231
235
|
return bound === "from"
|
|
232
236
|
? start.toString()
|
|
@@ -242,7 +246,10 @@ function dayBoundInstant(date, bound, timeZone) {
|
|
|
242
246
|
// A shared or hand-built URL can carry from > to; the handler's `from <= to`
|
|
243
247
|
// refine would answer 422, so the bounds are put in order before conversion.
|
|
244
248
|
function swapInvertedRange(range) {
|
|
245
|
-
|
|
249
|
+
// Comparing raw strings would swap a valid bound against garbage and flip the filter's meaning.
|
|
250
|
+
const from = parsePlainDate(range.from);
|
|
251
|
+
const to = parsePlainDate(range.to);
|
|
252
|
+
const inverted = from !== null && to !== null && Temporal.PlainDate.compare(from, to) > 0;
|
|
246
253
|
return inverted ? { from: range.to, to: range.from } : range;
|
|
247
254
|
}
|
|
248
255
|
export function buildDateRangePayload(specs, urlFilters, timeZone) {
|
package/dist/app/nav.d.ts
CHANGED
|
@@ -18,7 +18,9 @@ export type ObjectTarget = {
|
|
|
18
18
|
export type NavTarget = ScreenTarget | ObjectTarget;
|
|
19
19
|
export type NavigateOptions = {
|
|
20
20
|
/** Search params of the target route, set in the same history entry as
|
|
21
|
-
* the path. Without them
|
|
21
|
+
* the path. Without them a different target starts with an empty query;
|
|
22
|
+
* navigating to the current path keeps the current query (pass
|
|
23
|
+
* `searchParams: {}` to clear it). */
|
|
22
24
|
readonly searchParams?: Readonly<Record<string, string>>;
|
|
23
25
|
};
|
|
24
26
|
export type NavApi = {
|
|
@@ -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
|
|
@@ -13,6 +13,8 @@ export function FileListField({ field, id, kind, onChange, hasError, }) {
|
|
|
13
13
|
const { Input, Button, Grid, GridCell, Text } = usePrimitives();
|
|
14
14
|
const t = useTranslation();
|
|
15
15
|
const fileIds = readFileIds(field.value);
|
|
16
|
+
// The server accepts repeated ids, so the id alone is not a unique key.
|
|
17
|
+
const keys = fileIds.map((fileId, index) => `${fileId}#${fileIds.slice(0, index).filter((x) => x === fileId).length}`);
|
|
16
18
|
const disabled = field.readOnly;
|
|
17
19
|
const canAdd = !disabled && (field.maxCount === undefined || fileIds.length < field.maxCount);
|
|
18
20
|
const inputProps = {
|
|
@@ -42,7 +44,7 @@ export function FileListField({ field, id, kind, onChange, hasError, }) {
|
|
|
42
44
|
onChange(fileIds.filter((_, i) => i !== index));
|
|
43
45
|
else
|
|
44
46
|
onChange(fileIds.map((existing, i) => (i === index ? next : existing)));
|
|
45
|
-
} }), !disabled && (_jsxs(Grid, { columns: "auto", children: [index > 0 && (_jsx(Button, { variant: "ghost", size: "icon", icon: "chevron-up", ariaLabel: t("kumiko.field.file-list.move-up"), testId: `${id}-${index}-move-up`, onClick: () => move(index, index - 1) })), index < fileIds.length - 1 && (_jsx(Button, { variant: "ghost", size: "icon", icon: "chevron-down", ariaLabel: t("kumiko.field.file-list.move-down"), testId: `${id}-${index}-move-down`, onClick: () => move(index, index + 1) }))] }))] },
|
|
47
|
+
} }), !disabled && (_jsxs(Grid, { columns: "auto", children: [index > 0 && (_jsx(Button, { variant: "ghost", size: "icon", icon: "chevron-up", ariaLabel: t("kumiko.field.file-list.move-up"), testId: `${id}-${index}-move-up`, onClick: () => move(index, index - 1) })), index < fileIds.length - 1 && (_jsx(Button, { variant: "ghost", size: "icon", icon: "chevron-down", ariaLabel: t("kumiko.field.file-list.move-down"), testId: `${id}-${index}-move-down`, onClick: () => move(index, index + 1) }))] }))] }, keys[index]))), canAdd && (_jsxs(GridCell, { children: [_jsx(Text, { variant: "muted", children: t("kumiko.field.file-list.add") }), _jsx(Input, { ...inputProps, id: `${id}-add`, value: null, onChange: (next) => {
|
|
46
48
|
if (next !== null)
|
|
47
49
|
onChange([...fileIds, next]);
|
|
48
50
|
} })] }, "add"))] }));
|
|
@@ -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);
|
|
@@ -31,14 +31,20 @@ export function RenderList(props) {
|
|
|
31
31
|
// mount and can flush after a keystroke landing in the same commit,
|
|
32
32
|
// resetting localQ and swallowing the input.
|
|
33
33
|
const [syncedSearchValue, setSyncedSearchValue] = useState(searchValue);
|
|
34
|
-
// The parent may echo our own debounced
|
|
35
|
-
// trip)
|
|
36
|
-
|
|
34
|
+
// The parent may echo our own debounced emits back late (router/URL round
|
|
35
|
+
// trip), possibly after a newer emit already went out; adopting such a stale
|
|
36
|
+
// echo would erase keys typed in between. All unechoed emits are kept, and a
|
|
37
|
+
// matching echo drops itself and every older one.
|
|
38
|
+
const pendingEmitsRef = useRef([]);
|
|
37
39
|
if (searchValue !== syncedSearchValue) {
|
|
38
40
|
setSyncedSearchValue(searchValue);
|
|
39
|
-
|
|
41
|
+
const echoIndex = pendingEmitsRef.current.indexOf(searchValue ?? "");
|
|
42
|
+
if (echoIndex >= 0) {
|
|
43
|
+
pendingEmitsRef.current.splice(0, echoIndex + 1);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
40
46
|
setLocalQ(searchValue ?? "");
|
|
41
|
-
|
|
47
|
+
pendingEmitsRef.current = [];
|
|
42
48
|
}
|
|
43
49
|
}
|
|
44
50
|
useEffect(() => {
|
|
@@ -47,7 +53,7 @@ export function RenderList(props) {
|
|
|
47
53
|
if (localQ === (searchValue ?? ""))
|
|
48
54
|
return;
|
|
49
55
|
const timer = setTimeout(() => {
|
|
50
|
-
|
|
56
|
+
pendingEmitsRef.current.push(localQ);
|
|
51
57
|
onSearchChange(localQ);
|
|
52
58
|
}, SEARCH_DEBOUNCE_MS);
|
|
53
59
|
return () => clearTimeout(timer);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.365.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.365.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.365.0",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.365.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.365.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.365.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "List search keeps the newest typed text; file lists tolerate duplicate ids; date ranges and locked selects validate correctly",
|
|
6
|
+
"detail": "A late echo of an older debounced search emit no longer overwrites newer typing. Duplicate file ids no longer collide as React keys. An inverted date range is swapped only when both bounds parse. Select rules on writeExcluded or readOnly fields are now checked by the form schema."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.364.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "A failed refetch of a visibleWhen gate no longer unmounts a screen that is already showing",
|
|
12
|
+
"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."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.364.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "A held multiSelect value that optionsAvailabilityQuery marks unavailable stays listed but cannot be removed",
|
|
18
|
+
"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."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.364.0",
|
|
22
|
+
"type": "fix",
|
|
23
|
+
"title": "Screen fallback access, projectionDetail back navigation and stricter prefill",
|
|
24
|
+
"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."
|
|
25
|
+
},
|
|
2
26
|
{
|
|
3
27
|
"version": "0.363.0",
|
|
4
28
|
"type": "improvement",
|