@multiplatform.one/frappe-ui 7.11.0 → 7.15.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 (341) hide show
  1. package/README.md +2 -2
  2. package/dist/cjs/FrappeDashboard.cjs +8 -10
  3. package/dist/cjs/FrappeDashboard.native.js +8 -14
  4. package/dist/cjs/FrappeDashboard.native.js.map +1 -1
  5. package/dist/cjs/FrappeForm.cjs +465 -175
  6. package/dist/cjs/FrappeForm.native.js +611 -207
  7. package/dist/cjs/FrappeForm.native.js.map +1 -1
  8. package/dist/cjs/FrappeFormConnections.cjs +125 -0
  9. package/dist/cjs/FrappeFormConnections.native.js +149 -0
  10. package/dist/cjs/FrappeFormConnections.native.js.map +1 -0
  11. package/dist/cjs/FrappeImageView.cjs +7 -9
  12. package/dist/cjs/FrappeImageView.native.js +7 -13
  13. package/dist/cjs/FrappeImageView.native.js.map +1 -1
  14. package/dist/cjs/FrappeList.cjs +29 -4
  15. package/dist/cjs/FrappeList.native.js +37 -5
  16. package/dist/cjs/FrappeList.native.js.map +1 -1
  17. package/dist/cjs/FrappeListView.cjs +102 -29
  18. package/dist/cjs/FrappeListView.native.js +109 -29
  19. package/dist/cjs/FrappeListView.native.js.map +1 -1
  20. package/dist/cjs/FrappeProvider.cjs +7 -2
  21. package/dist/cjs/FrappeProvider.native.js +7 -2
  22. package/dist/cjs/FrappeProvider.native.js.map +1 -1
  23. package/dist/cjs/FrappeReportBuilder.cjs +10 -11
  24. package/dist/cjs/FrappeReportBuilder.native.js +10 -16
  25. package/dist/cjs/FrappeReportBuilder.native.js.map +1 -1
  26. package/dist/cjs/FrappeRowsReader.cjs +49 -0
  27. package/dist/cjs/FrappeRowsReader.native.js +53 -0
  28. package/dist/cjs/FrappeRowsReader.native.js.map +1 -0
  29. package/dist/cjs/FrappeTable.cjs +18 -6
  30. package/dist/cjs/FrappeTable.native.js +24 -6
  31. package/dist/cjs/FrappeTable.native.js.map +1 -1
  32. package/dist/cjs/fieldRegistry.cjs +36 -11
  33. package/dist/cjs/fieldRegistry.native.js +59 -11
  34. package/dist/cjs/fieldRegistry.native.js.map +1 -1
  35. package/dist/cjs/fields/Date/index.native.js +5 -2
  36. package/dist/cjs/fields/Date/index.native.js.map +1 -1
  37. package/dist/cjs/fields/Date/sheetKeyboard.cjs +2 -2
  38. package/dist/cjs/fields/Date/sheetKeyboard.native.js +2 -2
  39. package/dist/cjs/fields/Date/sheetKeyboard.native.js.map +1 -1
  40. package/dist/cjs/fields/DateRange/index.cjs +2 -1
  41. package/dist/cjs/fields/DateRange/index.native.js +4 -1
  42. package/dist/cjs/fields/DateRange/index.native.js.map +1 -1
  43. package/dist/cjs/formEngine/index.cjs +4 -2
  44. package/dist/cjs/formEngine/index.native.js +3 -1
  45. package/dist/cjs/formEngine/index.native.js.map +1 -1
  46. package/dist/cjs/formEngine/useSessionRoles.cjs +77 -0
  47. package/dist/cjs/formEngine/useSessionRoles.native.js +84 -0
  48. package/dist/cjs/formEngine/useSessionRoles.native.js.map +1 -0
  49. package/dist/cjs/frappeConnections.cjs +41 -0
  50. package/dist/cjs/frappeConnections.native.js +50 -0
  51. package/dist/cjs/frappeConnections.native.js.map +1 -0
  52. package/dist/cjs/frappeNavigation.cjs +107 -0
  53. package/dist/cjs/frappeNavigation.native.js +112 -0
  54. package/dist/cjs/frappeNavigation.native.js.map +1 -0
  55. package/dist/cjs/index.cjs +18 -1
  56. package/dist/cjs/index.native.js +18 -1
  57. package/dist/cjs/index.native.js.map +1 -1
  58. package/dist/cjs/schemaForm/index.cjs +4 -1
  59. package/dist/cjs/schemaForm/index.native.js +4 -1
  60. package/dist/cjs/schemaForm/index.native.js.map +1 -1
  61. package/dist/cjs/useConnectionFilters.cjs +146 -0
  62. package/dist/cjs/useConnectionFilters.native.js +213 -0
  63. package/dist/cjs/useConnectionFilters.native.js.map +1 -0
  64. package/dist/cjs/useFrappePagination.cjs +37 -18
  65. package/dist/cjs/useFrappePagination.native.js +59 -23
  66. package/dist/cjs/useFrappePagination.native.js.map +1 -1
  67. package/dist/cjs/useFrappeRows.native.js.map +1 -1
  68. package/dist/esm/FrappeDashboard.mjs +9 -11
  69. package/dist/esm/FrappeDashboard.mjs.map +1 -1
  70. package/dist/esm/FrappeDashboard.native.js +9 -15
  71. package/dist/esm/FrappeDashboard.native.js.map +1 -1
  72. package/dist/esm/FrappeForm.mjs +471 -181
  73. package/dist/esm/FrappeForm.mjs.map +1 -1
  74. package/dist/esm/FrappeForm.native.js +599 -195
  75. package/dist/esm/FrappeForm.native.js.map +1 -1
  76. package/dist/esm/FrappeFormConnections.mjs +99 -0
  77. package/dist/esm/FrappeFormConnections.mjs.map +1 -0
  78. package/dist/esm/FrappeFormConnections.native.js +120 -0
  79. package/dist/esm/FrappeFormConnections.native.js.map +1 -0
  80. package/dist/esm/FrappeImageView.mjs +8 -10
  81. package/dist/esm/FrappeImageView.mjs.map +1 -1
  82. package/dist/esm/FrappeImageView.native.js +8 -14
  83. package/dist/esm/FrappeImageView.native.js.map +1 -1
  84. package/dist/esm/FrappeList.mjs +31 -6
  85. package/dist/esm/FrappeList.mjs.map +1 -1
  86. package/dist/esm/FrappeList.native.js +39 -7
  87. package/dist/esm/FrappeList.native.js.map +1 -1
  88. package/dist/esm/FrappeListView.mjs +103 -30
  89. package/dist/esm/FrappeListView.mjs.map +1 -1
  90. package/dist/esm/FrappeListView.native.js +110 -30
  91. package/dist/esm/FrappeListView.native.js.map +1 -1
  92. package/dist/esm/FrappeProvider.mjs +7 -2
  93. package/dist/esm/FrappeProvider.mjs.map +1 -1
  94. package/dist/esm/FrappeProvider.native.js +7 -2
  95. package/dist/esm/FrappeProvider.native.js.map +1 -1
  96. package/dist/esm/FrappeReportBuilder.mjs +11 -12
  97. package/dist/esm/FrappeReportBuilder.mjs.map +1 -1
  98. package/dist/esm/FrappeReportBuilder.native.js +11 -17
  99. package/dist/esm/FrappeReportBuilder.native.js.map +1 -1
  100. package/dist/esm/FrappeRowsReader.mjs +24 -0
  101. package/dist/esm/FrappeRowsReader.mjs.map +1 -0
  102. package/dist/esm/FrappeRowsReader.native.js +25 -0
  103. package/dist/esm/FrappeRowsReader.native.js.map +1 -0
  104. package/dist/esm/FrappeTable.mjs +18 -6
  105. package/dist/esm/FrappeTable.mjs.map +1 -1
  106. package/dist/esm/FrappeTable.native.js +24 -6
  107. package/dist/esm/FrappeTable.native.js.map +1 -1
  108. package/dist/esm/fieldRegistry.mjs +35 -12
  109. package/dist/esm/fieldRegistry.mjs.map +1 -1
  110. package/dist/esm/fieldRegistry.native.js +58 -12
  111. package/dist/esm/fieldRegistry.native.js.map +1 -1
  112. package/dist/esm/fields/Date/index.native.js +6 -3
  113. package/dist/esm/fields/Date/index.native.js.map +1 -1
  114. package/dist/esm/fields/Date/sheetKeyboard.mjs +2 -2
  115. package/dist/esm/fields/Date/sheetKeyboard.mjs.map +1 -1
  116. package/dist/esm/fields/Date/sheetKeyboard.native.js +2 -2
  117. package/dist/esm/fields/Date/sheetKeyboard.native.js.map +1 -1
  118. package/dist/esm/fields/DateRange/index.mjs +2 -1
  119. package/dist/esm/fields/DateRange/index.mjs.map +1 -1
  120. package/dist/esm/fields/DateRange/index.native.js +4 -1
  121. package/dist/esm/fields/DateRange/index.native.js.map +1 -1
  122. package/dist/esm/formEngine/index.mjs +2 -1
  123. package/dist/esm/formEngine/index.mjs.map +1 -1
  124. package/dist/esm/formEngine/index.native.js +2 -1
  125. package/dist/esm/formEngine/index.native.js.map +1 -1
  126. package/dist/esm/formEngine/useSessionRoles.mjs +52 -0
  127. package/dist/esm/formEngine/useSessionRoles.mjs.map +1 -0
  128. package/dist/esm/formEngine/useSessionRoles.native.js +56 -0
  129. package/dist/esm/formEngine/useSessionRoles.native.js.map +1 -0
  130. package/dist/esm/frappeConnections.mjs +15 -0
  131. package/dist/esm/frappeConnections.mjs.map +1 -0
  132. package/dist/esm/frappeConnections.native.js +21 -0
  133. package/dist/esm/frappeConnections.native.js.map +1 -0
  134. package/dist/esm/frappeNavigation.mjs +78 -0
  135. package/dist/esm/frappeNavigation.mjs.map +1 -0
  136. package/dist/esm/frappeNavigation.native.js +80 -0
  137. package/dist/esm/frappeNavigation.native.js.map +1 -0
  138. package/dist/esm/index.mjs +7 -3
  139. package/dist/esm/index.mjs.map +1 -1
  140. package/dist/esm/index.native.js +7 -3
  141. package/dist/esm/index.native.js.map +1 -1
  142. package/dist/esm/schemaForm/index.mjs +4 -1
  143. package/dist/esm/schemaForm/index.mjs.map +1 -1
  144. package/dist/esm/schemaForm/index.native.js +4 -1
  145. package/dist/esm/schemaForm/index.native.js.map +1 -1
  146. package/dist/esm/useConnectionFilters.mjs +121 -0
  147. package/dist/esm/useConnectionFilters.mjs.map +1 -0
  148. package/dist/esm/useConnectionFilters.native.js +185 -0
  149. package/dist/esm/useConnectionFilters.native.js.map +1 -0
  150. package/dist/esm/useFrappePagination.mjs +38 -19
  151. package/dist/esm/useFrappePagination.mjs.map +1 -1
  152. package/dist/esm/useFrappePagination.native.js +60 -24
  153. package/dist/esm/useFrappePagination.native.js.map +1 -1
  154. package/dist/esm/useFrappeRows.mjs.map +1 -1
  155. package/dist/esm/useFrappeRows.native.js.map +1 -1
  156. package/dist/jsx/FrappeDashboard.mjs +9 -11
  157. package/dist/jsx/FrappeDashboard.mjs.map +1 -1
  158. package/dist/jsx/FrappeDashboard.native.js +8 -14
  159. package/dist/jsx/FrappeDashboard.native.js.map +1 -1
  160. package/dist/jsx/FrappeForm.mjs +471 -181
  161. package/dist/jsx/FrappeForm.mjs.map +1 -1
  162. package/dist/jsx/FrappeForm.native.js +611 -207
  163. package/dist/jsx/FrappeForm.native.js.map +1 -1
  164. package/dist/jsx/FrappeFormConnections.mjs +99 -0
  165. package/dist/jsx/FrappeFormConnections.mjs.map +1 -0
  166. package/dist/jsx/FrappeFormConnections.native.js +149 -0
  167. package/dist/jsx/FrappeFormConnections.native.js.map +1 -0
  168. package/dist/jsx/FrappeImageView.mjs +8 -10
  169. package/dist/jsx/FrappeImageView.mjs.map +1 -1
  170. package/dist/jsx/FrappeImageView.native.js +7 -13
  171. package/dist/jsx/FrappeImageView.native.js.map +1 -1
  172. package/dist/jsx/FrappeList.mjs +31 -6
  173. package/dist/jsx/FrappeList.mjs.map +1 -1
  174. package/dist/jsx/FrappeList.native.js +37 -5
  175. package/dist/jsx/FrappeList.native.js.map +1 -1
  176. package/dist/jsx/FrappeListView.mjs +103 -30
  177. package/dist/jsx/FrappeListView.mjs.map +1 -1
  178. package/dist/jsx/FrappeListView.native.js +109 -29
  179. package/dist/jsx/FrappeListView.native.js.map +1 -1
  180. package/dist/jsx/FrappeProvider.mjs +7 -2
  181. package/dist/jsx/FrappeProvider.mjs.map +1 -1
  182. package/dist/jsx/FrappeProvider.native.js +7 -2
  183. package/dist/jsx/FrappeProvider.native.js.map +1 -1
  184. package/dist/jsx/FrappeReportBuilder.mjs +11 -12
  185. package/dist/jsx/FrappeReportBuilder.mjs.map +1 -1
  186. package/dist/jsx/FrappeReportBuilder.native.js +10 -16
  187. package/dist/jsx/FrappeReportBuilder.native.js.map +1 -1
  188. package/dist/jsx/FrappeRowsReader.mjs +24 -0
  189. package/dist/jsx/FrappeRowsReader.mjs.map +1 -0
  190. package/dist/jsx/FrappeRowsReader.native.js +53 -0
  191. package/dist/jsx/FrappeRowsReader.native.js.map +1 -0
  192. package/dist/jsx/FrappeTable.mjs +18 -6
  193. package/dist/jsx/FrappeTable.mjs.map +1 -1
  194. package/dist/jsx/FrappeTable.native.js +24 -6
  195. package/dist/jsx/FrappeTable.native.js.map +1 -1
  196. package/dist/jsx/fieldRegistry.mjs +35 -12
  197. package/dist/jsx/fieldRegistry.mjs.map +1 -1
  198. package/dist/jsx/fieldRegistry.native.js +59 -11
  199. package/dist/jsx/fieldRegistry.native.js.map +1 -1
  200. package/dist/jsx/fields/Date/index.native.js +5 -2
  201. package/dist/jsx/fields/Date/index.native.js.map +1 -1
  202. package/dist/jsx/fields/Date/sheetKeyboard.mjs +2 -2
  203. package/dist/jsx/fields/Date/sheetKeyboard.mjs.map +1 -1
  204. package/dist/jsx/fields/Date/sheetKeyboard.native.js +2 -2
  205. package/dist/jsx/fields/Date/sheetKeyboard.native.js.map +1 -1
  206. package/dist/jsx/fields/DateRange/index.mjs +2 -1
  207. package/dist/jsx/fields/DateRange/index.mjs.map +1 -1
  208. package/dist/jsx/fields/DateRange/index.native.js +4 -1
  209. package/dist/jsx/fields/DateRange/index.native.js.map +1 -1
  210. package/dist/jsx/formEngine/index.mjs +2 -1
  211. package/dist/jsx/formEngine/index.mjs.map +1 -1
  212. package/dist/jsx/formEngine/index.native.js +3 -1
  213. package/dist/jsx/formEngine/index.native.js.map +1 -1
  214. package/dist/jsx/formEngine/useSessionRoles.mjs +52 -0
  215. package/dist/jsx/formEngine/useSessionRoles.mjs.map +1 -0
  216. package/dist/jsx/formEngine/useSessionRoles.native.js +84 -0
  217. package/dist/jsx/formEngine/useSessionRoles.native.js.map +1 -0
  218. package/dist/jsx/frappeConnections.mjs +15 -0
  219. package/dist/jsx/frappeConnections.mjs.map +1 -0
  220. package/dist/jsx/frappeConnections.native.js +50 -0
  221. package/dist/jsx/frappeConnections.native.js.map +1 -0
  222. package/dist/jsx/frappeNavigation.mjs +78 -0
  223. package/dist/jsx/frappeNavigation.mjs.map +1 -0
  224. package/dist/jsx/frappeNavigation.native.js +112 -0
  225. package/dist/jsx/frappeNavigation.native.js.map +1 -0
  226. package/dist/jsx/index.js +7 -3
  227. package/dist/jsx/index.js.map +1 -1
  228. package/dist/jsx/index.mjs +7 -3
  229. package/dist/jsx/index.mjs.map +1 -1
  230. package/dist/jsx/index.native.js +18 -1
  231. package/dist/jsx/index.native.js.map +1 -1
  232. package/dist/jsx/schemaForm/index.mjs +4 -1
  233. package/dist/jsx/schemaForm/index.mjs.map +1 -1
  234. package/dist/jsx/schemaForm/index.native.js +4 -1
  235. package/dist/jsx/schemaForm/index.native.js.map +1 -1
  236. package/dist/jsx/useConnectionFilters.mjs +121 -0
  237. package/dist/jsx/useConnectionFilters.mjs.map +1 -0
  238. package/dist/jsx/useConnectionFilters.native.js +213 -0
  239. package/dist/jsx/useConnectionFilters.native.js.map +1 -0
  240. package/dist/jsx/useFrappePagination.mjs +38 -19
  241. package/dist/jsx/useFrappePagination.mjs.map +1 -1
  242. package/dist/jsx/useFrappePagination.native.js +59 -23
  243. package/dist/jsx/useFrappePagination.native.js.map +1 -1
  244. package/dist/jsx/useFrappeRows.mjs.map +1 -1
  245. package/dist/jsx/useFrappeRows.native.js.map +1 -1
  246. package/package.json +24 -20
  247. package/src/FileBrowser/AttachLibrary.stories.tsx +23 -0
  248. package/src/FrappeDashboard.tsx +11 -16
  249. package/src/FrappeForm.connections.spec.tsx +280 -0
  250. package/src/FrappeForm.connections.stories.tsx +93 -0
  251. package/src/FrappeForm.deskSemantics.spec.tsx +284 -0
  252. package/src/FrappeForm.tsx +560 -145
  253. package/src/FrappeFormConnections.tsx +148 -0
  254. package/src/FrappeGuestMeta.spec.tsx +143 -0
  255. package/src/FrappeImageView.tsx +8 -12
  256. package/src/FrappeList.tsx +71 -15
  257. package/src/FrappeListView.cards.spec.tsx +175 -0
  258. package/src/FrappeListView.cards.stories.tsx +131 -0
  259. package/src/FrappeListView.connections.spec.tsx +192 -0
  260. package/src/FrappeListView.connections.stories.tsx +99 -0
  261. package/src/FrappeListView.seed.spec.tsx +228 -0
  262. package/src/FrappeListView.tsx +177 -31
  263. package/src/FrappeProvider.tsx +13 -1
  264. package/src/FrappeReportBuilder.tsx +11 -19
  265. package/src/FrappeRowsReader.tsx +38 -0
  266. package/src/FrappeTable.optionalProps.spec.tsx +249 -0
  267. package/src/FrappeTable.spec.tsx +32 -42
  268. package/src/FrappeTable.stories.spec.tsx +55 -0
  269. package/src/FrappeTable.stories.tsx +28 -37
  270. package/src/FrappeTable.tsx +108 -45
  271. package/src/childTableDependsOn.spec.tsx +85 -0
  272. package/src/desk/DeskFormSidebar.spec.tsx +20 -0
  273. package/src/desk/DeskFormSidebar.stories.tsx +42 -0
  274. package/src/fieldRegistry.spec.tsx +27 -64
  275. package/src/fieldRegistry.ts +71 -14
  276. package/src/fields/Date/index.native.tsx +4 -2
  277. package/src/fields/Date/sheetKeyboard.spec.ts +14 -5
  278. package/src/fields/Date/sheetKeyboard.ts +12 -4
  279. package/src/fields/DateRange/DateRange.spec.tsx +67 -0
  280. package/src/fields/DateRange/index.tsx +5 -1
  281. package/src/fields/LinkTree.spec.tsx +8 -0
  282. package/src/fields/LinkTree.stories.tsx +28 -5
  283. package/src/fields/Select/Select.hydration.spec.tsx +177 -0
  284. package/src/formEngine/doctypeMeta.ts +15 -114
  285. package/src/formEngine/index.ts +2 -0
  286. package/src/formEngine/seam.spec.ts +23 -4
  287. package/src/formEngine/useSessionRoles.ts +73 -0
  288. package/src/frappeConnections.ts +50 -0
  289. package/src/frappeNavigation.tsx +112 -0
  290. package/src/frappeRoutes.spec.tsx +335 -0
  291. package/src/index.ts +28 -1
  292. package/src/schemaForm/index.tsx +1 -0
  293. package/src/useConnectionFilters.tsx +157 -0
  294. package/src/useFrappePagination.ts +70 -26
  295. package/src/useFrappeRows.sharedPath.spec.tsx +206 -0
  296. package/src/useFrappeRows.ts +5 -4
  297. package/types/FrappeDashboard.d.ts +3 -4
  298. package/types/FrappeDashboard.d.ts.map +1 -1
  299. package/types/FrappeForm.d.ts +57 -5
  300. package/types/FrappeForm.d.ts.map +1 -1
  301. package/types/FrappeFormConnections.d.ts +18 -0
  302. package/types/FrappeFormConnections.d.ts.map +1 -0
  303. package/types/FrappeImageView.d.ts +2 -3
  304. package/types/FrappeImageView.d.ts.map +1 -1
  305. package/types/FrappeList.d.ts +20 -2
  306. package/types/FrappeList.d.ts.map +1 -1
  307. package/types/FrappeListView.d.ts +53 -3
  308. package/types/FrappeListView.d.ts.map +1 -1
  309. package/types/FrappeProvider.d.ts +8 -1
  310. package/types/FrappeProvider.d.ts.map +1 -1
  311. package/types/FrappeReportBuilder.d.ts.map +1 -1
  312. package/types/FrappeRowsReader.d.ts +26 -0
  313. package/types/FrappeRowsReader.d.ts.map +1 -0
  314. package/types/FrappeTable.d.ts +39 -8
  315. package/types/FrappeTable.d.ts.map +1 -1
  316. package/types/fieldRegistry.d.ts +17 -3
  317. package/types/fieldRegistry.d.ts.map +1 -1
  318. package/types/fields/Date/index.native.d.ts.map +1 -1
  319. package/types/fields/Date/sheetKeyboard.d.ts +4 -3
  320. package/types/fields/Date/sheetKeyboard.d.ts.map +1 -1
  321. package/types/fields/DateRange/index.d.ts.map +1 -1
  322. package/types/formEngine/doctypeMeta.d.ts +10 -114
  323. package/types/formEngine/doctypeMeta.d.ts.map +1 -1
  324. package/types/formEngine/index.d.ts +1 -0
  325. package/types/formEngine/index.d.ts.map +1 -1
  326. package/types/formEngine/useSessionRoles.d.ts +19 -0
  327. package/types/formEngine/useSessionRoles.d.ts.map +1 -0
  328. package/types/frappeConnections.d.ts +33 -0
  329. package/types/frappeConnections.d.ts.map +1 -0
  330. package/types/frappeNavigation.d.ts +49 -0
  331. package/types/frappeNavigation.d.ts.map +1 -0
  332. package/types/index.d.ts +7 -3
  333. package/types/index.d.ts.map +1 -1
  334. package/types/schemaForm/index.d.ts.map +1 -1
  335. package/types/useConnectionFilters.d.ts +34 -0
  336. package/types/useConnectionFilters.d.ts.map +1 -0
  337. package/types/useFrappePagination.d.ts +18 -5
  338. package/types/useFrappePagination.d.ts.map +1 -1
  339. package/types/useFrappeRows.d.ts +5 -4
  340. package/types/useFrappeRows.d.ts.map +1 -1
  341. package/src/FileBrowser/AttachLibrary.spec.tsx +0 -74
