@cosmicdrift/kumiko-renderer 0.239.0 → 0.241.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 +4 -4
- package/src/app/__tests__/projection-detail-shim.test.ts +43 -0
- package/src/app/__tests__/projection-detail-singleton.test.tsx +191 -0
- package/src/app/kumiko-screen.tsx +66 -185
- package/src/app/row-actions.ts +201 -0
- package/src/components/__tests__/related-list-section.test.tsx +163 -0
- package/src/components/__tests__/render-edit-logic.test.ts +14 -0
- package/src/components/__tests__/render-field-json-format.test.tsx +164 -0
- package/src/components/__tests__/write-form-section.test.tsx +225 -0
- package/src/components/grid-cell-for-field.tsx +60 -0
- package/src/components/related-list-section.tsx +36 -1
- package/src/components/render-edit-logic.ts +9 -4
- package/src/components/render-edit.tsx +24 -65
- package/src/components/render-field.tsx +35 -4
- package/src/components/write-form-section.tsx +165 -0
- package/src/index.ts +1 -0
- package/src/primitives.tsx +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.241.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.241.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.241.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@types/react-dom": "^19.2.3",
|
|
28
28
|
"jsdom": "^29.1.1",
|
|
29
29
|
"react-dom": "^19.2.6",
|
|
30
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
30
|
+
"@cosmicdrift/kumiko-locale-de": "0.241.0"
|
|
31
31
|
},
|
|
32
32
|
"repository": {
|
|
33
33
|
"type": "git",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import { synthesizeProjectionDetailScreen } from "../projection-detail-shim";
|
|
4
|
+
|
|
5
|
+
// Regression for the writeForm extension (fw editable-detail-screens): the
|
|
6
|
+
// hard readOnly:true enforcement in synthesizeProjectionDetailScreen must
|
|
7
|
+
// still apply to every ordinary fields-section, unchanged — a writeForm
|
|
8
|
+
// section is the one deliberate exception (isFieldsEditSection excludes it,
|
|
9
|
+
// same as relatedList/extension), it must pass through editable.
|
|
10
|
+
describe("synthesizeProjectionDetailScreen", () => {
|
|
11
|
+
test("forces readOnly:true on a fields section, but leaves a writeForm section's own readOnly untouched", () => {
|
|
12
|
+
const screen: ProjectionDetailScreenDefinition = {
|
|
13
|
+
id: "order-detail",
|
|
14
|
+
type: "projectionDetail",
|
|
15
|
+
query: "orders:query:order:detail",
|
|
16
|
+
layout: {
|
|
17
|
+
sections: [
|
|
18
|
+
{ title: "Basics", fields: [{ field: "name", readOnly: false }] },
|
|
19
|
+
{
|
|
20
|
+
kind: "writeForm",
|
|
21
|
+
title: "Add note",
|
|
22
|
+
fieldDefs: { note: { type: "text" } },
|
|
23
|
+
fields: [{ field: "note", readOnly: false }],
|
|
24
|
+
handler: "orders:write:add-note",
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const result = synthesizeProjectionDetailScreen(screen);
|
|
31
|
+
|
|
32
|
+
const [fieldsSection, writeFormSection] = result.layout.sections;
|
|
33
|
+
if (fieldsSection === undefined || !("fields" in fieldsSection) || "kind" in fieldsSection) {
|
|
34
|
+
throw new Error("expected the first section to stay a plain fields section");
|
|
35
|
+
}
|
|
36
|
+
expect(fieldsSection.fields[0]).toMatchObject({ field: "name", readOnly: true });
|
|
37
|
+
|
|
38
|
+
if (writeFormSection === undefined || writeFormSection.kind !== "writeForm") {
|
|
39
|
+
throw new Error("expected the second section to stay kind: writeForm, untouched");
|
|
40
|
+
}
|
|
41
|
+
expect(writeFormSection.fields[0]).toEqual({ field: "note", readOnly: false });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// fw#2312: `singleton: true` marks a projectionDetail screen whose query
|
|
2
|
+
// determines the shown row from the caller's session/context instead of a
|
|
3
|
+
// row id in the path (a self-service "my profile"/"my data" screen has no
|
|
4
|
+
// row to link to). Before this flag, ProjectionDetailBody always rendered
|
|
5
|
+
// the "needs a row id in the path" error banner when `entityId` was
|
|
6
|
+
// undefined (kumiko-screen.tsx) — both user-profile and user-data-rights'
|
|
7
|
+
// privacy-center screen hit exactly that, unrenderable, until the flag
|
|
8
|
+
// existed (fw#2312 bug report). This proves the fix, the no-flag regression
|
|
9
|
+
// case stays covered, and that a stray/spoofed path id never reaches the
|
|
10
|
+
// query under singleton (renderer-side half of the security requirement —
|
|
11
|
+
// the boot-validator half lives in framework's projection-detail-
|
|
12
|
+
// singleton.test.ts).
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from "bun:test";
|
|
15
|
+
import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
16
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
17
|
+
import { render, waitFor } from "@testing-library/react";
|
|
18
|
+
import type { ComponentType, ReactNode } from "react";
|
|
19
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
20
|
+
import { UserRolesProvider } from "../../context/user-roles-context";
|
|
21
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
22
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
23
|
+
import {
|
|
24
|
+
type BannerProps,
|
|
25
|
+
type CorePrimitives,
|
|
26
|
+
type FormProps,
|
|
27
|
+
PrimitivesProvider,
|
|
28
|
+
} from "../../primitives";
|
|
29
|
+
import { AppFeaturesProvider } from "../app-features-context";
|
|
30
|
+
import type { FeatureSchema } from "../feature-schema";
|
|
31
|
+
import { KumikoScreen } from "../kumiko-screen";
|
|
32
|
+
import type { NavApi } from "../nav";
|
|
33
|
+
import { NavProvider } from "../nav";
|
|
34
|
+
|
|
35
|
+
const noop = (): ReactNode => null;
|
|
36
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
37
|
+
|
|
38
|
+
const FormWithActions: ComponentType<FormProps> = ({ children }) => (
|
|
39
|
+
<div data-testid="form-body">{children}</div>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const TestBanner: ComponentType<BannerProps> = ({ children, testId }) => (
|
|
43
|
+
<div data-testid={testId}>{children}</div>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const testPrimitives: CorePrimitives = {
|
|
47
|
+
Button: noop,
|
|
48
|
+
Banner: TestBanner,
|
|
49
|
+
Field: passChildren,
|
|
50
|
+
Input: noop,
|
|
51
|
+
DataTable: noop,
|
|
52
|
+
Form: FormWithActions,
|
|
53
|
+
Section: passChildren,
|
|
54
|
+
Card: passChildren,
|
|
55
|
+
Grid: passChildren,
|
|
56
|
+
GridCell: passChildren,
|
|
57
|
+
Text: passChildren,
|
|
58
|
+
Heading: noop,
|
|
59
|
+
Dialog: noop,
|
|
60
|
+
Modal: noop,
|
|
61
|
+
Lightbox: noop,
|
|
62
|
+
ConfigSourceBadge: noop,
|
|
63
|
+
ConfigCascadeView: noop,
|
|
64
|
+
Link: noop,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function stubDispatcher(
|
|
68
|
+
record: Readonly<Record<string, unknown>> | null,
|
|
69
|
+
queries: Array<{ type: string; payload: unknown }>,
|
|
70
|
+
): Dispatcher {
|
|
71
|
+
return {
|
|
72
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
73
|
+
query: (async (type: string, payload: unknown) => {
|
|
74
|
+
queries.push({ type, payload });
|
|
75
|
+
return { isSuccess: true, data: record };
|
|
76
|
+
}) as unknown as Dispatcher["query"],
|
|
77
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
78
|
+
statusStore: {
|
|
79
|
+
getState: () => "online",
|
|
80
|
+
subscribe: () => () => {},
|
|
81
|
+
} as unknown as Dispatcher["statusStore"],
|
|
82
|
+
async *stream() {},
|
|
83
|
+
pendingWrites: () => [],
|
|
84
|
+
pendingFiles: () => [],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function meScreen(
|
|
89
|
+
overrides?: Partial<ProjectionDetailScreenDefinition>,
|
|
90
|
+
): FeatureSchema["screens"][number] {
|
|
91
|
+
return {
|
|
92
|
+
id: "profile",
|
|
93
|
+
type: "projectionDetail",
|
|
94
|
+
query: "app:query:me",
|
|
95
|
+
layout: { sections: [{ title: "s", fields: ["name"] }] },
|
|
96
|
+
...overrides,
|
|
97
|
+
} as FeatureSchema["screens"][number];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderScreen(opts: {
|
|
101
|
+
readonly screen: FeatureSchema["screens"][number];
|
|
102
|
+
readonly entityId?: string;
|
|
103
|
+
readonly queries: Array<{ type: string; payload: unknown }>;
|
|
104
|
+
readonly record?: Readonly<Record<string, unknown>> | null;
|
|
105
|
+
}): ReturnType<typeof render> {
|
|
106
|
+
const schema: FeatureSchema = { featureName: "app", entities: {}, screens: [opts.screen] };
|
|
107
|
+
const navApi: NavApi = {
|
|
108
|
+
route: { screenId: "app:screen:profile" },
|
|
109
|
+
navigate: () => {},
|
|
110
|
+
replace: () => {},
|
|
111
|
+
hrefFor: () => "",
|
|
112
|
+
searchParams: {},
|
|
113
|
+
setSearchParams: () => {},
|
|
114
|
+
};
|
|
115
|
+
return render(
|
|
116
|
+
<LocaleProvider
|
|
117
|
+
resolver={createStaticLocaleResolver({ locale: "de-DE" })}
|
|
118
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
119
|
+
>
|
|
120
|
+
<DispatcherProvider
|
|
121
|
+
dispatcher={stubDispatcher(
|
|
122
|
+
opts.record !== undefined ? opts.record : { id: "u1", name: "Ada" },
|
|
123
|
+
opts.queries,
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
<AppFeaturesProvider features={[schema]}>
|
|
127
|
+
<UserRolesProvider roles={[]}>
|
|
128
|
+
<NavProvider value={navApi}>
|
|
129
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
130
|
+
<KumikoScreen
|
|
131
|
+
schema={schema}
|
|
132
|
+
qn="app:screen:profile"
|
|
133
|
+
{...(opts.entityId !== undefined && { entityId: opts.entityId })}
|
|
134
|
+
/>
|
|
135
|
+
</PrimitivesProvider>
|
|
136
|
+
</NavProvider>
|
|
137
|
+
</UserRolesProvider>
|
|
138
|
+
</AppFeaturesProvider>
|
|
139
|
+
</DispatcherProvider>
|
|
140
|
+
</LocaleProvider>,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe("projectionDetail singleton (fw#2312)", () => {
|
|
145
|
+
test("singleton + no path id renders the record instead of the missing-id banner", async () => {
|
|
146
|
+
const queries: Array<{ type: string; payload: unknown }> = [];
|
|
147
|
+
const { queryByTestId, getByTestId } = renderScreen({
|
|
148
|
+
screen: meScreen({ singleton: true }),
|
|
149
|
+
queries,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await waitFor(() => expect(getByTestId("form-body")).toBeTruthy());
|
|
153
|
+
expect(queryByTestId("kumiko-screen-projection-detail-missing-id")).toBeNull();
|
|
154
|
+
expect(queries).toEqual([{ type: "app:query:me", payload: {} }]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("no singleton flag + no path id still shows the missing-id banner (no regression)", async () => {
|
|
158
|
+
const queries: Array<{ type: string; payload: unknown }> = [];
|
|
159
|
+
const { getByTestId } = renderScreen({ screen: meScreen(), queries });
|
|
160
|
+
|
|
161
|
+
await waitFor(() =>
|
|
162
|
+
expect(getByTestId("kumiko-screen-projection-detail-missing-id")).toBeTruthy(),
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("singleton ignores a stray path id — the query never receives it", async () => {
|
|
167
|
+
const queries: Array<{ type: string; payload: unknown }> = [];
|
|
168
|
+
const { getByTestId } = renderScreen({
|
|
169
|
+
screen: meScreen({ singleton: true }),
|
|
170
|
+
entityId: "attacker-controlled-id",
|
|
171
|
+
queries,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await waitFor(() => expect(getByTestId("form-body")).toBeTruthy());
|
|
175
|
+
expect(queries).toEqual([{ type: "app:query:me", payload: {} }]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("singleton + record-not-found banner never shows the (ignored) path id", async () => {
|
|
179
|
+
const queries: Array<{ type: string; payload: unknown }> = [];
|
|
180
|
+
const { getByTestId } = renderScreen({
|
|
181
|
+
screen: meScreen({ singleton: true }),
|
|
182
|
+
entityId: "99",
|
|
183
|
+
record: null,
|
|
184
|
+
queries,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const banner = await waitFor(() => getByTestId("kumiko-screen-record-missing"));
|
|
188
|
+
expect(banner.textContent).toBe("Record not found.");
|
|
189
|
+
expect(banner.textContent).not.toContain("99");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -6,14 +6,11 @@ import type {
|
|
|
6
6
|
EntityDefinition,
|
|
7
7
|
EntityEditScreenDefinition,
|
|
8
8
|
EntityListScreenDefinition,
|
|
9
|
-
IconKey,
|
|
10
9
|
ListFacetSpec,
|
|
11
10
|
ProjectionDetailScreenDefinition,
|
|
12
11
|
ProjectionListScreenDefinition,
|
|
13
12
|
RowAction,
|
|
14
13
|
RowActionNavigate,
|
|
15
|
-
RowActionWriteHandler,
|
|
16
|
-
RowFieldExtractor,
|
|
17
14
|
ScreenDefinition,
|
|
18
15
|
ToolbarAction,
|
|
19
16
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
@@ -39,8 +36,6 @@ import { useTranslation } from "../i18n";
|
|
|
39
36
|
import {
|
|
40
37
|
type DataTableFacet,
|
|
41
38
|
type DataTableRowAction,
|
|
42
|
-
type DataTableRowActionMode,
|
|
43
|
-
shouldRenderActionsIconOnly,
|
|
44
39
|
statusToneForValue,
|
|
45
40
|
usePrimitives,
|
|
46
41
|
} from "../primitives";
|
|
@@ -60,81 +55,20 @@ import {
|
|
|
60
55
|
import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim";
|
|
61
56
|
import { lastSegment, toKebab } from "./qn";
|
|
62
57
|
import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
|
|
58
|
+
import {
|
|
59
|
+
buildProjectionRowActions,
|
|
60
|
+
evalRowExtractor,
|
|
61
|
+
isWriteHandlerRowAction,
|
|
62
|
+
refetchAfterWrite,
|
|
63
|
+
resolveActionIcon,
|
|
64
|
+
rowActionModeFor,
|
|
65
|
+
runProjectionRowNavigate,
|
|
66
|
+
stringifyNavParams,
|
|
67
|
+
} from "./row-actions";
|
|
63
68
|
import { screenAccessAllows } from "./screen-access";
|
|
64
69
|
import { SecretsEditBody } from "./secrets-edit-body";
|
|
65
70
|
import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
|
|
66
71
|
|
|
67
|
-
function evalRowExtractor(
|
|
68
|
-
extractor: RowFieldExtractor,
|
|
69
|
-
row: Record<string, unknown>,
|
|
70
|
-
): Record<string, unknown> {
|
|
71
|
-
if ("pick" in extractor) {
|
|
72
|
-
return Object.fromEntries(extractor.pick.map((f) => [f, row[f]]));
|
|
73
|
-
}
|
|
74
|
-
return Object.fromEntries(Object.entries(extractor.map).map(([to, from]) => [to, row[from]]));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function isWriteHandlerRowAction(action: RowAction): action is RowActionWriteHandler {
|
|
78
|
-
return action.kind === "writeHandler" || action.kind === undefined;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Part B (fw-ui-defaults): id-derived default icon for actions that never
|
|
82
|
-
// declared one — a screen author still gets a recognizable glyph instead of
|
|
83
|
-
// a bare label. Checked against the actually registered IconKey vocabulary
|
|
84
|
-
// (nav-icon.ts) — no entry for verbs without a matching icon (e.g. "start",
|
|
85
|
-
// "pause").
|
|
86
|
-
const ACTION_ICON_BY_ID: Readonly<Partial<Record<string, IconKey>>> = {
|
|
87
|
-
delete: "trash",
|
|
88
|
-
edit: "pencil",
|
|
89
|
-
create: "plus",
|
|
90
|
-
new: "plus",
|
|
91
|
-
add: "plus",
|
|
92
|
-
view: "eye",
|
|
93
|
-
open: "eye",
|
|
94
|
-
cancel: "x",
|
|
95
|
-
reject: "x",
|
|
96
|
-
complete: "check",
|
|
97
|
-
resolve: "check",
|
|
98
|
-
approve: "check",
|
|
99
|
-
archive: "archive",
|
|
100
|
-
publish: "upload",
|
|
101
|
-
duplicate: "copy",
|
|
102
|
-
copy: "copy",
|
|
103
|
-
download: "download",
|
|
104
|
-
refresh: "refresh",
|
|
105
|
-
retry: "refresh",
|
|
106
|
-
settings: "settings",
|
|
107
|
-
share: "share",
|
|
108
|
-
send: "send",
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
// Ids are kebab-case (RowAction.id doc) — a compound id whose full form has
|
|
112
|
-
// no entry falls back to its last segment ("order-ship" -> "ship").
|
|
113
|
-
function kebabLastSegment(id: string): string {
|
|
114
|
-
const idx = id.lastIndexOf("-");
|
|
115
|
-
return idx === -1 ? id : id.slice(idx + 1);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Resolution order: author-declared `icon` wins, then the id-derived
|
|
119
|
-
// default (full id, then its last kebab segment). `declared` is `undefined`
|
|
120
|
-
// for ToolbarAction, which has no author-facing icon field.
|
|
121
|
-
function resolveActionIcon(id: string, declared?: IconKey): IconKey | undefined {
|
|
122
|
-
if (declared !== undefined) return declared;
|
|
123
|
-
return ACTION_ICON_BY_ID[id] ?? ACTION_ICON_BY_ID[kebabLastSegment(id)];
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Row-action column mode for a resolved action set: a group where every
|
|
127
|
-
// member carries an icon renders inline so `shouldRenderActionsIconOnly`
|
|
128
|
-
// can collapse it to icon-only buttons (fw#2580). Anything else keeps the
|
|
129
|
-
// DataTable's adaptive default (kebab past two actions) — inline text
|
|
130
|
-
// buttons for an icon-less group are the very thing the collapse avoids.
|
|
131
|
-
function rowActionModeFor(
|
|
132
|
-
actions: readonly DataTableRowAction[] | undefined,
|
|
133
|
-
): DataTableRowActionMode | undefined {
|
|
134
|
-
if (actions === undefined || !shouldRenderActionsIconOnly(actions)) return undefined;
|
|
135
|
-
return "inline";
|
|
136
|
-
}
|
|
137
|
-
|
|
138
72
|
// KumikoScreen picks up a ScreenDefinition from the schema by qn and
|
|
139
73
|
// routes it to the right renderer based on `screen.type`. Command
|
|
140
74
|
// qualification (`<feature>:write:<entity>:create` etc.) happens here
|
|
@@ -1106,26 +1040,10 @@ function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[
|
|
|
1106
1040
|
// passes through any field present in entity.fields, matching its
|
|
1107
1041
|
// pre-fw#2224 behavior; only the boolean-coercion branch cares about type.
|
|
1108
1042
|
|
|
1109
|
-
function stringifyNavParams(params: Record<string, unknown>): Record<string, string | null> {
|
|
1110
|
-
const out: Record<string, string | null> = {};
|
|
1111
|
-
for (const [k, v] of Object.entries(params)) {
|
|
1112
|
-
out[k] =
|
|
1113
|
-
v === null || v === undefined ? null : Array.isArray(v) ? JSON.stringify(v) : String(v);
|
|
1114
|
-
}
|
|
1115
|
-
return out;
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
1043
|
function isFacetI18nKey(label: string): boolean {
|
|
1119
1044
|
return !/\s/.test(label) && (label.includes(".") || label.includes(":"));
|
|
1120
1045
|
}
|
|
1121
1046
|
|
|
1122
|
-
async function refetchAfterWrite(refetch: () => Promise<unknown>): Promise<void> {
|
|
1123
|
-
await refetch().catch((err: unknown) => {
|
|
1124
|
-
// biome-ignore lint/suspicious/noConsole: refetch must not poison the write-action error path
|
|
1125
|
-
console.error("kumiko-screen: refetch after write action failed", err);
|
|
1126
|
-
});
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
1047
|
function buildFilterPayload(
|
|
1130
1048
|
urlFilters: Readonly<Record<string, readonly string[]>>,
|
|
1131
1049
|
typeOf: (field: string) => string | undefined,
|
|
@@ -1953,97 +1871,31 @@ function ProjectionListBody({
|
|
|
1953
1871
|
|
|
1954
1872
|
const filterFacets = useMemo<DataTableFacet[]>(() => buildFilterFacets(facetSpecs), [facetSpecs]);
|
|
1955
1873
|
|
|
1874
|
+
// Entity-Targets (fw#2228) — see EntityListBody.runNavigate for why the
|
|
1875
|
+
// resolution happens in the NavApi impl. Unlike there: NO row["id"]
|
|
1876
|
+
// fallback — projectionList rows come from an arbitrary query projection
|
|
1877
|
+
// with no guaranteed "id" field. The boot validator therefore enforces an
|
|
1878
|
+
// explicit entityId for projectionList entity targets. Same helper
|
|
1879
|
+
// relatedList's rowActions reuse (related-list-section.tsx) — a
|
|
1880
|
+
// projectionDetail relatedList row has the identical "no guaranteed id"
|
|
1881
|
+
// shape.
|
|
1956
1882
|
const runNavigate = useCallback(
|
|
1957
|
-
(action: RowActionNavigate, row: ListRowViewModel) =>
|
|
1958
|
-
|
|
1959
|
-
// Entity-Targets (fw#2228) — siehe EntityListBody.runNavigate für die
|
|
1960
|
-
// Begründung, warum die Auflösung in der NavApi-Impl passiert. Anders
|
|
1961
|
-
// als dort: KEIN row["id"]-Fallback — projectionList-Rows kommen aus
|
|
1962
|
-
// einer beliebigen Query-Projection ohne garantiertes "id"-Feld
|
|
1963
|
-
// (gleiche Begründung wie beim screen-Target unten). Der Boot-
|
|
1964
|
-
// Validator erzwingt deshalb einen expliziten entityId für
|
|
1965
|
-
// projectionList-entity-Targets.
|
|
1966
|
-
const id = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : "";
|
|
1967
|
-
if (id === "") return;
|
|
1968
|
-
nav.navigate({ entity: action.entity, id });
|
|
1969
|
-
} else if (action.screen !== undefined) {
|
|
1970
|
-
const entityId =
|
|
1971
|
-
action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
|
|
1972
|
-
nav.navigate({
|
|
1973
|
-
screenId: action.screen,
|
|
1974
|
-
...(entityId !== undefined && entityId !== "" && { entityId }),
|
|
1975
|
-
});
|
|
1976
|
-
} else {
|
|
1977
|
-
return;
|
|
1978
|
-
}
|
|
1979
|
-
const params =
|
|
1980
|
-
action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
|
|
1981
|
-
if (params !== undefined) {
|
|
1982
|
-
nav.setSearchParams(stringifyNavParams(params));
|
|
1983
|
-
}
|
|
1984
|
-
},
|
|
1883
|
+
(action: RowActionNavigate, row: ListRowViewModel) =>
|
|
1884
|
+
runProjectionRowNavigate(nav, action, row),
|
|
1985
1885
|
[nav],
|
|
1986
1886
|
);
|
|
1987
1887
|
|
|
1988
|
-
const rowActions = useMemo(
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
...(action.style !== undefined && { style: action.style }),
|
|
2000
|
-
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
2001
|
-
onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
|
|
2002
|
-
...(visible !== undefined && {
|
|
2003
|
-
isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
|
|
2004
|
-
}),
|
|
2005
|
-
});
|
|
2006
|
-
continue;
|
|
2007
|
-
}
|
|
2008
|
-
// writeHandler (default-kind) — gleicher Dispatch-Pfad wie entityList:
|
|
2009
|
-
// Failure-Result MUSS zum Error werden (sonst schließt der Confirm-
|
|
2010
|
-
// Dialog kommentarlos).
|
|
2011
|
-
if (dispatcher === undefined) continue;
|
|
2012
|
-
const writeAction = action;
|
|
2013
|
-
const writeVisible = writeAction.visible;
|
|
2014
|
-
out.push({
|
|
2015
|
-
id: writeAction.id,
|
|
2016
|
-
label: effectiveTranslate(writeAction.label),
|
|
2017
|
-
...(writeAction.style !== undefined && { style: writeAction.style }),
|
|
2018
|
-
icon: resolveActionIcon(writeAction.id, writeAction.icon),
|
|
2019
|
-
...(writeAction.confirm !== undefined && {
|
|
2020
|
-
confirm: effectiveTranslate(writeAction.confirm),
|
|
2021
|
-
}),
|
|
2022
|
-
...(writeAction.confirmLabel !== undefined && {
|
|
2023
|
-
confirmLabel: effectiveTranslate(writeAction.confirmLabel),
|
|
2024
|
-
}),
|
|
2025
|
-
onTrigger: async (row: ListRowViewModel) => {
|
|
2026
|
-
const payload =
|
|
2027
|
-
writeAction.payload !== undefined
|
|
2028
|
-
? evalRowExtractor(writeAction.payload, row.values)
|
|
2029
|
-
: { id: row.values["id"] };
|
|
2030
|
-
const result = await dispatcher.write(writeAction.handler, payload);
|
|
2031
|
-
if (!result.isSuccess) {
|
|
2032
|
-
throw new WriteFailedError(
|
|
2033
|
-
result.error,
|
|
2034
|
-
dispatcherErrorText(result.error, effectiveTranslate),
|
|
2035
|
-
);
|
|
2036
|
-
}
|
|
2037
|
-
// Same refetch as EntityListBody's rowActions above.
|
|
2038
|
-
await refetchAfterWrite(rowsQuery.refetch);
|
|
2039
|
-
},
|
|
2040
|
-
...(writeVisible !== undefined && {
|
|
2041
|
-
isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
|
|
2042
|
-
}),
|
|
2043
|
-
});
|
|
2044
|
-
}
|
|
2045
|
-
return out.length > 0 ? out : undefined;
|
|
2046
|
-
}, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher, rowsQuery.refetch]);
|
|
1888
|
+
const rowActions = useMemo(
|
|
1889
|
+
() =>
|
|
1890
|
+
buildProjectionRowActions({
|
|
1891
|
+
rowActions: screen.rowActions,
|
|
1892
|
+
translate: effectiveTranslate,
|
|
1893
|
+
dispatcher,
|
|
1894
|
+
nav,
|
|
1895
|
+
refetch: rowsQuery.refetch,
|
|
1896
|
+
}),
|
|
1897
|
+
[screen.rowActions, effectiveTranslate, dispatcher, nav, rowsQuery.refetch],
|
|
1898
|
+
);
|
|
2047
1899
|
|
|
2048
1900
|
// Same icon-only collapse as entityList (fw#2580) — projectionList rows go
|
|
2049
1901
|
// through the identical RenderList/DataTable path.
|
|
@@ -2224,6 +2076,12 @@ function ProjectionDetailBody({
|
|
|
2224
2076
|
const effectiveTranslate = translate ?? t;
|
|
2225
2077
|
const nav = useNav();
|
|
2226
2078
|
const idParam = screen.idParam ?? "id";
|
|
2079
|
+
// Singleton screens ignore whatever id the path happens to carry — the
|
|
2080
|
+
// server picks the row from session/context, so forwarding a path id
|
|
2081
|
+
// (even a stray/spoofed one) into the query or into extension sections'
|
|
2082
|
+
// entityId fallback (resolveExtensionEntityId in render-edit.tsx) would
|
|
2083
|
+
// let a client-controlled value leak where none is meant to reach.
|
|
2084
|
+
const effectiveEntityId = screen.singleton === true ? undefined : entityId;
|
|
2227
2085
|
const isTabsMode = screen.layout.mode === "tabs";
|
|
2228
2086
|
const activeSection = useMemo(() => {
|
|
2229
2087
|
if (!isTabsMode || Tabs === undefined) return undefined;
|
|
@@ -2252,9 +2110,21 @@ function ProjectionDetailBody({
|
|
|
2252
2110
|
}, [screen, activeSection]);
|
|
2253
2111
|
const detailQuery = useQuery<Readonly<Record<string, unknown>>>(
|
|
2254
2112
|
screen.query,
|
|
2255
|
-
|
|
2113
|
+
effectiveEntityId !== undefined ? { [idParam]: effectiveEntityId } : {},
|
|
2256
2114
|
);
|
|
2257
2115
|
|
|
2116
|
+
// A writeForm section's handler creates a new record (see EditWriteFormSection's
|
|
2117
|
+
// doc) — both the outer record (header/metrics/other sections) and a sibling
|
|
2118
|
+
// relatedList section's own independently-fetched rows must reflect it. A
|
|
2119
|
+
// query refetch alone only refreshes `record` below; bumping this into
|
|
2120
|
+
// <RenderEdit>'s `key` forces a full remount so RelatedListSection's own
|
|
2121
|
+
// useQuery call re-runs too (it has no `live` subscription of its own).
|
|
2122
|
+
const [reloadNonce, setReloadNonce] = useState(0);
|
|
2123
|
+
const reloadDetail = useCallback(async () => {
|
|
2124
|
+
await detailQuery.refetch();
|
|
2125
|
+
setReloadNonce((n) => n + 1);
|
|
2126
|
+
}, [detailQuery.refetch]);
|
|
2127
|
+
|
|
2258
2128
|
// Default edit action (fw#2166): resolved cross-feature over ALL mounted
|
|
2259
2129
|
// features, not just this feature's own schema — detailFor itself is
|
|
2260
2130
|
// resolved cross-feature by the boot-validator (detail-screens.ts), and
|
|
@@ -2293,9 +2163,12 @@ function ProjectionDetailBody({
|
|
|
2293
2163
|
label: effectiveTranslate("kumiko.actions.edit"),
|
|
2294
2164
|
icon: resolveActionIcon("edit"),
|
|
2295
2165
|
onPress: () =>
|
|
2296
|
-
nav.navigate({
|
|
2166
|
+
nav.navigate({
|
|
2167
|
+
screenId: targetScreenId,
|
|
2168
|
+
...(effectiveEntityId !== undefined && { entityId: effectiveEntityId }),
|
|
2169
|
+
}),
|
|
2297
2170
|
};
|
|
2298
|
-
}, [editScreen, effectiveTranslate, nav,
|
|
2171
|
+
}, [editScreen, effectiveTranslate, nav, effectiveEntityId]);
|
|
2299
2172
|
|
|
2300
2173
|
const headerActions = useMemo((): readonly RenderEditAction[] | undefined => {
|
|
2301
2174
|
const record = detailQuery.data ?? {};
|
|
@@ -2418,7 +2291,7 @@ function ProjectionDetailBody({
|
|
|
2418
2291
|
detailQuery.refetch,
|
|
2419
2292
|
]);
|
|
2420
2293
|
|
|
2421
|
-
if (
|
|
2294
|
+
if (effectiveEntityId === undefined && screen.singleton !== true) {
|
|
2422
2295
|
return (
|
|
2423
2296
|
<Banner padded variant="error" testId="kumiko-screen-projection-detail-missing-id">
|
|
2424
2297
|
Screen <Text variant="code">{screen.id}</Text> (projectionDetail) needs a row id in the path
|
|
@@ -2444,7 +2317,13 @@ function ProjectionDetailBody({
|
|
|
2444
2317
|
if (!record) {
|
|
2445
2318
|
return (
|
|
2446
2319
|
<Banner padded variant="error" testId="kumiko-screen-record-missing">
|
|
2447
|
-
|
|
2320
|
+
{screen.singleton === true ? (
|
|
2321
|
+
"Record not found."
|
|
2322
|
+
) : (
|
|
2323
|
+
<>
|
|
2324
|
+
Record <Text variant="code">{entityId}</Text> not found.
|
|
2325
|
+
</>
|
|
2326
|
+
)}
|
|
2448
2327
|
</Banner>
|
|
2449
2328
|
);
|
|
2450
2329
|
}
|
|
@@ -2528,12 +2407,14 @@ function ProjectionDetailBody({
|
|
|
2528
2407
|
);
|
|
2529
2408
|
return (
|
|
2530
2409
|
<RenderEdit
|
|
2410
|
+
key={`${entityId}:${reloadNonce}`}
|
|
2531
2411
|
screen={detailScreen}
|
|
2532
2412
|
entity={entity}
|
|
2533
2413
|
featureName={schema.featureName}
|
|
2534
2414
|
initial={record as FormValues}
|
|
2535
|
-
entityId={
|
|
2415
|
+
entityId={effectiveEntityId}
|
|
2536
2416
|
customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
|
|
2417
|
+
onReload={reloadDetail}
|
|
2537
2418
|
{...(headerActions !== undefined && { actions: headerActions })}
|
|
2538
2419
|
{...(translate !== undefined && { translate })}
|
|
2539
2420
|
{...(hasTabs && { hideSectionTitles: true })}
|