@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.
@@ -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: [issue.field],
110
- message: `"${issue.field}" is not available for the current selection.`,
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
  }
@@ -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 dayBoundInstant(date, bound, timeZone) {
221
- let plainDate;
220
+ function parsePlainDate(date) {
222
221
  try {
223
- plainDate = Temporal.PlainDate.from(date);
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
- const inverted = range.from !== "" && range.to !== "" && range.from > range.to;
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 the target starts with an empty query. */
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) }))] }))] }, fileId))), 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) => {
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 emit back late (router/URL round
35
- // trip); adopting that stale echo would erase keys typed in between.
36
- const lastEmittedRef = useRef(undefined);
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
- if (searchValue !== lastEmittedRef.current) {
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
- lastEmittedRef.current = undefined;
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
- lastEmittedRef.current = localQ;
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.364.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.364.0",
19
- "@cosmicdrift/kumiko-headless": "0.364.0",
20
- "@cosmicdrift/kumiko-types": "0.364.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.364.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",