@@ -1,4 +1,5 @@
1
1
  import {
2
+ getFrappeSyncModule,
2
3
  useFrappeCollection,
3
4
  useFrappeDoctypeEvents,
4
5
  useLiveQuery,
@@ -7,32 +8,54 @@ import {
7
8
  type DocTypeField,
8
9
  type FormSection,
9
10
  } from "@multiplatform.one/frappe";
10
- import { AsyncSkeleton } from "@multiplatform.one/components";
11
11
  import {
12
- SchemaForm,
13
- type FormSchema,
14
- type FormSection as GenericFormSection,
15
- type FormColumn as GenericFormColumn,
16
- type FieldOverride,
17
- } from "./schemaForm";
12
+ AsyncBoundary,
13
+ AsyncSkeleton,
14
+ DetailPageLayout,
15
+ PageHeader,
16
+ Tabs,
17
+ type TabsItem,
18
+ } from "@multiplatform.one/components";
19
+ import { SchemaForm, type FieldOverride } from "./schemaForm";
18
20
  import type { HeadingLevel } from "./fields/Heading";
19
- import type { FieldSchema } from "./fieldRegistry";
20
21
  import { FormSidebar, type SidebarSection, type SidebarMetadataEntry } from "./FormSidebar";
21
22
  import { eq } from "@tanstack/db";
