@cosmicdrift/kumiko-renderer 0.174.0 → 0.176.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.174.0",
3
+ "version": "0.176.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,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.174.0",
19
- "@cosmicdrift/kumiko-headless": "0.174.0",
18
+ "@cosmicdrift/kumiko-framework": "0.176.0",
19
+ "@cosmicdrift/kumiko-headless": "0.176.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2"
22
22
  },
@@ -45,4 +45,24 @@ describe("mergeSearchParamsIntoInitial", () => {
45
45
  const result = mergeSearchParamsIntoInitial(fields, { price: "19.99" });
46
46
  expect(result["price"]).toBe(19.99);
47
47
  });
48
+
49
+ test("renderableFields set given: searchParam for a non-rendered field is ignored (#1708)", () => {
50
+ const fields: Record<string, FieldDef> = {
51
+ status: { type: "text", default: "draft" },
52
+ ownerId: { type: "text" },
53
+ };
54
+ const result = mergeSearchParamsIntoInitial(
55
+ fields,
56
+ { status: "approved", ownerId: "user-123" },
57
+ new Set(["status"]),
58
+ );
59
+ expect(result["status"]).toBe("approved");
60
+ expect(result["ownerId"]).toBe("");
61
+ });
62
+
63
+ test("no renderableFields set given (undefined): behaves as before, all fields eligible", () => {
64
+ const fields: Record<string, FieldDef> = { ownerId: { type: "text" } };
65
+ const result = mergeSearchParamsIntoInitial(fields, { ownerId: "user-123" });
66
+ expect(result["ownerId"]).toBe("user-123");
67
+ });
48
68
  });
