@cosmicdrift/kumiko-renderer 0.327.0 → 0.328.1

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.
Files changed (257) hide show
  1. package/dist/app/action-form-shim.d.ts +14 -0
  2. package/{src/app/action-form-shim.ts → dist/app/action-form-shim.js} +26 -44
  3. package/dist/app/app-features-context.d.ts +8 -0
  4. package/dist/app/app-features-context.js +16 -0
  5. package/dist/app/column-renderers.d.ts +30 -0
  6. package/dist/app/column-renderers.js +34 -0
  7. package/dist/app/config-edit-shim.d.ts +11 -0
  8. package/{src/app/config-edit-shim.ts → dist/app/config-edit-shim.js} +12 -25
  9. package/dist/app/content-editors.d.ts +38 -0
  10. package/dist/app/content-editors.js +26 -0
  11. package/dist/app/content-preview.d.ts +11 -0
  12. package/dist/app/content-preview.js +30 -0
  13. package/dist/app/custom-screens.d.ts +11 -0
  14. package/dist/app/custom-screens.js +20 -0
  15. package/dist/app/dashboard-body.d.ts +16 -0
  16. package/dist/app/dashboard-body.js +11 -0
  17. package/dist/app/extension-form-submit.d.ts +32 -0
  18. package/dist/app/extension-form-submit.js +73 -0
  19. package/dist/app/extension-sections.d.ts +72 -0
  20. package/dist/app/extension-sections.js +34 -0
  21. package/dist/app/feature-schema.d.ts +4 -0
  22. package/{src/app/feature-schema.ts → dist/app/feature-schema.js} +12 -22
  23. package/dist/app/form-schema.d.ts +4 -0
  24. package/dist/app/form-schema.js +85 -0
  25. package/dist/app/kumiko-screen.d.ts +43 -0
  26. package/dist/app/kumiko-screen.js +2206 -0
  27. package/dist/app/layout-fields.d.ts +3 -0
  28. package/dist/app/layout-fields.js +20 -0
  29. package/dist/app/list-facets.d.ts +48 -0
  30. package/dist/app/list-facets.js +185 -0
  31. package/dist/app/nav.d.ts +61 -0
  32. package/dist/app/nav.js +139 -0
  33. package/dist/app/projection-detail-shim.d.ts +8 -0
  34. package/dist/app/projection-detail-shim.js +71 -0
  35. package/dist/app/projection-list-shim.d.ts +13 -0
  36. package/{src/app/projection-list-shim.ts → dist/app/projection-list-shim.js} +26 -38
  37. package/dist/app/qn.d.ts +7 -0
  38. package/{src/app/qn.ts → dist/app/qn.js} +9 -11
  39. package/dist/app/qualify-screen-id.d.ts +2 -0
  40. package/{src/app/qualify-screen-id.ts → dist/app/qualify-screen-id.js} +5 -6
  41. package/dist/app/reference-facet-bridge.d.ts +10 -0
  42. package/dist/app/reference-facet-bridge.js +22 -0
  43. package/dist/app/return-to.d.ts +31 -0
  44. package/dist/app/return-to.js +188 -0
  45. package/dist/app/row-actions.d.ts +77 -0
  46. package/dist/app/row-actions.js +416 -0
  47. package/dist/app/screen-access.d.ts +8 -0
  48. package/dist/app/screen-access.js +48 -0
  49. package/dist/app/secret-mint-body.d.ts +10 -0
  50. package/dist/app/secret-mint-body.js +140 -0
  51. package/dist/app/secrets-edit-body.d.ts +8 -0
  52. package/dist/app/secrets-edit-body.js +76 -0
  53. package/dist/app/use-embedded-screen.d.ts +6 -0
  54. package/dist/app/use-embedded-screen.js +19 -0
  55. package/dist/app/variable-chips.d.ts +7 -0
  56. package/dist/app/variable-chips.js +16 -0
  57. package/dist/app/write-failed-error.d.ts +6 -0
  58. package/dist/app/write-failed-error.js +21 -0
  59. package/dist/components/embedded-list-field.d.ts +10 -0
  60. package/dist/components/embedded-list-field.js +259 -0
  61. package/dist/components/grid-cell-for-field.d.ts +25 -0
  62. package/dist/components/grid-cell-for-field.js +31 -0
  63. package/dist/components/multi-select-checkboxes.d.ts +14 -0
  64. package/dist/components/multi-select-checkboxes.js +36 -0
  65. package/dist/components/reference-create-dialog.d.ts +13 -0
  66. package/dist/components/reference-create-dialog.js +46 -0
  67. package/dist/components/related-list-section.d.ts +25 -0
  68. package/dist/components/related-list-section.js +273 -0
  69. package/dist/components/render-edit-action-button.d.ts +22 -0
  70. package/dist/components/render-edit-action-button.js +43 -0
  71. package/dist/components/render-edit-logic.d.ts +6 -0
  72. package/dist/components/render-edit-logic.js +63 -0
  73. package/dist/components/render-edit-types.d.ts +209 -0
  74. package/dist/components/render-edit-types.js +1 -0
  75. package/dist/components/render-edit.d.ts +6 -0
  76. package/dist/components/render-edit.js +1169 -0
  77. package/dist/components/render-field.d.ts +38 -0
  78. package/dist/components/render-field.js +544 -0
  79. package/dist/components/render-list.d.ts +117 -0
  80. package/dist/components/render-list.js +232 -0
  81. package/dist/components/write-form-section.d.ts +17 -0
  82. package/dist/components/write-form-section.js +104 -0
  83. package/dist/context/dispatcher-context.d.ts +10 -0
  84. package/{src/context/dispatcher-context.tsx → dist/context/dispatcher-context.js} +16 -29
  85. package/dist/context/draft-storage-context.d.ts +12 -0
  86. package/dist/context/draft-storage-context.js +18 -0
  87. package/dist/context/user-roles-context.d.ts +7 -0
  88. package/{src/context/user-roles-context.tsx → dist/context/user-roles-context.js} +7 -15
  89. package/dist/format-when.d.ts +1 -0
  90. package/{src/format-when.ts → dist/format-when.js} +7 -7
  91. package/dist/hooks/reference-limits.d.ts +3 -0
  92. package/{src/hooks/reference-limits.ts → dist/hooks/reference-limits.js} +0 -1
  93. package/dist/hooks/use-ai-text.d.ts +52 -0
  94. package/dist/hooks/use-ai-text.js +90 -0
  95. package/dist/hooks/use-disclosure.d.ts +7 -0
  96. package/dist/hooks/use-disclosure.js +8 -0
  97. package/dist/hooks/use-form.d.ts +7 -0
  98. package/dist/hooks/use-form.js +36 -0
  99. package/dist/hooks/use-list-url-state.d.ts +43 -0
  100. package/dist/hooks/use-list-url-state.js +131 -0
  101. package/dist/hooks/use-mutation.d.ts +9 -0
  102. package/dist/hooks/use-mutation.js +37 -0
  103. package/dist/hooks/use-query.d.ts +14 -0
  104. package/dist/hooks/use-query.js +118 -0
  105. package/dist/hooks/use-reference-lookup.d.ts +14 -0
  106. package/dist/hooks/use-reference-lookup.js +63 -0
  107. package/dist/hooks/use-store.d.ts +3 -0
  108. package/dist/hooks/use-store.js +38 -0
  109. package/dist/hooks/use-stream-handler.d.ts +14 -0
  110. package/dist/hooks/use-stream-handler.js +93 -0
  111. package/dist/i18n-defaults.d.ts +2 -0
  112. package/dist/i18n-defaults.js +201 -0
  113. package/dist/i18n.d.ts +64 -0
  114. package/dist/i18n.js +166 -0
  115. package/dist/index.d.ts +77 -0
  116. package/dist/index.js +55 -0
  117. package/dist/inside-drawer.d.ts +2 -0
  118. package/dist/inside-drawer.js +9 -0
  119. package/dist/page-header-slot.d.ts +2 -0
  120. package/dist/page-header-slot.js +9 -0
  121. package/dist/primitives.d.ts +1351 -0
  122. package/dist/primitives.js +73 -0
  123. package/dist/screen-fills-height.d.ts +3 -0
  124. package/dist/screen-fills-height.js +3 -0
  125. package/dist/share-links.d.ts +4 -0
  126. package/dist/share-links.js +11 -0
  127. package/dist/sort-by-accessor.d.ts +5 -0
  128. package/dist/sort-by-accessor.js +16 -0
  129. package/dist/sse/live-events.d.ts +23 -0
  130. package/dist/sse/live-events.js +28 -0
  131. package/dist/tokens.d.ts +83 -0
  132. package/dist/tokens.js +80 -0
  133. package/package.json +16 -9
  134. package/src/changes.json +52 -0
  135. package/src/__tests__/error-i18n-defaults.test.ts +0 -82
  136. package/src/__tests__/format-when.test.ts +0 -12
  137. package/src/__tests__/i18n.test.tsx +0 -283
  138. package/src/__tests__/merge-search-params-into-initial.test.ts +0 -525
  139. package/src/__tests__/qn.test.ts +0 -83
  140. package/src/__tests__/share-links.test.ts +0 -26
  141. package/src/__tests__/sort-by-accessor.test.ts +0 -48
  142. package/src/__tests__/stringify-nav-params.test.ts +0 -33
  143. package/src/__tests__/use-list-url-state.test.tsx +0 -250
  144. package/src/app/__tests__/action-form-field-labels.test.tsx +0 -92
  145. package/src/app/__tests__/action-form-shim.test.ts +0 -137
  146. package/src/app/__tests__/build-list-query-payload.test.ts +0 -107
  147. package/src/app/__tests__/config-edit-shim.test.ts +0 -57
  148. package/src/app/__tests__/content-editors.test.tsx +0 -128
  149. package/src/app/__tests__/content-preview.test.tsx +0 -184
  150. package/src/app/__tests__/entity-edit-create-search-params.test.tsx +0 -236
  151. package/src/app/__tests__/entity-edit-jsonb-embedded-submit.test.tsx +0 -239
  152. package/src/app/__tests__/entity-list-create-singleton.test.tsx +0 -128
  153. package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +0 -424
  154. package/src/app/__tests__/entity-list-row-action-icon.test.tsx +0 -248
  155. package/src/app/__tests__/entity-list-row-action-kumiko-actions-view.test.tsx +0 -162
  156. package/src/app/__tests__/entity-list-row-action-refetch.test.tsx +0 -401
  157. package/src/app/__tests__/entity-list-screen-padding.test.tsx +0 -111
  158. package/src/app/__tests__/feature-schema.test.ts +0 -40
  159. package/src/app/__tests__/form-schema.test.ts +0 -247
  160. package/src/app/__tests__/list-daterange-facet.test.ts +0 -165
  161. package/src/app/__tests__/list-filter-facets.test.tsx +0 -521
  162. package/src/app/__tests__/nav.test.ts +0 -198
  163. package/src/app/__tests__/projection-detail-actions.test.tsx +0 -719
  164. package/src/app/__tests__/projection-detail-reference-field.test.tsx +0 -165
  165. package/src/app/__tests__/projection-detail-shim.test.ts +0 -188
  166. package/src/app/__tests__/projection-detail-singleton.test.tsx +0 -191
  167. package/src/app/__tests__/projection-list-bad-shape.test.tsx +0 -152
  168. package/src/app/__tests__/projection-list-reference-column.test.tsx +0 -219
  169. package/src/app/__tests__/projection-list-search-sort.test.tsx +0 -212
  170. package/src/app/__tests__/qualified-names.test.ts +0 -37
  171. package/src/app/__tests__/return-to.test.ts +0 -396
  172. package/src/app/__tests__/row-actions-navigate-tab.test.ts +0 -223
  173. package/src/app/__tests__/row-default-edit-action.test.tsx +0 -324
  174. package/src/app/__tests__/screen-access-allows.test.ts +0 -34
  175. package/src/app/__tests__/secret-mint-body.test.tsx +0 -404
  176. package/src/app/__tests__/variable-chips.test.tsx +0 -70
  177. package/src/app/app-features-context.tsx +0 -25
  178. package/src/app/column-renderers.tsx +0 -68
  179. package/src/app/content-editors.tsx +0 -85
  180. package/src/app/content-preview.tsx +0 -67
  181. package/src/app/custom-screens.tsx +0 -29
  182. package/src/app/dashboard-body.tsx +0 -33
  183. package/src/app/extension-form-submit.tsx +0 -123
  184. package/src/app/extension-sections.tsx +0 -111
  185. package/src/app/form-schema.ts +0 -91
  186. package/src/app/kumiko-screen.tsx +0 -3614
  187. package/src/app/layout-fields.ts +0 -31
  188. package/src/app/list-facets.ts +0 -259
  189. package/src/app/nav.tsx +0 -258
  190. package/src/app/projection-detail-shim.ts +0 -85
  191. package/src/app/reference-facet-bridge.tsx +0 -58
  192. package/src/app/return-to.tsx +0 -234
  193. package/src/app/row-actions.ts +0 -639
  194. package/src/app/screen-access.ts +0 -63
  195. package/src/app/secret-mint-body.tsx +0 -259
  196. package/src/app/secrets-edit-body.tsx +0 -194
  197. package/src/app/use-embedded-screen.ts +0 -27
  198. package/src/app/variable-chips.tsx +0 -39
  199. package/src/app/write-failed-error.ts +0 -27
  200. package/src/components/__tests__/embedded-list-field.test.tsx +0 -531
  201. package/src/components/__tests__/multi-select-checkboxes.test.tsx +0 -162
  202. package/src/components/__tests__/related-list-section-empty-state.test.tsx +0 -143
  203. package/src/components/__tests__/related-list-section.test.tsx +0 -1669
  204. package/src/components/__tests__/render-edit-action-button.test.tsx +0 -205
  205. package/src/components/__tests__/render-edit-hidden-section.test.tsx +0 -128
  206. package/src/components/__tests__/render-edit-logic.test.ts +0 -314
  207. package/src/components/__tests__/render-edit-screen-description.test.tsx +0 -152
  208. package/src/components/__tests__/render-edit-submit-actions.test.tsx +0 -460
  209. package/src/components/__tests__/render-field-app-locale.test.tsx +0 -142
  210. package/src/components/__tests__/render-field-enum-option.test.tsx +0 -99
  211. package/src/components/__tests__/render-field-icon-derivation.test.tsx +0 -119
  212. package/src/components/__tests__/render-field-json-format.test.tsx +0 -164
  213. package/src/components/__tests__/render-field-money-embedded-list-consistency.test.tsx +0 -212
  214. package/src/components/__tests__/render-field-money-roundtrip.test.tsx +0 -217
  215. package/src/components/__tests__/render-field-number-grouping.test.tsx +0 -76
  216. package/src/components/__tests__/render-field-number-unit.test.tsx +0 -227
  217. package/src/components/__tests__/render-field-password-format.test.tsx +0 -101
  218. package/src/components/__tests__/render-field-select-display.test.tsx +0 -134
  219. package/src/components/__tests__/render-field-unit-format.test.tsx +0 -107
  220. package/src/components/__tests__/render-field-unsupported-types.test.tsx +0 -195
  221. package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +0 -146
  222. package/src/components/__tests__/write-form-section.test.tsx +0 -375
  223. package/src/components/embedded-list-field.tsx +0 -354
  224. package/src/components/grid-cell-for-field.tsx +0 -60
  225. package/src/components/multi-select-checkboxes.tsx +0 -93
  226. package/src/components/reference-create-dialog.tsx +0 -91
  227. package/src/components/related-list-section.tsx +0 -467
  228. package/src/components/render-edit-action-button.tsx +0 -106
  229. package/src/components/render-edit-logic.ts +0 -85
  230. package/src/components/render-edit-types.ts +0 -216
  231. package/src/components/render-edit.tsx +0 -1700
  232. package/src/components/render-field.tsx +0 -942
  233. package/src/components/render-list.tsx +0 -561
  234. package/src/components/write-form-section.tsx +0 -199
  235. package/src/context/draft-storage-context.tsx +0 -39
  236. package/src/hooks/__tests__/use-ai-text.test.tsx +0 -177
  237. package/src/hooks/__tests__/use-disclosure.test.tsx +0 -25
  238. package/src/hooks/__tests__/use-form.test.tsx +0 -65
  239. package/src/hooks/__tests__/use-mutation.test.tsx +0 -77
  240. package/src/hooks/__tests__/use-stream-handler.test.tsx +0 -107
  241. package/src/hooks/use-ai-text.ts +0 -172
  242. package/src/hooks/use-disclosure.ts +0 -20
  243. package/src/hooks/use-form.ts +0 -78
  244. package/src/hooks/use-list-url-state.ts +0 -171
  245. package/src/hooks/use-mutation.ts +0 -61
  246. package/src/hooks/use-query.ts +0 -132
  247. package/src/hooks/use-reference-lookup.ts +0 -76
  248. package/src/hooks/use-store.ts +0 -48
  249. package/src/hooks/use-stream-handler.ts +0 -134
  250. package/src/i18n-defaults.ts +0 -220
  251. package/src/i18n.tsx +0 -286
  252. package/src/index.ts +0 -272
  253. package/src/primitives.tsx +0 -1446
  254. package/src/share-links.ts +0 -12
  255. package/src/sort-by-accessor.ts +0 -20
  256. package/src/sse/live-events.tsx +0 -56
  257. package/src/tokens.tsx +0 -170