22
- import { FloppyDiskIcon, TrashIcon, XIcon } from "@phosphor-icons/react";
23
+ import {
24
+ ArrowLeftIcon,
25
+ FloppyDiskIcon,
26
+ PencilSimpleIcon,
27
+ TrashIcon,
28
+ XIcon,
29
+ } from "@phosphor-icons/react";
23
30
  import type React from "react";
24
- import { useEffect, useMemo, useRef, useState } from "react";
31
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
25
32
  import type { TFunction } from "i18next";
26
33
  import { useTranslation } from "react-i18next";
27
- import { Button, formCommonColors } from "@multiplatform.one/forms";
34
+ import { Button, Input, formCommonColors } from "@multiplatform.one/forms";
28
35
  import { Text, XStack, YStack } from "tamagui";
29
36
  import { useResolvedKnobs } from "@multiplatform.one/theme";
30
37
  import { useFrappeConfig } from "./FrappeProvider";
31
38
  import { FrappeReconnectIndicator } from "./FrappeReconnectIndicator";
32
39
  import { Timeline } from "./components/timeline";
33
40
  import { dialog } from "@multiplatform.one/desktop";
41
+ import { applyFetchedValues, buildFetchMap } from "./formEngine/fetchFrom";
42
+ import { buildPermLevelMatrix } from "./formEngine/fieldPermissions";
43
+ import { createHttpClient, validateLink } from "./formEngine/formActions";
44
+ import { buildFormTabs, isPromptNamed, type FormTab } from "./formEngine/formLayout";
45
+ import { useSessionRoles } from "./formEngine/useSessionRoles";
46
+ import { connectionFields, connectionsFromMeta, type FrappeConnection } from "./frappeConnections";
47
+ import { FrappeFormConnections } from "./FrappeFormConnections";
48
+ import { FrappeRowsReader, type FrappeRowsSnapshot } from "./FrappeRowsReader";
49
+ import {
50
+ frappeRouteHref,
51
+ frappeRouteTarget,
52
+ useFrappeRouter,
53
+ type FrappeRouteTarget,
54
+ } from "./frappeNavigation";
55
+ import type { FieldSchema } from "./fieldRegistry";
34
56
 
