@multiplatform.one/frappe-ui 7.6.3 → 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,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
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Dirty tracking for the desk form engine (R-1 false "Not Saved" fix).
3
+ *
4
+ * The engine diffs the locally edited doc against the last server baseline.
5
+ * A strict `!==` diff falsely armed Save on untouched documents whenever a
6
+ * field's *representation* of a value differed from the wire format (mount
7
+ * emitters have been fixed at the field level — forms contract: initialization
8
+ * must not emit change events — but the engine stays defensive).
9
+ */
10
+
11
+ export function isEmptyValue(value: unknown): boolean {
12
+ if (value === undefined || value === null) return true;
13
+ if (typeof value === "string" && value.trim() === "") return true;
14
+ if (Array.isArray(value) && value.length === 0) return true;
15
+ return false;
16
+ }
17
+
18
+ /**
19
+ * Init-aware value equality. Two representation classes are folded:
20
+ *
21
+ * - empty: null / undefined / "" / [] are one empty state (a field
22
+ * normalizing null → "" is not an edit)
23
+ * - boolean: Check wires 0/1 but a control may emit true/false
24
+ *
25
+ * Everything else stays strict equality — deliberately shallow and honest
26
+ * (no deep equality): desk parity means an edit reverted to a value that is
27
+ * merely *equivalent* (not identical) may still read dirty, while reverting
28
+ * to the exact original value reads clean.
29
+ */
30
+ export function isSameValue(a: unknown, b: unknown): boolean {
31
+ if (a === b) return true;
32
+ if (isEmptyValue(a) && isEmptyValue(b)) return true;
33
+ if (typeof a === "boolean" || typeof b === "boolean") {
34
+ const an = a === true ? 1 : a === false ? 0 : a;
35
+ const bn = b === true ? 1 : b === false ? 0 : b;
36
+ return an === bn;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * Names of fields whose local value differs from the baseline.
43
+ *
44
+ * - Edit mode (baseline defined): normalization-insensitive diff via
45
+ * `isSameValue` — an untouched doc reads pristine even if a field
46
+ * re-represented its value during initialization.
47
+ * - Create mode (no baseline): any non-empty value counts, defaults included —
48
+ * desk parity, a new doc shows "Not Saved".
49
+ *
50
+ * `doctype` / `name` are bookkeeping, never dirty.
51
+ */
52
+ export function computeDirtyFields(
53
+ localDoc: Record<string, unknown>,
54
+ baseline: Record<string, unknown> | undefined,
55
+ ): string[] {
56
+ const dirty: string[] = [];
57
+ for (const key of Object.keys(localDoc)) {
58
+ if (key === "doctype" || key === "name") continue;
59
+ if (baseline ? !isSameValue(localDoc[key], baseline[key]) : !isEmptyValue(localDoc[key])) {
60
+ dirty.push(key);
61
+ }
62
+ }
63
+ return dirty;
64
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * DocType meta shapes as `frappe.desk.form.load.getdoctype` returns them.
3
+ *
4
+ * These were declared in `@multiplatform.one/backoffice`
5
+ * (`shared/useDoctypeMeta.ts`) while the desk form engine lived there. The
6
+ * engine now lives here (MPO-15), so the shapes it consumes live here too —
7
+ * a lower-tier package must never reach up into backoffice for a type.
8
+ * `useDoctypeMeta` (the loader hook, which needs the backoffice app context)
9
+ * stays where it is and re-exports these.
10
+ *
11
+ * Deliberately loose: the server is the source of truth, `0 | 1` flags are
12
+ * what the RPC actually sends, and the index signature carries the keys no
13
+ * surface has needed yet. `fieldtype` stays `string` here; narrowing it to
14
+ * the `FrappeFieldType` union is the remaining half of MPO-15's meta-model
15
+ * criterion and is tracked there, not silently done in a move.
16
+ */
17
+
18
+ /** A single DocField row from a DocType meta (loose — server is source of truth). */
19
+ export interface DocFieldMeta {
20
+ fieldname: string;
21
+ label?: string;
22
+ fieldtype: string;
23
+ options?: string;
24
+ reqd?: 0 | 1;
25
+ hidden?: 0 | 1;
26
+ read_only?: 0 | 1;
27
+ bold?: 0 | 1;
28
+ in_list_view?: 0 | 1;
29
+ in_standard_filter?: 0 | 1;
30
+ in_global_search?: 0 | 1;
31
+ allow_in_quick_entry?: 0 | 1;
32
+ allow_on_submit?: 0 | 1;
33
+ no_copy?: 0 | 1;
34
+ collapsible?: 0 | 1;
35
+ collapsible_depends_on?: string;
36
+ depends_on?: string;
37
+ mandatory_depends_on?: string;
38
+ read_only_depends_on?: string;
39
+ fetch_from?: string;
40
+ permlevel?: number;
41
+ length?: number;
42
+ default?: string;
43
+ description?: string;
44
+ precision?: string | number;
45
+ idx?: number;
46
+ [key: string]: unknown;
47
+ }
48
+
49
+ /** DocType Link child row (Connections tab). Probed: `group` arrives null when unset. */
50
+ export interface DocLinkMeta {
51
+ name?: string;
52
+ link_doctype: string;
53
+ link_fieldname: string;
54
+ group?: string | null;
55
+ hidden?: 0 | 1;
56
+ is_child_table?: 0 | 1;
57
+ parent_doctype?: string;
58
+ table_fieldname?: string;
59
+ [key: string]: unknown;
60
+ }
61
+
62
+ /** Role permission row from the DocType meta. */
63
+ export interface DocPermissionRule {
64
+ role?: string;
65
+ permlevel?: number;
66
+ read?: 0 | 1;
67
+ write?: 0 | 1;
68
+ create?: 0 | 1;
69
+ delete?: 0 | 1;
70
+ submit?: 0 | 1;
71
+ cancel?: 0 | 1;
72
+ amend?: 0 | 1;
73
+ print?: 0 | 1;
74
+ email?: 0 | 1;
75
+ share?: 0 | 1;
76
+ if_owner?: 0 | 1;
77
+ [key: string]: unknown;
78
+ }
79
+
80
+ /** DocType meta as returned by frappe.desk.form.load.getdoctype (loose). */
81
+ export interface DoctypeMeta {
82
+ doctype?: string;
83
+ name: string;
84
+ module?: string;
85
+ issingle?: 0 | 1;
86
+ istable?: 0 | 1;
87
+ is_submittable?: 0 | 1;
88
+ is_tree?: 0 | 1;
89
+ /** Desk treeview_settings asset (`{doctype}_tree.js`) — gates Tree. */
90
+ __tree_js?: string;
91
+ /** Desk calendar_settings asset (`{doctype}_calendar.js`) — gates Calendar. */
92
+ __calendar_js?: string;
93
+ is_virtual?: 0 | 1;
94
+ editable_grid?: 0 | 1;
95
+ track_changes?: 0 | 1;
96
+ quick_entry?: 0 | 1;
97
+ autoname?: string;
98
+ naming_rule?: string;
99
+ allow_rename?: 0 | 1;
100
+ allow_copy?: 0 | 1;
101
+ read_only?: 0 | 1;
102
+ in_create?: 0 | 1;
103
+ hide_toolbar?: 0 | 1;
104
+ max_attachments?: number;
105
+ title_field?: string;
106
+ image_field?: string;
107
+ search_fields?: string;
108
+ sort_field?: string;
109
+ sort_order?: string;
110
+ icon?: string;
111
+ document_type?: string;
112
+ fields: DocFieldMeta[];
113
+ permissions?: DocPermissionRule[];
114
+ links?: DocLinkMeta[];
115
+ states?: Array<{ title?: string; color?: string } & Record<string, unknown>>;
116
+ [key: string]: unknown;
117
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * fetchFrom — fetch_from preview parity with desk script_manager.js
3
+ * setup_add_fetch + link.js validate_link_and_fetch.
4
+ */
5
+ import { describe, expect, it } from "vitest";
6
+ import type { DocFieldMeta } from "./doctypeMeta";
7
+ import { applyFetchedValues, buildFetchMap, isFetchLocked } from "./fetchFrom";
8
+
9
+ function field(overrides: Partial<DocFieldMeta> & { fieldname: string }): DocFieldMeta {
10
+ return { fieldtype: "Data", ...overrides };
11
+ }
12
+
13
+ describe("buildFetchMap", () => {
14
+ it("registers scalar fieldtypes keyed by their link field", () => {
15
+ const map = buildFetchMap([
16
+ field({ fieldname: "customer", fieldtype: "Link", options: "Customer" }),
17
+ field({ fieldname: "customer_name", fetch_from: "customer.customer_name" }),
18
+ field({ fieldname: "territory", fieldtype: "Select", fetch_from: "customer.territory" }),
19
+ ]);
20
+ expect(map.get("customer")).toEqual([
21
+ { target: "customer_name", source: "customer_name", fetchIfEmpty: false },
22
+ { target: "territory", source: "territory", fetchIfEmpty: false },
23
+ ]);
24
+ });
25
+
26
+ it("skips fetch_from without a dot and non-registered fieldtypes", () => {
27
+ const map = buildFetchMap([
28
+ field({ fieldname: "bad", fetch_from: "customer" }),
29
+ field({ fieldname: "html", fieldtype: "HTML", fetch_from: "customer.blurb" }),
30
+ ]);
31
+ expect(map.size).toBe(0);
32
+ });
33
+
34
+ it("includes non-scalar fieldtypes when read_only (script_manager gate)", () => {
35
+ const map = buildFetchMap([
36
+ field({
37
+ fieldname: "blurb",
38
+ fieldtype: "HTML Editor",
39
+ fetch_from: "customer.blurb",
40
+ read_only: 1,
41
+ }),
42
+ ]);
43
+ expect(map.get("customer")).toHaveLength(1);
44
+ });
45
+
46
+ it("records fetch_if_empty", () => {
47
+ const map = buildFetchMap([
48
+ field({ fieldname: "note", fetch_from: "customer.note", fetch_if_empty: 1 }),
49
+ ]);
50
+ expect(map.get("customer")?.[0].fetchIfEmpty).toBe(true);
51
+ });
52
+ });
53
+
54
+ describe("applyFetchedValues", () => {
55
+ const rules = [
56
+ { target: "customer_name", source: "customer_name", fetchIfEmpty: false },
57
+ { target: "note", source: "note", fetchIfEmpty: true },
58
+ ];
59
+
60
+ it("writes fetched values onto targets", () => {
61
+ const patch = applyFetchedValues(rules, { customer_name: "Acme", note: "VIP" }, {});
62
+ expect(patch).toEqual({ customer_name: "Acme", note: "VIP" });
63
+ });
64
+
65
+ it("fetch_if_empty targets keep an existing value", () => {
66
+ const patch = applyFetchedValues(
67
+ rules,
68
+ { customer_name: "Acme", note: "VIP" },
69
+ { note: "user wrote this" },
70
+ );
71
+ expect(patch).toEqual({ customer_name: "Acme" });
72
+ });
73
+
74
+ it("clearing the link blanks plain targets but spares fetch_if_empty values", () => {
75
+ const patch = applyFetchedValues(rules, {}, { customer_name: "Acme", note: "mine" });
76
+ expect(patch).toEqual({ customer_name: "" });
77
+ });
78
+ });
79
+
80
+ describe("isFetchLocked", () => {
81
+ it("locks while the source link holds a value, unless fetch_if_empty", () => {
82
+ const target = field({ fieldname: "customer_name", fetch_from: "customer.customer_name" });
83
+ expect(isFetchLocked(target, { customer: "CUST-1" })).toBe(true);
84
+ expect(isFetchLocked(target, {})).toBe(false);
85
+ const softTarget = field({
86
+ fieldname: "note",
87
+ fetch_from: "customer.note",
88
+ fetch_if_empty: 1,
89
+ });
90
+ expect(isFetchLocked(softTarget, { customer: "CUST-1" })).toBe(false);
91
+ });
92
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * fetch_from client-side preview — desk parity with
3
+ * frappe/public/js/frappe/form/script_manager.js (`setup_add_fetch`) and
4
+ * controls/link.js (`validate_link_and_fetch`):
5
+ *
6
+ * - A field participates when `fetch_from` is "<link_field>.<source_field>"
7
+ * AND (its fieldtype is in the scalar set desk registers, or it is
8
+ * read_only / is_virtual).
9
+ * - When the link field changes to a value, desk calls
10
+ * `frappe.client.validate_link` with the source columns and writes the
11
+ * returned values onto the targets; clearing the link clears the targets.
12
+ * - `fetch_if_empty` targets keep a non-empty existing value (server
13
+ * semantics; desk leaves such fields editable — see
14
+ * `read_only_because_of_fetch_from`, base_input.js:56-66).
15
+ *
16
+ * The server remains authoritative: this is a preview so the user sees the
17
+ * fetched values before save.
18
+ */
19
+
20
+ import type { DocFieldMeta } from "./doctypeMeta";
21
+
22
+ /** Fieldtypes desk registers for client-side fetch (script_manager.js:196-215). */
23
+ const FETCHABLE_FIELDTYPES = new Set([
24
+ "Data",
25
+ "Read Only",
26
+ "Text",
27
+ "Small Text",
28
+ "Currency",
29
+ "Check",
30
+ "Text Editor",
31
+ "Attach Image",
32
+ "Code",
33
+ "Link",
34
+ "Float",
35
+ "Int",
36
+ "Date",
37
+ "Datetime",
38
+ "Select",
39
+ "Duration",
40
+ "Time",
41
+ ]);
42
+
43
+ export interface FetchRule {
44
+ /** Field to populate. */
45
+ target: string;
46
+ /** Column on the linked doc to read. */
47
+ source: string;
48
+ /** Only fill when the target is currently empty; never lock the field. */
49
+ fetchIfEmpty: boolean;
50
+ }
51
+
52
+ /** Map of link fieldname → fetch rules hanging off that link. */
53
+ export type FetchMap = Map<string, FetchRule[]>;
54
+
55
+ export function buildFetchMap(fields: DocFieldMeta[]): FetchMap {
56
+ const map: FetchMap = new Map();
57
+ for (const field of fields) {
58
+ const fetchFrom = typeof field.fetch_from === "string" ? field.fetch_from : "";
59
+ if (!fetchFrom || !fetchFrom.includes(".")) continue;
60
+ const eligible =
61
+ FETCHABLE_FIELDTYPES.has(field.fieldtype) ||
62
+ !!field.read_only ||
63
+ !!(field as { is_virtual?: 0 | 1 }).is_virtual;
64
+ if (!eligible) continue;
65
+ const [linkField, ...rest] = fetchFrom.split(".");
66
+ const source = rest.join(".");
67
+ if (!linkField || !source) continue;
68
+ const rules = map.get(linkField) ?? [];
69
+ rules.push({
70
+ target: field.fieldname,
71
+ source,
72
+ fetchIfEmpty: !!field.fetch_if_empty,
73
+ });
74
+ map.set(linkField, rules);
75
+ }
76
+ return map;
77
+ }
78
+
79
+ function isEmpty(value: unknown): boolean {
80
+ return value === undefined || value === null || value === "";
81
+ }
82
+
83
+ /**
84
+ * Values to patch onto the doc after the linked doc's columns arrive.
85
+ * `fetched` is empty when the link was cleared — desk then blanks the
86
+ * targets; `fetch_if_empty` targets holding a value are left alone in both
87
+ * directions (they belong to the user until the server reconciles).
88
+ */
89
+ export function applyFetchedValues(
90
+ rules: FetchRule[],
91
+ fetched: Record<string, unknown>,
92
+ currentDoc: Record<string, unknown>,
93
+ ): Record<string, unknown> {
94
+ const patch: Record<string, unknown> = {};
95
+ for (const rule of rules) {
96
+ if (rule.fetchIfEmpty && !isEmpty(currentDoc[rule.target])) continue;
97
+ patch[rule.target] = fetched[rule.source] ?? "";
98
+ }
99
+ return patch;
100
+ }
101
+
102
+ /**
103
+ * Desk's `read_only_because_of_fetch_from`: a fetch target without
104
+ * `fetch_if_empty` renders read-only while its source link holds a value.
105
+ */
106
+ export function isFetchLocked(field: DocFieldMeta, doc: Record<string, unknown>): boolean {
107
+ const fetchFrom = typeof field.fetch_from === "string" ? field.fetch_from : "";
108
+ if (!fetchFrom || !fetchFrom.includes(".") || field.fetch_if_empty) return false;
109
+ const linkField = fetchFrom.split(".")[0];
110
+ return !isEmpty(doc[linkField]);
111
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * fieldPermissions — permlevel matrix parity with desk perm.js
3
+ * get_role_permissions / get_field_display_status.
4
+ */
5
+ import { describe, expect, it } from "vitest";
6
+ import { buildPermLevelMatrix, canReadLevel, canWriteLevel } from "./fieldPermissions";
7
+
8
+ const userDoctypePerms = [
9
+ { role: "System Manager", permlevel: 0, read: 1 as const, write: 1 as const },
10
+ { role: "System Manager", permlevel: 1, read: 1 as const, write: 1 as const },
11
+ { role: "Desk User", permlevel: 0, read: 0 as const, write: 0 as const },
12
+ ];
13
+
14
+ describe("buildPermLevelMatrix", () => {
15
+ it("unions rights across the user's matching roles", () => {
16
+ const matrix = buildPermLevelMatrix(
17
+ [
18
+ { role: "Blogger", permlevel: 1, read: 1 as const, write: 0 as const },
19
+ { role: "Website Manager", permlevel: 1, read: 1 as const, write: 1 as const },
20
+ ],
21
+ ["Blogger", "Website Manager"],
22
+ );
23
+ expect(matrix?.[1]).toEqual({ read: true, write: true });
24
+ });
25
+
26
+ it("ignores rows for roles the user does not hold (User doctype shape)", () => {
27
+ const matrix = buildPermLevelMatrix(userDoctypePerms, ["Desk User"]);
28
+ expect(matrix?.[1]).toBeUndefined();
29
+ expect(canReadLevel(matrix, 1)).toBe(false);
30
+ expect(canWriteLevel(matrix, 1)).toBe(false);
31
+ });
32
+
33
+ it("returns undefined without roles or permission rows (fallback signal)", () => {
34
+ expect(buildPermLevelMatrix(userDoctypePerms, undefined)).toBeUndefined();
35
+ expect(buildPermLevelMatrix([], ["System Manager"])).toBeUndefined();
36
+ expect(buildPermLevelMatrix(undefined, ["System Manager"])).toBeUndefined();
37
+ });
38
+ });
39
+
40
+ describe("canReadLevel / canWriteLevel", () => {
41
+ const matrix = buildPermLevelMatrix(userDoctypePerms, ["System Manager"]);
42
+
43
+ it("level 0 defers to doc-level perms (always true here)", () => {
44
+ expect(canReadLevel(matrix, 0)).toBe(true);
45
+ expect(canWriteLevel(matrix, 0)).toBe(true);
46
+ expect(canReadLevel(undefined, 0)).toBe(true);
47
+ expect(canWriteLevel(undefined, 0)).toBe(true);
48
+ });
49
+
50
+ it("higher levels resolve from the matrix", () => {
51
+ expect(canReadLevel(matrix, 1)).toBe(true);
52
+ expect(canWriteLevel(matrix, 1)).toBe(true);
53
+ // Level with no rows at all → hidden (desk fills gaps with {}).
54
+ expect(canReadLevel(matrix, 2)).toBe(false);
55
+ });
56
+
57
+ it("without a matrix: visible but read-only above level 0 (coarse fallback)", () => {
58
+ expect(canReadLevel(undefined, 1)).toBe(true);
59
+ expect(canWriteLevel(undefined, 1)).toBe(false);
60
+ });
61
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Field-level permission matrix — desk parity with
3
+ * frappe/public/js/frappe/model/perm.js (`get_role_permissions` +
4
+ * `get_field_display_status`):
5
+ *
6
+ * perm[level] = OR of meta.permissions rows whose role the session user
7
+ * holds. A field at permlevel L then resolves to:
8
+ * - no read at L → hidden ("None")
9
+ * - read, no write → read-only ("Read")
10
+ * - write → editable ("Write", subject to the other rules)
11
+ *
12
+ * Levels that appear on fields but have no granting rows resolve to
13
+ * {read: false} — desk hides those fields (gap-filled `{}` in perm.js:153).
14
+ * When the caller has no role list (fixtures, roles RPC unavailable) the
15
+ * matrix is undefined and formLayout falls back to the conservative
16
+ * "permlevel > 0 → read-only" rule (never hides, never unlocks).
17
+ */
18
+
19
+ import type { DocPermissionRule } from "./doctypeMeta";
20
+
21
+ export interface LevelPerm {
22
+ read: boolean;
23
+ write: boolean;
24
+ }
25
+
26
+ /** Sparse array indexed by permlevel; missing entries mean "no rights". */
27
+ export type PermLevelMatrix = LevelPerm[];
28
+
29
+ export function buildPermLevelMatrix(
30
+ permissions: DocPermissionRule[] | undefined,
31
+ userRoles: string[] | undefined,
32
+ ): PermLevelMatrix | undefined {
33
+ if (!userRoles || !permissions || permissions.length === 0) return undefined;
34
+ const roles = new Set(userRoles);
35
+ const matrix: PermLevelMatrix = [];
36
+ for (const rule of permissions) {
37
+ if (!rule.role || !roles.has(rule.role)) continue;
38
+ const level = Number(rule.permlevel ?? 0) || 0;
39
+ const current = (matrix[level] ??= { read: false, write: false });
40
+ // if_owner rows are unioned in like desk's get_role_permissions — the
41
+ // owner check itself is enforced server-side (and via docinfo at level 0).
42
+ if (rule.read) current.read = true;
43
+ if (rule.write) current.write = true;
44
+ }
45
+ return matrix;
46
+ }
47
+
48
+ /**
49
+ * Read access at a permlevel. Level 0 is implied by having the doc at all
50
+ * (docinfo/doc-level perms govern it — including share/if_owner grants the
51
+ * role matrix can't see), so only higher levels consult the matrix.
52
+ */
53
+ export function canReadLevel(matrix: PermLevelMatrix | undefined, permlevel: number): boolean {
54
+ if (!permlevel) return true;
55
+ if (!matrix) return true; // caller falls back to the coarse read-only rule
56
+ return !!matrix[permlevel]?.read;
57
+ }
58
+
59
+ /**
60
+ * Write access at a permlevel. Level 0 defers to the doc-level write flag
61
+ * (formReadOnly); higher levels require a granting role row — without a
62
+ * matrix the coarse fallback keeps them read-only (desk-safe direction).
63
+ */
64
+ export function canWriteLevel(matrix: PermLevelMatrix | undefined, permlevel: number): boolean {
65
+ if (!permlevel) return true;
66
+ if (!matrix) return false;
67
+ return !!matrix[permlevel]?.write;
68
+ }