@multiplatform.one/frappe-ui 7.10.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 (342) 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 +26 -22
  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.catchUp.spec.tsx +502 -0
  264. package/src/FrappeProvider.tsx +15 -3
  265. package/src/FrappeReportBuilder.tsx +11 -19
  266. package/src/FrappeRowsReader.tsx +38 -0
  267. package/src/FrappeTable.optionalProps.spec.tsx +249 -0
  268. package/src/FrappeTable.spec.tsx +32 -42
  269. package/src/FrappeTable.stories.spec.tsx +55 -0
  270. package/src/FrappeTable.stories.tsx +28 -37
  271. package/src/FrappeTable.tsx +108 -45
  272. package/src/childTableDependsOn.spec.tsx +85 -0
  273. package/src/desk/DeskFormSidebar.spec.tsx +20 -0
  274. package/src/desk/DeskFormSidebar.stories.tsx +42 -0
  275. package/src/fieldRegistry.spec.tsx +27 -64
  276. package/src/fieldRegistry.ts +71 -14
  277. package/src/fields/Date/index.native.tsx +4 -2
  278. package/src/fields/Date/sheetKeyboard.spec.ts +14 -5
  279. package/src/fields/Date/sheetKeyboard.ts +12 -4
  280. package/src/fields/DateRange/DateRange.spec.tsx +67 -0
  281. package/src/fields/DateRange/index.tsx +5 -1
  282. package/src/fields/LinkTree.spec.tsx +8 -0
  283. package/src/fields/LinkTree.stories.tsx +28 -5
  284. package/src/fields/Select/Select.hydration.spec.tsx +177 -0
  285. package/src/formEngine/doctypeMeta.ts +15 -114
  286. package/src/formEngine/index.ts +2 -0
  287. package/src/formEngine/seam.spec.ts +23 -4
  288. package/src/formEngine/useSessionRoles.ts +73 -0
  289. package/src/frappeConnections.ts +50 -0
  290. package/src/frappeNavigation.tsx +112 -0
  291. package/src/frappeRoutes.spec.tsx +335 -0
  292. package/src/index.ts +28 -1
  293. package/src/schemaForm/index.tsx +1 -0
  294. package/src/useConnectionFilters.tsx +157 -0
  295. package/src/useFrappePagination.ts +70 -26
  296. package/src/useFrappeRows.sharedPath.spec.tsx +206 -0
  297. package/src/useFrappeRows.ts +5 -4
  298. package/types/FrappeDashboard.d.ts +3 -4
  299. package/types/FrappeDashboard.d.ts.map +1 -1
  300. package/types/FrappeForm.d.ts +57 -5
  301. package/types/FrappeForm.d.ts.map +1 -1
  302. package/types/FrappeFormConnections.d.ts +18 -0
  303. package/types/FrappeFormConnections.d.ts.map +1 -0
  304. package/types/FrappeImageView.d.ts +2 -3
  305. package/types/FrappeImageView.d.ts.map +1 -1
  306. package/types/FrappeList.d.ts +20 -2
  307. package/types/FrappeList.d.ts.map +1 -1
  308. package/types/FrappeListView.d.ts +53 -3
  309. package/types/FrappeListView.d.ts.map +1 -1
  310. package/types/FrappeProvider.d.ts +10 -3
  311. package/types/FrappeProvider.d.ts.map +1 -1
  312. package/types/FrappeReportBuilder.d.ts.map +1 -1
  313. package/types/FrappeRowsReader.d.ts +26 -0
  314. package/types/FrappeRowsReader.d.ts.map +1 -0
  315. package/types/FrappeTable.d.ts +39 -8
  316. package/types/FrappeTable.d.ts.map +1 -1
  317. package/types/fieldRegistry.d.ts +17 -3
  318. package/types/fieldRegistry.d.ts.map +1 -1
  319. package/types/fields/Date/index.native.d.ts.map +1 -1
  320. package/types/fields/Date/sheetKeyboard.d.ts +4 -3
  321. package/types/fields/Date/sheetKeyboard.d.ts.map +1 -1
  322. package/types/fields/DateRange/index.d.ts.map +1 -1
  323. package/types/formEngine/doctypeMeta.d.ts +10 -114
  324. package/types/formEngine/doctypeMeta.d.ts.map +1 -1
  325. package/types/formEngine/index.d.ts +1 -0
  326. package/types/formEngine/index.d.ts.map +1 -1
  327. package/types/formEngine/useSessionRoles.d.ts +19 -0
  328. package/types/formEngine/useSessionRoles.d.ts.map +1 -0
  329. package/types/frappeConnections.d.ts +33 -0
  330. package/types/frappeConnections.d.ts.map +1 -0
  331. package/types/frappeNavigation.d.ts +49 -0
  332. package/types/frappeNavigation.d.ts.map +1 -0
  333. package/types/index.d.ts +7 -3
  334. package/types/index.d.ts.map +1 -1
  335. package/types/schemaForm/index.d.ts.map +1 -1
  336. package/types/useConnectionFilters.d.ts +34 -0
  337. package/types/useConnectionFilters.d.ts.map +1 -0
  338. package/types/useFrappePagination.d.ts +18 -5
  339. package/types/useFrappePagination.d.ts.map +1 -1
  340. package/types/useFrappeRows.d.ts +5 -4
  341. package/types/useFrappeRows.d.ts.map +1 -1
  342. package/src/FileBrowser/AttachLibrary.spec.tsx +0 -74
