@multiplatform.one/frappe-ui 7.6.2 → 7.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/dist/cjs/ChartCard.cjs +52 -0
  2. package/dist/cjs/ChartCard.native.js +72 -0
  3. package/dist/cjs/ChartCard.native.js.map +1 -0
  4. package/dist/cjs/FrappeChartCard.cjs +3 -3
  5. package/dist/cjs/FrappeChartCard.native.js +3 -3
  6. package/dist/cjs/FrappeChartCard.native.js.map +1 -1
  7. package/dist/cjs/FrappeDashboard.cjs +6 -27
  8. package/dist/cjs/FrappeDashboard.native.js +6 -33
  9. package/dist/cjs/FrappeDashboard.native.js.map +1 -1
  10. package/dist/cjs/fields/CodeEditor/index.cjs +4 -0
  11. package/dist/cjs/fields/CodeEditor/index.native.js +4 -0
  12. package/dist/cjs/fields/CodeEditor/index.native.js.map +1 -1
  13. package/dist/cjs/formEngine/createPrefill.cjs +37 -0
  14. package/dist/cjs/formEngine/createPrefill.native.js +40 -0
  15. package/dist/cjs/formEngine/createPrefill.native.js.map +1 -0
  16. package/dist/cjs/formEngine/dirtyTracking.cjs +55 -0
  17. package/dist/cjs/formEngine/dirtyTracking.native.js +77 -0
  18. package/dist/cjs/formEngine/dirtyTracking.native.js.map +1 -0
  19. package/dist/cjs/formEngine/doctypeMeta.cjs +18 -0
  20. package/dist/cjs/formEngine/doctypeMeta.native.js +21 -0
  21. package/dist/cjs/formEngine/doctypeMeta.native.js.map +1 -0
  22. package/dist/cjs/formEngine/fetchFrom.cjs +67 -0
  23. package/dist/cjs/formEngine/fetchFrom.native.js +110 -0
  24. package/dist/cjs/formEngine/fetchFrom.native.js.map +1 -0
  25. package/dist/cjs/formEngine/fieldPermissions.cjs +55 -0
  26. package/dist/cjs/formEngine/fieldPermissions.native.js +80 -0
  27. package/dist/cjs/formEngine/fieldPermissions.native.js.map +1 -0
  28. package/dist/cjs/formEngine/formActions.cjs +189 -0
  29. package/dist/cjs/formEngine/formActions.native.js +199 -0
  30. package/dist/cjs/formEngine/formActions.native.js.map +1 -0
  31. package/dist/cjs/formEngine/formLayout.cjs +245 -0
  32. package/dist/cjs/formEngine/formLayout.native.js +388 -0
  33. package/dist/cjs/formEngine/formLayout.native.js.map +1 -0
  34. package/dist/cjs/formEngine/index.cjs +48 -0
  35. package/dist/cjs/formEngine/index.native.js +51 -0
  36. package/dist/cjs/formEngine/index.native.js.map +1 -0
  37. package/dist/cjs/formEngine/useFormEngine.cjs +387 -0
  38. package/dist/cjs/formEngine/useFormEngine.native.js +476 -0
  39. package/dist/cjs/formEngine/useFormEngine.native.js.map +1 -0
  40. package/dist/cjs/index.cjs +19 -1
  41. package/dist/cjs/index.native.js +19 -1
  42. package/dist/cjs/index.native.js.map +1 -1
  43. package/dist/esm/ChartCard.mjs +27 -0
  44. package/dist/esm/ChartCard.mjs.map +1 -0
  45. package/dist/esm/ChartCard.native.js +33 -0
  46. package/dist/esm/ChartCard.native.js.map +1 -0
  47. package/dist/esm/FrappeChartCard.mjs +1 -1
  48. package/dist/esm/FrappeChartCard.native.js +1 -1
  49. package/dist/esm/FrappeChartCard.native.js.map +1 -1
  50. package/dist/esm/FrappeDashboard.mjs +6 -27
  51. package/dist/esm/FrappeDashboard.mjs.map +1 -1
  52. package/dist/esm/FrappeDashboard.native.js +6 -33
  53. package/dist/esm/FrappeDashboard.native.js.map +1 -1
  54. package/dist/esm/fields/CodeEditor/index.mjs +4 -0
  55. package/dist/esm/fields/CodeEditor/index.mjs.map +1 -1
  56. package/dist/esm/fields/CodeEditor/index.native.js +4 -0
  57. package/dist/esm/fields/CodeEditor/index.native.js.map +1 -1
  58. package/dist/esm/formEngine/createPrefill.mjs +11 -0
  59. package/dist/esm/formEngine/createPrefill.mjs.map +1 -0
  60. package/dist/esm/formEngine/createPrefill.native.js +11 -0
  61. package/dist/esm/formEngine/createPrefill.native.js.map +1 -0
  62. package/dist/esm/formEngine/dirtyTracking.mjs +28 -0
  63. package/dist/esm/formEngine/dirtyTracking.mjs.map +1 -0
  64. package/dist/esm/formEngine/dirtyTracking.native.js +47 -0
  65. package/dist/esm/formEngine/dirtyTracking.native.js.map +1 -0
  66. package/dist/esm/formEngine/doctypeMeta.mjs +2 -0
  67. package/dist/esm/formEngine/doctypeMeta.mjs.map +1 -0
  68. package/dist/esm/formEngine/doctypeMeta.native.js +2 -0
  69. package/dist/esm/formEngine/doctypeMeta.native.js.map +1 -0
  70. package/dist/esm/formEngine/fetchFrom.mjs +40 -0
  71. package/dist/esm/formEngine/fetchFrom.mjs.map +1 -0
  72. package/dist/esm/formEngine/fetchFrom.native.js +80 -0
  73. package/dist/esm/formEngine/fetchFrom.native.js.map +1 -0
  74. package/dist/esm/formEngine/fieldPermissions.mjs +28 -0
  75. package/dist/esm/formEngine/fieldPermissions.mjs.map +1 -0
  76. package/dist/esm/formEngine/fieldPermissions.native.js +50 -0
  77. package/dist/esm/formEngine/fieldPermissions.native.js.map +1 -0
  78. package/dist/esm/formEngine/formActions.mjs +146 -0
  79. package/dist/esm/formEngine/formActions.mjs.map +1 -0
  80. package/dist/esm/formEngine/formActions.native.js +153 -0
  81. package/dist/esm/formEngine/formActions.native.js.map +1 -0
  82. package/dist/esm/formEngine/formLayout.mjs +217 -0
  83. package/dist/esm/formEngine/formLayout.mjs.map +1 -0
  84. package/dist/esm/formEngine/formLayout.native.js +357 -0
  85. package/dist/esm/formEngine/formLayout.native.js.map +1 -0
  86. package/dist/esm/formEngine/index.mjs +8 -0
  87. package/dist/esm/formEngine/index.mjs.map +1 -0
  88. package/dist/esm/formEngine/index.native.js +8 -0
  89. package/dist/esm/formEngine/index.native.js.map +1 -0
  90. package/dist/esm/formEngine/useFormEngine.mjs +362 -0
  91. package/dist/esm/formEngine/useFormEngine.mjs.map +1 -0
  92. package/dist/esm/formEngine/useFormEngine.native.js +448 -0
  93. package/dist/esm/formEngine/useFormEngine.native.js.map +1 -0
  94. package/dist/esm/index.mjs +4 -2
  95. package/dist/esm/index.mjs.map +1 -1
  96. package/dist/esm/index.native.js +4 -2
  97. package/dist/esm/index.native.js.map +1 -1
  98. package/dist/jsx/ChartCard.mjs +27 -0
  99. package/dist/jsx/ChartCard.mjs.map +1 -0
  100. package/dist/jsx/ChartCard.native.js +72 -0
  101. package/dist/jsx/ChartCard.native.js.map +1 -0
  102. package/dist/jsx/FrappeChartCard.mjs +1 -1
  103. package/dist/jsx/FrappeChartCard.native.js +3 -3
  104. package/dist/jsx/FrappeChartCard.native.js.map +1 -1
  105. package/dist/jsx/FrappeDashboard.mjs +6 -27
  106. package/dist/jsx/FrappeDashboard.mjs.map +1 -1
  107. package/dist/jsx/FrappeDashboard.native.js +6 -33
  108. package/dist/jsx/FrappeDashboard.native.js.map +1 -1
  109. package/dist/jsx/fields/CodeEditor/index.mjs +4 -0
  110. package/dist/jsx/fields/CodeEditor/index.mjs.map +1 -1
  111. package/dist/jsx/fields/CodeEditor/index.native.js +4 -0
  112. package/dist/jsx/fields/CodeEditor/index.native.js.map +1 -1
  113. package/dist/jsx/formEngine/createPrefill.mjs +11 -0
  114. package/dist/jsx/formEngine/createPrefill.mjs.map +1 -0
  115. package/dist/jsx/formEngine/createPrefill.native.js +40 -0
  116. package/dist/jsx/formEngine/createPrefill.native.js.map +1 -0
  117. package/dist/jsx/formEngine/dirtyTracking.mjs +28 -0
  118. package/dist/jsx/formEngine/dirtyTracking.mjs.map +1 -0
  119. package/dist/jsx/formEngine/dirtyTracking.native.js +77 -0
  120. package/dist/jsx/formEngine/dirtyTracking.native.js.map +1 -0
  121. package/dist/jsx/formEngine/doctypeMeta.mjs +2 -0
  122. package/dist/jsx/formEngine/doctypeMeta.mjs.map +1 -0
  123. package/dist/jsx/formEngine/doctypeMeta.native.js +21 -0
  124. package/dist/jsx/formEngine/doctypeMeta.native.js.map +1 -0
  125. package/dist/jsx/formEngine/fetchFrom.mjs +40 -0
  126. package/dist/jsx/formEngine/fetchFrom.mjs.map +1 -0
  127. package/dist/jsx/formEngine/fetchFrom.native.js +110 -0
  128. package/dist/jsx/formEngine/fetchFrom.native.js.map +1 -0
  129. package/dist/jsx/formEngine/fieldPermissions.mjs +28 -0
  130. package/dist/jsx/formEngine/fieldPermissions.mjs.map +1 -0
  131. package/dist/jsx/formEngine/fieldPermissions.native.js +80 -0
  132. package/dist/jsx/formEngine/fieldPermissions.native.js.map +1 -0
  133. package/dist/jsx/formEngine/formActions.mjs +146 -0
  134. package/dist/jsx/formEngine/formActions.mjs.map +1 -0
  135. package/dist/jsx/formEngine/formActions.native.js +199 -0
  136. package/dist/jsx/formEngine/formActions.native.js.map +1 -0
  137. package/dist/jsx/formEngine/formLayout.mjs +217 -0
  138. package/dist/jsx/formEngine/formLayout.mjs.map +1 -0
  139. package/dist/jsx/formEngine/formLayout.native.js +388 -0
  140. package/dist/jsx/formEngine/formLayout.native.js.map +1 -0
  141. package/dist/jsx/formEngine/index.mjs +8 -0
  142. package/dist/jsx/formEngine/index.mjs.map +1 -0
  143. package/dist/jsx/formEngine/index.native.js +51 -0
  144. package/dist/jsx/formEngine/index.native.js.map +1 -0
  145. package/dist/jsx/formEngine/useFormEngine.mjs +362 -0
  146. package/dist/jsx/formEngine/useFormEngine.mjs.map +1 -0
  147. package/dist/jsx/formEngine/useFormEngine.native.js +476 -0
  148. package/dist/jsx/formEngine/useFormEngine.native.js.map +1 -0
  149. package/dist/jsx/index.js +4 -2
  150. package/dist/jsx/index.js.map +1 -1
  151. package/dist/jsx/index.mjs +4 -2
  152. package/dist/jsx/index.mjs.map +1 -1
  153. package/dist/jsx/index.native.js +19 -1
  154. package/dist/jsx/index.native.js.map +1 -1
  155. package/package.json +23 -17
  156. package/src/ChartCard.tsx +31 -0
  157. package/src/FileBrowser/FileBrowser.spec.tsx +16 -0
  158. package/src/FileBrowser/FileBrowser.stories.tsx +18 -1
  159. package/src/FileBrowser/folderTree.spec.tsx +68 -0
  160. package/src/FrappeCalendar.stories.tsx +1 -2
  161. package/src/FrappeChartCard.stories.tsx +1 -1
  162. package/src/FrappeChartCard.tsx +1 -1
  163. package/src/FrappeDashboard.stories.tsx +6 -5
  164. package/src/FrappeDashboard.tsx +6 -33
  165. package/src/FrappeGantt.stories.tsx +1 -2
  166. package/src/FrappeList.stories.tsx +3 -10
  167. package/src/components/attachments.stories.tsx +9 -2
  168. package/src/dependsOn.ts +2 -2
  169. package/src/fields/CodeEditor/CodeEditor.spec.tsx +37 -0
  170. package/src/fields/CodeEditor/CodeEditor.stories.tsx +83 -66
  171. package/src/fields/CodeEditor/index.tsx +4 -0
  172. package/src/fields/DateRange/DateRange.spec.tsx +143 -0
  173. package/src/fields/FileUpload.spec.tsx +334 -0
  174. package/src/fields/FileUploadField/FileUploadField.spec.tsx +166 -0
  175. package/src/fields/HTML/Html.spec.tsx +179 -0
  176. package/src/fields/Icon/Icon.stories.tsx +138 -0
  177. package/src/fields/Link.stories.tsx +2 -0
  178. package/src/fields/LongText/LongText.spec.tsx +217 -0
  179. package/src/fields/OTPInput/OTPInput.spec.tsx +167 -0
  180. package/src/fields/Password/Password.stories.tsx +21 -0
  181. package/src/fields/Phone/Phone.spec.tsx +151 -0
  182. package/src/fields/ReadOnly/ReadOnly.spec.tsx +229 -0
  183. package/src/fields/SmallText/SmallText.spec.tsx +175 -0
  184. package/src/fields/shared/EditorPreviewPanes.spec.tsx +122 -0
  185. package/src/formEngine/createPrefill.ts +28 -0
  186. package/src/formEngine/dirtyTracking.spec.ts +91 -0
  187. package/src/formEngine/dirtyTracking.ts +64 -0
  188. package/src/formEngine/doctypeMeta.ts +117 -0
  189. package/src/formEngine/fetchFrom.spec.ts +92 -0
  190. package/src/formEngine/fetchFrom.ts +111 -0
  191. package/src/formEngine/fieldPermissions.spec.ts +61 -0
  192. package/src/formEngine/fieldPermissions.ts +68 -0
  193. package/src/formEngine/formActions.ts +285 -0
  194. package/src/formEngine/formLayout.spec.ts +373 -0
  195. package/src/formEngine/formLayout.ts +392 -0
  196. package/src/formEngine/index.ts +33 -0
  197. package/src/formEngine/seam.spec.ts +148 -0
  198. package/src/formEngine/useFormEngine.ts +496 -0
  199. package/src/frappeRemainder.designLaw.spec.ts +9 -0
  200. package/src/frappeViews.designLaw.spec.ts +1 -0
  201. package/src/index.spec.ts +7 -3
  202. package/src/index.ts +51 -1
  203. package/src/schemaForm/SchemaForm.spec.tsx +4 -1
  204. package/types/ChartCard.d.ts +13 -0
  205. package/types/ChartCard.d.ts.map +1 -0
  206. package/types/FrappeDashboard.d.ts +2 -10
  207. package/types/FrappeDashboard.d.ts.map +1 -1
  208. package/types/dependsOn.d.ts +2 -2
  209. package/types/fields/CodeEditor/index.d.ts.map +1 -1
  210. package/types/formEngine/createPrefill.d.ts +19 -0
  211. package/types/formEngine/createPrefill.d.ts.map +1 -0
  212. package/types/formEngine/dirtyTracking.d.ts +36 -0
  213. package/types/formEngine/dirtyTracking.d.ts.map +1 -0
  214. package/types/formEngine/doctypeMeta.d.ts +117 -0
  215. package/types/formEngine/doctypeMeta.d.ts.map +1 -0
  216. package/types/formEngine/fetchFrom.d.ts +43 -0
  217. package/types/formEngine/fetchFrom.d.ts.map +1 -0
  218. package/types/formEngine/fieldPermissions.d.ts +38 -0
  219. package/types/formEngine/fieldPermissions.d.ts.map +1 -0
  220. package/types/formEngine/formActions.d.ts +95 -0
  221. package/types/formEngine/formActions.d.ts.map +1 -0
  222. package/types/formEngine/formLayout.d.ts +91 -0
  223. package/types/formEngine/formLayout.d.ts.map +1 -0
  224. package/types/formEngine/index.d.ts +27 -0
  225. package/types/formEngine/index.d.ts.map +1 -0
  226. package/types/formEngine/useFormEngine.d.ts +61 -0
  227. package/types/formEngine/useFormEngine.d.ts.map +1 -0
  228. package/types/index.d.ts +4 -1
  229. package/types/index.d.ts.map +1 -1
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Desk form layout engine.
3
+ *
4
+ * Splits the flat DocType field list into desk tabs (Tab Break), sections
5
+ * (Section Break, collapsible honored), and columns (Column Break), and
6
+ * maps them to the generic `FormSchema` consumed by frappe-ui's SchemaForm.
7
+ *
8
+ * Dynamic behavior is computed HERE with the safe depends_on evaluator
9
+ * (dependsOn.ts) — the `depends_on` string is deliberately stripped from the
10
+ * FieldSchema handed to SchemaForm so its string-eval-based evaluator
11
+ * (which cannot run on Hermes/GJS) never engages:
12
+ *
13
+ * - depends_on → field omitted from the built schema
14
+ * - mandatory_depends_on → `required: true` on the built FieldSchema
15
+ * - read_only_depends_on → `readOnly: true` on the built FieldSchema
16
+ * - permlevel → hidden without level read, read-only without
17
+ * level write (role matrix via fieldPermissions.ts; coarse read-only
18
+ * fallback when roles are unavailable)
19
+ * - set_only_once → read-only on saved docs
20
+ * - fetch_from → read-only while the source link holds a value
21
+ * (unless fetch_if_empty)
22
+ * - docstatus > 0 → everything read-only except allow_on_submit
23
+ * fields on a submitted (1) doc
24
+ * - collapsible / collapsible_depends_on / missing-mandatory → per-section
25
+ * computed collapse state (desk refresh_section_collapse)
26
+ *
27
+ * Sections whose Section Break has a false depends_on hide wholesale; a
28
+ * section with no visible fields is dropped so SchemaForm never renders an
29
+ * empty card.
30
+ */
31
+
32
+ import { formatCalendarDate } from "@multiplatform.one/theme";
33
+ import type { FieldSchema } from "../fieldRegistry";
34
+ import type { FormSchema, FormSection } from "../schemaForm";
35
+ import { evaluateDependsOn } from "../dependsOn";
36
+ import { canReadLevel, canWriteLevel, type PermLevelMatrix } from "./fieldPermissions";
37
+ import { isFetchLocked } from "./fetchFrom";
38
+ import type { DocFieldMeta } from "./doctypeMeta";
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Types
42
+ // ---------------------------------------------------------------------------
43
+
44
+ export interface FormTab {
45
+ /** Stable key (fieldname of the Tab Break or "__details") */
46
+ key: string;
47
+ label: string;
48
+ schema: FormSchema;
49
+ }
50
+
51
+ interface LayoutColumn {
52
+ fields: DocFieldMeta[];
53
+ }
54
+
55
+ interface LayoutSection {
56
+ breakField?: DocFieldMeta;
57
+ columns: LayoutColumn[];
58
+ }
59
+
60
+ interface LayoutTab {
61
+ key: string;
62
+ label?: string;
63
+ sections: LayoutSection[];
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Static structure (from meta, doc-independent)
68
+ // ---------------------------------------------------------------------------
69
+
70
+ function splitIntoTabs(fields: DocFieldMeta[]): LayoutTab[] {
71
+ const tabs: LayoutTab[] = [];
72
+ let currentTab: LayoutTab = { key: "__details", sections: [] };
73
+ let currentSection: LayoutSection = { columns: [{ fields: [] }] };
74
+ currentTab.sections.push(currentSection);
75
+ tabs.push(currentTab);
76
+
77
+ for (const field of fields) {
78
+ switch (field.fieldtype) {
79
+ case "Tab Break":
80
+ currentTab = { key: field.fieldname, label: field.label, sections: [] };
81
+ currentSection = { columns: [{ fields: [] }] };
82
+ currentTab.sections.push(currentSection);
83
+ tabs.push(currentTab);
84
+ break;
85
+ case "Section Break":
86
+ currentSection = { breakField: field, columns: [{ fields: [] }] };
87
+ currentTab.sections.push(currentSection);
88
+ break;
89
+ case "Column Break":
90
+ currentSection.columns.push({ fields: [] });
91
+ break;
92
+ default:
93
+ currentSection.columns[currentSection.columns.length - 1].fields.push(field);
94
+ break;
95
+ }
96
+ }
97
+ return tabs;
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Doc-dependent resolution
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export interface BuildTabsOptions {
105
+ fields: DocFieldMeta[];
106
+ doc: Record<string, unknown>;
107
+ /** docstatus of the loaded doc (0 draft / 1 submitted / 2 cancelled). */
108
+ docstatus: number;
109
+ /** Meta-level read_only or missing write permission. */
110
+ formReadOnly: boolean;
111
+ /** Create mode relaxes read_only_depends_on on unsaved values. */
112
+ isCreate: boolean;
113
+ /** Default label for the implicit first tab. */
114
+ detailsLabel: string;
115
+ /**
116
+ * Role-derived permlevel matrix (fieldPermissions.ts). Desk semantics:
117
+ * fields at a level without read HIDE; read without write → read-only.
118
+ * Undefined (fixtures / roles unavailable) → coarse fallback: any
119
+ * permlevel > 0 renders read-only, never hidden.
120
+ */
121
+ permLevels?: PermLevelMatrix;
122
+ /** Host-hidden fields (fieldnames) — dropped before layout resolution. */
123
+ hiddenFields?: readonly string[];
124
+ /**
125
+ * Host-hidden sections/tabs. Matches a Section Break's or Tab Break's
126
+ * fieldname OR label; the whole section (or tab) is dropped.
127
+ */
128
+ hiddenSections?: readonly string[];
129
+ }
130
+
131
+ function isFieldVisible(
132
+ field: DocFieldMeta,
133
+ doc: Record<string, unknown>,
134
+ permLevels?: PermLevelMatrix,
135
+ ): boolean {
136
+ if (field.hidden) return false;
137
+ // Desk hides fields whose permlevel the user cannot read (perm.js:209-215).
138
+ if (!canReadLevel(permLevels, field.permlevel ?? 0)) return false;
139
+ // Visibility fallback = true: an unparseable expression shows the field.
140
+ return evaluateDependsOn(field.depends_on, doc, true);
141
+ }
142
+
143
+ function toFieldSchema(
144
+ field: DocFieldMeta,
145
+ doc: Record<string, unknown>,
146
+ options: BuildTabsOptions,
147
+ ): FieldSchema {
148
+ const required =
149
+ !!field.reqd ||
150
+ (!!field.mandatory_depends_on && evaluateDependsOn(field.mandatory_depends_on, doc, false));
151
+
152
+ let readOnly =
153
+ !!field.read_only ||
154
+ (!!field.read_only_depends_on && evaluateDependsOn(field.read_only_depends_on, doc, false)) ||
155
+ !canWriteLevel(options.permLevels, field.permlevel ?? 0);
156
+ // set_only_once: editable only while the doc is unsaved (perm.js:261-263).
157
+ if (field.set_only_once && !options.isCreate) readOnly = true;
158
+ // fetch_from without fetch_if_empty locks the target while its source link
159
+ // holds a value (desk read_only_because_of_fetch_from).
160
+ if (!readOnly && isFetchLocked(field, doc)) readOnly = true;
161
+ if (options.docstatus === 1 && !field.allow_on_submit) readOnly = true;
162
+ if (options.docstatus === 2) readOnly = true;
163
+ if (options.formReadOnly) readOnly = true;
164
+
165
+ return {
166
+ type: field.fieldtype,
167
+ name: field.fieldname,
168
+ label: field.label,
169
+ options: field.options,
170
+ description: field.description,
171
+ required,
172
+ readOnly,
173
+ precision:
174
+ field.precision !== undefined && field.precision !== null && field.precision !== ""
175
+ ? Number(field.precision)
176
+ : undefined,
177
+ maxLength:
178
+ field.length !== undefined && field.length !== null && Number(field.length) > 0
179
+ ? Number(field.length)
180
+ : undefined,
181
+ // depends_on intentionally NOT forwarded — visibility resolved here.
182
+ fieldtype: field.fieldtype,
183
+ fieldname: field.fieldname,
184
+ in_list_view: field.in_list_view,
185
+ in_standard_filter: field.in_standard_filter,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Desk's refresh_section_collapse (layout.js:463-483): a collapsible section
191
+ * starts collapsed unless its `collapsible_depends_on` evaluates truthy, and
192
+ * a section with a missing mandatory value is forced open. Recomputed per
193
+ * doc change — SchemaForm treats it as the live default while manual toggles
194
+ * survive between recomputes.
195
+ */
196
+ function resolveSectionCollapsed(
197
+ breakField: DocFieldMeta | undefined,
198
+ sectionFields: DocFieldMeta[],
199
+ doc: Record<string, unknown>,
200
+ ): boolean | undefined {
201
+ if (!breakField?.collapsible) return undefined;
202
+ let collapsed = true;
203
+ if (breakField.collapsible_depends_on) {
204
+ // Unparseable expression → expanded (never hide content silently).
205
+ collapsed = !evaluateDependsOn(breakField.collapsible_depends_on, doc, true);
206
+ }
207
+ if (collapsed && sectionHasMissingMandatory(sectionFields, doc)) collapsed = false;
208
+ return collapsed;
209
+ }
210
+
211
+ /** Desk section.has_missing_mandatory: `reqd && value == null` (section.js:146-156). */
212
+ function sectionHasMissingMandatory(fields: DocFieldMeta[], doc: Record<string, unknown>): boolean {
213
+ return fields.some((field) => !!field.reqd && doc[field.fieldname] == null);
214
+ }
215
+
216
+ /**
217
+ * Build the desk tab list for the current doc state. Recomputed whenever
218
+ * the doc changes so depends_on chains re-resolve; the work is O(fields).
219
+ */
220
+ export function buildFormTabs(options: BuildTabsOptions): FormTab[] {
221
+ const { fields, doc, detailsLabel, permLevels } = options;
222
+ const hiddenFieldSet = new Set(options.hiddenFields ?? []);
223
+ const hiddenSectionSet = new Set(options.hiddenSections ?? []);
224
+ const sectionHidden = (breakField: DocFieldMeta | undefined, label?: string): boolean => {
225
+ if (hiddenSectionSet.size === 0) return false;
226
+ if (breakField && hiddenSectionSet.has(breakField.fieldname)) return true;
227
+ const resolvedLabel = breakField?.label ?? label;
228
+ return !!resolvedLabel && hiddenSectionSet.has(resolvedLabel);
229
+ };
230
+ const layoutTabs = splitIntoTabs(fields);
231
+ const tabs: FormTab[] = [];
232
+
233
+ for (const layoutTab of layoutTabs) {
234
+ // Tab Break fields support depends_on + permlevel gating too (tab.js:38-43).
235
+ const tabBreak = fields.find(
236
+ (field) => field.fieldtype === "Tab Break" && field.fieldname === layoutTab.key,
237
+ );
238
+ if (tabBreak && !isFieldVisible(tabBreak, doc, permLevels)) continue;
239
+ // Host hiddenSections also match tabs (fieldname or label).
240
+ if (sectionHidden(tabBreak, layoutTab.label)) continue;
241
+
242
+ const sections: FormSection[] = [];
243
+ const flatFields: FieldSchema[] = [];
244
+
245
+ for (const section of layoutTab.sections) {
246
+ // Section Breaks are not permlevel-gated in desk (sections aren't
247
+ // controls; they only auto-hide when all their fields hide) — so the
248
+ // permlevel matrix is deliberately not passed here.
249
+ if (section.breakField && !isFieldVisible(section.breakField, doc)) continue;
250
+ if (sectionHidden(section.breakField)) continue;
251
+
252
+ const columns = section.columns
253
+ .map((column) => ({
254
+ fields: column.fields
255
+ .filter(
256
+ (field) =>
257
+ !hiddenFieldSet.has(field.fieldname) && isFieldVisible(field, doc, permLevels),
258
+ )
259
+ .map((field) => toFieldSchema(field, doc, options)),
260
+ }))
261
+ .filter((column) => column.fields.length > 0);
262
+ if (columns.length === 0) continue;
263
+
264
+ for (const column of columns) flatFields.push(...column.fields);
265
+ const sectionFields = section.columns.flatMap((column) => column.fields);
266
+ sections.push({
267
+ label: section.breakField?.label,
268
+ description: section.breakField?.description,
269
+ collapsible: !!section.breakField?.collapsible,
270
+ collapsed: resolveSectionCollapsed(section.breakField, sectionFields, doc),
271
+ hideBorder: !!section.breakField?.hide_border,
272
+ columns,
273
+ });
274
+ }
275
+
276
+ if (sections.length === 0) continue;
277
+ tabs.push({
278
+ key: layoutTab.key,
279
+ label: layoutTab.label || detailsLabel,
280
+ schema: { sections, fields: flatFields },
281
+ });
282
+ }
283
+
284
+ return tabs;
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Create-mode defaults
289
+ // ---------------------------------------------------------------------------
290
+
291
+ const LAYOUT_TYPES = new Set(["Section Break", "Column Break", "Tab Break", "Fold"]);
292
+ const NUMERIC_TYPES = new Set(["Int", "Float", "Currency", "Percent", "Check", "Rating"]);
293
+
294
+ const todayISO = () => formatCalendarDate(new Date());
295
+ const nowTime = () => formatCalendarDate(new Date(), "time");
296
+
297
+ /**
298
+ * Seed a create-mode doc from meta field defaults — desk parity with
299
+ * create_new.js get_default_value / set_default_values:
300
+ *
301
+ * - `Today` → today's date; `now` → time (Time) / local datetime (else)
302
+ * - `__user` / `user` (Link/Data only, avoiding Select option collisions)
303
+ * → session user
304
+ * - Select fields with no default seed their first option
305
+ * (create_new.js:107-114)
306
+ * - `:fieldname` boot sysdefaults, `frappe.*`, `user_fullname` stay unset —
307
+ * server-resolved on insert (documented gap)
308
+ */
309
+ export function buildCreateDefaults(
310
+ fields: DocFieldMeta[],
311
+ currentUser?: string,
312
+ ): Record<string, unknown> {
313
+ const doc: Record<string, unknown> = {};
314
+ for (const field of fields) {
315
+ if (LAYOUT_TYPES.has(field.fieldtype)) continue;
316
+ const raw = field.default;
317
+ if (raw === undefined || raw === null || raw === "") {
318
+ // Desk seeds the first Select option when no default resolves.
319
+ if (
320
+ field.fieldtype === "Select" &&
321
+ typeof field.options === "string" &&
322
+ field.options &&
323
+ !["[Select]", "Loading..."].includes(field.options)
324
+ ) {
325
+ doc[field.fieldname] = field.options.split("\n")[0];
326
+ }
327
+ continue;
328
+ }
329
+ if (raw === "Today") {
330
+ doc[field.fieldname] = todayISO();
331
+ continue;
332
+ }
333
+ if (raw.toLowerCase() === "now") {
334
+ doc[field.fieldname] = field.fieldtype === "Time" ? nowTime() : `${todayISO()} ${nowTime()}`;
335
+ continue;
336
+ }
337
+ if (
338
+ raw === "__user" ||
339
+ (raw.toLowerCase() === "user" && (field.fieldtype === "Link" || field.fieldtype === "Data"))
340
+ ) {
341
+ if (currentUser) doc[field.fieldname] = currentUser;
342
+ continue;
343
+ }
344
+ if (raw === "user_fullname") continue; // session fullname not mirrored client-side
345
+ if (raw.startsWith(":") || raw.startsWith("frappe.")) continue; // server-resolved
346
+ doc[field.fieldname] = NUMERIC_TYPES.has(field.fieldtype) ? Number(raw) || 0 : raw;
347
+ }
348
+ return doc;
349
+ }
350
+
351
+ /**
352
+ * Copyable field values for Duplicate / Amend: strips standard bookkeeping
353
+ * fields, `no_copy` fields, layout breaks, and empty values.
354
+ */
355
+ export function buildCopyValues(
356
+ fields: DocFieldMeta[],
357
+ doc: Record<string, unknown>,
358
+ ): Record<string, unknown> {
359
+ const copy: Record<string, unknown> = {};
360
+ for (const field of fields) {
361
+ if (LAYOUT_TYPES.has(field.fieldtype)) continue;
362
+ if (field.no_copy) continue;
363
+ if (field.fieldtype === "Attach" || field.fieldtype === "Attach Image") continue;
364
+ const value = doc[field.fieldname];
365
+ if (value === undefined || value === null || value === "") continue;
366
+ if (field.fieldtype === "Table" || field.fieldtype === "Table MultiSelect") {
367
+ if (Array.isArray(value)) {
368
+ copy[field.fieldname] = value.map((row) => {
369
+ const {
370
+ name: _name,
371
+ parent: _parent,
372
+ parentfield: _parentfield,
373
+ parenttype: _parenttype,
374
+ creation: _creation,
375
+ modified: _modified,
376
+ ...rest
377
+ } = (row ?? {}) as Record<string, unknown>;
378
+ return rest;
379
+ });
380
+ }
381
+ continue;
382
+ }
383
+ copy[field.fieldname] = value;
384
+ }
385
+ return copy;
386
+ }
387
+
388
+ /** True when the doctype names docs by user prompt (show a name input). */
389
+ export function isPromptNamed(autoname?: string, namingRule?: string): boolean {
390
+ if (namingRule === 'By "Prompt"') return true;
391
+ return (autoname ?? "").toLowerCase() === "prompt";
392
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The desk form engine.
3
+ *
4
+ * Frappe's desk resolves a form from meta at render time: tabs and sections
5
+ * from the layout breaks, visibility and read-only from `depends_on` chains,
6
+ * `permlevel`, `set_only_once`, `fetch_from`, `docstatus`, and a
7
+ * save/submit/cancel/amend lifecycle on top. All of that used to live in
8
+ * `@multiplatform.one/backoffice`, so an app author who imported the
9
+ * EXPORTED `FrappeForm` got a strictly weaker form than the desk rendered
10
+ * for the same doctype (MPO-15).
11
+ *
12
+ * It lives here now. `@multiplatform.one/backoffice` re-exports every name
13
+ * from its previous paths, so existing imports — barrel and
14
+ * `@multiplatform.one/backoffice/src/form/*` deep — keep resolving.
15
+ */
16
+
17
+ export type { DocFieldMeta, DocLinkMeta, DocPermissionRule, DoctypeMeta } from "./doctypeMeta";
18
+
19
+ export { buildPermLevelMatrix, canReadLevel, canWriteLevel } from "./fieldPermissions";
20
+ export type { LevelPerm, PermLevelMatrix } from "./fieldPermissions";
21
+
22
+ export { applyFetchedValues, buildFetchMap, isFetchLocked } from "./fetchFrom";
23
+ export type { FetchMap, FetchRule } from "./fetchFrom";
24
+
25
+ export { computeDirtyFields, isEmptyValue, isSameValue } from "./dirtyTracking";
26
+
27
+ export { setCreatePrefill, takeCreatePrefill } from "./createPrefill";
28
+
29
+ export { buildCopyValues, buildCreateDefaults, buildFormTabs, isPromptNamed } from "./formLayout";
30
+ export type { BuildTabsOptions, FormTab } from "./formLayout";
31
+
32
+ export { useFormEngine } from "./useFormEngine";
33
+ export type { FormEngine } from "./useFormEngine";
@@ -0,0 +1,148 @@
1
+ /**
2
+ * MPO-15 — the form-engine seam, from this side.
3
+ *
4
+ * The desk form engine used to live in `@multiplatform.one/backoffice`, one
5
+ * tier ABOVE this package, so `FrappeForm` could not reach it: an app author
6
+ * who imported the EXPORTED component got a strictly weaker form than the
7
+ * desk renders for the same doctype. The engine lives here now.
8
+ *
9
+ * Three things have to stay true for that to mean anything, and each one has
10
+ * broken silently in this repo before:
11
+ *
12
+ * 1. The engine is REACHABLE from the package barrel — a hoist that lands
13
+ * the files but leaves them unexported moves the problem, not the code.
14
+ * 2. Nothing under `public/frappe-ui/src` imports `@multiplatform.one/`
15
+ * `backoffice`. A lower tier importing a higher one is the defect the
16
+ * hoist exists to remove; re-introducing it here would be a cycle
17
+ * (backoffice already depends on this package).
18
+ * 3. The engine modules do not reach back up through the barrel either —
19
+ * `formLayout` takes its FieldSchema/FormSchema types from the sibling
20
+ * modules directly, so importing the engine never drags SchemaForm,
21
+ * FrappeTable and the whole table/native stack into a node-ish consumer.
22
+ */
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { describe, expect, it } from "vitest";
26
+ import * as formEngine from "./index";
27
+
28
+ const engineDir = path.resolve(__dirname);
29
+ const srcDir = path.resolve(__dirname, "..");
30
+
31
+ function sourceFiles(dir: string): string[] {
32
+ const out: string[] = [];
33
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
34
+ const full = path.join(dir, entry.name);
35
+ if (entry.isDirectory()) {
36
+ out.push(...sourceFiles(full));
37
+ continue;
38
+ }
39
+ if (!/\.(ts|tsx)$/.test(entry.name)) continue;
40
+ out.push(full);
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /** Every name the engine barrel publishes (values and types alike). */
46
+ const ENGINE_VALUES = [
47
+ "applyFetchedValues",
48
+ "buildCopyValues",
49
+ "buildCreateDefaults",
50
+ "buildFetchMap",
51
+ "buildFormTabs",
52
+ "buildPermLevelMatrix",
53
+ "canReadLevel",
54
+ "canWriteLevel",
55
+ "computeDirtyFields",
56
+ "isEmptyValue",
57
+ "isFetchLocked",
58
+ "isPromptNamed",
59
+ "isSameValue",
60
+ "setCreatePrefill",
61
+ "takeCreatePrefill",
62
+ "useFormEngine",
63
+ ] as const;
64
+
65
+ /** Types the engine barrel publishes alongside those values. */
66
+ const ENGINE_TYPES = [
67
+ "BuildTabsOptions",
68
+ "DocFieldMeta",
69
+ "DocLinkMeta",
70
+ "DocPermissionRule",
71
+ "DoctypeMeta",
72
+ "FetchMap",
73
+ "FetchRule",
74
+ "FormEngine",
75
+ "FormTab",
76
+ "LevelPerm",
77
+ "PermLevelMatrix",
78
+ ] as const;
79
+
80
+ describe("form-engine seam (MPO-15)", () => {
81
+ it("hands every engine value out of ./formEngine", () => {
82
+ for (const name of ENGINE_VALUES) {
83
+ expect(
84
+ (formEngine as Record<string, unknown>)[name],
85
+ `./formEngine must export "${name}"`,
86
+ ).toBeTypeOf("function");
87
+ }
88
+ });
89
+
90
+ it("re-exports the whole engine from the package barrel", () => {
91
+ // Source-level, not a runtime import of ../index: pulling the full barrel
92
+ // in here would drag FrappeTable -> the table/forms stacks into what is
93
+ // otherwise a node-ish spec, for no extra signal (tests/exports.spec.ts
94
+ // already exercises the barrel at runtime).
95
+ const barrel = fs.readFileSync(path.resolve(srcDir, "index.ts"), "utf-8");
96
+ const block = barrel.slice(barrel.indexOf("Desk Form Engine"));
97
+ expect(block, "the barrel must carry a form-engine block").toContain('from "./formEngine"');
98
+ for (const name of [...ENGINE_VALUES, ...ENGINE_TYPES]) {
99
+ expect(block, `the barrel must re-export "${name}"`).toContain(name);
100
+ }
101
+ });
102
+
103
+ it("keeps formActions off both barrels (deep-import only, colliding names)", () => {
104
+ // `addComment`, `addTag`, `getCount`, `sendEmail` and friends collide with
105
+ // the list and shell surfaces; the dedicated "./formActions" subpath
106
+ // export is how a consumer reaches them.
107
+ for (const name of ["addComment", "addTag", "getCount", "sendEmail", "renameDoc"]) {
108
+ expect((formEngine as Record<string, unknown>)[name], name).toBeUndefined();
109
+ }
110
+ const pkg = JSON.parse(fs.readFileSync(path.resolve(srcDir, "../package.json"), "utf-8")) as {
111
+ exports: Record<string, { source?: string }>;
112
+ };
113
+ expect(pkg.exports["./formActions"]?.source).toBe("./src/formEngine/formActions.ts");
114
+ });
115
+
116
+ it("never imports the package that sits above it", () => {
117
+ for (const file of sourceFiles(srcDir)) {
118
+ const source = fs.readFileSync(file, "utf-8");
119
+ expect(source, file).not.toMatch(/from ["']@multiplatform\.one\/backoffice/);
120
+ }
121
+ });
122
+
123
+ it("keeps the engine off its own package barrel (no import cycle)", () => {
124
+ for (const file of sourceFiles(engineDir)) {
125
+ if (file.includes(".spec.")) continue;
126
+ const source = fs.readFileSync(file, "utf-8");
127
+ expect(source, file).not.toMatch(/from ["']@multiplatform\.one\/frappe-ui/);
128
+ expect(source, file).not.toMatch(/from ["']\.\.\/index["']/);
129
+ }
130
+ });
131
+
132
+ it("keeps the meta shapes here, where the engine that consumes them lives", () => {
133
+ const meta = fs.readFileSync(path.resolve(engineDir, "doctypeMeta.ts"), "utf-8");
134
+ for (const name of ["DocFieldMeta", "DocLinkMeta", "DocPermissionRule", "DoctypeMeta"]) {
135
+ expect(meta, name).toContain(`export interface ${name}`);
136
+ }
137
+ // The shapes reach the engine by relative import, never through the barrel.
138
+ for (const file of [
139
+ "formLayout.ts",
140
+ "fetchFrom.ts",
141
+ "fieldPermissions.ts",
142
+ "useFormEngine.ts",
143
+ ]) {
144
+ const source = fs.readFileSync(path.resolve(engineDir, file), "utf-8");
145
+ expect(source, file).not.toMatch(/shared\/useDoctypeMeta/);
146
+ }
147
+ });
148
+ });