@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 +3 -3
- package/src/__tests__/merge-search-params-into-initial.test.ts +20 -0
- package/src/app/kumiko-screen.tsx +38 -21
- package/src/app/screen-access.ts +19 -0
- package/src/components/reference-create-dialog.tsx +6 -3
- package/src/components/render-field.tsx +29 -28
- package/src/i18n-defaults.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "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 {
|
|
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
|
-
|
|
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
|
-
() =>
|
|
435
|
-
|
|
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
|
-
() =>
|
|
1434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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)
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -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.",
|