@@ -1,1669 +0,0 @@
1
- import { beforeEach, describe, expect, spyOn, test } from "bun:test";
2
- import type { EntityEditScreenDefinition, RowAction } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import type { Dispatcher, EditRelatedListSectionViewModel } from "@cosmicdrift/kumiko-headless";
4
- import { act, fireEvent, render, screen as rtlScreen, waitFor } from "@testing-library/react";
5
- import type { ComponentType, ReactNode } from "react";
6
- import { AppFeaturesProvider } from "../../app/app-features-context";
7
- import type { FeatureSchema } from "../../app/feature-schema";
8
- import { type NavApi, NavProvider } from "../../app/nav";
9
- import { resetDrawerActionWarningsForTests } from "../../app/row-actions";
10
- import { DispatcherProvider } from "../../context/dispatcher-context";
11
- import { UserRolesProvider } from "../../context/user-roles-context";
12
- import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
13
- import { kumikoDefaultTranslations } from "../../i18n-defaults";
14
- import {
15
- type CorePrimitives,
16
- type DataTableProps,
17
- type InputProps,
18
- PrimitivesProvider,
19
- type SectionProps,
20
- } from "../../primitives";
21
- import { RelatedListSection } from "../related-list-section";
22
-
23
- const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
24
- const noop = () => {};
25
-
26
- const testSection: ComponentType<SectionProps> = ({ testId, children }) => (
27
- <div data-testid={testId}>{children}</div>
28
- );
29
-
30
- // A minimal DataTable stub: renders one button per row per row-action,
31
- // wired straight to the action's onTrigger — enough to prove
32
- // RelatedListSection wires rowActions through to a real dispatch, without
33
- // needing the production DataTable's sorting/paging/kebab-menu chrome.
34
- // The isVisible filter mirrors the production DataTable's own row-action
35
- // filter (renderer-web primitives/index.tsx) so per-row gating is exercised
36
- // here rather than assumed.
37
- const testDataTable: ComponentType<DataTableProps> = ({ rows, rowActions }) => (
38
- <table>
39
- <tbody>
40
- {rows.map((row) => (
41
- <tr key={row.id} data-testid={`row-${row.id}`}>
42
- <td>
43
- {(rowActions ?? [])
44
- .filter((action) => action.isVisible === undefined || action.isVisible(row))
45
- .map((action) => (
46
- <button
47
- key={action.id}
48
- type="button"
49
- data-testid={`action-${action.id}-${row.id}`}
50
- onClick={() => void action.onTrigger(row)}
51
- >
52
- {action.label}
53
- </button>
54
- ))}
55
- </td>
56
- </tr>
57
- ))}
58
- </tbody>
59
- </table>
60
- );
61
-
62
- const testInput = (props: InputProps) =>
63
- props.kind === "text" ? (
64
- <input
65
- data-testid={props.id}
66
- value={props.value}
67
- onChange={(e) => props.onChange(e.target.value)}
68
- />
69
- ) : null;
70
-
71
- function testPrimitives(): CorePrimitives {
72
- return {
73
- Button: noop,
74
- Banner: ({ children, testId }: { children?: ReactNode; testId?: string }) => (
75
- <div data-testid={testId}>{children}</div>
76
- ),
77
- Field: passChildren,
78
- Input: testInput,
79
- DataTable: testDataTable,
80
- Form: noop,
81
- Section: testSection,
82
- Card: passChildren,
83
- Grid: passChildren,
84
- GridCell: passChildren,
85
- Text: noop,
86
- Heading: noop,
87
- Dialog: noop,
88
- Modal: noop,
89
- Lightbox: noop,
90
- ConfigSourceBadge: noop,
91
- ConfigCascadeView: noop,
92
- Link: noop,
93
- } as unknown as CorePrimitives;
94
- }
95
-
96
- function stubDispatcher(
97
- rows: readonly Record<string, unknown>[] = [{ id: "r1", name: "Alice" }],
98
- nextCursor: string | null = null,
99
- ): {
100
- dispatcher: Dispatcher;
101
- writes: Array<{ type: string; payload: unknown }>;
102
- queryCount: () => number;
103
- } {
104
- const writes: Array<{ type: string; payload: unknown }> = [];
105
- let queryCalls = 0;
106
- const dispatcher: Dispatcher = {
107
- write: (async (type, payload) => {
108
- writes.push({ type, payload });
109
- return { isSuccess: true, data: null };
110
- }) as Dispatcher["write"],
111
- query: (async () => {
112
- queryCalls += 1;
113
- return { isSuccess: true, data: { rows, nextCursor } };
114
- }) as Dispatcher["query"],
115
- batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
116
- statusStore: {
117
- getState: () => "online",
118
- subscribe: () => () => {},
119
- } as unknown as Dispatcher["statusStore"],
120
- async *stream() {},
121
- pendingWrites: () => [],
122
- pendingFiles: () => [],
123
- };
124
- return { dispatcher, writes, queryCount: () => queryCalls };
125
- }
126
-
127
- function stubNav(): {
128
- nav: NavApi;
129
- navigations: unknown[];
130
- searchParams: Array<Record<string, string | null>>;
131
- } {
132
- const navigations: unknown[] = [];
133
- const searchParams: Array<Record<string, string | null>> = [];
134
- const nav: NavApi = {
135
- route: undefined,
136
- navigate: (target: unknown) => {
137
- navigations.push(target);
138
- },
139
- replace: noop,
140
- hrefFor: () => "#",
141
- searchParams: {},
142
- setSearchParams: (params: Record<string, string | null>) => {
143
- searchParams.push(params);
144
- },
145
- } as unknown as NavApi;
146
- return { nav, navigations, searchParams };
147
- }
148
-
149
- const rowActions: readonly RowAction[] = [
150
- {
151
- id: "resend",
152
- label: "actions.resend",
153
- handler: "orders:write:resend",
154
- payload: { pick: ["id"] },
155
- },
156
- ];
157
-
158
- const historySection: EditRelatedListSectionViewModel = {
159
- kind: "relatedList",
160
- title: "History",
161
- query: "orders:query:notifications:list",
162
- columns: [{ field: "name" }],
163
- rowActions,
164
- };
165
-
166
- function renderRelatedList(
167
- dispatcher: Dispatcher,
168
- section: EditRelatedListSectionViewModel = historySection,
169
- nav: NavApi = stubNav().nav,
170
- ) {
171
- return render(
172
- <LocaleProvider
173
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
174
- fallbackBundles={[kumikoDefaultTranslations]}
175
- >
176
- <DispatcherProvider dispatcher={dispatcher}>
177
- <PrimitivesProvider value={testPrimitives()}>
178
- <NavProvider value={nav}>
179
- <RelatedListSection
180
- section={section}
181
- parentId="order-1"
182
- record={{ id: "order-1" }}
183
- featureName="orders"
184
- />
185
- </NavProvider>
186
- </PrimitivesProvider>
187
- </DispatcherProvider>
188
- </LocaleProvider>,
189
- );
190
- }
191
-
192
- describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
193
- test("hideTitle (tabs mode) renders the list without a Section wrapper, drops the table frame (the tab card frames it), and marks scrollBody", async () => {
194
- const { dispatcher } = stubDispatcher();
195
- let capturedChromeless: boolean | undefined;
196
- let capturedScrollBody: boolean | undefined;
197
- const capturingDataTable: ComponentType<DataTableProps> = (props) => {
198
- capturedChromeless = props.chromeless;
199
- capturedScrollBody = props.scrollBody;
200
- return testDataTable(props);
201
- };
202
- render(
203
- <LocaleProvider
204
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
205
- fallbackBundles={[kumikoDefaultTranslations]}
206
- >
207
- <DispatcherProvider dispatcher={dispatcher}>
208
- <PrimitivesProvider value={{ ...testPrimitives(), DataTable: capturingDataTable }}>
209
- <NavProvider value={stubNav().nav}>
210
- <RelatedListSection
211
- section={historySection}
212
- parentId="order-1"
213
- record={{ id: "order-1" }}
214
- featureName="orders"
215
- hideTitle
216
- />
217
- </NavProvider>
218
- </PrimitivesProvider>
219
- </DispatcherProvider>
220
- </LocaleProvider>,
221
- );
222
-
223
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
224
- expect(rtlScreen.queryByTestId(`related-list-${historySection.title}`)).toBeNull();
225
- expect(capturedChromeless).toBe(true);
226
- expect(capturedScrollBody).toBe(true);
227
- });
228
-
229
- test("without hideTitle (stacked mode), the same section keeps its Section wrapper and an un-chromeless, unbounded-height table", async () => {
230
- const { dispatcher } = stubDispatcher();
231
- let capturedChromeless: boolean | undefined;
232
- let capturedScrollBody: boolean | undefined;
233
- const capturingDataTable: ComponentType<DataTableProps> = (props) => {
234
- capturedChromeless = props.chromeless;
235
- capturedScrollBody = props.scrollBody;
236
- return testDataTable(props);
237
- };
238
- render(
239
- <LocaleProvider
240
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
241
- fallbackBundles={[kumikoDefaultTranslations]}
242
- >
243
- <DispatcherProvider dispatcher={dispatcher}>
244
- <PrimitivesProvider value={{ ...testPrimitives(), DataTable: capturingDataTable }}>
245
- <NavProvider value={stubNav().nav}>
246
- <RelatedListSection
247
- section={historySection}
248
- parentId="order-1"
249
- record={{ id: "order-1" }}
250
- featureName="orders"
251
- />
252
- </NavProvider>
253
- </PrimitivesProvider>
254
- </DispatcherProvider>
255
- </LocaleProvider>,
256
- );
257
-
258
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
259
- expect(rtlScreen.getByTestId(`related-list-${historySection.title}`)).toBeTruthy();
260
- expect(capturedChromeless).toBeUndefined();
261
- expect(capturedScrollBody).toBeUndefined();
262
- });
263
- });
264
-
265
- describe("RelatedListSection — list-screen toolbar in tabs mode", () => {
266
- test("hideTitle (tabs mode) still surfaces search, facets and a toolbarActions button", async () => {
267
- const { dispatcher } = stubDispatcher();
268
- let capturedFilterFacets: DataTableProps["filterFacets"];
269
- const capturingDataTable: ComponentType<DataTableProps> = (props) => {
270
- capturedFilterFacets = props.filterFacets;
271
- return (
272
- <>
273
- {props.toolbarEnd}
274
- {testDataTable(props)}
275
- </>
276
- );
277
- };
278
- const testButton = ({
279
- children,
280
- testId,
281
- onClick,
282
- }: {
283
- readonly children?: ReactNode;
284
- readonly testId?: string;
285
- readonly onClick?: () => void;
286
- }) => (
287
- <button type="button" data-testid={testId} onClick={() => onClick?.()}>
288
- {children}
289
- </button>
290
- );
291
- const section: EditRelatedListSectionViewModel = {
292
- ...historySection,
293
- searchable: true,
294
- facets: [
295
- {
296
- field: "name",
297
- type: "select",
298
- label: "Name",
299
- options: [{ value: "Alice", label: "Alice" }],
300
- },
301
- ],
302
- toolbarActions: [
303
- {
304
- kind: "navigate",
305
- id: "create",
306
- label: "New notification",
307
- screen: "notification-create",
308
- },
309
- ],
310
- };
311
- render(
312
- <LocaleProvider
313
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
314
- fallbackBundles={[kumikoDefaultTranslations]}
315
- >
316
- <DispatcherProvider dispatcher={dispatcher}>
317
- <PrimitivesProvider
318
- value={{ ...testPrimitives(), DataTable: capturingDataTable, Button: testButton }}
319
- >
320
- <NavProvider value={stubNav().nav}>
321
- <RelatedListSection
322
- section={section}
323
- parentId="order-1"
324
- record={{ id: "order-1" }}
325
- featureName="orders"
326
- hideTitle
327
- />
328
- </NavProvider>
329
- </PrimitivesProvider>
330
- </DispatcherProvider>
331
- </LocaleProvider>,
332
- );
333
-
334
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
335
- expect(capturedFilterFacets?.length).toBe(1);
336
- expect(rtlScreen.getByTestId("render-list-toolbar-action-create")).toBeTruthy();
337
- });
338
-
339
- test("clicking a toolbarAction navigates to its screen and prefills the parent id under parentParam", async () => {
340
- const { dispatcher } = stubDispatcher();
341
- const { nav, navigations, searchParams } = stubNav();
342
- const capturingDataTable: ComponentType<DataTableProps> = (props) => (
343
- <>
344
- {props.toolbarEnd}
345
- {testDataTable(props)}
346
- </>
347
- );
348
- const testButton = ({
349
- children,
350
- testId,
351
- onClick,
352
- }: {
353
- readonly children?: ReactNode;
354
- readonly testId?: string;
355
- readonly onClick?: () => void;
356
- }) => (
357
- <button type="button" data-testid={testId} onClick={() => onClick?.()}>
358
- {children}
359
- </button>
360
- );
361
- const section: EditRelatedListSectionViewModel = {
362
- ...historySection,
363
- parentParam: "leaseId",
364
- toolbarActions: [
365
- { kind: "navigate", id: "create", label: "Add position", screen: "position-create" },
366
- ],
367
- };
368
- render(
369
- <LocaleProvider
370
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
371
- fallbackBundles={[kumikoDefaultTranslations]}
372
- >
373
- <DispatcherProvider dispatcher={dispatcher}>
374
- <PrimitivesProvider
375
- value={{ ...testPrimitives(), DataTable: capturingDataTable, Button: testButton }}
376
- >
377
- <NavProvider value={nav}>
378
- <RelatedListSection
379
- section={section}
380
- parentId="lease-9"
381
- record={{ id: "lease-9" }}
382
- featureName="leases"
383
- />
384
- </NavProvider>
385
- </PrimitivesProvider>
386
- </DispatcherProvider>
387
- </LocaleProvider>,
388
- );
389
-
390
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
391
- rtlScreen.getByTestId("render-list-toolbar-action-create").click();
392
-
393
- await waitFor(() => expect(navigations).toHaveLength(1));
394
- expect(navigations[0]).toEqual({ screenId: "position-create" });
395
- expect(searchParams).toEqual([{ leaseId: "lease-9" }]);
396
- });
397
-
398
- test("a toolbarAction with a visible condition that the parent record fails is not rendered", async () => {
399
- const { dispatcher } = stubDispatcher();
400
- const { nav } = stubNav();
401
- const capturingDataTable: ComponentType<DataTableProps> = (props) => (
402
- <>
403
- {props.toolbarEnd}
404
- {testDataTable(props)}
405
- </>
406
- );
407
- const testButton = ({
408
- children,
409
- testId,
410
- }: {
411
- readonly children?: ReactNode;
412
- readonly testId?: string;
413
- }) => (
414
- <button type="button" data-testid={testId}>
415
- {children}
416
- </button>
417
- );
418
- const section: EditRelatedListSectionViewModel = {
419
- ...historySection,
420
- parentParam: "leaseId",
421
- toolbarActions: [
422
- {
423
- kind: "navigate",
424
- id: "create",
425
- label: "Add position",
426
- screen: "position-create",
427
- visible: { field: "status", eq: "active" },
428
- },
429
- ],
430
- };
431
- render(
432
- <LocaleProvider
433
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
434
- fallbackBundles={[kumikoDefaultTranslations]}
435
- >
436
- <DispatcherProvider dispatcher={dispatcher}>
437
- <PrimitivesProvider
438
- value={{ ...testPrimitives(), DataTable: capturingDataTable, Button: testButton }}
439
- >
440
- <NavProvider value={nav}>
441
- <RelatedListSection
442
- section={section}
443
- parentId="lease-9"
444
- record={{ id: "lease-9", status: "closed" }}
445
- featureName="leases"
446
- />
447
- </NavProvider>
448
- </PrimitivesProvider>
449
- </DispatcherProvider>
450
- </LocaleProvider>,
451
- );
452
-
453
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
454
- expect(rtlScreen.queryByTestId("render-list-toolbar-action-create")).toBeNull();
455
- });
456
-
457
- test("a toolbarAction with a visible condition the parent record satisfies is rendered", async () => {
458
- const { dispatcher } = stubDispatcher();
459
- const { nav } = stubNav();
460
- const capturingDataTable: ComponentType<DataTableProps> = (props) => (
461
- <>
462
- {props.toolbarEnd}
463
- {testDataTable(props)}
464
- </>
465
- );
466
- const testButton = ({
467
- children,
468
- testId,
469
- }: {
470
- readonly children?: ReactNode;
471
- readonly testId?: string;
472
- }) => (
473
- <button type="button" data-testid={testId}>
474
- {children}
475
- </button>
476
- );
477
- const section: EditRelatedListSectionViewModel = {
478
- ...historySection,
479
- parentParam: "leaseId",
480
- toolbarActions: [
481
- {
482
- kind: "navigate",
483
- id: "create",
484
- label: "Add position",
485
- screen: "position-create",
486
- visible: { field: "status", eq: "active" },
487
- },
488
- ],
489
- };
490
- render(
491
- <LocaleProvider
492
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
493
- fallbackBundles={[kumikoDefaultTranslations]}
494
- >
495
- <DispatcherProvider dispatcher={dispatcher}>
496
- <PrimitivesProvider
497
- value={{ ...testPrimitives(), DataTable: capturingDataTable, Button: testButton }}
498
- >
499
- <NavProvider value={nav}>
500
- <RelatedListSection
501
- section={section}
502
- parentId="lease-9"
503
- record={{ id: "lease-9", status: "active" }}
504
- featureName="leases"
505
- />
506
- </NavProvider>
507
- </PrimitivesProvider>
508
- </DispatcherProvider>
509
- </LocaleProvider>,
510
- );
511
-
512
- await waitFor(() =>
513
- expect(rtlScreen.getByTestId("render-list-toolbar-action-create")).toBeTruthy(),
514
- );
515
- });
516
-
517
- test("a toolbarAction's params extractor maps the parent record and replaces the implicit parentParam prefill", async () => {
518
- const { dispatcher } = stubDispatcher();
519
- const { nav, navigations, searchParams } = stubNav();
520
- const capturingDataTable: ComponentType<DataTableProps> = (props) => (
521
- <>
522
- {props.toolbarEnd}
523
- {testDataTable(props)}
524
- </>
525
- );
526
- const testButton = ({
527
- children,
528
- testId,
529
- onClick,
530
- }: {
531
- readonly children?: ReactNode;
532
- readonly testId?: string;
533
- readonly onClick?: () => void;
534
- }) => (
535
- <button type="button" data-testid={testId} onClick={() => onClick?.()}>
536
- {children}
537
- </button>
538
- );
539
- const section: EditRelatedListSectionViewModel = {
540
- ...historySection,
541
- // Default parentParam ("id") would prefill `{ id: "lease-9" }` — the
542
- // declared params extractor below must produce `{ leaseId: ... }`
543
- // instead, proving params replaces rather than merges with it.
544
- toolbarActions: [
545
- {
546
- kind: "navigate",
547
- id: "create",
548
- label: "Add position",
549
- screen: "position-create",
550
- params: { map: { leaseId: "id" } },
551
- },
552
- ],
553
- };
554
- render(
555
- <LocaleProvider
556
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
557
- fallbackBundles={[kumikoDefaultTranslations]}
558
- >
559
- <DispatcherProvider dispatcher={dispatcher}>
560
- <PrimitivesProvider
561
- value={{ ...testPrimitives(), DataTable: capturingDataTable, Button: testButton }}
562
- >
563
- <NavProvider value={nav}>
564
- <RelatedListSection
565
- section={section}
566
- parentId="lease-9"
567
- record={{ id: "lease-9" }}
568
- featureName="leases"
569
- />
570
- </NavProvider>
571
- </PrimitivesProvider>
572
- </DispatcherProvider>
573
- </LocaleProvider>,
574
- );
575
-
576
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
577
- rtlScreen.getByTestId("render-list-toolbar-action-create").click();
578
-
579
- await waitFor(() => expect(navigations).toHaveLength(1));
580
- expect(searchParams).toEqual([{ leaseId: "lease-9" }]);
581
- });
582
-
583
- test("a reference facet loads its options from the referenced entity's list query, and selecting one sends an id filter", async () => {
584
- const calls: Array<{ type: string; payload: unknown }> = [];
585
- const dispatcher: Dispatcher = {
586
- write: (async () => ({ isSuccess: true, data: null })) as Dispatcher["write"],
587
- query: (async (type: string, payload: unknown) => {
588
- calls.push({ type, payload });
589
- if (type === "orders:query:tenant:list") {
590
- return {
591
- isSuccess: true,
592
- data: {
593
- rows: [{ id: "t1", name: "Acme" }],
594
- nextCursor: null,
595
- },
596
- };
597
- }
598
- return { isSuccess: true, data: { rows: [{ id: "r1", name: "Alice" }], nextCursor: null } };
599
- }) as Dispatcher["query"],
600
- batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
601
- statusStore: {
602
- getState: () => "online",
603
- subscribe: () => () => {},
604
- } as unknown as Dispatcher["statusStore"],
605
- async *stream() {},
606
- pendingWrites: () => [],
607
- pendingFiles: () => [],
608
- };
609
- let capturedFilterFacets: DataTableProps["filterFacets"];
610
- let capturedOnFilterChange: DataTableProps["onFilterChange"];
611
- const capturingDataTable: ComponentType<DataTableProps> = (props) => {
612
- capturedFilterFacets = props.filterFacets;
613
- capturedOnFilterChange = props.onFilterChange;
614
- return testDataTable(props);
615
- };
616
- const section: EditRelatedListSectionViewModel = {
617
- ...historySection,
618
- facets: [
619
- { field: "name", type: "reference", label: "Tenant", entity: "tenant", labelField: "name" },
620
- ],
621
- };
622
- render(
623
- <LocaleProvider
624
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
625
- fallbackBundles={[kumikoDefaultTranslations]}
626
- >
627
- <DispatcherProvider dispatcher={dispatcher}>
628
- <PrimitivesProvider value={{ ...testPrimitives(), DataTable: capturingDataTable }}>
629
- <NavProvider value={stubNav().nav}>
630
- <RelatedListSection
631
- section={section}
632
- parentId="order-1"
633
- record={{ id: "order-1" }}
634
- featureName="orders"
635
- hideTitle
636
- />
637
- </NavProvider>
638
- </PrimitivesProvider>
639
- </DispatcherProvider>
640
- </LocaleProvider>,
641
- );
642
-
643
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
644
- await waitFor(() => expect(capturedFilterFacets?.[0]?.options).toHaveLength(1));
645
- expect(capturedFilterFacets).toEqual([
646
- { field: "name", label: "Tenant", options: [{ value: "t1", label: "Acme" }] },
647
- ]);
648
-
649
- const countBeforeSelect = calls.length;
650
- await act(async () => {
651
- capturedOnFilterChange?.("name", ["t1"]);
652
- });
653
- await waitFor(() => expect(calls.length).toBeGreaterThan(countBeforeSelect));
654
- const lastListCall = [...calls]
655
- .reverse()
656
- .find((c) => c.type === "orders:query:notifications:list");
657
- expect(lastListCall?.payload).toMatchObject({
658
- filters: [{ field: "name", op: "in", value: ["t1"] }],
659
- });
660
- });
661
- });
662
-
663
- describe("RelatedListSection — rowActions", () => {
664
- test("clicking a row action dispatches through the configured write-handler with the extracted payload, then refetches", async () => {
665
- const { dispatcher, writes, queryCount } = stubDispatcher();
666
- renderRelatedList(dispatcher);
667
-
668
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
669
- rtlScreen.getByTestId("action-resend-r1").click();
670
-
671
- await waitFor(() => expect(writes).toHaveLength(1));
672
- expect(writes[0]).toEqual({ type: "orders:write:resend", payload: { id: "r1" } });
673
- await waitFor(() => expect(queryCount()).toBe(2));
674
- });
675
-
676
- test("a navigate row action targets its screen and carries the clicked row's own values as search params", async () => {
677
- const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
678
- const { nav, navigations, searchParams } = stubNav();
679
- renderRelatedList(
680
- dispatcher,
681
- {
682
- kind: "relatedList",
683
- title: "Positions",
684
- query: "lease:query:items:list",
685
- columns: [{ field: "name" }],
686
- rowActions: [
687
- {
688
- kind: "navigate",
689
- id: "adjust-rent",
690
- label: "actions.adjustRent",
691
- screen: "adjust-rent-form",
692
- params: { map: { itemId: "id", currentAmount: "amount" } },
693
- },
694
- ],
695
- },
696
- nav,
697
- );
698
-
699
- await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
700
- rtlScreen.getByTestId("action-adjust-rent-item-7").click();
701
-
702
- await waitFor(() => expect(navigations).toHaveLength(1));
703
- expect(navigations[0]).toEqual({ screenId: "adjust-rent-form" });
704
- expect(searchParams).toEqual([{ itemId: "item-7", currentAmount: "1200" }]);
705
- });
706
-
707
- test("a row action with a visible condition renders only on the rows that satisfy it", async () => {
708
- const { dispatcher } = stubDispatcher([
709
- { id: "active-1", name: "Running", status: "active" },
710
- { id: "ended-1", name: "Closed", status: "ended" },
711
- ]);
712
- renderRelatedList(dispatcher, {
713
- kind: "relatedList",
714
- title: "Positions",
715
- query: "lease:query:items:list",
716
- columns: [{ field: "name" }],
717
- rowActions: [
718
- {
719
- id: "end-item",
720
- label: "actions.endItem",
721
- handler: "lease:write:end-item",
722
- payload: { pick: ["id"] },
723
- visible: { field: "status", eq: "active" },
724
- },
725
- ],
726
- });
727
-
728
- await waitFor(() => expect(rtlScreen.getByTestId("row-ended-1")).toBeTruthy());
729
- expect(rtlScreen.getByTestId("action-end-item-active-1")).toBeTruthy();
730
- expect(rtlScreen.queryByTestId("action-end-item-ended-1")).toBeNull();
731
- });
732
- });
733
-
734
- // RelatedListSection never renders the Drawer itself (require-cycle with
735
- // kumiko-screen.tsx, which owns the shared Drawer state) — it only forwards
736
- // a drawer-kind rowAction to the injected `onOpenDrawer` callback. This
737
- // pins that hand-off, not the Drawer UI itself (covered by kumiko-screen's
738
- // own tests).
739
- describe("RelatedListSection — rowActions drawer-kind (fw#2710)", () => {
740
- beforeEach(() => {
741
- resetDrawerActionWarningsForTests();
742
- });
743
-
744
- test("clicking a drawer rowAction calls onOpenDrawer with the action and the row's extracted values", async () => {
745
- const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
746
- const openDrawerCalls: unknown[][] = [];
747
- render(
748
- <LocaleProvider
749
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
750
- fallbackBundles={[kumikoDefaultTranslations]}
751
- >
752
- <DispatcherProvider dispatcher={dispatcher}>
753
- <PrimitivesProvider value={testPrimitives()}>
754
- <NavProvider value={stubNav().nav}>
755
- <RelatedListSection
756
- section={{
757
- kind: "relatedList",
758
- title: "Positions",
759
- query: "lease:query:items:list",
760
- columns: [{ field: "name" }],
761
- rowActions: [
762
- {
763
- kind: "drawer",
764
- id: "adjust-rent",
765
- label: "actions.adjustRent",
766
- screen: "adjust-rent-form",
767
- params: { map: { itemId: "id", currentAmount: "amount" } },
768
- },
769
- ],
770
- }}
771
- parentId="order-1"
772
- record={{ id: "order-1" }}
773
- featureName="orders"
774
- onOpenDrawer={(action, initialValues) =>
775
- openDrawerCalls.push([action, initialValues])
776
- }
777
- />
778
- </NavProvider>
779
- </PrimitivesProvider>
780
- </DispatcherProvider>
781
- </LocaleProvider>,
782
- );
783
-
784
- await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
785
- rtlScreen.getByTestId("action-adjust-rent-item-7").click();
786
-
787
- await waitFor(() => expect(openDrawerCalls).toHaveLength(1));
788
- const call = openDrawerCalls[0];
789
- if (call === undefined) throw new Error("expected onOpenDrawer to have been called");
790
- expect((call[0] as { readonly id: string }).id).toBe("adjust-rent");
791
- expect(call[1]).toEqual({ itemId: "item-7", currentAmount: 1200 });
792
- });
793
-
794
- test("a drawer rowAction is dropped (not rendered) when no onOpenDrawer is wired", async () => {
795
- const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
796
- renderRelatedList(dispatcher, {
797
- kind: "relatedList",
798
- title: "Positions",
799
- query: "lease:query:items:list",
800
- columns: [{ field: "name" }],
801
- rowActions: [
802
- {
803
- kind: "drawer",
804
- id: "adjust-rent",
805
- label: "actions.adjustRent",
806
- screen: "adjust-rent-form",
807
- },
808
- ],
809
- });
810
-
811
- await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
812
- expect(rtlScreen.queryByTestId("action-adjust-rent-item-7")).toBeNull();
813
- });
814
-
815
- test("a dropped drawer rowAction without onOpenDrawer logs a dev warning naming the action id (fw#2733)", async () => {
816
- const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
817
- const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
818
- try {
819
- renderRelatedList(dispatcher, {
820
- kind: "relatedList",
821
- title: "Positions",
822
- query: "lease:query:items:list",
823
- columns: [{ field: "name" }],
824
- rowActions: [
825
- {
826
- kind: "drawer",
827
- id: "adjust-rent-warn-only-2733",
828
- label: "actions.adjustRent",
829
- screen: "adjust-rent-form",
830
- },
831
- ],
832
- });
833
-
834
- await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
835
- expect(warnSpy).toHaveBeenCalledTimes(1);
836
- expect(warnSpy.mock.calls[0]?.[0]).toContain("adjust-rent-warn-only-2733");
837
- } finally {
838
- warnSpy.mockRestore();
839
- }
840
- });
841
-
842
- test("a drawer rowAction with onOpenDrawer wired renders without a dev warning (fw#2733)", async () => {
843
- const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
844
- const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
845
- try {
846
- render(
847
- <LocaleProvider
848
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
849
- fallbackBundles={[kumikoDefaultTranslations]}
850
- >
851
- <DispatcherProvider dispatcher={dispatcher}>
852
- <PrimitivesProvider value={testPrimitives()}>
853
- <NavProvider value={stubNav().nav}>
854
- <RelatedListSection
855
- section={{
856
- kind: "relatedList",
857
- title: "Positions",
858
- query: "lease:query:items:list",
859
- columns: [{ field: "name" }],
860
- rowActions: [
861
- {
862
- kind: "drawer",
863
- id: "adjust-rent-warn-wired-2733",
864
- label: "actions.adjustRent",
865
- screen: "adjust-rent-form",
866
- },
867
- ],
868
- }}
869
- parentId="order-1"
870
- record={{ id: "order-1" }}
871
- featureName="orders"
872
- onOpenDrawer={noop}
873
- />
874
- </NavProvider>
875
- </PrimitivesProvider>
876
- </DispatcherProvider>
877
- </LocaleProvider>,
878
- );
879
-
880
- await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
881
- expect(rtlScreen.getByTestId("action-adjust-rent-warn-wired-2733-item-7")).toBeTruthy();
882
- expect(warnSpy).not.toHaveBeenCalled();
883
- } finally {
884
- warnSpy.mockRestore();
885
- }
886
- });
887
- });
888
-
889
- // A DataTable stub that renders one row per query row (DOM order == passed
890
- // `rows` order, so a re-sort is observable as a re-ordered row list) plus a
891
- // button that calls `onSortChange` the same way a real SortableHeader click
892
- // would — enough to prove RelatedListSection actually re-orders rows, not
893
- // just that a header is clickable.
894
- const orderedDataTable: ComponentType<DataTableProps> = ({ rows, onSortChange }) => (
895
- <div>
896
- <table>
897
- <tbody>
898
- {rows.map((row) => (
899
- <tr key={row.id} data-testid={`row-${row.id}`} />
900
- ))}
901
- </tbody>
902
- </table>
903
- {onSortChange !== undefined && (
904
- <button
905
- type="button"
906
- data-testid="sort-amount-desc"
907
- onClick={() => onSortChange({ field: "amount", dir: "desc" })}
908
- >
909
- sort
910
- </button>
911
- )}
912
- </div>
913
- );
914
-
915
- function renderedRowOrder(): string[] {
916
- return rtlScreen
917
- .getAllByTestId(/^row-/)
918
- .map((el) => el.getAttribute("data-testid")?.replace("row-", "") ?? "");
919
- }
920
-
921
- describe("RelatedListSection — sorting (fw#2722)", () => {
922
- const unsortedRows = [
923
- { id: "r1", name: "Charlie", amount: 300 },
924
- { id: "r2", name: "Alice", amount: 100 },
925
- { id: "r3", name: "Bob", amount: 200 },
926
- ];
927
-
928
- test("defaultSort sorts the already-loaded rows client-side, independent of query response order", async () => {
929
- const { dispatcher } = stubDispatcher(unsortedRows);
930
- render(
931
- <LocaleProvider
932
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
933
- fallbackBundles={[kumikoDefaultTranslations]}
934
- >
935
- <DispatcherProvider dispatcher={dispatcher}>
936
- <PrimitivesProvider value={{ ...testPrimitives(), DataTable: orderedDataTable }}>
937
- <NavProvider value={stubNav().nav}>
938
- <RelatedListSection
939
- section={{
940
- kind: "relatedList",
941
- title: "Positions",
942
- query: "lease:query:items:list",
943
- columns: [{ field: "amount", sortable: true }],
944
- defaultSort: { field: "amount", dir: "asc" },
945
- }}
946
- parentId="order-1"
947
- record={{ id: "order-1" }}
948
- featureName="orders"
949
- />
950
- </NavProvider>
951
- </PrimitivesProvider>
952
- </DispatcherProvider>
953
- </LocaleProvider>,
954
- );
955
-
956
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
957
- expect(renderedRowOrder()).toEqual(["r2", "r3", "r1"]);
958
- });
959
-
960
- test("toggling sort via onSortChange re-orders rows without a refetch", async () => {
961
- const { dispatcher, queryCount } = stubDispatcher(unsortedRows);
962
- render(
963
- <LocaleProvider
964
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
965
- fallbackBundles={[kumikoDefaultTranslations]}
966
- >
967
- <DispatcherProvider dispatcher={dispatcher}>
968
- <PrimitivesProvider value={{ ...testPrimitives(), DataTable: orderedDataTable }}>
969
- <NavProvider value={stubNav().nav}>
970
- <RelatedListSection
971
- section={{
972
- kind: "relatedList",
973
- title: "Positions",
974
- query: "lease:query:items:list",
975
- columns: [{ field: "amount", sortable: true }],
976
- }}
977
- parentId="order-1"
978
- record={{ id: "order-1" }}
979
- featureName="orders"
980
- />
981
- </NavProvider>
982
- </PrimitivesProvider>
983
- </DispatcherProvider>
984
- </LocaleProvider>,
985
- );
986
-
987
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
988
- expect(renderedRowOrder()).toEqual(["r1", "r2", "r3"]);
989
-
990
- fireEvent.click(rtlScreen.getByTestId("sort-amount-desc"));
991
-
992
- await waitFor(() => expect(renderedRowOrder()).toEqual(["r1", "r3", "r2"]));
993
- expect(queryCount()).toBe(1);
994
- });
995
-
996
- test("a section without defaultSort renders rows in the original query order (unchanged behavior)", async () => {
997
- const { dispatcher } = stubDispatcher(unsortedRows);
998
- render(
999
- <LocaleProvider
1000
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
1001
- fallbackBundles={[kumikoDefaultTranslations]}
1002
- >
1003
- <DispatcherProvider dispatcher={dispatcher}>
1004
- <PrimitivesProvider value={{ ...testPrimitives(), DataTable: orderedDataTable }}>
1005
- <NavProvider value={stubNav().nav}>
1006
- <RelatedListSection
1007
- section={{
1008
- kind: "relatedList",
1009
- title: "Positions",
1010
- query: "lease:query:items:list",
1011
- columns: [{ field: "amount" }],
1012
- }}
1013
- parentId="order-1"
1014
- record={{ id: "order-1" }}
1015
- featureName="orders"
1016
- />
1017
- </NavProvider>
1018
- </PrimitivesProvider>
1019
- </DispatcherProvider>
1020
- </LocaleProvider>,
1021
- );
1022
-
1023
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1024
- expect(renderedRowOrder()).toEqual(["r1", "r2", "r3"]);
1025
- });
1026
- });
1027
-
1028
- describe("RelatedListSection — truncation banner (fw#2722 review)", () => {
1029
- test("nextCursor !== null renders a truncation banner", async () => {
1030
- const { dispatcher } = stubDispatcher([{ id: "r1", name: "Alice" }], "cursor-abc");
1031
- renderRelatedList(dispatcher);
1032
-
1033
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1034
- const banner = rtlScreen.getByTestId("related-list-truncated");
1035
- expect(banner).toBeTruthy();
1036
- // Proves {count} actually interpolates (single-brace, the renderer's own
1037
- // i18n.tsx interpolate() syntax) rather than rendering as literal braces.
1038
- expect(banner.textContent).toContain("Showing the first 1 entries.");
1039
- });
1040
-
1041
- test("nextCursor === null renders no truncation banner", async () => {
1042
- const { dispatcher } = stubDispatcher([{ id: "r1", name: "Alice" }], null);
1043
- renderRelatedList(dispatcher);
1044
-
1045
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1046
- expect(rtlScreen.queryByTestId("related-list-truncated")).toBeNull();
1047
- });
1048
-
1049
- test("the banner shows regardless of an active sort — an unsorted truncated list is equally misleading", async () => {
1050
- const { dispatcher } = stubDispatcher(
1051
- [
1052
- { id: "r1", name: "Charlie", amount: 300 },
1053
- { id: "r2", name: "Alice", amount: 100 },
1054
- ],
1055
- "cursor-abc",
1056
- );
1057
- renderRelatedList(dispatcher, {
1058
- kind: "relatedList",
1059
- title: "Positions",
1060
- query: "lease:query:items:list",
1061
- columns: [{ field: "amount", sortable: true }],
1062
- defaultSort: { field: "amount", dir: "asc" },
1063
- });
1064
-
1065
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1066
- expect(rtlScreen.getByTestId("related-list-truncated")).toBeTruthy();
1067
- });
1068
- });
1069
-
1070
- // A DataTable stub that renders the toolbarStart slot (carries the search
1071
- // Input) and filterFacets as one button per option, wired straight to
1072
- // onFilterChange/onFilterReset — enough to prove RelatedListSection resolves
1073
- // facets and wires search through RenderList, without the production
1074
- // DataTable's own facet-dropdown chrome.
1075
- const searchFacetDataTable: ComponentType<DataTableProps> = ({
1076
- rows,
1077
- toolbarStart,
1078
- filterFacets,
1079
- onFilterChange,
1080
- onFilterReset,
1081
- }) => (
1082
- <div>
1083
- {toolbarStart}
1084
- {(filterFacets ?? []).map((facet) => (
1085
- <div key={facet.field}>
1086
- {facet.options.map((opt) => (
1087
- <button
1088
- key={opt.value}
1089
- type="button"
1090
- data-testid={`facet-${facet.field}-${opt.value}`}
1091
- onClick={() => onFilterChange?.(facet.field, [opt.value])}
1092
- >
1093
- {opt.label}
1094
- </button>
1095
- ))}
1096
- </div>
1097
- ))}
1098
- {onFilterReset !== undefined && (
1099
- <button type="button" data-testid="filter-reset" onClick={onFilterReset}>
1100
- Reset
1101
- </button>
1102
- )}
1103
- <table>
1104
- <tbody>
1105
- {rows.map((row) => (
1106
- <tr key={row.id} data-testid={`row-${row.id}`} />
1107
- ))}
1108
- </tbody>
1109
- </table>
1110
- </div>
1111
- );
1112
-
1113
- // A dispatcher stub that genuinely filters a fixed row set by the received
1114
- // payload — `search` as a case-insensitive substring on `name`, `filters`
1115
- // entries (`{ field, op: "in", value }`) as membership on that field —
1116
- // instead of returning canned rows, so a test can tell a real payload from a
1117
- // stale one. Every payload it sees is recorded for the "last payload"
1118
- // assertions below.
1119
- function filteringDispatcher(rows: readonly Record<string, unknown>[]): {
1120
- dispatcher: Dispatcher;
1121
- payloads: Record<string, unknown>[];
1122
- } {
1123
- const payloads: Record<string, unknown>[] = [];
1124
- const dispatcher: Dispatcher = {
1125
- write: (async () => ({ isSuccess: true, data: null })) as Dispatcher["write"],
1126
- query: (async (_type: string, payload: unknown) => {
1127
- const p = payload as {
1128
- search?: string;
1129
- filters?: readonly { field: string; op: "in"; value: readonly unknown[] }[];
1130
- };
1131
- payloads.push(p);
1132
- let result = rows;
1133
- if (p.search !== undefined && p.search !== "") {
1134
- const term = p.search.toLowerCase();
1135
- result = result.filter((r) =>
1136
- String(r["name"] ?? "")
1137
- .toLowerCase()
1138
- .includes(term),
1139
- );
1140
- }
1141
- for (const f of p.filters ?? []) {
1142
- result = result.filter((r) => f.value.includes(r[f.field]));
1143
- }
1144
- return { isSuccess: true, data: { rows: result, nextCursor: null } };
1145
- }) as Dispatcher["query"],
1146
- batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
1147
- statusStore: {
1148
- getState: () => "online",
1149
- subscribe: () => () => {},
1150
- } as unknown as Dispatcher["statusStore"],
1151
- async *stream() {},
1152
- pendingWrites: () => [],
1153
- pendingFiles: () => [],
1154
- };
1155
- return { dispatcher, payloads };
1156
- }
1157
-
1158
- function renderWithDataTable(
1159
- dispatcher: Dispatcher,
1160
- section: EditRelatedListSectionViewModel,
1161
- DataTable: ComponentType<DataTableProps>,
1162
- hideTitle?: boolean,
1163
- ) {
1164
- return render(
1165
- <LocaleProvider
1166
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
1167
- fallbackBundles={[kumikoDefaultTranslations]}
1168
- >
1169
- <DispatcherProvider dispatcher={dispatcher}>
1170
- <PrimitivesProvider value={{ ...testPrimitives(), DataTable }}>
1171
- <NavProvider value={stubNav().nav}>
1172
- <RelatedListSection
1173
- section={section}
1174
- parentId="order-1"
1175
- record={{ id: "order-1" }}
1176
- featureName="orders"
1177
- {...(hideTitle === true && { hideTitle: true })}
1178
- />
1179
- </NavProvider>
1180
- </PrimitivesProvider>
1181
- </DispatcherProvider>
1182
- </LocaleProvider>,
1183
- );
1184
- }
1185
-
1186
- describe("RelatedListSection — search + facets (fw#2740)", () => {
1187
- const nameRows = [
1188
- { id: "r1", name: "Alice" },
1189
- { id: "r2", name: "Bob" },
1190
- ];
1191
-
1192
- test("searchable: true — typing (after the 300ms debounce) sends payload.search and narrows the rendered rows", async () => {
1193
- const { dispatcher, payloads } = filteringDispatcher(nameRows);
1194
- renderWithDataTable(
1195
- dispatcher,
1196
- {
1197
- kind: "relatedList",
1198
- title: "Contacts",
1199
- query: "lease:query:contacts:list",
1200
- columns: [{ field: "name" }],
1201
- searchable: true,
1202
- },
1203
- searchFacetDataTable,
1204
- );
1205
-
1206
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1207
- expect(rtlScreen.getByTestId("row-r2")).toBeTruthy();
1208
-
1209
- fireEvent.change(rtlScreen.getByTestId("render-list-search"), {
1210
- target: { value: "ali" },
1211
- });
1212
-
1213
- await waitFor(
1214
- () => {
1215
- const last = payloads[payloads.length - 1];
1216
- expect(last?.["search"]).toBe("ali");
1217
- },
1218
- { timeout: 2000 },
1219
- );
1220
- await waitFor(() => expect(rtlScreen.queryByTestId("row-r2")).toBeNull());
1221
- expect(rtlScreen.getByTestId("row-r1")).toBeTruthy();
1222
- });
1223
-
1224
- test("without searchable, RenderList shows no search input and no payload ever carries a search key", async () => {
1225
- const { dispatcher, payloads } = filteringDispatcher(nameRows);
1226
- renderWithDataTable(
1227
- dispatcher,
1228
- {
1229
- kind: "relatedList",
1230
- title: "Contacts",
1231
- query: "lease:query:contacts:list",
1232
- columns: [{ field: "name" }],
1233
- },
1234
- searchFacetDataTable,
1235
- );
1236
-
1237
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1238
- expect(rtlScreen.queryByTestId("render-list-search")).toBeNull();
1239
- for (const p of payloads) {
1240
- expect("search" in p).toBe(false);
1241
- }
1242
- });
1243
-
1244
- test("tabs mode (hideTitle): the search box still renders and narrows the rows", async () => {
1245
- const { dispatcher, payloads } = filteringDispatcher(nameRows);
1246
- renderWithDataTable(
1247
- dispatcher,
1248
- {
1249
- kind: "relatedList",
1250
- title: "Contacts",
1251
- query: "lease:query:contacts:list",
1252
- columns: [{ field: "name" }],
1253
- searchable: true,
1254
- },
1255
- searchFacetDataTable,
1256
- true,
1257
- );
1258
-
1259
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1260
- expect(rtlScreen.getByTestId("row-r2")).toBeTruthy();
1261
- expect(rtlScreen.getByTestId("render-list-search")).toBeTruthy();
1262
-
1263
- fireEvent.change(rtlScreen.getByTestId("render-list-search"), {
1264
- target: { value: "ali" },
1265
- });
1266
-
1267
- await waitFor(
1268
- () => {
1269
- const last = payloads[payloads.length - 1];
1270
- expect(last?.["search"]).toBe("ali");
1271
- },
1272
- { timeout: 2000 },
1273
- );
1274
- await waitFor(() => expect(rtlScreen.queryByTestId("row-r2")).toBeNull());
1275
- expect(rtlScreen.getByTestId("row-r1")).toBeTruthy();
1276
- });
1277
-
1278
- const statusSection: EditRelatedListSectionViewModel = {
1279
- kind: "relatedList",
1280
- title: "Positions",
1281
- query: "lease:query:items:list",
1282
- columns: [{ field: "name" }, { field: "status" }],
1283
- facets: [
1284
- {
1285
- field: "status",
1286
- type: "select",
1287
- label: "Status",
1288
- options: [
1289
- { value: "active", label: "Active" },
1290
- { value: "ended", label: "Ended" },
1291
- ],
1292
- },
1293
- ],
1294
- };
1295
- const statusRows = [
1296
- { id: "active-1", name: "Running", status: "active" },
1297
- { id: "ended-1", name: "Closed", status: "ended" },
1298
- ];
1299
-
1300
- test("select facet: clicking an option filters payload.filters and rows, resetting clears both", async () => {
1301
- const { dispatcher, payloads } = filteringDispatcher(statusRows);
1302
- renderWithDataTable(dispatcher, statusSection, searchFacetDataTable);
1303
-
1304
- await waitFor(() => expect(rtlScreen.getByTestId("row-active-1")).toBeTruthy());
1305
- expect(rtlScreen.getByTestId("row-ended-1")).toBeTruthy();
1306
-
1307
- fireEvent.click(rtlScreen.getByTestId("facet-status-active"));
1308
-
1309
- await waitFor(() => expect(rtlScreen.queryByTestId("row-ended-1")).toBeNull());
1310
- expect(rtlScreen.getByTestId("row-active-1")).toBeTruthy();
1311
- expect(payloads[payloads.length - 1]?.["filters"]).toEqual([
1312
- { field: "status", op: "in", value: ["active"] },
1313
- ]);
1314
-
1315
- fireEvent.click(rtlScreen.getByTestId("filter-reset"));
1316
-
1317
- await waitFor(() => expect(rtlScreen.getByTestId("row-ended-1")).toBeTruthy());
1318
- expect(rtlScreen.getByTestId("row-active-1")).toBeTruthy();
1319
- expect(payloads[payloads.length - 1]?.["filters"]).toBeUndefined();
1320
- });
1321
-
1322
- test("boolean facet: selecting a value sends a real boolean in payload.filters and narrows rows accordingly", async () => {
1323
- const paidRows = [
1324
- { id: "paid-1", name: "Invoice A", paid: true },
1325
- { id: "unpaid-1", name: "Invoice B", paid: false },
1326
- ];
1327
- const { dispatcher, payloads } = filteringDispatcher(paidRows);
1328
- renderWithDataTable(
1329
- dispatcher,
1330
- {
1331
- kind: "relatedList",
1332
- title: "Invoices",
1333
- query: "lease:query:invoices:list",
1334
- columns: [{ field: "name" }, { field: "paid" }],
1335
- facets: [
1336
- {
1337
- field: "paid",
1338
- type: "boolean",
1339
- label: "Paid",
1340
- trueLabel: "Yes",
1341
- falseLabel: "No",
1342
- },
1343
- ],
1344
- },
1345
- searchFacetDataTable,
1346
- );
1347
-
1348
- await waitFor(() => expect(rtlScreen.getByTestId("row-paid-1")).toBeTruthy());
1349
- expect(rtlScreen.getByTestId("row-unpaid-1")).toBeTruthy();
1350
-
1351
- fireEvent.click(rtlScreen.getByTestId("facet-paid-true"));
1352
-
1353
- await waitFor(() => expect(rtlScreen.queryByTestId("row-unpaid-1")).toBeNull());
1354
- expect(rtlScreen.getByTestId("row-paid-1")).toBeTruthy();
1355
- expect(payloads[payloads.length - 1]?.["filters"]).toEqual([
1356
- { field: "paid", op: "in", value: [true] },
1357
- ]);
1358
- });
1359
- });
1360
-
1361
- // A dispatcher stub that filters a fixed row set by `payload.filter` (single
1362
- // eq clause), `payload.search` (substring on `name`) and `payload.filters`
1363
- // (facet membership) together — proves parentFilter composes with search
1364
- // and facets instead of being clobbered by either.
1365
- function parentFilterDispatcher(rows: readonly Record<string, unknown>[]): {
1366
- dispatcher: Dispatcher;
1367
- payloads: Record<string, unknown>[];
1368
- } {
1369
- const payloads: Record<string, unknown>[] = [];
1370
- const dispatcher: Dispatcher = {
1371
- write: (async () => ({ isSuccess: true, data: null })) as Dispatcher["write"],
1372
- query: (async (_type: string, payload: unknown) => {
1373
- const p = payload as {
1374
- filter?: { field: string; op: "eq"; value: unknown };
1375
- search?: string;
1376
- filters?: readonly { field: string; op: "in"; value: readonly unknown[] }[];
1377
- };
1378
- payloads.push(p);
1379
- let result = rows;
1380
- if (p.filter !== undefined) {
1381
- const { field, value } = p.filter;
1382
- result = result.filter((r) => r[field] === value);
1383
- }
1384
- if (p.search !== undefined && p.search !== "") {
1385
- const term = p.search.toLowerCase();
1386
- result = result.filter((r) =>
1387
- String(r["name"] ?? "")
1388
- .toLowerCase()
1389
- .includes(term),
1390
- );
1391
- }
1392
- for (const f of p.filters ?? []) {
1393
- result = result.filter((r) => f.value.includes(r[f.field]));
1394
- }
1395
- return { isSuccess: true, data: { rows: result, nextCursor: null } };
1396
- }) as Dispatcher["query"],
1397
- batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
1398
- statusStore: {
1399
- getState: () => "online",
1400
- subscribe: () => () => {},
1401
- } as unknown as Dispatcher["statusStore"],
1402
- async *stream() {},
1403
- pendingWrites: () => [],
1404
- pendingFiles: () => [],
1405
- };
1406
- return { dispatcher, payloads };
1407
- }
1408
-
1409
- describe("RelatedListSection — parentFilter", () => {
1410
- const rows = [
1411
- { id: "r1", parentId: "order-1", name: "Alice", status: "active" },
1412
- { id: "r2", parentId: "order-1", name: "Bob", status: "ended" },
1413
- { id: "r3", parentId: "order-2", name: "Carol", status: "active" },
1414
- ];
1415
- const section: EditRelatedListSectionViewModel = {
1416
- kind: "relatedList",
1417
- title: "Positions",
1418
- query: "orders:query:items:list",
1419
- columns: [{ field: "name" }, { field: "status" }],
1420
- parentFilter: { field: "parentId" },
1421
- pageSize: 25,
1422
- searchable: true,
1423
- facets: [
1424
- {
1425
- field: "status",
1426
- type: "select",
1427
- label: "Status",
1428
- options: [
1429
- { value: "active", label: "Active" },
1430
- { value: "ended", label: "Ended" },
1431
- ],
1432
- },
1433
- ],
1434
- };
1435
-
1436
- test("sends payload.filter eq parentId (no top-level parentParam key), combined with limit/search/facets, and only the parent's rows render", async () => {
1437
- const { dispatcher, payloads } = parentFilterDispatcher(rows);
1438
- renderWithDataTable(dispatcher, section, searchFacetDataTable);
1439
-
1440
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1441
- expect(rtlScreen.getByTestId("row-r2")).toBeTruthy();
1442
- expect(rtlScreen.queryByTestId("row-r3")).toBeNull();
1443
-
1444
- const firstPayload = payloads[0];
1445
- expect(firstPayload).toEqual({
1446
- filter: { field: "parentId", op: "eq", value: "order-1" },
1447
- limit: 25,
1448
- });
1449
- expect(firstPayload?.["id"]).toBeUndefined();
1450
- expect(firstPayload?.["parentId"]).toBeUndefined();
1451
- });
1452
-
1453
- test("typing a search term keeps payload.filter and narrows via payload.search together", async () => {
1454
- const { dispatcher, payloads } = parentFilterDispatcher(rows);
1455
- renderWithDataTable(dispatcher, section, searchFacetDataTable);
1456
-
1457
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1458
- fireEvent.change(rtlScreen.getByTestId("render-list-search"), {
1459
- target: { value: "ali" },
1460
- });
1461
-
1462
- await waitFor(
1463
- () => {
1464
- const last = payloads[payloads.length - 1];
1465
- expect(last?.["search"]).toBe("ali");
1466
- },
1467
- { timeout: 2000 },
1468
- );
1469
- const last = payloads[payloads.length - 1];
1470
- expect(last?.["filter"]).toEqual({ field: "parentId", op: "eq", value: "order-1" });
1471
- await waitFor(() => expect(rtlScreen.queryByTestId("row-r2")).toBeNull());
1472
- expect(rtlScreen.getByTestId("row-r1")).toBeTruthy();
1473
- });
1474
-
1475
- test("selecting a facet leaves payload.filter unchanged and adds payload.filters", async () => {
1476
- const { dispatcher, payloads } = parentFilterDispatcher(rows);
1477
- renderWithDataTable(dispatcher, section, searchFacetDataTable);
1478
-
1479
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1480
- fireEvent.click(rtlScreen.getByTestId("facet-status-active"));
1481
-
1482
- await waitFor(() => expect(rtlScreen.queryByTestId("row-r2")).toBeNull());
1483
- expect(rtlScreen.getByTestId("row-r1")).toBeTruthy();
1484
- const last = payloads[payloads.length - 1];
1485
- expect(last?.["filter"]).toEqual({ field: "parentId", op: "eq", value: "order-1" });
1486
- expect(last?.["filters"]).toEqual([{ field: "status", op: "in", value: ["active"] }]);
1487
- });
1488
-
1489
- test("a toolbarAction without params prefills the parent id under parentFilter.field, not parentParam/id", async () => {
1490
- const { dispatcher } = parentFilterDispatcher(rows);
1491
- const { nav, navigations, searchParams } = stubNav();
1492
- const capturingDataTable: ComponentType<DataTableProps> = (props) => (
1493
- <>
1494
- {props.toolbarEnd}
1495
- {testDataTable(props)}
1496
- </>
1497
- );
1498
- const testButton = ({
1499
- children,
1500
- testId,
1501
- onClick,
1502
- }: {
1503
- readonly children?: ReactNode;
1504
- readonly testId?: string;
1505
- readonly onClick?: () => void;
1506
- }) => (
1507
- <button type="button" data-testid={testId} onClick={() => onClick?.()}>
1508
- {children}
1509
- </button>
1510
- );
1511
- render(
1512
- <LocaleProvider
1513
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
1514
- fallbackBundles={[kumikoDefaultTranslations]}
1515
- >
1516
- <DispatcherProvider dispatcher={dispatcher}>
1517
- <PrimitivesProvider
1518
- value={{ ...testPrimitives(), DataTable: capturingDataTable, Button: testButton }}
1519
- >
1520
- <NavProvider value={nav}>
1521
- <RelatedListSection
1522
- section={{
1523
- ...section,
1524
- toolbarActions: [
1525
- {
1526
- kind: "navigate",
1527
- id: "create",
1528
- label: "Add position",
1529
- screen: "item-create",
1530
- },
1531
- ],
1532
- }}
1533
- parentId="order-1"
1534
- record={{ id: "order-1" }}
1535
- featureName="orders"
1536
- />
1537
- </NavProvider>
1538
- </PrimitivesProvider>
1539
- </DispatcherProvider>
1540
- </LocaleProvider>,
1541
- );
1542
-
1543
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1544
- rtlScreen.getByTestId("render-list-toolbar-action-create").click();
1545
-
1546
- await waitFor(() => expect(navigations).toHaveLength(1));
1547
- expect(searchParams).toEqual([{ parentId: "order-1" }]);
1548
- });
1549
- });
1550
-
1551
- // Same findEditScreenFor/buildDefaultEditRowAction resolution as
1552
- // entityList/projectionList, driven by rowClick.entity and rowClick.idColumn.
1553
- describe("RelatedListSection — default edit row action", () => {
1554
- function editScreen(entity: string, roles?: readonly string[]): EntityEditScreenDefinition {
1555
- return {
1556
- id: "lease:screen:item-edit",
1557
- type: "entityEdit",
1558
- entity,
1559
- layout: { sections: [{ columns: 1, fields: ["name"] }] },
1560
- ...(roles !== undefined && { access: { roles } }),
1561
- };
1562
- }
1563
-
1564
- function renderWithFeatures(
1565
- section: EditRelatedListSectionViewModel,
1566
- dispatcher: Dispatcher,
1567
- features: readonly FeatureSchema[],
1568
- userRoles: readonly string[] = [],
1569
- nav: NavApi = stubNav().nav,
1570
- ) {
1571
- return render(
1572
- <LocaleProvider
1573
- resolver={createStaticLocaleResolver({ locale: "en-US" })}
1574
- fallbackBundles={[kumikoDefaultTranslations]}
1575
- >
1576
- <DispatcherProvider dispatcher={dispatcher}>
1577
- <AppFeaturesProvider features={features}>
1578
- <UserRolesProvider roles={userRoles}>
1579
- <PrimitivesProvider value={testPrimitives()}>
1580
- <NavProvider value={nav}>
1581
- <RelatedListSection
1582
- section={section}
1583
- parentId="order-1"
1584
- record={{ id: "order-1" }}
1585
- featureName="orders"
1586
- />
1587
- </NavProvider>
1588
- </PrimitivesProvider>
1589
- </UserRolesProvider>
1590
- </AppFeaturesProvider>
1591
- </DispatcherProvider>
1592
- </LocaleProvider>,
1593
- );
1594
- }
1595
-
1596
- const sectionWithRowClick: EditRelatedListSectionViewModel = {
1597
- kind: "relatedList",
1598
- title: "Positions",
1599
- query: "lease:query:items:list",
1600
- columns: [{ field: "name" }],
1601
- rowClick: { entity: "item" },
1602
- };
1603
-
1604
- test("adds a default edit action at the first position when rowClick.entity resolves an entityEdit screen", async () => {
1605
- const { dispatcher } = stubDispatcher();
1606
- const { nav, navigations } = stubNav();
1607
- const schema: FeatureSchema = {
1608
- featureName: "lease",
1609
- entities: {},
1610
- screens: [editScreen("item")],
1611
- };
1612
- renderWithFeatures(sectionWithRowClick, dispatcher, [schema], [], nav);
1613
-
1614
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1615
- const editButton = rtlScreen.getByTestId("action-edit-r1");
1616
- expect(editButton.textContent).toBe("Edit");
1617
-
1618
- fireEvent.click(editButton);
1619
- await waitFor(() => expect(navigations).toHaveLength(1));
1620
- expect(navigations[0]).toEqual({ screenId: "item-edit", entityId: "r1" });
1621
- });
1622
-
1623
- test("a declared rowAction with id 'edit' wins — no doubling", async () => {
1624
- const { dispatcher } = stubDispatcher();
1625
- const schema: FeatureSchema = {
1626
- featureName: "lease",
1627
- entities: {},
1628
- screens: [editScreen("item")],
1629
- };
1630
- renderWithFeatures(
1631
- {
1632
- ...sectionWithRowClick,
1633
- rowActions: [{ kind: "navigate", id: "edit", label: "custom-edit", screen: "item-edit" }],
1634
- },
1635
- dispatcher,
1636
- [schema],
1637
- );
1638
-
1639
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1640
- expect(rtlScreen.queryAllByTestId("action-edit-r1")).toHaveLength(1);
1641
- expect(rtlScreen.getByTestId("action-edit-r1").textContent).toBe("custom-edit");
1642
- });
1643
-
1644
- test("no rowClick.entity → no default edit action", async () => {
1645
- const { dispatcher } = stubDispatcher();
1646
- const schema: FeatureSchema = {
1647
- featureName: "lease",
1648
- entities: {},
1649
- screens: [editScreen("item")],
1650
- };
1651
- renderWithFeatures({ ...sectionWithRowClick, rowClick: undefined }, dispatcher, [schema]);
1652
-
1653
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1654
- expect(rtlScreen.queryByTestId("action-edit-r1")).toBeNull();
1655
- });
1656
-
1657
- test("access denied to the entityEdit screen → no default edit action", async () => {
1658
- const { dispatcher } = stubDispatcher();
1659
- const schema: FeatureSchema = {
1660
- featureName: "lease",
1661
- entities: {},
1662
- screens: [editScreen("item", ["admin"])],
1663
- };
1664
- renderWithFeatures(sectionWithRowClick, dispatcher, [schema]);
1665
-
1666
- await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1667
- expect(rtlScreen.queryByTestId("action-edit-r1")).toBeNull();
1668
- });
1669
- });