create-mithaq 0.1.1 → 0.2.1

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 (348) hide show
  1. package/LICENSES/openstatus-time-picker.txt +21 -0
  2. package/index.js +457 -63
  3. package/package.json +1 -1
  4. package/template/apps/{asas-platform → asas-backoffice}/package.json +5 -3
  5. package/template/apps/{asas-platform → asas-backoffice}/src/api.ts +1 -1
  6. package/template/apps/{asas-platform → asas-backoffice}/src/auth-frame.tsx +12 -12
  7. package/template/apps/asas-backoffice/src/components.ts +35 -0
  8. package/template/apps/asas-backoffice/src/first-user.ts +14 -0
  9. package/template/apps/{asas-platform → asas-backoffice}/src/links.ts +1 -1
  10. package/template/apps/{asas-platform → asas-backoffice}/src/locale-switch.tsx +7 -5
  11. package/template/apps/asas-backoffice/src/no-organization.tsx +5 -0
  12. package/template/apps/asas-backoffice/src/redirect.ts +1 -0
  13. package/template/apps/{asas-platform → asas-backoffice}/src/request-headers.ts +1 -1
  14. package/template/apps/asas-backoffice/src/routeTree.gen.ts +206 -0
  15. package/template/apps/asas-backoffice/src/router.tsx +18 -0
  16. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/files.ts +10 -0
  17. package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/screen.json +426 -64
  18. package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/types.ts +18 -0
  19. package/template/apps/{asas-platform/src/routes/(screens)/platform.$.tsx → asas-backoffice/src/routes/(screens)/backoffice.$.tsx} +4 -4
  20. package/template/apps/{asas-platform/src/routes/(screens)/platform.index.tsx → asas-backoffice/src/routes/(screens)/backoffice.index.tsx} +3 -3
  21. package/template/apps/{asas-platform/src/routes/(screens)/platform.tsx → asas-backoffice/src/routes/(screens)/backoffice.tsx} +3 -3
  22. package/template/apps/{asas-platform → asas-backoffice}/src/routes/__root.tsx +2 -2
  23. package/template/apps/asas-backoffice/src/routes/api/$.ts +34 -0
  24. package/template/apps/asas-backoffice/src/routes/auth.create-first-user.tsx +113 -0
  25. package/template/apps/{asas-platform → asas-backoffice}/src/routes/auth.sign-in.tsx +9 -4
  26. package/template/apps/{asas-platform → asas-backoffice}/src/routes/unauthorized.tsx +24 -12
  27. package/template/apps/{asas-platform → asas-backoffice}/src/screen-form.tsx +5 -3
  28. package/template/apps/{asas-platform → asas-backoffice}/src/screen-list.tsx +6 -5
  29. package/template/apps/{asas-platform → asas-backoffice}/src/screen-names.ts +1 -1
  30. package/template/apps/{asas-platform → asas-backoffice}/src/screens.tsx +46 -26
  31. package/template/apps/asas-backoffice/src/server.ts +30 -0
  32. package/template/apps/{asas-platform → asas-backoffice}/src/slide.ts +1 -1
  33. package/template/apps/{asas-platform → asas-backoffice}/src/start.ts +1 -1
  34. package/template/apps/{asas-platform → asas-backoffice}/src/styles.css +9 -7
  35. package/template/apps/{asas-platform → asas-backoffice}/tsconfig.json +2 -2
  36. package/template/apps/{asas-platform → asas-backoffice}/vite.config.ts +7 -1
  37. package/template/apps/asas-tanstack/package.json +4 -2
  38. package/template/apps/asas-tanstack/src/api.ts +1 -1
  39. package/template/apps/asas-tanstack/src/auth-frame.tsx +12 -12
  40. package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
  41. package/template/apps/asas-tanstack/src/components.ts +35 -0
  42. package/template/apps/asas-tanstack/src/copy.ts +416 -79
  43. package/template/apps/asas-tanstack/src/first-run.ts +16 -0
  44. package/template/apps/asas-tanstack/src/links.ts +1 -1
  45. package/template/apps/asas-tanstack/src/locale-switch.tsx +7 -5
  46. package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
  47. package/template/apps/asas-tanstack/src/redirect.ts +20 -1
  48. package/template/apps/asas-tanstack/src/request-headers.ts +1 -1
  49. package/template/apps/asas-tanstack/src/routeTree.gen.ts +42 -0
  50. package/template/apps/asas-tanstack/src/router.tsx +12 -1
  51. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +9 -2
  52. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +475 -116
  53. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +4 -2
  54. package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +7 -2
  55. package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +83 -319
  56. package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +3 -1
  57. package/template/apps/asas-tanstack/src/routes/__root.tsx +1 -1
  58. package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +14 -7
  59. package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
  60. package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +2 -2
  61. package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +5 -5
  62. package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +7 -100
  63. package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
  64. package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +27 -15
  65. package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +59 -38
  66. package/template/apps/asas-tanstack/src/routes/index.tsx +18 -14
  67. package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +24 -12
  68. package/template/apps/asas-tanstack/src/screen-form.tsx +5 -3
  69. package/template/apps/asas-tanstack/src/screen-list.tsx +6 -5
  70. package/template/apps/asas-tanstack/src/screen-names.ts +1 -1
  71. package/template/apps/asas-tanstack/src/screens.tsx +46 -26
  72. package/template/apps/asas-tanstack/src/server.ts +10 -0
  73. package/template/apps/asas-tanstack/src/slide.ts +1 -1
  74. package/template/apps/asas-tanstack/src/styles.css +7 -3
  75. package/template/apps/asas-tanstack/tsconfig.json +2 -2
  76. package/template/apps/asas-tanstack/vite.config.ts +7 -1
  77. package/template/package.json +3 -3
  78. package/template/templates/asas/.claude/skills/add-field/SKILL.md +27 -0
  79. package/template/templates/asas/.claude/skills/add-kayan/SKILL.md +26 -0
  80. package/template/templates/asas/.claude/skills/eject-admin-piece/SKILL.md +28 -0
  81. package/template/templates/asas/.claude/skills/upgrade-mithaq/SKILL.md +35 -0
  82. package/template/templates/asas/.env.example +22 -5
  83. package/template/templates/asas/AGENTS.md +101 -0
  84. package/template/templates/asas/CLAUDE.md +1 -0
  85. package/template/templates/asas/README.md +1094 -0
  86. package/template/templates/asas/package.json +19 -4
  87. package/template/templates/asas/src/asas.ts +81 -56
  88. package/template/templates/asas/src/{platform-http.ts → backoffice-http.ts} +48 -27
  89. package/template/templates/asas/src/dev-setup.ts +112 -0
  90. package/template/templates/asas/src/local-database.ts +22 -0
  91. package/template/templates/asas/src/messages/account.json +129 -103
  92. package/template/templates/asas/src/messages/auditLog.json +223 -105
  93. package/template/templates/asas/src/messages/backofficeLog.json +132 -0
  94. package/template/templates/asas/src/messages/courses.json +256 -214
  95. package/template/templates/asas/src/messages/enrollments.json +45 -27
  96. package/template/templates/asas/src/messages/invitation.json +72 -56
  97. package/template/templates/asas/src/messages/localUsers.json +34 -28
  98. package/template/templates/asas/src/messages/media.json +31 -21
  99. package/template/templates/asas/src/messages/member.json +59 -39
  100. package/template/templates/asas/src/messages/memberRoles.json +37 -31
  101. package/template/templates/asas/src/messages/mithaq.json +206 -162
  102. package/template/templates/asas/src/messages/organization.json +63 -41
  103. package/template/templates/asas/src/messages/organizationRole.json +59 -47
  104. package/template/templates/asas/src/messages/roles.json +59 -45
  105. package/template/templates/asas/src/messages/session.json +99 -79
  106. package/template/templates/asas/src/messages/sessions.json +110 -80
  107. package/template/templates/asas/src/messages/settings.json +51 -53
  108. package/template/templates/asas/src/messages/tags.json +31 -21
  109. package/template/templates/asas/src/messages/user.json +109 -87
  110. package/template/templates/asas/src/messages/verification.json +59 -47
  111. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/migration.sql +1 -0
  112. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/snapshot.json +3881 -0
  113. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/migration.sql +1 -0
  114. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/snapshot.json +3894 -0
  115. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/migration.sql +1 -0
  116. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/snapshot.json +3907 -0
  117. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/migration.sql +1 -0
  118. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/snapshot.json +3907 -0
  119. package/template/templates/asas/src/migrations/20261015042444_organization_names/migration.sql +1 -0
  120. package/template/templates/asas/src/migrations/20261015042444_organization_names/snapshot.json +3920 -0
  121. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/migration.sql +1 -0
  122. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/snapshot.json +3933 -0
  123. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/migration.sql +15 -0
  124. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/snapshot.json +4113 -0
  125. package/template/templates/asas/src/migrations/20261015095752_portal/migration.sql +2 -0
  126. package/template/templates/asas/src/migrations/20261015095752_portal/snapshot.json +4113 -0
  127. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/migration.sql +9 -0
  128. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/snapshot.json +4302 -0
  129. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/migration.sql +27 -0
  130. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/snapshot.json +4289 -0
  131. package/template/templates/asas/src/migrations/schema.ts +1 -0
  132. package/template/templates/asas/src/mithaq.ts +4 -406
  133. package/template/templates/asas/src/organizations.ts +3 -0
  134. package/template/templates/asas/src/seed.ts +147 -45
  135. package/template/templates/asas/src/{ui-platform → ui-backoffice}/home.tsx +9 -9
  136. package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
  137. package/template/templates/asas/src/{ui-platform → ui-backoffice}/record-page.tsx +4 -4
  138. package/template/templates/asas/src/{ui-platform → ui-backoffice}/role-cells.tsx +13 -6
  139. package/template/templates/asas/src/{ui-platform → ui-backoffice}/user-actions.tsx +46 -48
  140. package/template/templates/asas/src/ui-dashboard/dashboard.tsx +10 -4
  141. package/template/templates/asas/src/ui-dashboard/home.tsx +73 -0
  142. package/template/templates/asas/src/ui-dashboard/members-card.tsx +147 -0
  143. package/template/templates/asas/src/ui-dashboard/members.tsx +637 -438
  144. package/template/templates/asas/src/ui-dashboard/versions.tsx +22 -8
  145. package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
  146. package/template/templates/asas/src/ui-portal/home.tsx +9 -13
  147. package/template/templates/features/audit-log/audit-log/audit-log.ts +284 -0
  148. package/template/templates/features/audit-log/backoffice-log/backoffice-log.ts +67 -0
  149. package/template/templates/features/audit-log/index.ts +4 -0
  150. package/template/templates/features/audit-log/package.json +13 -0
  151. package/template/templates/features/auth/index.ts +11 -0
  152. package/template/templates/{asas/src → features/auth}/local-users/local-users.ts +7 -7
  153. package/template/templates/{auth → features/auth}/package.json +2 -3
  154. package/template/templates/{asas/src/kayan → features}/auth/user/account.access.ts +2 -2
  155. package/template/templates/{asas/src/kayan → features}/auth/user/account.ts +2 -2
  156. package/template/templates/{asas/src/kayan → features}/auth/user/invitation.access.ts +1 -1
  157. package/template/templates/{asas/src/kayan → features}/auth/user/invitation.ts +3 -3
  158. package/template/templates/{asas/src/kayan → features}/auth/user/member.access.ts +4 -4
  159. package/template/templates/{asas/src/kayan → features}/auth/user/member.screens.ts +5 -5
  160. package/template/templates/{asas/src/kayan → features}/auth/user/member.ts +4 -3
  161. package/template/templates/{asas/src/kayan → features}/auth/user/organization.access.ts +6 -6
  162. package/template/templates/{asas/src/kayan → features}/auth/user/organization.screens.ts +6 -6
  163. package/template/templates/{asas/src/kayan → features}/auth/user/organization.ts +2 -1
  164. package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.access.ts +2 -2
  165. package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.ts +3 -3
  166. package/template/templates/{asas/src/kayan → features}/auth/user/session.access.ts +2 -2
  167. package/template/templates/{asas/src/kayan → features}/auth/user/session.ts +2 -2
  168. package/template/templates/{asas/src/kayan → features}/auth/user/user.access.ts +8 -7
  169. package/template/templates/{asas/src/kayan → features}/auth/user/user.screens.ts +9 -9
  170. package/template/templates/{asas/src/kayan → features}/auth/user/user.ts +2 -2
  171. package/template/templates/{asas/src/kayan → features}/auth/user/verification.access.ts +2 -2
  172. package/template/templates/{asas/src/kayan → features}/auth/user/verification.ts +2 -2
  173. package/template/templates/{asas/src → features/courses}/courses/courses.ts +122 -133
  174. package/template/templates/features/courses/courses/cover-reads.ts +33 -0
  175. package/template/templates/features/courses/courses/ui/archived-badge.tsx +9 -0
  176. package/template/templates/{asas/src → features/courses}/courses/ui/fee-cell.tsx +1 -1
  177. package/template/templates/{asas/src → features/courses}/courses/ui/status-cell.tsx +5 -3
  178. package/template/templates/features/courses/courses/ui/unpublish-button.tsx +15 -0
  179. package/template/templates/features/courses/domain/archive.ts +39 -0
  180. package/template/templates/features/courses/domain/on-offer.ts +6 -0
  181. package/template/templates/features/courses/enrollments/enrollments.ts +309 -0
  182. package/template/templates/{asas/src → features/courses}/enrollments/ui/session-cell.tsx +17 -7
  183. package/template/templates/{asas/src → features/courses/enrollments}/ui/session-labels.tsx +3 -3
  184. package/template/templates/features/courses/index.ts +8 -0
  185. package/template/templates/features/courses/package.json +24 -0
  186. package/template/templates/features/courses/portal/ui/catalogue.tsx +86 -0
  187. package/template/templates/features/courses/portal/ui/course.tsx +176 -0
  188. package/template/templates/features/courses/portal/ui/enrollments.tsx +170 -0
  189. package/template/templates/features/courses/portal/ui/parts.tsx +95 -0
  190. package/template/templates/features/courses/portal/ui/reads.ts +76 -0
  191. package/template/templates/features/courses/portal/ui/rules.ts +87 -0
  192. package/template/templates/features/courses/portal/ui/shell.tsx +194 -0
  193. package/template/templates/features/courses/sessions/seats.ts +42 -0
  194. package/template/templates/{asas/src → features/courses}/sessions/sessions.ts +45 -14
  195. package/template/templates/features/media/index.ts +10 -0
  196. package/template/templates/{asas/src → features/media}/media/media.ts +34 -40
  197. package/template/templates/{asas/src → features/media}/media/ui/preview.tsx +2 -2
  198. package/template/templates/features/media/package.json +19 -0
  199. package/template/templates/features/roles/index.ts +4 -0
  200. package/template/templates/{asas/src → features/roles}/member-roles/member-roles.ts +15 -15
  201. package/template/templates/features/roles/package.json +21 -0
  202. package/template/templates/{asas/src → features/roles}/roles/roles.ts +23 -23
  203. package/template/templates/{asas/src → features/roles}/roles/ui/role-form.tsx +65 -24
  204. package/template/templates/{asas/src → features/roles}/roles/ui/roles.tsx +49 -25
  205. package/template/templates/features/settings/index.ts +3 -0
  206. package/template/templates/features/settings/package.json +13 -0
  207. package/template/templates/{asas/src → features/settings}/settings/settings.ts +12 -17
  208. package/template/templates/features/tags/index.ts +3 -0
  209. package/template/templates/features/tags/package.json +14 -0
  210. package/template/templates/{asas/src → features/tags}/tags/tags.ts +15 -15
  211. package/template/templates/mithaq/package.json +33 -0
  212. package/template/templates/mithaq/src/mail/index.ts +32 -0
  213. package/template/templates/mithaq/src/mail/mailers.ts +36 -0
  214. package/template/templates/{asas/src/audit-log/sink.ts → mithaq/src/mithaq/audit-sink.ts} +31 -9
  215. package/template/templates/{auth/src/platform-roles.ts → mithaq/src/mithaq/auth/backoffice-roles.ts} +44 -23
  216. package/template/templates/mithaq/src/mithaq/auth/index.ts +1859 -0
  217. package/template/templates/mithaq/src/mithaq/auth/organizations.ts +80 -0
  218. package/template/templates/{asas/src → mithaq/src/mithaq}/pages.ts +16 -7
  219. package/template/templates/mithaq/src/mithaq/rich-text-nodes.ts +19 -0
  220. package/template/templates/mithaq/src/mithaq.ts +593 -0
  221. package/template/templates/mithaq/src/roles/default-member-roles.ts +29 -0
  222. package/template/templates/{asas → mithaq}/src/roles/grants.ts +1 -1
  223. package/template/templates/mithaq/src/roles/member-access.ts +11 -0
  224. package/template/templates/{asas/src → mithaq/src/roles}/statements.ts +6 -4
  225. package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
  226. package/template/templates/{asas/src → mithaq/src/roles}/tenant-access.ts +96 -57
  227. package/template/templates/{asas/src → mithaq/src/roles}/tenant-roles.ts +84 -25
  228. package/template/templates/{asas → mithaq}/src/steps/expected-updated-at.ts +2 -2
  229. package/template/templates/{asas → mithaq}/src/storage/index.ts +3 -1
  230. package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
  231. package/template/templates/mithaq/src/ui/activity.tsx +527 -0
  232. package/template/templates/{asas → mithaq}/src/ui/app-shell.tsx +27 -19
  233. package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
  234. package/template/templates/{asas → mithaq}/src/ui/screen-nav.tsx +1 -1
  235. package/template/templates/{asas → mithaq}/src/ui/top-shell.tsx +8 -8
  236. package/template/apps/asas-platform/src/components/mithaq/cells.tsx +0 -112
  237. package/template/apps/asas-platform/src/components/mithaq/controls.tsx +0 -650
  238. package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +0 -53
  239. package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +0 -108
  240. package/template/apps/asas-platform/src/components/mithaq/form.tsx +0 -1467
  241. package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +0 -108
  242. package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +0 -368
  243. package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +0 -153
  244. package/template/apps/asas-platform/src/components/mithaq/list-query.ts +0 -432
  245. package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +0 -74
  246. package/template/apps/asas-platform/src/components/mithaq/list.tsx +0 -865
  247. package/template/apps/asas-platform/src/components/mithaq/nav.tsx +0 -871
  248. package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +0 -1024
  249. package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +0 -127
  250. package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +0 -400
  251. package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +0 -6
  252. package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +0 -1083
  253. package/template/apps/asas-platform/src/components/mithaq/upload.tsx +0 -686
  254. package/template/apps/asas-platform/src/components/ui/drawer.tsx +0 -216
  255. package/template/apps/asas-platform/src/redirect.ts +0 -1
  256. package/template/apps/asas-platform/src/routeTree.gen.ts +0 -181
  257. package/template/apps/asas-platform/src/router.tsx +0 -7
  258. package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +0 -4
  259. package/template/apps/asas-platform/src/routes/api/$.ts +0 -30
  260. package/template/apps/asas-platform/src/server.ts +0 -20
  261. package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +0 -112
  262. package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +0 -650
  263. package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +0 -53
  264. package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +0 -108
  265. package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +0 -1467
  266. package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +0 -108
  267. package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +0 -368
  268. package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +0 -153
  269. package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +0 -432
  270. package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +0 -74
  271. package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +0 -865
  272. package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +0 -871
  273. package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +0 -1024
  274. package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +0 -127
  275. package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +0 -400
  276. package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +0 -6
  277. package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +0 -1083
  278. package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +0 -686
  279. package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +0 -215
  280. package/template/templates/asas/src/audit-log/audit-log.ts +0 -187
  281. package/template/templates/asas/src/audit-log/ui/activity.tsx +0 -243
  282. package/template/templates/asas/src/courses/ui/archive-button.tsx +0 -25
  283. package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +0 -11
  284. package/template/templates/asas/src/courses/ui/on-offer.ts +0 -6
  285. package/template/templates/asas/src/enrollments/enrollments.ts +0 -137
  286. package/template/templates/asas/src/rich-text-nodes.ts +0 -11
  287. package/template/templates/asas/src/roles.ts +0 -16
  288. package/template/templates/asas/src/ui/rich-text-nodes.tsx +0 -15
  289. package/template/templates/asas/src/ui-platform/organization-actions.tsx +0 -114
  290. package/template/templates/auth/src/index.ts +0 -690
  291. /package/template/apps/{asas-platform → asas-backoffice}/components.json +0 -0
  292. /package/template/apps/{asas-platform → asas-backoffice}/gitignore +0 -0
  293. /package/template/apps/{asas-platform → asas-backoffice}/public/favicon.svg +0 -0
  294. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/accordion.tsx +0 -0
  295. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/alert-dialog.tsx +0 -0
  296. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/avatar.tsx +0 -0
  297. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/badge.tsx +0 -0
  298. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/breadcrumb.tsx +0 -0
  299. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button-group.tsx +0 -0
  300. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button.tsx +0 -0
  301. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/calendar.tsx +0 -0
  302. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/card.tsx +0 -0
  303. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/chart.tsx +0 -0
  304. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/checkbox.tsx +0 -0
  305. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/collapsible.tsx +0 -0
  306. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/combobox.tsx +0 -0
  307. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dialog.tsx +0 -0
  308. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/direction.tsx +0 -0
  309. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/directional-icon.tsx +0 -0
  310. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dropdown-menu.tsx +0 -0
  311. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/field.tsx +0 -0
  312. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-group.tsx +0 -0
  313. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-otp.tsx +0 -0
  314. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input.tsx +0 -0
  315. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/label.tsx +0 -0
  316. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/popover.tsx +0 -0
  317. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/radio-group.tsx +0 -0
  318. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/resizable.tsx +0 -0
  319. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/scroll-area.tsx +0 -0
  320. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/select.tsx +0 -0
  321. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/separator.tsx +0 -0
  322. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sheet.tsx +0 -0
  323. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sidebar.tsx +0 -0
  324. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/skeleton.tsx +0 -0
  325. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/slider.tsx +0 -0
  326. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/spinner.tsx +0 -0
  327. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/switch.tsx +0 -0
  328. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/table.tsx +0 -0
  329. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tabs.tsx +0 -0
  330. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/textarea.tsx +0 -0
  331. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle-group.tsx +0 -0
  332. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle.tsx +0 -0
  333. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tooltip.tsx +0 -0
  334. /package/template/apps/{asas-platform → asas-backoffice}/src/copy.ts +0 -0
  335. /package/template/apps/{asas-platform → asas-backoffice}/src/hooks/use-mobile.ts +0 -0
  336. /package/template/apps/{asas-platform → asas-backoffice}/src/lib/utils.ts +0 -0
  337. /package/template/apps/{asas-platform → asas-backoffice}/src/theme.ts +0 -0
  338. /package/template/templates/{asas/src/kayan → features}/auth/user/index.ts +0 -0
  339. /package/template/templates/{asas/src → features/courses}/courses/ui/no-courses.tsx +0 -0
  340. /package/template/templates/{asas → mithaq}/components.json +0 -0
  341. /package/template/templates/{asas → mithaq}/src/storage/defaults.ts +0 -0
  342. /package/template/templates/{asas → mithaq}/src/storage/local.ts +0 -0
  343. /package/template/templates/{asas → mithaq}/src/storage/r2.ts +0 -0
  344. /package/template/templates/{asas → mithaq}/src/storage/s3.ts +0 -0
  345. /package/template/templates/{asas → mithaq}/src/storage/uploadthing.ts +0 -0
  346. /package/template/templates/{asas → mithaq}/src/ui/kpi-card.tsx +0 -0
  347. /package/template/templates/{asas → mithaq}/src/ui/record-page.tsx +0 -0
  348. /package/template/templates/{asas → mithaq}/src/ui/table-page.tsx +0 -0
