@multiplatform.one/frappe-ui 7.6.2 → 7.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/dist/cjs/ChartCard.cjs +52 -0
  2. package/dist/cjs/ChartCard.native.js +72 -0
  3. package/dist/cjs/ChartCard.native.js.map +1 -0
  4. package/dist/cjs/FrappeChartCard.cjs +3 -3
  5. package/dist/cjs/FrappeChartCard.native.js +3 -3
  6. package/dist/cjs/FrappeChartCard.native.js.map +1 -1
  7. package/dist/cjs/FrappeDashboard.cjs +6 -27
  8. package/dist/cjs/FrappeDashboard.native.js +6 -33
  9. package/dist/cjs/FrappeDashboard.native.js.map +1 -1
  10. package/dist/cjs/fields/CodeEditor/index.cjs +4 -0
  11. package/dist/cjs/fields/CodeEditor/index.native.js +4 -0
  12. package/dist/cjs/fields/CodeEditor/index.native.js.map +1 -1
  13. package/dist/cjs/formEngine/createPrefill.cjs +37 -0
  14. package/dist/cjs/formEngine/createPrefill.native.js +40 -0
  15. package/dist/cjs/formEngine/createPrefill.native.js.map +1 -0
  16. package/dist/cjs/formEngine/dirtyTracking.cjs +55 -0
  17. package/dist/cjs/formEngine/dirtyTracking.native.js +77 -0
  18. package/dist/cjs/formEngine/dirtyTracking.native.js.map +1 -0
  19. package/dist/cjs/formEngine/doctypeMeta.cjs +18 -0
  20. package/dist/cjs/formEngine/doctypeMeta.native.js +21 -0
  21. package/dist/cjs/formEngine/doctypeMeta.native.js.map +1 -0
  22. package/dist/cjs/formEngine/fetchFrom.cjs +67 -0
  23. package/dist/cjs/formEngine/fetchFrom.native.js +110 -0
  24. package/dist/cjs/formEngine/fetchFrom.native.js.map +1 -0
  25. package/dist/cjs/formEngine/fieldPermissions.cjs +55 -0
  26. package/dist/cjs/formEngine/fieldPermissions.native.js +80 -0
  27. package/dist/cjs/formEngine/fieldPermissions.native.js.map +1 -0
  28. package/dist/cjs/formEngine/formActions.cjs +189 -0
  29. package/dist/cjs/formEngine/formActions.native.js +199 -0
  30. package/dist/cjs/formEngine/formActions.native.js.map +1 -0
  31. package/dist/cjs/formEngine/formLayout.cjs +245 -0
  32. package/dist/cjs/formEngine/formLayout.native.js +388 -0
  33. package/dist/cjs/formEngine/formLayout.native.js.map +1 -0
  34. package/dist/cjs/formEngine/index.cjs +48 -0
  35. package/dist/cjs/formEngine/index.native.js +51 -0
  36. package/dist/cjs/formEngine/index.native.js.map +1 -0
  37. package/dist/cjs/formEngine/useFormEngine.cjs +387 -0
  38. package/dist/cjs/formEngine/useFormEngine.native.js +476 -0
  39. package/dist/cjs/formEngine/useFormEngine.native.js.map +1 -0
  40. package/dist/cjs/index.cjs +19 -1
  41. package/dist/cjs/index.native.js +19 -1
  42. package/dist/cjs/index.native.js.map +1 -1
  43. package/dist/esm/ChartCard.mjs +27 -0
  44. package/dist/esm/ChartCard.mjs.map +1 -0
  45. package/dist/esm/ChartCard.native.js +33 -0
  46. package/dist/esm/ChartCard.native.js.map +1 -0
  47. package/dist/esm/FrappeChartCard.mjs +1 -1
  48. package/dist/esm/FrappeChartCard.native.js +1 -1
  49. package/dist/esm/FrappeChartCard.native.js.map +1 -1
  50. package/dist/esm/FrappeDashboard.mjs +6 -27
  51. package/dist/esm/FrappeDashboard.mjs.map +1 -1
  52. package/dist/esm/FrappeDashboard.native.js +6 -33
  53. package/dist/esm/FrappeDashboard.native.js.map +1 -1
  54. package/dist/esm/fields/CodeEditor/index.mjs +4 -0
  55. package/dist/esm/fields/CodeEditor/index.mjs.map +1 -1
  56. package/dist/esm/fields/CodeEditor/index.native.js +4 -0
  57. package/dist/esm/fields/CodeEditor/index.native.js.map +1 -1
  58. package/dist/esm/formEngine/createPrefill.mjs +11 -0
  59. package/dist/esm/formEngine/createPrefill.mjs.map +1 -0
  60. package/dist/esm/formEngine/createPrefill.native.js +11 -0
  61. package/dist/esm/formEngine/createPrefill.native.js.map +1 -0
  62. package/dist/esm/formEngine/dirtyTracking.mjs +28 -0
  63. package/dist/esm/formEngine/dirtyTracking.mjs.map +1 -0
  64. package/dist/esm/formEngine/dirtyTracking.native.js +47 -0
  65. package/dist/esm/formEngine/dirtyTracking.native.js.map +1 -0
  66. package/dist/esm/formEngine/doctypeMeta.mjs +2 -0
  67. package/dist/esm/formEngine/doctypeMeta.mjs.map +1 -0
  68. package/dist/esm/formEngine/doctypeMeta.native.js +2 -0
  69. package/dist/esm/formEngine/doctypeMeta.native.js.map +1 -0
  70. package/dist/esm/formEngine/fetchFrom.mjs +40 -0
  71. package/dist/esm/formEngine/fetchFrom.mjs.map +1 -0
  72. package/dist/esm/formEngine/fetchFrom.native.js +80 -0
  73. package/dist/esm/formEngine/fetchFrom.native.js.map +1 -0
  74. package/dist/esm/formEngine/fieldPermissions.mjs +28 -0
  75. package/dist/esm/formEngine/fieldPermissions.mjs.map +1 -0
  76. package/dist/esm/formEngine/fieldPermissions.native.js +50 -0
  77. package/dist/esm/formEngine/fieldPermissions.native.js.map +1 -0
  78. package/dist/esm/formEngine/formActions.mjs +146 -0
  79. package/dist/esm/formEngine/formActions.mjs.map +1 -0
  80. package/dist/esm/formEngine/formActions.native.js +153 -0
  81. package/dist/esm/formEngine/formActions.native.js.map +1 -0
  82. package/dist/esm/formEngine/formLayout.mjs +217 -0
  83. package/dist/esm/formEngine/formLayout.mjs.map +1 -0
  84. package/dist/esm/formEngine/formLayout.native.js +357 -0
  85. package/dist/esm/formEngine/formLayout.native.js.map +1 -0
  86. package/dist/esm/formEngine/index.mjs +8 -0
  87. package/dist/esm/formEngine/index.mjs.map +1 -0
  88. package/dist/esm/formEngine/index.native.js +8 -0
  89. package/dist/esm/formEngine/index.native.js.map +1 -0
  90. package/dist/esm/formEngine/useFormEngine.mjs +362 -0
  91. package/dist/esm/formEngine/useFormEngine.mjs.map +1 -0
  92. package/dist/esm/formEngine/useFormEngine.native.js +448 -0
  93. package/dist/esm/formEngine/useFormEngine.native.js.map +1 -0
  94. package/dist/esm/index.mjs +4 -2
  95. package/dist/esm/index.mjs.map +1 -1
  96. package/dist/esm/index.native.js +4 -2
  97. package/dist/esm/index.native.js.map +1 -1
  98. package/dist/jsx/ChartCard.mjs +27 -0
  99. package/dist/jsx/ChartCard.mjs.map +1 -0
  100. package/dist/jsx/ChartCard.native.js +72 -0
  101. package/dist/jsx/ChartCard.native.js.map +1 -0
  102. package/dist/jsx/FrappeChartCard.mjs +1 -1
  103. package/dist/jsx/FrappeChartCard.native.js +3 -3
  104. package/dist/jsx/FrappeChartCard.native.js.map +1 -1
  105. package/dist/jsx/FrappeDashboard.mjs +6 -27
  106. package/dist/jsx/FrappeDashboard.mjs.map +1 -1
  107. package/dist/jsx/FrappeDashboard.native.js +6 -33
  108. package/dist/jsx/FrappeDashboard.native.js.map +1 -1
  109. package/dist/jsx/fields/CodeEditor/index.mjs +4 -0
  110. package/dist/jsx/fields/CodeEditor/index.mjs.map +1 -1
  111. package/dist/jsx/fields/CodeEditor/index.native.js +4 -0
  112. package/dist/jsx/fields/CodeEditor/index.native.js.map +1 -1
  113. package/dist/jsx/formEngine/createPrefill.mjs +11 -0
  114. package/dist/jsx/formEngine/createPrefill.mjs.map +1 -0
  115. package/dist/jsx/formEngine/createPrefill.native.js +40 -0
  116. package/dist/jsx/formEngine/createPrefill.native.js.map +1 -0
  117. package/dist/jsx/formEngine/dirtyTracking.mjs +28 -0
  118. package/dist/jsx/formEngine/dirtyTracking.mjs.map +1 -0
  119. package/dist/jsx/formEngine/dirtyTracking.native.js +77 -0
  120. package/dist/jsx/formEngine/dirtyTracking.native.js.map +1 -0
  121. package/dist/jsx/formEngine/doctypeMeta.mjs +2 -0
  122. package/dist/jsx/formEngine/doctypeMeta.mjs.map +1 -0
  123. package/dist/jsx/formEngine/doctypeMeta.native.js +21 -0
  124. package/dist/jsx/formEngine/doctypeMeta.native.js.map +1 -0
  125. package/dist/jsx/formEngine/fetchFrom.mjs +40 -0
  126. package/dist/jsx/formEngine/fetchFrom.mjs.map +1 -0
  127. package/dist/jsx/formEngine/fetchFrom.native.js +110 -0
  128. package/dist/jsx/formEngine/fetchFrom.native.js.map +1 -0
  129. package/dist/jsx/formEngine/fieldPermissions.mjs +28 -0
  130. package/dist/jsx/formEngine/fieldPermissions.mjs.map +1 -0
  131. package/dist/jsx/formEngine/fieldPermissions.native.js +80 -0
  132. package/dist/jsx/formEngine/fieldPermissions.native.js.map +1 -0
  133. package/dist/jsx/formEngine/formActions.mjs +146 -0
  134. package/dist/jsx/formEngine/formActions.mjs.map +1 -0
  135. package/dist/jsx/formEngine/formActions.native.js +199 -0
  136. package/dist/jsx/formEngine/formActions.native.js.map +1 -0
  137. package/dist/jsx/formEngine/formLayout.mjs +217 -0
  138. package/dist/jsx/formEngine/formLayout.mjs.map +1 -0
  139. package/dist/jsx/formEngine/formLayout.native.js +388 -0
  140. package/dist/jsx/formEngine/formLayout.native.js.map +1 -0
  141. package/dist/jsx/formEngine/index.mjs +8 -0
  142. package/dist/jsx/formEngine/index.mjs.map +1 -0
  143. package/dist/jsx/formEngine/index.native.js +51 -0
  144. package/dist/jsx/formEngine/index.native.js.map +1 -0
  145. package/dist/jsx/formEngine/useFormEngine.mjs +362 -0
  146. package/dist/jsx/formEngine/useFormEngine.mjs.map +1 -0
  147. package/dist/jsx/formEngine/useFormEngine.native.js +476 -0
  148. package/dist/jsx/formEngine/useFormEngine.native.js.map +1 -0
  149. package/dist/jsx/index.js +4 -2
  150. package/dist/jsx/index.js.map +1 -1
  151. package/dist/jsx/index.mjs +4 -2
  152. package/dist/jsx/index.mjs.map +1 -1
  153. package/dist/jsx/index.native.js +19 -1
  154. package/dist/jsx/index.native.js.map +1 -1
  155. package/package.json +23 -17
  156. package/src/ChartCard.tsx +31 -0
  157. package/src/FileBrowser/FileBrowser.spec.tsx +16 -0
  158. package/src/FileBrowser/FileBrowser.stories.tsx +18 -1
  159. package/src/FileBrowser/folderTree.spec.tsx +68 -0
  160. package/src/FrappeCalendar.stories.tsx +1 -2
  161. package/src/FrappeChartCard.stories.tsx +1 -1
  162. package/src/FrappeChartCard.tsx +1 -1
  163. package/src/FrappeDashboard.stories.tsx +6 -5
  164. package/src/FrappeDashboard.tsx +6 -33
  165. package/src/FrappeGantt.stories.tsx +1 -2
  166. package/src/FrappeList.stories.tsx +3 -10
  167. package/src/components/attachments.stories.tsx +9 -2
  168. package/src/dependsOn.ts +2 -2
  169. package/src/fields/CodeEditor/CodeEditor.spec.tsx +37 -0
  170. package/src/fields/CodeEditor/CodeEditor.stories.tsx +83 -66
  171. package/src/fields/CodeEditor/index.tsx +4 -0
  172. package/src/fields/DateRange/DateRange.spec.tsx +143 -0
  173. package/src/fields/FileUpload.spec.tsx +334 -0
  174. package/src/fields/FileUploadField/FileUploadField.spec.tsx +166 -0
  175. package/src/fields/HTML/Html.spec.tsx +179 -0
  176. package/src/fields/Icon/Icon.stories.tsx +138 -0
  177. package/src/fields/Link.stories.tsx +2 -0
  178. package/src/fields/LongText/LongText.spec.tsx +217 -0
  179. package/src/fields/OTPInput/OTPInput.spec.tsx +167 -0
  180. package/src/fields/Password/Password.stories.tsx +21 -0
  181. package/src/fields/Phone/Phone.spec.tsx +151 -0
  182. package/src/fields/ReadOnly/ReadOnly.spec.tsx +229 -0
  183. package/src/fields/SmallText/SmallText.spec.tsx +175 -0
  184. package/src/fields/shared/EditorPreviewPanes.spec.tsx +122 -0
  185. package/src/formEngine/createPrefill.ts +28 -0
  186. package/src/formEngine/dirtyTracking.spec.ts +91 -0
  187. package/src/formEngine/dirtyTracking.ts +64 -0
  188. package/src/formEngine/doctypeMeta.ts +117 -0
  189. package/src/formEngine/fetchFrom.spec.ts +92 -0
  190. package/src/formEngine/fetchFrom.ts +111 -0
  191. package/src/formEngine/fieldPermissions.spec.ts +61 -0
  192. package/src/formEngine/fieldPermissions.ts +68 -0
  193. package/src/formEngine/formActions.ts +285 -0
  194. package/src/formEngine/formLayout.spec.ts +373 -0
  195. package/src/formEngine/formLayout.ts +392 -0
  196. package/src/formEngine/index.ts +33 -0
  197. package/src/formEngine/seam.spec.ts +148 -0
  198. package/src/formEngine/useFormEngine.ts +496 -0
  199. package/src/frappeRemainder.designLaw.spec.ts +9 -0
  200. package/src/frappeViews.designLaw.spec.ts +1 -0
  201. package/src/index.spec.ts +7 -3
  202. package/src/index.ts +51 -1
  203. package/src/schemaForm/SchemaForm.spec.tsx +4 -1
  204. package/types/ChartCard.d.ts +13 -0
  205. package/types/ChartCard.d.ts.map +1 -0
  206. package/types/FrappeDashboard.d.ts +2 -10
  207. package/types/FrappeDashboard.d.ts.map +1 -1
  208. package/types/dependsOn.d.ts +2 -2
  209. package/types/fields/CodeEditor/index.d.ts.map +1 -1
  210. package/types/formEngine/createPrefill.d.ts +19 -0
  211. package/types/formEngine/createPrefill.d.ts.map +1 -0
  212. package/types/formEngine/dirtyTracking.d.ts +36 -0
  213. package/types/formEngine/dirtyTracking.d.ts.map +1 -0
  214. package/types/formEngine/doctypeMeta.d.ts +117 -0
  215. package/types/formEngine/doctypeMeta.d.ts.map +1 -0
  216. package/types/formEngine/fetchFrom.d.ts +43 -0
  217. package/types/formEngine/fetchFrom.d.ts.map +1 -0
  218. package/types/formEngine/fieldPermissions.d.ts +38 -0
  219. package/types/formEngine/fieldPermissions.d.ts.map +1 -0
  220. package/types/formEngine/formActions.d.ts +95 -0
  221. package/types/formEngine/formActions.d.ts.map +1 -0
  222. package/types/formEngine/formLayout.d.ts +91 -0
  223. package/types/formEngine/formLayout.d.ts.map +1 -0
  224. package/types/formEngine/index.d.ts +27 -0
  225. package/types/formEngine/index.d.ts.map +1 -0
  226. package/types/formEngine/useFormEngine.d.ts +61 -0
  227. package/types/formEngine/useFormEngine.d.ts.map +1 -0
  228. package/types/index.d.ts +4 -1
  229. package/types/index.d.ts.map +1 -1
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Frappe FileUpload contract specs (the live-data uploader behind the
3
+ * "FrappeUI/FileUpload" catalog entry — not the forms dropzone, and not the
4
+ * FileUploadField registry adapter).
5
+ *
6
+ * Everything worth pinning here is transport state, because that is what the
7
+ * component owns and what a screenshot cannot show: the size gate before a
8
+ * byte is read, the progress channel, the failure row that keeps the existing
9
+ * value instead of wiping it (DG-VAL-04), retry, and the abort that has to
10
+ * actually reach the handler's AbortSignal.
11
+ *
12
+ * The default transport inserts into the File doctype, so every test injects
13
+ * `onUpload` — the documented override — and drives it by hand.
14
+ */
15
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
16
+ import { act, cleanup, fireEvent, waitFor } from "@testing-library/react";
17
+ import { afterEach, describe, expect, it, vi } from "vitest";
18
+ import { FileUpload } from "./FileUpload";
19
+
20
+ afterEach(cleanup);
21
+
22
+ const hiddenInput = (container: HTMLElement) =>
23
+ container.querySelector('input[type="file"]') as HTMLInputElement;
24
+
25
+ function makeFile(name = "report.pdf", size = 1024, type = "application/pdf") {
26
+ const file = new File(["x"], name, { type });
27
+ Object.defineProperty(file, "size", { value: size });
28
+ return file;
29
+ }
30
+
31
+ async function selectFile(container: HTMLElement, file: File) {
32
+ await act(async () => {
33
+ fireEvent.change(hiddenInput(container), { target: { files: [file] } });
34
+ });
35
+ }
36
+
37
+ /** A handler the test resolves or rejects on demand. */
38
+ function deferredUpload() {
39
+ let resolve!: (url: string) => void;
40
+ let reject!: (err: Error) => void;
41
+ let progress!: (percent: number) => void;
42
+ let signal!: AbortSignal;
43
+ const handler = vi.fn(
44
+ (_file: File, ctx: { onProgress: (p: number) => void; signal: AbortSignal }) => {
45
+ progress = ctx.onProgress;
46
+ signal = ctx.signal;
47
+ return new Promise<string>((res, rej) => {
48
+ resolve = res;
49
+ reject = rej;
50
+ });
51
+ },
52
+ );
53
+ return {
54
+ handler,
55
+ resolve: (url: string) => resolve(url),
56
+ reject: (err: Error) => reject(err),
57
+ progress: (p: number) => progress(p),
58
+ get signal() {
59
+ return signal;
60
+ },
61
+ };
62
+ }
63
+
64
+ describe("FileUpload empty state", () => {
65
+ it("offers a Choose File affordance and nothing else", () => {
66
+ const result = renderWithProviders(<FileUpload label="Attachment" onChange={() => {}} />);
67
+ expect(result.getButton("Choose File")).toBeTruthy();
68
+ expect(result.container.querySelector('[data-file-upload="transfer"]')).toBeNull();
69
+ });
70
+
71
+ it("marks a required field on its label", () => {
72
+ const result = renderWithProviders(
73
+ <FileUpload label="Attachment" required onChange={() => {}} />,
74
+ );
75
+ expect(result.container.textContent).toContain("Attachment");
76
+ expect(result.container.textContent).toContain("*");
77
+ });
78
+
79
+ it("skeleton branch offers no affordance at all", () => {
80
+ const result = renderWithProviders(
81
+ <FileUpload label="Attachment" skeleton onChange={() => {}} />,
82
+ );
83
+ expect(result.getButton("Choose File")).toBeUndefined();
84
+ });
85
+
86
+ it("shows helperText while nothing is in flight", () => {
87
+ const result = renderWithProviders(
88
+ <FileUpload label="Attachment" helperText="PDF or PNG, under 5MB" onChange={() => {}} />,
89
+ );
90
+ expect(result.container.textContent).toContain("PDF or PNG, under 5MB");
91
+ });
92
+ });
93
+
94
+ describe("FileUpload size gate", () => {
95
+ it("refuses a file over maxSize before the transport is ever called", async () => {
96
+ const onUpload = vi.fn();
97
+ const onChange = vi.fn();
98
+ const result = renderWithProviders(
99
+ <FileUpload label="Attachment" maxSize={1024} onUpload={onUpload} onChange={onChange} />,
100
+ );
101
+ await selectFile(result.container, makeFile("big.pdf", 2048));
102
+ expect(onUpload).not.toHaveBeenCalled();
103
+ expect(onChange).not.toHaveBeenCalled();
104
+ expect(result.container.textContent).toContain("exceeds");
105
+ });
106
+
107
+ it("accepts a file at exactly maxSize", async () => {
108
+ const onUpload = vi.fn().mockResolvedValue("/files/ok.pdf");
109
+ const result = renderWithProviders(
110
+ <FileUpload label="Attachment" maxSize={1024} onUpload={onUpload} onChange={() => {}} />,
111
+ );
112
+ await selectFile(result.container, makeFile("ok.pdf", 1024));
113
+ expect(onUpload).toHaveBeenCalledTimes(1);
114
+ });
115
+ });
116
+
117
+ /**
118
+ * i18n interpolation is INERT under `renderWithProviders`: the provider hands
119
+ * back the raw key, so `t("Retry {{name}}", { name })` renders the accessible
120
+ * name literally as `Retry {{name}}` rather than `Retry report.pdf`. Buttons
121
+ * are therefore looked up by the stable half of the label, and the per-file
122
+ * parameterisation is asserted separately (`ariaNames` below) so a dropped
123
+ * `aria-label` still fails. Recorded on MPO-123.
124
+ */
125
+ const buttonLabelled = (container: HTMLElement, prefix: string) =>
126
+ Array.from(container.querySelectorAll("button")).find((b) =>
127
+ (b.getAttribute("aria-label") ?? "").startsWith(prefix),
128
+ );
129
+
130
+ describe("FileUpload transfer", () => {
131
+ it("shows the file name and a live progressbar while uploading", async () => {
132
+ const upload = deferredUpload();
133
+ const result = renderWithProviders(
134
+ <FileUpload label="Attachment" onUpload={upload.handler} onChange={() => {}} />,
135
+ );
136
+ await selectFile(result.container, makeFile("report.pdf"));
137
+
138
+ const row = result.container.querySelector('[data-file-upload="transfer"]');
139
+ expect(row?.getAttribute("data-transfer-status")).toBe("uploading");
140
+ expect(result.container.textContent).toContain("report.pdf");
141
+
142
+ const bar = result.container.querySelector('[data-file-upload="progress"]');
143
+ expect(bar?.getAttribute("aria-valuenow")).toBe("0");
144
+ await act(async () => {
145
+ upload.progress(42);
146
+ });
147
+ expect(
148
+ result.container
149
+ .querySelector('[data-file-upload="progress"]')
150
+ ?.getAttribute("aria-valuenow"),
151
+ ).toBe("42");
152
+ });
153
+
154
+ it("clamps a nonsense progress report into 0..100", async () => {
155
+ const upload = deferredUpload();
156
+ const result = renderWithProviders(
157
+ <FileUpload label="Attachment" onUpload={upload.handler} onChange={() => {}} />,
158
+ );
159
+ await selectFile(result.container, makeFile());
160
+ await act(async () => {
161
+ upload.progress(999);
162
+ });
163
+ expect(
164
+ result.container
165
+ .querySelector('[data-file-upload="progress"]')
166
+ ?.getAttribute("aria-valuenow"),
167
+ ).toBe("100");
168
+ });
169
+
170
+ it("emits the committed URL through onChange and clears the transfer row", async () => {
171
+ const upload = deferredUpload();
172
+ const onChange = vi.fn();
173
+ const result = renderWithProviders(
174
+ <FileUpload label="Attachment" onUpload={upload.handler} onChange={onChange} />,
175
+ );
176
+ await selectFile(result.container, makeFile());
177
+ await act(async () => {
178
+ upload.resolve("/files/report.pdf");
179
+ });
180
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith("/files/report.pdf"));
181
+ expect(result.container.querySelector('[data-file-upload="transfer"]')).toBeNull();
182
+ });
183
+
184
+ it("keeps the existing value when the transfer fails (DG-VAL-04)", async () => {
185
+ const upload = deferredUpload();
186
+ const onChange = vi.fn();
187
+ const result = renderWithProviders(
188
+ <FileUpload
189
+ label="Attachment"
190
+ value="/files/previous.pdf"
191
+ onUpload={upload.handler}
192
+ onChange={onChange}
193
+ />,
194
+ );
195
+ await selectFile(result.container, makeFile());
196
+ await act(async () => {
197
+ upload.reject(new Error("network is down"));
198
+ });
199
+ await waitFor(() =>
200
+ expect(
201
+ result.container
202
+ .querySelector('[data-file-upload="transfer"]')
203
+ ?.getAttribute("data-transfer-status"),
204
+ ).toBe("failed"),
205
+ );
206
+ expect(onChange).not.toHaveBeenCalled();
207
+ expect(result.container.textContent).toContain("network is down");
208
+ expect(result.container.textContent).toContain("previous.pdf");
209
+ });
210
+
211
+ it("offers Retry and Remove on a failed row, and Retry re-runs the transport", async () => {
212
+ const upload = deferredUpload();
213
+ const result = renderWithProviders(
214
+ <FileUpload label="Attachment" onUpload={upload.handler} onChange={() => {}} />,
215
+ );
216
+ await selectFile(result.container, makeFile("report.pdf"));
217
+ await act(async () => {
218
+ upload.reject(new Error("boom"));
219
+ });
220
+ await waitFor(() => expect(buttonLabelled(result.container, "Retry ")).toBeTruthy());
221
+ const remove = buttonLabelled(result.container, "Remove ");
222
+ expect(remove).toBeTruthy();
223
+ // Both affordances carry a per-file accessible name, so a screen reader
224
+ // can tell one failed row's Retry from another's.
225
+ expect(buttonLabelled(result.container, "Retry ")?.getAttribute("aria-label")).toMatch(
226
+ /^Retry \S/,
227
+ );
228
+ expect(remove?.getAttribute("aria-label")).toMatch(/^Remove \S/);
229
+ // The visible text stays the short verb; the file name lives on the label.
230
+ expect(buttonLabelled(result.container, "Retry ")?.textContent).toContain("Retry");
231
+
232
+ await act(async () => {
233
+ fireEvent.click(buttonLabelled(result.container, "Retry ") as Element);
234
+ });
235
+ expect(upload.handler).toHaveBeenCalledTimes(2);
236
+ });
237
+
238
+ it("Cancel aborts the in-flight signal and drops the row", async () => {
239
+ const upload = deferredUpload();
240
+ const result = renderWithProviders(
241
+ <FileUpload label="Attachment" onUpload={upload.handler} onChange={() => {}} />,
242
+ );
243
+ await selectFile(result.container, makeFile("report.pdf"));
244
+ expect(upload.signal.aborted).toBe(false);
245
+
246
+ await act(async () => {
247
+ fireEvent.click(buttonLabelled(result.container, "Cancel ") as Element);
248
+ });
249
+ expect(upload.signal.aborted).toBe(true);
250
+ expect(result.container.querySelector('[data-file-upload="transfer"]')).toBeNull();
251
+ });
252
+
253
+ it("an aborted transport never emits onChange", async () => {
254
+ const upload = deferredUpload();
255
+ const onChange = vi.fn();
256
+ const result = renderWithProviders(
257
+ <FileUpload label="Attachment" onUpload={upload.handler} onChange={onChange} />,
258
+ );
259
+ await selectFile(result.container, makeFile("report.pdf"));
260
+ await act(async () => {
261
+ fireEvent.click(buttonLabelled(result.container, "Cancel ") as Element);
262
+ upload.resolve("/files/late.pdf");
263
+ });
264
+ expect(onChange).not.toHaveBeenCalled();
265
+ });
266
+ });
267
+
268
+ describe("FileUpload with a value", () => {
269
+ it("previews an image value as an <img> with a string src", () => {
270
+ const result = renderWithProviders(
271
+ <FileUpload label="Photo" isImage value="/files/photo.png" onChange={() => {}} />,
272
+ );
273
+ const img = result.container.querySelector("img");
274
+ expect(img?.getAttribute("src")).toBe("/files/photo.png");
275
+ });
276
+
277
+ it("shows the file name for a non-image value", () => {
278
+ const result = renderWithProviders(
279
+ <FileUpload label="Attachment" value="/files/deep/report.pdf" onChange={() => {}} />,
280
+ );
281
+ expect(result.container.textContent).toContain("report.pdf");
282
+ expect(result.container.querySelector("img")).toBeNull();
283
+ });
284
+
285
+ it("offers Replace and Remove, and Remove clears the value", () => {
286
+ const onChange = vi.fn();
287
+ const result = renderWithProviders(
288
+ <FileUpload label="Attachment" value="/files/report.pdf" onChange={onChange} />,
289
+ );
290
+ expect(result.getButton("Replace")).toBeTruthy();
291
+ fireEvent.click(result.getButton("Remove") as Element);
292
+ expect(onChange).toHaveBeenCalledWith("");
293
+ });
294
+
295
+ it("readOnly hides Replace and Remove — the value is visible, not editable", () => {
296
+ const result = renderWithProviders(
297
+ <FileUpload label="Attachment" value="/files/report.pdf" readOnly onChange={() => {}} />,
298
+ );
299
+ expect(result.getButton("Replace")).toBeUndefined();
300
+ expect(result.getButton("Remove")).toBeUndefined();
301
+ });
302
+
303
+ it("disabled refuses a Remove even though the button is on screen", () => {
304
+ const onChange = vi.fn();
305
+ const result = renderWithProviders(
306
+ <FileUpload label="Attachment" value="/files/report.pdf" disabled onChange={onChange} />,
307
+ );
308
+ fireEvent.click(result.getButton("Remove") as Element);
309
+ expect(onChange).not.toHaveBeenCalled();
310
+ });
311
+
312
+ it("disabled refuses a file selection outright", async () => {
313
+ const onUpload = vi.fn();
314
+ const result = renderWithProviders(
315
+ <FileUpload label="Attachment" disabled onUpload={onUpload} onChange={() => {}} />,
316
+ );
317
+ await selectFile(result.container, makeFile());
318
+ expect(onUpload).not.toHaveBeenCalled();
319
+ });
320
+ });
321
+
322
+ describe("FileUpload accept", () => {
323
+ it("passes an explicit accept list to the native picker", () => {
324
+ const result = renderWithProviders(
325
+ <FileUpload label="Attachment" accept=".pdf,.docx" onChange={() => {}} />,
326
+ );
327
+ expect(hiddenInput(result.container).getAttribute("accept")).toBe(".pdf,.docx");
328
+ });
329
+
330
+ it("falls back to image/* when the field is an image field", () => {
331
+ const result = renderWithProviders(<FileUpload label="Photo" isImage onChange={() => {}} />);
332
+ expect(hiddenInput(result.container).getAttribute("accept")).toBe("image/*");
333
+ });
334
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * FileUploadField contract specs. This is the registry adapter behind Frappe's
3
+ * `Attach` and `Attach Image` fieldtypes, so it is the field a DocType actually
4
+ * gets. Three things are its own — everything else it forwards to the forms
5
+ * FileUpload:
6
+ *
7
+ * 1. the choose-from-library tab (MPO-53 AC-11), on by default, which is
8
+ * what makes this field Attach rather than a plain dropzone,
9
+ * 2. the `isImage` accept default, which is what makes Attach Image narrower
10
+ * than Attach,
11
+ * 3. a controlled `value` winning over `defaultValue`, so a saved attachment
12
+ * paints on a re-render instead of the mount-time default.
13
+ */
14
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
15
+ import { cleanup } from "@testing-library/react";
16
+ import type React from "react";
17
+ import { afterEach, describe, expect, it, vi } from "vitest";
18
+ import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
19
+ import { FileUploadField } from ".";
20
+
21
+ afterEach(cleanup);
22
+
23
+ const nativePicker = (container: HTMLElement) =>
24
+ container.querySelector('input[type="file"]') as HTMLInputElement | null;
25
+
26
+ describe("FileUploadField library tab", () => {
27
+ it("offers the From Library action by default — this field IS Attach", () => {
28
+ const result = renderWithProviders(<FileUploadField label="Attachment" />);
29
+ expect(result.getButton("From Library")).toBeTruthy();
30
+ });
31
+
32
+ it("drops the action when the field opts out", () => {
33
+ const result = renderWithProviders(<FileUploadField label="Attachment" library={false} />);
34
+ expect(result.getButton("From Library")).toBeUndefined();
35
+ });
36
+
37
+ it("disables the action on a disabled field", () => {
38
+ const result = renderWithProviders(<FileUploadField label="Attachment" disabled />);
39
+ const button = result.getButton("From Library");
40
+ expect(button).toBeTruthy();
41
+ expect(
42
+ button?.getAttribute("aria-disabled") === "true" || (button as HTMLButtonElement).disabled,
43
+ ).toBe(true);
44
+ });
45
+
46
+ it("disables the action on a read-only field", () => {
47
+ const result = renderWithProviders(<FileUploadField label="Attachment" readOnly />);
48
+ const button = result.getButton("From Library");
49
+ expect(button).toBeTruthy();
50
+ expect(
51
+ button?.getAttribute("aria-disabled") === "true" || (button as HTMLButtonElement).disabled,
52
+ ).toBe(true);
53
+ });
54
+ });
55
+
56
+ describe("FileUploadField accept", () => {
57
+ it("sends no accept list for a plain Attach", () => {
58
+ const result = renderWithProviders(<FileUploadField label="Attachment" />);
59
+ expect(nativePicker(result.container)?.getAttribute("accept")).toBeNull();
60
+ });
61
+
62
+ it("narrows to the image list for Attach Image", () => {
63
+ const result = renderWithProviders(<FileUploadField label="Photo" isImage />);
64
+ const accept = nativePicker(result.container)?.getAttribute("accept") ?? "";
65
+ expect(accept).toContain("image/png");
66
+ expect(accept).toContain("image/jpeg");
67
+ expect(accept).toContain("image/svg+xml");
68
+ });
69
+
70
+ it("an explicit accept wins over the isImage default", () => {
71
+ const result = renderWithProviders(
72
+ <FileUploadField label="Photo" isImage accept="image/png" />,
73
+ );
74
+ expect(nativePicker(result.container)?.getAttribute("accept")).toBe("image/png");
75
+ });
76
+ });
77
+
78
+ describe("FileUploadField chrome", () => {
79
+ it("shows the label and the helperText when the field is clean", () => {
80
+ const result = renderWithProviders(
81
+ <FileUploadField label="Attachment" helperText="under 5MB" />,
82
+ );
83
+ expect(result.container.textContent).toContain("Attachment");
84
+ expect(result.container.textContent).toContain("under 5MB");
85
+ });
86
+
87
+ it("an error REPLACES the helperText rather than stacking under it", () => {
88
+ // FieldLayout's law, and the reason a field never shows contradictory
89
+ // guidance: one message slot, error wins.
90
+ const result = renderWithProviders(
91
+ <FileUploadField label="Attachment" helperText="under 5MB" error="required" />,
92
+ );
93
+ expect(result.container.textContent).toContain("Attachment");
94
+ expect(result.container.textContent).toContain("required");
95
+ expect(result.container.textContent).not.toContain("under 5MB");
96
+ });
97
+
98
+ it("skeleton branch renders no picker", () => {
99
+ const result = renderWithProviders(<FileUploadField label="Attachment" skeleton />);
100
+ expect(nativePicker(result.container)).toBeNull();
101
+ });
102
+ });
103
+
104
+ describe("registry resolution (Attach / Attach Image fieldtypes)", () => {
105
+ function propsFor(overrides: Partial<FieldRenderProps> = {}): FieldRenderProps {
106
+ return { value: "", onChange: () => {}, ...overrides };
107
+ }
108
+
109
+ it("resolves 'Attach' to FileUploadField with the library on", () => {
110
+ const Component = getFieldComponent("Attach")!;
111
+ expect(Component).toBeTruthy();
112
+ const element = (Component as Function)(
113
+ propsFor({ value: "/files/a.pdf", label: "Attachment" }),
114
+ ) as React.ReactElement<Record<string, any>>;
115
+ expect(element.type).toBe(FileUploadField);
116
+ expect(element.props.value).toBe("/files/a.pdf");
117
+ expect(element.props.library).toBe(true);
118
+ expect(element.props.isImage).toBeUndefined();
119
+ });
120
+
121
+ it("resolves 'Attach Image' to the same component with isImage set", () => {
122
+ const Component = getFieldComponent("Attach Image")!;
123
+ expect(Component).toBeTruthy();
124
+ expect(Component).not.toBe(getFieldComponent("Attach"));
125
+ const element = (Component as Function)(propsFor({ label: "Photo" })) as React.ReactElement<
126
+ Record<string, any>
127
+ >;
128
+ expect(element.type).toBe(FileUploadField);
129
+ expect(element.props.isImage).toBe(true);
130
+ });
131
+
132
+ it("honours an explicit library:false from the schema", () => {
133
+ const Component = getFieldComponent("Attach")!;
134
+ const element = (Component as Function)(
135
+ propsFor({ library: false } as Partial<FieldRenderProps>),
136
+ ) as React.ReactElement<Record<string, any>>;
137
+ expect(element.props.library).toBe(false);
138
+ });
139
+
140
+ it("feeds the library its file list and folder from the schema", () => {
141
+ const Component = getFieldComponent("Attach")!;
142
+ const libraryFiles = [{ name: "a.png", file_url: "/files/a.png" }];
143
+ const element = (Component as Function)(
144
+ propsFor({ libraryFiles, libraryFolder: "Home/Attachments" } as Partial<FieldRenderProps>),
145
+ ) as React.ReactElement<Record<string, any>>;
146
+ expect(element.props.libraryFiles).toBe(libraryFiles);
147
+ expect(element.props.libraryFolder).toBe("Home/Attachments");
148
+ });
149
+
150
+ it("does not coerce a null value — an unset Attach must stay unset", () => {
151
+ const Component = getFieldComponent("Attach")!;
152
+ const element = (Component as Function)(
153
+ propsFor({ value: null as unknown as string }),
154
+ ) as React.ReactElement<Record<string, any>>;
155
+ expect(element.props.value).toBeNull();
156
+ });
157
+
158
+ it("wires the canonical onChange straight through", () => {
159
+ const onChange = vi.fn();
160
+ const Component = getFieldComponent("Attach")!;
161
+ const element = (Component as Function)(propsFor({ onChange })) as React.ReactElement<
162
+ Record<string, any>
163
+ >;
164
+ expect(element.props.onChange).toBe(onChange);
165
+ });
166
+ });
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Html field contract specs. The HTML fieldtype is display-only: it takes a
3
+ * server-supplied HTML string and paints it, so the whole contract is
4
+ * (1) what `sanitizeHtml` refuses to let through and (2) what the field shows
5
+ * when there is nothing to paint.
6
+ *
7
+ * The sanitiser is the security surface — a Frappe HTML field carries whatever
8
+ * a desk author typed — so it is asserted directly, tag by tag, rather than
9
+ * through the rendered tree.
10
+ */
11
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
12
+ import { cleanup } from "@testing-library/react";
13
+ import type React from "react";
14
+ import { afterEach, describe, expect, it } from "vitest";
15
+ import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
16
+ import { Html, sanitizeHtml } from ".";
17
+
18
+ afterEach(cleanup);
19
+
20
+ describe("sanitizeHtml", () => {
21
+ it("keeps ordinary formatting markup", () => {
22
+ const out = sanitizeHtml(
23
+ '<p>hello <strong>world</strong> <a href="https://x.test">link</a></p>',
24
+ );
25
+ expect(out).toContain("<strong>world</strong>");
26
+ expect(out).toContain('href="https://x.test"');
27
+ });
28
+
29
+ it("drops executable and document-structure tags", () => {
30
+ for (const tag of ["script", "style", "iframe", "object", "embed", "form", "svg", "template"]) {
31
+ const out = sanitizeHtml(`<p>keep</p><${tag}>evil</${tag}>`);
32
+ expect(out).toContain("keep");
33
+ expect(out.toLowerCase()).not.toContain(`<${tag}`);
34
+ }
35
+ });
36
+
37
+ it("drops interactive form controls", () => {
38
+ const out = sanitizeHtml(
39
+ '<input value="x"><button>go</button><textarea></textarea><select></select>',
40
+ );
41
+ expect(out.toLowerCase()).not.toContain("<input");
42
+ expect(out.toLowerCase()).not.toContain("<button");
43
+ expect(out.toLowerCase()).not.toContain("<textarea");
44
+ expect(out.toLowerCase()).not.toContain("<select");
45
+ });
46
+
47
+ it("strips every event-handler attribute and srcdoc", () => {
48
+ const out = sanitizeHtml('<div onclick="steal()" onmouseover="x()" srcdoc="y">text</div>');
49
+ expect(out.toLowerCase()).not.toContain("onclick");
50
+ expect(out.toLowerCase()).not.toContain("onmouseover");
51
+ expect(out.toLowerCase()).not.toContain("srcdoc");
52
+ expect(out).toContain("text");
53
+ });
54
+
55
+ it("strips javascript:, vbscript: and data:text/html URIs, keeping the element", () => {
56
+ for (const uri of ["javascript:alert(1)", "vbscript:msgbox(1)", "data:text/html,<b>x</b>"]) {
57
+ const out = sanitizeHtml(`<a href="${uri}">click</a>`);
58
+ expect(out).toContain("click");
59
+ expect(out.toLowerCase()).not.toContain("href=");
60
+ }
61
+ });
62
+
63
+ it("sees through control characters padded into a javascript: URI", () => {
64
+ const padded = `java
65
+ script:alert(1)`;
66
+ const out = sanitizeHtml(`<a href="${padded}">click</a>`);
67
+ expect(out.toLowerCase()).not.toContain("href=");
68
+ });
69
+
70
+ it("leaves safe URI attributes alone", () => {
71
+ const out = sanitizeHtml('<a href="/desk/app">in-app</a>');
72
+ expect(out).toContain('href="/desk/app"');
73
+ });
74
+
75
+ it("removes comments and returns empty string for empty input", () => {
76
+ expect(sanitizeHtml("<!-- secret --><p>a</p>")).not.toContain("secret");
77
+ expect(sanitizeHtml("")).toBe("");
78
+ });
79
+
80
+ it("sanitises nested descendants, not just the top level", () => {
81
+ const out = sanitizeHtml(
82
+ '<div><section><img src="javascript:alert(1)" onerror="x()"></section></div>',
83
+ );
84
+ expect(out.toLowerCase()).not.toContain("onerror");
85
+ expect(out.toLowerCase()).not.toContain("javascript:");
86
+ });
87
+ });
88
+
89
+ describe("Html field", () => {
90
+ it("paints the sanitised value into the DOM", () => {
91
+ const result = renderWithProviders(
92
+ <Html label="Body" value='<p id="painted">hello <em>there</em></p>' />,
93
+ );
94
+ const painted = result.container.querySelector("#painted");
95
+ expect(painted).toBeTruthy();
96
+ expect(painted?.querySelector("em")?.textContent).toBe("there");
97
+ });
98
+
99
+ it("never paints a script the value carried", () => {
100
+ const result = renderWithProviders(
101
+ <Html label="Body" value="<p>ok</p><script>globalThis.__pwned = 1;</script>" />,
102
+ );
103
+ expect(result.container.querySelector("script")).toBeNull();
104
+ expect(result.container.textContent).toContain("ok");
105
+ });
106
+
107
+ it("falls back to 'No content' when the value is empty", () => {
108
+ const result = renderWithProviders(<Html label="Body" value="" />);
109
+ expect(result.container.textContent).toContain("No content");
110
+ });
111
+
112
+ it("falls back to 'No content' when sanitising removed everything", () => {
113
+ const result = renderWithProviders(<Html label="Body" value="<script>evil()</script>" />);
114
+ expect(result.container.textContent).toContain("No content");
115
+ });
116
+
117
+ it("shows the label, helperText and error from FieldLayout", () => {
118
+ const result = renderWithProviders(
119
+ <Html label="Description" value="<p>x</p>" helperText="server rendered" error="broken" />,
120
+ );
121
+ expect(result.container.textContent).toContain("Description");
122
+ expect(result.container.textContent).toContain("broken");
123
+ });
124
+
125
+ it("skeleton branch paints neither the value nor the empty text", () => {
126
+ const result = renderWithProviders(
127
+ <Html label="Body" skeleton value='<p id="painted">x</p>' />,
128
+ );
129
+ expect(result.container.querySelector("#painted")).toBeNull();
130
+ expect(result.container.textContent).not.toContain("No content");
131
+ });
132
+ });
133
+
134
+ describe("registry resolution (HTML fieldtype)", () => {
135
+ function propsFor(overrides: Partial<FieldRenderProps> = {}): FieldRenderProps {
136
+ return { value: "", onChange: () => {}, ...overrides };
137
+ }
138
+
139
+ it("resolves 'HTML' to Html and forwards the value", () => {
140
+ const Component = getFieldComponent("HTML")!;
141
+ expect(Component).toBeTruthy();
142
+ const element = (Component as Function)(
143
+ propsFor({ value: "<p>doc</p>", label: "Body" }),
144
+ ) as React.ReactElement<Record<string, any>>;
145
+ expect(element.type).toBe(Html);
146
+ expect(element.props.value).toBe("<p>doc</p>");
147
+ expect(element.props.label).toBe("Body");
148
+ });
149
+
150
+ it("is display-only: the adapter passes no onChange", () => {
151
+ const Component = getFieldComponent("HTML")!;
152
+ const element = (Component as Function)(propsFor()) as React.ReactElement<Record<string, any>>;
153
+ expect(element.props.onChange).toBeUndefined();
154
+ });
155
+
156
+ it("substitutes an empty string for a null value so 'No content' shows", () => {
157
+ const Component = getFieldComponent("HTML")!;
158
+ const element = (Component as Function)(
159
+ propsFor({ value: null as unknown as string }),
160
+ ) as React.ReactElement<Record<string, any>>;
161
+ expect(element.props.value).toBe("");
162
+ });
163
+
164
+ it("passes helperText / readOnly / skeleton / compact through", () => {
165
+ const Component = getFieldComponent("HTML")!;
166
+ const element = (Component as Function)(
167
+ propsFor({
168
+ helperText: "rendered server-side",
169
+ readOnly: true,
170
+ skeleton: true,
171
+ compact: true,
172
+ }),
173
+ ) as React.ReactElement<Record<string, any>>;
174
+ expect(element.props.helperText).toBe("rendered server-side");
175
+ expect(element.props.readOnly).toBe(true);
176
+ expect(element.props.skeleton).toBe(true);
177
+ expect(element.props.compact).toBe(true);
178
+ });
179
+ });