@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 +3 -3
- package/src/__tests__/merge-search-params-into-initial.test.ts +48 -0
- package/src/app/__tests__/entity-edit-create-search-params.test.tsx +142 -0
- package/src/app/app-features-context.tsx +25 -0
- package/src/app/kumiko-screen.tsx +33 -28
- package/src/app/projection-detail-shim.ts +1 -1
- package/src/components/__tests__/render-edit-logic.test.ts +54 -0
- package/src/components/__tests__/render-field-app-locale.test.tsx +1 -0
- package/src/components/reference-create-dialog.tsx +80 -0
- package/src/components/render-edit-logic.ts +1 -1
- package/src/components/render-edit.tsx +2 -0
- package/src/components/render-field.tsx +90 -14
- package/src/i18n-defaults.ts +2 -0
- package/src/index.ts +4 -0
- package/src/primitives.tsx +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "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
|
|
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
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
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
|
});
|
|
@@ -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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
}
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -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,
|
package/src/primitives.tsx
CHANGED
|
@@ -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>;
|