@@ -1,865 +0,0 @@
1
- import { useEffect, useId, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactElement, type ReactNode } from 'react'
2
- import { ArrowDown, ArrowUp, ChevronDown, MoreHorizontal, Plus, Search as SearchIcon, X } from 'lucide-react'
3
- import type { ButtonClient, ClientField, ClientKayan, Row } from 'mithaq/admin'
4
- import { useScreen } from 'mithaq/admin/react'
5
- import { Button, buttonVariants } from '@/components/ui/button'
6
- import { Card, CardContent } from '@/components/ui/card'
7
- import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
8
- import { Input } from '@/components/ui/input'
9
- import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
10
- import { NodeButton, OwnButton, drawsKind, isUntitled, listOf, nodesOf, pageButtonOf, refOf, rowTitle, rowWarning, scopeOf, shown as holds, type ListNode, type PageButton, type ScreenPage } from './nodes.tsx'
11
- import { cellFor, cells } from './cells.tsx'
12
- import { counted, descriptionOf, labelFor, operationLabelFor, refusalKeyOf, titleOf, type Copy, type FindApi, type Translate } from './controls.tsx'
13
- import { DeleteButton, Form, OperationButton, type FormProps } from './form.tsx'
14
- import { useRelationshipTitles } from './relationship-cell.tsx'
15
- import { filterPathsOf } from './filter-operators.ts'
16
- import { Filters } from './list-filters.tsx'
17
- import { Columns, columnCandidates } from './list-columns.tsx'
18
- import { Pagination } from './list-pagination.tsx'
19
- import { SelectAll, SelectRow, writableFields } from './list-selection.tsx'
20
- import {
21
- canSort,
22
- columnsFrom,
23
- columnsTo,
24
- refined,
25
- remember,
26
- remembered,
27
- searchableFields,
28
- type ColumnChoice,
29
- type FilterWhere,
30
- type ListQuery,
31
- } from './list-query.ts'
32
-
33
- /** One page, as the managed reader answers it. */
34
- export type Page = {
35
- docs: readonly Record<string, unknown>[]
36
- hasNextPage: boolean
37
- hasPrevPage: boolean
38
- limit: number
39
- nextPage: number | null
40
- page: number
41
- prevPage: number | null
42
- totalDocs: number
43
- totalPages: number
44
- }
45
-
46
- /** The six options this screen drives `find` and the URL with. */
47
- export type Query = ListQuery
48
-
49
- export type ListProps = {
50
- kayan: ClientKayan
51
- page: Page
52
- query: Query
53
- t: Translate
54
- locale: string
55
- /**
56
- * Which locales exist, from the app: a localized field is offered to the filter panel as its locale paths.
57
- */
58
- locales?: readonly string[]
59
- /** Every kayan of the build, so a relationship's hop can name the target's fields. */
60
- client?: readonly ClientKayan[]
61
- /**
62
- * The typed client's reads. A relationship column names the rows it points at with one read per target for the whole page, and the filter panel's relationship value input pages its target with it.
63
- */
64
- api?: FindApi
65
- /**
66
- * Whether this actor may read one field, off `GET /api/access`. Chrome only: the read masks a denied column and refuses a denied filter whatever this says.
67
- */
68
- reads?: (kayan: string, field: string) => boolean
69
- /**
70
- * Whether this actor may create a row of this kayan, off the same report. Chrome only: the create is authorized server-side. Undeclared is allowed.
71
- */
72
- creates?: boolean
73
- /**
74
- * This actor's answer per operation name, off the same report. A button in the list node's `toolbar`, `bulk` or `row` is drawn where it allows that operation, and the selection column where it allows a bulk button. Chrome only; an absent key is denied.
75
- */
76
- operations?: Readonly<Record<string, boolean>>
77
- /** Whether this actor may write one field on an update, for a bulk update's boxes. */
78
- writes?: (field: string) => boolean
79
- /**
80
- * The access report a list button's `when` reads as `$user`, cut to the screen's kayan.
81
- */
82
- access?: unknown
83
- /**
84
- * What running one of the list's buttons costs the host: the call and the re-read after it. With none, no button is drawn, as there would be nothing for it to do.
85
- */
86
- run?: (operation: string, input: Record<string, unknown>) => Promise<unknown>
87
- /** Where the next query goes. Navigation, as a callback. */
88
- onQuery: (query: Query) => void
89
- /** Where one row goes. Navigation, as a prop, so no router is imported. */
90
- href: (id: string) => string
91
- /** Where a new row is written. */
92
- newHref: string
93
- /**
94
- * A screen's list page, where this list draws one: its list node names the columns, filters and search, and the components it names by path are drawn from here.
95
- */
96
- screen?: ScreenPage
97
- /** The list node this list draws, where it is not the page's own: a list of another kayan. */
98
- node?: ListNode
99
- /**
100
- * How a list of another kayan edits its rows in place, below the table: one row at a time, in the row's own form over the list's columns, a create being the same form with the page's row already in its relationship. Every write is the listed kayan's own and saves apart from the page's form. A row still opens its own page through `href`.
101
- */
102
- edit?: Pick<FormProps, 'client' | 'writes'> & {
103
- /** What every row it adds holds whatever the form sends: the relationship to the page's row. */
104
- prefill?: Readonly<Record<string, unknown>>
105
- }
106
- }
107
-
108
- /**
109
- * Which columns a list shows, in order: the URL's list when it carries one, else the list node's `columns`, else every field. Never a field this actor may not read.
110
- */
111
- export const columnsOf = ({
112
- kayan,
113
- cols,
114
- reads,
115
- opens,
116
- }: {
117
- kayan: ClientKayan
118
- cols?: readonly string[]
119
- reads?: (kayan: string, field: string) => boolean
120
- /** The columns a page's list node opens with, which is every field where there is no node. */
121
- opens?: readonly string[]
122
- }): readonly ColumnChoice[] => {
123
- const isDefault = (_kayan: ClientKayan, field: ClientField) => !opens || opens.includes(field.name)
124
- const candidates = columnCandidates(kayan, reads)
125
- const offered = new Set(candidates.map((field) => field.name))
126
- const fromUrl = columnsFrom(cols).filter((one) => offered.has(one.name))
127
- const named = new Set(fromUrl.map((one) => one.name))
128
- const rest = candidates.filter((field) => !named.has(field.name))
129
- if (fromUrl.length) {
130
- // A column the URL did not name keeps the declaration's answer, so a field added to a kayan is not hidden from old links.
131
- return [...fromUrl, ...rest.map((field) => ({ name: field.name, active: isDefault(kayan, field) }))]
132
- }
133
- return candidates.map((field) => ({ name: field.name, active: isDefault(kayan, field) }))
134
- }
135
-
136
- /**
137
- * The query a column change asks for. It keeps the page it is on, since it changes what a row shows and never which rows match.
138
- */
139
- export const withColumns = (query: Query, columns: readonly ColumnChoice[]): Query => ({
140
- ...query,
141
- cols: columnsTo(columns),
142
- })
143
-
144
- // Pinned cells are opaque (the card's fill with the head's or hovered row's tint mixed in), because a translucent one
145
- // shows the cells that scroll beneath it.
146
- const headFill = 'bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]'
147
- const pinnedHead = `sticky z-10 ${headFill}`
148
- const pinned = 'sticky z-10 bg-card group-hover/row:bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]'
149
- // A shadow on the row menu column's start edge while the table runs on under it. The head cell keeps its rule underneath
150
- // (`[&_th]:shadow-[inset_0_-1px_0_var(--border)]` on the head row), so the head's own takes `!` and carries the rule too.
151
- const edge = 'shadow-[-10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)] rtl:shadow-[10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)]'
152
- const edgeHead = 'shadow-[inset_0_-1px_0_var(--border),-10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)]! rtl:shadow-[inset_0_-1px_0_var(--border),10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)]!'
153
- // Exactly a touch target wide, so the first column pins right after it; on a phone as tall, so the box's 44 pixel layer
154
- // stays inside its own row.
155
- const selection = 'w-11 min-w-11 p-0 max-sm:h-11 [&>*]:mx-auto [&>[role=checkbox]]:translate-y-0'
156
-
157
- const nextSort = (name: string, sort: string | undefined) =>
158
- sort === name ? `-${name}` : sort === `-${name}` ? undefined : name
159
-
160
- function Search({
161
- fields,
162
- locale,
163
- onSearch,
164
- t,
165
- value,
166
- }: {
167
- value: string | undefined
168
- /** The labels of the fields a search runs over, which is empty for a kayan with none. */
169
- fields: readonly string[]
170
- locale: string
171
- onSearch: (term: string | undefined) => void
172
- t: Translate
173
- }): ReactElement | null {
174
- const [text, setText] = useState(value ?? '')
175
- useEffect(() => setText(value ?? ''), [value])
176
- useEffect(() => {
177
- if (text === (value ?? '')) return
178
- const timer = setTimeout(() => onSearch(text.trim() === '' ? undefined : text), 300)
179
- return () => clearTimeout(timer)
180
- }, [onSearch, text, value])
181
- // No searchable field, no box. After the hooks, which must run on every render or on none.
182
- if (!fields.length) return null
183
- const label = t('admin.search', 'Search') ?? 'Search'
184
- const placeholder = new Intl.ListFormat(locale, { type: 'disjunction' }).format(fields)
185
- return (
186
- <span className="flex w-full items-center gap-1 lg:max-w-sm">
187
- <span className="relative w-full">
188
- <SearchIcon className="text-muted-foreground absolute start-3 top-1/2 size-4 -translate-y-1/2" aria-hidden="true" />
189
- <Input
190
- type="search"
191
- className="ps-9 max-sm:h-11"
192
- placeholder={placeholder}
193
- aria-label={label}
194
- value={text}
195
- onChange={(event) => setText(event.target.value)}
196
- />
197
- </span>
198
- {text ? (
199
- <Button type="button" variant="outline" size="icon-sm" className="max-sm:size-11" onClick={() => setText('')} aria-label={t('admin.clearSearch', 'Clear search') ?? 'Clear search'}>
200
- <X />
201
- </Button>
202
- ) : null}
203
- </span>
204
- )
205
- }
206
-
207
- export function List({
208
- access,
209
- api,
210
- client,
211
- creates,
212
- edit,
213
- href,
214
- kayan,
215
- locale,
216
- locales,
217
- newHref,
218
- onQuery,
219
- operations,
220
- page,
221
- query,
222
- reads,
223
- run,
224
- screen,
225
- t,
226
- writes,
227
- ...props
228
- }: ListProps): ReactElement {
229
- const copy: Copy = { t, locale }
230
- const { maxLimit } = useScreen()
231
- const node = props.node ?? listOf(screen)
232
- /** The row being edited in place, `null` for a row being added, and nothing while no form is open. */
233
- const [editing, setEditing] = useState<Row | null | undefined>(undefined)
234
- const edits = edit && node
235
- ? { ...edit, client: { ...edit.client, create: (input: Record<string, unknown>) => edit.client.create({ ...input, ...edit.prefill }) } }
236
- : undefined
237
- const Heading = node?.of ? 'h2' : 'h1'
238
- const Empty = refOf({ screen, place: 'empty', path: node?.empty })
239
- /** The project's items in each row's menu, and the dialog one of them opened, which outlives the menu. */
240
- const Menu = refOf({ screen, place: 'menu', path: node?.menu })
241
- const [over, setOver] = useState<ReactNode>(null)
242
- const cellOf = new Map(node?.columns.map((column) => [column.field, column.cell]))
243
- const searched = searchableFields({ kayan, search: node?.search, client })
244
- const columns = columnsOf({ kayan, cols: query.cols, reads, opens: node?.columns.map((column) => column.field) })
245
- const active = columns.flatMap((column) => kayan.fields.find((field) => field.name === column.name && column.active) ?? [])
246
- const titled = active.find((field) => field.name === kayan.useAsTitle)
247
- const shown = titled ? [titled, ...active.filter((field) => field !== titled)] : active
248
- // One read per target for the whole page, so a relationship column is not one read per cell. Over the shown columns
249
- // alone, so a relationship the viewer has switched off costs no read.
250
- const titles = useRelationshipTitles({ fields: shown, docs: page.docs, context: { api, client, locale } })
251
- const paths = filterPathsOf(kayan, { client, locales, reads, ...(node?.filters && { filters: node.filters }) })
252
- // The first shown cell carries the row's link, so a hidden or unreadable title leaves a row reachable.
253
- const linked = shown[0]
254
- const refine = (next: Partial<Query>) => onQuery(refined(query, next))
255
- const filtered = Boolean(query.where || query.search)
256
- /**
257
- * Whether the person has opened or closed the filter panel, and nothing until they do. Until then it is open on a list whose URL filters, read on every render so a `where` that arrives after mount opens it.
258
- */
259
- const [filtering, setFiltering] = useState<boolean | undefined>(undefined)
260
- const open = filtering ?? Boolean(query.where)
261
- const filtersId = useId()
262
- /** The kayan's plural, which the bulk dialog and its result name the ticked rows by. */
263
- const plural = titleOf({ kayan, copy, many: true })
264
- const about = descriptionOf(kayan, copy)
265
-
266
- /**
267
- * The list node's buttons this actor may run, drawn only where the host said what running one costs and the report allows its operation by name.
268
- */
269
- const allowed = (declared: readonly ButtonClient[] | undefined) =>
270
- run ? (declared ?? []).flatMap((node) => {
271
- const operation = pageButtonOf({ kayan, node, allowed: operations })
272
- return operation ? [{ node, operation }] : []
273
- }) : []
274
- /** What a toolbar or bulk button's `when` reads: no values and no row, and the report as `$user`. */
275
- const scope = scopeOf({ values: {}, record: null, access, kayan })
276
- const toolbar = allowed(node?.toolbar).filter((one) => holds(one.node, scope))
277
- // A bulk button's boxes are the row's fields it names, cut to those this actor could write; its call is the ticked
278
- // rows' `where` around them.
279
- const bulkButtons = allowed(node?.bulk).filter((one) => holds(one.node, scope)).map((one) => ({
280
- ...one,
281
- operation: { ...one.operation, fields: writableFields(kayan, writes).filter((field) => one.node.fields?.includes(field.name)) },
282
- }))
283
- const rowButtons = allowed(node?.row)
284
- const editsRows = edits !== undefined && operations?.update === true
285
- const deletes = run !== undefined && operations?.delete === true && kayan.operations.some((one) => one.name === 'delete')
286
- const [deleting, setDeleting] = useState<Row | undefined>(undefined)
287
- const [asking, setAsking] = useState<{ row: Row; operation: PageButton } | undefined>(undefined)
288
- const [running, setRunning] = useState(false)
289
- const table = useRef<HTMLTableElement>(null)
290
- // Whether the table runs past its frame's end, so the frame fades there. The value is the width of the row menu's pinned
291
- // column, which the fade sits before, and `false` where nothing runs past. `scrollLeft` is negative on a right-to-left page.
292
- const [more, setMore] = useState<number | false>(false)
293
- useEffect(() => {
294
- const grid = table.current
295
- const frame = grid?.parentElement
296
- if (!grid || !frame) return
297
- const check = () => setMore(frame.scrollWidth - frame.clientWidth - Math.abs(frame.scrollLeft) > 1 && (grid.querySelector('thead th:last-child')?.getBoundingClientRect().width ?? 0))
298
- check()
299
- frame.addEventListener('scroll', check, { passive: true })
300
- const resized = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(check)
301
- resized?.observe(frame)
302
- resized?.observe(grid)
303
- return () => {
304
- frame.removeEventListener('scroll', check)
305
- resized?.disconnect()
306
- }
307
- }, [])
308
- /**
309
- * The name of a row's menu button, naming the row by its title so a reader going down the column hears each row's own; a row with no title, or with the title column hidden, keeps the shared name.
310
- */
311
- const rowActionsLabel = (row: Row) => {
312
- const name = titled && rowTitle({ kayan, row, locale })
313
- // Bidi-isolated, so a name in the other direction does not reorder the sentence.
314
- return name ? (t('admin.rowActionsFor', 'Actions for {name}') ?? 'Actions for {name}').replace('{name}', () => `\u2068${name}\u2069`) : (t('admin.rowActions', 'Row actions') ?? 'Row actions')
315
- }
316
- const openHint = useId()
317
- /**
318
- * Opens a row by clicking its own link, so the host routes it as it routes every other link and the list imports no router.
319
- */
320
- const openRow = (id: string) =>
321
- [...(table.current?.querySelectorAll<HTMLAnchorElement>('a[data-row-link]') ?? [])].find((one) => one.dataset.rowLink === id)?.click()
322
- const runOnRow = async (operation: string, input: Record<string, unknown>, refuse: (thrown: unknown) => void = setRefused) => {
323
- setRunning(true)
324
- setRefused(undefined)
325
- try {
326
- await run!(operation, input)
327
- return true
328
- } catch (thrown) {
329
- refuse(thrown)
330
- return false
331
- } finally {
332
- setRunning(false)
333
- }
334
- }
335
- /**
336
- * Whether no column the list opens with is one the viewer may read, which the projection says by leaving the field out. Such a list draws no rows, nor anything that acts on them.
337
- */
338
- const unreadable = !(node?.columns.map((column) => column.field) ?? kayan.fields.map((field) => field.name)).some((name) => kayan.fields.some((field) => field.name === name))
339
- const picks = bulkButtons.length > 0 && !unreadable
340
- /**
341
- * The refusal the last run answered with, drawn above the table, so a refused button that closed does not leave the page reading as changed.
342
- */
343
- const [refused, setRefused] = useState<unknown>(undefined)
344
- const refusal = refusalKeyOf(refused)
345
- const button = (one: PageButton, inputOf: (values: Record<string, unknown>) => Record<string, unknown>, key: string, extra: { description?: string; disabled?: boolean; onRan?: (written: unknown) => void } = {}) => (
346
- <ListButton key={key} operation={one} kayan={kayan} client={client} api={api} copy={copy} screen={screen} run={run!} inputOf={inputOf} onRefused={setRefused} {...extra} />
347
- )
348
- const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set())
349
- /**
350
- * How many rows the last bulk changed, announced in the count's live region after the ticks drop. A new object per bulk restarts its six seconds; it clears after them or when a row is ticked again.
351
- */
352
- const [done, setDone] = useState<{ count: number } | undefined>(undefined)
353
- useEffect(() => {
354
- if (!done) return
355
- const timer = setTimeout(() => setDone(undefined), 6000)
356
- return () => clearTimeout(timer)
357
- }, [done])
358
- const rowIds = page.docs.map((row) => String(row.id ?? ''))
359
- /**
360
- * The ticked rows still on the page. A page that moved under the selection leaves ids that are no longer on screen, and a delete of rows nobody can see is the one thing this must not send.
361
- */
362
- const ticked = rowIds.filter((one) => one && selected.has(one))
363
- useEffect(() => {
364
- if (ticked.length) setDone(undefined)
365
- }, [ticked.length])
366
- /** A counted sentence about the ticked rows, capitalised though the kayan's plural may be lower case. */
367
- const aboutTicked = (key: string, count: number, fallback: string) => {
368
- const said = counted({ t, locale, key, count, fallback }).replace('{plural}', () => plural)
369
- return said.charAt(0).toLocaleUpperCase(locale) + said.slice(1)
370
- }
371
-
372
- // Per-viewer limit and columns are remembered in `localStorage`, read once and only where the URL named neither.
373
- // Read in an effect: it does not exist on the server, and reading it during render would mismatch hydration.
374
- useEffect(() => {
375
- if (query.limit !== undefined || query.cols !== undefined) return
376
- const held = remembered(kayan.name, maxLimit)
377
- if (held.limit === undefined && held.cols === undefined) return
378
- onQuery({ ...query, ...held })
379
- // The kayan alone: running again on the query would undo a viewer's own change back to what was remembered.
380
- }, [kayan.name])
381
- useEffect(() => {
382
- if (query.limit === undefined && query.cols === undefined) return
383
- remember(kayan.name, { ...(query.limit !== undefined && { limit: query.limit }), ...(query.cols !== undefined && { cols: query.cols }) })
384
- }, [kayan.name, query.cols, query.limit])
385
-
386
- return (
387
- <section className={`flex w-full min-w-0 flex-1 flex-col gap-4 ${ticked.length || done ? 'max-sm:pb-[calc(5rem+env(safe-area-inset-bottom))]' : ''}`}>
388
- {/* `flex-wrap`: on a phone the title and the button share 320 pixels. `min-w-0` on the section lets the list shrink
389
- inside the grid or flex row that holds it, whose items otherwise keep the width of their widest row. */}
390
- <header className="flex flex-wrap items-start justify-between gap-4">
391
- <div className="grid min-w-0 gap-1">
392
- {/* A title node on the screen's own list page is the page's one h1, so the list keeps only its sentence; a list
393
- of another kayan keeps its heading. */}
394
- {screen && !node?.of && drawsKind({ nodes: nodesOf(screen.page), kind: 'title', scope }) ? null : <Heading className={node?.of ? 'text-lg font-semibold' : 'text-2xl font-bold tracking-tight'}>{titleOf({ kayan, copy, many: true })}</Heading>}
395
- {about ? <p className="text-muted-foreground text-sm">{about}</p> : null}
396
- </div>
397
- {creates === false ? null : edits ? (
398
- // A list of another kayan adds a row in place, below the table.
399
- <Button type="button" className="max-sm:h-11" onClick={() => setEditing(null)}>
400
- <Plus className="me-2 h-4 w-4" />
401
- {t(`${kayan.name}.new`, t('admin.new', 'New'))}
402
- </Button>
403
- ) : (
404
- // `render` keeps the destination an anchor; `nativeButton={false}` tells base-ui the rendered element is not
405
- // a button.
406
- <Button nativeButton={false} className="max-sm:h-11" render={<a href={newHref} />}>
407
- <Plus className="me-2 h-4 w-4" />
408
- {t(`${kayan.name}.new`, t('admin.new', 'New'))}
409
- </Button>
410
- )}
411
- </header>
412
-
413
- <Card>
414
- <CardContent>
415
- <div className="w-full space-y-4">
416
- <div className="flex flex-col justify-between gap-2 lg:flex-row lg:items-center">
417
- <div className="flex flex-1 flex-col gap-2 lg:flex-row lg:items-center">
418
- <Search
419
- value={query.search}
420
- fields={searched.flatMap((path) => {
421
- // A path across a relationship is named by both labels ("Course Title"), so the far field is not
422
- // mistaken for the list's own, in the locale's order.
423
- const [own, far] = path.split('.')
424
- const near = kayan.fields.find((one) => one.name === own)
425
- const there = far === undefined ? kayan : client?.find((one) => one.name === near?.relationTo)
426
- const field = there?.fields.find((one) => one.name === (far ?? own))
427
- if (!there || !field) return []
428
- const label = labelFor({ kayan: there, field, copy })
429
- if (far === undefined || !near) return [label]
430
- return [(t('admin.searchThrough', '{relation} {field}') ?? '{relation} {field}').replace('{relation}', () => labelFor({ kayan, field: near, copy })).replace('{field}', () => label)]
431
- })}
432
- locale={locale}
433
- t={t}
434
- onSearch={(search) => refine({ search })}
435
- />
436
- </div>
437
- <div className="flex flex-wrap items-center gap-2">
438
- {refusal ? (
439
- <p role="alert" className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive">
440
- {t(refusal, refusal)}
441
- </p>
442
- ) : null}
443
- {toolbar.map((one, at) => button(one.operation, (values) => values, `toolbar-${at}`, { description: t('admin.toolbarWarning', 'This runs on no row of the list.') ?? 'This runs on no row of the list.' }))}
444
- {/* Both stay mounted, the buttons hidden until a row is ticked: a live region must exist before its first change
445
- to be announced, and a dialog a button opened must outlive a read that took the ticked rows off the page. */}
446
- {picks ? (
447
- // On a phone the count and bulk buttons are a bar fixed to the bottom while a row is ticked, so ticking
448
- // does not push the table down under the finger. It stands on the form's own bar where a record page draws one.
449
- <div
450
- data-mithaq-bulk-bar=""
451
- role="region"
452
- aria-label={t('admin.bulkBar', 'Ticked rows') ?? 'Ticked rows'}
453
- className={ticked.length || done ? 'contents max-sm:bg-background max-sm:fixed max-sm:inset-x-0 max-sm:bottom-[var(--action-bar-height,0px)] max-sm:z-30 max-sm:flex max-sm:flex-nowrap max-sm:items-center max-sm:gap-2 max-sm:overflow-x-auto max-sm:border-t max-sm:p-3 max-sm:pb-[max(0.75rem,env(safe-area-inset-bottom))] max-sm:[&_button]:shrink-0 max-sm:[&>span]:shrink-0' : 'contents'}
454
- >
455
- {/* `aria-live`: the count changes as boxes are ticked and nothing else announces it. */}
456
- <span className={ticked.length || done ? 'text-muted-foreground text-sm' : 'sr-only'} aria-live="polite" data-mithaq-selected={ticked.length || undefined} data-mithaq-done={!ticked.length && done ? done.count : undefined}>
457
- {ticked.length
458
- ? (t('admin.selected', '{count} selected') ?? '{count} selected').replace('{count}', String(ticked.length))
459
- : done ? aboutTicked('admin.bulkDone', done.count, '{plural} done: {count}.') : ''}
460
- </span>
461
- <span hidden={ticked.length === 0} className={ticked.length ? 'contents' : undefined}>
462
- {bulkButtons.map((one, at) =>
463
- button(
464
- one.operation,
465
- // Only the ticked rows still on the page, with the boxes' values as the data written over each.
466
- (values) => ({ where: { id: { in: ticked } }, ...(one.operation.fields.length > 0 && { data: values }) }),
467
- `bulk-${at}`,
468
- {
469
- // A where of no id is a call nobody ticked, which a dialog left open over rows that left the
470
- // page would confirm, so it is disabled.
471
- disabled: ticked.length === 0,
472
- description: aboutTicked('admin.bulkWarning', ticked.length, '{plural} you ticked: {count}. This applies to each of them.'),
473
- // The ticked ids name rows the host's re-read is about to drop, so the selection is cleared
474
- // and what the bulk did is said, less the rows it named as left.
475
- onRan: (written) => {
476
- setDone({ count: ticked.length - ((written as { errors?: readonly unknown[] } | null)?.errors?.length ?? 0) })
477
- setSelected(new Set())
478
- },
479
- },
480
- ),
481
- )}
482
- </span>
483
- </div>
484
- ) : null}
485
- {paths.length ? (
486
- <Button
487
- type="button"
488
- variant={open || query.where ? 'secondary' : 'outline'}
489
- className="max-sm:h-11"
490
- aria-controls={open ? filtersId : undefined}
491
- aria-expanded={open}
492
- onClick={() => setFiltering(!open)}
493
- >
494
- {t('admin.filters', 'Filters') ?? 'Filters'}
495
- <ChevronDown className={open ? 'rotate-180' : undefined} aria-hidden="true" />
496
- </Button>
497
- ) : null}
498
- {unreadable ? null : (
499
- <Columns
500
- kayan={kayan}
501
- columns={columns}
502
- t={t}
503
- locale={locale}
504
- onChange={(next) => onQuery(withColumns(query, next))}
505
- />
506
- )}
507
- </div>
508
- </div>
509
-
510
- {open ? (
511
- <div id={filtersId}>
512
- <Filters
513
- paths={paths}
514
- value={query.where}
515
- t={t}
516
- locale={locale}
517
- client={client}
518
- api={api}
519
- onChange={(where: FilterWhere | undefined) => {
520
- // The person is at work in the panel, so it stays, even where the last filter goes.
521
- setFiltering(true)
522
- refine({ where })
523
- }}
524
- />
525
- </div>
526
- ) : null}
527
-
528
- {/* Held to the screen below the page's header so it scrolls both ways and the head row can stay (a printed page
529
- holds every row). It isolates, so pinned cells never paint over the page's sticky header or take its clicks. */}
530
- <div className="bg-card relative isolate overflow-hidden rounded-lg border [&>div:first-child]:max-h-[calc(100dvh-var(--header-height,0px))] print:[&>div:first-child]:max-h-none">
531
- {unreadable ? null : (
532
- <Table ref={table}>
533
- {/* The head rule is each head cell's own inset shadow, since a collapsed border scrolls away from a sticky
534
- head; the row's own `border-b` is dropped so both do not draw. */}
535
- <TableHeader className={`sticky top-0 z-20 ${headFill} [&_tr]:border-b-0 [&_th]:shadow-[inset_0_-1px_0_var(--border)]`}>
536
- <TableRow>
537
- {picks ? (
538
- <TableHead scope="col" className={`${pinnedHead} start-0 ${selection}`}>
539
- <SelectAll ids={rowIds} selected={selected} onSelect={setSelected} t={t} />
540
- </TableHead>
541
- ) : null}
542
- {shown.map((field) => {
543
- // A heading is a button only where the read would take the sort: the server answers 400 for a
544
- // localized field, a set, a join and a screen's `sortable: false`.
545
- const sortable = canSort(field)
546
- // The URL's sort, else the list node's default, which is the order the page was read in.
547
- const sort = query.sort ?? node?.sort
548
- const direction = sort === field.name ? 'ascending' : sort === `-${field.name}` ? 'descending' : 'none'
549
- return (
550
- <TableHead key={field.name} scope="col" aria-sort={sortable ? direction : undefined} className={field === linked ? `${pinnedHead} ${picks ? 'start-11' : 'start-0'}` : undefined}>
551
- {sortable ? (
552
- // One arrow, on the sorted column only. `aria-hidden` on the glyph and `aria-sort` on the
553
- // cell keep the answer readable without it.
554
- <Button
555
- type="button"
556
- variant="ghost"
557
- size="sm"
558
- className="-ms-2 h-8 px-2 max-sm:h-11 max-sm:min-w-11"
559
- onClick={() => refine({ sort: nextSort(field.name, query.sort) })}
560
- >
561
- {labelFor({ kayan, field, copy })}
562
- {direction === 'ascending' ? (
563
- <ArrowUp className="size-4" aria-hidden="true" />
564
- ) : direction === 'descending' ? (
565
- <ArrowDown className="size-4" aria-hidden="true" />
566
- ) : null}
567
- </Button>
568
- ) : (
569
- labelFor({ kayan, field, copy })
570
- )}
571
- </TableHead>
572
- )
573
- })}
574
- <TableHead scope="col" className={`${pinnedHead} end-0 w-0 ${more === false ? '' : edgeHead}`}>
575
- <span className="sr-only">{t('admin.actions', 'Actions')}</span>
576
- </TableHead>
577
- </TableRow>
578
- </TableHeader>
579
- <TableBody>
580
- {page.docs.map((row, index) => {
581
- const id = String(row.id ?? '')
582
- // The list node's row buttons whose `when` holds over this row. One a project draws with its own
583
- // component stays in the cell, since a menu item cannot be one.
584
- const offered = rowButtons.filter((one) => holds(one.node, scopeOf({ values: row, record: row, access, kayan })))
585
- const own = offered.filter((one) => refOf({ screen, place: 'button', path: one.operation.component }))
586
- const menu = offered.filter((one) => !own.includes(one))
587
- // `#` is the host's word for a kayan with no page that draws a row, so such a row is no link and
588
- // no tab stop, and its menu offers neither Edit nor View.
589
- const to = href(id)
590
- const opens = to !== '#'
591
- const edits = editsRows || opens
592
- return (
593
- // The whole row opens the record, and so does Enter on the row. A click on anything inside that acts
594
- // on its own, or on the menu or a dialog it opened (React hands those up through its portal), opens nothing.
595
- <TableRow
596
- key={String(row.id ?? index)}
597
- tabIndex={opens ? 0 : undefined}
598
- data-row={id}
599
- aria-describedby={opens ? openHint : undefined}
600
- // The ring every focusable control draws, inset so the table's frame does not clip it.
601
- className={opens ? 'group/row cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset' : 'group/row'}
602
- onClick={(event: MouseEvent<HTMLTableRowElement>) => {
603
- const target = event.target as Element
604
- if (!opens || !event.currentTarget.contains(target) || target.closest('a, button, input, select, textarea, label, [role="checkbox"], [role="menuitem"]')) return
605
- openRow(id)
606
- }}
607
- onKeyDown={(event: KeyboardEvent<HTMLTableRowElement>) => {
608
- if (!opens || event.key !== 'Enter' || event.target !== event.currentTarget) return
609
- event.preventDefault()
610
- openRow(id)
611
- }}
612
- >
613
- {picks ? (
614
- <TableCell className={`${pinned} start-0 ${selection}`}>
615
- <SelectRow id={id} selected={selected} onSelect={setSelected} t={t} />
616
- </TableCell>
617
- ) : null}
618
- {shown.map((field) => {
619
- const Ref = refOf({ screen, place: 'cell', path: cellOf.get(field.name) ?? field.cell })
620
- // An email cell is a mail link, and a link cannot sit in the row's link (`<a>` in `<a>` is a
621
- // hydration error), so the linked column draws its text. Upload draws no anchor.
622
- const drawn = cellFor(field, cellOf.get(field.name))
623
- const Cell = drawn === cells.email && field === linked && opens ? cells.text! : drawn
624
- const untitled = field.name === kayan.useAsTitle && isUntitled({ kayan, row, locale })
625
- const cell = untitled ? (
626
- <span className="text-muted-foreground">{t('admin.untitled', 'Untitled') ?? 'Untitled'}</span>
627
- ) : Ref && screen ? (
628
- <Ref field={field} value={row[field.name]} row={row} />
629
- ) : (
630
- <Cell kayan={kayan} field={field} value={row[field.name]} t={t} locale={locale} titles={titles} />
631
- )
632
- return (
633
- <TableCell key={field.name} className={field === linked ? `${pinned} ${picks ? 'start-11' : 'start-0'}` : undefined}>
634
- {field === linked && opens ? (
635
- // A real link, so it opens in a new tab and lists for a reader, but out of the tab
636
- // order since the row is the stop. An untitled row's name holds the visible text.
637
- <a href={to} data-row-link={id} tabIndex={-1} aria-label={untitled ? (t('admin.openUntitled', 'Untitled, open') ?? 'Untitled, open') : undefined} className="font-medium hover:underline">
638
- {cell}
639
- </a>
640
- ) : (
641
- cell
642
- )}
643
- </TableCell>
644
- )
645
- })}
646
- <TableCell className={`${pinned} end-0 w-0 ${more === false ? '' : edge}`}>
647
- <div className="flex items-center justify-end gap-2">
648
- {own.map((one, at) => button(one.operation, (values) => ({ ...values, id: row.id }), `row-${at}`, { description: rowWarning({ kayan, row, operation: one.operation.name, copy }) }))}
649
- {edits || menu.length || deletes || Menu ? (
650
- <DropdownMenu>
651
- <DropdownMenuTrigger
652
- data-row-menu={id}
653
- aria-label={rowActionsLabel(row)}
654
- className={buttonVariants({ variant: 'ghost', size: 'icon-sm', className: 'max-sm:size-11' })}
655
- >
656
- <MoreHorizontal className="size-4" aria-hidden="true" />
657
- </DropdownMenuTrigger>
658
- <DropdownMenuContent align="end" className="w-40">
659
- {/* View where the report lets this actor update no row of the kayan, since the row's
660
- page is then only read. */}
661
- {edits ? (
662
- operations && operations.update !== true ? (
663
- <DropdownMenuItem data-row-action="view" className="max-sm:min-h-11" onClick={() => openRow(id)}>
664
- {t('admin.view', 'View') ?? 'View'}
665
- </DropdownMenuItem>
666
- ) : (
667
- <DropdownMenuItem data-row-action="edit" className="max-sm:min-h-11" onClick={() => (editsRows ? setEditing(row) : openRow(id))}>
668
- {t('admin.edit', 'Edit') ?? 'Edit'}
669
- </DropdownMenuItem>
670
- )
671
- ) : null}
672
- {menu.map((one) => (
673
- <DropdownMenuItem
674
- key={one.operation.name}
675
- data-row-action={one.operation.name}
676
- className="max-sm:min-h-11"
677
- disabled={running}
678
- onClick={() => {
679
- // A button that asks for values or carries a confirm opens its dialog over the
680
- // row; any other runs.
681
- if (one.operation.fields?.length || one.operation.confirm) return setAsking({ row, operation: one.operation })
682
- void runOnRow(one.operation.name, { id: row.id })
683
- }}
684
- >
685
- {operationLabelFor({ kayan, operation: one.operation, copy })}
686
- </DropdownMenuItem>
687
- ))}
688
- {Menu ? <Menu row={row} open={setOver} /> : null}
689
- {deletes ? (
690
- <DropdownMenuItem data-row-action="delete" variant="destructive" className="max-sm:min-h-11" disabled={running} onClick={() => setDeleting(row)}>
691
- {t('admin.delete', 'Delete') ?? 'Delete'}
692
- </DropdownMenuItem>
693
- ) : null}
694
- </DropdownMenuContent>
695
- </DropdownMenu>
696
- ) : null}
697
- </div>
698
- </TableCell>
699
- </TableRow>
700
- )
701
- })}
702
- </TableBody>
703
- </Table>
704
- )}
705
- {/* The table can be wider than a phone, so a sentence in a cell spanning it would be cut off at the frame's
706
- edge. */}
707
- {page.docs.length === 0 ? (
708
- <div data-list-message="" className="text-muted-foreground flex min-h-24 items-center justify-center p-4 text-center text-sm">
709
- {filtered ? (
710
- <span className="flex flex-col items-center gap-2">
711
- {t('admin.noMatches', 'No rows match this filter.') ?? 'No rows match this filter.'}
712
- <Button type="button" variant="outline" size="sm" className="max-sm:h-11" onClick={() => refine({ search: undefined, where: undefined })}>
713
- {t('admin.clearFilters', 'Clear filters') ?? 'Clear filters'}
714
- </Button>
715
- </span>
716
- ) : Empty && screen ? (
717
- <Empty page={screen.page} />
718
- ) : (
719
- (t(`${kayan.name}.none`) ?? t('admin.empty', 'Nothing here yet.') ?? 'Nothing here yet.')
720
- )}
721
- </div>
722
- ) : unreadable ? (
723
- // Rows with no column to draw would read as records with nothing in them, so the list says what it
724
- // cannot show, and whom to ask
725
- <div data-list-message="" className="text-muted-foreground flex min-h-24 items-center justify-center p-4 text-center text-sm">
726
- {t('admin.noColumns', 'Your role can\'t see any column of this list. Ask an administrator of your organization for access.')}
727
- </div>
728
- ) : null}
729
- {more === false ? null : <div data-scroll-cue="" aria-hidden className="from-card pointer-events-none absolute inset-y-0 w-10 bg-linear-to-l to-transparent rtl:bg-linear-to-r" style={{ insetInlineEnd: more }} />}
730
- </div>
731
-
732
- <span id={openHint} hidden>
733
- {t('admin.openRow', 'Press Enter to open this row.') ?? 'Press Enter to open this row.'}
734
- </span>
735
-
736
- {/* Drawn once below the table rather than inside the menu, which closes as its item is pressed and would
737
- take a dialog inside it along. */}
738
- {deletes ? (
739
- <DeleteButton
740
- busy={running}
741
- copy={copy}
742
- kayan={kayan}
743
- label={t('admin.delete', 'Delete') ?? 'Delete'}
744
- open={deleting !== undefined}
745
- onOpenChange={(open) => (open ? undefined : setDeleting(undefined))}
746
- row={deleting}
747
- onDelete={(refuse) => runOnRow('delete', { id: deleting?.id }, refuse)}
748
- />
749
- ) : null}
750
- {asking ? (
751
- <OperationButton
752
- api={api}
753
- busy={running}
754
- copy={copy}
755
- description={rowWarning({ kayan, row: asking.row, operation: asking.operation.name, copy })}
756
- inputOf={(values) => ({ ...values, id: asking.row.id })}
757
- kayan={kayan}
758
- kayans={client}
759
- operation={asking.operation}
760
- open
761
- onOpenChange={(open) => (open ? undefined : setAsking(undefined))}
762
- run={(input, refuse) => runOnRow(asking.operation.name, input, refuse)}
763
- />
764
- ) : null}
765
- {over}
766
-
767
- {unreadable ? null : (
768
- <Pagination
769
- page={page}
770
- t={t}
771
- locale={locale}
772
- onPage={(next) => onQuery({ ...query, page: next })}
773
- onLimit={(limit) => refine({ limit })}
774
- />
775
- )}
776
-
777
- {edits && editing !== undefined ? (
778
- <div data-mithaq-inline-edit={editing ? String(editing.id ?? '') : 'new'} className="bg-muted/30 rounded-md border p-3">
779
- <Form
780
- kayan={kayan}
781
- screen={screen && { ...screen, kayan }}
782
- nodes={node!.columns}
783
- client={edits.client}
784
- writes={edits.writes}
785
- value={editing}
786
- t={t}
787
- locale={locale}
788
- locales={locales}
789
- kayans={client}
790
- api={api}
791
- onDone={() => setEditing(undefined)}
792
- onCancel={() => setEditing(undefined)}
793
- />
794
- </div>
795
- ) : null}
796
- </div>
797
- </CardContent>
798
- </Card>
799
- </section>
800
- )
801
- }
802
-
803
- function ListButton({
804
- api,
805
- client,
806
- copy,
807
- description,
808
- disabled,
809
- inputOf,
810
- kayan,
811
- onRan,
812
- onRefused,
813
- operation,
814
- run,
815
- screen,
816
- }: {
817
- api?: FindApi
818
- client?: readonly ClientKayan[]
819
- copy: Copy
820
- description?: string
821
- disabled?: boolean
822
- inputOf: (values: Record<string, unknown>) => Record<string, unknown>
823
- kayan: ClientKayan
824
- onRan?: (written: unknown) => void
825
- onRefused: (thrown: unknown) => void
826
- operation: PageButton
827
- run: (operation: string, input: Record<string, unknown>) => Promise<unknown>
828
- screen?: ScreenPage
829
- }): ReactElement {
830
- const [busy, setBusy] = useState(false)
831
- const call = async (input: Record<string, unknown>, refuse: (thrown: unknown) => void = onRefused) => {
832
- // Every path ends here, so a button that ignores `busy` sends nothing too.
833
- if (disabled) return false
834
- setBusy(true)
835
- onRefused(undefined)
836
- try {
837
- onRan?.(await run(operation.name, input))
838
- return true
839
- } catch (thrown) {
840
- refuse(thrown)
841
- return false
842
- } finally {
843
- setBusy(false)
844
- }
845
- }
846
- const Own = refOf({ screen, place: 'button', path: operation.component })
847
- if (Own && screen) {
848
- return <OwnButton Own={Own} operation={operation} busy={busy || disabled === true} copy={copy} call={async (input: Row) => void (await call(inputOf(input)))} />
849
- }
850
- if (!operation.fields?.length) return <NodeButton node={operation} copy={copy} busy={busy || disabled === true} description={description} run={() => void call(inputOf({}))} />
851
- return (
852
- <OperationButton
853
- api={api}
854
- busy={busy || disabled === true}
855
- copy={copy}
856
- description={description}
857
- inputOf={inputOf}
858
- kayan={kayan}
859
- kayans={client}
860
- operation={operation}
861
- run={call}
862
- />
863
- )
864
- }
865
- // mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 97acf94d409745118e2ec5a6f107c4ef0ff08f4ec9202ba97fb24f8408de7ddf