35
- const LAYOUT_FIELDTYPES = new Set(["Section Break", "Column Break", "Tab Break"]);
57
+ /** Linked rows per connection doctype, as the render prop receives them. */
58
+ export type FrappeConnectionRows = Record<string, Array<Record<string, unknown>>>;
36
59
 
37
60
  function isEmptyFieldValue(value: unknown): boolean {
38
61
  if (value === undefined || value === null) return true;
@@ -57,7 +80,32 @@ export interface FrappeFormProps<TDoc extends { name: string; doctype: string }>
57
80
  meta?: DocTypeMeta;
58
81
  /** True when the form is read-only (disabled prop, or doc deleted on the server). */
59
82
  disabled: boolean;
83
+ /** The record's linked rows per connection doctype (edit mode), live. */
84
+ connections: FrappeConnectionRows;
85
+ /** A connection's rows are still being read. */
86
+ connectionsLoading: boolean;
60
87
  }) => React.ReactNode;
88
+ /**
89
+ * Doctypes linking back to this one (the desk's Connections). Defaults to
90
+ * meta.links; declare them where the reader cannot load meta (Guest).
91
+ * Schema mode shows them on a Connections tab; render-prop mode hands the
92
+ * rows to `children`.
93
+ */
94
+ connections?: readonly FrappeConnection[];
95
+ /** False leaves the Connections tab out of schema mode. */
96
+ showConnections?: boolean;
97
+ /**
98
+ * The document a route loader rendered on the server (`loadDoc`), shown
99
+ * until the live read lands so the server page and the hydrated page match.
100
+ * Ignored unless its name is `name`.
101
+ */
102
+ initialDoc?: Partial<TDoc>;
103
+ /** Replaces the skeleton while the document loads. */
104
+ loadingFallback?: React.ReactNode;
105
+ /** Replaces the not-found message for a name that does not resolve. */
106
+ notFoundFallback?: React.ReactNode;
107
+ /** Replaces the error state when the document cannot be read. */
108
+ errorFallback?: React.ReactNode;
61
109
  /** Per-field overrides for schema-driven mode (when children not provided). */
62
110
  overrides?: Record<string, FieldOverride>;
63
111
  /** Fields to hide in schema-driven mode. */
@@ -72,9 +120,31 @@ export interface FrappeFormProps<TDoc extends { name: string; doctype: string }>
72
120
  * names are ignored. Cheaper than replacing `sections`.
73
121
  */
74
122
  order?: string[];
123
+ /**
124
+ * Session roles for permlevel resolution. Omitted: the form asks the site
125
+ * for them when the doctype has fields above permlevel 0, and a fixtures
126
+ * connection takes the conservative rule (permlevel > 0 is read-only,
127
+ * never hidden).
128
+ */
129
+ roles?: string[];
130
+ /** Called after a save; a created document carries the name the server gave it. */
75
131
  onSave?: (doc: Partial<TDoc>) => void;
76
132
  onCancel?: () => void;
77
133
  onDelete?: (doc: Partial<TDoc>) => void;
134
+ /**
135
+ * Given, the form is a read-only view of the record: its toolbar offers
136
+ * Edit (this callback) and Delete instead of Save and Cancel.
137
+ */
138
+ onEdit?: () => void;
139
+ /**
140
+ * The path the doctype's pages live under (`/moves`). Given, the form is
141
+ * the doctype's record page: it reads the record from the path through
142
+ * the FrappeProvider's navigation (`route/new` creates, `route/<name>`
143
+ * views, `route/<name>/edit` edits), carries the detail page template,
144
+ * and after a save, cancel, edit or delete moves to the matching page.
145
+ * Callbacks given alongside it replace those moves.
146
+ */
147
+ route?: string;
78
148
  showToolbar?: boolean;
79
149
  showSidebar?: boolean;
80
150
  showTimeline?: boolean;
@@ -87,56 +157,76 @@ export interface FrappeFormProps<TDoc extends { name: string; doctype: string }>
87
157
  }
88
158
 
89
159
  // ---------------------------------------------------------------------------
90
- // Frappe DocTypeMeta → Generic FormSchema mapper
160
+ // Layout source for the form engine
91
161
  // ---------------------------------------------------------------------------
92
162
 
93
163
  /**
94
- * Maps a Frappe `DocTypeField` to a generic `FieldSchema`.
164
+ * A `sections` override re-expressed as the flat break-delimited field list
165
+ * the form engine lays out, so an overridden layout resolves every field
166
+ * exactly like the meta one.
95
167
  */
