@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.
@@ -0,0 +1,201 @@
1
+ import type {
2
+ IconKey,
3
+ RowAction,
4
+ RowActionNavigate,
5
+ RowActionWriteHandler,
6
+ RowFieldExtractor,
7
+ } from "@cosmicdrift/kumiko-framework/ui-types";
8
+ import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
9
+ import type { Dispatcher, ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
10
+ import type { DataTableRowAction, DataTableRowActionMode } from "../primitives";
11
+ import { shouldRenderActionsIconOnly } from "../primitives";
12
+ import type { NavApi } from "./nav";
13
+ import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
14
+
15
+ export function evalRowExtractor(
16
+ extractor: RowFieldExtractor,
17
+ row: Record<string, unknown>,
18
+ ): Record<string, unknown> {
19
+ if ("pick" in extractor) {
20
+ return Object.fromEntries(extractor.pick.map((f) => [f, row[f]]));
21
+ }
22
+ return Object.fromEntries(Object.entries(extractor.map).map(([to, from]) => [to, row[from]]));
23
+ }
24
+
25
+ export function isWriteHandlerRowAction(action: RowAction): action is RowActionWriteHandler {
26
+ return action.kind === "writeHandler" || action.kind === undefined;
27
+ }
28
+
29
+ // Part B (fw-ui-defaults): id-derived default icon for actions that never
30
+ // declared one — a screen author still gets a recognizable glyph instead of
31
+ // a bare label. Checked against the actually registered IconKey vocabulary
32
+ // (nav-icon.ts) — no entry for verbs without a matching icon (e.g. "start",
33
+ // "pause").
34
+ const ACTION_ICON_BY_ID: Readonly<Partial<Record<string, IconKey>>> = {
35
+ delete: "trash",
36
+ edit: "pencil",
37
+ create: "plus",
38
+ new: "plus",
39
+ add: "plus",
40
+ view: "eye",
41
+ open: "eye",
42
+ cancel: "x",
43
+ reject: "x",
44
+ complete: "check",
45
+ resolve: "check",
46
+ approve: "check",
47
+ archive: "archive",
48
+ publish: "upload",
49
+ duplicate: "copy",
50
+ copy: "copy",
51
+ download: "download",
52
+ refresh: "refresh",
53
+ retry: "refresh",
54
+ settings: "settings",
55
+ share: "share",
56
+ send: "send",
57
+ };
58
+
59
+ // Ids are kebab-case (RowAction.id doc) — a compound id whose full form has
60
+ // no entry falls back to its last segment ("order-ship" -> "ship").
61
+ function kebabLastSegment(id: string): string {
62
+ const idx = id.lastIndexOf("-");
63
+ return idx === -1 ? id : id.slice(idx + 1);
64
+ }
65
+
66
+ // Resolution order: author-declared `icon` wins, then the id-derived
67
+ // default (full id, then its last kebab segment). `declared` is `undefined`
68
+ // for ToolbarAction, which has no author-facing icon field.
69
+ export function resolveActionIcon(id: string, declared?: IconKey): IconKey | undefined {
70
+ if (declared !== undefined) return declared;
71
+ return ACTION_ICON_BY_ID[id] ?? ACTION_ICON_BY_ID[kebabLastSegment(id)];
72
+ }
73
+
74
+ // Row-action column mode for a resolved action set: a group where every
75
+ // member carries an icon renders inline so `shouldRenderActionsIconOnly`
76
+ // can collapse it to icon-only buttons (fw#2580). Anything else keeps the
77
+ // DataTable's adaptive default (kebab past two actions) — inline text
78
+ // buttons for an icon-less group are the very thing the collapse avoids.
79
+ export function rowActionModeFor(
80
+ actions: readonly DataTableRowAction[] | undefined,
81
+ ): DataTableRowActionMode | undefined {
82
+ if (actions === undefined || !shouldRenderActionsIconOnly(actions)) return undefined;
83
+ return "inline";
84
+ }
85
+
86
+ export function stringifyNavParams(params: Record<string, unknown>): Record<string, string | null> {
87
+ const out: Record<string, string | null> = {};
88
+ for (const [k, v] of Object.entries(params)) {
89
+ out[k] =
90
+ v === null || v === undefined ? null : Array.isArray(v) ? JSON.stringify(v) : String(v);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ export async function refetchAfterWrite(refetch: () => Promise<unknown>): Promise<void> {
96
+ await refetch().catch((err: unknown) => {
97
+ // biome-ignore lint/suspicious/noConsole: refetch must not poison the write-action error path
98
+ console.error("kumiko-screen: refetch after write action failed", err);
99
+ });
100
+ }
101
+
102
+ // Navigate execution for query-driven rows (projectionList, and relatedList
103
+ // — both have no guaranteed "id" field, unlike entityList's rows which back
104
+ // a real entity). No same-entity row["id"] fallback (see EntityListBody's
105
+ // own runNavigate for that variant, which stays separate — entityList's
106
+ // fallback needs `screen.entity`, which neither projectionList nor
107
+ // relatedList has).
108
+ export function runProjectionRowNavigate(
109
+ nav: NavApi,
110
+ action: RowActionNavigate,
111
+ row: ListRowViewModel,
112
+ ): void {
113
+ if (action.entity !== undefined) {
114
+ const id = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : "";
115
+ // skip: no entityId column on this row — nothing to navigate to.
116
+ if (id === "") return;
117
+ nav.navigate({ entity: action.entity, id });
118
+ } else if (action.screen !== undefined) {
119
+ const entityId =
120
+ action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
121
+ nav.navigate({
122
+ screenId: action.screen,
123
+ ...(entityId !== undefined && entityId !== "" && { entityId }),
124
+ });
125
+ } else {
126
+ // skip: neither entity nor screen set — the boot-validator rejects this
127
+ // shape (resolveRowActionNavigateTarget), so this only guards types.
128
+ return;
129
+ }
130
+ const params =
131
+ action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
132
+ if (params !== undefined) {
133
+ nav.setSearchParams(stringifyNavParams(params));
134
+ }
135
+ }
136
+
137
+ // Builds the DataTable-ready row-action set for a query-driven row source
138
+ // (projectionList, relatedList) — navigate dispatches through
139
+ // runProjectionRowNavigate, writeHandler dispatches through the shared
140
+ // Dispatcher and refetches `refetch` on success. Single implementation so
141
+ // projectionList's rowActions and a projectionDetail relatedList section's
142
+ // rowActions can't drift apart (fw editable-detail-screens).
143
+ export function buildProjectionRowActions(options: {
144
+ readonly rowActions: readonly RowAction[] | undefined;
145
+ readonly translate: Translate;
146
+ readonly dispatcher: Dispatcher | undefined;
147
+ readonly nav: NavApi;
148
+ readonly refetch: () => Promise<unknown>;
149
+ }): readonly DataTableRowAction[] | undefined {
150
+ const { rowActions, translate, dispatcher, nav, refetch } = options;
151
+ if (rowActions === undefined) return undefined;
152
+ const out: DataTableRowAction[] = [];
153
+ for (const action of rowActions) {
154
+ if (action.kind === "navigate") {
155
+ const navigateAction = action;
156
+ const visible = action.visible;
157
+ const actionIcon = resolveActionIcon(action.id, action.icon);
158
+ out.push({
159
+ id: action.id,
160
+ label: translate(action.label),
161
+ ...(action.style !== undefined && { style: action.style }),
162
+ ...(actionIcon !== undefined && { icon: actionIcon }),
163
+ onTrigger: (row: ListRowViewModel) => runProjectionRowNavigate(nav, navigateAction, row),
164
+ ...(visible !== undefined && {
165
+ isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
166
+ }),
167
+ });
168
+ continue;
169
+ }
170
+ // writeHandler (default-kind) — a swallowed failure result must become a
171
+ // thrown error (fw prod-bug 2026-06-07), same as every other write path.
172
+ if (dispatcher === undefined) continue;
173
+ const writeAction = action;
174
+ const writeVisible = writeAction.visible;
175
+ out.push({
176
+ id: writeAction.id,
177
+ label: translate(writeAction.label),
178
+ ...(writeAction.style !== undefined && { style: writeAction.style }),
179
+ icon: resolveActionIcon(writeAction.id, writeAction.icon),
180
+ ...(writeAction.confirm !== undefined && { confirm: translate(writeAction.confirm) }),
181
+ ...(writeAction.confirmLabel !== undefined && {
182
+ confirmLabel: translate(writeAction.confirmLabel),
183
+ }),
184
+ onTrigger: async (row: ListRowViewModel) => {
185
+ const payload =
186
+ writeAction.payload !== undefined
187
+ ? evalRowExtractor(writeAction.payload, row.values)
188
+ : { id: row.values["id"] };
189
+ const result = await dispatcher.write(writeAction.handler, payload);
190
+ if (!result.isSuccess) {
191
+ throw new WriteFailedError(result.error, dispatcherErrorText(result.error, translate));
192
+ }
193
+ await refetchAfterWrite(refetch);
194
+ },
195
+ ...(writeVisible !== undefined && {
196
+ isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
197
+ }),
198
+ });
199
+ }
200
+ return out.length > 0 ? out : undefined;
201
+ }
@@ -0,0 +1,163 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { RowAction } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import type { Dispatcher, EditRelatedListSectionViewModel } from "@cosmicdrift/kumiko-headless";
4
+ import { render, screen as rtlScreen, waitFor } from "@testing-library/react";
5
+ import type { ComponentType, ReactNode } from "react";
6
+ import { NavProvider } from "../../app/nav";
7
+ import { DispatcherProvider } from "../../context/dispatcher-context";
8
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
9
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
10
+ import {
11
+ type CorePrimitives,
12
+ type DataTableProps,
13
+ PrimitivesProvider,
14
+ type SectionProps,
15
+ } from "../../primitives";
16
+ import { RelatedListSection } from "../related-list-section";
17
+
18
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
19
+ const noop = () => {};
20
+
21
+ const testSection: ComponentType<SectionProps> = ({ testId, children }) => (
22
+ <div data-testid={testId}>{children}</div>
23
+ );
24
+
25
+ // A minimal DataTable stub: renders one button per row per row-action,
26
+ // wired straight to the action's onTrigger — enough to prove
27
+ // RelatedListSection wires rowActions through to a real dispatch, without
28
+ // needing the production DataTable's sorting/paging/kebab-menu chrome.
29
+ const testDataTable: ComponentType<DataTableProps> = ({ rows, rowActions }) => (
30
+ <table>
31
+ <tbody>
32
+ {rows.map((row) => (
33
+ <tr key={row.id} data-testid={`row-${row.id}`}>
34
+ <td>
35
+ {(rowActions ?? []).map((action) => (
36
+ <button
37
+ key={action.id}
38
+ type="button"
39
+ data-testid={`action-${action.id}-${row.id}`}
40
+ onClick={() => void action.onTrigger(row)}
41
+ >
42
+ {action.label}
43
+ </button>
44
+ ))}
45
+ </td>
46
+ </tr>
47
+ ))}
48
+ </tbody>
49
+ </table>
50
+ );
51
+
52
+ function testPrimitives(): CorePrimitives {
53
+ return {
54
+ Button: noop,
55
+ Banner: ({ children, testId }: { children?: ReactNode; testId?: string }) => (
56
+ <div data-testid={testId}>{children}</div>
57
+ ),
58
+ Field: passChildren,
59
+ Input: noop,
60
+ DataTable: testDataTable,
61
+ Form: noop,
62
+ Section: testSection,
63
+ Card: passChildren,
64
+ Grid: passChildren,
65
+ GridCell: passChildren,
66
+ Text: noop,
67
+ Heading: noop,
68
+ Dialog: noop,
69
+ Modal: noop,
70
+ Lightbox: noop,
71
+ ConfigSourceBadge: noop,
72
+ ConfigCascadeView: noop,
73
+ Link: noop,
74
+ } as unknown as CorePrimitives;
75
+ }
76
+
77
+ function stubDispatcher(): {
78
+ dispatcher: Dispatcher;
79
+ writes: Array<{ type: string; payload: unknown }>;
80
+ queryCount: () => number;
81
+ } {
82
+ const writes: Array<{ type: string; payload: unknown }> = [];
83
+ let queryCalls = 0;
84
+ const dispatcher: Dispatcher = {
85
+ write: (async (type, payload) => {
86
+ writes.push({ type, payload });
87
+ return { isSuccess: true, data: null };
88
+ }) as Dispatcher["write"],
89
+ query: (async () => {
90
+ queryCalls += 1;
91
+ return {
92
+ isSuccess: true,
93
+ data: { rows: [{ id: "r1", name: "Alice" }], nextCursor: null },
94
+ };
95
+ }) as Dispatcher["query"],
96
+ batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
97
+ statusStore: {
98
+ getState: () => "online",
99
+ subscribe: () => () => {},
100
+ } as unknown as Dispatcher["statusStore"],
101
+ async *stream() {},
102
+ pendingWrites: () => [],
103
+ pendingFiles: () => [],
104
+ };
105
+ return { dispatcher, writes, queryCount: () => queryCalls };
106
+ }
107
+
108
+ const rowActions: readonly RowAction[] = [
109
+ {
110
+ id: "resend",
111
+ label: "actions.resend",
112
+ handler: "orders:write:resend",
113
+ payload: { pick: ["id"] },
114
+ },
115
+ ];
116
+
117
+ const historySection: EditRelatedListSectionViewModel = {
118
+ kind: "relatedList",
119
+ title: "History",
120
+ query: "orders:query:notifications:list",
121
+ columns: [{ field: "name" }],
122
+ rowActions,
123
+ };
124
+
125
+ function renderRelatedList(dispatcher: Dispatcher) {
126
+ return render(
127
+ <LocaleProvider
128
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
129
+ fallbackBundles={[kumikoDefaultTranslations]}
130
+ >
131
+ <DispatcherProvider dispatcher={dispatcher}>
132
+ <PrimitivesProvider value={testPrimitives()}>
133
+ <NavProvider
134
+ value={{
135
+ route: undefined,
136
+ navigate: noop,
137
+ replace: noop,
138
+ hrefFor: () => "#",
139
+ searchParams: {},
140
+ setSearchParams: noop,
141
+ }}
142
+ >
143
+ <RelatedListSection section={historySection} parentId="order-1" featureName="orders" />
144
+ </NavProvider>
145
+ </PrimitivesProvider>
146
+ </DispatcherProvider>
147
+ </LocaleProvider>,
148
+ );
149
+ }
150
+
151
+ describe("RelatedListSection — rowActions", () => {
152
+ test("clicking a row action dispatches through the configured write-handler with the extracted payload, then refetches", async () => {
153
+ const { dispatcher, writes, queryCount } = stubDispatcher();
154
+ renderRelatedList(dispatcher);
155
+
156
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
157
+ rtlScreen.getByTestId("action-resend-r1").click();
158
+
159
+ await waitFor(() => expect(writes).toHaveLength(1));
160
+ expect(writes[0]).toEqual({ type: "orders:write:resend", payload: { id: "r1" } });
161
+ await waitFor(() => expect(queryCount()).toBe(2));
162
+ });
163
+ });
@@ -123,6 +123,20 @@ describe("hasEditableSection", () => {
123
123
  expect(hasEditableSection([])).toBe(false);
124
124
  });
125
125
 
126
+ // writeForm owns its own submit button (write-form-section-submit) —
127
+ // counting it here would additionally render the screen-level Save button
128
+ // (render-edit-submit) on a projectionDetail, which has no form submit to
129
+ // wire it to.
130
+ test("writeForm section → false (has its own submit, not the screen's)", () => {
131
+ const writeFormSection: EditSectionViewModel = {
132
+ kind: "writeForm",
133
+ columns: 1,
134
+ handler: "orders:write:add-note",
135
+ fields: [field(false)],
136
+ };
137
+ expect(hasEditableSection([writeFormSection])).toBe(false);
138
+ });
139
+
126
140
  test("hidden section with editable field → false (user cannot see the field)", () => {
127
141
  const hiddenSectionWithEditableField: EditSectionViewModel = {
128
142
  kind: "fields",
@@ -0,0 +1,164 @@
1
+ // fw#2312: readOnly fields with `field.renderer: { format: "json" }` (audit
2
+ // payload/metadata, job logs) must hand the RAW value to a registered
3
+ // JsonView primitive — applyFormatSpec's already-indented string is only
4
+ // the fallback for a primitives-provider without JsonView (e.g. an app
5
+ // predating this rollout). "children contains \n" is not a discriminating
6
+ // assertion here (applyFormatSpec already returns a real newline string
7
+ // today) — the test instead checks that JsonView is actually invoked and
8
+ // receives the structured value, not a flattened string.
9
+ //
10
+ // Capture-primitives instead of real ones, same pattern as
11
+ // render-field-unit-format.test.tsx.
12
+
13
+ import { describe, expect, test } from "bun:test";
14
+ import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
15
+ import { render } from "@testing-library/react";
16
+ import type { ComponentType, ReactNode } from "react";
17
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
18
+ import {
19
+ type CorePrimitives,
20
+ type JsonViewProps,
21
+ PrimitivesProvider,
22
+ type TextProps,
23
+ } from "../../primitives";
24
+ import { RenderField } from "../render-field";
25
+
26
+ let capturedJsonView: JsonViewProps | undefined;
27
+ const captureJsonView: ComponentType<JsonViewProps> = (props) => {
28
+ capturedJsonView = props;
29
+ return null;
30
+ };
31
+
32
+ let capturedText: TextProps | undefined;
33
+ const captureText: ComponentType<TextProps> = (props) => {
34
+ capturedText = props;
35
+ return null;
36
+ };
37
+
38
+ const noop = (): ReactNode => null;
39
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
40
+
41
+ const basePrimitives: Omit<CorePrimitives, "Text" | "JsonView"> = {
42
+ Button: noop,
43
+ // Passes children through (not noop): the "unsupported jsonb" fallback
44
+ // path wraps its Text/JsonView content in <Banner>, so a Banner that
45
+ // swallows children would silently never mount them, making those
46
+ // captures a no-op regardless of which branch RenderField actually took.
47
+ Banner: passChildren,
48
+ Field: passChildren,
49
+ Input: noop,
50
+ DataTable: noop,
51
+ Form: noop,
52
+ Section: noop,
53
+ Card: noop,
54
+ Grid: noop,
55
+ GridCell: noop,
56
+ Heading: noop,
57
+ Dialog: noop,
58
+ Modal: noop,
59
+ Lightbox: noop,
60
+ ConfigSourceBadge: noop,
61
+ ConfigCascadeView: noop,
62
+ Link: noop,
63
+ };
64
+
65
+ const primitivesWithJsonView: CorePrimitives = {
66
+ ...basePrimitives,
67
+ Text: captureText,
68
+ JsonView: captureJsonView,
69
+ };
70
+
71
+ const primitivesWithoutJsonView: CorePrimitives = {
72
+ ...basePrimitives,
73
+ Text: captureText,
74
+ };
75
+
76
+ function payloadField(
77
+ value: unknown,
78
+ renderer: EditFieldViewModel["renderer"] = { format: "json" },
79
+ ): EditFieldViewModel {
80
+ return {
81
+ field: "payload",
82
+ label: "Payload",
83
+ type: "jsonb",
84
+ value,
85
+ visible: true,
86
+ readOnly: true,
87
+ required: false,
88
+ renderer,
89
+ };
90
+ }
91
+
92
+ // No `renderer` key at all (not even `renderer: undefined`, which a default
93
+ // parameter would still override) — this is the "no author-declared
94
+ // FieldRenderer" case that falls through to renderInput's jsonb/embedded/
95
+ // files/images fallback banner instead of FieldRendererOutput.
96
+ function unrenderedJsonbField(value: unknown): EditFieldViewModel {
97
+ return {
98
+ field: "payload",
99
+ label: "Payload",
100
+ type: "jsonb",
101
+ value,
102
+ visible: true,
103
+ readOnly: true,
104
+ required: false,
105
+ };
106
+ }
107
+
108
+ function renderPayload(primitives: CorePrimitives, field: EditFieldViewModel): void {
109
+ capturedJsonView = undefined;
110
+ capturedText = undefined;
111
+ render(
112
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
113
+ <PrimitivesProvider value={primitives}>
114
+ <RenderField field={field} onChange={() => {}} />
115
+ </PrimitivesProvider>
116
+ </LocaleProvider>,
117
+ );
118
+ }
119
+
120
+ describe("RenderField — format: json (fw#2312)", () => {
121
+ test("mit registriertem JsonView bekommt es den rohen, strukturierten Wert — keinen flachen String", () => {
122
+ const value = { user: { id: 1, roles: ["admin", "billing"] } };
123
+ renderPayload(primitivesWithJsonView, payloadField(value));
124
+ expect(capturedJsonView?.value).toBe(value);
125
+ expect(typeof capturedJsonView?.value).toBe("object");
126
+ expect(capturedText).toBeUndefined();
127
+ });
128
+
129
+ test("renderer.indent wird an JsonView durchgereicht", () => {
130
+ renderPayload(primitivesWithJsonView, payloadField({ a: 1 }, { format: "json", indent: 4 }));
131
+ expect(capturedJsonView?.indent).toBe(4);
132
+ });
133
+
134
+ test("ohne registriertes JsonView fällt es auf Text mit dem von applyFormatSpec formatierten String zurück", () => {
135
+ const value = { a: 1 };
136
+ renderPayload(primitivesWithoutJsonView, payloadField(value));
137
+ expect(capturedJsonView).toBeUndefined();
138
+ expect(capturedText?.children).toBe(JSON.stringify(value, null, 2));
139
+ });
140
+
141
+ test("ein zirkulärer Wert erreicht JsonView unverändert — die Aufbereitung ist Sache der Primitive-Implementierung, nicht von RenderField", () => {
142
+ const circular: Record<string, unknown> = { name: "job-1" };
143
+ circular["self"] = circular;
144
+ expect(() => renderPayload(primitivesWithJsonView, payloadField(circular))).not.toThrow();
145
+ expect(capturedJsonView?.value).toBe(circular);
146
+ });
147
+ });
148
+
149
+ describe("RenderField — unsupported jsonb/embedded/files/images Fallback-Banner (fw#2312)", () => {
150
+ test("mit registriertem JsonView zeigt der Banner den Wert strukturiert statt als JSON.stringify-Einzeiler", () => {
151
+ const value = { assignee: "user-1", tags: ["a", "b"] };
152
+ renderPayload(primitivesWithJsonView, unrenderedJsonbField(value));
153
+ expect(capturedJsonView?.value).toBe(value);
154
+ expect(capturedText).toBeUndefined();
155
+ });
156
+
157
+ test("ohne registriertes JsonView fällt der Banner auf Text variant=code mit dem alten Einzeiler zurück", () => {
158
+ const value = { assignee: "user-1" };
159
+ renderPayload(primitivesWithoutJsonView, unrenderedJsonbField(value));
160
+ expect(capturedJsonView).toBeUndefined();
161
+ expect(capturedText?.children).toBe(JSON.stringify(value));
162
+ expect(capturedText?.variant).toBe("code");
163
+ });
164
+ });