@rangka/client 0.1.1 → 0.1.3

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 (317) hide show
  1. package/dist/App.d.ts.map +1 -1
  2. package/dist/App.js +7 -1
  3. package/dist/App.js.map +1 -1
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/main.d.ts.map +1 -1
  9. package/dist/main.js +20 -0
  10. package/dist/main.js.map +1 -1
  11. package/dist/shell/assets/index-63v1sBS3.css +1 -0
  12. package/dist/shell/assets/index-Dh7K40cQ.js +8634 -0
  13. package/dist/shell/assets/vendor-query-B2cydN5j.js +1 -0
  14. package/dist/shell/assets/vendor-radix-BJxYPxPb.js +69 -0
  15. package/dist/shell/assets/vendor-router-ET_myMt5.js +17 -0
  16. package/dist/shell/index.html +5 -2
  17. package/dist/theme.css +82 -0
  18. package/dist/widgets/components/lazy-manifest.d.ts +11 -0
  19. package/dist/widgets/components/lazy-manifest.d.ts.map +1 -0
  20. package/dist/widgets/components/lazy-manifest.js +32 -0
  21. package/dist/widgets/components/lazy-manifest.js.map +1 -0
  22. package/dist/widgets/components/register.js +12 -12
  23. package/dist/widgets/components/register.js.map +1 -1
  24. package/dist/widgets/loader.d.ts +3 -0
  25. package/dist/widgets/loader.d.ts.map +1 -0
  26. package/dist/widgets/loader.js +73 -0
  27. package/dist/widgets/loader.js.map +1 -0
  28. package/dist/widgets/renderer/LazyWidget.d.ts +8 -0
  29. package/dist/widgets/renderer/LazyWidget.d.ts.map +1 -0
  30. package/dist/widgets/renderer/LazyWidget.js +31 -0
  31. package/dist/widgets/renderer/LazyWidget.js.map +1 -0
  32. package/dist/widgets/renderer/WidgetErrorBoundary.d.ts +17 -0
  33. package/dist/widgets/renderer/WidgetErrorBoundary.d.ts.map +1 -0
  34. package/dist/widgets/renderer/WidgetErrorBoundary.js +18 -0
  35. package/dist/widgets/renderer/WidgetErrorBoundary.js.map +1 -0
  36. package/dist/widgets/renderer/WidgetRenderer.d.ts.map +1 -1
  37. package/dist/widgets/renderer/WidgetRenderer.js +8 -6
  38. package/dist/widgets/renderer/WidgetRenderer.js.map +1 -1
  39. package/package.json +7 -4
  40. package/.claude/skills/add-widget/SKILL.md +0 -101
  41. package/.turbo/turbo-build.log +0 -29
  42. package/CHANGELOG.md +0 -25
  43. package/CLAUDE.md +0 -236
  44. package/components.json +0 -25
  45. package/dist/components/ui/chart.d.ts +0 -45
  46. package/dist/components/ui/chart.d.ts.map +0 -1
  47. package/dist/components/ui/chart.js +0 -119
  48. package/dist/components/ui/chart.js.map +0 -1
  49. package/dist/shell/assets/index--35CAvcP.js +0 -8715
  50. package/dist/shell/assets/index-COLmoPYo.css +0 -1
  51. package/index.html +0 -12
  52. package/src/App.tsx +0 -44
  53. package/src/__tests__/setup.ts +0 -1
  54. package/src/api/auth.ts +0 -41
  55. package/src/api/boot.ts +0 -10
  56. package/src/api/client.ts +0 -26
  57. package/src/api/paths.ts +0 -3
  58. package/src/api/token.ts +0 -13
  59. package/src/auth/LoginForm.tsx +0 -67
  60. package/src/auth/SessionExpired.tsx +0 -24
  61. package/src/auth/SetupForm.tsx +0 -76
  62. package/src/boot/BootGate.tsx +0 -35
  63. package/src/boot/BootProvider.tsx +0 -28
  64. package/src/boot/types.ts +0 -9
  65. package/src/boot/useBoot.ts +0 -111
  66. package/src/components/Icon.tsx +0 -17
  67. package/src/components/ui/accordion.tsx +0 -82
  68. package/src/components/ui/alert-dialog.tsx +0 -180
  69. package/src/components/ui/alert.tsx +0 -76
  70. package/src/components/ui/aspect-ratio.tsx +0 -9
  71. package/src/components/ui/avatar.tsx +0 -94
  72. package/src/components/ui/badge.tsx +0 -45
  73. package/src/components/ui/breadcrumb.tsx +0 -104
  74. package/src/components/ui/button-group.tsx +0 -78
  75. package/src/components/ui/button.tsx +0 -65
  76. package/src/components/ui/calendar.tsx +0 -187
  77. package/src/components/ui/card.tsx +0 -85
  78. package/src/components/ui/carousel.tsx +0 -229
  79. package/src/components/ui/chart.tsx +0 -339
  80. package/src/components/ui/checkbox.tsx +0 -27
  81. package/src/components/ui/collapsible.tsx +0 -21
  82. package/src/components/ui/combobox.tsx +0 -275
  83. package/src/components/ui/command.tsx +0 -178
  84. package/src/components/ui/context-menu.tsx +0 -242
  85. package/src/components/ui/dialog.tsx +0 -146
  86. package/src/components/ui/direction.tsx +0 -20
  87. package/src/components/ui/drawer.tsx +0 -118
  88. package/src/components/ui/dropdown-menu.tsx +0 -247
  89. package/src/components/ui/empty.tsx +0 -94
  90. package/src/components/ui/field.tsx +0 -224
  91. package/src/components/ui/hover-card.tsx +0 -36
  92. package/src/components/ui/input-group.tsx +0 -142
  93. package/src/components/ui/input-otp.tsx +0 -86
  94. package/src/components/ui/input.tsx +0 -19
  95. package/src/components/ui/item.tsx +0 -182
  96. package/src/components/ui/kbd.tsx +0 -26
  97. package/src/components/ui/label.tsx +0 -19
  98. package/src/components/ui/menubar.tsx +0 -260
  99. package/src/components/ui/native-select.tsx +0 -55
  100. package/src/components/ui/navigation-menu.tsx +0 -160
  101. package/src/components/ui/pagination.tsx +0 -112
  102. package/src/components/ui/popover.tsx +0 -74
  103. package/src/components/ui/progress.tsx +0 -31
  104. package/src/components/ui/radio-group.tsx +0 -42
  105. package/src/components/ui/resizable.tsx +0 -42
  106. package/src/components/ui/scroll-area.tsx +0 -53
  107. package/src/components/ui/select.tsx +0 -185
  108. package/src/components/ui/separator.tsx +0 -26
  109. package/src/components/ui/sheet.tsx +0 -128
  110. package/src/components/ui/sidebar.tsx +0 -669
  111. package/src/components/ui/skeleton.tsx +0 -13
  112. package/src/components/ui/slider.tsx +0 -54
  113. package/src/components/ui/sonner.tsx +0 -43
  114. package/src/components/ui/spinner.tsx +0 -16
  115. package/src/components/ui/switch.tsx +0 -33
  116. package/src/components/ui/table.tsx +0 -87
  117. package/src/components/ui/tabs.tsx +0 -80
  118. package/src/components/ui/textarea.tsx +0 -18
  119. package/src/components/ui/toggle-group.tsx +0 -86
  120. package/src/components/ui/toggle.tsx +0 -44
  121. package/src/components/ui/tooltip.tsx +0 -53
  122. package/src/context/MetaContext.tsx +0 -22
  123. package/src/context/ModuleContext.tsx +0 -62
  124. package/src/context/PermissionsContext.tsx +0 -39
  125. package/src/context/ShellProviders.tsx +0 -33
  126. package/src/context/UserContext.tsx +0 -16
  127. package/src/data/QueryProvider.tsx +0 -7
  128. package/src/data/queryClient.ts +0 -18
  129. package/src/data/useModelMeta.ts +0 -17
  130. package/src/data/useMutation.ts +0 -60
  131. package/src/data/useRecord.ts +0 -29
  132. package/src/data/useSource.ts +0 -112
  133. package/src/hooks/use-mobile.ts +0 -19
  134. package/src/index.css +0 -260
  135. package/src/index.ts +0 -16
  136. package/src/lib/utils.ts +0 -6
  137. package/src/main.tsx +0 -17
  138. package/src/router/NotFound.tsx +0 -8
  139. package/src/router/RouterProvider.tsx +0 -7
  140. package/src/router/buildRouteTree.tsx +0 -63
  141. package/src/router/createShellRouter.ts +0 -9
  142. package/src/router/hooks.ts +0 -43
  143. package/src/shell/CommandPalette.tsx +0 -76
  144. package/src/shell/ConfirmDialog.tsx +0 -34
  145. package/src/shell/ConfirmProvider.tsx +0 -56
  146. package/src/shell/DrawerContext.tsx +0 -44
  147. package/src/shell/HeaderActions.tsx +0 -31
  148. package/src/shell/ModuleSelectorPage.tsx +0 -149
  149. package/src/shell/PageOutlet.tsx +0 -21
  150. package/src/shell/ShellContext.tsx +0 -45
  151. package/src/shell/ShellDevTools.tsx +0 -153
  152. package/src/shell/ShellLayout.tsx +0 -231
  153. package/src/shell/Toast.tsx +0 -58
  154. package/src/shell/ToastProvider.tsx +0 -60
  155. package/src/shell/app-sidebar/AppSidebar.tsx +0 -44
  156. package/src/shell/app-sidebar/ModuleSwitcher.tsx +0 -87
  157. package/src/shell/app-sidebar/NavMain.tsx +0 -64
  158. package/src/shell/app-sidebar/NavUser.tsx +0 -97
  159. package/src/shell/app-sidebar/SearchMenu.tsx +0 -22
  160. package/src/shell/app-sidebar/index.ts +0 -8
  161. package/src/shell/app-sidebar/types.ts +0 -38
  162. package/src/shell/types.ts +0 -6
  163. package/src/shell/useBreadcrumbs.ts +0 -42
  164. package/src/studio/bridge.ts +0 -125
  165. package/src/studio/index.ts +0 -3
  166. package/src/studio/overlay.ts +0 -47
  167. package/src/studio/types.ts +0 -32
  168. package/src/studio/walker.ts +0 -48
  169. package/src/vite-env.d.ts +0 -1
  170. package/src/widgets/__tests__/action-edge-cases.test.ts +0 -281
  171. package/src/widgets/__tests__/action.test.ts +0 -236
  172. package/src/widgets/__tests__/attachment-widget.test.tsx +0 -85
  173. package/src/widgets/__tests__/attachments-widget.test.tsx +0 -109
  174. package/src/widgets/__tests__/binding.test.ts +0 -76
  175. package/src/widgets/__tests__/button-widget.test.tsx +0 -145
  176. package/src/widgets/__tests__/checkbox-widget.test.tsx +0 -158
  177. package/src/widgets/__tests__/code-widget.test.tsx +0 -64
  178. package/src/widgets/__tests__/computed-widget.test.tsx +0 -62
  179. package/src/widgets/__tests__/condition-edge-cases.test.ts +0 -120
  180. package/src/widgets/__tests__/condition.test.ts +0 -221
  181. package/src/widgets/__tests__/context.test.ts +0 -99
  182. package/src/widgets/__tests__/data-widget.test.tsx +0 -204
  183. package/src/widgets/__tests__/datepicker-widget.test.tsx +0 -66
  184. package/src/widgets/__tests__/datetime-widget.test.tsx +0 -67
  185. package/src/widgets/__tests__/drawer-widget.test.tsx +0 -149
  186. package/src/widgets/__tests__/dynamic-link-widget.test.tsx +0 -52
  187. package/src/widgets/__tests__/edge-cases.test.ts +0 -232
  188. package/src/widgets/__tests__/evaluator.test.ts +0 -107
  189. package/src/widgets/__tests__/functions.test.ts +0 -147
  190. package/src/widgets/__tests__/grid-widget.test.tsx +0 -137
  191. package/src/widgets/__tests__/hooks.test.tsx +0 -249
  192. package/src/widgets/__tests__/icon-widget.test.tsx +0 -129
  193. package/src/widgets/__tests__/input-widget.test.tsx +0 -264
  194. package/src/widgets/__tests__/integration.test.ts +0 -116
  195. package/src/widgets/__tests__/json-widget.test.tsx +0 -70
  196. package/src/widgets/__tests__/link-widget.test.tsx +0 -92
  197. package/src/widgets/__tests__/many-to-many-widget.test.tsx +0 -93
  198. package/src/widgets/__tests__/modal-widget.test.tsx +0 -148
  199. package/src/widgets/__tests__/money-widget.test.tsx +0 -97
  200. package/src/widgets/__tests__/parser.test.ts +0 -171
  201. package/src/widgets/__tests__/reactive-variables.test.ts +0 -383
  202. package/src/widgets/__tests__/renderer.test.tsx +0 -300
  203. package/src/widgets/__tests__/repeat-widget.test.tsx +0 -229
  204. package/src/widgets/__tests__/select-widget.test.tsx +0 -231
  205. package/src/widgets/__tests__/sequence-widget.test.tsx +0 -58
  206. package/src/widgets/__tests__/shell-integration.test.tsx +0 -1343
  207. package/src/widgets/__tests__/split-widget.test.tsx +0 -133
  208. package/src/widgets/__tests__/state-edge-cases.test.ts +0 -118
  209. package/src/widgets/__tests__/state.test.ts +0 -106
  210. package/src/widgets/__tests__/table-data-binding.test.tsx +0 -482
  211. package/src/widgets/__tests__/table-filter-popover.test.tsx +0 -486
  212. package/src/widgets/__tests__/table-search.test.tsx +0 -305
  213. package/src/widgets/__tests__/table-widget.test.tsx +0 -509
  214. package/src/widgets/__tests__/textarea-widget.test.tsx +0 -105
  215. package/src/widgets/__tests__/tracker-validator-edge-cases.test.ts +0 -242
  216. package/src/widgets/__tests__/tracker.test.ts +0 -133
  217. package/src/widgets/__tests__/tree-widget.test.tsx +0 -97
  218. package/src/widgets/__tests__/use-model-source.test.ts +0 -67
  219. package/src/widgets/__tests__/validator.test.ts +0 -208
  220. package/src/widgets/action/dispatcher.ts +0 -334
  221. package/src/widgets/action/index.ts +0 -2
  222. package/src/widgets/binding/index.ts +0 -2
  223. package/src/widgets/binding/resolver.ts +0 -61
  224. package/src/widgets/components/AttachmentWidget.tsx +0 -111
  225. package/src/widgets/components/AttachmentsWidget.tsx +0 -121
  226. package/src/widgets/components/BadgeWidget.tsx +0 -35
  227. package/src/widgets/components/ButtonWidget.tsx +0 -43
  228. package/src/widgets/components/CardWidget.tsx +0 -68
  229. package/src/widgets/components/CheckboxWidget.tsx +0 -39
  230. package/src/widgets/components/CodeWidget.tsx +0 -44
  231. package/src/widgets/components/ColumnWidget.tsx +0 -22
  232. package/src/widgets/components/ComputedWidget.tsx +0 -49
  233. package/src/widgets/components/DataWidget.tsx +0 -189
  234. package/src/widgets/components/DatePickerWidget.tsx +0 -73
  235. package/src/widgets/components/DatetimeWidget.tsx +0 -160
  236. package/src/widgets/components/DividerWidget.tsx +0 -37
  237. package/src/widgets/components/DrawerWidget.tsx +0 -52
  238. package/src/widgets/components/DynamicLinkWidget.tsx +0 -130
  239. package/src/widgets/components/GridWidget.tsx +0 -134
  240. package/src/widgets/components/GroupWidget.tsx +0 -111
  241. package/src/widgets/components/IconWidget.tsx +0 -29
  242. package/src/widgets/components/ImageWidget.tsx +0 -28
  243. package/src/widgets/components/InputWidget.tsx +0 -70
  244. package/src/widgets/components/JsonWidget.tsx +0 -78
  245. package/src/widgets/components/LinkWidget.tsx +0 -99
  246. package/src/widgets/components/ManyToManyWidget.tsx +0 -125
  247. package/src/widgets/components/ModalWidget.tsx +0 -52
  248. package/src/widgets/components/MoneyWidget.tsx +0 -80
  249. package/src/widgets/components/RepeatWidget.tsx +0 -66
  250. package/src/widgets/components/ScrollAreaWidget.tsx +0 -40
  251. package/src/widgets/components/SectionWidget.tsx +0 -78
  252. package/src/widgets/components/SelectWidget.tsx +0 -63
  253. package/src/widgets/components/SequenceWidget.tsx +0 -32
  254. package/src/widgets/components/SpacerWidget.tsx +0 -29
  255. package/src/widgets/components/SplitWidget.tsx +0 -60
  256. package/src/widgets/components/StackWidget.tsx +0 -44
  257. package/src/widgets/components/TableWidget.tsx +0 -366
  258. package/src/widgets/components/TextWidget.tsx +0 -44
  259. package/src/widgets/components/TextareaWidget.tsx +0 -49
  260. package/src/widgets/components/TreeWidget.tsx +0 -109
  261. package/src/widgets/components/index.ts +0 -30
  262. package/src/widgets/components/register.ts +0 -93
  263. package/src/widgets/components/table/CellRenderers.tsx +0 -83
  264. package/src/widgets/components/table/TablePagination.tsx +0 -45
  265. package/src/widgets/components/table/TableToolbar.tsx +0 -285
  266. package/src/widgets/components/table/filter-operators.ts +0 -134
  267. package/src/widgets/components/table/index.ts +0 -11
  268. package/src/widgets/condition/evaluator.ts +0 -57
  269. package/src/widgets/condition/index.ts +0 -1
  270. package/src/widgets/context/builder.ts +0 -99
  271. package/src/widgets/context/index.ts +0 -8
  272. package/src/widgets/context/types.ts +0 -37
  273. package/src/widgets/data/index.ts +0 -5
  274. package/src/widgets/data/useModelQuery.ts +0 -116
  275. package/src/widgets/data/useModelRecord.ts +0 -37
  276. package/src/widgets/expression/evaluator.ts +0 -100
  277. package/src/widgets/expression/functions.ts +0 -131
  278. package/src/widgets/expression/index.ts +0 -13
  279. package/src/widgets/expression/parser.ts +0 -229
  280. package/src/widgets/expression/types.ts +0 -45
  281. package/src/widgets/form/FormContext.ts +0 -29
  282. package/src/widgets/form/FormProvider.tsx +0 -84
  283. package/src/widgets/form/FormWidget.tsx +0 -42
  284. package/src/widgets/form/index.ts +0 -4
  285. package/src/widgets/form/useFormState.ts +0 -127
  286. package/src/widgets/form/useFormSubmit.ts +0 -90
  287. package/src/widgets/form/useFormValidation.ts +0 -62
  288. package/src/widgets/hooks/index.ts +0 -8
  289. package/src/widgets/hooks/useAction.ts +0 -83
  290. package/src/widgets/hooks/useBind.ts +0 -34
  291. package/src/widgets/hooks/useCondition.ts +0 -21
  292. package/src/widgets/hooks/useDataQuery.ts +0 -48
  293. package/src/widgets/hooks/useExpression.ts +0 -14
  294. package/src/widgets/hooks/usePageState.ts +0 -21
  295. package/src/widgets/hooks/useSurfaceContext.ts +0 -11
  296. package/src/widgets/hooks/useWidgetContext.ts +0 -14
  297. package/src/widgets/index.ts +0 -80
  298. package/src/widgets/lib/layout-props.ts +0 -135
  299. package/src/widgets/reactivity/index.ts +0 -11
  300. package/src/widgets/reactivity/tracker.ts +0 -139
  301. package/src/widgets/reactivity/variables.ts +0 -213
  302. package/src/widgets/registry.ts +0 -41
  303. package/src/widgets/renderer/SlotRenderer.tsx +0 -47
  304. package/src/widgets/renderer/WidgetRenderer.tsx +0 -191
  305. package/src/widgets/renderer/index.ts +0 -4
  306. package/src/widgets/shell/WidgetSlotRenderer.tsx +0 -73
  307. package/src/widgets/shell/index.ts +0 -4
  308. package/src/widgets/shell/useActionHandlers.ts +0 -170
  309. package/src/widgets/state/index.ts +0 -2
  310. package/src/widgets/state/store.ts +0 -96
  311. package/src/widgets/types.ts +0 -28
  312. package/src/widgets/validation/index.ts +0 -2
  313. package/src/widgets/validation/validator.ts +0 -140
  314. package/tsconfig.json +0 -27
  315. package/tsconfig.tsbuildinfo +0 -1
  316. package/vite.config.ts +0 -21
  317. package/vitest.config.ts +0 -16
