@cosmicdrift/kumiko-renderer 0.241.0 → 0.242.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.241.0",
3
+ "version": "0.242.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.241.0",
19
- "@cosmicdrift/kumiko-headless": "0.241.0",
18
+ "@cosmicdrift/kumiko-framework": "0.242.0",
19
+ "@cosmicdrift/kumiko-headless": "0.242.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.241.0"
30
+ "@cosmicdrift/kumiko-locale-de": "0.242.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -3,7 +3,7 @@ import type { RowAction } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import type { Dispatcher, EditRelatedListSectionViewModel } from "@cosmicdrift/kumiko-headless";
4
4
  import { render, screen as rtlScreen, waitFor } from "@testing-library/react";
5
5
  import type { ComponentType, ReactNode } from "react";
6
- import { NavProvider } from "../../app/nav";
6
+ import { type NavApi, NavProvider } from "../../app/nav";
7
7
  import { DispatcherProvider } from "../../context/dispatcher-context";
8
8
  import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
9
9
  import { kumikoDefaultTranslations } from "../../i18n-defaults";
@@ -26,22 +26,27 @@ const testSection: ComponentType<SectionProps> = ({ testId, children }) => (
26
26
  // wired straight to the action's onTrigger — enough to prove
27
27
  // RelatedListSection wires rowActions through to a real dispatch, without
28
28
  // needing the production DataTable's sorting/paging/kebab-menu chrome.
29
+ // The isVisible filter mirrors the production DataTable's own row-action
30
+ // filter (renderer-web primitives/index.tsx) so per-row gating is exercised
31
+ // here rather than assumed.
29
32
  const testDataTable: ComponentType<DataTableProps> = ({ rows, rowActions }) => (
30
33
  <table>
31
34
  <tbody>
32
35
  {rows.map((row) => (
33
36
  <tr key={row.id} data-testid={`row-${row.id}`}>
34
37
  <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
- ))}
38
+ {(rowActions ?? [])
39
+ .filter((action) => action.isVisible === undefined || action.isVisible(row))
40
+ .map((action) => (
41
+ <button
42
+ key={action.id}
43
+ type="button"
44
+ data-testid={`action-${action.id}-${row.id}`}
45
+ onClick={() => void action.onTrigger(row)}
46
+ >
47
+ {action.label}
48
+ </button>
49
+ ))}
45
50
  </td>
46
51
  </tr>
47
52
  ))}
@@ -74,7 +79,7 @@ function testPrimitives(): CorePrimitives {
74
79
  } as unknown as CorePrimitives;
75
80
  }
76
81
 
