@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,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
+ });
@@ -0,0 +1,138 @@
1
+ import { action } from "@multiplatform.one/storybook";
2
+ import { DataTable, type DataTableColumn } from "@multiplatform.one/table";
3
+ import { useState } from "react";
4
+ import { YStack } from "tamagui";
5
+ import { fieldtypeToTableField, getFieldComponent, initDefaultRegistry } from "../../fieldRegistry";
6
+ import { FrappeStoryProvider } from "../../frappeStoryTarget";
7
+ import { Icon, type IconProps } from ".";
8
+
9
+ export default {
10
+ title: "FrappeUI/Icon",
11
+ component: Icon,
12
+ parameters: {
13
+ status: { type: "stable" },
14
+ docs: {
15
+ description: {
16
+ component:
17
+ "Icon picker for the Frappe Icon field type. Options come from the DG-IDN-06 drawn set and open in a FloatingPanel; df.options narrows the catalog to a subset.",
18
+ },
19
+ },
20
+ },
21
+ argTypes: {
22
+ label: { control: "text", description: "Field label" },
23
+ value: { control: "text", description: "Selected drawn-icon name" },
24
+ placeholder: { control: "text", description: "Placeholder text" },
25
+ disabled: { control: "boolean", description: "Disable the field" },
26
+ readOnly: { control: "boolean", description: "Render read-only" },
27
+ required: { control: "boolean", description: "Mark as required" },
28
+ compact: { control: "boolean", description: "Compact (table cell) presentation" },
29
+ options: { control: "text", description: "df.options — newline list narrowing the catalog" },
30
+ error: { control: "text", description: "Error message to display" },
31
+ },
32
+ };
33
+
34
+ function ControlledIcon(props: Omit<IconProps, "onChange">) {
35
+ const [value, setValue] = useState(props.value || "");
36
+ return (
37
+ <Icon
38
+ {...props}
39
+ value={value}
40
+ onChange={(v) => {
41
+ setValue(v);
42
+ action("onChange")(v);
43
+ }}
44
+ />
45
+ );
46
+ }
47
+
48
+ export const Default = (args: IconProps) => <ControlledIcon {...args} />;
49
+ Default.args = {
50
+ label: "Workspace icon",
51
+ placeholder: "Choose an icon",
52
+ } satisfies Partial<IconProps>;
53
+
54
+ Default.storyName = "Main";
55
+
56
+ export const AllStates = () => (
57
+ <YStack gap="$4" width={400}>
58
+ <ControlledIcon label="Default" placeholder="Choose an icon" />
59
+ <ControlledIcon label="With Value" value="mail" />
60
+ <ControlledIcon label="Required" required />
61
+ <ControlledIcon label="With Error" error="Pick an icon for this workspace" />
62
+ <ControlledIcon label="Disabled" value="upload" disabled />
63
+ <ControlledIcon label="Read Only" value="disk" readOnly />
64
+ <ControlledIcon label="Narrowed by options" value="check" options={"check\nwarn\nx"} />
65
+ </YStack>
66
+ );
67
+ AllStates.parameters = {
68
+ docs: {
69
+ description: {
70
+ story: "All possible states of the Icon field component.",
71
+ },
72
+ },
73
+ };
74
+
75
+ // MPO-51 AC-1. A DocField of fieldtype Icon has to reach this picker through
76
+ // the registry, not just through a direct import. MPO-60 (035867446) clobbered
77
+ // registerField("Icon", …) once already; this story renders whatever the
78
+ // registry hands back, so a repeat shows up as the "Unsupported field type"
79
+ // string on the board rather than only in union_vs_registry.py.
80
+ export const ViaRegistry = () => {
81
+ initDefaultRegistry();
82
+ const RegisteredIcon = getFieldComponent("Icon");
83
+ const [value, setValue] = useState("mail");
84
+ if (!RegisteredIcon) {
85
+ throw new Error("fieldtype Icon is not registered — initDefaultRegistry() lost registerField");
86
+ }
87
+ return (
88
+ <YStack gap="$4" width={400}>
89
+ <RegisteredIcon
90
+ label="Workspace icon (through the registry)"
91
+ value={value}
92
+ onChange={(v: unknown) => {
93
+ setValue(String(v ?? ""));
94
+ action("onChange")(v);
95
+ }}
96
+ />
97
+ </YStack>
98
+ );
99
+ };
100
+ ViaRegistry.storyName = "Via registry";
101
+ ViaRegistry.parameters = {
102
+ docs: {
103
+ description: {
104
+ story:
105
+ "Resolves the component through getFieldComponent('Icon') the way a DocField-driven form does, so a lost registration is visible here and not only in the census script.",
106
+ },
107
+ },
108
+ };
109
+
110
+ interface IconRow {
111
+ id: string;
112
+ label: string;
113
+ value: string;
114
+ }
115
+
116
+ export const InTable = () => {
117
+ const [data, setData] = useState<IconRow[]>([
118
+ { id: "1", label: "Inbox", value: "mail" },
119
+ { id: "2", label: "Uploads", value: "upload" },
120
+ { id: "3", label: "Unset", value: "" },
121
+ ]);
122
+ const columns: DataTableColumn<IconRow>[] = [
123
+ { accessorKey: "label", header: "Row" },
124
+ {
125
+ accessorKey: "value",
126
+ header: "Icon",
127
+ ...fieldtypeToTableField("Icon"),
128
+ },
129
+ ];
130
+ return (
131
+ <FrappeStoryProvider>
132
+ <YStack width="100%" height={280}>
133
+ <DataTable data={data} columns={columns} enablePagination={false} onDataChange={setData} />
134
+ </YStack>
135
+ </FrappeStoryProvider>
136
+ );
137
+ };
138
+ InTable.storyName = "In table";
@@ -50,6 +50,8 @@ const MOCK_HOST = "https://frappelink.mock.test";
50
50
  function contactMeta() {
51
51
  return {
52
52
  name: "Contact",
53
+ // FixtureDoc requires `doctype`; a DocType meta doc's own doctype is DocType.
54
+ doctype: "DocType",
53
55
  module: "Contacts",
54
56
  title_field: "first_name",
55
57
  fields: [
@@ -40,9 +40,24 @@ export function LinkTree({
40
40
  const isLocked = disabled || readOnly;
41
41
 
42
42
  const finalBaseURL = baseURL ?? config?.baseURL ?? "";
43
- const connectionConfig: FrappeConnectionConfig | undefined = finalBaseURL
44
- ? { baseURL: finalBaseURL, socketPort: config?.socketPort, auth: auth ?? config?.auth }
45
- : undefined;
43
+ const finalAuth = auth ?? config?.auth;
44
+ // Carry `fixtures` through, the way FrappeTreeView does. Without it a
45
+ // LinkTree under a fixture-backed FrappeProvider takes the HTTP path
46
+ // instead: every story dials socket.io at the mock host and prints
47
+ // ERR_TUNNEL_CONNECTION_FAILED, which is a G-15 story-honesty breach and
48
+ // was the only console noise left on FrappeUI/LinkTree (MPO-197).
49
+ const connectionConfig: FrappeConnectionConfig | undefined = useMemo(
50
+ () =>
51
+ finalBaseURL
52
+ ? {
53
+ baseURL: finalBaseURL,
54
+ socketPort: config?.socketPort,
55
+ auth: finalAuth,
56
+ fixtures: config?.fixtures,
57
+ }
58
+ : undefined,
59
+ [finalBaseURL, config?.socketPort, finalAuth, config?.fixtures],
60
+ );
46
61
 
47
62
  const { meta } = useDocTypeMeta(connectionConfig, doctype);
48
63
  const titleField = meta?.title_field ?? "name";
@@ -0,0 +1,217 @@
1
+ /**
2
+ * LongText contract specs. Frappe's `Long Text` and `Text` fieldtypes both land
3
+ * here, and the adapter's whole job is: compose the forms TextArea (never fork
4
+ * Input chrome), emit canonical onChange, and translate the Frappe-shaped
5
+ * `minHeight` in pixels into the TextArea's row budget.
6
+ *
7
+ * The row budget is the one piece of real arithmetic in the file
8
+ * (`minRows = minHeight ? max(4, round(minHeight / 24)) : 6`, maxRows 16).
9
+ *
10
+ * It does NOT reach the DOM as the textarea's `rows` attribute. TextArea
11
+ * spreads `numberOfLines: minRows` onto the Tamagui TextArea, and on web that
12
+ * prop is dropped: `rows` measured 3 for every adapter at every minHeight
13
+ * (SmallText's 2 included). What the budget DOES drive is the rendered minimum
14
+ * height, `getFieldHeight(size, minRows)`, so the rows are asserted through
15
+ * that instead — one row of pixels is read off a known 2-row render rather
16
+ * than hard-coded, so a size-token change moves both sides together.
17
+ */
18
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
19
+ import { cleanup, fireEvent } from "@testing-library/react";
20
+ import type React from "react";
21
+ import { afterEach, describe, expect, it, vi } from "vitest";
22
+ import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
23
+ import { SmallText } from "../SmallText";
24
+ import { LongText } from ".";
25
+
26
+ afterEach(cleanup);
27
+
28
+ /** Rendered minimum height in px, off the Tamagui `_mih-<n>px` class. */
29
+ const minHeightPxOf = (container: HTMLElement) => {
30
+ const cls = container.querySelector("textarea")?.getAttribute("class") ?? "";
31
+ const match = /(?:^|\s)_mih-(\d+)px(?:\s|$)/.exec(cls);
32
+ return match ? Number(match[1]) : undefined;
33
+ };
34
+
35
+ /**
36
+ * One text row in px, derived from SmallText's fixed two-row budget rather
37
+ * than hard-coded, so this spec follows a size-token change instead of
38
+ * breaking on one.
39
+ */
40
+ function rowPx(): number {
41
+ const probe = renderWithProviders(<SmallText label="probe" value="" onChange={() => {}} />);
42
+ const px = minHeightPxOf(probe.container);
43
+ cleanup();
44
+ if (!px) throw new Error("could not measure a row: SmallText rendered no _mih- class");
45
+ return px / 2;
46
+ }
47
+
48
+ /** Rendered rows = rendered minimum height / one row. */
49
+ const rowsOf = (container: HTMLElement) => {
50
+ const px = minHeightPxOf(container);
51
+ return px === undefined ? undefined : String(px / rowPx());
52
+ };
53
+
54
+ describe("LongText composes the forms TextArea", () => {
55
+ it("renders a textarea, not a single-line input", () => {
56
+ const result = renderWithProviders(<LongText label="Notes" value="" onChange={() => {}} />);
57
+ expect(result.getTextArea()).toBeTruthy();
58
+ expect(result.container.querySelector('input[type="text"]')).toBeNull();
59
+ });
60
+
61
+ it("shows the controlled value", () => {
62
+ const result = renderWithProviders(
63
+ <LongText label="Notes" value="line one" onChange={() => {}} />,
64
+ );
65
+ expect((result.getTextArea() as HTMLTextAreaElement).value).toBe("line one");
66
+ });
67
+
68
+ it("emits the text through canonical onChange", () => {
69
+ const onChange = vi.fn();
70
+ const result = renderWithProviders(<LongText label="Notes" value="" onChange={onChange} />);
71
+ fireEvent.change(result.getTextArea() as HTMLTextAreaElement, {
72
+ target: { value: "typed" },
73
+ });
74
+ expect(onChange).toHaveBeenCalledWith("typed");
75
+ });
76
+
77
+ it("readOnly blocks emission", () => {
78
+ const onChange = vi.fn();
79
+ const result = renderWithProviders(
80
+ <LongText label="Notes" value="original" readOnly onChange={onChange} />,
81
+ );
82
+ const textarea = result.getTextArea() as HTMLTextAreaElement;
83
+ expect(textarea.hasAttribute("readonly")).toBe(true);
84
+ fireEvent.change(textarea, { target: { value: "sneaky" } });
85
+ expect(onChange).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it("disabled blocks emission", () => {
89
+ const onChange = vi.fn();
90
+ const result = renderWithProviders(
91
+ <LongText label="Notes" value="original" disabled onChange={onChange} />,
92
+ );
93
+ fireEvent.change(result.getTextArea() as HTMLTextAreaElement, {
94
+ target: { value: "sneaky" },
95
+ });
96
+ expect(onChange).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it("forwards maxLength to the control", () => {
100
+ const result = renderWithProviders(
101
+ <LongText label="Notes" value="" maxLength={140} onChange={() => {}} />,
102
+ );
103
+ expect((result.getTextArea() as HTMLTextAreaElement).getAttribute("maxlength")).toBe("140");
104
+ });
105
+
106
+ it("forwards placeholder and textAreaProps", () => {
107
+ const result = renderWithProviders(
108
+ <LongText
109
+ label="Notes"
110
+ value=""
111
+ placeholder="Say something"
112
+ textAreaProps={{ tabIndex: -1 }}
113
+ onChange={() => {}}
114
+ />,
115
+ );
116
+ const textarea = result.getTextArea() as HTMLTextAreaElement;
117
+ expect(textarea.getAttribute("placeholder")).toBe("Say something");
118
+ expect(textarea.getAttribute("tabindex")).toBe("-1");
119
+ });
120
+ });
121
+
122
+ describe("LongText row budget", () => {
123
+ it("defaults to six rows when no minHeight is given", () => {
124
+ const result = renderWithProviders(<LongText label="Notes" value="" onChange={() => {}} />);
125
+ expect(rowsOf(result.container)).toBe("6");
126
+ });
127
+
128
+ it("derives rows from a pixel minHeight at 24px a row", () => {
129
+ const result = renderWithProviders(
130
+ <LongText label="Notes" value="" minHeight={240} onChange={() => {}} />,
131
+ );
132
+ expect(rowsOf(result.container)).toBe("10");
133
+ });
134
+
135
+ it("rounds rather than truncates", () => {
136
+ const result = renderWithProviders(
137
+ <LongText label="Notes" value="" minHeight={250} onChange={() => {}} />,
138
+ );
139
+ expect(rowsOf(result.container)).toBe("10");
140
+ });
141
+
142
+ it("never goes below four rows, however small the minHeight", () => {
143
+ const result = renderWithProviders(
144
+ <LongText label="Notes" value="" minHeight={24} onChange={() => {}} />,
145
+ );
146
+ expect(rowsOf(result.container)).toBe("4");
147
+ });
148
+
149
+ it("treats a zero minHeight as unset, not as zero rows", () => {
150
+ const result = renderWithProviders(
151
+ <LongText label="Notes" value="" minHeight={0} onChange={() => {}} />,
152
+ );
153
+ expect(rowsOf(result.container)).toBe("6");
154
+ });
155
+
156
+ it("is taller than SmallText: LongText starts at six rows, SmallText at two", () => {
157
+ const long = renderWithProviders(<LongText label="Notes" value="" onChange={() => {}} />);
158
+ expect(Number(rowsOf(long.container))).toBeGreaterThan(2);
159
+ });
160
+
161
+ it("caps nothing on web: maxRows={16} leaves no rendered maximum height", () => {
162
+ // maxRows only reaches the DOM through the autoResize branch, and the
163
+ // adapter never turns autoResize on. Recorded so a future autoResize
164
+ // switch has to come past this assertion rather than silently changing
165
+ // how tall a Long Text can grow. Reported on MPO-123.
166
+ const result = renderWithProviders(
167
+ <LongText label="Notes" value="" minHeight={999} onChange={() => {}} />,
168
+ );
169
+ const cls = result.container.querySelector("textarea")?.getAttribute("class") ?? "";
170
+ expect(cls).not.toMatch(/(?:^|\s)_mah-/);
171
+ });
172
+ });
173
+
174
+ describe("registry resolution (Long Text / Text fieldtypes)", () => {
175
+ function propsFor(overrides: Partial<FieldRenderProps> = {}): FieldRenderProps {
176
+ return { value: "", onChange: () => {}, ...overrides };
177
+ }
178
+
179
+ it("resolves both 'Long Text' and 'Text' to the same adapter", () => {
180
+ const longText = getFieldComponent("Long Text")!;
181
+ const text = getFieldComponent("Text")!;
182
+ expect(longText).toBeTruthy();
183
+ expect(text).toBe(longText);
184
+ });
185
+
186
+ it("renders LongText and forwards label / value", () => {
187
+ const Component = getFieldComponent("Long Text")!;
188
+ const element = (Component as Function)(
189
+ propsFor({ value: "body", label: "Description" }),
190
+ ) as React.ReactElement<Record<string, any>>;
191
+ expect(element.type).toBe(LongText);
192
+ expect(element.props.value).toBe("body");
193
+ expect(element.props.label).toBe("Description");
194
+ });
195
+
196
+ it("substitutes an empty string for a null value so the control stays controlled", () => {
197
+ const Component = getFieldComponent("Long Text")!;
198
+ const element = (Component as Function)(
199
+ propsFor({ value: null as unknown as string }),
200
+ ) as React.ReactElement<Record<string, any>>;
201
+ expect(element.props.value).toBe("");
202
+ });
203
+
204
+ it("a disabled field also blocks the DOM textarea (Tamagui disabled is aria-only)", () => {
205
+ const Component = getFieldComponent("Long Text")!;
206
+ const element = (Component as Function)(propsFor({ disabled: true })) as React.ReactElement<
207
+ Record<string, any>
208
+ >;
209
+ expect(element.props.textAreaProps).toEqual({ readOnly: true, tabIndex: -1 });
210
+ });
211
+
212
+ it("an enabled field adds no textAreaProps block", () => {
213
+ const Component = getFieldComponent("Long Text")!;
214
+ const element = (Component as Function)(propsFor()) as React.ReactElement<Record<string, any>>;
215
+ expect(element.props.textAreaProps).toBeUndefined();
216
+ });
217
+ });