@cosmicdrift/kumiko-renderer 0.170.0 → 0.171.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.170.0",
3
+ "version": "0.171.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.170.0",
19
- "@cosmicdrift/kumiko-headless": "0.170.0",
18
+ "@cosmicdrift/kumiko-framework": "0.171.0",
19
+ "@cosmicdrift/kumiko-headless": "0.171.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2"
22
22
  },
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mergeSearchParamsIntoInitial } from "../app/kumiko-screen";
3
+
4
+ type FieldDef = { type?: string; default?: unknown; sensitive?: boolean };
5
+
6
+ describe("mergeSearchParamsIntoInitial", () => {
7
+ test("raw string param merges in as-is for a text field", () => {
8
+ const fields: Record<string, FieldDef> = { name: { type: "text" } };
9
+ const result = mergeSearchParamsIntoInitial(fields, { name: "Alice" });
10
+ expect(result["name"]).toBe("Alice");
11
+ });
12
+
13
+ test("number-type field coerces a numeric string", () => {
14
+ const fields: Record<string, FieldDef> = { age: { type: "number" } };
15
+ const result = mergeSearchParamsIntoInitial(fields, { age: "42" });
16
+ expect(result["age"]).toBe(42);
17
+ });
18
+
19
+ test("invalid number string falls back to field default", () => {
20
+ const fields: Record<string, FieldDef> = { count: { type: "number", default: 7 } };
21
+ const result = mergeSearchParamsIntoInitial(fields, { count: "not-a-number" });
22
+ expect(result["count"]).toBe(7);
23
+ });
24
+
25
+ test("boolean field coerces 'true' and 'false'", () => {
26
+ const fields: Record<string, FieldDef> = { active: { type: "boolean" } };
27
+ expect(mergeSearchParamsIntoInitial(fields, { active: "true" })["active"]).toBe(true);
28
+ expect(mergeSearchParamsIntoInitial(fields, { active: "false" })["active"]).toBe(false);
29
+ });
30
+
31
+ test("sensitive field is skipped even when a matching searchParam exists", () => {
32
+ const fields: Record<string, FieldDef> = { password: { type: "text", sensitive: true } };
33
+ const result = mergeSearchParamsIntoInitial(fields, { password: "secret" });
34
+ expect(result["password"]).toBe("");
35
+ });
36
+
37
+ test("field with no matching searchParam keeps its buildInitialValues default", () => {
38
+ const fields: Record<string, FieldDef> = { total: { type: "number", default: 100 } };
39
+ const result = mergeSearchParamsIntoInitial(fields, {});
40
+ expect(result["total"]).toBe(100);
41
+ });
42
+
43
+ test("money-type field coerces a numeric string", () => {
44
+ const fields: Record<string, FieldDef> = { price: { type: "money" } };
45
+ const result = mergeSearchParamsIntoInitial(fields, { price: "19.99" });
46
+ expect(result["price"]).toBe(19.99);
47
+ });
48
+ });
@@ -0,0 +1,142 @@
1
+ // Issue #1680: navigate params on entityEdit-create were documented as
2
+ // "will be read" but EntityEditCreateBody never consulted nav.searchParams —
3
+ // only buildInitialValues(entity.fields). A rowAction navigate with params
4
+ // to an entityEdit target therefore opened an empty form with no boot error.
5
+ // This test renders the real create path (KumikoScreen → EntityEditScreen →
6
+ // EntityEditCreateBody → RenderEdit → RenderField) under a NavProvider with
7
+ // searchParams set and asserts the input is prefilled — not just the helper
8
+ // in isolation.
9
+ import { describe, expect, test } from "bun:test";
10
+ import type {
11
+ EntityDefinition,
12
+ EntityEditScreenDefinition,
13
+ } from "@cosmicdrift/kumiko-framework/ui-types";
14
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
15
+ import { render } from "@testing-library/react";
16
+ import type { ComponentType, ReactNode } from "react";
17
+ import { DispatcherProvider } from "../../context/dispatcher-context";
18
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
19
+ import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
20
+ import type { FeatureSchema } from "../feature-schema";
21
+ import { KumikoScreen } from "../kumiko-screen";
22
+ import { NavProvider } from "../nav";
23
+
24
+ const captured: Record<string, InputProps> = {};
25
+ const captureInput: ComponentType<InputProps> = (props) => {
26
+ captured[props.name] = props;
27
+ return null;
28
+ };
29
+ const noop = (): ReactNode => null;
30
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
31
+
32
+ const testPrimitives: CorePrimitives = {
33
+ Button: noop,
34
+ Banner: passChildren,
35
+ Field: passChildren,
36
+ Input: captureInput,
37
+ DataTable: noop,
38
+ Form: passChildren,
39
+ Section: passChildren,
40
+ Card: passChildren,
41
+ Grid: passChildren,
42
+ GridCell: passChildren,
43
+ Text: passChildren,
44
+ Heading: noop,
45
+ Dialog: noop,
46
+ Modal: noop,
47
+ Lightbox: noop,
48
+ ConfigSourceBadge: noop,
49
+ ConfigCascadeView: noop,
50
+ Link: noop,
51
+ };
52
+
53
+ function stubDispatcher(): Dispatcher {
54
+ return {
55
+ write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
56
+ query: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["query"],
57
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
58
+ statusStore: {
59
+ getState: () => "online",
60
+ subscribe: () => () => {},
61
+ } as unknown as Dispatcher["statusStore"],
62
+ async *stream() {},
63
+ pendingWrites: () => [],
64
+ pendingFiles: () => [],
65
+ };
66
+ }
67
+
68
+ function buildSchema(): FeatureSchema {
69
+ const entity: EntityDefinition = {
70
+ fields: {
71
+ name: { type: "text", maxLength: 200, required: false, searchable: false, sortable: false },
72
+ floorCount: { type: "number", required: false, sortable: false },
73
+ },
74
+ };
75
+ const screen: EntityEditScreenDefinition = {
76
+ id: "unit-edit",
77
+ type: "entityEdit",
78
+ entity: "unit",
79
+ layout: { sections: [{ columns: 1, fields: ["name", "floorCount"] }] },
80
+ };
81
+ return {
82
+ featureName: "housing",
83
+ entities: { unit: entity },
84
+ screens: [screen],
85
+ } as FeatureSchema;
86
+ }
87
+
88
+ describe("EntityEditCreateBody — navigate-params als initial values (#1680)", () => {
89
+ test("URL-searchParams aus rowAction navigate füllen das Create-Form vor", () => {
90
+ captured["name"] = undefined as unknown as InputProps;
91
+ captured["floorCount"] = undefined as unknown as InputProps;
92
+ render(
93
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "de-DE" })}>
94
+ <DispatcherProvider dispatcher={stubDispatcher()}>
95
+ <NavProvider
96
+ value={{
97
+ route: { screenId: "housing:unit-edit" },
98
+ navigate: () => {},
99
+ replace: () => {},
100
+ hrefFor: () => "",
101
+ searchParams: { name: "Erdgeschoss", floorCount: "3" },
102
+ setSearchParams: () => {},
103
+ }}
104
+ >
105
+ <PrimitivesProvider value={testPrimitives}>
106
+ <KumikoScreen schema={buildSchema()} qn="housing:screen:unit-edit" />
107
+ </PrimitivesProvider>
108
+ </NavProvider>
109
+ </DispatcherProvider>
110
+ </LocaleProvider>,
111
+ );
112
+
113
+ expect(captured["name"]?.value).toBe("Erdgeschoss");
114
+ expect(captured["floorCount"]?.value).toBe(3);
115
+ });
116
+
117
+ test("ohne matching searchParam bleibt der Field-Default (leer)", () => {
118
+ captured["name"] = undefined as unknown as InputProps;
119
+ render(
120
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "de-DE" })}>
121
+ <DispatcherProvider dispatcher={stubDispatcher()}>
122
+ <NavProvider
123
+ value={{
124
+ route: { screenId: "housing:unit-edit" },
125
+ navigate: () => {},
126
+ replace: () => {},
127
+ hrefFor: () => "",
128
+ searchParams: {},
129
+ setSearchParams: () => {},
130
+ }}
131
+ >
132
+ <PrimitivesProvider value={testPrimitives}>
133
+ <KumikoScreen schema={buildSchema()} qn="housing:screen:unit-edit" />
134
+ </PrimitivesProvider>
135
+ </NavProvider>
136
+ </DispatcherProvider>
137
+ </LocaleProvider>,
138
+ );
139
+
140
+ expect(captured["name"]?.value).toBe("");
141
+ });
142
+ });
@@ -0,0 +1,25 @@
1
+ // Cross-feature schema access for components that need to resolve a
2
+ // screen owned by a DIFFERENT feature than the one they're rendering
3
+ // under — e.g. a reference field opening the target entity's create
4
+ // screen (kumiko-framework#1681). `createKumikoApp` provides the full
5
+ // `app.features` list; components elsewhere in the render tree default
6
+ // to an empty list (no cross-feature schema known, e.g. outside
7
+ // createKumikoApp or in isolated tests).
8
+
9
+ import { createContext, type ReactNode, useContext } from "react";
10
+ import type { FeatureSchema } from "./feature-schema";
11
+
12
+ const AppFeaturesContext = createContext<readonly FeatureSchema[]>([]);
13
+
14
+ export type AppFeaturesProviderProps = {
15
+ readonly features: readonly FeatureSchema[];
16
+ readonly children: ReactNode;
17
+ };
18
+
19
+ export function AppFeaturesProvider({ features, children }: AppFeaturesProviderProps): ReactNode {
20
+ return <AppFeaturesContext.Provider value={features}>{children}</AppFeaturesContext.Provider>;
21
+ }
22
+
23
+ export function useAppFeatures(): readonly FeatureSchema[] {
24
+ return useContext(AppFeaturesContext);
25
+ }
@@ -320,7 +320,7 @@ function useNavigateToCreateFor(
320
320
  // with a `default: true`/`default: 5` would show the form in a state
321
321
  // the entity didn't ask for — subtle and easy to miss until a user
322
322
  // submits and is surprised.
323
- function buildInitialValues(
323
+ export function buildInitialValues(
324
324
  fields: Readonly<Record<string, unknown>>,
325
325
  ): Readonly<Record<string, unknown>> {
326
326
  const out: Record<string, unknown> = {};
@@ -336,6 +336,29 @@ function buildInitialValues(
336
336
  return out;
337
337
  }
338
338
 
339
+ export function mergeSearchParamsIntoInitial(
340
+ fields: Readonly<Record<string, unknown>>,
341
+ searchParams: Readonly<Record<string, string>>,
342
+ ): Record<string, unknown> {
343
+ const defaults = buildInitialValues(fields) as Record<string, unknown>;
344
+ const merged: Record<string, unknown> = { ...defaults };
345
+ for (const [name, fieldDef] of Object.entries(fields)) {
346
+ const shape = fieldDef as { type?: string; sensitive?: boolean };
347
+ if (shape.sensitive === true) continue;
348
+ const raw = searchParams[name];
349
+ if (raw === undefined) continue;
350
+ if (shape.type === "number" || shape.type === "money") {
351
+ const parsed = Number(raw);
352
+ merged[name] = Number.isNaN(parsed) ? defaults[name] : parsed;
353
+ } else if (shape.type === "boolean") {
354
+ merged[name] = raw === "true";
355
+ } else {
356
+ merged[name] = raw;
357
+ }
358
+ }
359
+ return merged;
360
+ }
361
+
339
362
  function EntityEditScreen({
340
363
  schema,
341
364
  screen,
@@ -406,7 +429,11 @@ function EntityEditCreateBody({
406
429
  readonly entity: EntityDefinition;
407
430
  readonly translate?: Translate;
408
431
  }): ReactNode {
409
- const initial = useMemo(() => buildInitialValues(entity.fields) as FormValues, [entity.fields]);
432
+ const nav = useNav();
433
+ const initial = useMemo(
434
+ () => mergeSearchParamsIntoInitial(entity.fields, nav.searchParams) as FormValues,
435
+ [entity.fields, nav.searchParams],
436
+ );
410
437
  const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
411
438
  const navigateToList = useNavigateToListAfter(schema, screen.entity);
412
439
  const handleSubmitted = useCallback(
@@ -1402,32 +1429,10 @@ function ActionFormBody({
1402
1429
  const nav = useNav();
1403
1430
  const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
1404
1431
  const synthScreen = useMemo(() => synthesizeActionFormScreen(screen), [screen]);
1405
- // Tier 2.7e-2: URL-Search-Params überschreiben Field-Defaults bei
1406
- // initial values. Use-case: rowAction kind=navigate setzt
1407
- // `?customerId=row-uuid` und der actionForm liest das pre-filled.
1408
- // String-Coercion auf Field-Type: URL kennt nur Strings, aber
1409
- // ein Field mit type:"number" erwartet eine Zahl. Boolean-Strings
1410
- // ("true"/"false") und Number-Strings werden hier coerced; sonst
1411
- // bleibt der String — der Field-Validator beim Submit fängt einen
1412
- // Type-Mismatch ab.
1413
- const initial = useMemo(() => {
1414
- const defaults = buildInitialValues(screen.fields) as Record<string, unknown>; // @cast-boundary render-helper
1415
- const merged: Record<string, unknown> = { ...defaults };
1416
- for (const [name, fieldDef] of Object.entries(screen.fields)) {
1417
- const raw = nav.searchParams[name];
1418
- if (raw === undefined) continue;
1419
- const ftype = (fieldDef as { type?: string }).type;
1420
- if (ftype === "number" || ftype === "money") {
1421
- const parsed = Number(raw);
1422
- merged[name] = Number.isNaN(parsed) ? defaults[name] : parsed;
1423
- } else if (ftype === "boolean") {
1424
- merged[name] = raw === "true";
1425
- } else {
1426
- merged[name] = raw;
1427
- }
1428
- }
1429
- return merged as FormValues;
1430
- }, [screen.fields, nav.searchParams]);
1432
+ const initial = useMemo(
1433
+ () => mergeSearchParamsIntoInitial(screen.fields, nav.searchParams) as FormValues,
1434
+ [screen.fields, nav.searchParams],
1435
+ );
1431
1436
  const handleSubmitted = useCallback(
1432
1437
  (result: SubmitResult<unknown>) => {
1433
1438
  // Redirect ist optional. Bei isSuccess + redirect → nav.navigate.
@@ -62,7 +62,7 @@ export function synthesizeProjectionDetailScreen(
62
62
  id: screen.id,
63
63
  type: "entityEdit",
64
64
  entity: PROJECTION_DETAIL_PSEUDO_ENTITY,
65
- layout: { sections },
65
+ layout: { sections, ...(screen.layout.width !== undefined && { width: screen.layout.width }) },
66
66
  allowCreate: false,
67
67
  allowDelete: false,
68
68
  ...(screen.fieldLabels !== undefined && { fieldLabels: screen.fieldLabels }),
@@ -122,4 +122,58 @@ describe("hasEditableSection", () => {
122
122
  };
123
123
  expect(hasEditableSection([hiddenSectionWithEditableField])).toBe(false);
124
124
  });
125
+
126
+ test("visible section with only a field-hidden editable field → false", () => {
127
+ const section: EditSectionViewModel = {
128
+ kind: "fields",
129
+ columns: 1,
130
+ visible: true,
131
+ fields: [
132
+ {
133
+ field: "f",
134
+ label: "F",
135
+ type: "text",
136
+ value: "",
137
+ visible: false,
138
+ readOnly: false,
139
+ required: false,
140
+ },
141
+ ],
142
+ };
143
+ expect(hasEditableSection([section])).toBe(false);
144
+ });
145
+
146
+ // The #1689 regression: the section is visible because field A is visible
147
+ // (readOnly), but the only editable field B is hidden by its own
148
+ // FieldCondition. Old code checked section.visible + !readOnly only, so it
149
+ // saw B's !readOnly and returned true — Save appeared over zero editable
150
+ // visible fields.
151
+ test("visible section, visible readOnly field + hidden editable field → false", () => {
152
+ const section: EditSectionViewModel = {
153
+ kind: "fields",
154
+ columns: 1,
155
+ visible: true,
156
+ fields: [
157
+ {
158
+ field: "a",
159
+ label: "A",
160
+ type: "text",
161
+ value: "",
162
+ visible: true,
163
+ readOnly: true,
164
+ required: false,
165
+ },
166
+ {
167
+ field: "b",
168
+ label: "B",
169
+ type: "text",
170
+ value: "",
171
+ visible: false,
172
+ readOnly: false,
173
+ required: false,
174
+ },
175
+ ],
176
+ };
177
+ expect(hasEditableSection([section])).toBe(false);
178
+ });
125
179
  });
@@ -37,6 +37,7 @@ const testPrimitives: CorePrimitives = {
37
37
  Text: noop,
38
38
  Heading: noop,
39
39
  Dialog: noop,
40
+ Modal: noop,
40
41
  Lightbox: noop,
41
42
  ConfigSourceBadge: noop,
42
43
  ConfigCascadeView: noop,
@@ -0,0 +1,80 @@
1
+ import type {
2
+ EntityDefinition,
3
+ EntityEditScreenDefinition,
4
+ } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import type { FormValues, SubmitResult, Translate } from "@cosmicdrift/kumiko-headless";
6
+ import { type ReactNode, useMemo } from "react";
7
+ import { buildInitialValues } from "../app/kumiko-screen";
8
+ import { toKebab } from "../app/qn";
9
+ import { useTranslation } from "../i18n";
10
+ import { usePrimitives } from "../primitives";
11
+ import { RenderEdit } from "./render-edit";
12
+
13
+ // Hosts a target entity's create-form inside a bare Modal so a reference
14
+ // field can create a missing record without leaving the current form
15
+ // (kumiko-framework#1681) — same create wiring as EntityEditCreateBody
16
+ // (kumiko-screen.tsx), but reports the new id back via a callback instead
17
+ // of navigating to the entity's list screen.
18
+
19
+ function entityWriteCommand(featureName: string, entity: string): string {
20
+ return `${toKebab(featureName)}:write:${toKebab(entity)}:create`;
21
+ }
22
+
23
+ // The create handler's success payload is `{ kind: "save", id, ... }`
24
+ // (see event-store-executor-write.ts) but RenderEdit's onSubmit only
25
+ // types it as `unknown` — narrow defensively instead of casting through it.
26
+ function extractCreatedId(data: unknown): string | undefined {
27
+ if (typeof data !== "object" || data === null) return undefined;
28
+ const id = (data as Record<string, unknown>)["id"];
29
+ return typeof id === "string" ? id : undefined;
30
+ }
31
+
32
+ export type ReferenceCreateDialogProps = {
33
+ readonly open: boolean;
34
+ readonly onClose: () => void;
35
+ readonly onCreated: (id: string) => void;
36
+ readonly featureName: string;
37
+ readonly screen: EntityEditScreenDefinition;
38
+ readonly entity: EntityDefinition;
39
+ readonly translate?: Translate;
40
+ };
41
+
42
+ export function ReferenceCreateDialog({
43
+ open,
44
+ onClose,
45
+ onCreated,
46
+ featureName,
47
+ screen,
48
+ entity,
49
+ translate,
50
+ }: ReferenceCreateDialogProps): ReactNode {
51
+ const { Modal } = usePrimitives();
52
+ const t = useTranslation();
53
+ const initial = useMemo(() => buildInitialValues(entity.fields) as FormValues, [entity.fields]);
54
+ const writeCommand = entityWriteCommand(featureName, screen.entity);
55
+ const handleSubmitted = (result: SubmitResult<unknown>): void => {
56
+ if (result.validationBlocked || !result.isSuccess) return;
57
+ const id = extractCreatedId(result.data);
58
+ if (id !== undefined) onCreated(id);
59
+ };
60
+ if (!open) return null;
61
+ return (
62
+ <Modal
63
+ open={open}
64
+ onOpenChange={(next) => !next && onClose()}
65
+ title={t("kumiko.actions.create")}
66
+ >
67
+ <RenderEdit
68
+ screen={screen}
69
+ entity={entity}
70
+ featureName={featureName}
71
+ initial={initial}
72
+ writeCommand={writeCommand}
73
+ onSubmit={handleSubmitted}
74
+ onCancel={onClose}
75
+ {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
76
+ {...(translate !== undefined && { translate })}
77
+ />
78
+ </Modal>
79
+ );
80
+ }
@@ -9,7 +9,7 @@ export function hasEditableSection(sections: readonly EditSectionViewModel[]): b
9
9
  return sections.some(
10
10
  (s) =>
11
11
  s.kind === "extension" ||
12
- (s.kind === "fields" && s.visible && s.fields.some((f) => !f.readOnly)),
12
+ (s.kind === "fields" && s.visible && s.fields.some((f) => !f.readOnly && f.visible)),
13
13
  );
14
14
  }
15
15
 
@@ -422,6 +422,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
422
422
  {...(formSubtitle !== undefined && { subtitle: formSubtitle })}
423
423
  actions={formActions}
424
424
  testId="render-edit-form"
425
+ {...(screen.layout.width !== undefined && { width: screen.layout.width })}
425
426
  >
426
427
  {vm.sections.map((section: EditSectionViewModel, sectionIndex: number) => {
427
428
  if (section.kind === "extension") {
@@ -446,6 +447,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
446
447
  <Section
447
448
  key={sectionKey}
448
449
  {...(sectionTitle !== undefined && { title: sectionTitle })}
450
+ {...(section.description !== undefined && { subtitle: section.description })}
449
451
  testId={`section-${sectionKey}`}
450
452
  >
451
453
  <Grid columns={section.columns}>
@@ -1,10 +1,17 @@
1
+ import type {
2
+ AccessRule,
3
+ EntityEditScreenDefinition,
4
+ } from "@cosmicdrift/kumiko-framework/ui-types";
1
5
  import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
2
6
  import { type ReactNode, useCallback, useMemo, useState } from "react";
7
+ import { useAppFeatures } from "../app/app-features-context";
3
8
  import { toKebab } from "../app/qn";
9
+ import { useUserRoles } from "../context/user-roles-context";
4
10
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits";
5
11
  import { useQuery } from "../hooks/use-query";
6
- import { useLocale } from "../i18n";
12
+ import { useLocale, useTranslation } from "../i18n";
7
13
  import { usePrimitives } from "../primitives";
14
+ import { ReferenceCreateDialog } from "./reference-create-dialog";
8
15
 
9
16
  // RenderField übersetzt ein EditFieldViewModel → Primitives-Baum.
10
17
  // Kein raw HTML mehr; alle Darstellungsentscheidungen (Label-Position,
@@ -82,6 +89,19 @@ export function RenderField({
82
89
  );
83
90
  }
84
91
 
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
+
85
105
  // Tier 2.7e-3 + 2.1c: Reference-Input rendert eine Searchable Combobox
86
106
  // gefüllt aus einer Live-Query auf die referenced Entity. Default-
87
107
  // Limit: 200 — bei größeren Datasets fehlt der Tail im Dropdown
@@ -120,6 +140,26 @@ function ReferenceInput({
120
140
  // (z.B. items.assignee → users:query:user:list). Default ist
121
141
  // same-feature, kommt aus dem ViewModel (parseRefTarget).
122
142
  const queryQn = `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
143
+ // Issue #1681: "+ Neu" in der Combobox öffnet den Create-Screen der
144
+ // referenced entity als Dialog, statt die aktuelle Form zu verlassen.
145
+ // refFeature kann ein anderes Feature als das aktuell gerenderte sein
146
+ // — appFeatures (createKumikoApp) kennt alle Feature-Schemas, nicht
147
+ // nur das der aktiven Screen. Kein Match (Feature/Screen/Entity nicht
148
+ // registriert, oder allowCreate:false) → onCreate bleibt undefined,
149
+ // Combobox rendert dann ohne den Footer.
150
+ const appFeatures = useAppFeatures();
151
+ const userRoles = useUserRoles();
152
+ const t = useTranslation();
153
+ const [createOpen, setCreateOpen] = useState(false);
154
+ const refTargetSchema = appFeatures.find((f) => f.featureName === refFeature);
155
+ const refCreateScreen = refTargetSchema?.screens.find(
156
+ (s): s is EntityEditScreenDefinition =>
157
+ s.type === "entityEdit" &&
158
+ s.entity === refEntity &&
159
+ s.allowCreate !== false &&
160
+ createScreenAccessAllows(s.access, userRoles),
161
+ );
162
+ const refEntityDef = refTargetSchema?.entities[refEntity];
123
163
  // Tier 2.7e Remote-Search: User tippt im Combobox → Server filtert
124
164
  // via existing list-payload `search`-Param (Tier 2.6c). Combobox
125
165
  // debounced den keystroke selbst (300ms) und ruft onSearchChange.
@@ -137,6 +177,20 @@ function ReferenceInput({
137
177
  queryPayload,
138
178
  );
139
179
  const handleSearchChange = useCallback((q: string) => setSearchTerm(q), []);
180
+ const canCreate = !field.readOnly && refCreateScreen !== undefined && refEntityDef !== undefined;
181
+ const handleCreated = useCallback(
182
+ (newId: string) => {
183
+ setCreateOpen(false);
184
+ void queryResult.refetch();
185
+ if (isMultiple) {
186
+ const current = Array.isArray(field.value) ? (field.value as readonly string[]) : [];
187
+ onChange([...current, newId]);
188
+ } else {
189
+ onChange(newId);
190
+ }
191
+ },
192
+ [isMultiple, field.value, onChange, queryResult.refetch],
193
+ );
140
194
  const options = useMemo(() => {
141
195
  const rows = queryResult.data?.rows ?? [];
142
196
  return rows.map((row) => {
@@ -158,29 +212,49 @@ function ReferenceInput({
158
212
  options,
159
213
  onSearchChange: handleSearchChange,
160
214
  loading: queryResult.loading,
215
+ ...(canCreate && {
216
+ onCreate: () => setCreateOpen(true),
217
+ createLabel: t("kumiko.actions.create"),
218
+ }),
161
219
  } 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
+ />
229
+ );
162
230
  if (isMultiple) {
163
231
  const arrayValue: readonly string[] = Array.isArray(field.value)
164
232
  ? (field.value as readonly string[])
165
233
  : [];
166
234
  return (
167
- <Input
168
- kind="combobox"
169
- {...baseInputProps}
170
- multiple
171
- value={arrayValue}
172
- onChange={(v) => onChange(v)}
173
- />
235
+ <>
236
+ <Input
237
+ kind="combobox"
238
+ {...baseInputProps}
239
+ multiple
240
+ value={arrayValue}
241
+ onChange={(v) => onChange(v)}
242
+ />
243
+ {createDialog}
244
+ </>
174
245
  );
175
246
  }
176
247
  const stringValue = field.value === undefined || field.value === null ? "" : String(field.value);
177
248
  return (
178
- <Input
179
- kind="combobox"
180
- {...baseInputProps}
181
- value={stringValue}
182
- onChange={(v) => onChange(v === "" ? null : v)}
183
- />
249
+ <>
250
+ <Input
251
+ kind="combobox"
252
+ {...baseInputProps}
253
+ value={stringValue}
254
+ onChange={(v) => onChange(v === "" ? null : v)}
255
+ />
256
+ {createDialog}
257
+ </>
184
258
  );
185
259
  }
186
260
 
@@ -223,6 +297,7 @@ function renderInput({
223
297
  {...common}
224
298
  value={numberValue(field.value)}
225
299
  onChange={(v) => onChange(v)}
300
+ {...(field.icon !== undefined && { icon: field.icon })}
226
301
  />
227
302
  );
228
303
  case "money": {
@@ -344,6 +419,7 @@ function renderInput({
344
419
  {...common}
345
420
  value={stringValue(field.value)}
346
421
  onChange={(v) => onChange(v)}
422
+ {...(field.icon !== undefined && { icon: field.icon })}
347
423
  />
348
424
  );
349
425
  }
@@ -40,6 +40,7 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
40
40
  "kumiko.list.empty.title": "Noch keine Einträge.",
41
41
  "kumiko.list.empty.hint": "Lege den ersten an, um loszulegen.",
42
42
  "kumiko.list.no-entries": "Keine Einträge.",
43
+ "kumiko.list.end-of-list": "— Ende der Liste —",
43
44
 
44
45
  // Combobox — Tier 2.1c Searchable-Select.
45
46
  "kumiko.combobox.search-placeholder": "Suchen…",
@@ -176,6 +177,7 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
176
177
  "kumiko.list.empty.title": "No entries yet.",
177
178
  "kumiko.list.empty.hint": "Create the first one to get started.",
178
179
  "kumiko.list.no-entries": "No entries.",
180
+ "kumiko.list.end-of-list": "— End of list —",
179
181
 
180
182
  "kumiko.combobox.search-placeholder": "Search…",
181
183
  "kumiko.combobox.empty": "No matches.",
package/src/index.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  // eigenen Bootstrap schreiben will. Normale Samples gehen über
10
10
  // @cosmicdrift/kumiko-renderer-web/createKumikoApp, das alle Provider verdrahtet.
11
11
 
12
+ export type { AppFeaturesProviderProps } from "./app/app-features-context";
13
+ export { AppFeaturesProvider, useAppFeatures } from "./app/app-features-context";
12
14
  export type {
13
15
  ColumnRendererComponent,
14
16
  ColumnRendererProps,
@@ -132,12 +134,14 @@ export type {
132
134
  DialogProps,
133
135
  FieldProps,
134
136
  FormProps,
137
+ FormWidth,
135
138
  GridCellProps,
136
139
  GridProps,
137
140
  HeadingProps,
138
141
  InputProps,
139
142
  LightboxProps,
140
143
  LinkProps,
144
+ ModalProps,
141
145
  PrimitivesProviderProps,
142
146
  PrimitivesRegistry,
143
147
  RuntimeRenderer,
@@ -34,6 +34,7 @@ import type {
34
34
  ConfigScope,
35
35
  ConfigValueSource,
36
36
  } from "@cosmicdrift/kumiko-framework/engine";
37
+ import type { FormWidth } from "@cosmicdrift/kumiko-framework/ui-types";
37
38
  import type {
38
39
  FieldIssue,
39
40
  ListColumnViewModel,
@@ -158,6 +159,9 @@ export type InputProps =
158
159
  /** Read-only Input (z.B. gewürfelter Free-Tier-Slug). Nicht `disabled`
159
160
  * — bleibt fokussier-/kopierbar. */
160
161
  readonly readOnly?: boolean;
162
+ /** Symbolic icon key (FIELD_ICONS registry, renderer-web) — renders
163
+ * as a prefix on the input. Unknown key → no icon (no boot-fail). */
164
+ readonly icon?: string;
161
165
  }
162
166
  | {
163
167
  readonly kind: "email";
@@ -199,6 +203,9 @@ export type InputProps =
199
203
  readonly required?: boolean;
200
204
  readonly hasError?: boolean;
201
205
  readonly testId?: string;
206
+ /** Symbolic icon key (FIELD_ICONS registry, renderer-web) — renders
207
+ * as a prefix on the input. Unknown key → no icon (no boot-fail). */
208
+ readonly icon?: string;
202
209
  }
203
210
  | {
204
211
  readonly kind: "range";
@@ -302,6 +309,11 @@ export type InputProps =
302
309
  * debounced an den Caller). */
303
310
  readonly onSearchChange?: (q: string) => void;
304
311
  readonly loading?: boolean;
312
+ /** Fixed "+ createLabel" footer row that fires instead of
313
+ * selecting an option — omit for the plain select-existing
314
+ * combobox (kumiko-framework#1681). */
315
+ readonly onCreate?: () => void;
316
+ readonly createLabel?: string;
305
317
  } & (
306
318
  | {
307
319
  readonly multiple?: false;
@@ -521,6 +533,8 @@ export type DataTableProps = {
521
533
  readonly testId?: string;
522
534
  };
523
535
 
536
+ export type { FormWidth };
537
+
524
538
  /** Submit-Wrapper. Web: `<form onSubmit>`, Native: View das einen
525
539
  * onSubmit-Callback via Button-Press triggert. `onSubmit` bekommt
526
540
  * eine abstrakte Signatur (keine FormEvent) damit Native-Impls das
@@ -542,6 +556,10 @@ export type FormProps = {
542
556
  readonly subtitle?: ReactNode;
543
557
  readonly actions?: ReactNode;
544
558
  readonly testId?: string;
559
+ /** Max width of the form container. Default "3xl" — see FormWidth
560
+ * (`packages/types/src/screen.ts`, EditLayout.width, #1676). Native
561
+ * impls may ignore this prop (no width constraint there). */
562
+ readonly width?: FormWidth;
545
563
  };
546
564
 
547
565
  /** Titled Gruppe von Feldern. Web: `<fieldset>` + `<legend>`, Native:
@@ -635,6 +653,21 @@ export type DialogProps = {
635
653
  readonly testId?: string;
636
654
  };
637
655
 
656
+ /** Bare content shell for hosting an existing self-contained form/widget
657
+ * (own submit/cancel buttons) inside a modal overlay — unlike `Dialog`,
658
+ * it renders no footer buttons of its own. Web renders the same
659
+ * focus-trapped Radix overlay as `Dialog`, just without the button row. */
660
+ export type ModalProps = {
661
+ readonly open: boolean;
662
+ readonly onOpenChange: (open: boolean) => void;
663
+ /** Screen-reader-only accessible title (required by the underlying
664
+ * dialog primitive) — not shown visually since the hosted content
665
+ * is expected to render its own heading. */
666
+ readonly title: string;
667
+ readonly children: ReactNode;
668
+ readonly testId?: string;
669
+ };
670
+
638
671
  /** Image lightbox — full-size preview on click. Web renders Radix overlay;
639
672
  * trigger (thumbnail) and open state live in the app. */
640
673
  export type LightboxProps = {
@@ -725,6 +758,7 @@ export type CorePrimitives = {
725
758
  readonly Text: ComponentType<TextProps>;
726
759
  readonly Heading: ComponentType<HeadingProps>;
727
760
  readonly Dialog: ComponentType<DialogProps>;
761
+ readonly Modal: ComponentType<ModalProps>;
728
762
  readonly Lightbox: ComponentType<LightboxProps>;
729
763
  readonly ConfigSourceBadge: ComponentType<ConfigSourceBadgeProps>;
730
764
  readonly ConfigCascadeView: ComponentType<ConfigCascadeViewProps>;