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