@@ -1,6 +1,5 @@
1
1
  import type { ConfigCascade } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import type {
3
- AccessRule,
4
3
  ActionFormScreenDefinition,
5
4
  ConfigEditScreenDefinition,
6
5
  DashboardScreenDefinition,
@@ -16,7 +15,11 @@ import type {
16
15
  ScreenDefinition,
17
16
  ToolbarAction,
18
17
  } from "@cosmicdrift/kumiko-framework/ui-types";
19
- import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
18
+ import {
19
+ evalFieldCondition,
20
+ isExtensionEditSection,
21
+ normalizeEditField,
22
+ } from "@cosmicdrift/kumiko-framework/ui-types";
20
23
  import type {
21
24
  Command,
22
25
  FormSnapshot,
@@ -47,6 +50,7 @@ import {
47
50
  } from "./projection-detail-shim";
48
51
  import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim";
49
52
  import { lastSegment, toKebab } from "./qn";
53
+ import { screenAccessAllows } from "./screen-access";
50
54
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
51
55
 
52
56
  function evalRowExtractor(
@@ -114,21 +118,7 @@ export function qualifyNavId(featureName: string, navId: string): string {
114
118
  return `${featureName}:nav:${navId}`;
115
119
  }
116
120
 
117
- // Minimal role-gate for the screen-render path (#1203 — nav filtering via
118
- // filterByAccess in workspace-shell.tsx hid role-gated screens from the
119
- // menu, but a direct URL/screenQn hit reached KumikoScreen unchecked).
120
- // Duplicated instead of imported from framework/engine's hasAccess (pulls
121
- // server-side deps) — same bundle-purity reasoning as headless/nav's
122
- // resolve.ts:userCanSee, which this mirrors.
123
- export function screenAccessAllows(
124
- access: AccessRule | undefined,
125
- userRoles: readonly string[] | undefined,
126
- ): boolean {
127
- if (!access) return true;
128
- if ("openToAll" in access) return access.openToAll;
129
- if (userRoles === undefined) return false;
130
- return access.roles.some((role) => userRoles.includes(role));
131
- }
121
+ export { screenAccessAllows };
132
122
 
133
123
  export function KumikoScreen({
134
124
  schema,
@@ -336,13 +326,30 @@ export function buildInitialValues(
336
326
  return out;
337
327
  }
338
328
 
329
+ // Field names actually rendered by the screen's layout — a search-param
330
+ // merge must not set fields the form never shows the user (#1708:
331
+ // unrendered fields get no client-side validation and no chance to
332
+ // review/correct the injected value).
333
+ function layoutFieldNames(screen: EntityEditScreenDefinition): ReadonlySet<string> {
334
+ const names = new Set<string>();
335
+ for (const section of screen.layout.sections) {
336
+ if (isExtensionEditSection(section)) continue;
337
+ for (const spec of section.fields) {
338
+ names.add(normalizeEditField(spec).field);
339
+ }
340
+ }
341
+ return names;
342
+ }
343
+
339
344
  export function mergeSearchParamsIntoInitial(
340
345
  fields: Readonly<Record<string, unknown>>,
341
346
  searchParams: Readonly<Record<string, string>>,
347
+ renderableFields?: ReadonlySet<string>,
342
348
  ): Record<string, unknown> {
343
349
  const defaults = buildInitialValues(fields) as Record<string, unknown>;
344
350
  const merged: Record<string, unknown> = { ...defaults };
345
351
  for (const [name, fieldDef] of Object.entries(fields)) {
352
+ if (renderableFields !== undefined && !renderableFields.has(name)) continue;
346
353
  const shape = fieldDef as { type?: string; sensitive?: boolean };
347
354
  if (shape.sensitive === true) continue;
348
355
  const raw = searchParams[name];
@@ -431,8 +438,13 @@ function EntityEditCreateBody({
431
438
  }): ReactNode {
432
439
  const nav = useNav();
433
440
  const initial = useMemo(
434
- () => mergeSearchParamsIntoInitial(entity.fields, nav.searchParams) as FormValues,
435
- [entity.fields, nav.searchParams],
441
+ () =>
442
+ mergeSearchParamsIntoInitial(
443
+ entity.fields,
444
+ nav.searchParams,
445
+ layoutFieldNames(screen),
446
+ ) as FormValues,
447
+ [entity.fields, nav.searchParams, screen],
436
448
  );
437
449
  const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
438
450
  const navigateToList = useNavigateToListAfter(schema, screen.entity);
@@ -1430,8 +1442,13 @@ function ActionFormBody({
1430
1442
  const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
1431
1443
  const synthScreen = useMemo(() => synthesizeActionFormScreen(screen), [screen]);
1432
1444
  const initial = useMemo(
1433
- () => mergeSearchParamsIntoInitial(screen.fields, nav.searchParams) as FormValues,
1434
- [screen.fields, nav.searchParams],
1445
+ () =>
1446
+ mergeSearchParamsIntoInitial(
1447
+ screen.fields,
1448
+ nav.searchParams,
1449
+ layoutFieldNames(synthScreen),
1450
+ ) as FormValues,
1451
+ [screen.fields, nav.searchParams, synthScreen],
1435
1452
  );
1436
1453
  const handleSubmitted = useCallback(
1437
1454
  (result: SubmitResult<unknown>) => {
@@ -0,0 +1,19 @@
1
+ import type { AccessRule } from "@cosmicdrift/kumiko-framework/ui-types";
2
+
3
+ // Minimal role-gate for the screen-render path (#1203 — nav filtering via
4
+ // filterByAccess in workspace-shell.tsx hid role-gated screens from the
5
+ // menu, but a direct URL/screenQn hit reached KumikoScreen unchecked).
6
+ // Reimplemented instead of imported from framework/engine's hasAccess
7
+ // (pulls server-side deps) — same bundle-purity reasoning as headless/nav's
8
+ // resolve.ts:userCanSee, which this mirrors. Own leaf module (not exported
9
+ // from kumiko-screen.tsx directly) so render-field.tsx can import it too
10
+ // without the kumiko-screen → RenderEdit → RenderField cycle.
11
+ export function screenAccessAllows(
12
+ access: AccessRule | undefined,
13
+ userRoles: readonly string[] | undefined,
14
+ ): boolean {
15
+ if (!access) return true;
16
+ if ("openToAll" in access) return access.openToAll;
17
+ if (userRoles === undefined) return false;
18
+ return access.roles.some((role) => userRoles.includes(role));
19
+ }
@@ -32,7 +32,11 @@ function extractCreatedId(data: unknown): string | undefined {
32
32
  export type ReferenceCreateDialogProps = {
33
33
  readonly open: boolean;
34
34
  readonly onClose: () => void;
35
- readonly onCreated: (id: string) => void;
35
+ // id is undefined when the record was created but the write-handler's
36
+ // success payload carried no `id` (custom create-handler variant) — the
37
+ // record already exists server-side, so this is still a success signal
38
+ // to the caller, just one it can't auto-select from (#1694).
39
+ readonly onCreated: (id: string | undefined) => void;
36
40
  readonly featureName: string;
37
41
  readonly screen: EntityEditScreenDefinition;
38
42
  readonly entity: EntityDefinition;
@@ -52,8 +56,7 @@ export function ReferenceCreateDialog({
52
56
  const writeCommand = entityWriteCommand(featureName, screen.entity);
53
57
  const handleSubmitted = (result: SubmitResult<unknown>): void => {
54
58
  if (result.validationBlocked || !result.isSuccess) return;
55
- const id = extractCreatedId(result.data);
56
- if (id !== undefined) onCreated(id);
59
+ onCreated(extractCreatedId(result.data));
57
60
  };
58
61
  if (!open) return null;
59
62
  return (
@@ -1,11 +1,9 @@
1
- import type {
2
- AccessRule,
3
- EntityEditScreenDefinition,
4
- } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import type { EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
5
2
  import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
6
3
  import { type ReactNode, useCallback, useMemo, useState } from "react";
7
4
  import { useAppFeatures } from "../app/app-features-context";
8
5
  import { toKebab } from "../app/qn";
6
+ import { screenAccessAllows } from "../app/screen-access";
9
7
  import { useUserRoles } from "../context/user-roles-context";
10
8
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits";
11
9
  import { useQuery } from "../hooks/use-query";
@@ -89,19 +87,6 @@ export function RenderField({
89
87
  );
90
88
  }
91
89
 
92
- // Duplicated from kumiko-screen.tsx's screenAccessAllows (not imported —
93
- // that module imports RenderEdit → RenderField, importing back from here
94
- // would cycle). Same minimal role-gate logic.
95
- function createScreenAccessAllows(
96
- access: AccessRule | undefined,
97
- userRoles: readonly string[] | undefined,
98
- ): boolean {
99
- if (!access) return true;
100
- if ("openToAll" in access) return access.openToAll;
101
- if (userRoles === undefined) return false;
102
- return access.roles.some((role) => userRoles.includes(role));
103
- }
104
-
105
90
  // Tier 2.7e-3 + 2.1c: Reference-Input rendert eine Searchable Combobox
106
91
  // gefüllt aus einer Live-Query auf die referenced Entity. Default-
107
92
  // Limit: 200 — bei größeren Datasets fehlt der Tail im Dropdown
@@ -132,6 +117,7 @@ function ReferenceInput({
132
117
  readonly Input: ReturnType<typeof usePrimitives>["Input"];
133
118
  readonly featureName: string;
134
119
  }): ReactNode {
120
+ const { Banner } = usePrimitives();
135
121
  const refEntity = field.refEntity ?? "";
136
122
  const refFeature = field.refFeature ?? featureName;
137
123
  const labelField = field.refLabelField ?? "id";
@@ -157,7 +143,7 @@ function ReferenceInput({
157
143
  s.type === "entityEdit" &&
158
144
  s.entity === refEntity &&
159
145
  s.allowCreate !== false &&
160
- createScreenAccessAllows(s.access, userRoles),
146
+ screenAccessAllows(s.access, userRoles),
161
147
  );
162
148
  const refEntityDef = refTargetSchema?.entities[refEntity];
163
149
  // Tier 2.7e Remote-Search: User tippt im Combobox → Server filtert
@@ -178,10 +164,18 @@ function ReferenceInput({
178
164
  );
179
165
  const handleSearchChange = useCallback((q: string) => setSearchTerm(q), []);
180
166
  const canCreate = !field.readOnly && refCreateScreen !== undefined && refEntityDef !== undefined;
167
+ const [createdWithoutIdWarning, setCreatedWithoutIdWarning] = useState(false);
181
168
  const handleCreated = useCallback(
182
- (newId: string) => {
169
+ (newId: string | undefined) => {
183
170
  setCreateOpen(false);
184
171
  void queryResult.refetch();
172
+ if (newId === undefined) {
173
+ // Record was created server-side but the payload carried no id —
174
+ // can't auto-select it, surface that instead of failing silently.
175
+ setCreatedWithoutIdWarning(true);
176
+ return;
177
+ }
178
+ setCreatedWithoutIdWarning(false);
185
179
  if (isMultiple) {
186
180
  const current = Array.isArray(field.value) ? (field.value as readonly string[]) : [];
187
181
  onChange([...current, newId]);
@@ -217,15 +211,22 @@ function ReferenceInput({
217
211
  createLabel: t("kumiko.actions.create"),
218
212
  }),
219
213
  } as const;
220
- const createDialog = canCreate && refCreateScreen && refEntityDef && (
221
- <ReferenceCreateDialog
222
- open={createOpen}
223
- onClose={() => setCreateOpen(false)}
224
- onCreated={handleCreated}
225
- featureName={refFeature}
226
- screen={refCreateScreen}
227
- entity={refEntityDef}
228
- />
214
+ const createDialog = (
215
+ <>
216
+ {canCreate && refCreateScreen && refEntityDef && (
217
+ <ReferenceCreateDialog
218
+ open={createOpen}
219
+ onClose={() => setCreateOpen(false)}
220
+ onCreated={handleCreated}
221
+ featureName={refFeature}
222
+ screen={refCreateScreen}
223
+ entity={refEntityDef}
224
+ />
225
+ )}
226
+ {createdWithoutIdWarning && (
227
+ <Banner variant="error">{t("kumiko.field.reference-created-no-id")}</Banner>
228
+ )}
229
+ </>
229
230
  );
230
231
  if (isMultiple) {
231
232
  const arrayValue: readonly string[] = Array.isArray(field.value)
@@ -34,6 +34,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
34
34
  "kumiko.field.time": "Uhrzeit",
35
35
  "kumiko.field.timezone": "Zeitzone",
36
36
  "kumiko.field.locatedTzHint": "Zeit lokal am angegebenen Ort",
37
+ "kumiko.field.reference-created-no-id":
38
+ "Datensatz wurde angelegt, konnte aber nicht automatisch ausgewählt werden. Bitte manuell auswählen.",
37
39
 
38
40
  // List — DataTable Toolbar, Empty-State, Search.
39
41
  "kumiko.list.search-placeholder": "Suchen…",
@@ -172,6 +174,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
172
174
  "kumiko.field.time": "Time",
173
175
  "kumiko.field.timezone": "Time zone",
174
176
  "kumiko.field.locatedTzHint": "Time local to the given location",
177
+ "kumiko.field.reference-created-no-id":
178
+ "Record was created but could not be selected automatically. Please select it manually.",
175
179
 
176
180
  "kumiko.list.search-placeholder": "Search…",
177
181
  "kumiko.list.empty.title": "No entries yet.",