@multiplatform.one/frappe-ui 7.6.3 → 7.7.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 (285) 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/FileBrowser/AttachLibrary.cjs +2 -2
  5. package/dist/cjs/FileBrowser/AttachLibrary.native.js +8 -2
  6. package/dist/cjs/FileBrowser/AttachLibrary.native.js.map +1 -1
  7. package/dist/cjs/FrappeChartCard.cjs +3 -3
  8. package/dist/cjs/FrappeChartCard.native.js +3 -3
  9. package/dist/cjs/FrappeChartCard.native.js.map +1 -1
  10. package/dist/cjs/FrappeDashboard.cjs +6 -27
  11. package/dist/cjs/FrappeDashboard.native.js +6 -33
  12. package/dist/cjs/FrappeDashboard.native.js.map +1 -1
  13. package/dist/cjs/FrappeImageView.cjs +2 -8
  14. package/dist/cjs/FrappeImageView.native.js +2 -9
  15. package/dist/cjs/FrappeImageView.native.js.map +1 -1
  16. package/dist/cjs/fields/CodeEditor/index.cjs +4 -0
  17. package/dist/cjs/fields/CodeEditor/index.native.js +4 -0
  18. package/dist/cjs/fields/CodeEditor/index.native.js.map +1 -1
  19. package/dist/cjs/fields/LinkTree.cjs +5 -3
  20. package/dist/cjs/fields/LinkTree.native.js +9 -5
  21. package/dist/cjs/fields/LinkTree.native.js.map +1 -1
  22. package/dist/cjs/formEngine/createPrefill.cjs +37 -0
  23. package/dist/cjs/formEngine/createPrefill.native.js +40 -0
  24. package/dist/cjs/formEngine/createPrefill.native.js.map +1 -0
  25. package/dist/cjs/formEngine/dirtyTracking.cjs +55 -0
  26. package/dist/cjs/formEngine/dirtyTracking.native.js +77 -0
  27. package/dist/cjs/formEngine/dirtyTracking.native.js.map +1 -0
  28. package/dist/cjs/formEngine/doctypeMeta.cjs +18 -0
  29. package/dist/cjs/formEngine/doctypeMeta.native.js +21 -0
  30. package/dist/cjs/formEngine/doctypeMeta.native.js.map +1 -0
  31. package/dist/cjs/formEngine/fetchFrom.cjs +67 -0
  32. package/dist/cjs/formEngine/fetchFrom.native.js +110 -0
  33. package/dist/cjs/formEngine/fetchFrom.native.js.map +1 -0
  34. package/dist/cjs/formEngine/fieldPermissions.cjs +55 -0
  35. package/dist/cjs/formEngine/fieldPermissions.native.js +80 -0
  36. package/dist/cjs/formEngine/fieldPermissions.native.js.map +1 -0
  37. package/dist/cjs/formEngine/formActions.cjs +189 -0
  38. package/dist/cjs/formEngine/formActions.native.js +199 -0
  39. package/dist/cjs/formEngine/formActions.native.js.map +1 -0
  40. package/dist/cjs/formEngine/formLayout.cjs +245 -0
  41. package/dist/cjs/formEngine/formLayout.native.js +388 -0
  42. package/dist/cjs/formEngine/formLayout.native.js.map +1 -0
  43. package/dist/cjs/formEngine/index.cjs +48 -0
  44. package/dist/cjs/formEngine/index.native.js +51 -0
  45. package/dist/cjs/formEngine/index.native.js.map +1 -0
  46. package/dist/cjs/formEngine/useFormEngine.cjs +387 -0
  47. package/dist/cjs/formEngine/useFormEngine.native.js +476 -0
  48. package/dist/cjs/formEngine/useFormEngine.native.js.map +1 -0
  49. package/dist/cjs/frappeStoryTarget.cjs +4 -2
  50. package/dist/cjs/frappeStoryTarget.native.js +4 -3
  51. package/dist/cjs/frappeStoryTarget.native.js.map +1 -1
  52. package/dist/cjs/index.cjs +19 -1
  53. package/dist/cjs/index.native.js +19 -1
  54. package/dist/cjs/index.native.js.map +1 -1
  55. package/dist/esm/ChartCard.mjs +27 -0
  56. package/dist/esm/ChartCard.mjs.map +1 -0
  57. package/dist/esm/ChartCard.native.js +33 -0
  58. package/dist/esm/ChartCard.native.js.map +1 -0
  59. package/dist/esm/FileBrowser/AttachLibrary.mjs +2 -2
  60. package/dist/esm/FileBrowser/AttachLibrary.mjs.map +1 -1
  61. package/dist/esm/FileBrowser/AttachLibrary.native.js +8 -2
  62. package/dist/esm/FileBrowser/AttachLibrary.native.js.map +1 -1
  63. package/dist/esm/FrappeChartCard.mjs +1 -1
  64. package/dist/esm/FrappeChartCard.native.js +1 -1
  65. package/dist/esm/FrappeChartCard.native.js.map +1 -1
  66. package/dist/esm/FrappeDashboard.mjs +6 -27
  67. package/dist/esm/FrappeDashboard.mjs.map +1 -1
  68. package/dist/esm/FrappeDashboard.native.js +6 -33
  69. package/dist/esm/FrappeDashboard.native.js.map +1 -1
  70. package/dist/esm/FrappeImageView.mjs +2 -7
  71. package/dist/esm/FrappeImageView.mjs.map +1 -1
  72. package/dist/esm/FrappeImageView.native.js +2 -8
  73. package/dist/esm/FrappeImageView.native.js.map +1 -1
  74. package/dist/esm/fields/CodeEditor/index.mjs +4 -0
  75. package/dist/esm/fields/CodeEditor/index.mjs.map +1 -1
  76. package/dist/esm/fields/CodeEditor/index.native.js +4 -0
  77. package/dist/esm/fields/CodeEditor/index.native.js.map +1 -1
  78. package/dist/esm/fields/LinkTree.mjs +5 -3
  79. package/dist/esm/fields/LinkTree.mjs.map +1 -1
  80. package/dist/esm/fields/LinkTree.native.js +9 -5
  81. package/dist/esm/fields/LinkTree.native.js.map +1 -1
  82. package/dist/esm/formEngine/createPrefill.mjs +11 -0
  83. package/dist/esm/formEngine/createPrefill.mjs.map +1 -0
  84. package/dist/esm/formEngine/createPrefill.native.js +11 -0
  85. package/dist/esm/formEngine/createPrefill.native.js.map +1 -0
  86. package/dist/esm/formEngine/dirtyTracking.mjs +28 -0
  87. package/dist/esm/formEngine/dirtyTracking.mjs.map +1 -0
  88. package/dist/esm/formEngine/dirtyTracking.native.js +47 -0
  89. package/dist/esm/formEngine/dirtyTracking.native.js.map +1 -0
  90. package/dist/esm/formEngine/doctypeMeta.mjs +2 -0
  91. package/dist/esm/formEngine/doctypeMeta.mjs.map +1 -0
  92. package/dist/esm/formEngine/doctypeMeta.native.js +2 -0
  93. package/dist/esm/formEngine/doctypeMeta.native.js.map +1 -0
  94. package/dist/esm/formEngine/fetchFrom.mjs +40 -0
  95. package/dist/esm/formEngine/fetchFrom.mjs.map +1 -0
  96. package/dist/esm/formEngine/fetchFrom.native.js +80 -0
  97. package/dist/esm/formEngine/fetchFrom.native.js.map +1 -0
  98. package/dist/esm/formEngine/fieldPermissions.mjs +28 -0
  99. package/dist/esm/formEngine/fieldPermissions.mjs.map +1 -0
  100. package/dist/esm/formEngine/fieldPermissions.native.js +50 -0
  101. package/dist/esm/formEngine/fieldPermissions.native.js.map +1 -0
  102. package/dist/esm/formEngine/formActions.mjs +146 -0
  103. package/dist/esm/formEngine/formActions.mjs.map +1 -0
  104. package/dist/esm/formEngine/formActions.native.js +153 -0
  105. package/dist/esm/formEngine/formActions.native.js.map +1 -0
  106. package/dist/esm/formEngine/formLayout.mjs +217 -0
  107. package/dist/esm/formEngine/formLayout.mjs.map +1 -0
  108. package/dist/esm/formEngine/formLayout.native.js +357 -0
  109. package/dist/esm/formEngine/formLayout.native.js.map +1 -0
  110. package/dist/esm/formEngine/index.mjs +8 -0
  111. package/dist/esm/formEngine/index.mjs.map +1 -0
  112. package/dist/esm/formEngine/index.native.js +8 -0
  113. package/dist/esm/formEngine/index.native.js.map +1 -0
  114. package/dist/esm/formEngine/useFormEngine.mjs +362 -0
  115. package/dist/esm/formEngine/useFormEngine.mjs.map +1 -0
  116. package/dist/esm/formEngine/useFormEngine.native.js +448 -0
  117. package/dist/esm/formEngine/useFormEngine.native.js.map +1 -0
  118. package/dist/esm/frappeStoryTarget.mjs +4 -1
  119. package/dist/esm/frappeStoryTarget.mjs.map +1 -1
  120. package/dist/esm/frappeStoryTarget.native.js +4 -2
  121. package/dist/esm/frappeStoryTarget.native.js.map +1 -1
  122. package/dist/esm/index.mjs +4 -2
  123. package/dist/esm/index.mjs.map +1 -1
  124. package/dist/esm/index.native.js +4 -2
  125. package/dist/esm/index.native.js.map +1 -1
  126. package/dist/jsx/ChartCard.mjs +27 -0
  127. package/dist/jsx/ChartCard.mjs.map +1 -0
  128. package/dist/jsx/ChartCard.native.js +72 -0
  129. package/dist/jsx/ChartCard.native.js.map +1 -0
  130. package/dist/jsx/FileBrowser/AttachLibrary.mjs +2 -2
  131. package/dist/jsx/FileBrowser/AttachLibrary.mjs.map +1 -1
  132. package/dist/jsx/FileBrowser/AttachLibrary.native.js +8 -2
  133. package/dist/jsx/FileBrowser/AttachLibrary.native.js.map +1 -1
  134. package/dist/jsx/FrappeChartCard.mjs +1 -1
  135. package/dist/jsx/FrappeChartCard.native.js +3 -3
  136. package/dist/jsx/FrappeChartCard.native.js.map +1 -1
  137. package/dist/jsx/FrappeDashboard.mjs +6 -27
  138. package/dist/jsx/FrappeDashboard.mjs.map +1 -1
  139. package/dist/jsx/FrappeDashboard.native.js +6 -33
  140. package/dist/jsx/FrappeDashboard.native.js.map +1 -1
  141. package/dist/jsx/FrappeImageView.mjs +2 -7
  142. package/dist/jsx/FrappeImageView.mjs.map +1 -1
  143. package/dist/jsx/FrappeImageView.native.js +2 -9
  144. package/dist/jsx/FrappeImageView.native.js.map +1 -1
  145. package/dist/jsx/fields/CodeEditor/index.mjs +4 -0
  146. package/dist/jsx/fields/CodeEditor/index.mjs.map +1 -1
  147. package/dist/jsx/fields/CodeEditor/index.native.js +4 -0
  148. package/dist/jsx/fields/CodeEditor/index.native.js.map +1 -1
  149. package/dist/jsx/fields/LinkTree.mjs +5 -3
  150. package/dist/jsx/fields/LinkTree.mjs.map +1 -1
  151. package/dist/jsx/fields/LinkTree.native.js +9 -5
  152. package/dist/jsx/fields/LinkTree.native.js.map +1 -1
  153. package/dist/jsx/formEngine/createPrefill.mjs +11 -0
  154. package/dist/jsx/formEngine/createPrefill.mjs.map +1 -0
  155. package/dist/jsx/formEngine/createPrefill.native.js +40 -0
  156. package/dist/jsx/formEngine/createPrefill.native.js.map +1 -0
  157. package/dist/jsx/formEngine/dirtyTracking.mjs +28 -0
  158. package/dist/jsx/formEngine/dirtyTracking.mjs.map +1 -0
  159. package/dist/jsx/formEngine/dirtyTracking.native.js +77 -0
  160. package/dist/jsx/formEngine/dirtyTracking.native.js.map +1 -0
  161. package/dist/jsx/formEngine/doctypeMeta.mjs +2 -0
  162. package/dist/jsx/formEngine/doctypeMeta.mjs.map +1 -0
  163. package/dist/jsx/formEngine/doctypeMeta.native.js +21 -0
  164. package/dist/jsx/formEngine/doctypeMeta.native.js.map +1 -0
  165. package/dist/jsx/formEngine/fetchFrom.mjs +40 -0
  166. package/dist/jsx/formEngine/fetchFrom.mjs.map +1 -0
  167. package/dist/jsx/formEngine/fetchFrom.native.js +110 -0
  168. package/dist/jsx/formEngine/fetchFrom.native.js.map +1 -0
  169. package/dist/jsx/formEngine/fieldPermissions.mjs +28 -0
  170. package/dist/jsx/formEngine/fieldPermissions.mjs.map +1 -0
  171. package/dist/jsx/formEngine/fieldPermissions.native.js +80 -0
  172. package/dist/jsx/formEngine/fieldPermissions.native.js.map +1 -0
  173. package/dist/jsx/formEngine/formActions.mjs +146 -0
  174. package/dist/jsx/formEngine/formActions.mjs.map +1 -0
  175. package/dist/jsx/formEngine/formActions.native.js +199 -0
  176. package/dist/jsx/formEngine/formActions.native.js.map +1 -0
  177. package/dist/jsx/formEngine/formLayout.mjs +217 -0
  178. package/dist/jsx/formEngine/formLayout.mjs.map +1 -0
  179. package/dist/jsx/formEngine/formLayout.native.js +388 -0
  180. package/dist/jsx/formEngine/formLayout.native.js.map +1 -0
  181. package/dist/jsx/formEngine/index.mjs +8 -0
  182. package/dist/jsx/formEngine/index.mjs.map +1 -0
  183. package/dist/jsx/formEngine/index.native.js +51 -0
  184. package/dist/jsx/formEngine/index.native.js.map +1 -0
  185. package/dist/jsx/formEngine/useFormEngine.mjs +362 -0
  186. package/dist/jsx/formEngine/useFormEngine.mjs.map +1 -0
  187. package/dist/jsx/formEngine/useFormEngine.native.js +476 -0
  188. package/dist/jsx/formEngine/useFormEngine.native.js.map +1 -0
  189. package/dist/jsx/frappeStoryTarget.mjs +4 -1
  190. package/dist/jsx/frappeStoryTarget.mjs.map +1 -1
  191. package/dist/jsx/frappeStoryTarget.native.js +4 -3
  192. package/dist/jsx/frappeStoryTarget.native.js.map +1 -1
  193. package/dist/jsx/index.js +4 -2
  194. package/dist/jsx/index.js.map +1 -1
  195. package/dist/jsx/index.mjs +4 -2
  196. package/dist/jsx/index.mjs.map +1 -1
  197. package/dist/jsx/index.native.js +19 -1
  198. package/dist/jsx/index.native.js.map +1 -1
  199. package/package.json +23 -17
  200. package/src/ChartCard.tsx +31 -0
  201. package/src/FileBrowser/AttachLibrary.tsx +13 -1
  202. package/src/FileBrowser/FileBrowser.spec.tsx +16 -0
  203. package/src/FileBrowser/FileBrowser.stories.tsx +18 -1
  204. package/src/FileBrowser/folderTree.spec.tsx +68 -0
  205. package/src/FrappeCalendar.stories.tsx +1 -2
  206. package/src/FrappeChartCard.stories.tsx +1 -1
  207. package/src/FrappeChartCard.tsx +1 -1
  208. package/src/FrappeDashboard.stories.tsx +6 -5
  209. package/src/FrappeDashboard.tsx +6 -33
  210. package/src/FrappeGantt.stories.tsx +1 -2
  211. package/src/FrappeImageView.tsx +16 -15
  212. package/src/FrappeList.stories.tsx +3 -10
  213. package/src/classicScriptSafety.spec.ts +47 -0
  214. package/src/components/attachments.stories.tsx +9 -2
  215. package/src/dependsOn.ts +2 -2
  216. package/src/desk/DeskFormSidebar.stories.tsx +19 -1
  217. package/src/fields/CodeEditor/CodeEditor.spec.tsx +37 -0
  218. package/src/fields/CodeEditor/CodeEditor.stories.tsx +83 -66
  219. package/src/fields/CodeEditor/index.tsx +4 -0
  220. package/src/fields/DateRange/DateRange.spec.tsx +143 -0
  221. package/src/fields/FileUpload.spec.tsx +334 -0
  222. package/src/fields/FileUploadField/FileUploadField.spec.tsx +166 -0
  223. package/src/fields/HTML/Html.spec.tsx +179 -0
  224. package/src/fields/Icon/Icon.stories.tsx +138 -0
  225. package/src/fields/Link.stories.tsx +2 -0
  226. package/src/fields/LinkTree.tsx +18 -3
  227. package/src/fields/LongText/LongText.spec.tsx +217 -0
  228. package/src/fields/OTPInput/OTPInput.spec.tsx +167 -0
  229. package/src/fields/Password/Password.stories.tsx +21 -0
  230. package/src/fields/Phone/Phone.spec.tsx +151 -0
  231. package/src/fields/Progress/Progress.spec.tsx +160 -0
  232. package/src/fields/ReadOnly/ReadOnly.spec.tsx +229 -0
  233. package/src/fields/SmallText/SmallText.spec.tsx +175 -0
  234. package/src/fields/shared/EditorPreviewPanes.spec.tsx +122 -0
  235. package/src/formEngine/createPrefill.ts +28 -0
  236. package/src/formEngine/dirtyTracking.spec.ts +91 -0
  237. package/src/formEngine/dirtyTracking.ts +64 -0
  238. package/src/formEngine/doctypeMeta.ts +117 -0
  239. package/src/formEngine/fetchFrom.spec.ts +92 -0
  240. package/src/formEngine/fetchFrom.ts +111 -0
  241. package/src/formEngine/fieldPermissions.spec.ts +61 -0
  242. package/src/formEngine/fieldPermissions.ts +68 -0
  243. package/src/formEngine/formActions.ts +285 -0
  244. package/src/formEngine/formLayout.spec.ts +373 -0
  245. package/src/formEngine/formLayout.ts +392 -0
  246. package/src/formEngine/index.ts +33 -0
  247. package/src/formEngine/seam.spec.ts +148 -0
  248. package/src/formEngine/useFormEngine.ts +496 -0
  249. package/src/frappeRemainder.designLaw.spec.ts +9 -0
  250. package/src/frappeStoryTarget.tsx +25 -9
  251. package/src/frappeViews.designLaw.spec.ts +1 -0
  252. package/src/index.spec.ts +7 -3
  253. package/src/index.ts +51 -1
  254. package/src/schemaForm/SchemaForm.spec.tsx +4 -1
  255. package/types/ChartCard.d.ts +13 -0
  256. package/types/ChartCard.d.ts.map +1 -0
  257. package/types/FileBrowser/AttachLibrary.d.ts.map +1 -1
  258. package/types/FrappeDashboard.d.ts +2 -10
  259. package/types/FrappeDashboard.d.ts.map +1 -1
  260. package/types/FrappeImageView.d.ts.map +1 -1
  261. package/types/dependsOn.d.ts +2 -2
  262. package/types/fields/CodeEditor/index.d.ts.map +1 -1
  263. package/types/fields/LinkTree.d.ts.map +1 -1
  264. package/types/formEngine/createPrefill.d.ts +19 -0
  265. package/types/formEngine/createPrefill.d.ts.map +1 -0
  266. package/types/formEngine/dirtyTracking.d.ts +36 -0
  267. package/types/formEngine/dirtyTracking.d.ts.map +1 -0
  268. package/types/formEngine/doctypeMeta.d.ts +117 -0
  269. package/types/formEngine/doctypeMeta.d.ts.map +1 -0
  270. package/types/formEngine/fetchFrom.d.ts +43 -0
  271. package/types/formEngine/fetchFrom.d.ts.map +1 -0
  272. package/types/formEngine/fieldPermissions.d.ts +38 -0
  273. package/types/formEngine/fieldPermissions.d.ts.map +1 -0
  274. package/types/formEngine/formActions.d.ts +95 -0
  275. package/types/formEngine/formActions.d.ts.map +1 -0
  276. package/types/formEngine/formLayout.d.ts +91 -0
  277. package/types/formEngine/formLayout.d.ts.map +1 -0
  278. package/types/formEngine/index.d.ts +27 -0
  279. package/types/formEngine/index.d.ts.map +1 -0
  280. package/types/formEngine/useFormEngine.d.ts +61 -0
  281. package/types/formEngine/useFormEngine.d.ts.map +1 -0
  282. package/types/frappeStoryTarget.d.ts +2 -2
  283. package/types/frappeStoryTarget.d.ts.map +1 -1
  284. package/types/index.d.ts +4 -1
  285. package/types/index.d.ts.map +1 -1