@@ -0,0 +1,148 @@
1
+ import { AsyncBoundary, PageSection } from "@multiplatform.one/components";
2
+ import { DataTable, type DataTableColumn } from "@multiplatform.one/table";
3
+ import { useResolvedKnobs } from "@multiplatform.one/theme";
4
+ import { useMemo } from "react";
5
+ import { useTranslation } from "react-i18next";
6
+ import { YStack } from "tamagui";
7
+ import { connectionFields, type FrappeConnection } from "./frappeConnections";
8
+ import { isPermissionError } from "./liveErrors";
9
+ import { useFrappeRows, type FrappeRowsConnection } from "./useFrappeRows";
10
+
11
+ type ConnectionRow = Record<string, unknown> & { name: string; doctype: string };
12
+
13
+ const bookkeepingKeys = new Set([
14
+ "name",
15
+ "doctype",
16
+ "owner",
17
+ "creation",
18
+ "modified",
19
+ "modified_by",
20
+ "docstatus",
21
+ "idx",
22
+ "parent",
23
+ "parentfield",
24
+ "parenttype",
25
+ "_user_tags",
26
+ "_comments",
27
+ "_assign",
28
+ "_liked_by",
29
+ "_seen",
30
+ ]);
31
+
32
+ /** "poke_type" -> "Poke Type". */
33
+ function fieldLabel(key: string): string {
34
+ return key.replace(/_/g, " ").replace(/(^|\s)\S/g, (c) => c.toUpperCase());
35
+ }
36
+
37
+ /**
38
+ * Columns for one connection's rows: its declared fields, else what the rows
39
+ * carry. The link field is left out; every row holds the same record.
40
+ */
41
+ function connectionColumns(
42
+ link: FrappeConnection,
43
+ rows: readonly ConnectionRow[],
44
+ ): DataTableColumn<ConnectionRow>[] {
45
+ const declared = (link.fields ?? []).filter((field) => field !== "name");
46
+ const keys = (
47
+ declared.length > 0
48
+ ? declared
49
+ : Object.keys(rows[0] ?? {}).filter((key) => !bookkeepingKeys.has(key))
50
+ ).filter((key) => key !== link.fieldname);
51
+ return (keys.length > 0 ? keys : ["name"]).map((key) => ({
52
+ id: key,
53
+ accessorKey: key,
54
+ header: fieldLabel(key),
55
+ }));
56
+ }
57
+
58
+ export interface FrappeFormConnectionSectionProps {
59
+ connection: FrappeRowsConnection | undefined;
60
+ link: FrappeConnection;
61
+ /** The record whose linked rows to read. */
62
+ name: string;
63
+ }
64
+
65
+ /** One connection of a record: its linked rows, live, with honest states. */
66
+ export function FrappeFormConnectionSection({
67
+ connection,
68
+ link,
69
+ name,
70
+ }: FrappeFormConnectionSectionProps) {
71
+ const { t } = useTranslation();
72
+ const { knobProps } = useResolvedKnobs();
73
+ const options = useMemo(
74
+ () => ({
75
+ fields: connectionFields(link),
76
+ filters: [[link.fieldname, "=", name]] as Array<[string, string, unknown]>,
77
+ limit: link.limit ?? 0,
78
+ }),
79
+ [link, name],
80
+ );
81
+ const { rows, isLoading, error } = useFrappeRows<ConnectionRow>(
82
+ connection,
83
+ link.doctype,
84
+ options as never,
85
+ );
86
+ const columns = useMemo(() => connectionColumns(link, rows), [link, rows]);
87
+ const title = t(link.label ?? link.doctype);
88
+ return (
89
+ <PageSection
90
+ title={isLoading || error ? title : `${title} (${rows.length})`}
91
+ divider={false}
92
+ testID={`frappe-form-connection-${link.doctype.toLowerCase().replace(/\s+/g, "-")}`}
93
+ >
94
+ <YStack {...knobProps.gap}>
95
+ <AsyncBoundary
96
+ layout="table"
97
+ compact
98
+ loading={isLoading}
99
+ error={error}
100
+ errorTitle={
101
+ error && isPermissionError(error)
102
+ ? t("You do not have permission to view {{doctype}}", { doctype: link.doctype })
103
+ : t("Error loading {{doctype}}", { doctype: link.doctype })
104
+ }
105
+ empty={rows.length === 0}
106
+ emptyTitle={t("No {{doctype}} rows", { doctype: link.doctype })}
107
+ >
108
+ <DataTable<ConnectionRow>
109
+ data={rows}
110
+ columns={columns}
111
+ enableFiltering={false}
112
+ enableGlobalSearch={false}
113
+ enablePagination={false}
114
+ enableColumnVisibility={false}
115
+ enableViews={false}
116
+ />
117
+ </AsyncBoundary>
118
+ </YStack>
119
+ </PageSection>
120
+ );
121
+ }
122
+
123
+ export interface FrappeFormConnectionsProps {
124
+ connection: FrappeRowsConnection | undefined;
125
+ connections: readonly FrappeConnection[];
126
+ name: string;
127
+ }
128
+
129
+ /** The desk's Connections for one record: a section per linked doctype. */
130
+ export function FrappeFormConnections({
131
+ connection,
132
+ connections,
133
+ name,
134
+ }: FrappeFormConnectionsProps) {
135
+ const { knobProps } = useResolvedKnobs();
136
+ return (
137
+ <YStack {...knobProps.gapLg} testID="frappe-form-connections">
138
+ {connections.map((link) => (
139
+ <FrappeFormConnectionSection
140
+ key={`${link.doctype}:${link.fieldname}`}
141
+ connection={connection}
142
+ link={link}
143
+ name={name}
144
+ />
145
+ ))}
146
+ </YStack>
147
+ );
148
+ }
@@ -0,0 +1,143 @@
1
+ // A signed-out visitor reads Guest-readable rows, but getdoctype and the
2
+ // DocType document are closed to Guest (MPO-322): pokemon's list and detail
3
+ // each logged a 403 for meta they render without. The bench below answers
4
+ // meta only to the signed-in bearer token, like the live tenant.
5
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
6
+ import { cleanup, screen, waitFor } from "@testing-library/react";
7
+ import i18next from "i18next";
8
+ import { initReactI18next } from "react-i18next";
9
+ import { Text } from "tamagui";
10
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { FrappeForm } from "./FrappeForm";
12
+ import { FrappeListView } from "./FrappeListView";
13
+ import { FrappeProvider } from "./FrappeProvider";
14
+
15
+ interface Pokemon {
16
+ name: string;
17
+ doctype: string;
18
+ pokemon_name: string;
19
+ poke_id: number;
20
+ }
21
+
22
+ const fields = ["name", "pokemon_name", "poke_id"] as const;
23
+ const roster = [
24
+ { name: "bulbasaur", pokemon_name: "Bulbasaur", poke_id: 1 },
25
+ { name: "ivysaur", pokemon_name: "Ivysaur", poke_id: 2 },
26
+ ];
27
+ const pokemonMeta = {
28
+ name: "Pokemon",
29
+ title_field: "pokemon_name",
30
+ fields: [{ fieldname: "pokemon_name", fieldtype: "Data", label: "Pokemon Name" }],
31
+ permissions: [{ role: "Guest", read: 1 }],
32
+ };
33
+
34
+ const signedOut = { useToken: true, type: "Bearer", token: async () => "" };
35
+ const signedIn = { useToken: true, type: "Bearer", token: async () => "jwt" };
36
+
37
+ let run = 0;
38
+ let bench = "";
39
+ let metaReads: string[] = [];
40
+
41
+ function json(body: unknown, status = 200) {
42
+ return new Response(JSON.stringify(body), {
43
+ status,
44
+ headers: { "content-type": "application/json" },
45
+ });
46
+ }
47
+
48
+ function stubBench() {
49
+ metaReads = [];
50
+ vi.stubGlobal("fetch", async (input: string | URL | Request, init?: RequestInit) => {
51
+ const url = new URL(String(input instanceof Request ? input.url : input));
52
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : {}));
53
+ if (
54
+ url.pathname === "/api/method/frappe.desk.form.load.getdoctype" ||
55
+ url.pathname.startsWith("/api/resource/DocType/")
56
+ ) {
57
+ metaReads.push(url.pathname);
58
+ return headers.get("authorization") === "Bearer jwt"
59
+ ? json({ docs: [pokemonMeta] })
60
+ : json({ exc_type: "PermissionError" }, 403);
61
+ }
62
+ if (url.pathname === "/api/resource/Pokemon") return json({ data: roster });
63
+ if (url.pathname === "/api/resource/Pokemon/bulbasaur") {
64
+ return json({ data: { ...roster[0], doctype: "Pokemon" } });
65
+ }
66
+ return json({ data: [] });
67
+ });
68
+ }
69
+
70
+ beforeAll(async () => {
71
+ await i18next.use(initReactI18next).init({
72
+ lng: "en",
73
+ interpolation: { escapeValue: false },
74
+ resources: { en: { translation: {} } },
75
+ });
76
+ });
77
+
78
+ beforeEach(() => {
79
+ run += 1;
80
+ bench = `http://guest-meta-${run}.spec.test`;
81
+ stubBench();
82
+ });
83
+
84
+ afterEach(() => {
85
+ cleanup();
86
+ vi.unstubAllGlobals();
87
+ });
88
+
89
+ function settle() {
90
+ return new Promise((resolve) => setTimeout(resolve, 50));
91
+ }
92
+
93
+ function cardGrid(auth: typeof signedOut) {
94
+ return (
95
+ <FrappeProvider baseURL={bench} realtime={false} auth={auth}>
96
+ <FrappeListView<Pokemon>
97
+ doctype="Pokemon"
98
+ fields={fields}
99
+ viewMode="grid"
100
+ renderCard={(pokemon) => <Text>{pokemon.pokemon_name}</Text>}
101
+ showToolbar={false}
102
+ showSearch={false}
103
+ />
104
+ </FrappeProvider>
105
+ );
106
+ }
107
+
108
+ describe("DocType meta for a signed-out visitor", () => {
109
+ it("the card grid renders its rows without asking for meta", async () => {
110
+ renderWithProviders(cardGrid(signedOut));
111
+ await waitFor(() => expect(screen.getByText("Ivysaur")).toBeTruthy());
112
+ await settle();
113
+ expect(metaReads).toEqual([]);
114
+ });
115
+
116
+ it("a render-prop form renders its doc without asking for meta", async () => {
117
+ renderWithProviders(
118
+ <FrappeProvider baseURL={bench} realtime={false} auth={signedOut}>
119
+ <FrappeForm<Pokemon>
120
+ doctype="Pokemon"
121
+ name="bulbasaur"
122
+ connections={[]}
123
+ showToolbar={false}
124
+ showSidebar={false}
125
+ showTimeline={false}
126
+ >
127
+ {({ doc }) => <Text>{`read ${doc.pokemon_name ?? ""}`}</Text>}
128
+ </FrappeForm>
129
+ </FrappeProvider>,
130
+ );
131
+ await waitFor(() => expect(screen.getByText("read Bulbasaur")).toBeTruthy());
132
+ await settle();
133
+ expect(metaReads).toEqual([]);
134
+ });
135
+
136
+ it("a signed-in reader still loads meta", async () => {
137
+ renderWithProviders(cardGrid(signedIn));
138
+ await waitFor(() => expect(screen.getByText("Ivysaur")).toBeTruthy());
139
+ await waitFor(() =>
140
+ expect(metaReads).toContain("/api/method/frappe.desk.form.load.getdoctype"),
141
+ );
142
+ });
143
+ });
@@ -1,4 +1,4 @@
1
- import { useDocTypeMeta, useFrappeCollection, useLiveQuery } from "@multiplatform.one/frappe";
1
+ import { useDocTypeMeta } from "@multiplatform.one/frappe";
2
2
  import { AsyncSkeleton, Card, ImageGrid } from "@multiplatform.one/components";
