@cosmicdrift/kumiko-renderer 0.364.0 → 0.365.1
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
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
|
}
|
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 = {
|
|
@@ -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"))] }));
|
|
@@ -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.1",
|
|
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.1",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.365.1",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.365.1",
|
|
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.1"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
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
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.364.0",
|
|
4
10
|
"type": "fix",
|