@@ -0,0 +1,229 @@
1
+ /**
2
+ * ReadOnly field contract specs. This is the display half of every fieldtype:
3
+ * SchemaForm swaps a live control for ReadOnly whenever a DocField is
4
+ * read-only, so what it prints IS the value the user sees. The contract is the
5
+ * formatting matrix (one per ReadOnlyType), the empty fallback, and the two
6
+ * types that render more than text — Color (swatch) and Link (pressable).
7
+ *
8
+ * Formatted output is asserted against the shared registers
9
+ * (formatNumericText / formatCurrency / formatPercent / formatDateText /
10
+ * formatTimeOfDay) rather than against hardcoded strings: the contract is
11
+ * "ReadOnly speaks the house register", not "ReadOnly speaks en-GB".
12
+ */
13
+ import { formatDateText, formatNumericText } from "@multiplatform.one/forms";
14
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
15
+ import { formatCurrency, formatPercent, formatTimeOfDay } from "@multiplatform.one/theme";
16
+ import { cleanup, fireEvent, screen } from "@testing-library/react";
17
+ import type React from "react";
18
+ import { afterEach, describe, expect, it, vi } from "vitest";
19
+ import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
20
+ import { ReadOnly } from ".";
21
+
22
+ afterEach(cleanup);
23
+
24
+ describe("ReadOnly formatting matrix", () => {
25
+ it("Data prints the value verbatim", () => {
26
+ renderWithProviders(<ReadOnly label="Title" type="Data" value="Sales Invoice" />);
27
+ expect(screen.getByText("Sales Invoice")).toBeTruthy();
28
+ });
29
+
30
+ it("Check prints Yes / No, never true / false", () => {
31
+ const yes = renderWithProviders(<ReadOnly label="Paid" type="Check" value={true} />);
32
+ expect(yes.container.textContent).toContain("Yes");
33
+ expect(yes.container.textContent).not.toContain("true");
34
+ cleanup();
35
+ const no = renderWithProviders(<ReadOnly label="Paid" type="Check" value={false} />);
36
+ expect(no.container.textContent).toContain("No");
37
+ });
38
+
39
+ it("Int rounds and drops the decimals", () => {
40
+ renderWithProviders(<ReadOnly label="Qty" type="Int" value={3.7} />);
41
+ expect(screen.getByText(formatNumericText(4, { precision: 0 }))).toBeTruthy();
42
+ });
43
+
44
+ it("Float honours precision", () => {
45
+ renderWithProviders(<ReadOnly label="Rate" type="Float" value={1.23456} precision={3} />);
46
+ expect(screen.getByText(formatNumericText(1.23456, { precision: 3 }))).toBeTruthy();
47
+ });
48
+
49
+ it("Currency goes through the money register with the given symbol", () => {
50
+ renderWithProviders(
51
+ <ReadOnly label="Total" type="Currency" value={1234.5} currencySymbol="$" precision={2} />,
52
+ );
53
+ expect(screen.getByText(formatCurrency(1234.5, { precision: 2, symbol: "$" }))).toBeTruthy();
54
+ });
55
+
56
+ it("Percent goes through the percent register", () => {
57
+ renderWithProviders(<ReadOnly label="Done" type="Percent" value={42.5} precision={1} />);
58
+ expect(screen.getByText(formatPercent(42.5, { precision: 1 }))).toBeTruthy();
59
+ });
60
+
61
+ it("Date goes through the date register", () => {
62
+ renderWithProviders(<ReadOnly label="Posting" type="Date" value="2026-03-04" />);
63
+ expect(screen.getByText(formatDateText("2026-03-04", { dateFormat: undefined }))).toBeTruthy();
64
+ });
65
+
66
+ it("Datetime carries the time half", () => {
67
+ const wire = "2026-03-04 14:30:00";
68
+ renderWithProviders(<ReadOnly label="Modified" type="Datetime" value={wire} />);
69
+ expect(
70
+ screen.getByText(formatDateText(wire, { withTime: true, dateFormat: undefined })),
71
+ ).toBeTruthy();
72
+ });
73
+
74
+ it("Time speaks the time-of-day register, not the raw wire string (LC-12)", () => {
75
+ renderWithProviders(<ReadOnly label="Start" type="Time" value="14:30:00" />);
76
+ expect(screen.getByText(formatTimeOfDay("14:30:00"))).toBeTruthy();
77
+ expect(screen.queryByText("14:30:00")).toBeNull();
78
+ });
79
+
80
+ it("Color uppercases the hex", () => {
81
+ renderWithProviders(<ReadOnly label="Brand" type="Color" value="#ab12ef" />);
82
+ expect(screen.getByText("#AB12EF")).toBeTruthy();
83
+ });
84
+ });
85
+
86
+ describe("ReadOnly empty handling", () => {
87
+ it.each([
88
+ ["undefined", undefined],
89
+ ["null", null],
90
+ ["empty string", ""],
91
+ ])("prints the default dash for %s", (_label, value) => {
92
+ const result = renderWithProviders(
93
+ <ReadOnly label="Ref" type="Data" value={value as string | null | undefined} />,
94
+ );
95
+ expect(result.container.textContent).toContain("-");
96
+ });
97
+
98
+ it("honours a custom emptyText", () => {
99
+ const result = renderWithProviders(
100
+ <ReadOnly label="Ref" type="Data" value={null} emptyText="Not set" />,
101
+ );
102
+ expect(result.container.textContent).toContain("Not set");
103
+ });
104
+
105
+ it("an empty Date is empty, not an epoch", () => {
106
+ const result = renderWithProviders(<ReadOnly label="Posting" type="Date" value="" />);
107
+ expect(result.container.textContent).toContain("-");
108
+ expect(result.container.textContent).not.toContain("1970");
109
+ });
110
+
111
+ it("a zero number is a value, not an empty", () => {
112
+ renderWithProviders(<ReadOnly label="Qty" type="Int" value={0} />);
113
+ expect(screen.getByText(formatNumericText(0, { precision: 0 }))).toBeTruthy();
114
+ });
115
+ });
116
+
117
+ describe("ReadOnly Color swatch", () => {
118
+ it("renders a swatch beside the text for a 6-digit hex", () => {
119
+ renderWithProviders(<ReadOnly label="Brand" type="Color" value="#FF0000" />);
120
+ const text = screen.getByText("#FF0000");
121
+ expect(text.parentElement?.childElementCount).toBe(2);
122
+ });
123
+
124
+ it("renders a swatch for a 3-digit hex too", () => {
125
+ renderWithProviders(<ReadOnly label="Brand" type="Color" value="#f00" />);
126
+ const text = screen.getByText("#F00");
127
+ expect(text.parentElement?.childElementCount).toBe(2);
128
+ });
129
+
130
+ it("refuses a swatch for a value that is not a hex colour", () => {
131
+ renderWithProviders(<ReadOnly label="Brand" type="Color" value="red" />);
132
+ const text = screen.getByText("RED");
133
+ expect(text.parentElement?.childElementCount).toBe(1);
134
+ });
135
+ });
136
+
137
+ describe("ReadOnly Link", () => {
138
+ it("calls onLinkPress when the value is pressed", () => {
139
+ const onLinkPress = vi.fn();
140
+ renderWithProviders(
141
+ <ReadOnly label="Customer" type="Link" value="CUST-0001" onLinkPress={onLinkPress} />,
142
+ );
143
+ fireEvent.click(screen.getByText("CUST-0001"));
144
+ expect(onLinkPress).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ it("does not make an empty Link pressable", () => {
148
+ const onLinkPress = vi.fn();
149
+ const result = renderWithProviders(
150
+ <ReadOnly label="Customer" type="Link" value="" onLinkPress={onLinkPress} />,
151
+ );
152
+ fireEvent.click(result.getByText("-"));
153
+ expect(onLinkPress).not.toHaveBeenCalled();
154
+ });
155
+
156
+ it("a Link with neither href nor handler is plain text", () => {
157
+ const result = renderWithProviders(<ReadOnly label="Customer" type="Link" value="CUST-1" />);
158
+ expect(result.container.textContent).toContain("CUST-1");
159
+ });
160
+ });
161
+
162
+ describe("ReadOnly chrome", () => {
163
+ it("shows the label and helperText", () => {
164
+ const result = renderWithProviders(
165
+ <ReadOnly label="Status" type="Data" value="Draft" helperText="set by the workflow" />,
166
+ );
167
+ expect(result.container.textContent).toContain("Status");
168
+ expect(result.container.textContent).toContain("set by the workflow");
169
+ });
170
+
171
+ it("skeleton branch hides the value", () => {
172
+ const result = renderWithProviders(
173
+ <ReadOnly label="Status" type="Data" value="Draft" skeleton />,
174
+ );
175
+ expect(result.container.textContent).not.toContain("Draft");
176
+ });
177
+ });
178
+
179
+ describe("registry resolution (Read Only fieldtype)", () => {
180
+ function propsFor(overrides: Partial<FieldRenderProps> = {}): FieldRenderProps {
181
+ return { value: "", onChange: () => {}, ...overrides };
182
+ }
183
+
184
+ it("resolves 'Read Only' to ReadOnly", () => {
185
+ const Component = getFieldComponent("Read Only")!;
186
+ expect(Component).toBeTruthy();
187
+ const element = (Component as Function)(
188
+ propsFor({ value: "Draft", label: "Status" }),
189
+ ) as React.ReactElement<Record<string, any>>;
190
+ expect(element.type).toBe(ReadOnly);
191
+ expect(element.props.value).toBe("Draft");
192
+ });
193
+
194
+ it("forwards the whole formatting contract, not just the value", () => {
195
+ const Component = getFieldComponent("Read Only")!;
196
+ const onLinkPress = () => {};
197
+ const element = (Component as Function)(
198
+ propsFor({
199
+ value: 12.5,
200
+ type: "Currency",
201
+ precision: 3,
202
+ currencySymbol: "€",
203
+ emptyText: "n/a",
204
+ dateFormat: "dd-MM-yyyy",
205
+ datetimeFormat: "dd-MM-yyyy HH:mm",
206
+ timeFormat: "HH:mm",
207
+ linkHref: "/app/x",
208
+ onLinkPress,
209
+ } as Partial<FieldRenderProps>),
210
+ ) as React.ReactElement<Record<string, any>>;
211
+ expect(element.props.type).toBe("Currency");
212
+ expect(element.props.precision).toBe(3);
213
+ expect(element.props.currencySymbol).toBe("€");
214
+ expect(element.props.emptyText).toBe("n/a");
215
+ expect(element.props.dateFormat).toBe("dd-MM-yyyy");
216
+ expect(element.props.datetimeFormat).toBe("dd-MM-yyyy HH:mm");
217
+ expect(element.props.timeFormat).toBe("HH:mm");
218
+ expect(element.props.linkHref).toBe("/app/x");
219
+ expect(element.props.onLinkPress).toBe(onLinkPress);
220
+ });
221
+
222
+ it("does not coerce a null value to an empty string — ReadOnly owns the empty text", () => {
223
+ const Component = getFieldComponent("Read Only")!;
224
+ const element = (Component as Function)(
225
+ propsFor({ value: null as unknown as string }),
226
+ ) as React.ReactElement<Record<string, any>>;
227
+ expect(element.props.value).toBeNull();
228
+ });
229
+ });
@@ -0,0 +1,175 @@
1
+ /**
2
+ * SmallText contract specs. Frappe's `Small Text` is the short free-text
3
+ * fieldtype, and the adapter's promise is that it is the SAME control as
4
+ * LongText with a smaller row budget (2..4 instead of 6..16) — not a second
5
+ * textarea implementation, and not an Input with a taller box.
6
+ */
7
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
8
+ import { cleanup, fireEvent } from "@testing-library/react";
9
+ import type React from "react";
10
+ import { afterEach, describe, expect, it, vi } from "vitest";
11
+ import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
12
+ import { LongText } from "../LongText";
13
+ import { SmallText } from ".";
14
+
15
+ afterEach(cleanup);
16
+
17
+ describe("SmallText composes the forms TextArea", () => {
18
+ it("renders a textarea, not a single-line input", () => {
19
+ const result = renderWithProviders(<SmallText label="Note" value="" onChange={() => {}} />);
20
+ expect(result.getTextArea()).toBeTruthy();
21
+ expect(result.container.querySelector('input[type="text"]')).toBeNull();
22
+ });
23
+
24
+ it("shows the controlled value", () => {
25
+ const result = renderWithProviders(
26
+ <SmallText label="Note" value="short note" onChange={() => {}} />,
27
+ );
28
+ expect((result.getTextArea() as HTMLTextAreaElement).value).toBe("short note");
29
+ });
30
+
31
+ it("emits the text through canonical onChange", () => {
32
+ const onChange = vi.fn();
33
+ const result = renderWithProviders(<SmallText label="Note" value="" onChange={onChange} />);
34
+ fireEvent.change(result.getTextArea() as HTMLTextAreaElement, { target: { value: "typed" } });
35
+ expect(onChange).toHaveBeenCalledWith("typed");
36
+ });
37
+
38
+ it("readOnly blocks emission", () => {
39
+ const onChange = vi.fn();
40
+ const result = renderWithProviders(
41
+ <SmallText label="Note" value="original" readOnly onChange={onChange} />,
42
+ );
43
+ const textarea = result.getTextArea() as HTMLTextAreaElement;
44
+ expect(textarea.hasAttribute("readonly")).toBe(true);
45
+ fireEvent.change(textarea, { target: { value: "sneaky" } });
46
+ expect(onChange).not.toHaveBeenCalled();
47
+ });
48
+
49
+ it("disabled blocks emission", () => {
50
+ const onChange = vi.fn();
51
+ const result = renderWithProviders(
52
+ <SmallText label="Note" value="original" disabled onChange={onChange} />,
53
+ );
54
+ fireEvent.change(result.getTextArea() as HTMLTextAreaElement, { target: { value: "sneaky" } });
55
+ expect(onChange).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it("forwards maxLength — Frappe's df.length caps a Small Text", () => {
59
+ const result = renderWithProviders(
60
+ <SmallText label="Note" value="" maxLength={80} onChange={() => {}} />,
61
+ );
62
+ expect((result.getTextArea() as HTMLTextAreaElement).getAttribute("maxlength")).toBe("80");
63
+ });
64
+
65
+ it("forwards placeholder and textAreaProps", () => {
66
+ const result = renderWithProviders(
67
+ <SmallText
68
+ label="Note"
69
+ value=""
70
+ placeholder="One or two lines"
71
+ textAreaProps={{ tabIndex: -1 }}
72
+ onChange={() => {}}
73
+ />,
74
+ );
75
+ const textarea = result.getTextArea() as HTMLTextAreaElement;
76
+ expect(textarea.getAttribute("placeholder")).toBe("One or two lines");
77
+ expect(textarea.getAttribute("tabindex")).toBe("-1");
78
+ });
79
+ });
80
+
81
+ /**
82
+ * The row budget does not reach the DOM as the textarea's `rows` attribute:
83
+ * TextArea spreads `numberOfLines: minRows` onto the Tamagui TextArea and web
84
+ * drops it, so `rows` measured 3 for every adapter at every setting. The
85
+ * budget DOES drive the rendered minimum height, `getFieldHeight(size,
86
+ * minRows)`, so it is asserted through that. Reported on MPO-123.
87
+ */
88
+ const minHeightPxOf = (container: HTMLElement) => {
89
+ const cls = container.querySelector("textarea")?.getAttribute("class") ?? "";
90
+ const match = /(?:^|\s)_mih-(\d+)px(?:\s|$)/.exec(cls);
91
+ return match ? Number(match[1]) : undefined;
92
+ };
93
+
94
+ describe("SmallText row budget", () => {
95
+ it("opens at a two-row box", () => {
96
+ const small = renderWithProviders(<SmallText label="Note" value="" onChange={() => {}} />);
97
+ const smallPx = minHeightPxOf(small.container);
98
+ cleanup();
99
+ // LongText's default budget is six rows to SmallText's two, on the same
100
+ // per-row height — so the ratio is the row count, with no hard-coded px.
101
+ const long = renderWithProviders(<LongText label="Note" value="" onChange={() => {}} />);
102
+ const longPx = minHeightPxOf(long.container);
103
+ expect(smallPx).toBeGreaterThan(0);
104
+ expect((longPx as number) / (smallPx as number)).toBe(3);
105
+ });
106
+
107
+ it("is shorter than LongText, which is the only thing that distinguishes them", () => {
108
+ const small = renderWithProviders(<SmallText label="Note" value="" onChange={() => {}} />);
109
+ const smallPx = minHeightPxOf(small.container) as number;
110
+ cleanup();
111
+ const long = renderWithProviders(<LongText label="Note" value="" onChange={() => {}} />);
112
+ const longPx = minHeightPxOf(long.container) as number;
113
+ expect(smallPx).toBeLessThan(longPx);
114
+ });
115
+
116
+ it("ignores minHeight — a Small Text does not grow into a Long Text", () => {
117
+ const plain = renderWithProviders(<SmallText label="Note" value="" onChange={() => {}} />);
118
+ const plainPx = minHeightPxOf(plain.container);
119
+ cleanup();
120
+ const result = renderWithProviders(
121
+ <SmallText
122
+ label="Note"
123
+ value=""
124
+ onChange={() => {}}
125
+ {...({ minHeight: 480 } as Record<string, unknown>)}
126
+ />,
127
+ );
128
+ expect(minHeightPxOf(result.container)).toBe(plainPx);
129
+ });
130
+ });
131
+
132
+ describe("registry resolution (Small Text fieldtype)", () => {
133
+ function propsFor(overrides: Partial<FieldRenderProps> = {}): FieldRenderProps {
134
+ return { value: "", onChange: () => {}, ...overrides };
135
+ }
136
+
137
+ it("resolves 'Small Text' to SmallText, and not to the Long Text adapter", () => {
138
+ const Component = getFieldComponent("Small Text")!;
139
+ expect(Component).toBeTruthy();
140
+ expect(Component).not.toBe(getFieldComponent("Long Text"));
141
+ const element = (Component as Function)(
142
+ propsFor({ value: "note", label: "Remark" }),
143
+ ) as React.ReactElement<Record<string, any>>;
144
+ expect(element.type).toBe(SmallText);
145
+ expect(element.props.value).toBe("note");
146
+ expect(element.props.label).toBe("Remark");
147
+ });
148
+
149
+ it("substitutes an empty string for a null value", () => {
150
+ const Component = getFieldComponent("Small Text")!;
151
+ const element = (Component as Function)(
152
+ propsFor({ value: null as unknown as string }),
153
+ ) as React.ReactElement<Record<string, any>>;
154
+ expect(element.props.value).toBe("");
155
+ });
156
+
157
+ it("a disabled field also blocks the DOM textarea", () => {
158
+ const Component = getFieldComponent("Small Text")!;
159
+ const element = (Component as Function)(propsFor({ disabled: true })) as React.ReactElement<
160
+ Record<string, any>
161
+ >;
162
+ expect(element.props.textAreaProps).toEqual({ readOnly: true, tabIndex: -1 });
163
+ });
164
+
165
+ it("passes helperText / readOnly / skeleton / compact through", () => {
166
+ const Component = getFieldComponent("Small Text")!;
167
+ const element = (Component as Function)(
168
+ propsFor({ helperText: "keep it short", readOnly: true, skeleton: true, compact: true }),
169
+ ) as React.ReactElement<Record<string, any>>;
170
+ expect(element.props.helperText).toBe("keep it short");
171
+ expect(element.props.readOnly).toBe(true);
172
+ expect(element.props.skeleton).toBe(true);
173
+ expect(element.props.compact).toBe(true);
174
+ });
175
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * EditorPreviewPanes contract specs. Both source-editing fields
3
+ * (MarkdownEditorField, CodeEditorField) get their edit/preview arrangement
4
+ * from here, so the promises worth pinning are the ones a field author relies
5
+ * on without re-reading the file:
6
+ *
7
+ * - below the house md breakpoint it is Write/Preview TABS, and the tab
8
+ * labels are the caller's words, not hardcoded ones;
9
+ * - `defaultPreview` opens on the preview, which is what a read-only field
10
+ * wants — lead with the rendering, not with the source;
11
+ * - the tabs stay interactive on a disabled or read-only field, because
12
+ * those states gate EDITING and never VIEWING. A preview you cannot reach
13
+ * is the regression this component exists to prevent.
14
+ *
15
+ * The ≥ md two-pane branch pivots on a real layout measurement
16
+ * (`onLayout` → ResizeObserver), which happy-dom's stub never delivers, so it
17
+ * is left to the rendered proof rather than faked here.
18
+ */
19
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
20
+ import { cleanup, fireEvent, screen } from "@testing-library/react";
21
+ import { Text } from "tamagui";
22
+ import { afterEach, describe, expect, it } from "vitest";
23
+ import { EditorPreviewPanes, PreviewSurface } from "./EditorPreviewPanes";
24
+
25
+ afterEach(cleanup);
26
+
27
+ const panes = (extra: Partial<React.ComponentProps<typeof EditorPreviewPanes>> = {}) => (
28
+ <EditorPreviewPanes
29
+ editor={<Text>the source</Text>}
30
+ preview={<Text>the rendering</Text>}
31
+ editTabLabel="Write"
32
+ previewTabLabel="Preview"
33
+ {...extra}
34
+ />
35
+ );
36
+
37
+ describe("EditorPreviewPanes narrow arrangement", () => {
38
+ it("collapses into a two-tab strip", () => {
39
+ renderWithProviders(panes());
40
+ expect(screen.getByRole("tablist")).toBeTruthy();
41
+ expect(screen.getAllByRole("tab")).toHaveLength(2);
42
+ });
43
+
44
+ it("uses the caller's tab labels", () => {
45
+ renderWithProviders(panes({ editTabLabel: "Source", previewTabLabel: "Rendered" }));
46
+ expect(screen.getByText("Source")).toBeTruthy();
47
+ expect(screen.getByText("Rendered")).toBeTruthy();
48
+ });
49
+
50
+ it("opens on the editor and mounts only that pane", () => {
51
+ renderWithProviders(panes());
52
+ expect(screen.getByText("the source")).toBeTruthy();
53
+ expect(screen.queryByText("the rendering")).toBeNull();
54
+ });
55
+
56
+ it("defaultPreview opens on the preview instead", () => {
57
+ renderWithProviders(panes({ defaultPreview: true }));
58
+ expect(screen.getByText("the rendering")).toBeTruthy();
59
+ expect(screen.queryByText("the source")).toBeNull();
60
+ });
61
+
62
+ it("switches to the preview on a tab press", () => {
63
+ renderWithProviders(panes());
64
+ fireEvent.click(screen.getByText("Preview"));
65
+ expect(screen.getByText("the rendering")).toBeTruthy();
66
+ expect(screen.queryByText("the source")).toBeNull();
67
+ });
68
+
69
+ it("switches back to the editor", () => {
70
+ renderWithProviders(panes({ defaultPreview: true }));
71
+ fireEvent.click(screen.getByText("Write"));
72
+ expect(screen.getByText("the source")).toBeTruthy();
73
+ expect(screen.queryByText("the rendering")).toBeNull();
74
+ });
75
+
76
+ it("marks the open tab selected", () => {
77
+ renderWithProviders(panes());
78
+ const tabs = screen.getAllByRole("tab");
79
+ expect(tabs[0].getAttribute("aria-selected")).toBe("true");
80
+ expect(tabs[1].getAttribute("aria-selected")).toBe("false");
81
+ fireEvent.click(screen.getByText("Preview"));
82
+ expect(screen.getAllByRole("tab")[1].getAttribute("aria-selected")).toBe("true");
83
+ });
84
+
85
+ it("keeps the preview reachable even when the editor pane is a read-only surface", () => {
86
+ // The gate belongs to the editor, never to the tab strip: a read-only
87
+ // field still has to be able to look at its own rendering.
88
+ renderWithProviders(panes({ editor: <Text>read-only source</Text>, defaultPreview: true }));
89
+ fireEvent.click(screen.getByText("Write"));
90
+ expect(screen.getByText("read-only source")).toBeTruthy();
91
+ fireEvent.click(screen.getByText("Preview"));
92
+ expect(screen.getByText("the rendering")).toBeTruthy();
93
+ });
94
+ });
95
+
96
+ describe("PreviewSurface", () => {
97
+ it("renders its children inside a scrollable surface", () => {
98
+ const result = renderWithProviders(
99
+ <PreviewSurface>
100
+ <Text>rendered output</Text>
101
+ </PreviewSurface>,
102
+ );
103
+ expect(result.container.textContent).toContain("rendered output");
104
+ });
105
+
106
+ it("adds a second, horizontal scroller for code panes", () => {
107
+ const plain = renderWithProviders(
108
+ <PreviewSurface>
109
+ <Text>wrapped prose</Text>
110
+ </PreviewSurface>,
111
+ );
112
+ const plainDepth = plain.container.querySelectorAll("*").length;
113
+ cleanup();
114
+ const code = renderWithProviders(
115
+ <PreviewSurface horizontalScroll>
116
+ <Text>long unwrapped line</Text>
117
+ </PreviewSurface>,
118
+ );
119
+ expect(code.container.querySelectorAll("*").length).toBeGreaterThan(plainDepth);
120
+ expect(code.container.textContent).toContain("long unwrapped line");
121
+ });
122
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * One-shot create-mode prefill hand-off.
3
+ *
4
+ * Two producers, one mechanism:
5
+ * - Duplicate and Amend navigate to `newDocPath(doctype)` and stash the
6
+ * copied values here (in-memory only — a refresh honestly falls back to
7
+ * a blank form; no URL-size limits for large docs).
8
+ * - NewDocRouteScreen stashes parsed `?d=` URL defaults (kanban column "+",
9
+ * calendar day click, connections "+") before rendering the form — those
10
+ * survive refresh because the URL re-stashes them.
11
+ *
12
+ * The create-mode form (useFormEngine seeding) consumes and clears the
13
+ * stash on mount, merging it over meta defaults. Last stash per doctype
14
+ * wins, so a `?d=` navigation cannot double-apply with a stale
15
+ * Duplicate/Amend hand-off.
16
+ */
17
+
18
+ const prefills = new Map<string, Record<string, unknown>>();
19
+
20
+ export function setCreatePrefill(doctype: string, values: Record<string, unknown>): void {
21
+ prefills.set(doctype, values);
22
+ }
23
+
24
+ export function takeCreatePrefill(doctype: string): Record<string, unknown> | undefined {
25
+ const values = prefills.get(doctype);
26
+ if (values) prefills.delete(doctype);
27
+ return values;
28
+ }
@@ -0,0 +1,91 @@
1
+ // R-1 false "Not Saved": engine-level dirty-tracking contract.
2
+ // An untouched existing doc must read pristine, a real edit must read dirty,
3
+ // create mode must read dirty from applied defaults (desk parity).
4
+ import { describe, expect, it } from "vitest";
5
+ import { computeDirtyFields, isSameValue } from "./dirtyTracking";
6
+
7
+ describe("isSameValue (normalization-insensitive equality)", () => {
8
+ it("treats null / undefined / empty string / empty array as one empty state", () => {
9
+ expect(isSameValue(null, "")).toBe(true);
10
+ expect(isSameValue(undefined, "")).toBe(true);
11
+ expect(isSameValue(null, undefined)).toBe(true);
12
+ expect(isSameValue([], null)).toBe(true);
13
+ expect(isSameValue(" ", "")).toBe(true);
14
+ });
15
+
16
+ it("folds boolean and wire 0/1 representations", () => {
17
+ expect(isSameValue(false, 0)).toBe(true);
18
+ expect(isSameValue(true, 1)).toBe(true);
19
+ expect(isSameValue(0, false)).toBe(true);
20
+ expect(isSameValue(true, 0)).toBe(false);
21
+ expect(isSameValue(false, 1)).toBe(false);
22
+ });
23
+
24
+ it("does not conflate empty with falsy non-empty values", () => {
25
+ expect(isSameValue(0, "")).toBe(false);
26
+ expect(isSameValue(false, "")).toBe(false);
27
+ expect(isSameValue(0, null)).toBe(false);
28
+ });
29
+
30
+ it("stays strict for real values — no deep equality", () => {
31
+ expect(isSameValue("a", "a")).toBe(true);
32
+ expect(isSameValue("a", "b")).toBe(false);
33
+ // Distinct array/object identities read as different even when
34
+ // structurally equal (deliberate: no deep-equality time bombs).
35
+ expect(isSameValue([{ a: 1 }], [{ a: 1 }])).toBe(false);
36
+ // Representation changes of real content are edits (the field layer is
37
+ // responsible for not emitting them during initialization).
38
+ expect(isSameValue("plain", "<p>plain</p>")).toBe(false);
39
+ });
40
+ });
41
+
42
+ describe("computeDirtyFields", () => {
43
+ const baseline = {
44
+ doctype: "ToDo",
45
+ name: "abc",
46
+ description: "hello",
47
+ status: "Open",
48
+ color: null,
49
+ priority: "Medium",
50
+ date: "2026-08-12",
51
+ checked: 0,
52
+ };
53
+
54
+ it("existing doc mount: local === baseline snapshot reads pristine", () => {
55
+ expect(computeDirtyFields({ ...baseline }, baseline)).toEqual([]);
56
+ });
57
+
58
+ it("field re-representation during init does not dirty (null→'' and 0→false)", () => {
59
+ const local = { ...baseline, color: "", checked: false };
60
+ expect(computeDirtyFields(local, baseline)).toEqual([]);
61
+ });
62
+
63
+ it("a real edit reads dirty", () => {
64
+ const local = { ...baseline, description: "hello edited" };
65
+ expect(computeDirtyFields(local, baseline)).toEqual(["description"]);
66
+ });
67
+
68
+ it("revert to the exact baseline value reads clean again", () => {
69
+ // Documented behavior: value equality, not edit history — typing and
70
+ // deleting back to the original string is pristine. An equivalent-but-
71
+ // differently-represented value (e.g. tiptap-normalized HTML) would
72
+ // still read dirty, which matches desk.
73
+ const local = { ...baseline, description: "hello" };
74
+ expect(computeDirtyFields(local, baseline)).toEqual([]);
75
+ });
76
+
77
+ it("bookkeeping keys never dirty", () => {
78
+ const local = { ...baseline, name: "renamed", doctype: "Other" };
79
+ expect(computeDirtyFields(local, baseline)).toEqual([]);
80
+ });
81
+
82
+ it("new doc (no baseline) with applied create-defaults reads dirty — desk parity", () => {
83
+ const local = { doctype: "ToDo", status: "Open", priority: "Medium", description: "" };
84
+ expect(computeDirtyFields(local, undefined)).toEqual(["status", "priority"]);
85
+ });
86
+
87
+ it("new doc with only empty values reads pristine", () => {
88
+ const local = { doctype: "ToDo", description: "", color: null };
89
+ expect(computeDirtyFields(local, undefined)).toEqual([]);
90
+ });
91
+ });