3
3
  import { ImageSquareIcon } from "@phosphor-icons/react";
4
4
  import React, { useMemo } from "react";
@@ -9,6 +9,7 @@ import { isServer } from "@multiplatform.one/platform";
9
9
  import { useResolvedKnobs } from "@multiplatform.one/theme";
10
10
  import { useFrappeConfig } from "./FrappeProvider";
11
11
  import { FrappeReconnectIndicator } from "./FrappeReconnectIndicator";
12
+ import { useFrappeRows } from "./useFrappeRows";
12
13
 
13
14
  /**
14
15
  * True when this module is evaluated during web server rendering.
@@ -66,9 +67,8 @@ export interface FrappeImageViewProps<TDoc extends { name: string; doctype: stri
66
67
  /**
67
68
  * Thin Frappe wrapper around the generic ImageGrid component.
68
69
  *
69
- * Fetches data via `useFrappeCollection` / `useLiveQuery`, resolves image
70
- * URLs, and passes items to the generic `ImageGrid` from
71
- * `@multiplatform.one/components`.
70
+ * Fetches data via `useFrappeRows`, resolves image URLs, and passes items
71
+ * to the generic `ImageGrid` from `@multiplatform.one/components`.
72
72
  *
73
73
  * **SSR**: `useLiveQuery` (via `useSyncExternalStore`) lacks a
74
74
  * `getServerSnapshot` so it can't run on the server. If the consumer
@@ -150,7 +150,7 @@ export function FrappeImageView<TDoc extends { name: string; doctype: string }>(
150
150
 
151
151
  /**
152
152
  * Inner client-only implementation that calls the hooks requiring a
153
- * browser environment (useFrappeCollection, useLiveQuery).
153
+ * browser environment (useFrappeRows).
154
154
  */
