@cosmicdrift/kumiko-renderer 0.327.0 → 0.328.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/dist/app/action-form-shim.d.ts +14 -0
  2. package/{src/app/action-form-shim.ts → dist/app/action-form-shim.js} +26 -44
  3. package/dist/app/app-features-context.d.ts +8 -0
  4. package/dist/app/app-features-context.js +16 -0
  5. package/dist/app/column-renderers.d.ts +30 -0
  6. package/dist/app/column-renderers.js +34 -0
  7. package/dist/app/config-edit-shim.d.ts +11 -0
  8. package/{src/app/config-edit-shim.ts → dist/app/config-edit-shim.js} +12 -25
  9. package/dist/app/content-editors.d.ts +38 -0
  10. package/dist/app/content-editors.js +26 -0
  11. package/dist/app/content-preview.d.ts +11 -0
  12. package/dist/app/content-preview.js +30 -0
  13. package/dist/app/custom-screens.d.ts +11 -0
  14. package/dist/app/custom-screens.js +20 -0
  15. package/dist/app/dashboard-body.d.ts +16 -0
  16. package/dist/app/dashboard-body.js +11 -0
  17. package/dist/app/extension-form-submit.d.ts +32 -0
  18. package/dist/app/extension-form-submit.js +73 -0
  19. package/dist/app/extension-sections.d.ts +72 -0
  20. package/dist/app/extension-sections.js +34 -0
  21. package/dist/app/feature-schema.d.ts +4 -0
  22. package/{src/app/feature-schema.ts → dist/app/feature-schema.js} +12 -22
  23. package/dist/app/form-schema.d.ts +4 -0
  24. package/dist/app/form-schema.js +85 -0
  25. package/dist/app/kumiko-screen.d.ts +43 -0
  26. package/dist/app/kumiko-screen.js +2206 -0
  27. package/dist/app/layout-fields.d.ts +3 -0
  28. package/dist/app/layout-fields.js +20 -0
  29. package/dist/app/list-facets.d.ts +48 -0
  30. package/dist/app/list-facets.js +185 -0
  31. package/dist/app/nav.d.ts +61 -0
  32. package/dist/app/nav.js +139 -0
  33. package/dist/app/projection-detail-shim.d.ts +8 -0
  34. package/dist/app/projection-detail-shim.js +71 -0
  35. package/dist/app/projection-list-shim.d.ts +13 -0
  36. package/{src/app/projection-list-shim.ts → dist/app/projection-list-shim.js} +26 -38
  37. package/dist/app/qn.d.ts +7 -0
  38. package/{src/app/qn.ts → dist/app/qn.js} +9 -11
  39. package/dist/app/qualify-screen-id.d.ts +2 -0
  40. package/{src/app/qualify-screen-id.ts → dist/app/qualify-screen-id.js} +5 -6
  41. package/dist/app/reference-facet-bridge.d.ts +10 -0
  42. package/dist/app/reference-facet-bridge.js +22 -0
  43. package/dist/app/return-to.d.ts +31 -0
  44. package/dist/app/return-to.js +188 -0
  45. package/dist/app/row-actions.d.ts +77 -0
  46. package/dist/app/row-actions.js +416 -0
  47. package/dist/app/screen-access.d.ts +8 -0
  48. package/dist/app/screen-access.js +48 -0
  49. package/dist/app/secret-mint-body.d.ts +10 -0
  50. package/dist/app/secret-mint-body.js +140 -0
  51. package/dist/app/secrets-edit-body.d.ts +8 -0
  52. package/dist/app/secrets-edit-body.js +76 -0
  53. package/dist/app/use-embedded-screen.d.ts +6 -0
  54. package/dist/app/use-embedded-screen.js +19 -0
  55. package/dist/app/variable-chips.d.ts +7 -0
  56. package/dist/app/variable-chips.js +16 -0
  57. package/dist/app/write-failed-error.d.ts +6 -0
  58. package/dist/app/write-failed-error.js +21 -0
  59. package/dist/components/embedded-list-field.d.ts +10 -0
  60. package/dist/components/embedded-list-field.js +259 -0
  61. package/dist/components/grid-cell-for-field.d.ts +25 -0
  62. package/dist/components/grid-cell-for-field.js +31 -0
  63. package/dist/components/multi-select-checkboxes.d.ts +14 -0
  64. package/dist/components/multi-select-checkboxes.js +36 -0
  65. package/dist/components/reference-create-dialog.d.ts +13 -0
  66. package/dist/components/reference-create-dialog.js +46 -0
  67. package/dist/components/related-list-section.d.ts +25 -0
  68. package/dist/components/related-list-section.js +273 -0
  69. package/dist/components/render-edit-action-button.d.ts +22 -0
  70. package/dist/components/render-edit-action-button.js +43 -0
  71. package/dist/components/render-edit-logic.d.ts +6 -0
  72. package/dist/components/render-edit-logic.js +63 -0
  73. package/dist/components/render-edit-types.d.ts +209 -0
  74. package/dist/components/render-edit-types.js +1 -0
  75. package/dist/components/render-edit.d.ts +6 -0
  76. package/dist/components/render-edit.js +1169 -0
  77. package/dist/components/render-field.d.ts +38 -0
  78. package/dist/components/render-field.js +544 -0
  79. package/dist/components/render-list.d.ts +117 -0
  80. package/dist/components/render-list.js +232 -0
  81. package/dist/components/write-form-section.d.ts +17 -0
  82. package/dist/components/write-form-section.js +104 -0
  83. package/dist/context/dispatcher-context.d.ts +10 -0
  84. package/{src/context/dispatcher-context.tsx → dist/context/dispatcher-context.js} +16 -29
  85. package/dist/context/draft-storage-context.d.ts +12 -0
  86. package/dist/context/draft-storage-context.js +18 -0
  87. package/dist/context/user-roles-context.d.ts +7 -0
  88. package/{src/context/user-roles-context.tsx → dist/context/user-roles-context.js} +7 -15
  89. package/dist/format-when.d.ts +1 -0
  90. package/{src/format-when.ts → dist/format-when.js} +7 -7
  91. package/dist/hooks/reference-limits.d.ts +3 -0
  92. package/{src/hooks/reference-limits.ts → dist/hooks/reference-limits.js} +0 -1
  93. package/dist/hooks/use-ai-text.d.ts +52 -0
  94. package/dist/hooks/use-ai-text.js +90 -0
  95. package/dist/hooks/use-disclosure.d.ts +7 -0
  96. package/dist/hooks/use-disclosure.js +8 -0
  97. package/dist/hooks/use-form.d.ts +7 -0
  98. package/dist/hooks/use-form.js +36 -0
  99. package/dist/hooks/use-list-url-state.d.ts +43 -0
  100. package/dist/hooks/use-list-url-state.js +131 -0
  101. package/dist/hooks/use-mutation.d.ts +9 -0
  102. package/dist/hooks/use-mutation.js +37 -0
  103. package/dist/hooks/use-query.d.ts +14 -0
  104. package/dist/hooks/use-query.js +118 -0
  105. package/dist/hooks/use-reference-lookup.d.ts +14 -0
  106. package/dist/hooks/use-reference-lookup.js +63 -0
  107. package/dist/hooks/use-store.d.ts +3 -0
  108. package/dist/hooks/use-store.js +38 -0
  109. package/dist/hooks/use-stream-handler.d.ts +14 -0
  110. package/dist/hooks/use-stream-handler.js +93 -0
  111. package/dist/i18n-defaults.d.ts +2 -0
  112. package/dist/i18n-defaults.js +201 -0
  113. package/dist/i18n.d.ts +64 -0
  114. package/dist/i18n.js +166 -0
  115. package/dist/index.d.ts +77 -0
  116. package/dist/index.js +55 -0
  117. package/dist/inside-drawer.d.ts +2 -0
  118. package/dist/inside-drawer.js +9 -0
  119. package/dist/page-header-slot.d.ts +2 -0
  120. package/dist/page-header-slot.js +9 -0
  121. package/dist/primitives.d.ts +1351 -0
  122. package/dist/primitives.js +73 -0
  123. package/dist/screen-fills-height.d.ts +3 -0
  124. package/dist/screen-fills-height.js +3 -0
  125. package/dist/share-links.d.ts +4 -0
  126. package/dist/share-links.js +11 -0
  127. package/dist/sort-by-accessor.d.ts +5 -0
  128. package/dist/sort-by-accessor.js +16 -0
  129. package/dist/sse/live-events.d.ts +23 -0
  130. package/dist/sse/live-events.js +28 -0
  131. package/dist/tokens.d.ts +83 -0
  132. package/dist/tokens.js +80 -0
  133. package/package.json +16 -9
  134. package/src/changes.json +52 -0
  135. package/src/__tests__/error-i18n-defaults.test.ts +0 -82
  136. package/src/__tests__/format-when.test.ts +0 -12
  137. package/src/__tests__/i18n.test.tsx +0 -283
  138. package/src/__tests__/merge-search-params-into-initial.test.ts +0 -525
  139. package/src/__tests__/qn.test.ts +0 -83
  140. package/src/__tests__/share-links.test.ts +0 -26
  141. package/src/__tests__/sort-by-accessor.test.ts +0 -48
  142. package/src/__tests__/stringify-nav-params.test.ts +0 -33
  143. package/src/__tests__/use-list-url-state.test.tsx +0 -250
  144. package/src/app/__tests__/action-form-field-labels.test.tsx +0 -92
  145. package/src/app/__tests__/action-form-shim.test.ts +0 -137
  146. package/src/app/__tests__/build-list-query-payload.test.ts +0 -107
  147. package/src/app/__tests__/config-edit-shim.test.ts +0 -57
  148. package/src/app/__tests__/content-editors.test.tsx +0 -128
  149. package/src/app/__tests__/content-preview.test.tsx +0 -184
  150. package/src/app/__tests__/entity-edit-create-search-params.test.tsx +0 -236
  151. package/src/app/__tests__/entity-edit-jsonb-embedded-submit.test.tsx +0 -239
  152. package/src/app/__tests__/entity-list-create-singleton.test.tsx +0 -128
  153. package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +0 -424
  154. package/src/app/__tests__/entity-list-row-action-icon.test.tsx +0 -248
  155. package/src/app/__tests__/entity-list-row-action-kumiko-actions-view.test.tsx +0 -162
  156. package/src/app/__tests__/entity-list-row-action-refetch.test.tsx +0 -401
  157. package/src/app/__tests__/entity-list-screen-padding.test.tsx +0 -111
  158. package/src/app/__tests__/feature-schema.test.ts +0 -40
  159. package/src/app/__tests__/form-schema.test.ts +0 -247
  160. package/src/app/__tests__/list-daterange-facet.test.ts +0 -165
  161. package/src/app/__tests__/list-filter-facets.test.tsx +0 -521
  162. package/src/app/__tests__/nav.test.ts +0 -198
  163. package/src/app/__tests__/projection-detail-actions.test.tsx +0 -719
  164. package/src/app/__tests__/projection-detail-reference-field.test.tsx +0 -165
  165. package/src/app/__tests__/projection-detail-shim.test.ts +0 -188
  166. package/src/app/__tests__/projection-detail-singleton.test.tsx +0 -191
  167. package/src/app/__tests__/projection-list-bad-shape.test.tsx +0 -152
  168. package/src/app/__tests__/projection-list-reference-column.test.tsx +0 -219
  169. package/src/app/__tests__/projection-list-search-sort.test.tsx +0 -212
  170. package/src/app/__tests__/qualified-names.test.ts +0 -37
  171. package/src/app/__tests__/return-to.test.ts +0 -396
  172. package/src/app/__tests__/row-actions-navigate-tab.test.ts +0 -223
  173. package/src/app/__tests__/row-default-edit-action.test.tsx +0 -324
  174. package/src/app/__tests__/screen-access-allows.test.ts +0 -34
  175. package/src/app/__tests__/secret-mint-body.test.tsx +0 -404
  176. package/src/app/__tests__/variable-chips.test.tsx +0 -70
  177. package/src/app/app-features-context.tsx +0 -25
  178. package/src/app/column-renderers.tsx +0 -68
  179. package/src/app/content-editors.tsx +0 -85
  180. package/src/app/content-preview.tsx +0 -67
  181. package/src/app/custom-screens.tsx +0 -29
  182. package/src/app/dashboard-body.tsx +0 -33
  183. package/src/app/extension-form-submit.tsx +0 -123
  184. package/src/app/extension-sections.tsx +0 -111
  185. package/src/app/form-schema.ts +0 -91
  186. package/src/app/kumiko-screen.tsx +0 -3614
  187. package/src/app/layout-fields.ts +0 -31
  188. package/src/app/list-facets.ts +0 -259
  189. package/src/app/nav.tsx +0 -258
  190. package/src/app/projection-detail-shim.ts +0 -85
  191. package/src/app/reference-facet-bridge.tsx +0 -58
  192. package/src/app/return-to.tsx +0 -234
  193. package/src/app/row-actions.ts +0 -639
  194. package/src/app/screen-access.ts +0 -63
  195. package/src/app/secret-mint-body.tsx +0 -259
  196. package/src/app/secrets-edit-body.tsx +0 -194
  197. package/src/app/use-embedded-screen.ts +0 -27
  198. package/src/app/variable-chips.tsx +0 -39
  199. package/src/app/write-failed-error.ts +0 -27
  200. package/src/components/__tests__/embedded-list-field.test.tsx +0 -531
  201. package/src/components/__tests__/multi-select-checkboxes.test.tsx +0 -162
  202. package/src/components/__tests__/related-list-section-empty-state.test.tsx +0 -143
  203. package/src/components/__tests__/related-list-section.test.tsx +0 -1669
  204. package/src/components/__tests__/render-edit-action-button.test.tsx +0 -205
  205. package/src/components/__tests__/render-edit-hidden-section.test.tsx +0 -128
  206. package/src/components/__tests__/render-edit-logic.test.ts +0 -314
  207. package/src/components/__tests__/render-edit-screen-description.test.tsx +0 -152
  208. package/src/components/__tests__/render-edit-submit-actions.test.tsx +0 -460
  209. package/src/components/__tests__/render-field-app-locale.test.tsx +0 -142
  210. package/src/components/__tests__/render-field-enum-option.test.tsx +0 -99
  211. package/src/components/__tests__/render-field-icon-derivation.test.tsx +0 -119
  212. package/src/components/__tests__/render-field-json-format.test.tsx +0 -164
  213. package/src/components/__tests__/render-field-money-embedded-list-consistency.test.tsx +0 -212
  214. package/src/components/__tests__/render-field-money-roundtrip.test.tsx +0 -217
  215. package/src/components/__tests__/render-field-number-grouping.test.tsx +0 -76
  216. package/src/components/__tests__/render-field-number-unit.test.tsx +0 -227
  217. package/src/components/__tests__/render-field-password-format.test.tsx +0 -101
  218. package/src/components/__tests__/render-field-select-display.test.tsx +0 -134
  219. package/src/components/__tests__/render-field-unit-format.test.tsx +0 -107
  220. package/src/components/__tests__/render-field-unsupported-types.test.tsx +0 -195
  221. package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +0 -146
  222. package/src/components/__tests__/write-form-section.test.tsx +0 -375
  223. package/src/components/embedded-list-field.tsx +0 -354
  224. package/src/components/grid-cell-for-field.tsx +0 -60
  225. package/src/components/multi-select-checkboxes.tsx +0 -93
  226. package/src/components/reference-create-dialog.tsx +0 -91
  227. package/src/components/related-list-section.tsx +0 -467
  228. package/src/components/render-edit-action-button.tsx +0 -106
  229. package/src/components/render-edit-logic.ts +0 -85
  230. package/src/components/render-edit-types.ts +0 -216
  231. package/src/components/render-edit.tsx +0 -1700
  232. package/src/components/render-field.tsx +0 -942
  233. package/src/components/render-list.tsx +0 -561
  234. package/src/components/write-form-section.tsx +0 -199
  235. package/src/context/draft-storage-context.tsx +0 -39
  236. package/src/hooks/__tests__/use-ai-text.test.tsx +0 -177
  237. package/src/hooks/__tests__/use-disclosure.test.tsx +0 -25
  238. package/src/hooks/__tests__/use-form.test.tsx +0 -65
  239. package/src/hooks/__tests__/use-mutation.test.tsx +0 -77
  240. package/src/hooks/__tests__/use-stream-handler.test.tsx +0 -107
  241. package/src/hooks/use-ai-text.ts +0 -172
  242. package/src/hooks/use-disclosure.ts +0 -20
  243. package/src/hooks/use-form.ts +0 -78
  244. package/src/hooks/use-list-url-state.ts +0 -171
  245. package/src/hooks/use-mutation.ts +0 -61
  246. package/src/hooks/use-query.ts +0 -132
  247. package/src/hooks/use-reference-lookup.ts +0 -76
  248. package/src/hooks/use-store.ts +0 -48
  249. package/src/hooks/use-stream-handler.ts +0 -134
  250. package/src/i18n-defaults.ts +0 -220
  251. package/src/i18n.tsx +0 -286
  252. package/src/index.ts +0 -272
  253. package/src/primitives.tsx +0 -1446
  254. package/src/share-links.ts +0 -12
  255. package/src/sort-by-accessor.ts +0 -20
  256. package/src/sse/live-events.tsx +0 -56
  257. package/src/tokens.tsx +0 -170