77
- function stubDispatcher(): {
82
+ function stubDispatcher(rows: readonly Record<string, unknown>[] = [{ id: "r1", name: "Alice" }]): {
78
83
  dispatcher: Dispatcher;
79
84
  writes: Array<{ type: string; payload: unknown }>;
80
85
  queryCount: () => number;
@@ -88,10 +93,7 @@ function stubDispatcher(): {
88
93
  }) as Dispatcher["write"],
89
94
  query: (async () => {
90
95
  queryCalls += 1;
91
- return {
92
- isSuccess: true,
93
- data: { rows: [{ id: "r1", name: "Alice" }], nextCursor: null },
94
- };
96
+ return { isSuccess: true, data: { rows, nextCursor: null } };
95
97
  }) as Dispatcher["query"],
96
98
  batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
97
99
  statusStore: {
@@ -105,6 +107,28 @@ function stubDispatcher(): {
105
107
  return { dispatcher, writes, queryCount: () => queryCalls };
106
108
  }
107
109
 
110
+ function stubNav(): {
111
+ nav: NavApi;
112
+ navigations: unknown[];
113
+ searchParams: Array<Record<string, string | null>>;
114
+ } {
115
+ const navigations: unknown[] = [];
116
+ const searchParams: Array<Record<string, string | null>> = [];
117
+ const nav: NavApi = {
118
+ route: undefined,
119
+ navigate: (target: unknown) => {
120
+ navigations.push(target);
121
+ },
122
+ replace: noop,
123
+ hrefFor: () => "#",
124
+ searchParams: {},
125
+ setSearchParams: (params: Record<string, string | null>) => {
126
+ searchParams.push(params);
127
+ },
128
+ } as unknown as NavApi;
129
+ return { nav, navigations, searchParams };
130
+ }
131
+
108
132
  const rowActions: readonly RowAction[] = [
109
133
  {
110
134
  id: "resend",
@@ -122,7 +146,11 @@ const historySection: EditRelatedListSectionViewModel = {
122
146
  rowActions,
123
147
  };
124
148
 
125
- function renderRelatedList(dispatcher: Dispatcher) {
149
+ function renderRelatedList(
150
+ dispatcher: Dispatcher,
151
+ section: EditRelatedListSectionViewModel = historySection,
152
+ nav: NavApi = stubNav().nav,
153
+ ) {
126
154
  return render(
127
155
  <LocaleProvider
128
156
  resolver={createStaticLocaleResolver({ locale: "en-US" })}
@@ -130,17 +158,8 @@ function renderRelatedList(dispatcher: Dispatcher) {
130
158
  >
131
159
  <DispatcherProvider dispatcher={dispatcher}>
132
160
  <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" />
161
+ <NavProvider value={nav}>
162
+ <RelatedListSection section={section} parentId="order-1" featureName="orders" />
144
163
  </NavProvider>
145
164
  </PrimitivesProvider>
146
165
  </DispatcherProvider>
@@ -160,4 +179,61 @@ describe("RelatedListSection — rowActions", () => {
160
179
  expect(writes[0]).toEqual({ type: "orders:write:resend", payload: { id: "r1" } });
161
180
  await waitFor(() => expect(queryCount()).toBe(2));
162
181
  });
182
+
183
+ test("a navigate row action targets its screen and carries the clicked row's own values as search params", async () => {
184
+ const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
185
+ const { nav, navigations, searchParams } = stubNav();
186
+ renderRelatedList(
187
+ dispatcher,
188
+ {
189
+ kind: "relatedList",
190
+ title: "Positions",
191
+ query: "lease:query:items:list",
192
+ columns: [{ field: "name" }],
193
+ rowActions: [
194
+ {
195
+ kind: "navigate",
196
+ id: "adjust-rent",
197
+ label: "actions.adjustRent",
198
+ screen: "adjust-rent-form",
199
+ params: { map: { itemId: "id", currentAmount: "amount" } },
200
+ },
201
+ ],
202
+ },
203
+ nav,
204
+ );
205
+
206
+ await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
207
+ rtlScreen.getByTestId("action-adjust-rent-item-7").click();
208
+
209
+ await waitFor(() => expect(navigations).toHaveLength(1));
210
+ expect(navigations[0]).toEqual({ screenId: "adjust-rent-form" });
211
+ expect(searchParams).toEqual([{ itemId: "item-7", currentAmount: "1200" }]);
212
+ });
213
+
214
+ test("a row action with a visible condition renders only on the rows that satisfy it", async () => {
215
+ const { dispatcher } = stubDispatcher([
216
+ { id: "active-1", name: "Running", status: "active" },
217
+ { id: "ended-1", name: "Closed", status: "ended" },
218
+ ]);
219
+ renderRelatedList(dispatcher, {
220
+ kind: "relatedList",
221
+ title: "Positions",
222
+ query: "lease:query:items:list",
223
+ columns: [{ field: "name" }],
224
+ rowActions: [
225
+ {
226
+ id: "end-item",
227
+ label: "actions.endItem",
228
+ handler: "lease:write:end-item",
229
+ payload: { pick: ["id"] },
230
+ visible: { field: "status", eq: "active" },
231
+ },
232
+ ],
233
+ });
234
+
235
+ await waitFor(() => expect(rtlScreen.getByTestId("row-ended-1")).toBeTruthy());
236
+ expect(rtlScreen.getByTestId("action-end-item-active-1")).toBeTruthy();
237
+ expect(rtlScreen.queryByTestId("action-end-item-ended-1")).toBeNull();
238
+ });
163
239
  });
@@ -448,8 +448,9 @@ export type InputProps =
448
448
  readonly name: string;
449
449
  readonly value: string;
450
450
  readonly onChange: (v: string) => void;
451
- /** Anzahl sichtbarer Zeilen. Default 4 in der Default-Primitive
452
- * hinreichend für Notes, vertikal-scrollbar drüber. */
451
+ /** Visible rows. Acts as a minimum height — the field starts this tall
452
+ * and still grows with its content. Omitted, the default primitive
453
+ * falls back to its own floor (#2677). */
453
454
  readonly rows?: number;
454
455
  readonly disabled?: boolean;
455
456
  readonly required?: boolean;