@multiplatform.one/frappe-ui 7.28.1 → 7.29.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 (325) hide show
  1. package/dist/cjs/FrappeCalendar.cjs +3 -2
  2. package/dist/cjs/FrappeCalendar.native.js +3 -2
  3. package/dist/cjs/FrappeCalendar.native.js.map +1 -1
  4. package/dist/cjs/FrappeForm.cjs +148 -464
  5. package/dist/cjs/FrappeForm.native.js +152 -633
  6. package/dist/cjs/FrappeForm.native.js.map +1 -1
  7. package/dist/cjs/FrappeListView.cjs +5 -27
  8. package/dist/cjs/FrappeListView.native.js +11 -32
  9. package/dist/cjs/FrappeListView.native.js.map +1 -1
  10. package/dist/cjs/FrappeReportBuilder.cjs +523 -309
  11. package/dist/cjs/FrappeReportBuilder.native.js +658 -353
  12. package/dist/cjs/FrappeReportBuilder.native.js.map +1 -1
  13. package/dist/cjs/FrappeReportView.cjs +35 -234
  14. package/dist/cjs/FrappeReportView.native.js +39 -283
  15. package/dist/cjs/FrappeReportView.native.js.map +1 -1
  16. package/dist/cjs/dependsOn.cjs +2 -2
  17. package/dist/cjs/dependsOn.native.js +1 -2
  18. package/dist/cjs/dependsOn.native.js.map +1 -1
  19. package/dist/cjs/fields/shared/EditorPreviewPanes.cjs +2 -2
  20. package/dist/cjs/fields/shared/EditorPreviewPanes.native.js +2 -2
  21. package/dist/cjs/fields/shared/EditorPreviewPanes.native.js.map +1 -1
  22. package/dist/cjs/formEngine/docPermissions.cjs +20 -0
  23. package/dist/cjs/formEngine/docPermissions.native.js +69 -0
  24. package/dist/cjs/formEngine/docPermissions.native.js.map +1 -1
  25. package/dist/cjs/formEngine/index.cjs +2 -0
  26. package/dist/cjs/formEngine/index.native.js +2 -0
  27. package/dist/cjs/formEngine/index.native.js.map +1 -1
  28. package/dist/cjs/index.cjs +67 -7
  29. package/dist/cjs/index.native.js +67 -7
  30. package/dist/cjs/index.native.js.map +1 -1
  31. package/dist/cjs/liveErrors.cjs +8 -2
  32. package/dist/cjs/liveErrors.native.js +8 -2
  33. package/dist/cjs/liveErrors.native.js.map +1 -1
  34. package/dist/cjs/measures.cjs +31 -0
  35. package/dist/cjs/measures.native.js +34 -0
  36. package/dist/cjs/measures.native.js.map +1 -0
  37. package/dist/cjs/recordShape.cjs +103 -0
  38. package/dist/cjs/recordShape.native.js +115 -0
  39. package/dist/cjs/recordShape.native.js.map +1 -0
  40. package/dist/cjs/report/ReportGrid.cjs +453 -0
  41. package/dist/cjs/report/ReportGrid.native.js +510 -0
  42. package/dist/cjs/report/ReportGrid.native.js.map +1 -0
  43. package/dist/cjs/report/ReportViewToolbar.cjs +253 -0
  44. package/dist/cjs/report/ReportViewToolbar.native.js +303 -0
  45. package/dist/cjs/report/ReportViewToolbar.native.js.map +1 -0
  46. package/dist/cjs/report/queryReport.cjs +444 -0
  47. package/dist/cjs/report/queryReport.native.js +588 -0
  48. package/dist/cjs/report/queryReport.native.js.map +1 -0
  49. package/dist/cjs/report/queryReportColumns.cjs +131 -0
  50. package/dist/cjs/report/queryReportColumns.native.js +159 -0
  51. package/dist/cjs/report/queryReportColumns.native.js.map +1 -0
  52. package/dist/cjs/report/reportViewModel.cjs +386 -0
  53. package/dist/cjs/report/reportViewModel.native.js +657 -0
  54. package/dist/cjs/report/reportViewModel.native.js.map +1 -0
  55. package/dist/cjs/report/savedReports.cjs +137 -0
  56. package/dist/cjs/report/savedReports.native.js +169 -0
  57. package/dist/cjs/report/savedReports.native.js.map +1 -0
  58. package/dist/cjs/report/useFrappeReport.cjs +184 -0
  59. package/dist/cjs/report/useFrappeReport.native.js +214 -0
  60. package/dist/cjs/report/useFrappeReport.native.js.map +1 -0
  61. package/dist/cjs/report/useQueryReport.cjs +201 -0
  62. package/dist/cjs/report/useQueryReport.native.js +233 -0
  63. package/dist/cjs/report/useQueryReport.native.js.map +1 -0
  64. package/dist/cjs/useFrappeForm.cjs +558 -0
  65. package/dist/cjs/useFrappeForm.native.js +714 -0
  66. package/dist/cjs/useFrappeForm.native.js.map +1 -0
  67. package/dist/esm/FrappeCalendar.mjs +4 -3
  68. package/dist/esm/FrappeCalendar.mjs.map +1 -1
  69. package/dist/esm/FrappeCalendar.native.js +4 -3
  70. package/dist/esm/FrappeCalendar.native.js.map +1 -1
  71. package/dist/esm/FrappeForm.mjs +151 -467
  72. package/dist/esm/FrappeForm.mjs.map +1 -1
  73. package/dist/esm/FrappeForm.native.js +155 -636
  74. package/dist/esm/FrappeForm.native.js.map +1 -1
  75. package/dist/esm/FrappeListView.mjs +6 -28
  76. package/dist/esm/FrappeListView.mjs.map +1 -1
  77. package/dist/esm/FrappeListView.native.js +12 -33
  78. package/dist/esm/FrappeListView.native.js.map +1 -1
  79. package/dist/esm/FrappeReportBuilder.mjs +526 -312
  80. package/dist/esm/FrappeReportBuilder.mjs.map +1 -1
  81. package/dist/esm/FrappeReportBuilder.native.js +661 -356
  82. package/dist/esm/FrappeReportBuilder.native.js.map +1 -1
  83. package/dist/esm/FrappeReportView.mjs +30 -229
  84. package/dist/esm/FrappeReportView.mjs.map +1 -1
  85. package/dist/esm/FrappeReportView.native.js +34 -278
  86. package/dist/esm/FrappeReportView.native.js.map +1 -1
  87. package/dist/esm/dependsOn.mjs +2 -2
  88. package/dist/esm/dependsOn.mjs.map +1 -1
  89. package/dist/esm/dependsOn.native.js +1 -2
  90. package/dist/esm/dependsOn.native.js.map +1 -1
  91. package/dist/esm/fields/shared/EditorPreviewPanes.mjs +2 -2
  92. package/dist/esm/fields/shared/EditorPreviewPanes.mjs.map +1 -1
  93. package/dist/esm/fields/shared/EditorPreviewPanes.native.js +2 -2
  94. package/dist/esm/fields/shared/EditorPreviewPanes.native.js.map +1 -1
  95. package/dist/esm/formEngine/docPermissions.mjs +19 -1
  96. package/dist/esm/formEngine/docPermissions.mjs.map +1 -1
  97. package/dist/esm/formEngine/docPermissions.native.js +68 -1
  98. package/dist/esm/formEngine/docPermissions.native.js.map +1 -1
  99. package/dist/esm/formEngine/index.mjs +2 -2
  100. package/dist/esm/formEngine/index.mjs.map +1 -1
  101. package/dist/esm/formEngine/index.native.js +2 -2
  102. package/dist/esm/formEngine/index.native.js.map +1 -1
  103. package/dist/esm/index.mjs +16 -4
  104. package/dist/esm/index.mjs.map +1 -1
  105. package/dist/esm/index.native.js +16 -4
  106. package/dist/esm/index.native.js.map +1 -1
  107. package/dist/esm/liveErrors.mjs +8 -3
  108. package/dist/esm/liveErrors.mjs.map +1 -1
  109. package/dist/esm/liveErrors.native.js +8 -3
  110. package/dist/esm/liveErrors.native.js.map +1 -1
  111. package/dist/esm/measures.mjs +5 -0
  112. package/dist/esm/measures.mjs.map +1 -0
  113. package/dist/esm/measures.native.js +5 -0
  114. package/dist/esm/measures.native.js.map +1 -0
  115. package/dist/esm/recordShape.mjs +74 -0
  116. package/dist/esm/recordShape.mjs.map +1 -0
  117. package/dist/esm/recordShape.native.js +83 -0
  118. package/dist/esm/recordShape.native.js.map +1 -0
  119. package/dist/esm/report/ReportGrid.mjs +428 -0
  120. package/dist/esm/report/ReportGrid.mjs.map +1 -0
  121. package/dist/esm/report/ReportGrid.native.js +471 -0
  122. package/dist/esm/report/ReportGrid.native.js.map +1 -0
  123. package/dist/esm/report/ReportViewToolbar.mjs +228 -0
  124. package/dist/esm/report/ReportViewToolbar.mjs.map +1 -0
  125. package/dist/esm/report/ReportViewToolbar.native.js +264 -0
  126. package/dist/esm/report/ReportViewToolbar.native.js.map +1 -0
  127. package/dist/esm/report/queryReport.mjs +404 -0
  128. package/dist/esm/report/queryReport.mjs.map +1 -0
  129. package/dist/esm/report/queryReport.native.js +545 -0
  130. package/dist/esm/report/queryReport.native.js.map +1 -0
  131. package/dist/esm/report/queryReportColumns.mjs +105 -0
  132. package/dist/esm/report/queryReportColumns.mjs.map +1 -0
  133. package/dist/esm/report/queryReportColumns.native.js +119 -0
  134. package/dist/esm/report/queryReportColumns.native.js.map +1 -0
  135. package/dist/esm/report/reportViewModel.mjs +343 -0
  136. package/dist/esm/report/reportViewModel.mjs.map +1 -0
  137. package/dist/esm/report/reportViewModel.native.js +611 -0
  138. package/dist/esm/report/reportViewModel.native.js.map +1 -0
  139. package/dist/esm/report/savedReports.mjs +112 -0
  140. package/dist/esm/report/savedReports.mjs.map +1 -0
  141. package/dist/esm/report/savedReports.native.js +141 -0
  142. package/dist/esm/report/savedReports.native.js.map +1 -0
  143. package/dist/esm/report/useFrappeReport.mjs +158 -0
  144. package/dist/esm/report/useFrappeReport.mjs.map +1 -0
  145. package/dist/esm/report/useFrappeReport.native.js +185 -0
  146. package/dist/esm/report/useFrappeReport.native.js.map +1 -0
  147. package/dist/esm/report/useQueryReport.mjs +176 -0
  148. package/dist/esm/report/useQueryReport.mjs.map +1 -0
  149. package/dist/esm/report/useQueryReport.native.js +205 -0
  150. package/dist/esm/report/useQueryReport.native.js.map +1 -0
  151. package/dist/esm/useFrappeForm.mjs +533 -0
  152. package/dist/esm/useFrappeForm.mjs.map +1 -0
  153. package/dist/esm/useFrappeForm.native.js +686 -0
  154. package/dist/esm/useFrappeForm.native.js.map +1 -0
  155. package/dist/jsx/FrappeCalendar.mjs +4 -3
  156. package/dist/jsx/FrappeCalendar.mjs.map +1 -1
  157. package/dist/jsx/FrappeCalendar.native.js +3 -2
  158. package/dist/jsx/FrappeCalendar.native.js.map +1 -1
  159. package/dist/jsx/FrappeForm.mjs +151 -467
  160. package/dist/jsx/FrappeForm.mjs.map +1 -1
  161. package/dist/jsx/FrappeForm.native.js +152 -633
  162. package/dist/jsx/FrappeForm.native.js.map +1 -1
  163. package/dist/jsx/FrappeListView.mjs +6 -28
  164. package/dist/jsx/FrappeListView.mjs.map +1 -1
  165. package/dist/jsx/FrappeListView.native.js +11 -32
  166. package/dist/jsx/FrappeListView.native.js.map +1 -1
  167. package/dist/jsx/FrappeReportBuilder.mjs +526 -312
  168. package/dist/jsx/FrappeReportBuilder.mjs.map +1 -1
  169. package/dist/jsx/FrappeReportBuilder.native.js +658 -353
  170. package/dist/jsx/FrappeReportBuilder.native.js.map +1 -1
  171. package/dist/jsx/FrappeReportView.mjs +30 -229
  172. package/dist/jsx/FrappeReportView.mjs.map +1 -1
  173. package/dist/jsx/FrappeReportView.native.js +39 -283
  174. package/dist/jsx/FrappeReportView.native.js.map +1 -1
  175. package/dist/jsx/dependsOn.mjs +2 -2
  176. package/dist/jsx/dependsOn.mjs.map +1 -1
  177. package/dist/jsx/dependsOn.native.js +1 -2
  178. package/dist/jsx/dependsOn.native.js.map +1 -1
  179. package/dist/jsx/fields/shared/EditorPreviewPanes.mjs +2 -2
  180. package/dist/jsx/fields/shared/EditorPreviewPanes.mjs.map +1 -1
  181. package/dist/jsx/fields/shared/EditorPreviewPanes.native.js +2 -2
  182. package/dist/jsx/fields/shared/EditorPreviewPanes.native.js.map +1 -1
  183. package/dist/jsx/formEngine/docPermissions.mjs +19 -1
  184. package/dist/jsx/formEngine/docPermissions.mjs.map +1 -1
  185. package/dist/jsx/formEngine/docPermissions.native.js +69 -0
  186. package/dist/jsx/formEngine/docPermissions.native.js.map +1 -1
  187. package/dist/jsx/formEngine/index.mjs +2 -2
  188. package/dist/jsx/formEngine/index.mjs.map +1 -1
  189. package/dist/jsx/formEngine/index.native.js +2 -0
  190. package/dist/jsx/formEngine/index.native.js.map +1 -1
  191. package/dist/jsx/index.js +16 -4
  192. package/dist/jsx/index.js.map +1 -1
  193. package/dist/jsx/index.mjs +16 -4
  194. package/dist/jsx/index.mjs.map +1 -1
  195. package/dist/jsx/index.native.js +67 -7
  196. package/dist/jsx/index.native.js.map +1 -1
  197. package/dist/jsx/liveErrors.mjs +8 -3
  198. package/dist/jsx/liveErrors.mjs.map +1 -1
  199. package/dist/jsx/liveErrors.native.js +8 -2
  200. package/dist/jsx/liveErrors.native.js.map +1 -1
  201. package/dist/jsx/measures.mjs +5 -0
  202. package/dist/jsx/measures.mjs.map +1 -0
  203. package/dist/jsx/measures.native.js +34 -0
  204. package/dist/jsx/measures.native.js.map +1 -0
  205. package/dist/jsx/recordShape.mjs +74 -0
  206. package/dist/jsx/recordShape.mjs.map +1 -0
  207. package/dist/jsx/recordShape.native.js +115 -0
  208. package/dist/jsx/recordShape.native.js.map +1 -0
  209. package/dist/jsx/report/ReportGrid.mjs +428 -0
  210. package/dist/jsx/report/ReportGrid.mjs.map +1 -0
  211. package/dist/jsx/report/ReportGrid.native.js +510 -0
  212. package/dist/jsx/report/ReportGrid.native.js.map +1 -0
  213. package/dist/jsx/report/ReportViewToolbar.mjs +228 -0
  214. package/dist/jsx/report/ReportViewToolbar.mjs.map +1 -0
  215. package/dist/jsx/report/ReportViewToolbar.native.js +303 -0
  216. package/dist/jsx/report/ReportViewToolbar.native.js.map +1 -0
  217. package/dist/jsx/report/queryReport.mjs +404 -0
  218. package/dist/jsx/report/queryReport.mjs.map +1 -0
  219. package/dist/jsx/report/queryReport.native.js +588 -0
  220. package/dist/jsx/report/queryReport.native.js.map +1 -0
  221. package/dist/jsx/report/queryReportColumns.mjs +105 -0
  222. package/dist/jsx/report/queryReportColumns.mjs.map +1 -0
  223. package/dist/jsx/report/queryReportColumns.native.js +159 -0
  224. package/dist/jsx/report/queryReportColumns.native.js.map +1 -0
  225. package/dist/jsx/report/reportViewModel.mjs +343 -0
  226. package/dist/jsx/report/reportViewModel.mjs.map +1 -0
  227. package/dist/jsx/report/reportViewModel.native.js +657 -0
  228. package/dist/jsx/report/reportViewModel.native.js.map +1 -0
  229. package/dist/jsx/report/savedReports.mjs +112 -0
  230. package/dist/jsx/report/savedReports.mjs.map +1 -0
  231. package/dist/jsx/report/savedReports.native.js +169 -0
  232. package/dist/jsx/report/savedReports.native.js.map +1 -0
  233. package/dist/jsx/report/useFrappeReport.mjs +158 -0
  234. package/dist/jsx/report/useFrappeReport.mjs.map +1 -0
  235. package/dist/jsx/report/useFrappeReport.native.js +214 -0
  236. package/dist/jsx/report/useFrappeReport.native.js.map +1 -0
  237. package/dist/jsx/report/useQueryReport.mjs +176 -0
  238. package/dist/jsx/report/useQueryReport.mjs.map +1 -0
  239. package/dist/jsx/report/useQueryReport.native.js +233 -0
  240. package/dist/jsx/report/useQueryReport.native.js.map +1 -0
  241. package/dist/jsx/useFrappeForm.mjs +533 -0
  242. package/dist/jsx/useFrappeForm.mjs.map +1 -0
  243. package/dist/jsx/useFrappeForm.native.js +714 -0
  244. package/dist/jsx/useFrappeForm.native.js.map +1 -0
  245. package/package.json +16 -16
  246. package/src/FrappeCalendar.tsx +8 -2
  247. package/src/FrappeForm.stories.tsx +52 -0
  248. package/src/FrappeForm.tsx +176 -565
  249. package/src/FrappeListView.cards.stories.tsx +3 -6
  250. package/src/FrappeListView.tsx +15 -55
  251. package/src/FrappeReportBuilder.stories.tsx +34 -7
  252. package/src/FrappeReportBuilder.tsx +790 -380
  253. package/src/FrappeReportView.spec.tsx +10 -6
  254. package/src/FrappeReportView.stories.tsx +20 -1
  255. package/src/FrappeReportView.tsx +89 -415
  256. package/src/dependsOn.ts +4 -1
  257. package/src/fields/shared/EditorPreviewPanes.tsx +2 -2
  258. package/src/formEngine/docPermissions.spec.ts +36 -1
  259. package/src/formEngine/docPermissions.ts +34 -0
  260. package/src/formEngine/index.ts +8 -1
  261. package/src/frappeRemainder.designLaw.spec.ts +9 -1
  262. package/src/frappeRoutes.spec.tsx +9 -0
  263. package/src/frappeViews.designLaw.spec.ts +15 -2
  264. package/src/guestFallback.spec.tsx +236 -0
  265. package/src/index.ts +117 -16
  266. package/src/liveErrors.spec.ts +10 -0
  267. package/src/liveErrors.ts +16 -3
  268. package/src/measures.ts +11 -0
  269. package/src/recordShape.spec.ts +100 -0
  270. package/src/recordShape.ts +133 -0
  271. package/src/report/ReportGrid.spec.tsx +175 -0
  272. package/src/report/ReportGrid.tsx +530 -0
  273. package/src/report/ReportViewToolbar.spec.tsx +90 -0
  274. package/src/report/ReportViewToolbar.tsx +314 -0
  275. package/src/report/queryReport.spec.ts +483 -0
  276. package/src/report/queryReport.ts +722 -0
  277. package/src/report/queryReportColumns.tsx +123 -0
  278. package/src/report/reportViewModel.spec.ts +393 -0
  279. package/src/report/reportViewModel.ts +617 -0
  280. package/src/report/savedReports.ts +172 -0
  281. package/src/report/seam.spec.ts +216 -0
  282. package/src/report/useFrappeReport.ts +241 -0
  283. package/src/report/useQueryReport.spec.tsx +131 -0
  284. package/src/report/useQueryReport.ts +309 -0
  285. package/src/useFrappeForm.spec.tsx +209 -0
  286. package/src/useFrappeForm.ts +778 -0
  287. package/types/FrappeCalendar.d.ts.map +1 -1
  288. package/types/FrappeForm.d.ts +21 -1
  289. package/types/FrappeForm.d.ts.map +1 -1
  290. package/types/FrappeListView.d.ts.map +1 -1
  291. package/types/FrappeReportBuilder.d.ts +66 -16
  292. package/types/FrappeReportBuilder.d.ts.map +1 -1
  293. package/types/FrappeReportView.d.ts +9 -127
  294. package/types/FrappeReportView.d.ts.map +1 -1
  295. package/types/dependsOn.d.ts.map +1 -1
  296. package/types/formEngine/docPermissions.d.ts +14 -0
  297. package/types/formEngine/docPermissions.d.ts.map +1 -1
  298. package/types/formEngine/index.d.ts +1 -1
  299. package/types/formEngine/index.d.ts.map +1 -1
  300. package/types/index.d.ts +15 -3
  301. package/types/index.d.ts.map +1 -1
  302. package/types/liveErrors.d.ts +9 -1
  303. package/types/liveErrors.d.ts.map +1 -1
  304. package/types/measures.d.ts +3 -0
  305. package/types/measures.d.ts.map +1 -0
  306. package/types/recordShape.d.ts +45 -0
  307. package/types/recordShape.d.ts.map +1 -0
  308. package/types/report/ReportGrid.d.ts +20 -0
  309. package/types/report/ReportGrid.d.ts.map +1 -0
  310. package/types/report/ReportViewToolbar.d.ts +31 -0
  311. package/types/report/ReportViewToolbar.d.ts.map +1 -0
  312. package/types/report/queryReport.d.ts +181 -0
  313. package/types/report/queryReport.d.ts.map +1 -0
  314. package/types/report/queryReportColumns.d.ts +12 -0
  315. package/types/report/queryReportColumns.d.ts.map +1 -0
  316. package/types/report/reportViewModel.d.ts +146 -0
  317. package/types/report/reportViewModel.d.ts.map +1 -0
  318. package/types/report/savedReports.d.ts +27 -0
  319. package/types/report/savedReports.d.ts.map +1 -0
  320. package/types/report/useFrappeReport.d.ts +60 -0
  321. package/types/report/useFrappeReport.d.ts.map +1 -0
  322. package/types/report/useQueryReport.d.ts +37 -0
  323. package/types/report/useQueryReport.d.ts.map +1 -0
  324. package/types/useFrappeForm.d.ts +142 -0
  325. package/types/useFrappeForm.d.ts.map +1 -0