@@ -1,531 +0,0 @@
1
- // EmbeddedListField Tests — RTL + happy-dom, style follows
2
- // render-field-app-locale.test.tsx: mount under real Locale/Dispatcher/
3
- // Primitives providers, capture the props the (mocked) EmbeddedListInput
4
- // primitive receives, then invoke its callbacks the way the real
5
- // primitive would (a user clicking/typing) and assert the resulting
6
- // onChange call.
7
-
8
- import { describe, expect, test } from "bun:test";
9
- import type { Dispatcher, EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
10
- import { act, render, waitFor } from "@testing-library/react";
11
- import type { ComponentType, ReactNode } from "react";
12
- import { DispatcherProvider } from "../../context/dispatcher-context";
13
- import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
14
- import { kumikoDefaultTranslations } from "../../i18n-defaults";
15
- import {
16
- type CorePrimitives,
17
- type EmbeddedListInputProps,
18
- PrimitivesProvider,
19
- } from "../../primitives";
20
- import { EmbeddedListField } from "../embedded-list-field";
21
- import { RenderField, type RenderFieldProps } from "../render-field";
22
-
23
- let captured: EmbeddedListInputProps | undefined;
24
- const captureEmbeddedListInput: ComponentType<EmbeddedListInputProps> = (props) => {
25
- captured = {
26
- ...props,
27
- onCellChange: (...args) => act(() => props.onCellChange(...args)),
28
- onAddRow: () => act(() => props.onAddRow()),
29
- onRemoveRow: (rowIndex) => act(() => props.onRemoveRow(rowIndex)),
30
- onDuplicateRow: (rowIndex) => act(() => props.onDuplicateRow(rowIndex)),
31
- onMoveRow: (fromIndex, toIndex) => act(() => props.onMoveRow(fromIndex, toIndex)),
32
- ...(props.onPasteCells !== undefined && {
33
- onPasteCells: (...args: Parameters<NonNullable<EmbeddedListInputProps["onPasteCells"]>>) =>
34
- act(() => props.onPasteCells?.(...args)),
35
- }),
36
- };
37
- return null;
38
- };
39
- const noop = (): ReactNode => null;
40
- const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
41
-
42
- function testPrimitives(): CorePrimitives {
43
- return {
44
- Button: noop,
45
- Banner: noop,
46
- Field: passChildren,
47
- Input: noop,
48
- EmbeddedListInput: captureEmbeddedListInput,
49
- DataTable: noop,
50
- Form: passChildren,
51
- Section: passChildren,
52
- Card: passChildren,
53
- Grid: passChildren,
54
- GridCell: passChildren,
55
- Text: passChildren,
56
- Heading: noop,
57
- Dialog: noop,
58
- Modal: noop,
59
- Lightbox: noop,
60
- ConfigSourceBadge: noop,
61
- ConfigCascadeView: noop,
62
- Link: noop,
63
- };
64
- }
65
-
66
- type ProductRow = { readonly id: string; readonly name: string };
67
-
68
- function stubDispatcher(productRows: readonly ProductRow[] = []): Dispatcher {
69
- return {
70
- write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
71
- query: (async (type: string) => {
72
- if (type === "invoices:query:product:list") {
73
- return { isSuccess: true, data: { rows: productRows } };
74
- }
75
- return { isSuccess: true, data: { rows: [] } };
76
- }) as unknown as Dispatcher["query"],
77
- batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
78
- statusStore: {
79
- getState: () => "online",
80
- subscribe: () => () => {},
81
- } as unknown as Dispatcher["statusStore"],
82
- async *stream() {},
83
- pendingWrites: () => [],
84
- pendingFiles: () => [],
85
- };
86
- }
87
-
88
- function invoiceLinesField(overrides: Partial<EditFieldViewModel> = {}): EditFieldViewModel {
89
- return {
90
- field: "lines",
91
- label: "Lines",
92
- type: "embedded",
93
- value: [],
94
- visible: true,
95
- readOnly: false,
96
- required: true,
97
- embeddedListCells: [
98
- {
99
- field: "product",
100
- label: "Product",
101
- type: "reference",
102
- required: true,
103
- refEntity: "product",
104
- refFeature: "invoices",
105
- refLabelField: "name",
106
- },
107
- {
108
- field: "unit",
109
- label: "Unit",
110
- type: "select",
111
- required: true,
112
- options: ["pcs", "hours", "kg"],
113
- },
114
- { field: "quantity", label: "Qty", type: "number", required: true },
115
- { field: "unitPrice", label: "Unit Price", type: "money", required: true },
116
- { field: "amount", label: "Amount", type: "money", required: false },
117
- ],
118
- embeddedListDerived: { amount: { op: "multiply", from: ["quantity", "unitPrice"] } },
119
- embeddedListTotals: ["amount"],
120
- embeddedListMinItems: 1,
121
- embeddedListMaxItems: 5,
122
- ...overrides,
123
- };
124
- }
125
-
126
- async function renderEmbeddedListField(
127
- field: EditFieldViewModel,
128
- onChange: (v: unknown) => void,
129
- allIssues: Readonly<Record<string, readonly FieldIssue[]>> = {},
130
- productRows: readonly ProductRow[] = [],
131
- ): Promise<void> {
132
- captured = undefined;
133
- render(
134
- <LocaleProvider
135
- resolver={createStaticLocaleResolver()}
136
- fallbackBundles={[kumikoDefaultTranslations]}
137
- >
138
- <DispatcherProvider dispatcher={stubDispatcher(productRows)}>
139
- <PrimitivesProvider value={testPrimitives()}>
140
- <EmbeddedListField
141
- field={field}
142
- id="kumiko-edit-lines"
143
- onChange={onChange}
144
- allIssues={allIssues}
145
- featureName="invoices"
146
- />
147
- </PrimitivesProvider>
148
- </DispatcherProvider>
149
- </LocaleProvider>,
150
- );
151
- await act(async () => {});
152
- }
153
-
154
- describe("EmbeddedListField — cell change recomputes derived", () => {
155
- test("changing quantity updates the cell and recomputes amount in the same row", async () => {
156
- const rows = [{ product: "p1", unit: "pcs", quantity: 2, unitPrice: 500, amount: 1000 }];
157
- let lastValue: unknown;
158
- await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
159
- lastValue = v;
160
- });
161
- captured?.onCellChange(0, "quantity", 4);
162
- expect(lastValue).toEqual([
163
- { product: "p1", unit: "pcs", quantity: 4, unitPrice: 500, amount: 2000 },
164
- ]);
165
- });
166
-
167
- test("a fractional product on the money-typed amount cell shows the rounded live preview, not the raw fraction", async () => {
168
- const rows = [{ product: "p1", unit: "pcs", quantity: 2, unitPrice: 923, amount: 1846 }];
169
- let lastValue: unknown;
170
- await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
171
- lastValue = v;
172
- });
173
- // 2.5 * 923 = 2307.5 — a fractional minor-unit amount the money target
174
- // can't store as-is. The live preview must show the rounded value
175
- // (2308), matching what the server would compute and persist.
176
- captured?.onCellChange(0, "quantity", 2.5);
177
- expect(lastValue).toEqual([
178
- { product: "p1", unit: "pcs", quantity: 2.5, unitPrice: 923, amount: 2308 },
179
- ]);
180
- });
181
-
182
- // Regression: withRecomputedDerived used to read source values from
183
- // `result` (mutated in-place during the loop), so a chained derived cell
184
- // (gross depends on net, net depends on raw qty/price) got the FRESH net
185
- // when "net" happened to iterate before "gross" in embeddedListDerived's
186
- // key order, but the STALE pre-edit net otherwise — the client preview
187
- // drifted from the server (withDerivedCells always reads the untouched
188
- // source row) purely based on object-key order. Fixed by reading from a
189
- // frozen `source` snapshot, same as the server. Two field configs with
190
- // "net"/"gross" declared in opposite key order must therefore produce the
191
- // identical, order-independent result.
192
- test("chained derived cells (gross depends on net) recompute the same value regardless of the derived-map's key order", async () => {
193
- const cells: EditFieldViewModel["embeddedListCells"] = [
194
- { field: "qty", label: "Qty", type: "number", required: true },
195
- { field: "price", label: "Price", type: "number", required: true },
196
- { field: "tax", label: "Tax", type: "number", required: true },
197
- { field: "net", label: "Net", type: "number", required: false },
198
- { field: "gross", label: "Gross", type: "number", required: false },
199
- ];
200
- // Pre-edit, already-settled row: net = 2*3 = 6, gross = 6+1 = 7.
201
- const rows = [{ qty: 2, price: 3, tax: 1, net: 6, gross: 7 }];
202
- // A fixed reference calculation for "qty changes from 2 to 4": net
203
- // recomputes from the fresh raw inputs (qty=4, price=3) to 12; gross
204
- // reads net from the row as it was BEFORE this edit (still 6, same as
205
- // the server would), giving 6+1=7 — not 12+1=13.
206
- const expected = { qty: 4, price: 3, tax: 1, net: 12, gross: 7 };
207
-
208
- let netFirstResult: unknown;
209
- await renderEmbeddedListField(
210
- {
211
- ...invoiceLinesField({ value: rows }),
212
- embeddedListCells: cells,
213
- embeddedListDerived: {
214
- net: { op: "multiply", from: ["qty", "price"] },
215
- gross: { op: "sum", from: ["net", "tax"] },
216
- },
217
- },
218
- (v) => {
219
- netFirstResult = v;
220
- },
221
- );
222
- captured?.onCellChange(0, "qty", 4);
223
-
224
- let grossFirstResult: unknown;
225
- await renderEmbeddedListField(
226
- {
227
- ...invoiceLinesField({ value: rows }),
228
- embeddedListCells: cells,
229
- embeddedListDerived: {
230
- gross: { op: "sum", from: ["net", "tax"] },
231
- net: { op: "multiply", from: ["qty", "price"] },
232
- },
233
- },
234
- (v) => {
235
- grossFirstResult = v;
236
- },
237
- );
238
- captured?.onCellChange(0, "qty", 4);
239
-
240
- expect(netFirstResult).toEqual([expected]);
241
- expect(grossFirstResult).toEqual([expected]);
242
- });
243
- });
244
-
245
- describe("EmbeddedListField — row operations", () => {
246
- const rows = [
247
- { product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 },
248
- { product: "p2", unit: "hours", quantity: 2, unitPrice: 200, amount: 400 },
249
- ];
250
-
251
- test("onAddRow appends an empty row with derived recomputed", async () => {
252
- let lastValue: unknown;
253
- await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
254
- lastValue = v;
255
- });
256
- captured?.onAddRow();
257
- expect(lastValue).toEqual([...rows, { amount: undefined }]);
258
- });
259
-
260
- test("onRemoveRow removes exactly the targeted row", async () => {
261
- let lastValue: unknown;
262
- await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
263
- lastValue = v;
264
- });
265
- captured?.onRemoveRow(0);
266
- expect(lastValue).toEqual([rows[1]]);
267
- });
268
-
269
- test("onDuplicateRow inserts a copy right after the source row", async () => {
270
- let lastValue: unknown;
271
- await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
272
- lastValue = v;
273
- });
274
- captured?.onDuplicateRow(0);
275
- expect(lastValue).toEqual([rows[0], rows[0], rows[1]]);
276
- });
277
-
278
- test("onDuplicateRow is a no-op for an out-of-range index", async () => {
279
- let called = false;
280
- await renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {
281
- called = true;
282
- });
283
- captured?.onDuplicateRow(99);
284
- expect(called).toBe(false);
285
- });
286
-
287
- test("onMoveRow moves an element from one index to another, immutably", async () => {
288
- let lastValue: unknown;
289
- await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
290
- lastValue = v;
291
- });
292
- captured?.onMoveRow(1, 0);
293
- expect(lastValue).toEqual([rows[1], rows[0]]);
294
- // original array must stay untouched
295
- expect(rows).toEqual([
296
- { product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 },
297
- { product: "p2", unit: "hours", quantity: 2, unitPrice: 200, amount: 400 },
298
- ]);
299
- });
300
-
301
- test("onMoveRow is a no-op for an out-of-range target index", async () => {
302
- let called = false;
303
- await renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {
304
- called = true;
305
- });
306
- captured?.onMoveRow(0, 5);
307
- expect(called).toBe(false);
308
- });
309
- });
310
-
311
- describe("EmbeddedListField — paste coercion", () => {
312
- test("pastes number/money/select cells with correct coercion per column type", async () => {
313
- const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
314
- let lastValue: unknown;
315
- await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
316
- lastValue = v;
317
- });
318
- // Columns in order: product, unit, quantity, unitPrice, amount.
319
- // Paste starting at column index 1 ("unit") for row 0: unit, quantity, unitPrice.
320
- captured?.onPasteCells?.(0, 1, [["hours", "3", "12,50"]]);
321
- expect(lastValue).toEqual([
322
- { product: "p1", unit: "hours", quantity: 3, unitPrice: 1250, amount: 3750 },
323
- ]);
324
- });
325
-
326
- // kumiko-framework#1972: a hardcoded ×100 in the paste path diverged from
327
- // the typed-in path's currencyDecimals-based scaling for any non-2-decimal
328
- // currency — JPY (0 decimals) pasted "1234" would have landed on 123400
329
- // (100x too large) instead of 1234.
330
- test("pastes a money cell for a zero-decimal currency (JPY) without the paste-path ×100 bug", async () => {
331
- const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 0, amount: 0 }];
332
- let lastValue: unknown;
333
- await renderEmbeddedListField(
334
- invoiceLinesField({ value: rows, embeddedListCurrency: "JPY" }),
335
- (v) => {
336
- lastValue = v;
337
- },
338
- );
339
- // Columns in order: product, unit, quantity, unitPrice, amount. Paste
340
- // starting at "unitPrice" (index 3).
341
- captured?.onPasteCells?.(0, 3, [["1234"]]);
342
- const result = lastValue as readonly Record<string, unknown>[];
343
- expect(result[0]?.["unitPrice"]).toBe(1234);
344
- });
345
-
346
- test("paste beyond the current rows appends new rows but never past maxItems", async () => {
347
- const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
348
- let lastValue: unknown;
349
- await renderEmbeddedListField(
350
- invoiceLinesField({ value: rows, embeddedListMaxItems: 2 }),
351
- (v) => {
352
- lastValue = v;
353
- },
354
- );
355
- captured?.onPasteCells?.(0, 2, [
356
- ["1", "100"],
357
- ["2", "200"],
358
- ["3", "300"],
359
- ]);
360
- const result = lastValue as readonly Record<string, unknown>[];
361
- expect(result.length).toBe(2);
362
- });
363
-
364
- test("paste beyond maxItems surfaces a listIssue with the dropped-row count", async () => {
365
- const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
366
- await renderEmbeddedListField(
367
- invoiceLinesField({ value: rows, embeddedListMaxItems: 2 }),
368
- () => {},
369
- );
370
- act(() => {
371
- captured?.onPasteCells?.(0, 2, [
372
- ["1", "100"],
373
- ["2", "200"],
374
- ["3", "300"],
375
- ]);
376
- });
377
- await waitFor(() => {
378
- expect(captured?.listIssues).toEqual([
379
- {
380
- path: "lines",
381
- code: "paste-rows-truncated",
382
- i18nKey: "kumiko.field.embedded-list.paste-rows-truncated",
383
- params: { count: 1 },
384
- },
385
- ]);
386
- });
387
- });
388
-
389
- test("a select paste value with no matching option leaves the cell unchanged and surfaces a listIssue", async () => {
390
- const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
391
- let lastValue: unknown;
392
- await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
393
- lastValue = v;
394
- });
395
- // Columns in order: product, unit, quantity, unitPrice, amount. Column
396
- // index 1 = "unit" (select, options ["pcs","hours","kg"]) — "not-a-unit"
397
- // matches none of them.
398
- act(() => {
399
- captured?.onPasteCells?.(0, 1, [["not-a-unit"]]);
400
- });
401
- expect(lastValue).toEqual([rows[0]]);
402
- await waitFor(() => {
403
- expect(captured?.listIssues).toEqual([
404
- {
405
- path: "lines",
406
- code: "paste-cells-unmatched",
407
- i18nKey: "kumiko.field.embedded-list.paste-cells-unmatched",
408
- params: { count: 1 },
409
- },
410
- ]);
411
- });
412
- });
413
-
414
- // kumiko-framework#1838: a naive `replace(",", ".")` on a DE-locale paste
415
- // with a thousands separator turned "1.234,56" into "1.234.56" → NaN →
416
- // the cell silently cleared instead of keeping the pasted amount.
417
- test("pastes a DE-locale money value with a thousands separator (1.234,56)", async () => {
418
- const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
419
- let lastValue: unknown;
420
- await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
421
- lastValue = v;
422
- });
423
- // Column index 3 = "unitPrice".
424
- captured?.onPasteCells?.(0, 3, [["1.234,56"]]);
425
- const result = lastValue as readonly Record<string, unknown>[];
426
- expect(result[0]?.["unitPrice"]).toBe(123456);
427
- });
428
-
429
- test("pastes an en-US-locale money value with a thousands separator (1,234.56)", async () => {
430
- const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
431
- let lastValue: unknown;
432
- await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
433
- lastValue = v;
434
- });
435
- captured?.onPasteCells?.(0, 3, [["1,234.56"]]);
436
- const result = lastValue as readonly Record<string, unknown>[];
437
- expect(result[0]?.["unitPrice"]).toBe(123456);
438
- });
439
-
440
- test("an unparseable money paste leaves the cell unchanged and surfaces a listIssue instead of clearing it", async () => {
441
- const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
442
- let lastValue: unknown;
443
- await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
444
- lastValue = v;
445
- });
446
- act(() => {
447
- captured?.onPasteCells?.(0, 3, [["not-a-number"]]);
448
- });
449
- const result = lastValue as readonly Record<string, unknown>[];
450
- expect(result[0]?.["unitPrice"]).toBe(100);
451
- await waitFor(() => {
452
- expect(captured?.listIssues).toEqual([
453
- {
454
- path: "lines",
455
- code: "paste-cells-unmatched",
456
- i18nKey: "kumiko.field.embedded-list.paste-cells-unmatched",
457
- params: { count: 1 },
458
- },
459
- ]);
460
- });
461
- });
462
- });
463
-
464
- describe("EmbeddedListField — reference column populated via useQuery", () => {
465
- test("referenceOptions come from the product list query", async () => {
466
- await renderEmbeddedListField(invoiceLinesField({ value: [] }), () => {}, {}, [
467
- { id: "p1", name: "Widget A" },
468
- ]);
469
- await waitFor(() => {
470
- const productColumn = captured?.columns.find((c) => c.field === "product");
471
- expect(productColumn?.referenceOptions).toEqual([{ value: "p1", label: "Widget A" }]);
472
- });
473
- });
474
- });
475
-
476
- describe("EmbeddedListField — issue grouping wiring", () => {
477
- test("a lines.0.amount issue is routed as a cellIssue at 0.amount", async () => {
478
- const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
479
- const issue: FieldIssue = { path: "lines.0.amount", code: "custom", i18nKey: "Bad amount" };
480
- await renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {}, {
481
- "lines.0.amount": [issue],
482
- });
483
- expect(captured?.cellIssues?.["0.amount"]).toEqual([issue]);
484
- expect(captured?.rowIssues ?? {}).toEqual({});
485
- expect(captured?.listIssues ?? []).toEqual([]);
486
- });
487
-
488
- test("a lines-level issue is routed as a listIssue", async () => {
489
- const issue: FieldIssue = { path: "lines", code: "custom", i18nKey: "Too few lines" };
490
- await renderEmbeddedListField(invoiceLinesField({ value: [] }), () => {}, {
491
- lines: [issue],
492
- });
493
- expect(captured?.listIssues).toEqual([issue]);
494
- });
495
- });
496
-
497
- async function renderFieldWithEmbeddedList(
498
- field: EditFieldViewModel,
499
- allIssues: RenderFieldProps["allIssues"],
500
- ): Promise<void> {
501
- captured = undefined;
502
- render(
503
- <LocaleProvider
504
- resolver={createStaticLocaleResolver()}
505
- fallbackBundles={[kumikoDefaultTranslations]}
506
- >
507
- <DispatcherProvider dispatcher={stubDispatcher()}>
508
- <PrimitivesProvider value={testPrimitives()}>
509
- <RenderField
510
- field={field}
511
- onChange={() => {}}
512
- allIssues={allIssues}
513
- featureName="invoices"
514
- />
515
- </PrimitivesProvider>
516
- </DispatcherProvider>
517
- </LocaleProvider>,
518
- );
519
- await act(async () => {});
520
- }
521
-
522
- describe("RenderField — routes embedded-list fields to EmbeddedListField (plumbing)", () => {
523
- test("field.embeddedListCells set → RenderField mounts EmbeddedListField, allIssues propagate", async () => {
524
- const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
525
- const issue: FieldIssue = { path: "lines.0.amount", code: "custom", i18nKey: "Bad amount" };
526
- await renderFieldWithEmbeddedList(invoiceLinesField({ value: rows }), {
527
- "lines.0.amount": [issue],
528
- });
529
- expect(captured?.cellIssues?.["0.amount"]).toEqual([issue]);
530
- });
531
- });
@@ -1,162 +0,0 @@
1
- // display: "checkboxes" on a multiSelect field renders a checkbox grid
2
- // (MultiSelectCheckboxes) instead of the combobox dropdown. Style follows
3
- // render-field-app-locale.test.tsx: mount RenderField under real Locale +
4
- // Primitives providers with capturing stubs, then invoke the captured
5
- // callbacks the way a real primitive implementation would.
6
-
7
- import { describe, expect, test } from "bun:test";
8
- import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
9
- import { render } from "@testing-library/react";
10
- import type { ComponentType, ReactNode } from "react";
11
- import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
12
- import { kumikoDefaultTranslations } from "../../i18n-defaults";
13
- import {
14
- type ButtonProps,
15
- type CorePrimitives,
16
- type GridProps,
17
- type InputProps,
18
- PrimitivesProvider,
19
- } from "../../primitives";
20
- import { RenderField } from "../render-field";
21
-
22
- let capturedInputs: InputProps[] = [];
23
- let capturedButton: ButtonProps | undefined;
24
- let capturedGrid: GridProps | undefined;
25
-
26
- const captureInput: ComponentType<InputProps> = (props) => {
27
- capturedInputs.push(props);
28
- return null;
29
- };
30
- const captureButton: ComponentType<ButtonProps> = (props) => {
31
- capturedButton = props;
32
- return null;
33
- };
34
- const captureGrid: ComponentType<GridProps> = (props) => {
35
- capturedGrid = props;
36
- return <>{props.children}</>;
37
- };
38
- const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
39
- const noop = (): ReactNode => null;
40
-
41
- const testPrimitives: CorePrimitives = {
42
- Button: captureButton,
43
- Banner: noop,
44
- Field: passChildren,
45
- Input: captureInput,
46
- DataTable: noop,
47
- Form: noop,
48
- Section: noop,
49
- Card: noop,
50
- Grid: captureGrid,
51
- GridCell: passChildren,
52
- Text: noop,
53
- Heading: noop,
54
- Dialog: noop,
55
- Modal: noop,
56
- Lightbox: noop,
57
- ConfigSourceBadge: noop,
58
- ConfigCascadeView: noop,
59
- Link: noop,
60
- };
61
-
62
- function languagesField(overrides: Partial<EditFieldViewModel> = {}): EditFieldViewModel {
63
- return {
64
- field: "languages",
65
- label: "Languages",
66
- type: "multiSelect",
67
- value: [],
68
- visible: true,
69
- readOnly: false,
70
- required: false,
71
- options: ["en", "de", "es"],
72
- optionLabels: { en: "English", de: "German", es: "Spanish" },
73
- display: "checkboxes",
74
- ...overrides,
75
- };
76
- }
77
-
78
- let lastOnChange: unknown;
79
- function renderField(field: EditFieldViewModel): void {
80
- capturedInputs = [];
81
- capturedButton = undefined;
82
- capturedGrid = undefined;
83
- lastOnChange = undefined;
84
- render(
85
- <LocaleProvider
86
- resolver={createStaticLocaleResolver()}
87
- fallbackBundles={[kumikoDefaultTranslations]}
88
- >
89
- <PrimitivesProvider value={testPrimitives}>
90
- <RenderField field={field} onChange={(v) => (lastOnChange = v)} />
91
- </PrimitivesProvider>
92
- </LocaleProvider>,
93
- );
94
- }
95
-
96
- describe("RenderField — multiSelect display: checkboxes", () => {
97
- test("renders one boolean checkbox per option, no combobox", () => {
98
- renderField(languagesField());
99
- expect(capturedInputs).toHaveLength(3);
100
- for (const input of capturedInputs) {
101
- expect(input.kind).toBe("boolean");
102
- }
103
- });
104
-
105
- test("without display, the field still renders the combobox (back-compat)", () => {
106
- renderField(languagesField({ display: undefined }));
107
- expect(capturedInputs).toHaveLength(1);
108
- expect(capturedInputs[0]?.kind).toBe("combobox");
109
- });
110
-
111
- test("clicking one checkbox emits the full array including previously-set values, in options order", () => {
112
- renderField(languagesField({ value: ["en"] }));
113
- const deInput = capturedInputs.find(
114
- (i) => i.kind === "boolean" && i.id === "kumiko-edit-languages-de",
115
- );
116
- expect(deInput?.kind).toBe("boolean");
117
- if (deInput?.kind === "boolean") deInput.onChange(true);
118
- expect(lastOnChange).toEqual(["en", "de"]);
119
- });
120
-
121
- test("unchecking a checkbox drops only that value, keeping options order", () => {
122
- renderField(languagesField({ value: ["en", "de", "es"] }));
123
- const deInput = capturedInputs.find(
124
- (i) => i.kind === "boolean" && i.id === "kumiko-edit-languages-de",
125
- );
126
- if (deInput?.kind === "boolean") deInput.onChange(false);
127
- expect(lastOnChange).toEqual(["en", "es"]);
128
- });
129
-
130
- test("select-all toggle shows 'Select all' when nothing is selected, and selects everything in options order", () => {
131
- renderField(languagesField({ value: [] }));
132
- expect(capturedButton?.children).toBe("Select all");
133
- capturedButton?.onClick?.();
134
- expect(lastOnChange).toEqual(["en", "de", "es"]);
135
- });
136
-
137
- test("select-all toggle flips to 'Deselect all' once everything is selected, and clears on click", () => {
138
- renderField(languagesField({ value: ["en", "de", "es"] }));
139
- expect(capturedButton?.children).toBe("Deselect all");
140
- capturedButton?.onClick?.();
141
- expect(lastOnChange).toEqual([]);
142
- });
143
-
144
- test("disabled (readOnly) disables every checkbox and the select-all toggle", () => {
145
- renderField(languagesField({ readOnly: true }));
146
- expect(capturedButton?.disabled).toBe(true);
147
- for (const input of capturedInputs) {
148
- expect(input.disabled).toBe(true);
149
- }
150
- });
151
-
152
- test("columns and maxRows pass through to the Grid primitive", () => {
153
- renderField(languagesField({ columns: 2, maxRows: 3 }));
154
- expect(capturedGrid?.columns).toBe(2);
155
- expect(capturedGrid?.maxRows).toBe(3);
156
- });
157
-
158
- test("omitting columns/maxRows keeps the default layout (no maxRows on the Grid)", () => {
159
- renderField(languagesField());
160
- expect(capturedGrid?.maxRows).toBeUndefined();
161
- });
162
- });