@@ -1,1343 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import React from 'react';
3
- import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
4
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5
- import type { WidgetNode } from '@rangka/shared';
6
- import { WidgetSlotRenderer } from '../shell/WidgetSlotRenderer.js';
7
- import { ShellAPIProvider } from '../../shell/ShellContext.js';
8
- import { DrawerProvider } from '../../shell/DrawerContext.js';
9
- import { registerWidget, clearWidgetRegistry } from '../registry.js';
10
- import type { WidgetProps } from '../types.js';
11
-
12
- const mockNavigate = vi.fn();
13
- vi.mock('../../router/hooks.js', () => ({
14
- useNavigate: () => mockNavigate,
15
- useParams: () => ({}),
16
- useRoute: () => ({ pageKey: undefined, path: '/' }),
17
- useSearchParams: () => [new URLSearchParams(), vi.fn()],
18
- }));
19
-
20
- // --- Realistic API Mock ---
21
-
22
- let mockFetchResponses: Map<string, { status: number; body: unknown }>;
23
- let fetchCalls: Array<{ url: string; method: string; body?: string }>;
24
-
25
- function mockApiResponse(path: string, body: unknown, status = 200) {
26
- mockFetchResponses.set(path, { status, body });
27
- }
28
-
29
- function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
30
- const url = typeof input === 'string' ? input : input.toString();
31
- const pathname = url.startsWith('http') ? new URL(url).pathname + new URL(url).search : url;
32
- const method = init?.method ?? 'GET';
33
- const key = `${method}:${pathname}`;
34
-
35
- fetchCalls.push({ url: pathname, method, body: init?.body as string | undefined });
36
-
37
- // Try exact method:path match first, then just path for GET
38
- const match =
39
- mockFetchResponses.get(key) ??
40
- (method === 'GET' ? mockFetchResponses.get(pathname) : undefined);
41
-
42
- if (!match) {
43
- // Try prefix match for URLs with query params
44
- for (const [mockKey, mockValue] of mockFetchResponses.entries()) {
45
- const mockMethod = mockKey.includes(':') ? mockKey.split(':')[0] : 'GET';
46
- const mockPath = mockKey.includes(':') ? mockKey.slice(mockKey.indexOf(':') + 1) : mockKey;
47
- if (mockMethod === method && pathname.startsWith(mockPath.split('?')[0])) {
48
- return Promise.resolve(
49
- new Response(JSON.stringify(mockValue.body), {
50
- status: mockValue.status,
51
- headers: { 'Content-Type': 'application/json' },
52
- }),
53
- );
54
- }
55
- }
56
-
57
- return Promise.resolve(new Response(JSON.stringify({ message: 'Not found' }), { status: 404 }));
58
- }
59
-
60
- return Promise.resolve(
61
- new Response(JSON.stringify(match.body), {
62
- status: match.status,
63
- headers: { 'Content-Type': 'application/json' },
64
- }),
65
- );
66
- }
67
-
68
- // --- Test Widgets ---
69
-
70
- function TestButton({ props, on }: WidgetProps) {
71
- return (
72
- <button data-testid="test-button" onClick={() => on?.click?.()}>
73
- {String(props?.label ?? 'Button')}
74
- </button>
75
- );
76
- }
77
-
78
- function TestInput({ bind, on }: WidgetProps) {
79
- return (
80
- <input
81
- data-testid="test-input"
82
- value={String(bind?.value ?? '')}
83
- onChange={(e) => {
84
- bind?.setValue?.(e.target.value);
85
- on?.change?.(e.target.value);
86
- }}
87
- />
88
- );
89
- }
90
-
91
- function TestDisplay({ props, bind }: WidgetProps) {
92
- return <span data-testid="test-display">{String(bind?.value ?? props?.text ?? '')}</span>;
93
- }
94
-
95
- function TestGroup({ children }: WidgetProps) {
96
- return <div data-testid="test-group">{children}</div>;
97
- }
98
-
99
- // --- Test Wrapper ---
100
-
101
- function createWrapper() {
102
- const queryClient = new QueryClient({
103
- defaultOptions: { queries: { retry: false } },
104
- });
105
-
106
- function Wrapper({ children }: { children: React.ReactNode }) {
107
- return (
108
- <QueryClientProvider client={queryClient}>
109
- <ShellAPIProvider>
110
- <DrawerProvider>{children}</DrawerProvider>
111
- </ShellAPIProvider>
112
- </QueryClientProvider>
113
- );
114
- }
115
-
116
- return { Wrapper, queryClient, cleanup: () => {} };
117
- }
118
-
119
- // --- Setup/Teardown ---
120
-
121
- beforeEach(() => {
122
- mockFetchResponses = new Map();
123
- fetchCalls = [];
124
- vi.stubGlobal('fetch', mockFetch);
125
- clearWidgetRegistry();
126
-
127
- registerWidget(
128
- {
129
- name: 'test-button',
130
- label: 'Test Button',
131
- category: 'action',
132
- schema: {},
133
- binding: 'none',
134
- triggers: ['click'],
135
- container: false,
136
- },
137
- TestButton,
138
- );
139
- registerWidget(
140
- {
141
- name: 'test-input',
142
- label: 'Test Input',
143
- category: 'input',
144
- schema: {},
145
- binding: 'field',
146
- triggers: ['change'],
147
- container: false,
148
- },
149
- TestInput,
150
- );
151
- registerWidget(
152
- {
153
- name: 'test-display',
154
- label: 'Test Display',
155
- category: 'display',
156
- schema: {},
157
- binding: 'expression',
158
- triggers: [],
159
- container: false,
160
- },
161
- TestDisplay,
162
- );
163
- registerWidget(
164
- {
165
- name: 'test-group',
166
- label: 'Test Group',
167
- category: 'layout',
168
- schema: {},
169
- binding: 'none',
170
- triggers: [],
171
- container: true,
172
- },
173
- TestGroup,
174
- );
175
- });
176
-
177
- afterEach(() => {
178
- cleanup();
179
- vi.restoreAllMocks();
180
- clearWidgetRegistry();
181
- });
182
-
183
- // --- Tests ---
184
-
185
- describe('WidgetSlotRenderer — shell integration', () => {
186
- describe('basic rendering', () => {
187
- it('renders widget nodes with record context', () => {
188
- const { Wrapper } = createWrapper();
189
- const nodes: WidgetNode[] = [{ type: 'test-display', bind: { expression: '{{name}}' } }];
190
-
191
- render(
192
- <Wrapper>
193
- <WidgetSlotRenderer
194
- nodes={nodes}
195
- record={{ name: 'John Doe', id: '1' }}
196
- model="contacts.contact"
197
- />
198
- </Wrapper>,
199
- );
200
-
201
- expect(screen.getByTestId('test-display')).toHaveTextContent('John Doe');
202
- });
203
-
204
- it('renders nested container with children', () => {
205
- const { Wrapper } = createWrapper();
206
- const nodes: WidgetNode[] = [
207
- {
208
- type: 'test-group',
209
- children: [
210
- { type: 'test-button', props: { label: 'Save' } },
211
- { type: 'test-button', props: { label: 'Cancel' } },
212
- ],
213
- },
214
- ];
215
-
216
- render(
217
- <Wrapper>
218
- <WidgetSlotRenderer nodes={nodes} />
219
- </Wrapper>,
220
- );
221
-
222
- expect(screen.getByTestId('test-group')).toBeInTheDocument();
223
- expect(screen.getAllByTestId('test-button')).toHaveLength(2);
224
- });
225
-
226
- it('renders multiple slots independently', () => {
227
- const { Wrapper } = createWrapper();
228
- const nodes: WidgetNode[] = [
229
- { type: 'test-display', props: { text: 'Slot A' } },
230
- { type: 'test-display', props: { text: 'Slot B' } },
231
- ];
232
-
233
- render(
234
- <Wrapper>
235
- <WidgetSlotRenderer nodes={nodes} />
236
- </Wrapper>,
237
- );
238
-
239
- const displays = screen.getAllByTestId('test-display');
240
- expect(displays).toHaveLength(2);
241
- expect(displays[0]).toHaveTextContent('Slot A');
242
- expect(displays[1]).toHaveTextContent('Slot B');
243
- });
244
-
245
- it('renders empty nodes array without crashing', () => {
246
- const { Wrapper } = createWrapper();
247
- const { container } = render(
248
- <Wrapper>
249
- <WidgetSlotRenderer nodes={[]} />
250
- </Wrapper>,
251
- );
252
- expect(container).toBeTruthy();
253
- });
254
- });
255
-
256
- describe('record state management', () => {
257
- it('updates record state when setValue is called via binding', async () => {
258
- const onRecordChange = vi.fn();
259
- const { Wrapper } = createWrapper();
260
- const nodes: WidgetNode[] = [
261
- { type: 'test-input', bind: { field: 'name' } },
262
- { type: 'test-display', bind: { expression: '{{name}}' } },
263
- ];
264
-
265
- render(
266
- <Wrapper>
267
- <WidgetSlotRenderer
268
- nodes={nodes}
269
- record={{ name: 'initial' }}
270
- model="contacts.contact"
271
- onRecordChange={onRecordChange}
272
- />
273
- </Wrapper>,
274
- );
275
-
276
- const input = screen.getByTestId('test-input');
277
- fireEvent.change(input, { target: { value: 'updated' } });
278
-
279
- await waitFor(() => {
280
- expect(onRecordChange).toHaveBeenCalledWith(expect.objectContaining({ name: 'updated' }));
281
- });
282
- });
283
-
284
- it('handles rapid sequential setValue calls without losing state', async () => {
285
- const onRecordChange = vi.fn();
286
- const { Wrapper } = createWrapper();
287
- const nodes: WidgetNode[] = [{ type: 'test-input', bind: { field: 'field1' } }];
288
-
289
- render(
290
- <Wrapper>
291
- <WidgetSlotRenderer
292
- nodes={nodes}
293
- record={{ field1: '' }}
294
- model="test.model"
295
- onRecordChange={onRecordChange}
296
- />
297
- </Wrapper>,
298
- );
299
-
300
- const input = screen.getByTestId('test-input');
301
- fireEvent.change(input, { target: { value: 'a' } });
302
- fireEvent.change(input, { target: { value: 'ab' } });
303
- fireEvent.change(input, { target: { value: 'abc' } });
304
-
305
- await waitFor(() => {
306
- expect(onRecordChange).toHaveBeenLastCalledWith(expect.objectContaining({ field1: 'abc' }));
307
- });
308
- });
309
-
310
- it('preserves other record fields when updating one field', async () => {
311
- const onRecordChange = vi.fn();
312
- const { Wrapper } = createWrapper();
313
- const nodes: WidgetNode[] = [{ type: 'test-input', bind: { field: 'name' } }];
314
-
315
- render(
316
- <Wrapper>
317
- <WidgetSlotRenderer
318
- nodes={nodes}
319
- record={{ name: 'old', email: 'test@test.com', age: 25 }}
320
- model="contacts.contact"
321
- onRecordChange={onRecordChange}
322
- />
323
- </Wrapper>,
324
- );
325
-
326
- const input = screen.getByTestId('test-input');
327
- fireEvent.change(input, { target: { value: 'new' } });
328
-
329
- await waitFor(() => {
330
- expect(onRecordChange).toHaveBeenCalledWith({
331
- name: 'new',
332
- email: 'test@test.com',
333
- age: 25,
334
- });
335
- });
336
- });
337
- });
338
-
339
- describe('action handlers — navigate', () => {
340
- it('dispatches navigation via navigate action', async () => {
341
- const { Wrapper } = createWrapper();
342
- mockNavigate.mockClear();
343
-
344
- const nodes: WidgetNode[] = [
345
- {
346
- type: 'test-button',
347
- props: { label: 'Go' },
348
- on: { click: { type: 'navigate', path: '/orders/123' } },
349
- },
350
- ];
351
-
352
- render(
353
- <Wrapper>
354
- <WidgetSlotRenderer nodes={nodes} record={{ id: '123' }} model="sales.order" />
355
- </Wrapper>,
356
- );
357
-
358
- fireEvent.click(screen.getByTestId('test-button'));
359
-
360
- await waitFor(() => {
361
- expect(mockNavigate).toHaveBeenCalledWith('/orders/123');
362
- });
363
- });
364
-
365
- it('resolves expressions in navigate path', async () => {
366
- const { Wrapper } = createWrapper();
367
- mockNavigate.mockClear();
368
-
369
- const nodes: WidgetNode[] = [
370
- {
371
- type: 'test-button',
372
- props: { label: 'View' },
373
- on: { click: { type: 'navigate', path: '/orders/{{id}}' } },
374
- },
375
- ];
376
-
377
- render(
378
- <Wrapper>
379
- <WidgetSlotRenderer nodes={nodes} record={{ id: '456' }} model="sales.order" />
380
- </Wrapper>,
381
- );
382
-
383
- fireEvent.click(screen.getByTestId('test-button'));
384
-
385
- await waitFor(() => {
386
- expect(mockNavigate).toHaveBeenCalledWith('/orders/456');
387
- });
388
- });
389
- });
390
-
391
- describe('action handlers — model CRUD', () => {
392
- it('calls model.create with resolved data', async () => {
393
- const { Wrapper } = createWrapper();
394
- mockApiResponse('POST:/api/sales/order', { id: 'new-1', status: 'draft' }, 201);
395
-
396
- const nodes: WidgetNode[] = [
397
- {
398
- type: 'test-button',
399
- props: { label: 'Create' },
400
- on: {
401
- click: {
402
- type: 'model.create',
403
- model: 'sales.order',
404
- data: { status: 'draft', customer: '{{customer_id}}' },
405
- },
406
- },
407
- },
408
- ];
409
-
410
- render(
411
- <Wrapper>
412
- <WidgetSlotRenderer
413
- nodes={nodes}
414
- record={{ customer_id: 'cust-99' }}
415
- model="sales.order"
416
- />
417
- </Wrapper>,
418
- );
419
-
420
- fireEvent.click(screen.getByTestId('test-button'));
421
-
422
- await waitFor(() => {
423
- const createCall = fetchCalls.find(
424
- (c) => c.method === 'POST' && c.url.includes('/api/sales/order'),
425
- );
426
- expect(createCall).toBeDefined();
427
- const body = JSON.parse(createCall!.body!);
428
- expect(body.customer).toBe('cust-99');
429
- expect(body.status).toBe('draft');
430
- });
431
- });
432
-
433
- it('calls model.update with correct id and data', async () => {
434
- const { Wrapper } = createWrapper();
435
- mockApiResponse('PUT:/api/sales/order/order-1', { id: 'order-1', status: 'confirmed' });
436
-
437
- const nodes: WidgetNode[] = [
438
- {
439
- type: 'test-button',
440
- props: { label: 'Confirm' },
441
- on: {
442
- click: {
443
- type: 'model.update',
444
- model: 'sales.order',
445
- id: '{{id}}',
446
- data: { status: 'confirmed' },
447
- },
448
- },
449
- },
450
- ];
451
-
452
- render(
453
- <Wrapper>
454
- <WidgetSlotRenderer
455
- nodes={nodes}
456
- record={{ id: 'order-1', status: 'draft' }}
457
- model="sales.order"
458
- />
459
- </Wrapper>,
460
- );
461
-
462
- fireEvent.click(screen.getByTestId('test-button'));
463
-
464
- await waitFor(() => {
465
- const updateCall = fetchCalls.find(
466
- (c) => c.method === 'PUT' && c.url.includes('/api/sales/order/order-1'),
467
- );
468
- expect(updateCall).toBeDefined();
469
- });
470
- });
471
-
472
- it('calls model.delete and invalidates queries', async () => {
473
- const { Wrapper, queryClient } = createWrapper();
474
- mockApiResponse('DELETE:/api/hr/payslip/pay-1', null);
475
- const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
476
-
477
- const nodes: WidgetNode[] = [
478
- {
479
- type: 'test-button',
480
- props: { label: 'Delete' },
481
- on: {
482
- click: {
483
- type: 'model.delete',
484
- model: 'hr.payslip',
485
- id: '{{id}}',
486
- },
487
- },
488
- },
489
- ];
490
-
491
- render(
492
- <Wrapper>
493
- <WidgetSlotRenderer nodes={nodes} record={{ id: 'pay-1' }} model="hr.payslip" />
494
- </Wrapper>,
495
- );
496
-
497
- fireEvent.click(screen.getByTestId('test-button'));
498
-
499
- await waitFor(() => {
500
- expect(invalidateSpy).toHaveBeenCalledWith(
501
- expect.objectContaining({ queryKey: ['model', 'hr.payslip'] }),
502
- );
503
- });
504
- });
505
-
506
- it('calls model.fetch and loads into record', async () => {
507
- const { Wrapper } = createWrapper();
508
- mockApiResponse('/api/sales/order/fetch-1', {
509
- id: 'fetch-1',
510
- total: 500,
511
- status: 'confirmed',
512
- });
513
-
514
- const onRecordChange = vi.fn();
515
- const nodes: WidgetNode[] = [
516
- {
517
- type: 'test-button',
518
- props: { label: 'Load' },
519
- on: {
520
- click: {
521
- type: 'model.fetch',
522
- model: 'sales.order',
523
- id: 'fetch-1',
524
- into: '$record',
525
- },
526
- },
527
- },
528
- ];
529
-
530
- render(
531
- <Wrapper>
532
- <WidgetSlotRenderer
533
- nodes={nodes}
534
- record={{}}
535
- model="sales.order"
536
- onRecordChange={onRecordChange}
537
- />
538
- </Wrapper>,
539
- );
540
-
541
- fireEvent.click(screen.getByTestId('test-button'));
542
-
543
- await waitFor(() => {
544
- expect(onRecordChange).toHaveBeenCalled();
545
- });
546
- });
547
-
548
- it('calls model.list with filters', async () => {
549
- const { Wrapper } = createWrapper();
550
- mockApiResponse('GET:/api/sales/order', {
551
- data: [
552
- { id: '1', status: 'draft' },
553
- { id: '2', status: 'draft' },
554
- ],
555
- });
556
-
557
- const nodes: WidgetNode[] = [
558
- {
559
- type: 'test-button',
560
- props: { label: 'List' },
561
- on: {
562
- click: {
563
- type: 'model.list',
564
- model: 'sales.order',
565
- filters: { status: 'draft' },
566
- into: '$state.orders',
567
- },
568
- },
569
- },
570
- ];
571
-
572
- render(
573
- <Wrapper>
574
- <WidgetSlotRenderer nodes={nodes} record={{}} model="sales.order" />
575
- </Wrapper>,
576
- );
577
-
578
- fireEvent.click(screen.getByTestId('test-button'));
579
-
580
- await waitFor(() => {
581
- const listCall = fetchCalls.find(
582
- (c) =>
583
- c.method === 'GET' && c.url.includes('/api/sales/order') && c.url.includes('filter'),
584
- );
585
- expect(listCall).toBeDefined();
586
- });
587
- });
588
-
589
- it('handles model.create failure gracefully', async () => {
590
- const { Wrapper } = createWrapper();
591
- mockApiResponse('POST:/api/sales/order', { message: 'Validation failed' }, 400);
592
-
593
- const nodes: WidgetNode[] = [
594
- {
595
- type: 'test-button',
596
- props: { label: 'Create' },
597
- on: {
598
- click: {
599
- type: 'model.create',
600
- model: 'sales.order',
601
- data: { status: 'invalid' },
602
- },
603
- },
604
- },
605
- ];
606
-
607
- // Should not throw — action errors are silently caught
608
- render(
609
- <Wrapper>
610
- <WidgetSlotRenderer nodes={nodes} record={{}} model="sales.order" />
611
- </Wrapper>,
612
- );
613
-
614
- // Click should not crash the renderer
615
- fireEvent.click(screen.getByTestId('test-button'));
616
-
617
- await waitFor(() => {
618
- expect(screen.getByTestId('test-button')).toBeInTheDocument();
619
- });
620
- });
621
- });
622
-
623
- describe('action handlers — service calls', () => {
624
- it('calls service endpoint with resolved params', async () => {
625
- const { Wrapper } = createWrapper();
626
- mockApiResponse('POST:/api/services/approve-order', { approved: true });
627
-
628
- const nodes: WidgetNode[] = [
629
- {
630
- type: 'test-button',
631
- props: { label: 'Approve' },
632
- on: {
633
- click: {
634
- type: 'service',
635
- name: 'approve-order',
636
- params: { orderId: '{{id}}', force: true },
637
- },
638
- },
639
- },
640
- ];
641
-
642
- render(
643
- <Wrapper>
644
- <WidgetSlotRenderer nodes={nodes} record={{ id: 'ord-42' }} model="sales.order" />
645
- </Wrapper>,
646
- );
647
-
648
- fireEvent.click(screen.getByTestId('test-button'));
649
-
650
- await waitFor(() => {
651
- const serviceCall = fetchCalls.find(
652
- (c) => c.method === 'POST' && c.url.includes('/api/services/approve-order'),
653
- );
654
- expect(serviceCall).toBeDefined();
655
- const body = JSON.parse(serviceCall!.body!);
656
- expect(body.orderId).toBe('ord-42');
657
- expect(body.force).toBe(true);
658
- });
659
- });
660
-
661
- it('executes onSuccess action after successful service call', async () => {
662
- const { Wrapper } = createWrapper();
663
- mockApiResponse('POST:/api/services/calc-total', { total: 1500 });
664
-
665
- const onRecordChange = vi.fn();
666
- const nodes: WidgetNode[] = [
667
- {
668
- type: 'test-button',
669
- props: { label: 'Calculate' },
670
- on: {
671
- click: {
672
- type: 'service',
673
- name: 'calc-total',
674
- params: { items: '{{items}}' },
675
- onSuccess: {
676
- type: 'setValue',
677
- field: 'total',
678
- value: '{{$response.total}}',
679
- },
680
- },
681
- },
682
- },
683
- ];
684
-
685
- render(
686
- <Wrapper>
687
- <WidgetSlotRenderer
688
- nodes={nodes}
689
- record={{ items: [{ amount: 500 }, { amount: 1000 }], total: 0 }}
690
- model="sales.order"
691
- onRecordChange={onRecordChange}
692
- />
693
- </Wrapper>,
694
- );
695
-
696
- fireEvent.click(screen.getByTestId('test-button'));
697
-
698
- await waitFor(() => {
699
- expect(onRecordChange).toHaveBeenCalledWith(expect.objectContaining({ total: 1500 }));
700
- });
701
- });
702
-
703
- it('executes onError action after failed service call', async () => {
704
- const { Wrapper } = createWrapper();
705
- mockApiResponse('POST:/api/services/risky-op', { message: 'Server error' }, 500);
706
-
707
- const onRecordChange = vi.fn();
708
- const nodes: WidgetNode[] = [
709
- {
710
- type: 'test-button',
711
- props: { label: 'Risky' },
712
- on: {
713
- click: {
714
- type: 'service',
715
- name: 'risky-op',
716
- params: {},
717
- onError: {
718
- type: 'setValue',
719
- field: '$state.error',
720
- value: '{{$response.message}}',
721
- },
722
- },
723
- },
724
- },
725
- ];
726
-
727
- render(
728
- <Wrapper>
729
- <WidgetSlotRenderer
730
- nodes={nodes}
731
- record={{}}
732
- model="sales.order"
733
- onRecordChange={onRecordChange}
734
- />
735
- </Wrapper>,
736
- );
737
-
738
- fireEvent.click(screen.getByTestId('test-button'));
739
-
740
- // Should not crash
741
- await waitFor(() => {
742
- expect(screen.getByTestId('test-button')).toBeInTheDocument();
743
- });
744
- });
745
- });
746
-
747
- describe('action handlers — $state integration', () => {
748
- it('setValue writes to $state and affects visibility', async () => {
749
- const { Wrapper } = createWrapper();
750
-
751
- const nodes: WidgetNode[] = [
752
- {
753
- type: 'test-button',
754
- props: { label: 'Show Details' },
755
- on: { click: { type: 'setValue', field: '$state.showDetails', value: true } },
756
- },
757
- {
758
- type: 'test-display',
759
- props: { text: 'Details visible' },
760
- visible: { field: '$state.showDetails', operator: 'eq', value: true },
761
- },
762
- ];
763
-
764
- render(
765
- <Wrapper>
766
- <WidgetSlotRenderer nodes={nodes} record={{}} model="sales.order" />
767
- </Wrapper>,
768
- );
769
-
770
- expect(screen.queryByText('Details visible')).not.toBeInTheDocument();
771
-
772
- fireEvent.click(screen.getByTestId('test-button'));
773
-
774
- await waitFor(() => {
775
- expect(screen.getByText('Details visible')).toBeInTheDocument();
776
- });
777
- });
778
-
779
- it('setValues updates multiple $state keys atomically', async () => {
780
- const { Wrapper } = createWrapper();
781
-
782
- const nodes: WidgetNode[] = [
783
- {
784
- type: 'test-button',
785
- props: { label: 'Set Multiple' },
786
- on: {
787
- click: {
788
- type: 'setValues',
789
- values: {
790
- '$state.step': 2,
791
- '$state.loading': false,
792
- },
793
- },
794
- },
795
- },
796
- {
797
- type: 'test-display',
798
- props: { text: 'Step 2' },
799
- visible: { field: '$state.step', operator: 'eq', value: 2 },
800
- },
801
- ];
802
-
803
- render(
804
- <Wrapper>
805
- <WidgetSlotRenderer nodes={nodes} record={{}} model="sales.order" />
806
- </Wrapper>,
807
- );
808
-
809
- expect(screen.queryByText('Step 2')).not.toBeInTheDocument();
810
-
811
- fireEvent.click(screen.getByTestId('test-button'));
812
-
813
- await waitFor(() => {
814
- expect(screen.getByText('Step 2')).toBeInTheDocument();
815
- });
816
- });
817
- });
818
-
819
- describe('action handlers — sequence and conditional', () => {
820
- it('executes sequence of actions in order', async () => {
821
- const { Wrapper } = createWrapper();
822
- mockApiResponse('POST:/api/services/save', { success: true });
823
- mockNavigate.mockClear();
824
-
825
- const nodes: WidgetNode[] = [
826
- {
827
- type: 'test-button',
828
- props: { label: 'Save & Navigate' },
829
- on: {
830
- click: {
831
- type: 'sequence',
832
- actions: [
833
- { type: 'setValue', field: '$state.saving', value: true },
834
- { type: 'service', name: 'save', params: {} },
835
- { type: 'setValue', field: '$state.saving', value: false },
836
- { type: 'navigate', path: '/dashboard' },
837
- ],
838
- },
839
- },
840
- },
841
- ];
842
-
843
- render(
844
- <Wrapper>
845
- <WidgetSlotRenderer nodes={nodes} record={{}} model="sales.order" />
846
- </Wrapper>,
847
- );
848
-
849
- fireEvent.click(screen.getByTestId('test-button'));
850
-
851
- await waitFor(() => {
852
- expect(mockNavigate).toHaveBeenCalledWith('/dashboard');
853
- });
854
- });
855
-
856
- it('executes conditional action — then branch', async () => {
857
- const { Wrapper } = createWrapper();
858
- const onRecordChange = vi.fn();
859
-
860
- const nodes: WidgetNode[] = [
861
- {
862
- type: 'test-button',
863
- props: { label: 'Check' },
864
- on: {
865
- click: {
866
- type: 'conditional',
867
- condition: { field: 'status', operator: 'eq', value: 'draft' },
868
- then: { type: 'setValue', field: 'status', value: 'confirmed' },
869
- else: { type: 'setValue', field: 'status', value: 'draft' },
870
- },
871
- },
872
- },
873
- ];
874
-
875
- render(
876
- <Wrapper>
877
- <WidgetSlotRenderer
878
- nodes={nodes}
879
- record={{ status: 'draft' }}
880
- model="sales.order"
881
- onRecordChange={onRecordChange}
882
- />
883
- </Wrapper>,
884
- );
885
-
886
- fireEvent.click(screen.getByTestId('test-button'));
887
-
888
- await waitFor(() => {
889
- expect(onRecordChange).toHaveBeenCalledWith(
890
- expect.objectContaining({ status: 'confirmed' }),
891
- );
892
- });
893
- });
894
-
895
- it('executes conditional action — else branch', async () => {
896
- const { Wrapper } = createWrapper();
897
- const onRecordChange = vi.fn();
898
-
899
- const nodes: WidgetNode[] = [
900
- {
901
- type: 'test-button',
902
- props: { label: 'Check' },
903
- on: {
904
- click: {
905
- type: 'conditional',
906
- condition: { field: 'status', operator: 'eq', value: 'draft' },
907
- then: { type: 'setValue', field: 'status', value: 'confirmed' },
908
- else: { type: 'setValue', field: 'status', value: 'reverted' },
909
- },
910
- },
911
- },
912
- ];
913
-
914
- render(
915
- <Wrapper>
916
- <WidgetSlotRenderer
917
- nodes={nodes}
918
- record={{ status: 'confirmed' }}
919
- model="sales.order"
920
- onRecordChange={onRecordChange}
921
- />
922
- </Wrapper>,
923
- );
924
-
925
- fireEvent.click(screen.getByTestId('test-button'));
926
-
927
- await waitFor(() => {
928
- expect(onRecordChange).toHaveBeenCalledWith(
929
- expect.objectContaining({ status: 'reverted' }),
930
- );
931
- });
932
- });
933
- });
934
-
935
- describe('action handlers — refreshSource', () => {
936
- it('invalidates model query when refreshSource fires', async () => {
937
- const { Wrapper, queryClient } = createWrapper();
938
- const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
939
-
940
- const nodes: WidgetNode[] = [
941
- {
942
- type: 'test-button',
943
- props: { label: 'Refresh' },
944
- on: { click: { type: 'refreshSource' } },
945
- },
946
- ];
947
-
948
- render(
949
- <Wrapper>
950
- <WidgetSlotRenderer
951
- nodes={nodes}
952
- record={{}}
953
- model="sales.order"
954
- sourceQueryKey={['model', 'sales.order', {}]}
955
- />
956
- </Wrapper>,
957
- );
958
-
959
- fireEvent.click(screen.getByTestId('test-button'));
960
-
961
- await waitFor(() => {
962
- expect(invalidateSpy).toHaveBeenCalledWith(
963
- expect.objectContaining({ queryKey: ['model', 'sales.order', {}] }),
964
- );
965
- });
966
- });
967
-
968
- it('falls back to model key when no sourceQueryKey provided', async () => {
969
- const { Wrapper, queryClient } = createWrapper();
970
- const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
971
-
972
- const nodes: WidgetNode[] = [
973
- {
974
- type: 'test-button',
975
- props: { label: 'Refresh' },
976
- on: { click: { type: 'refreshSource' } },
977
- },
978
- ];
979
-
980
- render(
981
- <Wrapper>
982
- <WidgetSlotRenderer nodes={nodes} record={{}} model="hr.payslip" />
983
- </Wrapper>,
984
- );
985
-
986
- fireEvent.click(screen.getByTestId('test-button'));
987
-
988
- await waitFor(() => {
989
- expect(invalidateSpy).toHaveBeenCalledWith(
990
- expect.objectContaining({ queryKey: ['model', 'hr.payslip'] }),
991
- );
992
- });
993
- });
994
- });
995
-
996
- describe('expression resolution in context', () => {
997
- it('resolves nested record field expressions', () => {
998
- const { Wrapper } = createWrapper();
999
- const nodes: WidgetNode[] = [
1000
- {
1001
- type: 'test-display',
1002
- bind: { expression: '{{qty * rate}}' },
1003
- },
1004
- ];
1005
-
1006
- render(
1007
- <Wrapper>
1008
- <WidgetSlotRenderer nodes={nodes} record={{ qty: 5, rate: 100 }} model="sales.line" />
1009
- </Wrapper>,
1010
- );
1011
-
1012
- expect(screen.getByTestId('test-display')).toHaveTextContent('500');
1013
- });
1014
-
1015
- it('resolves template strings with multiple expressions', () => {
1016
- const { Wrapper } = createWrapper();
1017
- const nodes: WidgetNode[] = [
1018
- {
1019
- type: 'test-display',
1020
- props: { text: '{{name}} - {{status}}' },
1021
- },
1022
- ];
1023
-
1024
- render(
1025
- <Wrapper>
1026
- <WidgetSlotRenderer
1027
- nodes={nodes}
1028
- record={{ name: 'Order #1', status: 'draft' }}
1029
- model="sales.order"
1030
- />
1031
- </Wrapper>,
1032
- );
1033
-
1034
- expect(screen.getByTestId('test-display')).toHaveTextContent('Order #1 - draft');
1035
- });
1036
-
1037
- it('handles null/undefined field gracefully in expressions', () => {
1038
- const { Wrapper } = createWrapper();
1039
- const nodes: WidgetNode[] = [
1040
- {
1041
- type: 'test-display',
1042
- bind: { expression: '{{missing_field}}' },
1043
- },
1044
- ];
1045
-
1046
- render(
1047
- <Wrapper>
1048
- <WidgetSlotRenderer nodes={nodes} record={{}} model="sales.order" />
1049
- </Wrapper>,
1050
- );
1051
-
1052
- // Should render without crashing, showing empty or null
1053
- expect(screen.getByTestId('test-display')).toBeInTheDocument();
1054
- });
1055
- });
1056
-
1057
- describe('conditional rendering', () => {
1058
- it('hides widget when condition is not met', () => {
1059
- const { Wrapper } = createWrapper();
1060
- const nodes: WidgetNode[] = [
1061
- {
1062
- type: 'test-display',
1063
- props: { text: 'Only for draft' },
1064
- visible: { field: 'status', operator: 'eq', value: 'draft' },
1065
- },
1066
- ];
1067
-
1068
- render(
1069
- <Wrapper>
1070
- <WidgetSlotRenderer nodes={nodes} record={{ status: 'confirmed' }} model="sales.order" />
1071
- </Wrapper>,
1072
- );
1073
-
1074
- expect(screen.queryByText('Only for draft')).not.toBeInTheDocument();
1075
- });
1076
-
1077
- it('shows widget when condition is met', () => {
1078
- const { Wrapper } = createWrapper();
1079
- const nodes: WidgetNode[] = [
1080
- {
1081
- type: 'test-display',
1082
- props: { text: 'Only for draft' },
1083
- visible: { field: 'status', operator: 'eq', value: 'draft' },
1084
- },
1085
- ];
1086
-
1087
- render(
1088
- <Wrapper>
1089
- <WidgetSlotRenderer nodes={nodes} record={{ status: 'draft' }} model="sales.order" />
1090
- </Wrapper>,
1091
- );
1092
-
1093
- expect(screen.getByText('Only for draft')).toBeInTheDocument();
1094
- });
1095
-
1096
- it('evaluates multiple conditions (AND logic)', () => {
1097
- const { Wrapper } = createWrapper();
1098
- const nodes: WidgetNode[] = [
1099
- {
1100
- type: 'test-display',
1101
- props: { text: 'Draft with total' },
1102
- visible: [
1103
- { field: 'status', operator: 'eq', value: 'draft' },
1104
- { field: 'total', operator: 'gt', value: 0 },
1105
- ],
1106
- },
1107
- ];
1108
-
1109
- render(
1110
- <Wrapper>
1111
- <WidgetSlotRenderer
1112
- nodes={nodes}
1113
- record={{ status: 'draft', total: 0 }}
1114
- model="sales.order"
1115
- />
1116
- </Wrapper>,
1117
- );
1118
-
1119
- expect(screen.queryByText('Draft with total')).not.toBeInTheDocument();
1120
- });
1121
-
1122
- it('shows widget when all AND conditions pass', () => {
1123
- const { Wrapper } = createWrapper();
1124
- const nodes: WidgetNode[] = [
1125
- {
1126
- type: 'test-display',
1127
- props: { text: 'Draft with total' },
1128
- visible: [
1129
- { field: 'status', operator: 'eq', value: 'draft' },
1130
- { field: 'total', operator: 'gt', value: 0 },
1131
- ],
1132
- },
1133
- ];
1134
-
1135
- render(
1136
- <Wrapper>
1137
- <WidgetSlotRenderer
1138
- nodes={nodes}
1139
- record={{ status: 'draft', total: 100 }}
1140
- model="sales.order"
1141
- />
1142
- </Wrapper>,
1143
- );
1144
-
1145
- expect(screen.getByText('Draft with total')).toBeInTheDocument();
1146
- });
1147
- });
1148
-
1149
- describe('edge cases and stress tests', () => {
1150
- it('handles deeply nested widget tree (5 levels)', () => {
1151
- const { Wrapper } = createWrapper();
1152
-
1153
- const deepNode: WidgetNode = {
1154
- type: 'test-group',
1155
- children: [
1156
- {
1157
- type: 'test-group',
1158
- children: [
1159
- {
1160
- type: 'test-group',
1161
- children: [
1162
- {
1163
- type: 'test-group',
1164
- children: [{ type: 'test-display', props: { text: 'deeply nested' } }],
1165
- },
1166
- ],
1167
- },
1168
- ],
1169
- },
1170
- ],
1171
- };
1172
-
1173
- render(
1174
- <Wrapper>
1175
- <WidgetSlotRenderer nodes={[deepNode]} record={{}} model="test.model" />
1176
- </Wrapper>,
1177
- );
1178
-
1179
- expect(screen.getByText('deeply nested')).toBeInTheDocument();
1180
- expect(screen.getAllByTestId('test-group')).toHaveLength(4);
1181
- });
1182
-
1183
- it('handles large number of nodes (50 widgets)', () => {
1184
- const { Wrapper } = createWrapper();
1185
- const nodes: WidgetNode[] = Array.from({ length: 50 }, (_, i) => ({
1186
- type: 'test-display',
1187
- props: { text: `item-${i}` },
1188
- }));
1189
-
1190
- render(
1191
- <Wrapper>
1192
- <WidgetSlotRenderer nodes={nodes} record={{}} model="test.model" />
1193
- </Wrapper>,
1194
- );
1195
-
1196
- expect(screen.getAllByTestId('test-display')).toHaveLength(50);
1197
- expect(screen.getByText('item-0')).toBeInTheDocument();
1198
- expect(screen.getByText('item-49')).toBeInTheDocument();
1199
- });
1200
-
1201
- it('handles unknown widget type gracefully', () => {
1202
- const { Wrapper } = createWrapper();
1203
- const nodes: WidgetNode[] = [
1204
- { type: 'nonexistent-widget', props: { label: 'Ghost' } },
1205
- { type: 'test-display', props: { text: 'still renders' } },
1206
- ];
1207
-
1208
- render(
1209
- <Wrapper>
1210
- <WidgetSlotRenderer nodes={nodes} record={{}} model="test.model" />
1211
- </Wrapper>,
1212
- );
1213
-
1214
- expect(screen.getByText('still renders')).toBeInTheDocument();
1215
- });
1216
-
1217
- it('handles concurrent actions without race conditions', async () => {
1218
- const { Wrapper } = createWrapper();
1219
- mockApiResponse('POST:/api/services/slow', { result: 'done' });
1220
- mockApiResponse('POST:/api/services/fast', { result: 'quick' });
1221
-
1222
- const nodes: WidgetNode[] = [
1223
- {
1224
- type: 'test-button',
1225
- props: { label: 'Fire Both' },
1226
- on: {
1227
- click: {
1228
- type: 'sequence',
1229
- actions: [
1230
- { type: 'service', name: 'slow', params: {} },
1231
- { type: 'service', name: 'fast', params: {} },
1232
- { type: 'setValue', field: '$state.done', value: true },
1233
- ],
1234
- },
1235
- },
1236
- },
1237
- {
1238
- type: 'test-display',
1239
- props: { text: 'All done' },
1240
- visible: { field: '$state.done', operator: 'eq', value: true },
1241
- },
1242
- ];
1243
-
1244
- render(
1245
- <Wrapper>
1246
- <WidgetSlotRenderer nodes={nodes} record={{}} model="test.model" />
1247
- </Wrapper>,
1248
- );
1249
-
1250
- fireEvent.click(screen.getByTestId('test-button'));
1251
-
1252
- await waitFor(() => {
1253
- expect(screen.getByText('All done')).toBeInTheDocument();
1254
- });
1255
- });
1256
-
1257
- it('handles model with no record gracefully', () => {
1258
- const { Wrapper } = createWrapper();
1259
- const nodes: WidgetNode[] = [{ type: 'test-input', bind: { field: 'name' } }];
1260
-
1261
- render(
1262
- <Wrapper>
1263
- <WidgetSlotRenderer nodes={nodes} model="sales.order" />
1264
- </Wrapper>,
1265
- );
1266
-
1267
- expect(screen.getByTestId('test-input')).toBeInTheDocument();
1268
- });
1269
-
1270
- it('handles widget with no props, no bind, no triggers', () => {
1271
- const { Wrapper } = createWrapper();
1272
- const nodes: WidgetNode[] = [{ type: 'test-group' }];
1273
-
1274
- render(
1275
- <Wrapper>
1276
- <WidgetSlotRenderer nodes={nodes} record={{}} model="test.model" />
1277
- </Wrapper>,
1278
- );
1279
-
1280
- expect(screen.getByTestId('test-group')).toBeInTheDocument();
1281
- });
1282
-
1283
- it('handles action dispatch when model is undefined', async () => {
1284
- const { Wrapper } = createWrapper();
1285
-
1286
- const nodes: WidgetNode[] = [
1287
- {
1288
- type: 'test-button',
1289
- props: { label: 'Nav' },
1290
- on: { click: { type: 'navigate', path: '/home' } },
1291
- },
1292
- ];
1293
-
1294
- render(
1295
- <Wrapper>
1296
- <WidgetSlotRenderer nodes={nodes} record={{}} />
1297
- </Wrapper>,
1298
- );
1299
-
1300
- // Should not throw
1301
- fireEvent.click(screen.getByTestId('test-button'));
1302
-
1303
- await waitFor(() => {
1304
- expect(screen.getByTestId('test-button')).toBeInTheDocument();
1305
- });
1306
- });
1307
-
1308
- it('handles expressions with special characters in field values', () => {
1309
- const { Wrapper } = createWrapper();
1310
- const nodes: WidgetNode[] = [
1311
- {
1312
- type: 'test-display',
1313
- bind: { expression: '{{description}}' },
1314
- },
1315
- ];
1316
-
1317
- render(
1318
- <Wrapper>
1319
- <WidgetSlotRenderer
1320
- nodes={nodes}
1321
- record={{ description: 'Has "quotes" & <tags>' }}
1322
- model="test.model"
1323
- />
1324
- </Wrapper>,
1325
- );
1326
-
1327
- expect(screen.getByTestId('test-display')).toHaveTextContent('Has "quotes" & <tags>');
1328
- });
1329
-
1330
- it('handles numeric zero and boolean false as valid values', () => {
1331
- const { Wrapper } = createWrapper();
1332
- const nodes: WidgetNode[] = [{ type: 'test-display', bind: { expression: '{{count}}' } }];
1333
-
1334
- render(
1335
- <Wrapper>
1336
- <WidgetSlotRenderer nodes={nodes} record={{ count: 0 }} model="test.model" />
1337
- </Wrapper>,
1338
- );
1339
-
1340
- expect(screen.getByTestId('test-display')).toHaveTextContent('0');
1341
- });
1342
- });
1343
- });