96
- function mapFieldToGenericSchema(field: DocTypeField): FieldSchema {
97
- return {
98
- type: field.fieldtype,
99
- name: field.fieldname,
100
- label: field.label,
101
- options: field.options,
102
- required: !!field.reqd,
103
- hidden: !!field.hidden,
104
- readOnly: !!field.read_only,
105
- precision: field.precision,
106
- depends_on: field.depends_on,
107
- // Pass through additional Frappe-specific metadata
108
- fieldtype: field.fieldtype,
109
- fieldname: field.fieldname,
110
- in_list_view: field.in_list_view,
111
- in_standard_filter: field.in_standard_filter,
112
- };
168
+ function sectionsToLayoutFields(sections: FormSection[]): DocTypeField[] {
169
+ const fields: DocTypeField[] = [];
170
+ sections.forEach((section, sectionIndex) => {
171
+ fields.push({
172
+ fieldname: `__section_${sectionIndex}`,
173
+ fieldtype: section.tab ? "Tab Break" : "Section Break",
174
+ label: section.label,
175
+ collapsible: section.collapsible ? 1 : 0,
176
+ });
177
+ section.columns.forEach((column, columnIndex) => {
178
+ if (columnIndex > 0) {
179
+ fields.push({
180
+ fieldname: `__column_${sectionIndex}_${columnIndex}`,
181
+ fieldtype: "Column Break",
182
+ });
183
+ }
184
+ fields.push(...column.fields);
185
+ });
186
+ });
187
+ return fields;
113
188
  }
114
189
 
190
+ // Document bookkeeping, never shown as a field of the record.
191
+ const documentBookkeeping = new Set([
192
+ "name",
193
+ "owner",
194
+ "creation",
195
+ "modified",
196
+ "modified_by",
197
+ "docstatus",
198
+ "idx",
199
+ "doctype",
200
+ "_user_tags",
201
+ "_comments",
202
+ "_assign",
203
+ "_liked_by",
204
+ "_seen",
205
+ ]);
206
+
115
207
  /**
116
- * Maps Frappe `DocTypeMeta` (with optional section overrides) to a
117
- * generic `FormSchema` consumable by `SchemaForm` from
118
- * `@multiplatform.one/forms`.
208
+ * A record's own scalar fields as read-only schema fields, for a reader without
209
+ * meta. `$`-keys are the collection's virtual props ($origin, $key,
210
+ * $collectionId), which the row carries but the record does not.
119
211
  */
120
- function mapMetaToFormSchema(meta: DocTypeMeta, sectionOverrides?: FormSection[]): FormSchema {
121
- const sections = sectionOverrides ?? meta.sections;
122
-
123
- const genericSections: GenericFormSection[] = sections.map((section) => ({
124
- label: section.label,
125
- collapsible: section.collapsible,
126
- columns: section.columns.map(
127
- (col): GenericFormColumn => ({
128
- fields: col.fields.map(mapFieldToGenericSchema),
129
- }),
130
- ),
131
- }));
132
-
133
- // Flat list of all fields for depends_on pre-compilation
134
- const allFields = meta.fields.map(mapFieldToGenericSchema);
212
+ function fieldsFromDocument(doc: Record<string, unknown>): FieldSchema[] {
213
+ return Object.entries(doc)
214
+ .filter(
215
+ ([key, value]) =>
216
+ !key.startsWith("$") &&
217
+ !documentBookkeeping.has(key) &&
218
+ (value === null || typeof value === "string" || typeof value === "number"),
219
+ )
220
+ .map(([key, value]) => ({
221
+ name: key,
222
+ label: key.replace(/_/g, " ").replace(/(^|\s)\S/g, (c) => c.toUpperCase()),
223
+ type: typeof value === "number" ? (Number.isInteger(value) ? "Int" : "Float") : "Data",
224
+ readOnly: true,
225
+ }));
226
+ }
135
227
 
136
- return {
137
- sections: genericSections,
138
- fields: allFields,
139
- };
228
+ function hasRestrictedPermLevel(meta: DocTypeMeta | undefined): boolean {
229
+ return !!meta?.fields.some((field) => Number(field.permlevel ?? 0) > 0);
140
230
  }
141
231
 
142
232
  // ---------------------------------------------------------------------------
@@ -268,13 +358,75 @@ export function mapDocToSidebarMetadata(
268
358
  // ---------------------------------------------------------------------------
269
359
 
270
360
  /**
271
- * Thin Frappe wrapper around the generic SchemaForm component.
361
+ * The form for a Frappe doctype, resolved from meta by the desk form engine.
272
362
  *
273
- * Fetches Frappe doctype metadata and document data, maps the metadata
274
- * to a generic `FormSchema`, and passes it to `SchemaForm` from
275
- * `@multiplatform.one/forms`.
363
+ * Fetches doctype metadata and the document, lays the fields out through
364
+ * `buildFormTabs` (tabs, sections, `depends_on`, `mandatory_depends_on`,
365
+ * `read_only_depends_on`, `collapsible_depends_on`, permlevel,
366
+ * `set_only_once`, `fetch_from` and docstatus), and renders each tab with
367
+ * `SchemaForm`. With a `route` it is the doctype's record page.
276
368
  */
277
- export function FrappeForm<TDoc extends { name: string; doctype: string }>({
369
+ export function FrappeForm<TDoc extends { name: string; doctype: string }>(
370
+ props: FrappeFormProps<TDoc>,
371
+ ) {
372
+ const { route, ...formProps } = props;
373
+ if (route === undefined) return <FrappeFormBody<TDoc> {...formProps} />;
374
+ return <FrappeFormRoute<TDoc> {...formProps} route={route} />;
375
+ }
376
+
377
+ type FrappeFormBodyProps<TDoc extends { name: string; doctype: string }> = Omit<
378
+ FrappeFormProps<TDoc>,
379
+ "route"
380
+ > & {
381
+ /** Routed page: the detail template, with a back action to the list. */
382
+ routePage?: { onBack: () => void };
383
+ };
384
+
385
+ function FrappeFormRoute<TDoc extends { name: string; doctype: string }>({
386
+ route,
387
+ name: nameProp,
388
+ onSave,
389
+ onCancel,
390
+ onDelete,
391
+ onEdit,
392
+ ...props
393
+ }: Omit<FrappeFormProps<TDoc>, "route"> & { route: string }) {
394
+ const router = useFrappeRouter();
395
+ const fromPath = router ? frappeRouteTarget(route, router.pathname) : undefined;
396
+ const target: FrappeRouteTarget = nameProp
397
+ ? { mode: fromPath?.mode === "view" ? "view" : "edit", name: nameProp }
398
+ : fromPath?.mode === "view" || fromPath?.mode === "edit"
399
+ ? fromPath
400
+ : { mode: "create" };
401
+ const go = (next: FrappeRouteTarget) => router?.navigate(frappeRouteHref(route, next));
402
+ const name = target.mode === "view" || target.mode === "edit" ? target.name : undefined;
403
+
404
+ return (
405
+ <FrappeFormBody<TDoc>
406
+ {...props}
407
+ // A new target is a new page: no state carries over from the last one.
408
+ key={`${target.mode}:${name ?? ""}`}
409
+ name={name}
410
+ routePage={{ onBack: () => go({ mode: "list" }) }}
411
+ onEdit={
412
+ target.mode === "view"
413
+ ? (onEdit ?? (() => go({ mode: "edit", name: target.name })))
414
+ : undefined
415
+ }
416
+ onSave={
417
+ onSave ??
418
+ ((doc) => {
419
+ const saved = doc.name ?? name;
420
+ go(saved ? { mode: "view", name: saved } : { mode: "list" });
421
+ })
422
+ }
423
+ onCancel={onCancel ?? (() => go(name ? { mode: "view", name } : { mode: "list" }))}
424
+ onDelete={onDelete ?? (() => go({ mode: "list" }))}
425
+ />
426
+ );
427
+ }
428
+
429
+ function FrappeFormBody<TDoc extends { name: string; doctype: string }>({
278
430
  baseURL,
279
431
  auth,
280
432
  syncOptions,
@@ -287,27 +439,42 @@ export function FrappeForm<TDoc extends { name: string; doctype: string }>({
287
439
  readOnlyFields,
288
440
  sections,
289
441
  order,
442
+ roles,
290
443
  onSave,
291
444
  onCancel,
292
445
  onDelete,
446
+ onEdit,
293
447
  showToolbar = true,
294
448
  showSidebar = true,
295
449
  showTimeline = true,
296
450
  disabled = false,
297
451
  sectionHeadingLevel,
298
- }: FrappeFormProps<TDoc>) {
452
+ connections,
453
+ showConnections = true,
454
+ initialDoc,
455
+ loadingFallback,
456
+ notFoundFallback,
457
+ errorFallback,
458
+ routePage,
459
+ }: FrappeFormBodyProps<TDoc>) {
299
460
  const config = useFrappeConfig();
300
461
  const { knobProps } = useResolvedKnobs();
301
462
  const { t } = useTranslation();
302
- const [localDoc, setLocalDoc] = useState<Partial<TDoc>>({});
463
+ const seedDoc =
464
+ name && initialDoc && (initialDoc as { name?: string }).name === name ? initialDoc : undefined;
465
+ const seedRef = useRef(seedDoc);
466
+ seedRef.current = seedDoc;
467
+ const [localDoc, setLocalDoc] = useState<Partial<TDoc>>(() => seedDoc ?? {});
303
468
  /** Last authoritative server snapshot applied to the form (dirty = localDoc ≠ baseline). */
304
- const [baselineDoc, setBaselineDoc] = useState<Partial<TDoc> | undefined>(undefined);
469
+ const [baselineDoc, setBaselineDoc] = useState<Partial<TDoc> | undefined>(() => seedDoc);
470
+ const [docError, setDocError] = useState<Error | null>(null);
305
471
  /** Fields the user edited that ALSO changed on the server since baseline. */
306
472
  const [conflictFields, setConflictFields] = useState<string[]>([]);
307
473
  /** The open document was deleted on the server (doc_deleted / tombstone / eviction). */
308
474
  const [wasDeleted, setWasDeleted] = useState(false);
309
475
  const [isSubmitting, setIsSubmitting] = useState(false);
310
476
  const [errors, setErrors] = useState<Record<string, string>>({});
477
+ const [activeTab, setActiveTab] = useState<string | undefined>(undefined);
311
478
  const everLoadedRef = useRef(false);
312
479
  const localDocRef = useRef(localDoc);
313
480
  localDocRef.current = localDoc;
@@ -352,17 +519,58 @@ export function FrappeForm<TDoc extends { name: string; doctype: string }>({
352
519
  error: metaError,
353
520
  } = useDocTypeMeta(frappeConfig, doctype);
354
521
 
355
- // Map Frappe meta to generic FormSchema
356
- const formSchema = useMemo(() => {
522
+ const sessionRoles = useSessionRoles(
523
+ !roles && hasRestrictedPermLevel(meta) ? frappeConfig : undefined,
524
+ );
525
+ const effectiveRoles = roles ?? sessionRoles;
526
+ const permLevels = useMemo(
527
+ () => buildPermLevelMatrix(meta?.permissions, effectiveRoles),
528
+ [meta?.permissions, effectiveRoles],
529
+ );
530
+
531
+ const isCreate = !name;
532
+ const docstatus = Number((baselineDoc as Record<string, unknown> | undefined)?.docstatus ?? 0);
533
+ const layoutFields = useMemo(() => {
357
534
  if (!meta) return undefined;
358
- return mapMetaToFormSchema(meta, sections);
535
+ return sections ? sectionsToLayoutFields(sections) : meta.fields;
359
536
  }, [meta, sections]);
537
+ const formTabs = useMemo(() => {
538
+ if (!layoutFields) return undefined;
539
+ return buildFormTabs({
540
+ fields: layoutFields,
541
+ doc: { ...(localDoc as Record<string, unknown>), __islocal: isCreate ? 1 : 0 },
542
+ docstatus,
543
+ formReadOnly: !!meta?.read_only,
544
+ isCreate,
545
+ detailsLabel: t("Details"),
546
+ permLevels,
547
+ hiddenFields: hideFields,
548
+ });
549
+ }, [layoutFields, localDoc, docstatus, meta?.read_only, isCreate, permLevels, hideFields, t]);
550
+ // Meta is permission-gated (Guest gets 403) while the record may still be
551
+ // readable: the form then lays the record's own fields out, read-only.
552
+ const readsWithoutMeta = !children && !meta && Boolean(metaError) && Boolean(name);
553
+ const recordTabs = useMemo((): FormTab[] | undefined => {
554
+ if (!readsWithoutMeta || !baselineDoc) return undefined;
555
+ const recordFields = fieldsFromDocument(baselineDoc as Record<string, unknown>);
556
+ return [
557
+ {
558
+ key: "__details",
559
+ label: t("Details"),
560
+ schema: { sections: [{ columns: [{ fields: recordFields }] }], fields: recordFields },
561
+ },
562
+ ];
563
+ }, [readsWithoutMeta, baselineDoc, t]);
564
+ const promptNamed = isCreate && !!meta && isPromptNamed(meta.autoname, meta.naming_rule);
565
+ const fetchMap = useMemo(() => (meta ? buildFetchMap(meta.fields) : undefined), [meta]);
360
566
 
361
- const hideFieldSet = useMemo(() => new Set(hideFields ?? []), [hideFields]);
362
-
567
+ const handleDocSyncError = useCallback((err: unknown) => {
568
+ setDocError(err instanceof Error ? err : new Error(String(err)));
569
+ }, []);
363
570
  const docCollection = useFrappeCollection<TDoc>(name ? frappeConfig : undefined, doctype, {
364
571
  fields,
365
572
  filters: name ? { name } : undefined,
573
+ onSyncError: handleDocSyncError,
366
574
  });
367
575
  const { data: rawDocData, isLoading: isDocLoading } = useLiveQuery(
368
576
  (q) => {
@@ -440,9 +648,53 @@ export function FrappeForm<TDoc extends { name: string; doctype: string }>({
440
648
  everLoadedRef.current = false;
441
649
  setWasDeleted(false);
442
650
  setConflictFields([]);
443
- setBaselineDoc(undefined);
651
+ setDocError(null);
652
+ const seed = seedRef.current;
653
+ setBaselineDoc(seed);
654
+ if (seed) setLocalDoc(seed);
444
655
  }, [doctype, name]);
445
656
 
657
+ const effectiveConnections = useMemo(
658
+ () => connections ?? connectionsFromMeta(meta),
659
+ [connections, meta],
660
+ );
661
+ const readsConnections = Boolean(children) && Boolean(name) && effectiveConnections.length > 0;
662
+ const [connectionSnapshots, setConnectionSnapshots] = useState<
663
+ Record<string, FrappeRowsSnapshot<Record<string, unknown> & { name: string; doctype: string }>>
664
+ >({});
665
+ useEffect(() => {
666
+ setConnectionSnapshots({});
667
+ }, [doctype, name]);
668
+ const connectionReads = useMemo(
669
+ () =>
670
+ readsConnections && name
671
+ ? effectiveConnections.map((link) => ({
672
+ link,
673
+ options: {
674
+ fields: connectionFields(link),
675
+ filters: [[link.fieldname, "=", name]] as Array<[string, string, unknown]>,
676
+ limit: link.limit ?? 0,
677
+ },
678
+ onChange: (
679
+ snapshot: FrappeRowsSnapshot<
680
+ Record<string, unknown> & { name: string; doctype: string }
681
+ >,
682
+ ) => setConnectionSnapshots((prev) => ({ ...prev, [link.doctype]: snapshot })),
683
+ }))
684
+ : [],
685
+ [readsConnections, effectiveConnections, name],
686
+ );
687
+ const connectionRows = useMemo(() => {
688
+ const rows: FrappeConnectionRows = {};
689
+ for (const { link } of connectionReads) {
690
+ rows[link.doctype] = connectionSnapshots[link.doctype]?.rows ?? [];
691
+ }
692
+ return rows;
693
+ }, [connectionReads, connectionSnapshots]);
694
+ const connectionsLoading = connectionReads.some(
695
+ ({ link }) => connectionSnapshots[link.doctype]?.isLoading ?? true,
696
+ );
697
+
446
698
  // Immediate honest deleted state: the live app emits doc_deleted for the
447
699
  // open doc's doctype room; tombstone backfill / cache eviction below is the
448
700
  // durable fallback for deletions that happen while disconnected.
@@ -508,6 +760,38 @@ export function FrappeForm<TDoc extends { name: string; doctype: string }>({
508
760
  }, [localDoc, baselineDoc]);
509
761
  const hasUnsavedChanges = dirtyFieldNames.length > 0;
510
762
 
763
+ // fetch_from preview: a changed Link pulls its dependent columns off the
764
+ // linked doc (validate_link on a bench, the fixture corpus otherwise) and
765
+ // patches the targets; clearing the link blanks them.
766
+ const runFetchFrom = (field: string, value: unknown) => {
767
+ const rules = fetchMap?.get(field);
768
+ if (!rules || rules.length === 0) return;
769
+ const applyPatch = (fetched: Record<string, unknown>) => {
770
+ setLocalDoc((prev) => {
771
+ const current = prev as Record<string, unknown>;
772
+ if (current[field] !== value) return prev;
773
+ const patch = applyFetchedValues(rules, fetched, current);
774
+ return Object.keys(patch).length > 0 ? ({ ...current, ...patch } as Partial<TDoc>) : prev;
775
+ });
776
+ };
777
+ if (isEmptyFieldValue(value)) {
778
+ applyPatch({});
779
+ return;
780
+ }
781
+ const linkField = meta?.fields.find((metaField) => metaField.fieldname === field);
782
+ const targetDoctype = typeof linkField?.options === "string" ? linkField.options : "";
783
+ if (!targetDoctype) return;
784
+ const sources = Array.from(new Set(rules.map((rule) => rule.source)));
785
+ const fixtures = frappeConfig.fixtures;
786
+ const fetched = fixtures
787
+ ? fixtures
788
+ .getDoc(targetDoctype, String(value), sources)
789
+ .then((doc) => (doc ?? {}) as Record<string, unknown>)
790
+ : validateLink(createHttpClient(frappeConfig), targetDoctype, String(value), sources);
791
+ // An unknown link leaves the targets as they are; the server re-validates on save.
792
+ fetched.then(applyPatch).catch(() => undefined);
793
+ };
794
+
511
795
  const setValue = (field: keyof TDoc, value: unknown) => {
512
796
  setLocalDoc((prev) => ({
513
797
  ...prev,
@@ -518,20 +802,27 @@ export function FrappeForm<TDoc extends { name: string; doctype: string }>({
518
802
  delete newErrors[String(field)];
519
803
  return newErrors;
520
804
  });
805
+ runFetchFrom(String(field), value);
521
806
  };
522
807
 
808
+ // Required = what the engine resolved: reqd or a truthy
809
+ // mandatory_depends_on, on fields that are actually laid out (depends_on,
810
+ // permlevel and host-hidden fields never reach the tabs).
523
811
  const validateRequiredFields = (): Record<string, string> => {
524
- if (!meta) return {};
812
+ if (!formTabs) return {};
525
813
  const validationErrors: Record<string, string> = {};
526
814
  const doc = localDoc as Record<string, unknown>;
527
- for (const field of meta.fields) {
528
- if (!field.reqd) continue;
529
- if (field.hidden || LAYOUT_FIELDTYPES.has(field.fieldtype)) continue;
530
- if (hideFieldSet.has(field.fieldname)) continue;
531
- if (isEmptyFieldValue(doc[field.fieldname])) {
532
- validationErrors[field.fieldname] = t("{{label}} is required", {
533
- label: field.label || field.fieldname,
534
- });
815
+ if (promptNamed && isEmptyFieldValue(doc.name)) {
816
+ validationErrors.name = t("Name is required");
817
+ }
818
+ for (const tab of formTabs) {
819
+ for (const field of tab.schema.fields) {
820
+ if (!field.required) continue;
821
+ if (isEmptyFieldValue(doc[field.name])) {
822
+ validationErrors[field.name] = t("{{label}} is required", {
823
+ label: field.label || field.name,
824
+ });
825
+ }
535
826
  }
536
827
  }
537
828
  return validationErrors;
@@ -567,15 +858,15 @@ export function FrappeForm<TDoc extends { name: string; doctype: string }>({
567
858
  setConflictFields([]);
568
859
  } else {
569
860
  if (!collection) return;
570
- const docToInsert = {
571
- ...localDoc,
572
- doctype,
573
- name:
574
- ((localDoc as Record<string, unknown>).name as string | undefined) ??
575
- `new-${Date.now()}`,
576
- } as TDoc;
861
+ const temporaryName =
862
+ ((localDoc as Record<string, unknown>).name as string | undefined) ?? `new-${Date.now()}`;
863
+ const docToInsert = { ...localDoc, doctype, name: temporaryName } as TDoc;
577
864
  const tx = collection.insert(docToInsert);
578
865
  await tx.isPersisted.promise;
866
+ const createdName =
867
+ getFrappeSyncModule(frappeConfig)?.createdName(temporaryName) ?? temporaryName;
868
+ onSave?.({ ...localDoc, name: createdName } as Partial<TDoc>);
869
+ return;
579
870
  }
580
871
  onSave?.(localDoc as Partial<TDoc>);
581
872
  } catch (err: unknown) {
@@ -634,17 +925,46 @@ export function FrappeForm<TDoc extends { name: string; doctype: string }>({
634
925
  }
635
926
  };
636
927
 
928
+ const titleField = meta?.title_field;
929
+ const titleValue = titleField ? (localDoc as Record<string, unknown>)[titleField] : undefined;
930
+ const pageTitle = isCreate ? t("New {{doctype}}", { doctype }) : String(titleValue || name);
931
+ const frame = (content: React.ReactNode, aside?: React.ReactNode) =>
932
+ routePage ? (
933
+ // flexBasis auto: see DeskFormPage (react-native-web's min-height:0
934
+ // reset would cap the scroll page at viewport height).
935
+ <DetailPageLayout
936
+ flexBasis="auto"
937
+ toolbarLeading={
938
+ <Button
939
+ size="$3"
940
+ chromeless
941
+ icon={ArrowLeftIcon}
942
+ onPress={routePage.onBack}
943
+ aria-label={t("Back to {{doctype}} list", { doctype })}
944
+ >
945
+ {doctype}
946
+ </Button>
947
+ }
948
+ header={<PageHeader title={pageTitle} divider={false} />}
949
+ aside={aside}
950
+ >
951
+ {content}
952
+ </DetailPageLayout>
953
+ ) : (
954
+ content
955
+ );
956
+
637
957
  // Schema-driven mode needs meta; render-prop mode can proceed without it.
638
958
  if (!children && isMetaLoading && !meta) {
639
- return (
959
+ return frame(
640
960
  <YStack {...knobProps.panelPadding} testID="frappe-form-skeleton">
641
961
  <AsyncSkeleton layout="generic" />
642
- </YStack>
962
+ </YStack>,
643
963
  );
644
964
  }
645
965
 
646
- if (!children && metaError && !meta) {
647
- return (
966
+ if (!children && metaError && !meta && !name) {
967
+ return frame(
648
968
  <YStack {...knobProps.panelPadding} alignItems="center" {...knobProps.gap}>
649
969
  <Text color={formCommonColors.error} {...knobProps.body}>
650
970
  {t("Failed to load {{doctype}} form", { doctype })}
@@ -652,22 +972,40 @@ export function FrappeForm<TDoc extends { name: string; doctype: string }>({
652
972
  <Text color={formCommonColors.muted} {...knobProps.body}>
653
973
  {metaError.message}
654
974
  </Text>
655
- </YStack>
975
+ </YStack>,
976
+ );
977
+ }
978
+
979
+ // No collection yet (server render, hydration pass) reads as loading: the
980
+ // document is not known to be missing until the live read answers.
981
+ const isDocReading = Boolean(name) && (!docCollection || Boolean(liveDoc?.isLoading));
982
+ const showsSeed = Boolean(seedDoc) && isDocReading;
983
+
984
+ if (name && docError && !liveDoc?.data && !wasDeleted) {
985
+ if (errorFallback !== undefined) return frame(errorFallback);
986
+ return frame(
987
+ <AsyncBoundary
988
+ {...knobProps.panelPadding}
989
+ error={docError}
990
+ errorTitle={t("Failed to load {{doctype}}", { doctype })}
991
+ />,
656
992
  );
657
993
  }
658
994
 
659
- if (name && liveDoc?.isLoading && !wasDeleted) {
660
- return (
995
+ if (name && isDocReading && !showsSeed && !wasDeleted) {
996
+ if (loadingFallback !== undefined) return frame(loadingFallback);
997
+ return frame(
661
998
  <YStack {...knobProps.panelPadding} testID="frappe-form-skeleton">
662
999
  <AsyncSkeleton layout="generic" />
663
- </YStack>
1000
+ </YStack>,
664
1001
  );
665
1002
  }
666
1003
 
667
1004
  // Never-loaded miss (bad name, or read denied) — distinct from the
668
1005
  // deleted-while-open state below, which keeps the form mounted.
669
- if (name && !liveDoc?.isLoading && !liveDoc?.data && !wasDeleted) {
670
- return (
1006
+ if (name && !isDocReading && !liveDoc?.data && !wasDeleted) {
1007
+ if (notFoundFallback !== undefined) return frame(notFoundFallback);
1008
+ return frame(
671
1009
  <YStack {...knobProps.panelPadding} alignItems="center" {...knobProps.gap}>
672
1010
  <Text color={formCommonColors.error} {...knobProps.body}>
673
1011
  {t("{{doctype}} not found", { doctype })}
@@ -675,11 +1013,14 @@ export function FrappeForm<TDoc extends { name: string; doctype: string }>({
675
1013
  <Text color={formCommonColors.muted} {...knobProps.body}>
676
1014
  {t("Document {{name}} does not exist or could not be loaded.", { name })}
677
1015
  </Text>
678
- </YStack>
1016
+ </YStack>,
679
1017
  );
680
1018
  }
681
1019
 
682
1020
  const effectiveDisabled = disabled || wasDeleted;
1021
+ const readOnlyView = Boolean(onEdit) || readsWithoutMeta;
1022
+ const fieldsDisabled = effectiveDisabled || readOnlyView;
1023
+ const shownTabs = formTabs ?? recordTabs;
683
1024
  const imageFieldName = meta?.image_field;
684
1025
  const sidebarImageUrl = imageFieldName
685
1026
  ? resolveFileUrl((localDoc as Record<string, unknown>)[imageFieldName], finalBaseURL)
@@ -691,13 +1032,73 @@ export function FrappeForm<TDoc extends { name: string; doctype: string }>({
691
1032
  })
692
1033
  .join(", ");
693
1034
 
694
- return (
1035
+ const formTabItems: TabsItem[] = (shownTabs ?? []).map((tab) => ({
1036
+ value: tab.key,
1037
+ label: tab.label,
1038
+ content: (
1039
+ <SchemaForm
1040
+ schema={tab.schema}
1041
+ doc={localDoc as Record<string, any>}
1042
+ setValue={(field, value) => setValue(field as keyof TDoc, value)}
1043
+ errors={errors}
1044
+ disabled={fieldsDisabled}
1045
+ overrides={overrides}
1046
+ readOnlyFields={readOnlyFields}
1047
+ sectionHeadingLevel={sectionHeadingLevel}
1048
+ order={order}
1049
+ />
1050
+ ),
1051
+ }));
1052
+ const tabItems: TabsItem[] =
1053
+ showConnections && name && effectiveConnections.length > 0
1054
+ ? [
1055
+ ...formTabItems,
1056
+ {
1057
+ value: "__connections",
1058
+ label: t("Connections"),
1059
+ content: (
1060
+ <FrappeFormConnections
1061
+ connection={frappeConfig}
1062
+ connections={effectiveConnections}
1063
+ name={name}
1064
+ />
1065
+ ),
1066
+ },
1067
+ ]
1068
+ : formTabItems;
1069
+ const effectiveTab =
1070
+ activeTab && tabItems.some((tab) => tab.value === activeTab) ? activeTab : tabItems[0]?.value;
1071
+ const localName = (localDoc as Record<string, unknown>).name;
1072
+ const sidebar =
1073
+ showSidebar && name && meta ? (
1074
+ <FormSidebar
1075
+ imageUrl={sidebarImageUrl}
1076
+ sections={buildSidebarSections(
1077
+ localDoc as Record<string, any>,
1078
+ shareRows,
1079
+ t,
1080
+ assignmentRows,
1081
+ )}
1082
+ metadata={mapDocToSidebarMetadata(localDoc as Record<string, any>, t)}
1083
+ />
1084
+ ) : undefined;
1085
+
1086
+ return frame(
695
1087
  // flexBasis auto (not the flex:1 shorthand's 0%): inside scroll-flow page
696
1088
  // scaffolds (LC-29 templates) the parent is auto-height, and a 0% basis
697
1089
  // collapses this stack to zero height on web — content then paints over
698
1090
  // whatever follows. Bounded parents still fill (grow 1) exactly as before.
699
1091
  <YStack {...knobProps.gapLg} flexGrow={1} flexShrink={1} flexBasis="auto">
700
1092
  <FrappeReconnectIndicator config={frappeConfig} />
1093
+ {connectionReads.map((read) => (
1094
+ <FrappeRowsReader
1095
+ key={read.link.doctype}
1096
+ connection={frappeConfig}
1097
+ doctype={read.link.doctype}
1098
+ options={read.options as never}
1099
+ onChange={read.onChange}
1100
+ />
1101
+ ))}
701
1102
 
702
1103
  {wasDeleted && (
703
1104
  <XStack
@@ -742,19 +1143,9 @@ export function FrappeForm<TDoc extends { name: string; doctype: string }>({
742
1143
  {/* flexBasis auto — see the root stack note (zero-height collapse in
743
1144
  auto-height scroll flows would paint the Timeline over the form). */}
744
1145
  <XStack flexGrow={1} flexShrink={1} flexBasis="auto" {...knobProps.gapLg}>
745
- {/* Sidebar (edit mode only) — assignments/shares/image are live */}
746
- {showSidebar && name && meta && (
747
- <FormSidebar
748
- imageUrl={sidebarImageUrl}
749
- sections={buildSidebarSections(
750
- localDoc as Record<string, any>,
751
- shareRows,
752
- t,
753
- assignmentRows,
754
- )}
755
- metadata={mapDocToSidebarMetadata(localDoc as Record<string, any>, t)}
756
- />
757
- )}
1146
+ {/* Sidebar (edit mode only) — assignments/shares/image are live.
1147
+ A routed page carries it in the template's aside instead. */}
1148
+ {routePage ? null : sidebar}
758
1149
 
759
1150
  {/* Form body */}
760
1151
  <YStack flex={1} {...knobProps.gapLg}>
@@ -766,22 +1157,28 @@ export function FrappeForm<TDoc extends { name: string; doctype: string }>({
766
1157
  isSubmitting,
767
1158
  errors,
768
1159
  meta,
769
- disabled: effectiveDisabled,
1160
+ disabled: fieldsDisabled,
1161
+ connections: connectionRows,
1162
+ connectionsLoading,
770
1163
  })
771
- ) : formSchema ? (
772
- // Schema-driven mode using generic SchemaForm from @multiplatform.one/forms
773
- <SchemaForm
774
- schema={formSchema}
775
- doc={localDoc as Record<string, any>}
776
- setValue={(field, value) => setValue(field as keyof TDoc, value)}
777
- errors={errors}
778
- disabled={effectiveDisabled}
779
- overrides={overrides}
780
- hideFields={hideFields}
781
- readOnlyFields={readOnlyFields}
782
- sectionHeadingLevel={sectionHeadingLevel}
783
- order={order}
784
- />
1164
+ ) : shownTabs ? (
1165
+ <YStack {...knobProps.gapLg}>
1166
+ {promptNamed && (
1167
+ <Input
1168
+ label={t("Name")}
1169
+ value={typeof localName === "string" ? localName : ""}
1170
+ onChangeText={(value) => setValue("name" as keyof TDoc, value)}
1171
+ placeholder={t("New document name")}
1172
+ error={errors.name}
1173
+ disabled={effectiveDisabled}
1174
+ />
1175
+ )}
1176
+ {tabItems.length > 1 ? (
1177
+ <Tabs items={tabItems} value={effectiveTab} onChange={setActiveTab} lazyMount />
1178
+ ) : (
1179
+ (tabItems[0]?.content ?? null)
1180
+ )}
1181
+ </YStack>
785
1182
  ) : (
786
1183
  // Metadata unavailable (should be covered by metaError branch above)
787
1184
  <YStack {...knobProps.panelPadding} alignItems="center">
@@ -797,14 +1194,14 @@ export function FrappeForm<TDoc extends { name: string; doctype: string }>({
797
1194
  </Text>
798
1195
  )}
799
1196
 
800
- {showToolbar && (
1197
+ {showToolbar && !readsWithoutMeta && (
801
1198
  <XStack
802
1199
  {...knobProps.gap}
803
1200
  justifyContent="flex-end"
804
1201
  alignItems="center"
805
1202
  paddingTop="$3"
806
1203
  >
807
- {hasUnsavedChanges && (
1204
+ {hasUnsavedChanges && !onEdit && (
808
1205
  <Text color="$orange11" marginInlineEnd="auto" {...knobProps.body}>
809
1206
  {t("Not Saved")}
810
1207
  </Text>
@@ -825,37 +1222,55 @@ export function FrappeForm<TDoc extends { name: string; doctype: string }>({
825
1222
  </Button>
826
1223
  )}
827
1224
 
828
- {onCancel && (
1225
+ {onEdit ? (
829
1226
  <Button
830
- outlined
831
- icon={XIcon}
832
- disabled={isSubmitting || disabled}
1227
+ accent
1228
+ icon={PencilSimpleIcon}
1229
+ disabled={effectiveDisabled}
833
1230
  disabledReason={t("Form is read-only")}
834
1231
  disabledReasonPlacement="inline"
835
- onPress={handleCancel}
1232
+ onPress={onEdit}
836
1233
  >
837
- {t("Cancel")}
1234
+ {t("Edit")}
838
1235
  </Button>
839
- )}
1236
+ ) : (
1237
+ <>
1238
+ {onCancel && (
1239
+ <Button
1240
+ outlined
1241
+ icon={XIcon}
1242
+ disabled={disabled}
1243
+ disabledReason={t("Form is read-only")}
1244
+ disabledReasonPlacement="inline"
1245
+ onPress={handleCancel}
1246
+ >
1247
+ {t("Cancel")}
1248
+ </Button>
1249
+ )}
840
1250
 
841
- <Button
842
- accent
843
- icon={FloppyDiskIcon}
844
- loading={isSubmitting}
845
- disabled={effectiveDisabled}
846
- disabledReason={t("Form is read-only")}
847
- disabledReasonPlacement="inline"
848
- onPress={handleSave}
849
- >
850
- {isSubmitting ? t("Saving...") : t("Save")}
851
- </Button>
1251
+ <Button
1252
+ accent
1253
+ icon={FloppyDiskIcon}
1254
+ loading={isSubmitting}
1255
+ disabled={effectiveDisabled}
1256
+ disabledReason={t("Form is read-only")}
1257
+ disabledReasonPlacement="inline"
1258
+ onPress={handleSave}
1259
+ >
1260
+ {isSubmitting ? t("Saving...") : t("Save")}
1261
+ </Button>
1262
+ </>
1263
+ )}
852
1264
  </XStack>
853
1265
  )}
854
1266
  </YStack>
855
1267
  </XStack>
856
1268
 
857
1269
  {/* Timeline (edit mode only) — live Comment/Version via useFrappeCollection */}
858
- {showTimeline && name && <Timeline doctype={doctype} docname={name} label={t("Activity")} />}
859
- </YStack>
1270
+ {showTimeline && name && !readsWithoutMeta && (
1271
+ <Timeline doctype={doctype} docname={name} label={t("Activity")} />
1272
+ )}
1273
+ </YStack>,
1274
+ routePage ? sidebar : undefined,
860
1275
  );
861
1276
  }