155
155
  function FrappeImageViewClient<TDoc extends { name: string; doctype: string }>({
156
156
  doctype,
@@ -180,7 +180,7 @@ function FrappeImageViewClient<TDoc extends { name: string; doctype: string }>({
180
180
  throw new Error("FrappeImageView requires baseURL prop or FrappeProvider with baseURL");
181
181
  }
182
182
 
183
- // Memoize so downstream hooks (useDocTypeMeta / useFrappeCollection) receive
183
+ // Memoize so downstream hooks (useDocTypeMeta / useFrappeRows) receive
184
184
  // a stable config reference across renders instead of a fresh object each
185
185
  // time (which would otherwise retrigger their effects every render).
186
186
  const appConfig = useMemo(
@@ -240,15 +240,11 @@ function FrappeImageViewClient<TDoc extends { name: string; doctype: string }>({
240
240
  return searchFilter as unknown as typeof filters;
241
241
  }, [filters, searchText, effectiveTitleField]);
242
242
 
243
- const collection = useFrappeCollection<TDoc>(appConfig, doctype, {
243
+ const { rows: items, isQueryLoading: isDataLoading } = useFrappeRows<TDoc>(appConfig, doctype, {
244
244
  fields: effectiveFields,
245
245
  filters: effectiveFilters,
246
+ limit: null,
246
247
  });
247
- const { data: rawData, isLoading: isDataLoading } = useLiveQuery(
248
- (q) => (collection ? q.from({ _list: collection }) : null),
249
- [collection],
250
- );
251
- const items = useMemo(() => (rawData as TDoc[] | undefined) ?? [], [rawData]);
252
248
 
253
249
  // Only block rendering on DocType-meta loading when we actually need it
254
250
  // (i.e. the caller didn't pass explicit image/title fields). The meta
@@ -1,13 +1,19 @@
1
- import { AsyncBoundary, List, type ListProps } from "@multiplatform.one/components";
1
+ import {
2
+ AsyncBoundary,
3
+ ImageGrid,
4
+ List,
5
+ type ImageGridProps,
6
+ type ListProps,
7
+ } from "@multiplatform.one/components";
2
8
  import { useResolvedKnobs } from "@multiplatform.one/theme";
3
9
  import type React from "react";
4
- import { useMemo } from "react";
10
+ import { useCallback, useMemo } from "react";
5
11
  import { useTranslation } from "react-i18next";
6
12
  import { YStack } from "tamagui";
7
13
  import { useFrappeConfig } from "./FrappeProvider";
8
14
  import { FrappeReconnectIndicator } from "./FrappeReconnectIndicator";
9
15
  import { isPermissionError } from "./liveErrors";
10
- import { useFrappePagination } from "./useFrappePagination";
16
+ import { useFrappePagination, type UseFrappePaginationOptions } from "./useFrappePagination";
11
17
 
12
18
  export interface FrappeListProps<TDoc extends { name: string; doctype: string }> extends Omit<
13
19
  ListProps<Partial<TDoc>>,
@@ -21,8 +27,25 @@ export interface FrappeListProps<TDoc extends { name: string; doctype: string }>
21
27
  doctype: string;
22
28
  fields?: readonly (keyof TDoc & string)[];
23
29
  filters?: Partial<TDoc> | Array<[keyof TDoc & string, string, any]>;
30
+ /** Rows the subscription reads; omitted, the bench's page length (20) applies. */
31
+ limit?: number;
32
+ orderBy?: UseFrappePaginationOptions<TDoc>["orderBy"];
33
+ /** Server-rendered rows for this same query, shown until the first sync lands. */
34
+ initialRows?: readonly Partial<TDoc>[];
24
35
  /** Page size for progressive loading (default 20). */
25
36
  pageSize?: number;
37
+ /**
38
+ * `grid` lays the rows out as cards through the catalog ImageGrid;
39
+ * `renderItem` then draws one card. Loading, error and empty stay the
40
+ * same states in both layouts.
41
+ */
42
+ layout?: "list" | "grid";
43
+ /** Grid layout: column count, tile rung and media aspect of the cards. */
44
+ gridProps?: Pick<ImageGridProps<Partial<TDoc>>, "columns" | "tileSize" | "mediaAspect">;
45
+ /** Replaces the default empty message when the doctype has no rows to show. */
46
+ emptyState?: React.ReactNode;
47
+ /** Error-state retry, e.g. a remount that starts one fresh sync. */
48
+ onRetry?: () => void;
26
49
  }
27
50
 
28
51
  /**
@@ -39,8 +62,15 @@ export function FrappeList<TDoc extends { name: string; doctype: string }>({
39
62
  doctype,
40
63
  fields,
41
64
  filters,
65
+ limit,
66
+ orderBy,
67
+ initialRows,
42
68
  pageSize,
43
69
  renderItem,
70
+ layout = "list",
71
+ gridProps,
72
+ emptyState,
73
+ onRetry,
44
74
  ...listProps
45
75
  }: FrappeListProps<TDoc>) {
46
76
  const config = useFrappeConfig();
@@ -85,7 +115,16 @@ export function FrappeList<TDoc extends { name: string; doctype: string }>({
85
115
  const { items, isLoading, onLoadMore, hasMore, error } = useFrappePagination<TDoc>(
86
116
  connectionConfig,
87
117
  doctype,
88
- { fields, filters, pageSize },
118
+ { fields, filters, pageSize, limit, orderBy, initialRows: initialRows as readonly TDoc[] },
119
+ );
120
+
121
+ const itemIndex = useMemo(
122
+ () => new Map<Partial<TDoc>, number>(items.map((item, index) => [item, index])),
123
+ [items],
124
+ );
125
+ const renderCard = useCallback(
126
+ (item: Partial<TDoc>) => renderItem(item, itemIndex.get(item) ?? 0),
127
+ [renderItem, itemIndex],
89
128
  );
90
129
 
91
130
  // Honest error state: a permission/network failure must not masquerade
@@ -93,8 +132,9 @@ export function FrappeList<TDoc extends { name: string; doctype: string }>({
93
132
  if (error) {
94
133
  return (
95
134
  <AsyncBoundary
96
- layout="list"
135
+ layout={layout === "grid" ? "generic" : "list"}
97
136
  error={error}
137
+ onRetry={onRetry}
98
138
  errorTitle={
99
139
  isPermissionError(error)
100
140
  ? t("You do not have permission to view {{doctype}}", { doctype })
@@ -105,7 +145,7 @@ export function FrappeList<TDoc extends { name: string; doctype: string }>({
105
145
  }
106
146
 
107
147
  return (
108
- <YStack {...knobProps.gap} flex={1}>
148
+ <YStack {...knobProps.gap} flex={layout === "grid" ? undefined : 1}>
109
149
  <FrappeReconnectIndicator config={connectionConfig} />
110
150
  {/*
111
151
  No chrome fragment rides this passthrough (LC-55 FRAGMENT-LICENSE):
@@ -114,15 +154,31 @@ export function FrappeList<TDoc extends { name: string; doctype: string }>({
114
154
  clip. Rows and the error/empty panels own their own chrome; the error
115
155
  branch above renders a bare AsyncBoundary, so both paths now match.
116
156
  */}
117
- <List
118
- items={items}
119
- isLoading={isLoading}
120
- renderItem={renderItem}
121
- onLoadMore={onLoadMore}
122
- hasMore={hasMore}
123
- emptyMessage={t("No {{doctype}} records found", { doctype })}
124
- {...listProps}
125
- />
157
+ {!isLoading && items.length === 0 && emptyState ? (
158
+ emptyState
159
+ ) : layout === "grid" ? (
160
+ <ImageGrid
161
+ items={items}
162
+ isLoading={isLoading}
163
+ renderItem={renderCard}
164
+ onItemClick={listProps.onItemClick}
165
+ onLoadMore={onLoadMore}
166
+ hasMore={hasMore}
167
+ emptyMessage={listProps.emptyMessage ?? t("No {{doctype}} records found", { doctype })}
168
+ testID={listProps.testID}
169
+ {...gridProps}
170
+ />
171
+ ) : (
172
+ <List
173
+ items={items}
174
+ isLoading={isLoading}
175
+ renderItem={renderItem}
176
+ onLoadMore={onLoadMore}
177
+ hasMore={hasMore}
178
+ emptyMessage={t("No {{doctype}} records found", { doctype })}
179
+ {...listProps}
180
+ />
181
+ )}
126
182
  </YStack>
127
183
  );
128
184
  }
@@ -0,0 +1,175 @@
1
+ // FrappeListView's card slot (MPO-331): a doctype with a picture keeps its
2
+ // card without leaving the component. `renderCard` draws each row into the
3
+ // catalog ImageGrid; loading, empty and error stay the view's own states.
4
+ import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
5
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
6
+ import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
7
+ import i18next from "i18next";
8
+ import { initReactI18next } from "react-i18next";
9
+ import { Text } from "tamagui";
10
+ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
11
+ import {
12
+ createRosterFixtures,
13
+ rosterPokemon,
14
+ rosterPokemonFields,
15
+ rosterPokemonMeta,
16
+ type RosterPokemon,
17
+ } from "../tests/fixtures/pokemonRoster";
18
+ import { FrappeListView, type FrappeListViewProps } from "./FrappeListView";
19
+ import { FrappeProvider } from "./FrappeProvider";
20
+
21
+ const host = "https://listview-cards.spec.test";
22
+ const realFetch = globalThis.fetch;
23
+
24
+ beforeAll(async () => {
25
+ await i18next.use(initReactI18next).init({
26
+ lng: "en",
27
+ interpolation: { escapeValue: false },
28
+ resources: { en: { translation: {} } },
29
+ });
30
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
31
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
32
+ if (url.startsWith(host)) {
33
+ const body = /\/api\/resource\/DocType\//.test(url)
34
+ ? { data: rosterPokemonMeta }
35
+ : { data: [] };
36
+ return new Response(JSON.stringify(body), {
37
+ status: 200,
38
+ headers: { "Content-Type": "application/json" },
39
+ });
40
+ }
41
+ return realFetch(input as RequestInfo, init);
42
+ }) as typeof fetch;
43
+ });
44
+
45
+ afterAll(() => {
46
+ globalThis.fetch = realFetch;
47
+ });
48
+
49
+ afterEach(() => {
50
+ cleanup();
51
+ vi.restoreAllMocks();
52
+ });
53
+
54
+ const card = (pokemon: Partial<RosterPokemon>, index: number) => (
55
+ <Text testID={`card-${pokemon.name}`}>{`${index}:${pokemon.pokemon_name}`}</Text>
56
+ );
57
+
58
+ function renderCards(
59
+ fixtures: InMemoryFixtureProvider,
60
+ props: Partial<FrappeListViewProps<RosterPokemon>> = {},
61
+ ) {
62
+ return renderWithProviders(
63
+ <FrappeProvider baseURL={host} fixtures={fixtures}>
64
+ <FrappeListView<RosterPokemon>
65
+ doctype="Pokemon"
66
+ fields={rosterPokemonFields}
67
+ viewMode="grid"
68
+ renderCard={card}
69
+ {...props}
70
+ />
71
+ </FrappeProvider>,
72
+ );
73
+ }
74
+
75
+ describe("FrappeListView card slot", () => {
76
+ it("draws every row through renderCard in the grid", async () => {
77
+ renderCards(createRosterFixtures());
78
+ for (const pokemon of rosterPokemon) {
79
+ await waitFor(() => expect(screen.getByTestId(`card-${pokemon.name}`)).toBeTruthy());
80
+ }
81
+ expect(screen.getByTestId("card-bulbasaur").textContent).toMatch(/^\d+:bulbasaur$/);
82
+ });
83
+
84
+ it("hands the slot each row's position in the page", async () => {
85
+ renderCards(createRosterFixtures());
86
+ await waitFor(() => expect(screen.getByTestId("card-eevee")).toBeTruthy());
87
+ const positions = rosterPokemon.map((pokemon) =>
88
+ Number(screen.getByTestId(`card-${pokemon.name}`).textContent?.split(":")[0]),
89
+ );
90
+ expect([...positions].sort((a, b) => a - b)).toEqual(rosterPokemon.map((_, i) => i));
91
+ });
92
+
93
+ it("offers the card grid in the view switcher only when a slot is given", async () => {
94
+ const { unmount } = renderCards(createRosterFixtures(), { viewMode: undefined });
95
+ await waitFor(() => expect(screen.getByText("Cards")).toBeTruthy());
96
+ unmount();
97
+ renderWithProviders(
98
+ <FrappeProvider baseURL={host} fixtures={createRosterFixtures()}>
99
+ <FrappeListView<RosterPokemon> doctype="Pokemon" fields={rosterPokemonFields} />
100
+ </FrappeProvider>,
101
+ );
102
+ await waitFor(() => expect(screen.getByText("Table")).toBeTruthy());
103
+ expect(screen.queryByText("Cards")).toBeNull();
104
+ });
105
+
106
+ it("a grid asked for without a slot falls back to the list", async () => {
107
+ renderWithProviders(
108
+ <FrappeProvider baseURL={host} fixtures={createRosterFixtures()}>
109
+ <FrappeListView<RosterPokemon>
110
+ doctype="Pokemon"
111
+ fields={rosterPokemonFields}
112
+ viewMode="grid"
113
+ displayFields={[{ key: "pokemon_name" }]}
114
+ />
115
+ </FrappeProvider>,
116
+ );
117
+ await waitFor(() => expect(screen.getByText("pikachu")).toBeTruthy());
118
+ expect(screen.queryByTestId("card-pikachu")).toBeNull();
119
+ });
120
+
121
+ it("switching views reports the mode and leaves a controlled view where it is", async () => {
122
+ const onViewModeChange = vi.fn();
123
+ renderCards(createRosterFixtures(), { onViewModeChange });
124
+ await waitFor(() => expect(screen.getByTestId("card-pikachu")).toBeTruthy());
125
+ fireEvent.click(screen.getByText("Table"));
126
+ expect(onViewModeChange).toHaveBeenCalledWith("table");
127
+ expect(screen.getByTestId("card-pikachu")).toBeTruthy();
128
+ });
129
+ });
130
+
131
+ describe("FrappeListView card grid states", () => {
132
+ it("renders the grid's skeleton while the first page loads", async () => {
133
+ const fixtures = createRosterFixtures();
134
+ let release: () => void = () => undefined;
135
+ const gate = new Promise<void>((resolve) => {
136
+ release = resolve;
137
+ });
138
+ const getList = fixtures.getList.bind(fixtures);
139
+ vi.spyOn(fixtures, "getList").mockImplementation(async (...args) => {
140
+ await gate;
141
+ return getList(...args);
142
+ });
143
+ renderCards(fixtures);
144
+ await waitFor(() =>
145
+ expect(
146
+ document.querySelector('[data-testid="image-grid-skeleton"], [aria-busy="true"]'),
147
+ ).toBeTruthy(),
148
+ );
149
+ expect(screen.queryByTestId("card-pikachu")).toBeNull();
150
+ release();
151
+ await waitFor(() => expect(screen.getByTestId("card-pikachu")).toBeTruthy());
152
+ });
153
+
154
+ it("an empty doctype shows the empty message, not a blank grid", async () => {
155
+ renderCards(createRosterFixtures({ Pokemon: [] }));
156
+ await waitFor(() => expect(screen.getByText("No Pokemon records found")).toBeTruthy());
157
+ });
158
+
159
+ it("an empty doctype shows the screen's own empty state when one is given", async () => {
160
+ renderCards(createRosterFixtures({ Pokemon: [] }), {
161
+ emptyState: <Text testID="roster-empty">Catch your first Pokemon</Text>,
162
+ });
163
+ await waitFor(() => expect(screen.getByTestId("roster-empty")).toBeTruthy());
164
+ expect(screen.queryByText("No Pokemon records found")).toBeNull();
165
+ });
166
+
167
+ it("a failed read is an error with a retry, never an empty grid", async () => {
168
+ const fixtures = createRosterFixtures();
169
+ vi.spyOn(fixtures, "getList").mockRejectedValue(new Error("Failed to fetch"));
170
+ renderCards(fixtures, { emptyState: <Text testID="roster-empty">empty</Text> });
171
+ await waitFor(() => expect(screen.getByText("Error loading Pokemon")).toBeTruthy());
172
+ expect(screen.queryByTestId("roster-empty")).toBeNull();
173
+ expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
174
+ });
175
+ });