create-mithaq 0.0.0-stage → 0.1.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.
- package/LICENSE +22 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/index.js +127 -0
- package/package.json +19 -4
- package/template/apps/asas-platform/components.json +25 -0
- package/template/apps/asas-platform/gitignore +3 -0
- package/template/apps/asas-platform/package.json +55 -0
- package/template/apps/asas-platform/public/favicon.svg +1 -0
- package/template/apps/asas-platform/src/api.ts +147 -0
- package/template/apps/asas-platform/src/auth-frame.tsx +150 -0
- package/template/apps/asas-platform/src/components/mithaq/cells.tsx +112 -0
- package/template/apps/asas-platform/src/components/mithaq/controls.tsx +650 -0
- package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +53 -0
- package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +108 -0
- package/template/apps/asas-platform/src/components/mithaq/form.tsx +1467 -0
- package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +108 -0
- package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +368 -0
- package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +153 -0
- package/template/apps/asas-platform/src/components/mithaq/list-query.ts +432 -0
- package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +74 -0
- package/template/apps/asas-platform/src/components/mithaq/list.tsx +865 -0
- package/template/apps/asas-platform/src/components/mithaq/nav.tsx +871 -0
- package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +1024 -0
- package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +127 -0
- package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +400 -0
- package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +6 -0
- package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +1083 -0
- package/template/apps/asas-platform/src/components/mithaq/upload.tsx +686 -0
- package/template/apps/asas-platform/src/components/ui/accordion.tsx +74 -0
- package/template/apps/asas-platform/src/components/ui/alert-dialog.tsx +185 -0
- package/template/apps/asas-platform/src/components/ui/avatar.tsx +106 -0
- package/template/apps/asas-platform/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-platform/src/components/ui/breadcrumb.tsx +124 -0
- package/template/apps/asas-platform/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-platform/src/components/ui/button.tsx +55 -0
- package/template/apps/asas-platform/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-platform/src/components/ui/card.tsx +102 -0
- package/template/apps/asas-platform/src/components/ui/chart.tsx +371 -0
- package/template/apps/asas-platform/src/components/ui/checkbox.tsx +28 -0
- package/template/apps/asas-platform/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/combobox.tsx +308 -0
- package/template/apps/asas-platform/src/components/ui/dialog.tsx +158 -0
- package/template/apps/asas-platform/src/components/ui/direction.tsx +6 -0
- package/template/apps/asas-platform/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-platform/src/components/ui/drawer.tsx +216 -0
- package/template/apps/asas-platform/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-platform/src/components/ui/field.tsx +236 -0
- package/template/apps/asas-platform/src/components/ui/input-group.tsx +155 -0
- package/template/apps/asas-platform/src/components/ui/input-otp.tsx +84 -0
- package/template/apps/asas-platform/src/components/ui/input.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/label.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/popover.tsx +90 -0
- package/template/apps/asas-platform/src/components/ui/radio-group.tsx +37 -0
- package/template/apps/asas-platform/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-platform/src/components/ui/scroll-area.tsx +54 -0
- package/template/apps/asas-platform/src/components/ui/select.tsx +204 -0
- package/template/apps/asas-platform/src/components/ui/separator.tsx +24 -0
- package/template/apps/asas-platform/src/components/ui/sheet.tsx +139 -0
- package/template/apps/asas-platform/src/components/ui/sidebar.tsx +714 -0
- package/template/apps/asas-platform/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-platform/src/components/ui/slider.tsx +55 -0
- package/template/apps/asas-platform/src/components/ui/spinner.tsx +10 -0
- package/template/apps/asas-platform/src/components/ui/switch.tsx +29 -0
- package/template/apps/asas-platform/src/components/ui/table.tsx +115 -0
- package/template/apps/asas-platform/src/components/ui/tabs.tsx +79 -0
- package/template/apps/asas-platform/src/components/ui/textarea.tsx +17 -0
- package/template/apps/asas-platform/src/components/ui/toggle-group.tsx +89 -0
- package/template/apps/asas-platform/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-platform/src/components/ui/tooltip.tsx +63 -0
- package/template/apps/asas-platform/src/copy.ts +1 -0
- package/template/apps/asas-platform/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-platform/src/lib/utils.ts +1 -0
- package/template/apps/asas-platform/src/links.ts +38 -0
- package/template/apps/asas-platform/src/locale-switch.tsx +26 -0
- package/template/apps/asas-platform/src/redirect.ts +1 -0
- package/template/apps/asas-platform/src/request-headers.ts +11 -0
- package/template/apps/asas-platform/src/routeTree.gen.ts +181 -0
- package/template/apps/asas-platform/src/router.tsx +7 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +4 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/screen.json +3609 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/types.ts +145 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.$.tsx +17 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.index.tsx +10 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.tsx +13 -0
- package/template/apps/asas-platform/src/routes/__root.tsx +95 -0
- package/template/apps/asas-platform/src/routes/api/$.ts +30 -0
- package/template/apps/asas-platform/src/routes/auth.sign-in.tsx +72 -0
- package/template/apps/asas-platform/src/routes/unauthorized.tsx +122 -0
- package/template/apps/asas-platform/src/screen-form.tsx +34 -0
- package/template/apps/asas-platform/src/screen-list.tsx +139 -0
- package/template/apps/asas-platform/src/screen-names.ts +10 -0
- package/template/apps/asas-platform/src/screens.tsx +868 -0
- package/template/apps/asas-platform/src/server.ts +20 -0
- package/template/apps/asas-platform/src/slide.ts +22 -0
- package/template/apps/asas-platform/src/start.ts +42 -0
- package/template/apps/asas-platform/src/styles.css +200 -0
- package/template/apps/asas-platform/src/theme.ts +2 -0
- package/template/apps/asas-platform/tsconfig.json +19 -0
- package/template/apps/asas-platform/vite.config.ts +26 -0
- package/template/apps/asas-tanstack/components.json +25 -0
- package/template/apps/asas-tanstack/gitignore +3 -0
- package/template/apps/asas-tanstack/package.json +56 -0
- package/template/apps/asas-tanstack/public/favicon.svg +1 -0
- package/template/apps/asas-tanstack/src/api.ts +147 -0
- package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
- package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +112 -0
- package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +650 -0
- package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +53 -0
- package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +108 -0
- package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +1467 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +108 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +368 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +153 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +432 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +74 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +865 -0
- package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +871 -0
- package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +1024 -0
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +127 -0
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +400 -0
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +6 -0
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +1083 -0
- package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +686 -0
- package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
- package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
- package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
- package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
- package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
- package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
- package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
- package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
- package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
- package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
- package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
- package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +215 -0
- package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
- package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
- package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
- package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
- package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
- package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
- package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
- package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
- package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
- package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
- package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
- package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
- package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
- package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
- package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
- package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
- package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
- package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
- package/template/apps/asas-tanstack/src/copy.ts +870 -0
- package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
- package/template/apps/asas-tanstack/src/links.ts +38 -0
- package/template/apps/asas-tanstack/src/locale-switch.tsx +26 -0
- package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
- package/template/apps/asas-tanstack/src/redirect.ts +20 -0
- package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
- package/template/apps/asas-tanstack/src/routeTree.gen.ts +382 -0
- package/template/apps/asas-tanstack/src/router.tsx +7 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +4 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +3883 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +158 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +4 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1800 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +92 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
- package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
- package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
- package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +101 -0
- package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
- package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
- package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +109 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +86 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +122 -0
- package/template/apps/asas-tanstack/src/routes/index.tsx +142 -0
- package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +122 -0
- package/template/apps/asas-tanstack/src/screen-form.tsx +34 -0
- package/template/apps/asas-tanstack/src/screen-list.tsx +139 -0
- package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
- package/template/apps/asas-tanstack/src/screens.tsx +868 -0
- package/template/apps/asas-tanstack/src/server.ts +16 -0
- package/template/apps/asas-tanstack/src/slide.ts +22 -0
- package/template/apps/asas-tanstack/src/start.ts +57 -0
- package/template/apps/asas-tanstack/src/styles.css +159 -0
- package/template/apps/asas-tanstack/src/theme.ts +5 -0
- package/template/apps/asas-tanstack/tsconfig.json +19 -0
- package/template/apps/asas-tanstack/vite.config.ts +28 -0
- package/template/package.json +16 -0
- package/template/templates/asas/.env.example +16 -0
- package/template/templates/asas/components.json +22 -0
- package/template/templates/asas/mithaq.hints.json +25 -0
- package/template/templates/asas/package.json +38 -0
- package/template/templates/asas/src/asas.ts +93 -0
- package/template/templates/asas/src/audit-log/audit-log.ts +187 -0
- package/template/templates/asas/src/audit-log/sink.ts +89 -0
- package/template/templates/asas/src/audit-log/ui/activity.tsx +243 -0
- package/template/templates/asas/src/courses/courses.ts +612 -0
- package/template/templates/asas/src/courses/ui/archive-button.tsx +25 -0
- package/template/templates/asas/src/courses/ui/fee-cell.tsx +19 -0
- package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +11 -0
- package/template/templates/asas/src/courses/ui/no-courses.tsx +13 -0
- package/template/templates/asas/src/courses/ui/on-offer.ts +6 -0
- package/template/templates/asas/src/courses/ui/status-cell.tsx +31 -0
- package/template/templates/asas/src/enrollments/enrollments.ts +137 -0
- package/template/templates/asas/src/enrollments/ui/session-cell.tsx +65 -0
- package/template/templates/asas/src/kayan/auth/user/account.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/account.ts +31 -0
- package/template/templates/asas/src/kayan/auth/user/index.ts +18 -0
- package/template/templates/asas/src/kayan/auth/user/invitation.access.ts +4 -0
- package/template/templates/asas/src/kayan/auth/user/invitation.ts +27 -0
- package/template/templates/asas/src/kayan/auth/user/member.access.ts +10 -0
- package/template/templates/asas/src/kayan/auth/user/member.screens.ts +37 -0
- package/template/templates/asas/src/kayan/auth/user/member.ts +25 -0
- package/template/templates/asas/src/kayan/auth/user/organization.access.ts +20 -0
- package/template/templates/asas/src/kayan/auth/user/organization.screens.ts +37 -0
- package/template/templates/asas/src/kayan/auth/user/organization.ts +24 -0
- package/template/templates/asas/src/kayan/auth/user/organizationRole.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/organizationRole.ts +25 -0
- package/template/templates/asas/src/kayan/auth/user/session.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/session.ts +28 -0
- package/template/templates/asas/src/kayan/auth/user/user.access.ts +29 -0
- package/template/templates/asas/src/kayan/auth/user/user.screens.ts +45 -0
- package/template/templates/asas/src/kayan/auth/user/user.ts +31 -0
- package/template/templates/asas/src/kayan/auth/user/verification.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/verification.ts +23 -0
- package/template/templates/asas/src/local-users/local-users.ts +143 -0
- package/template/templates/asas/src/media/media.ts +137 -0
- package/template/templates/asas/src/media/ui/preview.tsx +143 -0
- package/template/templates/asas/src/member-roles/member-roles.ts +207 -0
- package/template/templates/asas/src/messages/account.json +106 -0
- package/template/templates/asas/src/messages/auditLog.json +186 -0
- package/template/templates/asas/src/messages/courses.json +270 -0
- package/template/templates/asas/src/messages/enrollments.json +42 -0
- package/template/templates/asas/src/messages/invitation.json +66 -0
- package/template/templates/asas/src/messages/localUsers.json +42 -0
- package/template/templates/asas/src/messages/media.json +66 -0
- package/template/templates/asas/src/messages/member.json +42 -0
- package/template/templates/asas/src/messages/memberRoles.json +42 -0
- package/template/templates/asas/src/messages/mithaq.json +170 -0
- package/template/templates/asas/src/messages/organization.json +50 -0
- package/template/templates/asas/src/messages/organizationRole.json +50 -0
- package/template/templates/asas/src/messages/roles.json +58 -0
- package/template/templates/asas/src/messages/session.json +82 -0
- package/template/templates/asas/src/messages/sessions.json +106 -0
- package/template/templates/asas/src/messages/settings.json +66 -0
- package/template/templates/asas/src/messages/tags.json +38 -0
- package/template/templates/asas/src/messages/user.json +90 -0
- package/template/templates/asas/src/messages/verification.json +50 -0
- package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
- package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
- package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
- package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
- package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
- package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
- package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
- package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
- package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
- package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
- package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
- package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
- package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
- package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
- package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
- package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
- package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
- package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
- package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
- package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
- package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
- package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
- package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
- package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
- package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
- package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
- package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
- package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
- package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
- package/template/templates/asas/src/migrations/drizzle.config.ts +11 -0
- package/template/templates/asas/src/migrations/schema.ts +29 -0
- package/template/templates/asas/src/mithaq.ts +406 -0
- package/template/templates/asas/src/pages.ts +74 -0
- package/template/templates/asas/src/platform-http.ts +96 -0
- package/template/templates/asas/src/rich-text-nodes.ts +11 -0
- package/template/templates/asas/src/roles/grants.ts +47 -0
- package/template/templates/asas/src/roles/roles.ts +184 -0
- package/template/templates/asas/src/roles/ui/role-form.tsx +226 -0
- package/template/templates/asas/src/roles/ui/roles.tsx +166 -0
- package/template/templates/asas/src/roles.ts +16 -0
- package/template/templates/asas/src/seed.ts +325 -0
- package/template/templates/asas/src/sessions/sessions.ts +155 -0
- package/template/templates/asas/src/settings/settings.ts +69 -0
- package/template/templates/asas/src/statements.ts +11 -0
- package/template/templates/asas/src/steps/expected-updated-at.ts +41 -0
- package/template/templates/asas/src/storage/defaults.ts +156 -0
- package/template/templates/asas/src/storage/index.ts +95 -0
- package/template/templates/asas/src/storage/local.ts +143 -0
- package/template/templates/asas/src/storage/r2.ts +5 -0
- package/template/templates/asas/src/storage/s3.ts +91 -0
- package/template/templates/asas/src/storage/uploadthing.ts +59 -0
- package/template/templates/asas/src/tags/tags.ts +80 -0
- package/template/templates/asas/src/tenant-access.ts +221 -0
- package/template/templates/asas/src/tenant-roles.ts +262 -0
- package/template/templates/asas/src/ui/app-shell.tsx +260 -0
- package/template/templates/asas/src/ui/kpi-card.tsx +25 -0
- package/template/templates/asas/src/ui/record-page.tsx +50 -0
- package/template/templates/asas/src/ui/rich-text-nodes.tsx +15 -0
- package/template/templates/asas/src/ui/screen-nav.tsx +28 -0
- package/template/templates/asas/src/ui/session-labels.tsx +36 -0
- package/template/templates/asas/src/ui/table-page.tsx +9 -0
- package/template/templates/asas/src/ui/top-shell.tsx +60 -0
- package/template/templates/asas/src/ui-dashboard/dashboard.tsx +294 -0
- package/template/templates/asas/src/ui-dashboard/members.tsx +630 -0
- package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
- package/template/templates/asas/src/ui-dashboard/versions.tsx +432 -0
- package/template/templates/asas/src/ui-platform/home.tsx +63 -0
- package/template/templates/asas/src/ui-platform/organization-actions.tsx +114 -0
- package/template/templates/asas/src/ui-platform/record-page.tsx +30 -0
- package/template/templates/asas/src/ui-platform/role-cells.tsx +52 -0
- package/template/templates/asas/src/ui-platform/user-actions.tsx +251 -0
- package/template/templates/asas/src/ui-portal/home.tsx +77 -0
- package/template/templates/asas/src/ui-portal/page.tsx +11 -0
- package/template/templates/auth/package.json +14 -0
- package/template/templates/auth/src/index.ts +690 -0
- package/template/templates/auth/src/platform-roles.ts +87 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1467 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState, type FormEvent, type ReactElement, type ReactNode } from 'react'
|
|
2
|
+
import { useField, useForm, useStore, type AnyFormApi } from '@tanstack/react-form'
|
|
3
|
+
import { Check } from 'lucide-react'
|
|
4
|
+
import { compareDecimals, decimalDigits, isDecimal, type ClientField, type ClientKayan, type LeafClient, type Row } from 'mithaq/admin'
|
|
5
|
+
import { FormContext, ScreenContext } from 'mithaq/admin/react'
|
|
6
|
+
import {
|
|
7
|
+
AlertDialog,
|
|
8
|
+
AlertDialogAction,
|
|
9
|
+
AlertDialogCancel,
|
|
10
|
+
AlertDialogContent,
|
|
11
|
+
AlertDialogDescription,
|
|
12
|
+
AlertDialogFooter,
|
|
13
|
+
AlertDialogHeader,
|
|
14
|
+
AlertDialogTitle,
|
|
15
|
+
AlertDialogTrigger,
|
|
16
|
+
} from '@/components/ui/alert-dialog'
|
|
17
|
+
import { Badge } from '@/components/ui/badge'
|
|
18
|
+
import { Button } from '@/components/ui/button'
|
|
19
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
20
|
+
import { Spinner } from '@/components/ui/spinner'
|
|
21
|
+
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
|
22
|
+
import { cellFor } from './cells.tsx'
|
|
23
|
+
import { RichTextValueView } from './rich-text.tsx'
|
|
24
|
+
import { UploadValue } from './upload.tsx'
|
|
25
|
+
import { useRelationshipTitles } from './relationship-cell.tsx'
|
|
26
|
+
import {
|
|
27
|
+
besideLabel,
|
|
28
|
+
controlFor,
|
|
29
|
+
counted,
|
|
30
|
+
descriptionFor,
|
|
31
|
+
descriptionOf,
|
|
32
|
+
labelFor,
|
|
33
|
+
listed,
|
|
34
|
+
localesOf,
|
|
35
|
+
operationFieldLabelFor,
|
|
36
|
+
operationLabelFor,
|
|
37
|
+
placeholderFor,
|
|
38
|
+
readOnlyField,
|
|
39
|
+
refusalKeyOf,
|
|
40
|
+
seedOf,
|
|
41
|
+
titleOf,
|
|
42
|
+
valuesOf,
|
|
43
|
+
type Copy,
|
|
44
|
+
type FindApi,
|
|
45
|
+
type Translate,
|
|
46
|
+
} from './controls.tsx'
|
|
47
|
+
import { FieldShell, describedBy } from './field-shell.tsx'
|
|
48
|
+
import { BulkChoice, LeafControl, NavLinks, NodeButton, OwnButton, initialFocusIn, rowWarning, drawNodes, drawsKind, frameOf, leavesOf, nodesOf, pageButtonOf, pageOf, refOf, scopeOf, type Draw, type Node, type PageButton, type ScreenPage } from './nodes.tsx'
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The three operations this screen drives, typed structurally so a typed oRPC client, an in-process
|
|
52
|
+
* router client or a plain object fits.
|
|
53
|
+
*/
|
|
54
|
+
export type FormClient = {
|
|
55
|
+
create: (input: Record<string, unknown>, options?: DraftOptions) => Promise<unknown>
|
|
56
|
+
update: (input: Record<string, unknown>, options?: DraftOptions) => Promise<unknown>
|
|
57
|
+
delete: (input: Record<string, unknown>) => Promise<unknown>
|
|
58
|
+
/** A kayan's own operation, by name, for a page button that asks for one. A host that hands none draws no such button. */
|
|
59
|
+
run?: (operation: string, input: Record<string, unknown>) => Promise<unknown>
|
|
60
|
+
/** The plain read of one row; on a kayan that keeps drafts, the published copy. A host that hands none draws no draft switch. */
|
|
61
|
+
findByID?: (input: Record<string, unknown>) => Promise<unknown>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What a write asks beside its input on a kayan that keeps drafts: a draft save, and an autosave beside it. */
|
|
65
|
+
export type DraftOptions = { readonly draft?: boolean; readonly autosave?: boolean }
|
|
66
|
+
|
|
67
|
+
/** Where a row of a kayan that keeps drafts stands: never published, published, or published with a newer draft pending. */
|
|
68
|
+
export type DraftStatus = 'draft' | 'published' | 'changed'
|
|
69
|
+
|
|
70
|
+
const afterDraft = (status?: DraftStatus): DraftStatus => (status === 'published' || status === 'changed' ? 'changed' : 'draft')
|
|
71
|
+
|
|
72
|
+
const pills: Record<DraftStatus, string> = {
|
|
73
|
+
draft: 'bg-muted text-muted-foreground',
|
|
74
|
+
published: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
|
75
|
+
changed: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const publishColour = 'bg-emerald-700 text-white hover:bg-emerald-700/90 focus-visible:border-emerald-600 focus-visible:ring-emerald-600/40 dark:bg-emerald-500 dark:text-emerald-950 dark:hover:bg-emerald-500/90 dark:focus-visible:ring-emerald-500/40'
|
|
79
|
+
const draftColour = 'border-transparent bg-amber-500 text-amber-950 hover:bg-amber-500/90 hover:text-amber-950 focus-visible:border-amber-600 focus-visible:ring-amber-500/50 dark:bg-amber-400 dark:hover:bg-amber-400/90'
|
|
80
|
+
|
|
81
|
+
export type FormProps = {
|
|
82
|
+
kayan: ClientKayan
|
|
83
|
+
client: FormClient
|
|
84
|
+
/** The row being edited, or nothing for a row being written. */
|
|
85
|
+
value?: Record<string, unknown> | null
|
|
86
|
+
t: Translate
|
|
87
|
+
locale: string
|
|
88
|
+
/** Which locales exist, from the host: a localized field draws one box for each. */
|
|
89
|
+
locales?: readonly string[]
|
|
90
|
+
/**
|
|
91
|
+
* What the host does once a write lands. The second argument is the effect that ran (`create`,
|
|
92
|
+
* `update` or `delete`), which decides where the host lands.
|
|
93
|
+
*/
|
|
94
|
+
onDone: (row: unknown, effect?: string) => void
|
|
95
|
+
/** Where Cancel goes. A host that hands none gets no Cancel button. */
|
|
96
|
+
onCancel?: () => void
|
|
97
|
+
/** A refusal the host already caught, for a host that writes on its own path too. */
|
|
98
|
+
error?: unknown
|
|
99
|
+
/** Every kayan of the build, so a relationship control can find its target. */
|
|
100
|
+
kayans?: readonly ClientKayan[]
|
|
101
|
+
/** The typed client's reads, for a control that pages a second kayan. */
|
|
102
|
+
api?: FindApi
|
|
103
|
+
/**
|
|
104
|
+
* Whether this actor may write one field on the operation being rendered. Chrome only: the server
|
|
105
|
+
* authorizes the write whatever this says. Undeclared is allowed.
|
|
106
|
+
*/
|
|
107
|
+
writes?: (field: string, on: 'create' | 'update') => boolean
|
|
108
|
+
/**
|
|
109
|
+
* This actor's answer per operation name. A button for a kayan's own operation reads its own name
|
|
110
|
+
* here, never its effect's. Chrome only; an absent key is denied.
|
|
111
|
+
*/
|
|
112
|
+
operations?: Readonly<Record<string, boolean>>
|
|
113
|
+
/**
|
|
114
|
+
* Whether this form holds a change nobody saved, reported each time the answer changes, so the
|
|
115
|
+
* host's router can block a navigation away.
|
|
116
|
+
*/
|
|
117
|
+
onDirty?: (dirty: boolean) => void
|
|
118
|
+
/**
|
|
119
|
+
* A navigation the host's router held back while this form reported a change. The form asks the
|
|
120
|
+
* discard question itself so the autosave is held while it asks: `proceed` lets the navigation go,
|
|
121
|
+
* `reset` keeps the person here.
|
|
122
|
+
*/
|
|
123
|
+
blocked?: { proceed: () => void; reset: () => void }
|
|
124
|
+
/** A screen's page, where this form draws one: its regions go to the page's layout and nothing the page does not name is drawn. */
|
|
125
|
+
screen?: ScreenPage
|
|
126
|
+
/** What the host draws a list node on this page with. A host that hands none draws no list. */
|
|
127
|
+
related?: Draw['list']
|
|
128
|
+
/** The nodes this form draws in place of a page, as one form node and no shell. They win over `screen`'s page. */
|
|
129
|
+
nodes?: readonly Node[]
|
|
130
|
+
/**
|
|
131
|
+
* The kayan keeps drafts and this actor holds its `findVersions`: the form offers Save draft and
|
|
132
|
+
* Publish changes in place of Save, and autosaves a draft after `autosave` ms of stillness.
|
|
133
|
+
* A host that hands none keeps the plain Save.
|
|
134
|
+
*/
|
|
135
|
+
drafts?: { status?: DraftStatus; autosave?: number }
|
|
136
|
+
/**
|
|
137
|
+
* The kayan's update takes `expectedUpdatedAt`: an update sends the `updatedAt` this form last read
|
|
138
|
+
* or wrote, so a save over a newer one from another tab is refused 409. On a kayan that keeps
|
|
139
|
+
* drafts it is the latest version's, and an autosave sends it too.
|
|
140
|
+
*/
|
|
141
|
+
expectedUpdatedAt?: boolean
|
|
142
|
+
/**
|
|
143
|
+
* The kayan's other operations that take `expectedUpdatedAt`, of `delete` and `restoreVersion`, so
|
|
144
|
+
* a newer draft another person saved is kept.
|
|
145
|
+
*/
|
|
146
|
+
expectedUpdatedAtOn?: readonly string[]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Which fields a schema refusal names, and what it says about each, keyed by the first segment of
|
|
151
|
+
* the issue's path (the field the form drew a control for).
|
|
152
|
+
*/
|
|
153
|
+
export const issuesOf = (error: unknown): Readonly<Record<string, string>> => {
|
|
154
|
+
const issues = (error as { data?: { params?: { issues?: unknown } } } | null | undefined)?.data?.params?.issues
|
|
155
|
+
// No prototype, so a field named `toString` has no issue it was never given.
|
|
156
|
+
const byField: Record<string, string> = Object.create(null)
|
|
157
|
+
if (!Array.isArray(issues)) return byField
|
|
158
|
+
for (const issue of issues as readonly { path?: unknown; message?: unknown }[]) {
|
|
159
|
+
const path = typeof issue?.path === 'string' ? issue.path.split('.')[0] : ''
|
|
160
|
+
if (!path || typeof issue.message !== 'string' || byField[path] !== undefined) continue
|
|
161
|
+
byField[path] = issue.message
|
|
162
|
+
}
|
|
163
|
+
return byField
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** An email address, by the pattern the server's `email` kind runs, copied so the browser loads no zod. */
|
|
167
|
+
const emailShape = /^(?:[A-Za-z0-9_'+\-]+\.)*[A-Za-z0-9_'+\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$/
|
|
168
|
+
|
|
169
|
+
/** Whether a value is nothing a person wrote. An empty list is a value, so a required list may be saved empty; `minRows` asks for rows. */
|
|
170
|
+
const empty = (value: unknown) => value === undefined || value === null || value === ''
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The first rule of the projection this value breaks, as the last segment of the server's copy key,
|
|
174
|
+
* in the server's order, or nothing. An empty box passes every rule but `required`; a value outside
|
|
175
|
+
* `options` is `invalid`.
|
|
176
|
+
*/
|
|
177
|
+
const brokenRule = (field: ClientField, held: unknown): string | undefined => {
|
|
178
|
+
const { rules } = field
|
|
179
|
+
if (rules.localized === true && held && typeof held === 'object') {
|
|
180
|
+
return Object.values(held).map((one) => brokenRule({ ...field, rules: { ...rules, localized: false } }, one)).find(Boolean)
|
|
181
|
+
}
|
|
182
|
+
// The server trims and cases before it judges, so judge the rewritten value.
|
|
183
|
+
const text = typeof held === 'string' && rules.trim === true ? held.trim() : held
|
|
184
|
+
const value = typeof text !== 'string' ? text : rules.uppercase === true ? text.toUpperCase() : rules.lowercase === true ? text.toLowerCase() : text
|
|
185
|
+
if (Array.isArray(value) && (value.length || rules.required === true)) {
|
|
186
|
+
if (typeof rules.minRows === 'number' && value.length < rules.minRows) return 'minRows'
|
|
187
|
+
if (typeof rules.maxRows === 'number' && value.length > rules.maxRows) return 'maxRows'
|
|
188
|
+
}
|
|
189
|
+
if (empty(value)) return rules.required === true ? 'required' : undefined
|
|
190
|
+
if (field.kind === 'integer' && !(Number.isInteger(value) && (value as number) >= -2147483648 && (value as number) <= 2147483647)) return 'invalid'
|
|
191
|
+
if (field.kind === 'decimal') {
|
|
192
|
+
if (!isDecimal(value)) return 'invalid'
|
|
193
|
+
const { whole, fraction } = decimalDigits(value)
|
|
194
|
+
if (typeof rules.precision === 'number' && (whole + fraction > rules.precision || whole > rules.precision - Number(rules.scale))) return 'precision'
|
|
195
|
+
if (typeof rules.scale === 'number' && fraction > rules.scale) return 'scale'
|
|
196
|
+
if (typeof rules.min === 'string' && compareDecimals(value, rules.min) < 0) return 'min'
|
|
197
|
+
if (typeof rules.max === 'string' && compareDecimals(value, rules.max) > 0) return 'max'
|
|
198
|
+
return undefined
|
|
199
|
+
}
|
|
200
|
+
if (field.kind === 'email' && typeof value === 'string' && !emailShape.test(value)) return 'invalid'
|
|
201
|
+
const size = typeof value === 'string' || Array.isArray(value) ? value.length : undefined
|
|
202
|
+
if (size !== undefined && typeof rules.minLength === 'number' && size < rules.minLength) return 'minLength'
|
|
203
|
+
if (size !== undefined && typeof rules.maxLength === 'number' && size > rules.maxLength) return 'maxLength'
|
|
204
|
+
if (typeof value === 'string' && rules.pattern instanceof RegExp && !new RegExp(rules.pattern).test(value)) return 'pattern'
|
|
205
|
+
const { min, max } = rules
|
|
206
|
+
if (typeof min === 'number' ? typeof value === 'number' && value < min : typeof min === 'string' && String(value) < min) return 'min'
|
|
207
|
+
if (typeof max === 'number' ? typeof value === 'number' && value > max : typeof max === 'string' && String(value) > max) return 'max'
|
|
208
|
+
if (field.options && [value].flat().some((one) => !field.options!.some((option) => option.value === one))) return 'invalid'
|
|
209
|
+
return undefined
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The refusal the server would send for these values, in its wire shape, or nothing when every rule
|
|
214
|
+
* holds. The rules are checked here because the derived zod shape does not cross `structuredClone`
|
|
215
|
+
* into the browser; the server stays the authority.
|
|
216
|
+
*/
|
|
217
|
+
export const refusalOf = ({
|
|
218
|
+
kayan,
|
|
219
|
+
fields,
|
|
220
|
+
values,
|
|
221
|
+
}: {
|
|
222
|
+
kayan: string
|
|
223
|
+
fields: readonly ClientField[]
|
|
224
|
+
values: Readonly<Record<string, unknown>>
|
|
225
|
+
}) => {
|
|
226
|
+
const issues = fields.flatMap((field) => {
|
|
227
|
+
const rule = brokenRule(field, values[field.name])
|
|
228
|
+
return rule ? [{ path: field.name, message: `${kayan}.${field.name}.${rule}` }] : []
|
|
229
|
+
})
|
|
230
|
+
return issues.length ? { data: { key: 'error.invalid.value', params: { issues } } } : undefined
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The sentence over a refused save: a schema refusal that names fields lists them by label; any other is its own key's sentence, or `fallback`. */
|
|
234
|
+
export const refusalWords = ({ shown, labels, fallback, copy: { t, locale } }: { shown: string; labels: readonly string[]; fallback?: string; copy: Copy }): string =>
|
|
235
|
+
shown === 'error.invalid.value' && labels.length
|
|
236
|
+
? counted({ t, locale, key: 'error.invalid.fields', count: labels.length, fallback: 'The following fields are invalid: {fields}' }).replace('{fields}', () => listed(labels, locale))
|
|
237
|
+
: (t(shown, fallback ?? shown) ?? shown)
|
|
238
|
+
|
|
239
|
+
const KayanForm = createContext<
|
|
240
|
+
| {
|
|
241
|
+
form: AnyFormApi
|
|
242
|
+
issues: Readonly<Record<string, string>>
|
|
243
|
+
locales: Readonly<Record<string, readonly string[]>>
|
|
244
|
+
writes?: (field: string, on: 'create' | 'update') => boolean
|
|
245
|
+
on: 'create' | 'update'
|
|
246
|
+
check: (name: string, value: unknown) => string | undefined
|
|
247
|
+
forget: (name: string) => void
|
|
248
|
+
}
|
|
249
|
+
| undefined
|
|
250
|
+
>(undefined)
|
|
251
|
+
|
|
252
|
+
export const useValues = <Values extends object = Record<string, unknown>>(): Values => {
|
|
253
|
+
const held = useContext(KayanForm)
|
|
254
|
+
if (!held) throw new Error('useValues is called inside the form this registry draws, and nowhere else')
|
|
255
|
+
return useStore(held.form.store, (state) => state.values as Values)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
type KayanField = {
|
|
259
|
+
value: unknown
|
|
260
|
+
onChange: (value: unknown) => void
|
|
261
|
+
onBlur: () => void
|
|
262
|
+
/** Whether this field is the client's to write on this operation, off the access report. */
|
|
263
|
+
writable: boolean
|
|
264
|
+
/** Whether a person has left this box, or the server has refused it. */
|
|
265
|
+
touched: boolean
|
|
266
|
+
/** This field's refusal, in the words a person reads, or nothing. */
|
|
267
|
+
error?: string
|
|
268
|
+
/** The locales of a localized field the server's refusal named, whose boxes alone are marked. */
|
|
269
|
+
locales?: readonly string[]
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* One field's value, change, blur, write access and error, read from the form this field is inside.
|
|
273
|
+
* The error shows once the box is left or the server has refused it, and falls back to the form's
|
|
274
|
+
* refusal record because a remount or a server render has no error map.
|
|
275
|
+
*/
|
|
276
|
+
export function useKayanField<Values extends object, Name extends keyof Values & string>(name: Name): Omit<KayanField, 'value'> & { value: Values[Name] }
|
|
277
|
+
export function useKayanField(name: string): KayanField
|
|
278
|
+
export function useKayanField(name: string): KayanField {
|
|
279
|
+
const held = useContext(KayanForm)
|
|
280
|
+
if (!held) throw new Error('useKayanField is called inside the form this registry draws, and nowhere else')
|
|
281
|
+
const { check, forget, form, issues, locales, on, writes } = held
|
|
282
|
+
const field = useField({
|
|
283
|
+
form,
|
|
284
|
+
name,
|
|
285
|
+
// Validated on blur, then on every change; a change also drops the refusal a save left on this box.
|
|
286
|
+
validators: { onBlur: ({ value }) => check(name, value) },
|
|
287
|
+
listeners: {
|
|
288
|
+
onChange: ({ fieldApi }) => {
|
|
289
|
+
forget(name)
|
|
290
|
+
if (fieldApi.state.meta.isBlurred) fieldApi.validate('blur')
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
})
|
|
294
|
+
const meta = field.state.meta
|
|
295
|
+
const refused = (meta.errorMap.onServer as string | undefined) ?? issues[name]
|
|
296
|
+
const touched = meta.isTouched || refused !== undefined
|
|
297
|
+
const invalid = !meta.isValid || refused !== undefined
|
|
298
|
+
const first = meta.errors.find((one) => one !== undefined && one !== null)
|
|
299
|
+
return {
|
|
300
|
+
value: field.state.value,
|
|
301
|
+
onChange: (next: unknown) => field.handleChange(next),
|
|
302
|
+
onBlur: field.handleBlur,
|
|
303
|
+
writable: writes ? writes(name, on) : true,
|
|
304
|
+
touched,
|
|
305
|
+
error: touched && invalid ? (refused ?? (typeof first === 'string' ? first : undefined)) : undefined,
|
|
306
|
+
locales: locales[name],
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The delete, in two clicks. The dialog stays open until the delete lands, so a refusal is said inside it.
|
|
312
|
+
* With `open` handed in, the dialog is controlled and draws no trigger, so a menu can open it.
|
|
313
|
+
*/
|
|
314
|
+
export function DeleteButton({
|
|
315
|
+
busy,
|
|
316
|
+
copy,
|
|
317
|
+
kayan,
|
|
318
|
+
label,
|
|
319
|
+
onDelete,
|
|
320
|
+
onOpenChange,
|
|
321
|
+
open: given,
|
|
322
|
+
row,
|
|
323
|
+
}: {
|
|
324
|
+
busy: boolean
|
|
325
|
+
copy: Copy
|
|
326
|
+
kayan: ClientKayan
|
|
327
|
+
label: string
|
|
328
|
+
/** Runs the delete, handing a refusal to `refuse`, and answers whether it landed, which is what the dialog closes on. */
|
|
329
|
+
onDelete: (refuse: (thrown: unknown) => void) => Promise<boolean>
|
|
330
|
+
open?: boolean
|
|
331
|
+
onOpenChange?: (open: boolean) => void
|
|
332
|
+
/** The row it deletes, which the dialog names. */
|
|
333
|
+
row?: Row
|
|
334
|
+
}): ReactElement {
|
|
335
|
+
const { t } = copy
|
|
336
|
+
const [held, setHeld] = useState(false)
|
|
337
|
+
const [refused, setRefused] = useState<unknown>(undefined)
|
|
338
|
+
const setOpen = (next: boolean) => {
|
|
339
|
+
if (!next) setRefused(undefined)
|
|
340
|
+
;(onOpenChange ?? setHeld)(next)
|
|
341
|
+
}
|
|
342
|
+
return (
|
|
343
|
+
<AlertDialog open={given ?? held} onOpenChange={setOpen}>
|
|
344
|
+
{given === undefined ? (
|
|
345
|
+
<AlertDialogTrigger
|
|
346
|
+
render={
|
|
347
|
+
<Button type="button" variant="destructive" className="max-sm:h-11" data-button="delete" disabled={busy}>
|
|
348
|
+
{label}
|
|
349
|
+
</Button>
|
|
350
|
+
}
|
|
351
|
+
/>
|
|
352
|
+
) : null}
|
|
353
|
+
<AlertDialogContent>
|
|
354
|
+
<AlertDialogHeader>
|
|
355
|
+
<AlertDialogTitle>{label}</AlertDialogTitle>
|
|
356
|
+
<AlertDialogDescription>
|
|
357
|
+
{[row ? rowWarning({ kayan, row, operation: 'delete', copy }) : undefined, t('admin.deleteWarning', 'This cannot be undone.') ?? 'This cannot be undone.'].filter(Boolean).join(' ')}
|
|
358
|
+
</AlertDialogDescription>
|
|
359
|
+
</AlertDialogHeader>
|
|
360
|
+
{refused === undefined ? null : (
|
|
361
|
+
<p role="alert" className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive">
|
|
362
|
+
{deleteRefusal({ refused, t })}
|
|
363
|
+
</p>
|
|
364
|
+
)}
|
|
365
|
+
<AlertDialogFooter className="max-sm:[&_button]:h-11">
|
|
366
|
+
<AlertDialogCancel>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</AlertDialogCancel>
|
|
367
|
+
<AlertDialogAction
|
|
368
|
+
variant="destructive"
|
|
369
|
+
disabled={busy}
|
|
370
|
+
onClick={async () => {
|
|
371
|
+
setRefused(undefined)
|
|
372
|
+
if (await onDelete(setRefused)) setOpen(false)
|
|
373
|
+
}}
|
|
374
|
+
>
|
|
375
|
+
{t(`${kayan.name}.delete.confirm`) ?? t('admin.deleteConfirm', 'Delete for good') ?? 'Delete for good'}
|
|
376
|
+
</AlertDialogAction>
|
|
377
|
+
</AlertDialogFooter>
|
|
378
|
+
</AlertDialogContent>
|
|
379
|
+
</AlertDialog>
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const deleteRefusal = ({ refused, t }: { refused: unknown; t: Translate }): string => {
|
|
384
|
+
const key = refusalKeyOf(refused)
|
|
385
|
+
if (!key) return t('admin.deleteFailed', 'Something went wrong, so nothing was deleted. Try again.') ?? 'Something went wrong, so nothing was deleted. Try again.'
|
|
386
|
+
const by = (refused as { data?: { params?: { by?: unknown } } }).data?.params?.by
|
|
387
|
+
if (key === 'error.conflict.referenced' && typeof by === 'string') {
|
|
388
|
+
const kind = t(`${by}.plural`, by) ?? by
|
|
389
|
+
return (t('error.conflict.referencedBy', '{kind} still use this, so it is not deleted.') ?? '{kind} still use this, so it is not deleted.').replace('{kind}', () => kind)
|
|
390
|
+
}
|
|
391
|
+
return t(key, key) ?? key
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The question asked before an unsaved edit is thrown away, used by the form's Cancel and by a
|
|
396
|
+
* navigation the host's router blocked. It stays mounted with the form, so an autosave landing while
|
|
397
|
+
* it asks cannot unmount it.
|
|
398
|
+
*/
|
|
399
|
+
export function DiscardDialog({
|
|
400
|
+
busy,
|
|
401
|
+
note,
|
|
402
|
+
onDiscard,
|
|
403
|
+
onOpenChange,
|
|
404
|
+
open,
|
|
405
|
+
t,
|
|
406
|
+
}: {
|
|
407
|
+
open: boolean
|
|
408
|
+
onOpenChange?: (open: boolean) => void
|
|
409
|
+
/** What discarding means, which is leaving: the Cancel's navigation, or the one the router blocked. */
|
|
410
|
+
onDiscard: () => void
|
|
411
|
+
/** A sentence after the question, such as that a draft already saved is kept. */
|
|
412
|
+
note?: string
|
|
413
|
+
busy?: boolean
|
|
414
|
+
t: Translate
|
|
415
|
+
}): ReactElement {
|
|
416
|
+
return (
|
|
417
|
+
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
418
|
+
<AlertDialogContent>
|
|
419
|
+
<AlertDialogHeader>
|
|
420
|
+
<AlertDialogTitle>{t('admin.discard', 'Discard changes') ?? 'Discard changes'}</AlertDialogTitle>
|
|
421
|
+
<AlertDialogDescription>
|
|
422
|
+
{[t('admin.discardWarning', 'You have unsaved changes. Leave without saving?') ?? 'You have unsaved changes. Leave without saving?', note].filter(Boolean).join(' ')}
|
|
423
|
+
</AlertDialogDescription>
|
|
424
|
+
</AlertDialogHeader>
|
|
425
|
+
<AlertDialogFooter className="max-sm:[&_button]:h-11">
|
|
426
|
+
<AlertDialogCancel>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</AlertDialogCancel>
|
|
427
|
+
<AlertDialogAction variant="destructive" disabled={busy} onClick={onDiscard}>
|
|
428
|
+
{t('admin.discard', 'Discard changes') ?? 'Discard changes'}
|
|
429
|
+
</AlertDialogAction>
|
|
430
|
+
</AlertDialogFooter>
|
|
431
|
+
</AlertDialogContent>
|
|
432
|
+
</AlertDialog>
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* One of a kayan's own operations, as a button and the dialog behind it. The dialog is controlled so a
|
|
438
|
+
* refusal keeps it open over the boxes it names; a list's own buttons open this same dialog.
|
|
439
|
+
*/
|
|
440
|
+
export function OperationButton({
|
|
441
|
+
api,
|
|
442
|
+
busy,
|
|
443
|
+
copy,
|
|
444
|
+
description,
|
|
445
|
+
inputOf,
|
|
446
|
+
kayan,
|
|
447
|
+
kayans,
|
|
448
|
+
operation,
|
|
449
|
+
run,
|
|
450
|
+
...controlled
|
|
451
|
+
}: {
|
|
452
|
+
api?: FindApi
|
|
453
|
+
busy: boolean
|
|
454
|
+
copy: Copy
|
|
455
|
+
/** Handed in by a caller that opens the dialog from elsewhere, such as a list row's menu, in which case no trigger is drawn. */
|
|
456
|
+
open?: boolean
|
|
457
|
+
onOpenChange?: (open: boolean) => void
|
|
458
|
+
/** What the dialog says it runs on, the row being edited unless the caller says otherwise. */
|
|
459
|
+
description?: string
|
|
460
|
+
/** The call's input from what the boxes hold: the row's id beside them on a form or list row, the ticked rows' `where` around them on a bulk. */
|
|
461
|
+
inputOf: (values: Record<string, unknown>) => Record<string, unknown>
|
|
462
|
+
kayan: ClientKayan
|
|
463
|
+
kayans?: readonly ClientKayan[]
|
|
464
|
+
operation: PageButton
|
|
465
|
+
/** The form's own writer, which lands the call and hands a refusal back here. */
|
|
466
|
+
run: (input: Record<string, unknown>, refuse: (thrown: unknown) => void) => Promise<boolean>
|
|
467
|
+
}): ReactElement {
|
|
468
|
+
const { t } = copy
|
|
469
|
+
const [held, setHeld] = useState(false)
|
|
470
|
+
const open = controlled.open ?? held
|
|
471
|
+
const setOpen = (next: boolean) => (controlled.onOpenChange ?? setHeld)(next)
|
|
472
|
+
const [values, setValues] = useState<Record<string, unknown>>({})
|
|
473
|
+
const [refused, setRefused] = useState<unknown>(undefined)
|
|
474
|
+
const fields = operation.fields ?? []
|
|
475
|
+
const setValue = (name: string, next: unknown) => {
|
|
476
|
+
if (!busy && fields.some((field) => field.name === name)) setValues((already) => ({ ...already, [name]: next }))
|
|
477
|
+
}
|
|
478
|
+
// Closing drops the refusal and the typed values, so the next opening starts empty.
|
|
479
|
+
const close = () => {
|
|
480
|
+
setOpen(false)
|
|
481
|
+
setRefused(undefined)
|
|
482
|
+
setValues({})
|
|
483
|
+
}
|
|
484
|
+
const label = operationLabelFor({ kayan, operation, copy })
|
|
485
|
+
const shown = refusalKeyOf(refused)
|
|
486
|
+
const issues = issuesOf(refused)
|
|
487
|
+
// A bulk update whose boxes are row data: a box left empty leaves each row's value as it is, and
|
|
488
|
+
// with every box empty there is nothing to write, which the server refuses too.
|
|
489
|
+
const writesData = operation.updatesMany === true && fields.length > 0
|
|
490
|
+
const popup = useRef<HTMLDivElement>(null)
|
|
491
|
+
|
|
492
|
+
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
|
493
|
+
event.preventDefault()
|
|
494
|
+
// React events from a portaled dialog still reach the screen's form through the React tree;
|
|
495
|
+
// without this one click would run the operation and save the row.
|
|
496
|
+
event.stopPropagation()
|
|
497
|
+
if (busy) return
|
|
498
|
+
// An untouched box is left out rather than sent as `undefined`, so a schema default for it still applies.
|
|
499
|
+
const typed: Record<string, unknown> = {}
|
|
500
|
+
for (const field of fields) {
|
|
501
|
+
if (values[field.name] !== undefined) typed[field.name] = values[field.name]
|
|
502
|
+
}
|
|
503
|
+
if (writesData && !Object.keys(typed).length) return setRefused({ data: { key: 'admin.bulkNothingFilled' }, fallback: 'Fill in at least one field.' })
|
|
504
|
+
if (await run(inputOf(typed), setRefused)) close()
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return (
|
|
508
|
+
<AlertDialog
|
|
509
|
+
open={open}
|
|
510
|
+
onOpenChange={(next: boolean) => (next ? setOpen(true) : close())}
|
|
511
|
+
>
|
|
512
|
+
{controlled.open === undefined ? (
|
|
513
|
+
<AlertDialogTrigger
|
|
514
|
+
render={
|
|
515
|
+
<Button type="button" variant="outline" className="max-sm:h-11" disabled={busy}>
|
|
516
|
+
{label}
|
|
517
|
+
</Button>
|
|
518
|
+
}
|
|
519
|
+
/>
|
|
520
|
+
) : null}
|
|
521
|
+
{/* On a phone the dialog takes focus, not its first box, so the keyboard stays down. */}
|
|
522
|
+
<AlertDialogContent ref={popup} initialFocus={initialFocusIn(popup)}>
|
|
523
|
+
<AlertDialogHeader>
|
|
524
|
+
<AlertDialogTitle>{operation.confirm?.title[copy.locale] ?? label}</AlertDialogTitle>
|
|
525
|
+
<AlertDialogDescription>
|
|
526
|
+
{[description, operation.confirm?.body[copy.locale], writesData && t('admin.bulkFilledOnly', 'Only the fields you fill in change.')].filter(Boolean).join(' ')
|
|
527
|
+
|| (t('admin.operationWarning', 'This runs on the row you are editing.') ?? 'This runs on the row you are editing.')}
|
|
528
|
+
</AlertDialogDescription>
|
|
529
|
+
</AlertDialogHeader>
|
|
530
|
+
{shown ? (
|
|
531
|
+
<p role="alert" className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive">
|
|
532
|
+
{refusalWords({ shown, fallback: (refused as { fallback?: string }).fallback, labels: fields.filter((field) => issues[field.name] !== undefined).map((field) => operationFieldLabelFor({ kayan, operation, field, copy })), copy })}
|
|
533
|
+
</p>
|
|
534
|
+
) : null}
|
|
535
|
+
{/* A form and not a div, so Enter in a one-box dialog submits. */}
|
|
536
|
+
<form className="grid gap-4" noValidate onSubmit={(event) => void submit(event)}>
|
|
537
|
+
{fields.map((field) => {
|
|
538
|
+
const Control = controlFor(field)
|
|
539
|
+
const inputId = `${kayan.name}-${operation.name}-${field.name}`
|
|
540
|
+
const issue = issues[field.name]
|
|
541
|
+
const failed = issue === undefined ? undefined : (t(issue, issue) ?? issue)
|
|
542
|
+
const choice = writesData && besideLabel(Control)
|
|
543
|
+
return (
|
|
544
|
+
<FieldShell
|
|
545
|
+
key={field.name}
|
|
546
|
+
id={inputId}
|
|
547
|
+
label={operationFieldLabelFor({ kayan, operation, field, copy })}
|
|
548
|
+
error={failed}
|
|
549
|
+
beside={!choice && besideLabel(Control)}
|
|
550
|
+
width={field.width}
|
|
551
|
+
>
|
|
552
|
+
{choice ? (
|
|
553
|
+
<BulkChoice
|
|
554
|
+
copy={copy}
|
|
555
|
+
describedBy={describedBy({ id: inputId, description: undefined, error: failed })}
|
|
556
|
+
error={failed}
|
|
557
|
+
field={field}
|
|
558
|
+
id={inputId}
|
|
559
|
+
kayan={kayan}
|
|
560
|
+
onChange={(next) => setValue(field.name, next)}
|
|
561
|
+
readOnly={busy}
|
|
562
|
+
value={values[field.name]}
|
|
563
|
+
/>
|
|
564
|
+
) : <Control
|
|
565
|
+
field={field}
|
|
566
|
+
kayan={kayan}
|
|
567
|
+
client={kayans}
|
|
568
|
+
api={api}
|
|
569
|
+
t={t}
|
|
570
|
+
id={inputId}
|
|
571
|
+
value={values[field.name]}
|
|
572
|
+
values={values}
|
|
573
|
+
setValue={setValue}
|
|
574
|
+
onChange={(next) => setValue(field.name, next)}
|
|
575
|
+
readOnly={busy}
|
|
576
|
+
error={failed}
|
|
577
|
+
describedBy={describedBy({ id: inputId, description: undefined, error: failed })}
|
|
578
|
+
/>}
|
|
579
|
+
</FieldShell>
|
|
580
|
+
)
|
|
581
|
+
})}
|
|
582
|
+
<AlertDialogFooter className="max-sm:[&_button]:h-11">
|
|
583
|
+
<AlertDialogCancel>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</AlertDialogCancel>
|
|
584
|
+
<AlertDialogAction type="submit" disabled={busy}>
|
|
585
|
+
{label}
|
|
586
|
+
</AlertDialogAction>
|
|
587
|
+
</AlertDialogFooter>
|
|
588
|
+
</form>
|
|
589
|
+
</AlertDialogContent>
|
|
590
|
+
</AlertDialog>
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
function BoundField({
|
|
596
|
+
api,
|
|
597
|
+
copy,
|
|
598
|
+
field,
|
|
599
|
+
form,
|
|
600
|
+
kayan,
|
|
601
|
+
kayans,
|
|
602
|
+
leaf,
|
|
603
|
+
readOnly,
|
|
604
|
+
screen,
|
|
605
|
+
values,
|
|
606
|
+
setValue,
|
|
607
|
+
}: {
|
|
608
|
+
api?: FindApi
|
|
609
|
+
copy: Copy
|
|
610
|
+
field: ClientField
|
|
611
|
+
/** The id of the form this field is saved by, wherever the page drew it. */
|
|
612
|
+
form: string
|
|
613
|
+
kayan: ClientKayan
|
|
614
|
+
kayans?: readonly ClientKayan[]
|
|
615
|
+
/** What this page alone draws the field with, on a screen's page. */
|
|
616
|
+
leaf?: LeafClient
|
|
617
|
+
/** Whether the control takes no edit, which is the form's own answer and never this field's to decide. */
|
|
618
|
+
readOnly: boolean
|
|
619
|
+
screen?: ScreenPage
|
|
620
|
+
values: Readonly<Record<string, unknown>>
|
|
621
|
+
setValue: (name: string, value: unknown) => void
|
|
622
|
+
}): ReactElement {
|
|
623
|
+
const bound = useKayanField(field.name)
|
|
624
|
+
return (
|
|
625
|
+
<LeafControl
|
|
626
|
+
field={field}
|
|
627
|
+
form={form}
|
|
628
|
+
kayan={kayan}
|
|
629
|
+
kayans={kayans}
|
|
630
|
+
leaf={leaf}
|
|
631
|
+
screen={screen}
|
|
632
|
+
copy={copy}
|
|
633
|
+
api={api}
|
|
634
|
+
value={bound.value}
|
|
635
|
+
values={values}
|
|
636
|
+
setValue={setValue}
|
|
637
|
+
onChange={bound.onChange}
|
|
638
|
+
onBlur={bound.onBlur}
|
|
639
|
+
readOnly={readOnly}
|
|
640
|
+
error={bound.error}
|
|
641
|
+
invalidLocales={bound.locales}
|
|
642
|
+
/>
|
|
643
|
+
)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* One field of a row that is read and never edited: its label over the cell a list column draws it
|
|
648
|
+
* with. A field the report hides is not in the projection, so nothing is drawn for it.
|
|
649
|
+
*/
|
|
650
|
+
export function FieldValue({
|
|
651
|
+
api,
|
|
652
|
+
copy,
|
|
653
|
+
field,
|
|
654
|
+
kayan,
|
|
655
|
+
kayans,
|
|
656
|
+
leaf,
|
|
657
|
+
row,
|
|
658
|
+
screen,
|
|
659
|
+
}: {
|
|
660
|
+
api?: FindApi
|
|
661
|
+
copy: Copy
|
|
662
|
+
field: ClientField
|
|
663
|
+
kayan: ClientKayan
|
|
664
|
+
kayans?: readonly ClientKayan[]
|
|
665
|
+
leaf?: LeafClient
|
|
666
|
+
row: Readonly<Record<string, unknown>> | null
|
|
667
|
+
screen?: ScreenPage
|
|
668
|
+
}): ReactElement {
|
|
669
|
+
const titles = useRelationshipTitles({ fields: [field], docs: row ? [row] : [], context: { api, client: kayans, locale: copy.locale } })
|
|
670
|
+
const label = useId()
|
|
671
|
+
const value = row?.[field.name]
|
|
672
|
+
const Ref = refOf({ screen, place: 'cell', path: leaf?.cell ?? field.cell })
|
|
673
|
+
const Cell = cellFor(field, leaf?.cell)
|
|
674
|
+
const description = leaf?.description?.[copy.locale] ?? descriptionFor({ kayan, field, copy })
|
|
675
|
+
const width = leaf?.width ?? field.width
|
|
676
|
+
return (
|
|
677
|
+
<div role="group" aria-labelledby={label} data-field-value={field.name} className="grid gap-1.5" style={width ? { width } : undefined}>
|
|
678
|
+
<span id={label} className="text-sm font-medium">{labelFor({ kayan, field, copy })}</span>
|
|
679
|
+
<span className="text-sm">
|
|
680
|
+
{Ref && screen
|
|
681
|
+
? <Ref field={field} value={value} row={row ?? {}} />
|
|
682
|
+
: (leaf?.cell ?? field.cell) === 'richText'
|
|
683
|
+
? <RichTextValueView value={value} t={copy.t} />
|
|
684
|
+
: (leaf?.cell ?? field.cell) === 'upload'
|
|
685
|
+
? <UploadValue kayan={kayan} field={field} value={value} t={copy.t} locale={copy.locale} titles={titles} />
|
|
686
|
+
: <Cell kayan={kayan} field={field} value={value} t={copy.t} locale={copy.locale} titles={titles} />}
|
|
687
|
+
</span>
|
|
688
|
+
{description ? <span className="text-muted-foreground text-sm">{description}</span> : null}
|
|
689
|
+
</div>
|
|
690
|
+
)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const units = [['year', 31_536_000], ['month', 2_592_000], ['week', 604_800], ['day', 86_400], ['hour', 3_600], ['minute', 60], ['second', 1]] as const
|
|
694
|
+
|
|
695
|
+
/** How long ago a moment was, in the viewer's language via `Intl.RelativeTimeFormat`: "2 hours ago", and "now" for the moment just passed or a moment ahead of this clock. */
|
|
696
|
+
export const timeAgo = ({ at, now, locale }: { at: Date; now: number; locale: string }) => {
|
|
697
|
+
const seconds = Math.min(0, (at.getTime() - now) / 1000)
|
|
698
|
+
const [unit, size] = units.find(([, size]) => -seconds >= size) ?? units.at(-1)!
|
|
699
|
+
return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(Math.round(seconds / size), unit)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function UpdatedAgo({ at, t, locale }: { at: Date; t: Translate; locale: string }): ReactElement {
|
|
703
|
+
const [now, setNow] = useState(Date.now)
|
|
704
|
+
useEffect(() => {
|
|
705
|
+
const interval = setInterval(() => setNow(Date.now()), 60_000)
|
|
706
|
+
return () => clearInterval(interval)
|
|
707
|
+
}, [])
|
|
708
|
+
return <>{(t('version.updatedAgo', 'Updated {time}') ?? 'Updated {time}').replace('{time}', timeAgo({ at, now, locale }))}</>
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* The form screen over one kayan's `create`, `update` and `delete`, holding its values in TanStack Form.
|
|
713
|
+
* It is mounted under a key of the kayan and the row's id, so a host that hands another row gets a form
|
|
714
|
+
* seeded from that row, and a refusal in hand goes with it.
|
|
715
|
+
*/
|
|
716
|
+
export function Form(props: FormProps): ReactElement {
|
|
717
|
+
const id = String(props.value?.id ?? '')
|
|
718
|
+
// Exception: the row this form's own autosave made on a create. The host's move to its edit page
|
|
719
|
+
// hands that row and the form stays mounted, so nothing typed since is lost.
|
|
720
|
+
const [held, setHeld] = useState<{ id: string; mount: number; made?: string }>({ id, mount: 0 })
|
|
721
|
+
// The made row's id is kept across that move alone, which is how the page knows the row is its own autosave's.
|
|
722
|
+
if (held.id !== id) setHeld(held.id === '' && held.made === id ? { ...held, id } : { id, mount: held.mount + 1 })
|
|
723
|
+
return <FormScreen key={`${props.kayan.name}:${held.mount}`} {...props} made={held.made} onMade={(made) => setHeld((before) => ({ ...before, made }))} />
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function FormScreen({
|
|
727
|
+
api,
|
|
728
|
+
blocked,
|
|
729
|
+
client,
|
|
730
|
+
drafts,
|
|
731
|
+
error,
|
|
732
|
+
expectedUpdatedAt,
|
|
733
|
+
expectedUpdatedAtOn,
|
|
734
|
+
kayan,
|
|
735
|
+
kayans,
|
|
736
|
+
locale,
|
|
737
|
+
locales,
|
|
738
|
+
made,
|
|
739
|
+
nodes,
|
|
740
|
+
onCancel,
|
|
741
|
+
onDirty,
|
|
742
|
+
onDone,
|
|
743
|
+
onMade,
|
|
744
|
+
operations,
|
|
745
|
+
related,
|
|
746
|
+
t,
|
|
747
|
+
screen,
|
|
748
|
+
value,
|
|
749
|
+
writes,
|
|
750
|
+
}: FormProps & { made?: string; onMade: (id: string) => void }): ReactElement {
|
|
751
|
+
const copy = { t, locale, locales }
|
|
752
|
+
const [refused, setRefused] = useState<unknown>(undefined)
|
|
753
|
+
const [busy, setBusy] = useState(false)
|
|
754
|
+
// Bumped to remount every control and panel, so a panel reads what it shows afresh.
|
|
755
|
+
const [revision, setRevision] = useState(0)
|
|
756
|
+
const reload = () => setRevision((already) => already + 1)
|
|
757
|
+
// Saves landed since mount (plain, draft, publish, autosave), for panels that keep their own history
|
|
758
|
+
// of the row; a draft save never moves `record.updatedAt`.
|
|
759
|
+
const [saves, setSaves] = useState(0)
|
|
760
|
+
// When the last save landed, shown as the row's last update ahead of its own `updatedAt`.
|
|
761
|
+
const [savedAt, setSavedAt] = useState<Date>()
|
|
762
|
+
const noteSave = () => {
|
|
763
|
+
setSaves((already) => already + 1)
|
|
764
|
+
setSavedAt(new Date())
|
|
765
|
+
}
|
|
766
|
+
// The seed is held in state, not read off `value`: a save makes what it wrote the seed before the host
|
|
767
|
+
// reads the row again. `saved` is what the server holds, kept apart because an autosave leaves the seed alone.
|
|
768
|
+
const seeded = (values: Record<string, unknown>, status = drafts?.status, updatedAt = value?.updatedAt) => ({ row: value, values, saved: values, status, updatedAt })
|
|
769
|
+
const [seed, setSeed] = useState(() => seeded(seedOf(kayan.fields, value)))
|
|
770
|
+
// The focused box and its caret when the host handed another row, put back once the new page has
|
|
771
|
+
// drawn it: an autosaved create moves to an edit page whose tree is new.
|
|
772
|
+
const focused = useRef<{ id: string; start: number | null; end: number | null }>(undefined)
|
|
773
|
+
if (seed.row !== value) {
|
|
774
|
+
const box = typeof document === 'undefined' ? null : (document.activeElement as HTMLInputElement | null)
|
|
775
|
+
focused.current = box?.id ? { id: box.id, start: box.selectionStart ?? null, end: box.selectionEnd ?? null } : undefined
|
|
776
|
+
setSeed(seeded(seedOf(kayan.fields, value)))
|
|
777
|
+
}
|
|
778
|
+
useLayoutEffect(() => {
|
|
779
|
+
const was = focused.current
|
|
780
|
+
focused.current = undefined
|
|
781
|
+
if (!was || document.activeElement?.id === was.id) return
|
|
782
|
+
const box = document.getElementById(was.id) as HTMLInputElement | null
|
|
783
|
+
box?.focus()
|
|
784
|
+
if (was.start !== null) box?.setSelectionRange(was.start, was.end)
|
|
785
|
+
}, [seed])
|
|
786
|
+
const form = useForm({ defaultValues: seed.values })
|
|
787
|
+
const values = useStore(form.store, (state) => state.values as Record<string, unknown>)
|
|
788
|
+
// The access report a node's `when` reads as `$user`; none outside a screen.
|
|
789
|
+
const access = useContext(ScreenContext)?.access
|
|
790
|
+
const held = refused === undefined ? error : refused
|
|
791
|
+
const shown = refusalKeyOf(held)
|
|
792
|
+
const issues = issuesOf(held)
|
|
793
|
+
// The wire message is itself a copy key; a key nothing answers is drawn as it arrived, which is what
|
|
794
|
+
// a custom shape that writes none of our keys sends.
|
|
795
|
+
const sentences: Record<string, string> = Object.create(null)
|
|
796
|
+
for (const [name, issue] of Object.entries(issues)) sentences[name] = t(issue, issue) ?? issue
|
|
797
|
+
const byName = new Map(kayan.fields.map((field) => [field.name, field]))
|
|
798
|
+
const banner = shown ? refusalWords({ shown, labels: Object.keys(issues).flatMap((name) => (byName.has(name) ? [labelFor({ kayan, field: byName.get(name)!, copy })] : [])), copy }) : undefined
|
|
799
|
+
// A create that has autosaved its row edits that row from then on, before
|
|
800
|
+
// the host has handed it.
|
|
801
|
+
const id = String(value?.id ?? made ?? '')
|
|
802
|
+
// A row with an id is being changed, one without is being made; a field may be the client's on one operation and the server's on the other.
|
|
803
|
+
const on = id ? 'update' : 'create'
|
|
804
|
+
|
|
805
|
+
// Put the refusal where TanStack keeps a server's answer (`onServer`), so a project's own control reading
|
|
806
|
+
// `field.state.meta.errors` sees the same sentence the shell draws.
|
|
807
|
+
useEffect(() => {
|
|
808
|
+
// The slot is typed off an `onServer` validator, which this form declares none of; TanStack
|
|
809
|
+
// writes the key anyway, so the cast only bridges the types.
|
|
810
|
+
form.setErrorMap({ onServer: { form: undefined, fields: sentences } } as Parameters<AnyFormApi['setErrorMap']>[0])
|
|
811
|
+
}, [form, JSON.stringify(sentences)])
|
|
812
|
+
|
|
813
|
+
// TanStack's per-field `isDefaultValue` over the whole form, so a value typed back to its seed is no change.
|
|
814
|
+
const dirty = useStore(form.store, (state) => !state.isDefaultValue)
|
|
815
|
+
|
|
816
|
+
// Guards a half-written row against a closed tab or a typed URL. The browser shows its own sentence;
|
|
817
|
+
// cleared on unmount so a form left behind does not hold the guard for the next screen.
|
|
818
|
+
useEffect(() => {
|
|
819
|
+
window.onbeforeunload = dirty ? () => '' : null
|
|
820
|
+
return () => {
|
|
821
|
+
window.onbeforeunload = null
|
|
822
|
+
}
|
|
823
|
+
}, [dirty])
|
|
824
|
+
|
|
825
|
+
// The same fact to the host, for navigations the browser's guard cannot see; cleared on unmount so a
|
|
826
|
+
// form left behind does not hold the host's blocker.
|
|
827
|
+
useEffect(() => {
|
|
828
|
+
onDirty?.(dirty)
|
|
829
|
+
return () => onDirty?.(false)
|
|
830
|
+
}, [dirty, onDirty])
|
|
831
|
+
|
|
832
|
+
// Whether the server owns this field on this operation (read-only, `uuid`, a non-client provider, or
|
|
833
|
+
// access denied); it decides both the disabled control and whether the field is in the write.
|
|
834
|
+
const owned = (field: ClientField) => readOnlyField(field, on) || (writes ? !writes(field.name, on) : false)
|
|
835
|
+
// Every field `setValue` changed, which a save counts as named.
|
|
836
|
+
const set = useRef(new Set<string>())
|
|
837
|
+
const setValue = (name: string, next: unknown) => {
|
|
838
|
+
const field = byName.get(name)
|
|
839
|
+
if (!busy && !viewing && field && !owned(field)) {
|
|
840
|
+
set.current.add(name)
|
|
841
|
+
form.setFieldValue(name, next)
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
// A field the server provides is not the person's to fix, so it is not checked, as the save does not check it.
|
|
845
|
+
const check = (name: string, next: unknown) => {
|
|
846
|
+
const field = byName.get(name)
|
|
847
|
+
const rule = field && !owned(field) ? brokenRule(field, next) : undefined
|
|
848
|
+
if (!rule) return undefined
|
|
849
|
+
const key = `${kayan.name}.${name}.${rule}`
|
|
850
|
+
return t(key, key) ?? key
|
|
851
|
+
}
|
|
852
|
+
// Once the refusal names no field it is `null`, not `undefined`, so the host's own refusal does not come back.
|
|
853
|
+
const forget = (name: string) => {
|
|
854
|
+
if (issues[name] === undefined) return
|
|
855
|
+
const { data } = held as { data: { params: { issues: { path?: unknown }[] } } }
|
|
856
|
+
const rest = data.params.issues.filter((issue) => String(issue.path).split('.')[0] !== name)
|
|
857
|
+
setRefused(rest.length ? { ...(held as object), data: { ...data, params: { ...data.params, issues: rest } } } : null)
|
|
858
|
+
}
|
|
859
|
+
const readOnly = (field: ClientField) => busy || owned(field)
|
|
860
|
+
|
|
861
|
+
// A leaf in another region is outside the page's one <form>, so its box names the form by this id
|
|
862
|
+
// (the platform's `form` attribute) and Enter in it still saves.
|
|
863
|
+
const formId = useId()
|
|
864
|
+
const control = (leaf: string | LeafClient) => {
|
|
865
|
+
const field = byName.get(typeof leaf === 'string' ? leaf : leaf.field)
|
|
866
|
+
// A leaf naming a field the kayan lacks is refused at `buildAsas`, so this fires only for a hand-built
|
|
867
|
+
// layout. A field the page names is drawn whoever provides it.
|
|
868
|
+
if (!field) return null
|
|
869
|
+
// The published copy is drawn read-only; the draft's boxes are not drawn, but what was typed stays in the form's values.
|
|
870
|
+
if (viewing) {
|
|
871
|
+
return (
|
|
872
|
+
<LeafControl
|
|
873
|
+
key={`${field.name}-published`}
|
|
874
|
+
field={field}
|
|
875
|
+
form={formId}
|
|
876
|
+
kayan={kayan}
|
|
877
|
+
kayans={kayans}
|
|
878
|
+
leaf={typeof leaf === 'string' ? undefined : leaf}
|
|
879
|
+
screen={screen}
|
|
880
|
+
copy={copy}
|
|
881
|
+
api={api}
|
|
882
|
+
value={showing[field.name]}
|
|
883
|
+
values={showing}
|
|
884
|
+
setValue={() => {}}
|
|
885
|
+
onChange={() => {}}
|
|
886
|
+
readOnly
|
|
887
|
+
/>
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
// A leaf the page marks read-only is read as text with its cell's formatter. A field the server
|
|
891
|
+
// provides keeps its control, which the side card draws.
|
|
892
|
+
if (typeof leaf !== 'string' && leaf.readOnly === true) {
|
|
893
|
+
return <FieldValue key={`${field.name}-${revision}`} api={api} copy={copy} field={field} kayan={kayan} kayans={kayans} leaf={leaf} row={values} screen={screen} />
|
|
894
|
+
}
|
|
895
|
+
return (
|
|
896
|
+
<BoundField
|
|
897
|
+
key={`${field.name}-${revision}`}
|
|
898
|
+
api={api}
|
|
899
|
+
copy={copy}
|
|
900
|
+
field={field}
|
|
901
|
+
form={formId}
|
|
902
|
+
kayan={kayan}
|
|
903
|
+
kayans={kayans}
|
|
904
|
+
leaf={typeof leaf === 'string' ? undefined : leaf}
|
|
905
|
+
readOnly={readOnly(field)}
|
|
906
|
+
screen={screen}
|
|
907
|
+
values={values}
|
|
908
|
+
setValue={setValue}
|
|
909
|
+
/>
|
|
910
|
+
)
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const draw: Draw = {
|
|
914
|
+
kayan,
|
|
915
|
+
copy,
|
|
916
|
+
issues,
|
|
917
|
+
scope: scopeOf({ values, record: value, access, kayan }),
|
|
918
|
+
control,
|
|
919
|
+
// A component has no field behind it; inside a form it reads and writes the form's values through
|
|
920
|
+
// `useForm()`. A reference with no registered component is said, not drawn blank.
|
|
921
|
+
component: (path, index) => {
|
|
922
|
+
const Ref = refOf({ screen, place: 'component', path })
|
|
923
|
+
if (Ref && screen) return <Ref key={`component-${index}-${revision}`} page={screen.page} />
|
|
924
|
+
return (
|
|
925
|
+
<p key={`component-${index}`} data-missing-component={path} className="text-muted-foreground text-sm">
|
|
926
|
+
{(t('admin.missingComponent', 'No component is registered for "{component}".')
|
|
927
|
+
?? 'No component is registered for "{component}".'
|
|
928
|
+
).replace('{component}', path)}
|
|
929
|
+
</p>
|
|
930
|
+
)
|
|
931
|
+
},
|
|
932
|
+
layout: ({ path, regions, index }) => screen && frameOf({ screen, t, path, regions, key: index }),
|
|
933
|
+
list: related,
|
|
934
|
+
nav: () => screen && <NavLinks />,
|
|
935
|
+
button: (node) => {
|
|
936
|
+
const button = id && client.run && !viewing ? pageButtonOf({ kayan, node, allowed: operations }) : undefined
|
|
937
|
+
return button ? buttonOf(button) : null
|
|
938
|
+
},
|
|
939
|
+
// The page's one <form>, so a list of another kayan beside it, with forms of its own, is never inside it;
|
|
940
|
+
// a leaf elsewhere on the page is still this form's value. A page with a controls node draws the actions there.
|
|
941
|
+
// `noValidate` so the browser's own bubble never stands in for the field's refusal in the page's language.
|
|
942
|
+
form: (children) => (
|
|
943
|
+
<form id={formId} noValidate onSubmit={submit} className="flex flex-col gap-6">
|
|
944
|
+
{chrome}
|
|
945
|
+
<div className={headed ? 'flex flex-col gap-4 border-t pt-6' : 'flex flex-col gap-4'}>
|
|
946
|
+
{children}
|
|
947
|
+
{controlled ? null : (
|
|
948
|
+
<Card>
|
|
949
|
+
<CardContent>{actionsOf(false)}</CardContent>
|
|
950
|
+
</Card>
|
|
951
|
+
)}
|
|
952
|
+
</div>
|
|
953
|
+
</form>
|
|
954
|
+
),
|
|
955
|
+
// Only while the page draws the form: a role page that draws a fixed role in place of its form offers no save.
|
|
956
|
+
controls: () => (tree && drawsKind({ nodes: tree, kind: 'form', scope: draw.scope }) ? actionsOf(true) : null),
|
|
957
|
+
}
|
|
958
|
+
const walk = (nodes: readonly Node[]) => drawNodes({ nodes, draw })
|
|
959
|
+
// A screen's page names every field it draws; a form handed no page draws every field in declared
|
|
960
|
+
// order, and no join, whose rows are another kayan's.
|
|
961
|
+
const tree = nodes ?? (screen && nodesOf(screen.page))
|
|
962
|
+
const controlled = !!tree && drawsKind({ nodes: tree, kind: 'controls', scope: draw.scope })
|
|
963
|
+
const unnamed = tree ? [] : kayan.fields.filter((field) => field.kind !== 'join')
|
|
964
|
+
const provided = unnamed.filter(owned)
|
|
965
|
+
const rest = unnamed.filter((field) => !owned(field))
|
|
966
|
+
// Titles of the rows the side card points at, read once for this row. With no `api` each id settles as
|
|
967
|
+
// a row the viewer cannot see; the cell never shows the id it holds.
|
|
968
|
+
const titles = useRelationshipTitles({ fields: provided, docs: value ? [value] : [], context: { api, client: kayans, locale } })
|
|
969
|
+
// A page that draws a row draws no kayan description, except a global's one row, whose page is the
|
|
970
|
+
// only place it is drawn.
|
|
971
|
+
const about = screen && value?.id !== undefined && !kayan.single ? undefined : descriptionOf(kayan, copy)
|
|
972
|
+
const headed = !screen || !!about
|
|
973
|
+
|
|
974
|
+
const runOperation =
|
|
975
|
+
(operation: PageButton) =>
|
|
976
|
+
async (input: Record<string, unknown>, refuse: (thrown: unknown) => void) => {
|
|
977
|
+
const landed = await run(() => client.run!(operation.name, input), operation.effect, refuse)
|
|
978
|
+
// A kayan's own operation can change anything the row touches, children included, so reload the panels.
|
|
979
|
+
if (landed) reload()
|
|
980
|
+
return landed
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// A relationship is drawn by the list's cell, not a picker: a disabled picker over a row the viewer
|
|
984
|
+
// may not choose shows the raw id, where the cell reads the target's title.
|
|
985
|
+
const sideEntry = (field: ClientField) => {
|
|
986
|
+
if (field.kind !== 'relationship') return control(field.name)
|
|
987
|
+
const Cell = cellFor(field)
|
|
988
|
+
return (
|
|
989
|
+
<div key={field.name} className="grid gap-1.5">
|
|
990
|
+
<span className="text-sm font-medium">{labelFor({ kayan, field, copy })}</span>
|
|
991
|
+
<span className="text-muted-foreground text-sm">
|
|
992
|
+
<Cell kayan={kayan} field={field} value={values[field.name]} t={t} locale={locale} titles={titles} />
|
|
993
|
+
</span>
|
|
994
|
+
</div>
|
|
995
|
+
)
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** The instant this form last read or wrote the row at, for a write whose operation takes it. */
|
|
999
|
+
const readAt = (takes?: boolean) => (takes && seed.updatedAt !== undefined ? { expectedUpdatedAt: seed.updatedAt } : {})
|
|
1000
|
+
/** Whether a write of the person's own has landed here, which makes a row this page's autosave made theirs. */
|
|
1001
|
+
const kept = useRef(false)
|
|
1002
|
+
// `refuse` is where a refusal lands: the form's own box by default, or an operation dialog's boxes, which
|
|
1003
|
+
// stays open to hold them. Answers whether the write landed.
|
|
1004
|
+
const run = async (write: () => Promise<unknown>, effect?: string, refuse: (thrown: unknown) => void = setRefused, status?: DraftStatus): Promise<boolean> => {
|
|
1005
|
+
setRefused(undefined)
|
|
1006
|
+
setBusy(true)
|
|
1007
|
+
try {
|
|
1008
|
+
const written = await write()
|
|
1009
|
+
kept.current = true
|
|
1010
|
+
// What was just written is what the form now holds, so it holds no change: Save goes disabled and
|
|
1011
|
+
// the unload guard comes down.
|
|
1012
|
+
form.reset(values)
|
|
1013
|
+
// The instant the row now stands at, which the next update sends.
|
|
1014
|
+
setSeed(seeded(values, status ?? seed.status, (written as { updatedAt?: unknown } | null)?.updatedAt ?? seed.updatedAt))
|
|
1015
|
+
noteSave()
|
|
1016
|
+
// A write may change the published copy, which View published reads again.
|
|
1017
|
+
setPublished(undefined)
|
|
1018
|
+
setView('draft')
|
|
1019
|
+
onDone(written, effect)
|
|
1020
|
+
return true
|
|
1021
|
+
} catch (thrown) {
|
|
1022
|
+
refusedOver.current = JSON.stringify(values)
|
|
1023
|
+
refuse(thrown)
|
|
1024
|
+
return false
|
|
1025
|
+
} finally {
|
|
1026
|
+
setBusy(false)
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Differs from the seed or from what the server holds since; an update sends only these.
|
|
1031
|
+
const changed = (field: ClientField) =>
|
|
1032
|
+
form.getFieldMeta(field.name)?.isDefaultValue === false || JSON.stringify(values[field.name]) !== JSON.stringify(seed.saved[field.name])
|
|
1033
|
+
|
|
1034
|
+
const submit = (event: FormEvent<HTMLFormElement>) => {
|
|
1035
|
+
event.preventDefault()
|
|
1036
|
+
save()
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// A draft save holds each value to its kind and none of its rules, so the rules are not checked here either.
|
|
1040
|
+
const save = (options?: DraftOptions) => {
|
|
1041
|
+
// An update sends only the fields that changed, so two people saving different fields of one row each
|
|
1042
|
+
// write their own and neither writes the other's back as it was opened.
|
|
1043
|
+
const fields = writable().filter((field) => !id || changed(field))
|
|
1044
|
+
const refusal = options?.draft ? undefined : refusalOf({ kayan: kayan.name, fields, values })
|
|
1045
|
+
if (refusal) return setRefused(refusal)
|
|
1046
|
+
const written = valuesOf({ fields, values, owned })
|
|
1047
|
+
const status = drafts && (options?.draft ? afterDraft(seed.status) : 'published')
|
|
1048
|
+
void run(() => (id ? client.update({ ...written, ...readAt(expectedUpdatedAt), id }, options) : client.create(written, options)), on, undefined, status)
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const writable = () => {
|
|
1052
|
+
// A page's form writes only what the page names and draws writable: sending a field it leaves out would
|
|
1053
|
+
// overwrite whatever was saved since. A field a control or panel set through `setValue` counts as named.
|
|
1054
|
+
const named = tree && new Set([...leavesOf(tree, (leaf) => !leaf.readOnly), ...set.current])
|
|
1055
|
+
return (named ? kayan.fields.filter((field) => named.has(field.name)) : kayan.fields).filter((field) => !owned(field))
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const status = drafts && seed.status
|
|
1059
|
+
// Which copy a changed row shows: the draft, or the published copy, read once when first chosen and drawn read-only with no save or autosave.
|
|
1060
|
+
const [view, setView] = useState<'draft' | 'published'>('draft')
|
|
1061
|
+
const [published, setPublished] = useState<Record<string, unknown> | null>()
|
|
1062
|
+
const viewing = status === 'changed' && view === 'published' && !!published
|
|
1063
|
+
const showing = viewing ? seedOf(kayan.fields, published) : values
|
|
1064
|
+
const choose = async (next: string) => {
|
|
1065
|
+
if (next === 'published' && published === undefined) {
|
|
1066
|
+
try {
|
|
1067
|
+
setPublished((await client.findByID!({ id })) as Record<string, unknown> | null)
|
|
1068
|
+
} catch (thrown) {
|
|
1069
|
+
return setRefused(thrown)
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
setView(next === 'published' ? 'published' : 'draft')
|
|
1073
|
+
}
|
|
1074
|
+
/** The values a save was last refused over, which the autosave below never sends again. */
|
|
1075
|
+
const refusedOver = useRef<string | undefined>(undefined)
|
|
1076
|
+
const [autosaved, setAutosaved] = useState<'saving' | 'saved'>()
|
|
1077
|
+
/** Whether the form is still on screen, which an autosave that lands late asks before it moves the host. */
|
|
1078
|
+
const alive = useRef(true)
|
|
1079
|
+
useEffect(() => {
|
|
1080
|
+
alive.current = true
|
|
1081
|
+
return () => {
|
|
1082
|
+
alive.current = false
|
|
1083
|
+
}
|
|
1084
|
+
}, [])
|
|
1085
|
+
const json = JSON.stringify(values)
|
|
1086
|
+
// Autosave a draft of what differs once the form has stayed still for the interval. Never while a save is out,
|
|
1087
|
+
// over values a save was refused over, or while a discard question is open: that question says the change is unsaved.
|
|
1088
|
+
const [asking, setAsking] = useState(false)
|
|
1089
|
+
/** Whether a Discard is on its way out, deleting a draft first where it has one to delete. */
|
|
1090
|
+
const [leaving, setLeaving] = useState(false)
|
|
1091
|
+
// Nor once a write was refused because the row changed since this form read it: every autosave after it
|
|
1092
|
+
// would be refused the same way, so it stops and the refusal stays to say why.
|
|
1093
|
+
const stale = shown === 'error.conflict.stale'
|
|
1094
|
+
const interval = viewing || asking || blocked || leaving || stale ? undefined : drafts?.autosave
|
|
1095
|
+
useEffect(() => {
|
|
1096
|
+
if (!interval || busy || autosaved === 'saving' || refusedOver.current === json) return
|
|
1097
|
+
const fields = writable()
|
|
1098
|
+
if (!fields.some((field) => JSON.stringify(values[field.name]) !== JSON.stringify(seed.saved[field.name]))) return
|
|
1099
|
+
const timer = setTimeout(() => {
|
|
1100
|
+
const sent = valuesOf({ fields: fields.filter((field) => !id || changed(field)), values, owned })
|
|
1101
|
+
setAutosaved('saving')
|
|
1102
|
+
const words = { draft: true, autosave: true }
|
|
1103
|
+
;(id ? client.update({ ...sent, ...readAt(expectedUpdatedAt), id }, words) : client.create(sent, words)).then(
|
|
1104
|
+
(written) => {
|
|
1105
|
+
setAutosaved('saved')
|
|
1106
|
+
// A refusal a save drew earlier is over: the row just saved fine, so a banner naming no field
|
|
1107
|
+
// would sit there with nothing left to say.
|
|
1108
|
+
setRefused(undefined)
|
|
1109
|
+
refusedOver.current = undefined
|
|
1110
|
+
noteSave()
|
|
1111
|
+
// The instant the draft now stands at, which the next write sends.
|
|
1112
|
+
const updatedAt = (written as { updatedAt?: unknown } | null)?.updatedAt ?? seed.updatedAt
|
|
1113
|
+
if (JSON.stringify(form.state.values) === json) {
|
|
1114
|
+
form.reset(values)
|
|
1115
|
+
setSeed(seeded(values, afterDraft(seed.status), updatedAt))
|
|
1116
|
+
} else {
|
|
1117
|
+
setSeed((held) => ({ ...held, saved: { ...held.saved, ...sent }, status: afterDraft(held.status), updatedAt }))
|
|
1118
|
+
}
|
|
1119
|
+
const row = (written as { id?: unknown } | null)?.id
|
|
1120
|
+
if (!id && row !== undefined && alive.current) {
|
|
1121
|
+
onMade(String(row))
|
|
1122
|
+
onDone(written, 'create')
|
|
1123
|
+
}
|
|
1124
|
+
},
|
|
1125
|
+
(thrown: unknown) => {
|
|
1126
|
+
refusedOver.current = json
|
|
1127
|
+
setAutosaved(undefined)
|
|
1128
|
+
setRefused(thrown)
|
|
1129
|
+
},
|
|
1130
|
+
)
|
|
1131
|
+
}, interval)
|
|
1132
|
+
return () => clearTimeout(timer)
|
|
1133
|
+
}, [interval, busy, autosaved, json, seed, id])
|
|
1134
|
+
// Leaving unsaved deletes the untouched draft this page's autosave made, so a cancelled new row leaves none.
|
|
1135
|
+
// A published row is kept (a plain read finds it), and so is a draft someone saved since (the delete gets a 409).
|
|
1136
|
+
const ownDraft = !!made && id === made && !kept.current && status === 'draft'
|
|
1137
|
+
const keepsDraft = ownDraft && operations?.delete !== true
|
|
1138
|
+
const out = useRef(false)
|
|
1139
|
+
const discard = async (leave: () => void) => {
|
|
1140
|
+
if (out.current) return
|
|
1141
|
+
out.current = true
|
|
1142
|
+
setLeaving(true)
|
|
1143
|
+
if (ownDraft && !keepsDraft && client.findByID) {
|
|
1144
|
+
const found = await client.findByID({ id }).then(
|
|
1145
|
+
(row) => (row ? 'published' : 'absent'),
|
|
1146
|
+
(thrown: unknown) => ((thrown as { status?: unknown } | null)?.status === 404 ? 'absent' : 'unknown'),
|
|
1147
|
+
)
|
|
1148
|
+
if (found === 'absent') await client.delete({ id, ...readAt(expectedUpdatedAtOn?.includes('delete')) }).catch((thrown: unknown) => console.error('The draft this page made could not be deleted:', thrown))
|
|
1149
|
+
}
|
|
1150
|
+
// The edit is thrown away before the navigation, so a slow navigation leaves no change for the autosave to save.
|
|
1151
|
+
form.reset(seed.saved)
|
|
1152
|
+
out.current = false
|
|
1153
|
+
setLeaving(false)
|
|
1154
|
+
setAsking(false)
|
|
1155
|
+
leave()
|
|
1156
|
+
}
|
|
1157
|
+
useEffect(() => {
|
|
1158
|
+
if (autosaved !== 'saved') return
|
|
1159
|
+
const timer = setTimeout(() => setAutosaved(undefined), 2000)
|
|
1160
|
+
return () => clearTimeout(timer)
|
|
1161
|
+
}, [autosaved, saves])
|
|
1162
|
+
|
|
1163
|
+
const buttonOf = (operation: PageButton) => {
|
|
1164
|
+
const Ref = refOf({ screen, place: 'button', path: operation.component })
|
|
1165
|
+
if (Ref && screen) {
|
|
1166
|
+
return (
|
|
1167
|
+
<OwnButton
|
|
1168
|
+
key={operation.name}
|
|
1169
|
+
Own={Ref}
|
|
1170
|
+
operation={operation}
|
|
1171
|
+
busy={busy}
|
|
1172
|
+
copy={copy}
|
|
1173
|
+
call={async (input) => {
|
|
1174
|
+
await runOperation(operation)({ ...input, id }, setRefused)
|
|
1175
|
+
}}
|
|
1176
|
+
/>
|
|
1177
|
+
)
|
|
1178
|
+
}
|
|
1179
|
+
// A delete is the one write nothing brings back, so its button is always the two-step dialog, whatever the node carries.
|
|
1180
|
+
if (operation.effect === 'delete') {
|
|
1181
|
+
return (
|
|
1182
|
+
<DeleteButton
|
|
1183
|
+
key={operation.name}
|
|
1184
|
+
busy={busy}
|
|
1185
|
+
copy={copy}
|
|
1186
|
+
kayan={kayan}
|
|
1187
|
+
label={operationLabelFor({ kayan, operation, copy })}
|
|
1188
|
+
row={value ?? undefined}
|
|
1189
|
+
onDelete={(refuse) => run(() => client.delete({ id }), 'delete', refuse)}
|
|
1190
|
+
/>
|
|
1191
|
+
)
|
|
1192
|
+
}
|
|
1193
|
+
// A button that asks for no values and carries a confirm asks it in the app's own dialog; any other opens the
|
|
1194
|
+
// registry's dialog, which asks the confirm in its heading. Each names the row it runs on.
|
|
1195
|
+
const description = value ? rowWarning({ kayan, row: value, operation: operation.name, copy }) : undefined
|
|
1196
|
+
if (operation.confirm && !operation.fields?.length) {
|
|
1197
|
+
return <NodeButton key={operation.name} node={operation} copy={copy} busy={busy} description={description} run={() => void runOperation(operation)({ id }, setRefused)} />
|
|
1198
|
+
}
|
|
1199
|
+
return (
|
|
1200
|
+
<OperationButton
|
|
1201
|
+
key={operation.name}
|
|
1202
|
+
api={api}
|
|
1203
|
+
busy={busy}
|
|
1204
|
+
copy={copy}
|
|
1205
|
+
description={description}
|
|
1206
|
+
inputOf={(values) => ({ id, ...values })}
|
|
1207
|
+
kayan={kayan}
|
|
1208
|
+
kayans={kayans}
|
|
1209
|
+
operation={operation}
|
|
1210
|
+
run={runOperation(operation)}
|
|
1211
|
+
/>
|
|
1212
|
+
)
|
|
1213
|
+
}
|
|
1214
|
+
// Below sm the action bar is fixed, so the document keeps its height free as padding and scroll padding
|
|
1215
|
+
// (the document is what scrolls), and a field reached with Tab is never under the bar.
|
|
1216
|
+
const measureBar = useCallback((bar: HTMLElement | null) => {
|
|
1217
|
+
// Inside a sheet or a dialog the bar is the popup's own, so the document keeps no room for it.
|
|
1218
|
+
if (!bar || bar.closest('[role=dialog]')) return
|
|
1219
|
+
const root = document.documentElement.style
|
|
1220
|
+
// `--action-bar-height` too, which a list's own bar on a phone stands on rather than covering this one.
|
|
1221
|
+
const keep = (height: number) => {
|
|
1222
|
+
for (const property of ['padding-bottom', 'scroll-padding-bottom', '--action-bar-height']) {
|
|
1223
|
+
if (height) root.setProperty(property, `${height}px`)
|
|
1224
|
+
else root.removeProperty(property)
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
// `borderBoxSize` is missing before Safari 15.4 and one box rather than a
|
|
1228
|
+
// list before Firefox 92, so the bar's own `offsetHeight` stands in there.
|
|
1229
|
+
const observer = new ResizeObserver(([entry]) => keep(entry ? (entry.borderBoxSize?.[0]?.blockSize ?? (entry.target as HTMLElement).offsetHeight) : 0))
|
|
1230
|
+
observer.observe(bar)
|
|
1231
|
+
return () => {
|
|
1232
|
+
observer.disconnect()
|
|
1233
|
+
keep(0)
|
|
1234
|
+
}
|
|
1235
|
+
}, [])
|
|
1236
|
+
const updated = id ? (savedAt ?? (value?.updatedAt ? new Date(value.updatedAt as string | Date) : undefined)) : undefined
|
|
1237
|
+
// The draft or the published copy, on a changed row only. Beside the actions from xl up; below xl a row at
|
|
1238
|
+
// the top of the form (`chrome`), so it is never in the phone's fixed bar.
|
|
1239
|
+
const viewSwitch = (className: string, phone = false) =>
|
|
1240
|
+
status === 'changed' && client.findByID ? (
|
|
1241
|
+
<ToggleGroup
|
|
1242
|
+
data-view-switch=""
|
|
1243
|
+
value={[viewing ? 'published' : 'draft']}
|
|
1244
|
+
onValueChange={(next) => next[0] && void choose(next[0])}
|
|
1245
|
+
aria-label={t('version.shownCopy', 'Version shown')}
|
|
1246
|
+
className={`bg-muted flex shrink-0 rounded-md p-0.5 ${className}`}
|
|
1247
|
+
>
|
|
1248
|
+
{(['draft', 'published'] as const).map((one) => (
|
|
1249
|
+
<ToggleGroupItem key={one} value={one} className={`data-pressed:bg-background border-0 px-2 py-0.5 text-xs data-pressed:shadow-sm${phone ? ' min-h-11 flex-1' : ''}`}>
|
|
1250
|
+
{t(`version.${one}`, { draft: 'Draft', published: 'Published' }[one])}
|
|
1251
|
+
</ToggleGroupItem>
|
|
1252
|
+
))}
|
|
1253
|
+
</ToggleGroup>
|
|
1254
|
+
) : null
|
|
1255
|
+
// A submit button in a controls node sits outside the <form>, so every submit names it by its id. The spacing is
|
|
1256
|
+
// logical, so the row mirrors in Arabic.
|
|
1257
|
+
const actionsOf = (header: boolean) => {
|
|
1258
|
+
const size = header ? 'sm' : 'default'
|
|
1259
|
+
const tall = header ? 'max-sm:h-11' : ''
|
|
1260
|
+
const actions = (
|
|
1261
|
+
<>
|
|
1262
|
+
{/* A refusal naming no field, drawn here and not in the form: the form sits in the Details panel, and a
|
|
1263
|
+
Publish pressed from another tab would be refused where nobody is looking. */}
|
|
1264
|
+
{header && shown ? (
|
|
1265
|
+
<p role="alert" className="text-destructive line-clamp-2 min-w-24 flex-1 text-sm max-sm:basis-full sm:-order-1">
|
|
1266
|
+
{banner}
|
|
1267
|
+
</p>
|
|
1268
|
+
) : null}
|
|
1269
|
+
{status && !header ? (
|
|
1270
|
+
<span data-status={status} className={`me-auto rounded-md px-2 py-0.5 text-sm ${pills[status]}`}>
|
|
1271
|
+
{t('version.status', 'Status')}: {t(`version.${status}`, { draft: 'Draft', published: 'Published', changed: 'Changed' }[status])}
|
|
1272
|
+
</span>
|
|
1273
|
+
) : null}
|
|
1274
|
+
{/* Below xl it leaves the header for the top of the form (`chrome`). */}
|
|
1275
|
+
{viewSwitch(header ? 'max-xl:hidden' : '')}
|
|
1276
|
+
{autosaved ? (
|
|
1277
|
+
<span
|
|
1278
|
+
role="status"
|
|
1279
|
+
data-autosave={autosaved}
|
|
1280
|
+
className={autosaved === 'saved'
|
|
1281
|
+
? 'animate-out fade-out fill-mode-forwards delay-1500 animation-duration-500 flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400'
|
|
1282
|
+
: 'text-muted-foreground text-sm'}
|
|
1283
|
+
>
|
|
1284
|
+
{autosaved === 'saved' ? <Check aria-hidden className="size-4" /> : null}
|
|
1285
|
+
{autosaved === 'saving' ? t('version.saving', 'Saving…') : t('version.saved', 'Saved')}
|
|
1286
|
+
</span>
|
|
1287
|
+
) : null}
|
|
1288
|
+
{/* Read off the browser's clock, so it can differ from the server's render. */}
|
|
1289
|
+
{header && updated && !Number.isNaN(updated.getTime()) ? (
|
|
1290
|
+
<span data-updated="" className="text-muted-foreground text-sm max-xl:hidden" suppressHydrationWarning>
|
|
1291
|
+
<UpdatedAgo at={updated} t={t} locale={locale} />
|
|
1292
|
+
</span>
|
|
1293
|
+
) : null}
|
|
1294
|
+
{/* A dirty form asks before Cancel: the router's navigation raises no `beforeunload`, so the browser's
|
|
1295
|
+
guard does not see it. A clean form's Cancel is a plain button. */}
|
|
1296
|
+
{onCancel ? (
|
|
1297
|
+
<Button
|
|
1298
|
+
type="button"
|
|
1299
|
+
variant="outline"
|
|
1300
|
+
size={size}
|
|
1301
|
+
className={tall}
|
|
1302
|
+
disabled={busy}
|
|
1303
|
+
aria-haspopup={dirty ? 'dialog' : undefined}
|
|
1304
|
+
onClick={dirty ? () => setAsking(true) : onCancel}
|
|
1305
|
+
>
|
|
1306
|
+
{t('admin.cancel', 'Cancel') ?? 'Cancel'}
|
|
1307
|
+
</Button>
|
|
1308
|
+
) : null}
|
|
1309
|
+
{viewing ? null : drafts ? (
|
|
1310
|
+
<>
|
|
1311
|
+
{/* Save draft waits for a change on an update and is always offered beside the autosave. Publish is
|
|
1312
|
+
offered while anything is unpublished, with no change of its own. */}
|
|
1313
|
+
<Button type="button" size={size} className={`${draftColour} ${tall}`} data-button="save-draft" disabled={busy || autosaved === 'saving' || (!!id && !dirty)} onClick={() => save({ draft: true })}>
|
|
1314
|
+
{t('version.saveDraft', 'Save draft')}
|
|
1315
|
+
</Button>
|
|
1316
|
+
<Button type="submit" form={formId} size={size} className={`${publishColour} ${tall}`} data-button="publish" disabled={busy || autosaved === 'saving' || (!dirty && status === 'published')}>
|
|
1317
|
+
{busy ? <Spinner /> : null}
|
|
1318
|
+
{t('version.publishChanges', 'Publish changes')}
|
|
1319
|
+
</Button>
|
|
1320
|
+
</>
|
|
1321
|
+
) : (
|
|
1322
|
+
// Waits for a change on an update alone: a create is pressed to be checked.
|
|
1323
|
+
<Button type="submit" form={formId} size={size} className={tall} disabled={busy || (on === 'update' && !dirty)}>
|
|
1324
|
+
{busy ? <Spinner /> : null}
|
|
1325
|
+
{t('admin.save', 'Save')}
|
|
1326
|
+
</Button>
|
|
1327
|
+
)}
|
|
1328
|
+
</>
|
|
1329
|
+
)
|
|
1330
|
+
// `flex-wrap`: a row that cannot wrap is as wide as all its buttons together, which widens every grid
|
|
1331
|
+
// and flex box above it past a phone's width.
|
|
1332
|
+
if (!header) return <div className="flex flex-wrap items-center justify-end gap-2 rtl:justify-start">{actions}</div>
|
|
1333
|
+
// Below sm the header keeps the trigger, title and pill, and the rest moves to a bar fixed to the bottom of the
|
|
1334
|
+
// screen; from sm up the bar is `contents`. Inside a sheet or dialog the row sticks to the popup's foot instead.
|
|
1335
|
+
return (
|
|
1336
|
+
<div className="ms-auto flex min-w-min items-center justify-end gap-2 sm:flex-wrap max-sm:in-[[role=dialog]]:bg-background max-sm:in-[[role=dialog]]:sticky max-sm:in-[[role=dialog]]:bottom-0 max-sm:in-[[role=dialog]]:z-20 max-sm:in-[[role=dialog]]:order-last max-sm:in-[[role=dialog]]:w-full max-sm:in-[[role=dialog]]:border-t">
|
|
1337
|
+
{/* Coloured by status and always carrying its word, never the colour alone. The row shrinks no further
|
|
1338
|
+
than the narrowest it can be drawn, so the title beside it wraps rather than sitting under it. */}
|
|
1339
|
+
{status ? (
|
|
1340
|
+
<Badge data-status={status} className={pills[status]} title={t('version.status', 'Status')}>
|
|
1341
|
+
{t(`version.${status}`, { draft: 'Draft', published: 'Published', changed: 'Changed' }[status])}
|
|
1342
|
+
</Badge>
|
|
1343
|
+
) : null}
|
|
1344
|
+
<div
|
|
1345
|
+
data-action-bar=""
|
|
1346
|
+
ref={measureBar}
|
|
1347
|
+
className="max-sm:bg-background max-sm:fixed max-sm:in-[[role=dialog]]:static max-sm:in-[[role=dialog]]:flex-1 max-sm:in-[[role=dialog]]:border-t-0 max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:flex max-sm:flex-wrap max-sm:items-center max-sm:justify-end max-sm:gap-2 max-sm:border-t max-sm:px-4 max-sm:pt-2 max-sm:pb-[calc(0.5rem+env(safe-area-inset-bottom))] sm:contents"
|
|
1348
|
+
>
|
|
1349
|
+
{actions}
|
|
1350
|
+
</div>
|
|
1351
|
+
</div>
|
|
1352
|
+
)
|
|
1353
|
+
}
|
|
1354
|
+
// On a screen's page the title is the page's own `title` node, so the form keeps only the sentence under it.
|
|
1355
|
+
const chrome = (
|
|
1356
|
+
<>
|
|
1357
|
+
{/* On a screen's page a kayan with no sentence draws no header and no rule under it. */}
|
|
1358
|
+
{headed ? (
|
|
1359
|
+
<header className="max-w-3xl">
|
|
1360
|
+
{screen ? null : <h1 className="text-2xl font-bold tracking-tight">{titleOf({ kayan, copy })}</h1>}
|
|
1361
|
+
{about ? <p className="text-muted-foreground mt-1 text-sm">{about}</p> : null}
|
|
1362
|
+
</header>
|
|
1363
|
+
) : null}
|
|
1364
|
+
{shown && !controlled ? (
|
|
1365
|
+
<p role="alert" className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive">
|
|
1366
|
+
{banner}
|
|
1367
|
+
</p>
|
|
1368
|
+
) : null}
|
|
1369
|
+
{controlled ? viewSwitch('xl:hidden', true) : null}
|
|
1370
|
+
{status === 'changed' && viewing ? (
|
|
1371
|
+
<p data-draft-banner="published" className="bg-muted text-muted-foreground rounded-md border px-3 py-1.5 text-sm">
|
|
1372
|
+
{t('version.readOnlyPublished', 'Read only. This is the published version.')}
|
|
1373
|
+
</p>
|
|
1374
|
+
) : status === 'changed' ? (
|
|
1375
|
+
<div data-draft-banner="draft" className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-md border border-amber-500/50 bg-amber-500/10 px-3 py-1.5 text-sm text-amber-900 dark:text-amber-200">
|
|
1376
|
+
<span className="me-auto">{t('version.editingDraft', 'You are editing a draft. The published version is different.')}</span>
|
|
1377
|
+
{client.findByID ? (
|
|
1378
|
+
<Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => void choose('published')}>
|
|
1379
|
+
{t('version.viewPublished', 'View published')}
|
|
1380
|
+
</Button>
|
|
1381
|
+
) : null}
|
|
1382
|
+
{/* Restores the row's own published copy, which needs both keys a restore needs. */}
|
|
1383
|
+
{operations?.restoreVersion === true && client.run ? (
|
|
1384
|
+
<AlertDialog>
|
|
1385
|
+
<AlertDialogTrigger
|
|
1386
|
+
render={
|
|
1387
|
+
<Button type="button" variant="link" size="sm" className="text-destructive h-auto p-0" disabled={busy || autosaved === 'saving'}>
|
|
1388
|
+
{t('admin.discard', 'Discard changes')}
|
|
1389
|
+
</Button>
|
|
1390
|
+
}
|
|
1391
|
+
/>
|
|
1392
|
+
<AlertDialogContent>
|
|
1393
|
+
<AlertDialogHeader>
|
|
1394
|
+
<AlertDialogTitle>{t('version.confirmRevertToSaved', 'Confirm revert to saved')}</AlertDialogTitle>
|
|
1395
|
+
<AlertDialogDescription>
|
|
1396
|
+
{t('version.aboutToRevertToPublished', 'You are about to revert this document\'s changes to its published state. Are you sure?')}
|
|
1397
|
+
</AlertDialogDescription>
|
|
1398
|
+
</AlertDialogHeader>
|
|
1399
|
+
<AlertDialogFooter className="max-sm:[&_button]:h-11">
|
|
1400
|
+
<AlertDialogCancel>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</AlertDialogCancel>
|
|
1401
|
+
<AlertDialogAction variant="destructive" data-button="confirm-discard-draft" disabled={busy || autosaved === 'saving'} onClick={() => void run(() => client.run!('restoreVersion', { id, ...readAt(expectedUpdatedAtOn?.includes('restoreVersion')) }), 'update', undefined, 'published')}>
|
|
1402
|
+
{t('admin.discard', 'Discard changes')}
|
|
1403
|
+
</AlertDialogAction>
|
|
1404
|
+
</AlertDialogFooter>
|
|
1405
|
+
</AlertDialogContent>
|
|
1406
|
+
</AlertDialog>
|
|
1407
|
+
) : null}
|
|
1408
|
+
</div>
|
|
1409
|
+
) : null}
|
|
1410
|
+
</>
|
|
1411
|
+
)
|
|
1412
|
+
return (
|
|
1413
|
+
// The provider is around the whole screen, not just the column of inputs, so a panel the project
|
|
1414
|
+
// registered binds a field of this form through `useKayanField` wherever the layout put it.
|
|
1415
|
+
<KayanForm.Provider value={{ check, forget, form, issues: sentences, locales: localesOf(held), on, writes }}>
|
|
1416
|
+
<FormContext value={{ getData: () => showing, setValue, disabled: busy || viewing, saves, expectedUpdatedAtOn }}>
|
|
1417
|
+
{/* One question for both ways out, Cancel and a blocked navigation,
|
|
1418
|
+
kept mounted so nothing but an answer closes it. */}
|
|
1419
|
+
<DiscardDialog
|
|
1420
|
+
t={t}
|
|
1421
|
+
busy={busy || autosaved === 'saving' || leaving}
|
|
1422
|
+
open={asking || !!blocked}
|
|
1423
|
+
note={keepsDraft ? (t('admin.discardKeepsDraft', 'The draft saved so far is kept.') ?? 'The draft saved so far is kept.') : undefined}
|
|
1424
|
+
onOpenChange={(open) => {
|
|
1425
|
+
if (open || leaving) return
|
|
1426
|
+
setAsking(false)
|
|
1427
|
+
blocked?.reset()
|
|
1428
|
+
}}
|
|
1429
|
+
onDiscard={() => void discard(blocked ? blocked.proceed : onCancel ?? (() => {}))}
|
|
1430
|
+
/>
|
|
1431
|
+
{nodes || screen ? (
|
|
1432
|
+
<div className="flex flex-1 flex-col gap-6">{nodes ? walk([{ form: [...nodes] }]) : pageOf(screen!, draw)}</div>
|
|
1433
|
+
) : (
|
|
1434
|
+
<form id={formId} noValidate onSubmit={submit} className="flex flex-1 flex-col gap-6">
|
|
1435
|
+
{chrome}
|
|
1436
|
+
<div className="border-t pt-6">
|
|
1437
|
+
<div className={provided.length ? 'grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(18rem,1fr)]' : 'max-w-3xl'}>
|
|
1438
|
+
{/* A div and not a `main`: the page's shell already holds the one main landmark. */}
|
|
1439
|
+
<div data-fields="" className="grid min-w-0 gap-4">
|
|
1440
|
+
<Card className="overflow-visible">
|
|
1441
|
+
<CardContent className="flex flex-col gap-4">
|
|
1442
|
+
{rest.length ? <div className="flex flex-col gap-4">{rest.map((field) => control(field.name))}</div> : null}
|
|
1443
|
+
{actionsOf(false)}
|
|
1444
|
+
</CardContent>
|
|
1445
|
+
</Card>
|
|
1446
|
+
</div>
|
|
1447
|
+
{provided.length ? (
|
|
1448
|
+
<aside className="grid h-fit gap-4 lg:sticky lg:top-20">
|
|
1449
|
+
<Card className="overflow-visible rounded-lg">
|
|
1450
|
+
<CardHeader>
|
|
1451
|
+
<CardTitle>
|
|
1452
|
+
<h2 className="text-base font-medium">{t('admin.record', 'Record') ?? 'Record'}</h2>
|
|
1453
|
+
</CardTitle>
|
|
1454
|
+
</CardHeader>
|
|
1455
|
+
<CardContent className="grid gap-4">{provided.map(sideEntry)}</CardContent>
|
|
1456
|
+
</Card>
|
|
1457
|
+
</aside>
|
|
1458
|
+
) : null}
|
|
1459
|
+
</div>
|
|
1460
|
+
</div>
|
|
1461
|
+
</form>
|
|
1462
|
+
)}
|
|
1463
|
+
</FormContext>
|
|
1464
|
+
</KayanForm.Provider>
|
|
1465
|
+
)
|
|
1466
|
+
}
|
|
1467
|
+
// mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 6a3950dbcb0d13b0f3d3943056ec08b120e52ccaf71877c2bf0e29a7823bbbdc
|