@@ -1,11 +1,7 @@
1
1
  import {
2
- getFrappeSyncModule,
3
2
  useFrappeCollection,
4
- useFrappeDoctypeEvents,
5
3
  useLiveQuery,
6
- useDocTypeMeta,
7
4
  type DocTypeMeta,
8
- type DocTypeField,
9
5
  type FormSection,
10
6
  } from "@multiplatform.one/frappe";
11
7
  import {
@@ -17,13 +13,12 @@ import {
17
13
  Tabs,
18
14
  type TabsItem,
19
15
  } from "@multiplatform.one/components";
20
- import { SchemaForm, type FieldOverride } from "./schemaForm";
16
+ import { SchemaForm, type FieldOverride, type SchemaFormProps } from "./schemaForm";
21
17
  import type { HeadingLevel } from "./fields/Heading";
22
18
  import { FormSidebar, type SidebarSection, type SidebarMetadataEntry } from "./FormSidebar";
23
- import { eq } from "@tanstack/db";
24
19
  import { ArrowLeftIcon } from "@phosphor-icons/react";
25
20
  import type React from "react";
26
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
21
+ import { useEffect, useMemo, useState } from "react";
27
22
  import type { TFunction } from "i18next";
28
23
  import { useTranslation } from "react-i18next";
29
24
  import { Button, Input, formCommonColors } from "@multiplatform.one/forms";
@@ -33,27 +28,21 @@ import { useFrappeConfig } from "./FrappeProvider";
33
28
  import { FrappeReconnectIndicator } from "./FrappeReconnectIndicator";
34
29
  import { Timeline } from "./components/timeline";
35
30
  import { dialog } from "@multiplatform.one/desktop";
36
- import { applyFetchedValues, buildFetchMap } from "./formEngine/fetchFrom";
37
- import { buildPermLevelMatrix } from "./formEngine/fieldPermissions";
38
- import { createHttpClient, validateLink } from "./formEngine/formActions";
39
- import { buildFormTabs, isPromptNamed, type FormTab } from "./formEngine/formLayout";
40
- import { setCreatePrefill, takeCreatePrefill } from "./formEngine/createPrefill";
31
+ import type { FormTab } from "./formEngine/formLayout";
41
32
  import {
42
- buildAmendValues,
43
33
  canDeleteDoc,
44
34
  canEditDoc,
45
35
  isAmendable,
46
36
  resolveDocAction,
47
37
  resolveDocIndicator,
48
38
  } from "./formEngine/docLifecycle";
49
- import { useAmendedAs } from "./formEngine/useAmendedAs";
50
- import { useDocRights } from "./formEngine/useDocRights";
51
- import { extractServerError } from "./formEngine/useFormEngine";
52
39
  import { FrappeFormActions } from "./FrappeFormActions";
53
40
  import { connectionFields, connectionsFromMeta, type FrappeConnection } from "./frappeConnections";
54
41
  import { FrappeFormConnections } from "./FrappeFormConnections";
55
42
  import { FrappeNotConfigured } from "./FrappeNotConfigured";
56
43
  import { FrappeRowsReader, type FrappeRowsSnapshot } from "./FrappeRowsReader";
44
+ import { isPermissionError } from "./liveErrors";
45
+ import { fieldsFromRecord } from "./recordShape";
57
46
  import {
58
47
  frappeRouteHref,
59
48
  frappeRouteTarget,
@@ -61,17 +50,11 @@ import {
61
50
  type FrappeRouteTarget,
62
51
  } from "./frappeNavigation";
63
52
  import type { FieldSchema } from "./fieldRegistry";
53
+ import { useFrappeForm, type FrappeFormController } from "./useFrappeForm";
64
54
 
65
55
  /** Linked rows per connection doctype, as the render prop receives them. */
66
56
  export type FrappeConnectionRows = Record<string, Array<Record<string, unknown>>>;
67
57
 
68
- function isEmptyFieldValue(value: unknown): boolean {
69
- if (value === undefined || value === null) return true;
70
- if (typeof value === "string" && value.trim() === "") return true;
71
- if (Array.isArray(value) && value.length === 0) return true;
72
- return false;
73
- }
74
-
75
58
  export interface FrappeFormProps<TDoc extends { name: string; doctype: string }> {
76
59
  baseURL?: string;
77
60
  auth?: { useToken?: boolean; token?: string; type?: string };
@@ -172,75 +155,36 @@ export interface FrappeFormProps<TDoc extends { name: string; doctype: string }>
172
155
  * via SchemaForm — sections sit one step below the page h1.
173
156
  */
174
157
  sectionHeadingLevel?: HeadingLevel;
158
+ /**
159
+ * The form's state, owned by the caller (`useFrappeForm`). Given, the form
160
+ * renders from it instead of holding its own, so a page can build its
161
+ * chrome on the same controller: turn the form's toolbar, sidebar,
162
+ * timeline and banners off and render them from `form` instead.
163
+ */
164
+ form?: FrappeFormController<TDoc>;
165
+ /** Section or Tab Breaks to hide, by fieldname or label. */
166
+ hideSections?: string[];
167
+ /** False leaves the deleted and changed-elsewhere banners to the host. */
168
+ showBanners?: boolean;
169
+ /** Host tabs after the form's own (and its Connections tab). */
170
+ extraTabs?: TabsItem[];
171
+ /** Wraps each rendered field, e.g. in an error boundary. */
172
+ renderFieldWrapper?: SchemaFormProps["renderFieldWrapper"];
173
+ /** Wraps each laid-out tab's body. */
174
+ renderTab?: (tab: FormTab, content: React.ReactNode) => React.ReactNode;
175
175
  }
176
176
 
177
- // ---------------------------------------------------------------------------
178
- // Layout source for the form engine
179
- // ---------------------------------------------------------------------------
180
-
181
- /**
182
- * A `sections` override re-expressed as the flat break-delimited field list
183
- * the form engine lays out, so an overridden layout resolves every field
184
- * exactly like the meta one.
185
- */
186
- function sectionsToLayoutFields(sections: FormSection[]): DocTypeField[] {
187
- const fields: DocTypeField[] = [];
188
- sections.forEach((section, sectionIndex) => {
189
- fields.push({
190
- fieldname: `__section_${sectionIndex}`,
191
- fieldtype: section.tab ? "Tab Break" : "Section Break",
192
- label: section.label,
193
- collapsible: section.collapsible ? 1 : 0,
194
- });
195
- section.columns.forEach((column, columnIndex) => {
196
- if (columnIndex > 0) {
197
- fields.push({
198
- fieldname: `__column_${sectionIndex}_${columnIndex}`,
199
- fieldtype: "Column Break",
200
- });
201
- }
202
- fields.push(...column.fields);
203
- });
204
- });
205
- return fields;
206
- }
207
-
208
- // Document bookkeeping, never shown as a field of the record.
209
- const documentBookkeeping = new Set([
210
- "name",
211
- "owner",
212
- "creation",
213
- "modified",
214
- "modified_by",
215
- "docstatus",
216
- "idx",
217
- "doctype",
218
- "_user_tags",
219
- "_comments",
220
- "_assign",
221
- "_liked_by",
222
- "_seen",
223
- ]);
224
-
225
177
  /**
226
- * A record's own scalar fields as read-only schema fields, for a reader without
227
- * meta. `$`-keys are the collection's virtual props ($origin, $key,
228
- * $collectionId), which the row carries but the record does not.
178
+ * A record's own fields as read-only schema fields, for a reader without meta
179
+ * (LC-96).
229
180
  */
230
- function fieldsFromDocument(doc: Record<string, unknown>): FieldSchema[] {
231
- return Object.entries(doc)
232
- .filter(
233
- ([key, value]) =>
234
- !key.startsWith("$") &&
235
- !documentBookkeeping.has(key) &&
236
- (value === null || typeof value === "string" || typeof value === "number"),
237
- )
238
- .map(([key, value]) => ({
239
- name: key,
240
- label: key.replace(/_/g, " ").replace(/(^|\s)\S/g, (c) => c.toUpperCase()),
241
- type: typeof value === "number" ? (Number.isInteger(value) ? "Int" : "Float") : "Data",
242
- readOnly: true,
243
- }));
181
+ function schemaFieldsFromRecord(doc: Record<string, unknown>): FieldSchema[] {
182
+ return fieldsFromRecord(doc).map((field) => ({
183
+ name: field.fieldname,
184
+ label: field.label,
185
+ type: field.fieldtype,
186
+ readOnly: true,
187
+ }));
244
188
  }
245
189
 
246
190
  // ---------------------------------------------------------------------------
@@ -447,20 +391,46 @@ function FrappeFormRoute<TDoc extends { name: string; doctype: string }>({
447
391
  );
448
392
  }
449
393
 
450
- function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
451
- baseURL,
452
- auth,
453
- syncOptions,
394
+ function FrappeFormBody<TDoc extends { name: string; doctype: string }>(
395
+ props: FrappeFormBodyProps<TDoc>,
396
+ ) {
397
+ if (props.form) return <FrappeFormView<TDoc> {...props} form={props.form} />;
398
+ return <FrappeFormOwned<TDoc> {...props} />;
399
+ }
400
+
401
+ function FrappeFormOwned<TDoc extends { name: string; doctype: string }>(
402
+ props: FrappeFormBodyProps<TDoc>,
403
+ ) {
404
+ const config = useFrappeConfig();
405
+ if (!(props.baseURL ?? config?.baseURL)) {
406
+ throw new Error("FrappeForm requires baseURL prop or FrappeProvider with baseURL");
407
+ }
408
+ const form = useFrappeForm<TDoc>({
409
+ doctype: props.doctype,
410
+ name: props.name,
411
+ baseURL: props.baseURL,
412
+ auth: props.auth,
413
+ syncOptions: props.syncOptions,
414
+ fields: props.fields,
415
+ sections: props.sections,
416
+ hideFields: props.hideFields,
417
+ hideSections: props.hideSections,
418
+ roles: props.roles,
419
+ readShares: props.showSidebar ?? true,
420
+ disabled: props.disabled,
421
+ initialDoc: props.initialDoc,
422
+ });
423
+ return <FrappeFormView<TDoc> {...props} form={form} />;
424
+ }
425
+
426
+ function FrappeFormView<TDoc extends { name: string; doctype: string }>({
427
+ form,
454
428
  doctype,
455
429
  name,
456
- fields,
457
430
  children,
458
431
  overrides,
459
- hideFields,
460
432
  readOnlyFields,
461
- sections,
462
433
  order,
463
- roles,
464
434
  onSave,
465
435
  onCancel,
466
436
  onDelete,
@@ -469,99 +439,53 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
469
439
  showToolbar = true,
470
440
  showSidebar = true,
471
441
  showTimeline = true,
442
+ showBanners = true,
472
443
  disabled = false,
473
444
  sectionHeadingLevel,
474
445
  connections,
475
446
  showConnections = true,
476
- initialDoc,
447
+ extraTabs,
448
+ renderFieldWrapper,
449
+ renderTab,
477
450
  loadingFallback,
478
451
  notFoundFallback,
479
452
  errorFallback,
480
453
  routePage,
481
- }: FrappeFormBodyProps<TDoc>) {
482
- const config = useFrappeConfig();
454
+ }: FrappeFormBodyProps<TDoc> & { form: FrappeFormController<TDoc> }) {
483
455
  const { knobProps } = useResolvedKnobs();
484
456
  const { t } = useTranslation();
485
- const seedDoc =
486
- name && initialDoc && (initialDoc as { name?: string }).name === name ? initialDoc : undefined;
487
- const seedRef = useRef(seedDoc);
488
- seedRef.current = seedDoc;
489
- const [localDoc, setLocalDoc] = useState<Partial<TDoc>>(() => seedDoc ?? {});
490
- /** Last authoritative server snapshot applied to the form (dirty = localDoc ≠ baseline). */
491
- const [baselineDoc, setBaselineDoc] = useState<Partial<TDoc> | undefined>(() => seedDoc);
492
- const [docError, setDocError] = useState<Error | null>(null);
493
- /** Fields the user edited that ALSO changed on the server since baseline. */
494
- const [conflictFields, setConflictFields] = useState<string[]>([]);
495
- /** The open document was deleted on the server (doc_deleted / tombstone / eviction). */
496
- const [wasDeleted, setWasDeleted] = useState(false);
497
- const [isSubmitting, setIsSubmitting] = useState(false);
498
- const [errors, setErrors] = useState<Record<string, string>>({});
499
- const [activeTab, setActiveTab] = useState<string | undefined>(undefined);
500
- const everLoadedRef = useRef(false);
501
- const localDocRef = useRef(localDoc);
502
- localDocRef.current = localDoc;
503
- const baselineRef = useRef(baselineDoc);
504
- baselineRef.current = baselineDoc;
505
- // Taken once per create session: effects re-run (StrictMode) must not
506
- // lose a draft the one-shot stash already handed over.
507
- const createDraftRef = useRef<{ doctype: string; values?: Record<string, unknown> }>(undefined);
508
-
509
- const finalBaseURL = baseURL ?? config?.baseURL;
510
- const finalAuth = auth ?? config?.auth;
511
- const finalSyncOptions = syncOptions ?? config?.syncOptions;
512
-
513
- if (!finalBaseURL) {
514
- throw new Error("FrappeForm requires baseURL prop or FrappeProvider with baseURL");
515
- }
516
-
517
- const frappeConfig = useMemo(
518
- () => ({
519
- baseURL: finalBaseURL,
520
- socketPort: config?.socketPort,
521
- siteName: config?.siteName,
522
- socketBaseURL: config?.socketBaseURL,
523
- auth: finalAuth,
524
- syncOptions: finalSyncOptions,
525
- realtime: config?.realtime,
526
- fixtures: config?.fixtures,
527
- }),
528
- [
529
- finalBaseURL,
530
- config?.socketPort,
531
- config?.siteName,
532
- config?.socketBaseURL,
533
- config?.realtime,
534
- finalAuth,
535
- finalSyncOptions,
536
- config?.fixtures,
537
- ],
538
- );
539
-
540
- // Fetch DocType metadata
541
457
  const {
542
458
  meta,
543
- isLoading: isMetaLoading,
544
- error: metaError,
545
- } = useDocTypeMeta(frappeConfig, doctype);
546
-
547
- const isSubmittable = !!meta?.is_submittable;
459
+ metaLoading: isMetaLoading,
460
+ metaError,
461
+ doc: localDoc,
462
+ baseline: baselineDoc,
463
+ setValue,
464
+ errors,
465
+ isSubmitting,
466
+ docstatus,
467
+ isSubmittable,
468
+ rights,
469
+ rightsPending,
470
+ canWrite: canWriteRecord,
471
+ wasDeleted,
472
+ conflictFields,
473
+ docError,
474
+ tabs: formTabs,
475
+ promptNamed,
476
+ activeTab,
477
+ setActiveTab,
478
+ amendedAs,
479
+ } = form;
480
+ const frappeConfig = form.connection;
548
481
  const isCreate = !name;
549
- const docstatus = Number((baselineDoc as Record<string, unknown> | undefined)?.docstatus ?? 0);
550
- const layoutFields = useMemo(() => {
551
- if (!meta) return undefined;
552
- return sections ? sectionsToLayoutFields(sections) : meta.fields;
553
- }, [meta, sections]);
554
- const amendedAs = useAmendedAs(
555
- isSubmittable && docstatus === 2 ? frappeConfig : undefined,
556
- doctype,
557
- name,
558
- );
482
+ const hasUnsavedChanges = form.isDirty;
559
483
  // Meta is permission-gated (Guest gets 403) while the record may still be
560
484
  // readable: the form then lays the record's own fields out, read-only.
561
485
  const readsWithoutMeta = !children && !meta && Boolean(metaError) && Boolean(name);
562
486
  const recordTabs = useMemo((): FormTab[] | undefined => {
563
487
  if (!readsWithoutMeta || !baselineDoc) return undefined;
564
- const recordFields = fieldsFromDocument(baselineDoc as Record<string, unknown>);
488
+ const recordFields = schemaFieldsFromRecord(baselineDoc as Record<string, unknown>);
565
489
  return [
566
490
  {
567
491
  key: "__details",
@@ -570,89 +494,6 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
570
494
  },
571
495
  ];
572
496
  }, [readsWithoutMeta, baselineDoc, t]);
573
- const promptNamed = isCreate && !!meta && isPromptNamed(meta.autoname, meta.naming_rule);
574
- const fetchMap = useMemo(() => (meta ? buildFetchMap(meta.fields) : undefined), [meta]);
575
-
576
- const handleDocSyncError = useCallback((err: unknown) => {
577
- setDocError(err instanceof Error ? err : new Error(String(err)));
578
- }, []);
579
- const docCollection = useFrappeCollection<TDoc>(name ? frappeConfig : undefined, doctype, {
580
- fields,
581
- filters: name ? { name } : undefined,
582
- onSyncError: handleDocSyncError,
583
- });
584
- const { data: rawDocData, isLoading: isDocLoading } = useLiveQuery(
585
- (q) => {
586
- if (!docCollection || !name) return null;
587
- return q.from({ _doc: docCollection }).where(({ _doc }) => {
588
- if (!_doc) return eq(name, name);
589
- return eq(_doc.name, name);
590
- });
591
- },
592
- [docCollection, name],
593
- );
594
- const liveDoc = useMemo(() => {
595
- const arr = Array.isArray(rawDocData) ? rawDocData : rawDocData ? [rawDocData] : [];
596
- const doc = (arr[0] ?? null) as TDoc | null;
597
- return doc
598
- ? { data: doc, isLoading: isDocLoading }
599
- : name
600
- ? { data: null, isLoading: isDocLoading }
601
- : null;
602
- }, [rawDocData, isDocLoading, name]);
603
-
604
- // Create-mode collection (insert path). Edit-mode saves/deletes go through
605
- // docCollection above — the name-filtered subscription that owns the doc —
606
- // so edit mode never subscribes to the whole doctype.
607
- const collection = useFrappeCollection<TDoc>(name ? undefined : frappeConfig, doctype, {
608
- fields,
609
- });
610
-
611
- // Live "Shared With" rows for the sidebar (edit mode only).
612
- const shareCollection = useFrappeCollection<DocShareRow>(
613
- showSidebar && name ? frappeConfig : undefined,
614
- "DocShare",
615
- {
616
- fields: ["name", "user", "everyone", "read", "write", "share"],
617
- filters: { share_doctype: doctype, share_name: name },
618
- },
619
- );
620
- const { data: rawShareRows } = useLiveQuery(
621
- (q) => (shareCollection ? q.from({ _share: shareCollection }) : null),
622
- [shareCollection],
623
- );
624
- const shareRows = useMemo(
625
- () => (shareCollection ? ((rawShareRows as DocShareRow[] | undefined) ?? []) : undefined),
626
- [shareCollection, rawShareRows],
627
- );
628
-
629
- const owner = (baselineDoc as Record<string, unknown> | undefined)?.owner;
630
- const {
631
- rights,
632
- pending: rightsPending,
633
- roles: effectiveRoles,
634
- } = useDocRights(frappeConfig, meta, {
635
- roles,
636
- owner: typeof owner === "string" ? owner : undefined,
637
- shares: shareRows,
638
- });
639
- const permLevels = useMemo(
640
- () => buildPermLevelMatrix(meta?.permissions, effectiveRoles),
641
- [meta?.permissions, effectiveRoles],
642
- );
643
- const formTabs = useMemo(() => {
644
- if (!layoutFields) return undefined;
645
- return buildFormTabs({
646
- fields: layoutFields,
647
- doc: { ...(localDoc as Record<string, unknown>), __islocal: isCreate ? 1 : 0 },
648
- docstatus,
649
- formReadOnly: !!meta?.read_only,
650
- isCreate,
651
- detailsLabel: t("Details"),
652
- permLevels,
653
- hiddenFields: hideFields,
654
- });
655
- }, [layoutFields, localDoc, docstatus, meta?.read_only, isCreate, permLevels, hideFields, t]);
656
497
 
657
498
  // Live "Assigned To" rows: the open assignment ToDos referencing this doc.
658
499
  // The doc's own `_assign` mirror is written silently (update_modified=False,
@@ -661,7 +502,7 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
661
502
  // source of truth. Fixture mode keeps the `_assign` fallback (no fixture
662
503
  // data exists for referencing ToDos), signalled by assignmentRows=undefined.
663
504
  const assignmentCollection = useFrappeCollection<AssignmentTodoRow>(
664
- showSidebar && name && !frappeConfig.fixtures ? frappeConfig : undefined,
505
+ showSidebar && name && frappeConfig && !frappeConfig.fixtures ? frappeConfig : undefined,
665
506
  "ToDo",
666
507
  {
667
508
  fields: ["name", "allocated_to", "status", "reference_type", "reference_name"],
@@ -680,18 +521,6 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
680
521
  [assignmentCollection, rawAssignmentRows],
681
522
  );
682
523
 
683
- // Reset per-document state when the target document changes.
684
- useEffect(() => {
685
- everLoadedRef.current = false;
686
- if (name) createDraftRef.current = undefined;
687
- setWasDeleted(false);
688
- setConflictFields([]);
689
- setDocError(null);
690
- const seed = seedRef.current;
691
- setBaselineDoc(seed);
692
- if (seed) setLocalDoc(seed);
693
- }, [doctype, name]);
694
-
695
524
  const effectiveConnections = useMemo(
696
525
  () => connections ?? connectionsFromMeta(meta),
697
526
  [connections, meta],
@@ -733,279 +562,44 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
733
562
  ({ link }) => connectionSnapshots[link.doctype]?.isLoading ?? true,
734
563
  );
735
564
 
736
- // Immediate honest deleted state: the live app emits doc_deleted for the
737
- // open doc's doctype room; tombstone backfill / cache eviction below is the
738
- // durable fallback for deletions that happen while disconnected.
739
- useFrappeDoctypeEvents(
740
- name ? frappeConfig : undefined,
741
- doctype,
742
- (event) => {
743
- if (event.type !== "doc_deleted") return;
744
- const payload = event.payload as { name?: string } | undefined;
745
- if (payload?.name === name) setWasDeleted(true);
746
- },
747
- Boolean(name),
748
- );
749
-
750
- // Field-level in-place sync: apply external updates to clean fields,
751
- // preserve the user's dirty edits, and flag true conflicts (both sides
752
- // changed the same field to different values) instead of clobbering.
753
- useEffect(() => {
754
- const incoming = liveDoc?.data as Record<string, unknown> | undefined;
755
- if (incoming) {
756
- everLoadedRef.current = true;
757
- setWasDeleted(false);
758
- const baseline = baselineRef.current as Record<string, unknown> | undefined;
759
- if (!baseline) {
760
- setBaselineDoc(incoming as Partial<TDoc>);
761
- setLocalDoc(incoming as Partial<TDoc>);
762
- return;
763
- }
764
- const local = localDocRef.current as Record<string, unknown>;
765
- const merged: Record<string, unknown> = { ...incoming };
766
- const newConflicts: string[] = [];
767
- for (const key of Object.keys(local)) {
768
- const localValue = local[key];
769
- if (localValue === baseline[key]) continue; // clean field → take server value
770
- merged[key] = localValue; // dirty field → preserve the user's edit
771
- const remote = incoming[key];
772
- if (remote !== baseline[key] && remote !== localValue) newConflicts.push(key);
773
- }
774
- setBaselineDoc(incoming as Partial<TDoc>);
775
- setLocalDoc(merged as Partial<TDoc>);
776
- if (newConflicts.length > 0) {
777
- setConflictFields((prev) => Array.from(new Set([...prev, ...newConflicts])));
778
- }
779
- } else if (name && everLoadedRef.current && !liveDoc?.isLoading) {
780
- // The doc was live and is now gone from the subscription: deleted on
781
- // the server (doc_deleted event, CDC tombstone, or 404 eviction).
782
- setWasDeleted(true);
783
- } else if (!name) {
784
- if (createDraftRef.current?.doctype !== doctype) {
785
- createDraftRef.current = { doctype, values: takeCreatePrefill(doctype) };
786
- }
787
- setLocalDoc({ ...createDraftRef.current.values, doctype } as Partial<TDoc>);
788
- }
789
- }, [liveDoc?.data, liveDoc?.isLoading, doctype, name]);
790
-
791
- // Dirty fields = fields where the local value diverges from the baseline.
792
- const dirtyFieldNames = useMemo(() => {
793
- const local = localDoc as Record<string, unknown>;
794
- const base = baselineDoc as Record<string, unknown> | undefined;
795
- const dirty: string[] = [];
796
- for (const key of Object.keys(local)) {
797
- if (key === "doctype" || key === "name") continue;
798
- if (base ? local[key] !== base[key] : local[key] !== undefined) dirty.push(key);
799
- }
800
- return dirty;
801
- }, [localDoc, baselineDoc]);
802
- const hasUnsavedChanges = dirtyFieldNames.length > 0;
803
-
804
- // fetch_from preview: a changed Link pulls its dependent columns off the
805
- // linked doc (validate_link on a bench, the fixture corpus otherwise) and
806
- // patches the targets; clearing the link blanks them.
807
- const runFetchFrom = (field: string, value: unknown) => {
808
- const rules = fetchMap?.get(field);
809
- if (!rules || rules.length === 0) return;
810
- const applyPatch = (fetched: Record<string, unknown>) => {
811
- setLocalDoc((prev) => {
812
- const current = prev as Record<string, unknown>;
813
- if (current[field] !== value) return prev;
814
- const patch = applyFetchedValues(rules, fetched, current);
815
- return Object.keys(patch).length > 0 ? ({ ...current, ...patch } as Partial<TDoc>) : prev;
816
- });
817
- };
818
- if (isEmptyFieldValue(value)) {
819
- applyPatch({});
820
- return;
821
- }
822
- const linkField = meta?.fields.find((metaField) => metaField.fieldname === field);
823
- const targetDoctype = typeof linkField?.options === "string" ? linkField.options : "";
824
- if (!targetDoctype) return;
825
- const sources = Array.from(new Set(rules.map((rule) => rule.source)));
826
- const fixtures = frappeConfig.fixtures;
827
- const fetched = fixtures
828
- ? fixtures
829
- .getDoc(targetDoctype, String(value), sources)
830
- .then((doc) => (doc ?? {}) as Record<string, unknown>)
831
- : validateLink(createHttpClient(frappeConfig), targetDoctype, String(value), sources);
832
- // An unknown link leaves the targets as they are; the server re-validates on save.
833
- fetched.then(applyPatch).catch(() => undefined);
834
- };
835
-
836
- const setValue = (field: keyof TDoc, value: unknown) => {
837
- setLocalDoc((prev) => ({
838
- ...prev,
839
- [field]: value,
840
- }));
841
- setErrors((prev) => {
842
- const newErrors = { ...prev };
843
- delete newErrors[String(field)];
844
- return newErrors;
845
- });
846
- runFetchFrom(String(field), value);
847
- };
848
-
849
- // Required = what the engine resolved: reqd or a truthy
850
- // mandatory_depends_on, on fields that are actually laid out (depends_on,
851
- // permlevel and host-hidden fields never reach the tabs).
852
- const validateRequiredFields = (): Record<string, string> => {
853
- if (!formTabs) return {};
854
- const validationErrors: Record<string, string> = {};
855
- const doc = localDoc as Record<string, unknown>;
856
- if (promptNamed && isEmptyFieldValue(doc.name)) {
857
- validationErrors.name = t("Name is required");
858
- }
859
- for (const tab of formTabs) {
860
- for (const field of tab.schema.fields) {
861
- if (!field.required) continue;
862
- if (isEmptyFieldValue(doc[field.name])) {
863
- validationErrors[field.name] = t("{{label}} is required", {
864
- label: field.label || field.name,
865
- });
866
- }
867
- }
868
- }
869
- return validationErrors;
870
- };
871
-
872
- const canWriteRecord = isCreate ? rights.create : rights.write;
873
-
874
565
  const handleSave = async () => {
875
- if (wasDeleted || !canWriteRecord) return;
876
-
877
- const requiredErrors = validateRequiredFields();
878
- if (Object.keys(requiredErrors).length > 0) {
879
- setErrors(requiredErrors);
880
- return;
881
- }
882
-
883
- setIsSubmitting(true);
884
- setErrors({});
885
-
886
- try {
887
- if (name) {
888
- if (!docCollection) return;
889
- // Write only the dirty fields through the optimistic mutation path —
890
- // clean fields stay untouched so concurrent server edits survive.
891
- const changedFields = dirtyFieldNames;
892
- if (changedFields.length > 0) {
893
- const local = localDoc as Record<string, unknown>;
894
- const tx = docCollection.update(name, (draft) => {
895
- for (const key of changedFields) {
896
- (draft as Record<string, unknown>)[key] = local[key];
897
- }
898
- });
899
- await tx.isPersisted.promise;
900
- }
901
- setConflictFields([]);
902
- } else {
903
- if (!collection) return;
904
- const temporaryName =
905
- ((localDoc as Record<string, unknown>).name as string | undefined) ?? `new-${Date.now()}`;
906
- const docToInsert = { ...localDoc, doctype, name: temporaryName } as TDoc;
907
- const tx = collection.insert(docToInsert);
908
- await tx.isPersisted.promise;
909
- const createdName =
910
- getFrappeSyncModule(frappeConfig)?.createdName(temporaryName) ?? temporaryName;
911
- onSave?.({ ...localDoc, name: createdName } as Partial<TDoc>);
912
- return;
913
- }
914
- onSave?.(localDoc as Partial<TDoc>);
915
- } catch (err: unknown) {
916
- const e = err as { exc_type?: string; _server_messages?: string; message?: string };
917
- if (e?.exc_type === "ValidationError") {
918
- const validationErrors: Record<string, string> = {};
919
- if (e._server_messages) {
920
- try {
921
- const messages = JSON.parse(e._server_messages) as string[];
922
- for (const msg of messages) {
923
- const parsed =
924
- typeof msg === "string" ? (JSON.parse(msg) as { message?: string }) : msg;
925
- if (parsed.message) {
926
- validationErrors.general = parsed.message;
927
- }
928
- }
929
- } catch {
930
- validationErrors.general = t("Validation error occurred");
931
- }
932
- }
933
- setErrors(validationErrors);
934
- } else {
935
- setErrors({ general: e?.message || t("Failed to save document") });
936
- }
937
- } finally {
938
- setIsSubmitting(false);
939
- }
566
+ const saved = await form.save();
567
+ if (!saved) return;
568
+ onSave?.((isCreate ? { ...localDoc, name: saved } : localDoc) as Partial<TDoc>);
940
569
  };
941
570
 
942
571
  const handleCancel = () => {
943
- if (baselineDoc) {
944
- setLocalDoc(baselineDoc);
945
- } else if (liveDoc?.data) {
946
- setLocalDoc(liveDoc.data as Partial<TDoc>);
947
- } else {
948
- setLocalDoc({ doctype } as Partial<TDoc>);
949
- }
950
- setConflictFields([]);
951
- setErrors({});
572
+ form.reset();
952
573
  onCancel?.();
953
574
  };
954
575
 
955
576
  const handleDelete = async () => {
956
- if (!docCollection || !name || wasDeleted || !rights.delete) return;
577
+ if (!name || wasDeleted || !rights.delete) return;
957
578
  if (!(await dialog.confirm(t("Delete this {{doctype}}?", { doctype })))) return;
958
- setIsSubmitting(true);
959
- setErrors({});
960
- try {
961
- const tx = docCollection.delete(name);
962
- await tx.isPersisted.promise;
963
- onDelete?.(localDoc as Partial<TDoc>);
964
- } catch (err) {
965
- setErrors({ general: (err as Error)?.message || t("Failed to delete") });
966
- } finally {
967
- setIsSubmitting(false);
968
- }
579
+ if (await form.remove()) onDelete?.(localDoc as Partial<TDoc>);
969
580
  };
970
581
 
971
- // Submit and Cancel write docstatus through the collection that owns the
972
- // doc: the server's save runs the transition (0 → 1 submits, 1 → 2
973
- // cancels) under the same OCC as any other edit.
974
- const transitionDocstatus = async (to: 1 | 2, question: string, failure: string) => {
975
- if (!docCollection || !name || wasDeleted || !rights[to === 1 ? "submit" : "cancel"]) return;
976
- if (to === 1) {
977
- const requiredErrors = validateRequiredFields();
978
- if (Object.keys(requiredErrors).length > 0) {
979
- setErrors(requiredErrors);
980
- return;
981
- }
982
- }
983
- if (!(await dialog.confirm(question))) return;
984
- setIsSubmitting(true);
985
- setErrors({});
986
- try {
987
- const tx = docCollection.update(name, (draft) => {
988
- (draft as Record<string, unknown>).docstatus = to;
989
- });
990
- await tx.isPersisted.promise;
991
- } catch (err) {
992
- setErrors({ general: extractServerError(err, failure) });
993
- } finally {
994
- setIsSubmitting(false);
582
+ const handleSubmitDocument = async () => {
583
+ if (!name || wasDeleted || !rights.submit) return;
584
+ const requiredErrors = form.validate();
585
+ if (Object.keys(requiredErrors).length > 0) {
586
+ form.setErrors(requiredErrors);
587
+ return;
995
588
  }
589
+ if (!(await dialog.confirm(t("Permanently Submit {{name}}?", { name })))) return;
590
+ await form.submitDocument();
996
591
  };
997
592
 
998
- const handleSubmitDocument = () =>
999
- transitionDocstatus(1, t("Permanently Submit {{name}}?", { name }), t("Failed to submit"));
1000
-
1001
- const handleCancelDocument = () =>
1002
- transitionDocstatus(2, t("Permanently Cancel {{name}}?", { name }), t("Failed to cancel"));
593
+ const handleCancelDocument = async () => {
594
+ if (!name || wasDeleted || !rights.cancel) return;
595
+ if (!(await dialog.confirm(t("Permanently Cancel {{name}}?", { name })))) return;
596
+ await form.cancelDocument();
597
+ };
1003
598
 
1004
599
  const handleAmend = () => {
1005
- if (!meta || !name || !onAmend || !rights.amend) return;
1006
- const draft = buildAmendValues(meta.fields, localDoc as Record<string, unknown>, name);
1007
- setCreatePrefill(doctype, draft);
1008
- onAmend(draft as Partial<TDoc>);
600
+ if (!onAmend) return;
601
+ const draft = form.amend();
602
+ if (draft) onAmend(draft);
1009
603
  };
1010
604
 
1011
605
  const titleField = meta?.title_field;
@@ -1066,24 +660,33 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
1066
660
 
1067
661
  if (!children && metaError && !meta && !name) {
1068
662
  return frame(
1069
- <YStack {...knobProps.panelPadding} alignItems="center" {...knobProps.gap}>
1070
- <Text color={formCommonColors.error} {...knobProps.body}>
1071
- {t("Failed to load {{doctype}} form", { doctype })}
1072
- </Text>
1073
- <Text color={formCommonColors.muted} {...knobProps.body}>
1074
- {metaError.message}
1075
- </Text>
1076
- </YStack>,
663
+ isPermissionError(metaError) ? (
664
+ <EmptyState
665
+ testID="frappe-form-not-permitted"
666
+ title={t("You can't create {{doctype}}", { doctype })}
667
+ description={metaError.message}
668
+ />
669
+ ) : (
670
+ <AsyncBoundary
671
+ {...knobProps.panelPadding}
672
+ error={metaError}
673
+ errorTitle={t("Failed to load {{doctype}} form", { doctype })}
674
+ />
675
+ ),
1077
676
  );
1078
677
  }
1079
678
 
1080
- // No collection yet (server render, hydration pass) reads as loading: the
1081
- // document is not known to be missing until the live read answers.
1082
- const isDocReading = Boolean(name) && (!docCollection || Boolean(liveDoc?.isLoading));
1083
- const showsSeed = Boolean(seedDoc) && isDocReading;
1084
-
1085
- if (name && docError && !liveDoc?.data && !wasDeleted) {
679
+ if (name && form.status === "error" && docError) {
1086
680
  if (errorFallback !== undefined) return frame(errorFallback);
681
+ if (isPermissionError(docError)) {
682
+ return frame(
683
+ <EmptyState
684
+ testID="frappe-form-not-permitted"
685
+ title={t("You can't open {{doctype}} {{name}}", { doctype, name })}
686
+ description={t("Your session can't read this {{doctype}}.", { doctype })}
687
+ />,
688
+ );
689
+ }
1087
690
  return frame(
1088
691
  <AsyncBoundary
1089
692
  {...knobProps.panelPadding}
@@ -1093,7 +696,7 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
1093
696
  );
1094
697
  }
1095
698
 
1096
- if (name && isDocReading && !showsSeed && !wasDeleted) {
699
+ if (name && form.status === "loading") {
1097
700
  if (loadingFallback !== undefined) return frame(loadingFallback);
1098
701
  return frame(
1099
702
  <YStack {...knobProps.panelPadding} testID="frappe-form-skeleton">
@@ -1104,7 +707,7 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
1104
707
 
1105
708
  // Never-loaded miss (bad name, or read denied) — distinct from the
1106
709
  // deleted-while-open state below, which keeps the form mounted.
1107
- if (name && !isDocReading && !liveDoc?.data && !wasDeleted) {
710
+ if (name && form.status === "notFound") {
1108
711
  if (notFoundFallback !== undefined) return frame(notFoundFallback);
1109
712
  return frame(
1110
713
  <YStack {...knobProps.panelPadding} alignItems="center" {...knobProps.gap}>
@@ -1153,7 +756,7 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
1153
756
  const shownTabs = formTabs ?? recordTabs;
1154
757
  const imageFieldName = meta?.image_field;
1155
758
  const sidebarImageUrl = imageFieldName
1156
- ? resolveFileUrl((localDoc as Record<string, unknown>)[imageFieldName], finalBaseURL)
759
+ ? resolveFileUrl((localDoc as Record<string, unknown>)[imageFieldName], frappeConfig?.baseURL)
1157
760
  : undefined;
1158
761
  const conflictLabels = conflictFields
1159
762
  .map((fieldname) => {
@@ -1162,10 +765,8 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
1162
765
  })
1163
766
  .join(", ");
1164
767
 
1165
- const formTabItems: TabsItem[] = (shownTabs ?? []).map((tab) => ({
1166
- value: tab.key,
1167
- label: tab.label,
1168
- content: (
768
+ const formTabItems: TabsItem[] = (shownTabs ?? []).map((tab) => {
769
+ const content = (
1169
770
  <SchemaForm
1170
771
  schema={tab.schema}
1171
772
  doc={localDoc as Record<string, any>}
@@ -1176,13 +777,19 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
1176
777
  readOnlyFields={readOnlyFields}
1177
778
  sectionHeadingLevel={sectionHeadingLevel}
1178
779
  order={order}
780
+ renderFieldWrapper={renderFieldWrapper}
1179
781
  />
1180
- ),
1181
- }));
1182
- const tabItems: TabsItem[] =
1183
- showConnections && name && effectiveConnections.length > 0
782
+ );
783
+ return {
784
+ value: tab.key,
785
+ label: tab.label,
786
+ content: renderTab ? renderTab(tab, content) : content,
787
+ };
788
+ });
789
+ const tabItems: TabsItem[] = [
790
+ ...formTabItems,
791
+ ...(showConnections && name && frappeConfig && effectiveConnections.length > 0
1184
792
  ? [
1185
- ...formTabItems,
1186
793
  {
1187
794
  value: "__connections",
1188
795
  label: t("Connections"),
@@ -1195,7 +802,9 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
1195
802
  ),
1196
803
  },
1197
804
  ]
1198
- : formTabItems;
805
+ : []),
806
+ ...(extraTabs ?? []),
807
+ ];
1199
808
  const effectiveTab =
1200
809
  activeTab && tabItems.some((tab) => tab.value === activeTab) ? activeTab : tabItems[0]?.value;
1201
810
  const localName = (localDoc as Record<string, unknown>).name;
@@ -1205,7 +814,7 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
1205
814
  imageUrl={sidebarImageUrl}
1206
815
  sections={buildSidebarSections(
1207
816
  localDoc as Record<string, any>,
1208
- shareRows,
817
+ form.shareRows as DocShareRow[] | undefined,
1209
818
  t,
1210
819
  assignmentRows,
1211
820
  )}
@@ -1219,18 +828,20 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
1219
828
  // collapses this stack to zero height on web — content then paints over
1220
829
  // whatever follows. Bounded parents still fill (grow 1) exactly as before.
1221
830
  <YStack {...knobProps.gapLg} flexGrow={1} flexShrink={1} flexBasis="auto">
1222
- <FrappeReconnectIndicator config={frappeConfig} />
1223
- {connectionReads.map((read) => (
1224
- <FrappeRowsReader
1225
- key={read.link.doctype}
1226
- connection={frappeConfig}
1227
- doctype={read.link.doctype}
1228
- options={read.options as never}
1229
- onChange={read.onChange}
1230
- />
1231
- ))}
831
+ {frappeConfig ? <FrappeReconnectIndicator config={frappeConfig} /> : null}
832
+ {frappeConfig
833
+ ? connectionReads.map((read) => (
834
+ <FrappeRowsReader
835
+ key={read.link.doctype}
836
+ connection={frappeConfig}
837
+ doctype={read.link.doctype}
838
+ options={read.options as never}
839
+ onChange={read.onChange}
840
+ />
841
+ ))
842
+ : null}
1232
843
 
1233
- {wasDeleted && (
844
+ {showBanners && wasDeleted && (
1234
845
  <XStack
1235
846
  role="alert"
1236
847
  alignItems="center"
@@ -1252,7 +863,7 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
1252
863
  </XStack>
1253
864
  )}
1254
865
 
1255
- {!wasDeleted && conflictFields.length > 0 && (
866
+ {showBanners && !wasDeleted && conflictFields.length > 0 && (
1256
867
  <XStack
1257
868
  role="status"
1258
869
  alignItems="center"
@@ -1319,7 +930,7 @@ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
1319
930
  )}
1320
931
 
1321
932
  {errors.general && (
1322
- <Text color={formCommonColors.error} {...knobProps.body}>
933
+ <Text color={formCommonColors.error} {...knobProps.body} role="alert">
1323
934
  {errors.general}
1324
935
  </Text>
1325
936
  )}