@modern-admin/react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/dist/action-guard.d.ts +13 -0
  2. package/dist/action-guard.d.ts.map +1 -0
  3. package/dist/action-guard.js +15 -0
  4. package/dist/action-guard.js.map +1 -0
  5. package/dist/action-menu.d.ts +17 -0
  6. package/dist/action-menu.d.ts.map +1 -0
  7. package/dist/action-menu.jsx +80 -0
  8. package/dist/action-menu.jsx.map +1 -0
  9. package/dist/admin-app.d.ts +23 -0
  10. package/dist/admin-app.d.ts.map +1 -0
  11. package/dist/admin-app.jsx +407 -0
  12. package/dist/admin-app.jsx.map +1 -0
  13. package/dist/admin-router.d.ts +29 -0
  14. package/dist/admin-router.d.ts.map +1 -0
  15. package/dist/admin-router.jsx +215 -0
  16. package/dist/admin-router.jsx.map +1 -0
  17. package/dist/breadcrumbs.d.ts +17 -0
  18. package/dist/breadcrumbs.d.ts.map +1 -0
  19. package/dist/breadcrumbs.jsx +40 -0
  20. package/dist/breadcrumbs.jsx.map +1 -0
  21. package/dist/client.d.ts +526 -0
  22. package/dist/client.d.ts.map +1 -0
  23. package/dist/client.js +582 -0
  24. package/dist/client.js.map +1 -0
  25. package/dist/component-loader.d.ts +10 -0
  26. package/dist/component-loader.d.ts.map +1 -0
  27. package/dist/component-loader.js +23 -0
  28. package/dist/component-loader.js.map +1 -0
  29. package/dist/components/ai-assistant-widget.d.ts +3 -0
  30. package/dist/components/ai-assistant-widget.d.ts.map +1 -0
  31. package/dist/components/ai-assistant-widget.jsx +390 -0
  32. package/dist/components/ai-assistant-widget.jsx.map +1 -0
  33. package/dist/components/ai-fill-dialog.d.ts +9 -0
  34. package/dist/components/ai-fill-dialog.d.ts.map +1 -0
  35. package/dist/components/ai-fill-dialog.jsx +105 -0
  36. package/dist/components/ai-fill-dialog.jsx.map +1 -0
  37. package/dist/components/chart-builder-dialog.d.ts +10 -0
  38. package/dist/components/chart-builder-dialog.d.ts.map +1 -0
  39. package/dist/components/chart-builder-dialog.jsx +433 -0
  40. package/dist/components/chart-builder-dialog.jsx.map +1 -0
  41. package/dist/components/chart-widget.d.ts +12 -0
  42. package/dist/components/chart-widget.d.ts.map +1 -0
  43. package/dist/components/chart-widget.jsx +365 -0
  44. package/dist/components/chart-widget.jsx.map +1 -0
  45. package/dist/components/global-search-dialog.d.ts +7 -0
  46. package/dist/components/global-search-dialog.d.ts.map +1 -0
  47. package/dist/components/global-search-dialog.jsx +187 -0
  48. package/dist/components/global-search-dialog.jsx.map +1 -0
  49. package/dist/components/group-settings-dialog.d.ts +13 -0
  50. package/dist/components/group-settings-dialog.d.ts.map +1 -0
  51. package/dist/components/group-settings-dialog.jsx +53 -0
  52. package/dist/components/group-settings-dialog.jsx.map +1 -0
  53. package/dist/components/move-chart-dialog.d.ts +18 -0
  54. package/dist/components/move-chart-dialog.d.ts.map +1 -0
  55. package/dist/components/move-chart-dialog.jsx +68 -0
  56. package/dist/components/move-chart-dialog.jsx.map +1 -0
  57. package/dist/components/reference-multi-table-dialog.d.ts +12 -0
  58. package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
  59. package/dist/components/reference-multi-table-dialog.jsx +126 -0
  60. package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
  61. package/dist/components/related-records-tabs.d.ts +8 -0
  62. package/dist/components/related-records-tabs.d.ts.map +1 -0
  63. package/dist/components/related-records-tabs.jsx +75 -0
  64. package/dist/components/related-records-tabs.jsx.map +1 -0
  65. package/dist/components/revisions-button.d.ts +7 -0
  66. package/dist/components/revisions-button.d.ts.map +1 -0
  67. package/dist/components/revisions-button.jsx +152 -0
  68. package/dist/components/revisions-button.jsx.map +1 -0
  69. package/dist/components/wizard-form.d.ts +43 -0
  70. package/dist/components/wizard-form.d.ts.map +1 -0
  71. package/dist/components/wizard-form.jsx +136 -0
  72. package/dist/components/wizard-form.jsx.map +1 -0
  73. package/dist/dashboard/time-series.d.ts +20 -0
  74. package/dist/dashboard/time-series.d.ts.map +1 -0
  75. package/dist/dashboard/time-series.js +108 -0
  76. package/dist/dashboard/time-series.js.map +1 -0
  77. package/dist/dialogs.d.ts +35 -0
  78. package/dist/dialogs.d.ts.map +1 -0
  79. package/dist/dialogs.jsx +152 -0
  80. package/dist/dialogs.jsx.map +1 -0
  81. package/dist/export.d.ts +39 -0
  82. package/dist/export.d.ts.map +1 -0
  83. package/dist/export.js +114 -0
  84. package/dist/export.js.map +1 -0
  85. package/dist/extension-registry.d.ts +122 -0
  86. package/dist/extension-registry.d.ts.map +1 -0
  87. package/dist/extension-registry.js +93 -0
  88. package/dist/extension-registry.js.map +1 -0
  89. package/dist/header-controls.d.ts +4 -0
  90. package/dist/header-controls.d.ts.map +1 -0
  91. package/dist/header-controls.jsx +70 -0
  92. package/dist/header-controls.jsx.map +1 -0
  93. package/dist/hooks.d.ts +104 -0
  94. package/dist/hooks.d.ts.map +1 -0
  95. package/dist/hooks.js +374 -0
  96. package/dist/hooks.js.map +1 -0
  97. package/dist/hotkey-help.d.ts +3 -0
  98. package/dist/hotkey-help.d.ts.map +1 -0
  99. package/dist/hotkey-help.jsx +32 -0
  100. package/dist/hotkey-help.jsx.map +1 -0
  101. package/dist/hotkey-registry.d.ts +18 -0
  102. package/dist/hotkey-registry.d.ts.map +1 -0
  103. package/dist/hotkey-registry.jsx +34 -0
  104. package/dist/hotkey-registry.jsx.map +1 -0
  105. package/dist/i18n.d.ts +74 -0
  106. package/dist/i18n.d.ts.map +1 -0
  107. package/dist/i18n.jsx +127 -0
  108. package/dist/i18n.jsx.map +1 -0
  109. package/dist/index.d.ts +35 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +36 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/notify.d.ts +41 -0
  114. package/dist/notify.d.ts.map +1 -0
  115. package/dist/notify.jsx +58 -0
  116. package/dist/notify.jsx.map +1 -0
  117. package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
  118. package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
  119. package/dist/pages/ai-assistant-settings-section.jsx +126 -0
  120. package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
  121. package/dist/pages/audit-log-page.d.ts +3 -0
  122. package/dist/pages/audit-log-page.d.ts.map +1 -0
  123. package/dist/pages/audit-log-page.jsx +354 -0
  124. package/dist/pages/audit-log-page.jsx.map +1 -0
  125. package/dist/pages/edit-page.d.ts +7 -0
  126. package/dist/pages/edit-page.d.ts.map +1 -0
  127. package/dist/pages/edit-page.jsx +614 -0
  128. package/dist/pages/edit-page.jsx.map +1 -0
  129. package/dist/pages/export-dialog.d.ts +11 -0
  130. package/dist/pages/export-dialog.d.ts.map +1 -0
  131. package/dist/pages/export-dialog.jsx +102 -0
  132. package/dist/pages/export-dialog.jsx.map +1 -0
  133. package/dist/pages/home-page.d.ts +3 -0
  134. package/dist/pages/home-page.d.ts.map +1 -0
  135. package/dist/pages/home-page.jsx +211 -0
  136. package/dist/pages/home-page.jsx.map +1 -0
  137. package/dist/pages/list-page.d.ts +42 -0
  138. package/dist/pages/list-page.d.ts.map +1 -0
  139. package/dist/pages/list-page.jsx +1596 -0
  140. package/dist/pages/list-page.jsx.map +1 -0
  141. package/dist/pages/login-page.d.ts +11 -0
  142. package/dist/pages/login-page.d.ts.map +1 -0
  143. package/dist/pages/login-page.jsx +157 -0
  144. package/dist/pages/login-page.jsx.map +1 -0
  145. package/dist/pages/settings-page.d.ts +5 -0
  146. package/dist/pages/settings-page.d.ts.map +1 -0
  147. package/dist/pages/settings-page.jsx +787 -0
  148. package/dist/pages/settings-page.jsx.map +1 -0
  149. package/dist/pages/settings-shared.d.ts +51 -0
  150. package/dist/pages/settings-shared.d.ts.map +1 -0
  151. package/dist/pages/settings-shared.jsx +66 -0
  152. package/dist/pages/settings-shared.jsx.map +1 -0
  153. package/dist/pages/show-page.d.ts +7 -0
  154. package/dist/pages/show-page.d.ts.map +1 -0
  155. package/dist/pages/show-page.jsx +147 -0
  156. package/dist/pages/show-page.jsx.map +1 -0
  157. package/dist/pages/wizard-create-page.d.ts +14 -0
  158. package/dist/pages/wizard-create-page.d.ts.map +1 -0
  159. package/dist/pages/wizard-create-page.jsx +106 -0
  160. package/dist/pages/wizard-create-page.jsx.map +1 -0
  161. package/dist/property-renderer.d.ts +8 -0
  162. package/dist/property-renderer.d.ts.map +1 -0
  163. package/dist/property-renderer.jsx +690 -0
  164. package/dist/property-renderer.jsx.map +1 -0
  165. package/dist/provider.d.ts +20 -0
  166. package/dist/provider.d.ts.map +1 -0
  167. package/dist/provider.jsx +32 -0
  168. package/dist/provider.jsx.map +1 -0
  169. package/dist/realtime.d.ts +22 -0
  170. package/dist/realtime.d.ts.map +1 -0
  171. package/dist/realtime.js +38 -0
  172. package/dist/realtime.js.map +1 -0
  173. package/dist/reference.d.ts +52 -0
  174. package/dist/reference.d.ts.map +1 -0
  175. package/dist/reference.jsx +224 -0
  176. package/dist/reference.jsx.map +1 -0
  177. package/dist/relations.d.ts +11 -0
  178. package/dist/relations.d.ts.map +1 -0
  179. package/dist/relations.js +36 -0
  180. package/dist/relations.js.map +1 -0
  181. package/dist/router.d.ts +82 -0
  182. package/dist/router.d.ts.map +1 -0
  183. package/dist/router.jsx +187 -0
  184. package/dist/router.jsx.map +1 -0
  185. package/dist/show-when.d.ts +7 -0
  186. package/dist/show-when.d.ts.map +1 -0
  187. package/dist/show-when.js +77 -0
  188. package/dist/show-when.js.map +1 -0
  189. package/dist/types.d.ts +194 -0
  190. package/dist/types.d.ts.map +1 -0
  191. package/dist/types.js +18 -0
  192. package/dist/types.js.map +1 -0
  193. package/dist/use-dashboard-charts.d.ts +93 -0
  194. package/dist/use-dashboard-charts.d.ts.map +1 -0
  195. package/dist/use-dashboard-charts.js +263 -0
  196. package/dist/use-dashboard-charts.js.map +1 -0
  197. package/dist/use-hotkey.d.ts +17 -0
  198. package/dist/use-hotkey.d.ts.map +1 -0
  199. package/dist/use-hotkey.js +103 -0
  200. package/dist/use-hotkey.js.map +1 -0
  201. package/dist/user-directory.d.ts +18 -0
  202. package/dist/user-directory.d.ts.map +1 -0
  203. package/dist/user-directory.js +51 -0
  204. package/dist/user-directory.js.map +1 -0
  205. package/dist/validation.d.ts +22 -0
  206. package/dist/validation.d.ts.map +1 -0
  207. package/dist/validation.js +338 -0
  208. package/dist/validation.js.map +1 -0
  209. package/package.json +59 -0
  210. package/src/action-guard.ts +20 -0
  211. package/src/action-menu.tsx +161 -0
  212. package/src/admin-app.tsx +630 -0
  213. package/src/admin-router.tsx +273 -0
  214. package/src/breadcrumbs.tsx +75 -0
  215. package/src/client.ts +1093 -0
  216. package/src/component-loader.ts +33 -0
  217. package/src/components/ai-assistant-widget.tsx +565 -0
  218. package/src/components/ai-fill-dialog.tsx +143 -0
  219. package/src/components/chart-builder-dialog.tsx +618 -0
  220. package/src/components/chart-widget.tsx +654 -0
  221. package/src/components/global-search-dialog.tsx +272 -0
  222. package/src/components/group-settings-dialog.tsx +93 -0
  223. package/src/components/move-chart-dialog.tsx +130 -0
  224. package/src/components/reference-multi-table-dialog.tsx +196 -0
  225. package/src/components/related-records-tabs.tsx +130 -0
  226. package/src/components/revisions-button.tsx +237 -0
  227. package/src/components/wizard-form.tsx +302 -0
  228. package/src/dashboard/time-series.ts +125 -0
  229. package/src/dialogs.tsx +265 -0
  230. package/src/export.ts +140 -0
  231. package/src/extension-registry.ts +195 -0
  232. package/src/header-controls.tsx +125 -0
  233. package/src/hooks.ts +509 -0
  234. package/src/hotkey-help.tsx +56 -0
  235. package/src/hotkey-registry.tsx +60 -0
  236. package/src/i18n.tsx +267 -0
  237. package/src/index.ts +192 -0
  238. package/src/notify.tsx +94 -0
  239. package/src/pages/ai-assistant-settings-section.tsx +167 -0
  240. package/src/pages/audit-log-page.tsx +580 -0
  241. package/src/pages/edit-page.tsx +743 -0
  242. package/src/pages/export-dialog.tsx +154 -0
  243. package/src/pages/home-page.tsx +318 -0
  244. package/src/pages/list-page.tsx +2645 -0
  245. package/src/pages/login-page.tsx +242 -0
  246. package/src/pages/settings-page.tsx +1143 -0
  247. package/src/pages/settings-shared.tsx +134 -0
  248. package/src/pages/show-page.tsx +223 -0
  249. package/src/pages/wizard-create-page.tsx +164 -0
  250. package/src/property-renderer.tsx +1143 -0
  251. package/src/provider.tsx +70 -0
  252. package/src/realtime.ts +55 -0
  253. package/src/reference.tsx +386 -0
  254. package/src/relations.ts +55 -0
  255. package/src/router.tsx +211 -0
  256. package/src/show-when.ts +76 -0
  257. package/src/types.ts +198 -0
  258. package/src/use-dashboard-charts.ts +362 -0
  259. package/src/use-hotkey.ts +128 -0
  260. package/src/user-directory.ts +56 -0
  261. package/src/validation.ts +361 -0
@@ -0,0 +1,134 @@
1
+ // Shared layout primitives for the Settings hub. Every section in
2
+ // `settings-page.tsx` (API keys, webhooks, AI assistant, ...) should use
3
+ // these so the look, spacing, and mobile behavior stays uniform.
4
+
5
+ import * as React from 'react'
6
+ import {
7
+ Card,
8
+ CardContent,
9
+ CardDescription,
10
+ CardHeader,
11
+ CardTitle,
12
+ Empty,
13
+ EmptyDescription,
14
+ EmptyHeader,
15
+ EmptyMedia,
16
+ EmptyTitle,
17
+ cn,
18
+ } from '@modern-admin/ui'
19
+
20
+ type IconComponent = React.ComponentType<{ className?: string }>
21
+
22
+ interface SettingsCardProps {
23
+ icon: IconComponent
24
+ title: React.ReactNode
25
+ description?: React.ReactNode
26
+ action?: React.ReactNode
27
+ /** Removes default padding/header so the card body can fill edge-to-edge. */
28
+ bodyClassName?: string
29
+ children: React.ReactNode
30
+ }
31
+
32
+ /**
33
+ * Unified card wrapper for a settings section. Header collapses
34
+ * vertically on mobile and lays the action button to the right on `sm+`.
35
+ * `CardContent` gets `min-w-0` so tables/grids inside can shrink and
36
+ * scroll horizontally instead of forcing the parent grid wider.
37
+ */
38
+ export function SettingsCard({
39
+ icon: Icon,
40
+ title,
41
+ description,
42
+ action,
43
+ bodyClassName,
44
+ children,
45
+ }: SettingsCardProps): React.ReactElement {
46
+ return (
47
+ <Card>
48
+ <CardHeader
49
+ className={cn(
50
+ 'flex-col items-start gap-2',
51
+ action && 'sm:flex-row sm:items-center sm:justify-between',
52
+ )}
53
+ >
54
+ <div className="flex min-w-0 flex-col gap-1.5">
55
+ <CardTitle className="flex items-center gap-2">
56
+ <Icon className="size-5" />
57
+ {title}
58
+ </CardTitle>
59
+ {description ? <CardDescription>{description}</CardDescription> : null}
60
+ </div>
61
+ {action}
62
+ </CardHeader>
63
+ <CardContent className={cn('min-w-0', bodyClassName)}>{children}</CardContent>
64
+ </Card>
65
+ )
66
+ }
67
+
68
+ /**
69
+ * Horizontal-scroll wrapper for tables inside `SettingsCard`. On mobile
70
+ * the content extends to the card edges (`-mx-6`) so the user sees the
71
+ * left edge of the table flush with the card; from `sm+` the negative
72
+ * margin is removed.
73
+ */
74
+ export function SettingsTableScroll({ children }: { children: React.ReactNode }): React.ReactElement {
75
+ return <div className="-mx-6 overflow-x-auto sm:mx-0">{children}</div>
76
+ }
77
+
78
+ interface SettingsEmptyProps {
79
+ icon: IconComponent
80
+ title: React.ReactNode
81
+ description?: React.ReactNode
82
+ }
83
+
84
+ export function SettingsEmpty({ icon: Icon, title, description }: SettingsEmptyProps): React.ReactElement {
85
+ return (
86
+ <Empty className="border-0">
87
+ <EmptyHeader>
88
+ <EmptyMedia>
89
+ <Icon />
90
+ </EmptyMedia>
91
+ <EmptyTitle>{title}</EmptyTitle>
92
+ {description ? <EmptyDescription>{description}</EmptyDescription> : null}
93
+ </EmptyHeader>
94
+ </Empty>
95
+ )
96
+ }
97
+
98
+ interface SettingsListStateProps {
99
+ isLoading: boolean
100
+ error: unknown
101
+ isEmpty: boolean
102
+ loadingLabel: React.ReactNode
103
+ empty: SettingsEmptyProps
104
+ children: React.ReactNode
105
+ }
106
+
107
+ /**
108
+ * Renders one of: loading row, destructive error banner, empty state, or
109
+ * the actual list `children`. Keeps every section's "list with status"
110
+ * surface identical.
111
+ */
112
+ export function SettingsListState({
113
+ isLoading,
114
+ error,
115
+ isEmpty,
116
+ loadingLabel,
117
+ empty,
118
+ children,
119
+ }: SettingsListStateProps): React.ReactElement {
120
+ if (isLoading) {
121
+ return <div className="py-8 text-center text-sm text-muted-foreground">{loadingLabel}</div>
122
+ }
123
+ if (error) {
124
+ return (
125
+ <div className="rounded-md border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
126
+ {error instanceof Error ? error.message : String(error)}
127
+ </div>
128
+ )
129
+ }
130
+ if (isEmpty) {
131
+ return <SettingsEmpty {...empty} />
132
+ }
133
+ return <>{children}</>
134
+ }
@@ -0,0 +1,223 @@
1
+ import * as React from 'react'
2
+ import {
3
+ Button,
4
+ Card,
5
+ CardContent,
6
+ CardHeader,
7
+ CardTitle,
8
+ Kbd,
9
+ Tooltip,
10
+ TooltipContent,
11
+ TooltipTrigger,
12
+ getModKeyLabel,
13
+ } from '@modern-admin/ui'
14
+ import { AlertCircle, Pencil, Trash2, Zap } from 'lucide-react'
15
+ import { useDeleteRecord, useFeatures, useInvokeRecordAction, useRecord, useResource } from '../hooks.js'
16
+ import { confirmGuard } from '../action-guard.js'
17
+ import { parseApiError } from '../client.js'
18
+ import { PropertyDisplay } from '../property-renderer.js'
19
+ import { Link, useNavigate } from '../router.js'
20
+ import { useI18n } from '../i18n.js'
21
+ import { useHotkey } from '../use-hotkey.js'
22
+ import { PageBreadcrumbs, homeCrumb } from '../breadcrumbs.js'
23
+ import { RelatedRecordsTabs } from '../components/related-records-tabs.js'
24
+ import { useDialogs } from '../dialogs.js'
25
+ import { useNotify } from '../notify.js'
26
+ import { ActionMenu } from '../action-menu.js'
27
+ import { RevisionsButton } from '../components/revisions-button.js'
28
+ import { visibleRecordProperties } from '../relations.js'
29
+
30
+ function PageError({
31
+ error,
32
+ t,
33
+ }: {
34
+ error: unknown
35
+ t: (key: string) => string
36
+ }): React.ReactElement {
37
+ const { status, message } = parseApiError(error)
38
+ const title =
39
+ status === 404
40
+ ? t('errors:notFound')
41
+ : status === 403
42
+ ? t('errors:forbidden')
43
+ : t('errors:server')
44
+ return (
45
+ <div className="flex items-start gap-3 rounded-lg border border-destructive/50 bg-destructive/10 p-4 dark:bg-destructive/15">
46
+ <AlertCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
47
+ <div className="space-y-1 text-sm">
48
+ <p className="font-semibold text-destructive">{title}</p>
49
+ <p className="text-destructive/90">{message}</p>
50
+ </div>
51
+ </div>
52
+ )
53
+ }
54
+
55
+ export interface ResourceShowPageProps {
56
+ resourceId: string
57
+ recordId: string
58
+ }
59
+
60
+ export function ResourceShowPage({
61
+ resourceId,
62
+ recordId,
63
+ }: ResourceShowPageProps): React.ReactElement {
64
+ const resource = useResource(resourceId)
65
+ const record = useRecord(resourceId, recordId)
66
+ const remove = useDeleteRecord(resourceId)
67
+ const invokeRecord = useInvokeRecordAction(resourceId)
68
+ const features = useFeatures()
69
+ const { t } = useI18n()
70
+ const navigate = useNavigate()
71
+ const dialogs = useDialogs()
72
+ const notify = useNotify()
73
+
74
+ const customRecordActions = (resource?.actions ?? []).filter(
75
+ (a) => a.actionType === 'record' && !['show', 'edit', 'delete'].includes(a.name),
76
+ )
77
+
78
+ // ── Keyboard shortcuts ──
79
+ // Ctrl/Cmd+E jumps into edit. Discoverable via the action-button tooltip.
80
+ useHotkey(
81
+ 'mod+e',
82
+ () => {
83
+ if (!record.data) return
84
+ navigate({ name: 'edit', resourceId, recordId })
85
+ },
86
+ { description: t('common:edit') },
87
+ )
88
+
89
+ const handleDelete = async (): Promise<void> => {
90
+ const ok = await dialogs.confirm({
91
+ title: t('common:confirmDelete'),
92
+ confirmLabel: t('common:delete'),
93
+ destructive: true,
94
+ })
95
+ if (!ok) return
96
+ await remove.mutateAsync(recordId)
97
+ navigate({ name: 'list', resourceId })
98
+ }
99
+
100
+ if (!resource) return <div className="p-6">{t('common:loading')}</div>
101
+
102
+ const modLabel = getModKeyLabel()
103
+ const recordLabel = record.data?.record?.title || recordId
104
+
105
+ return (
106
+ <div className="space-y-2 sm:space-y-4">
107
+ <PageBreadcrumbs
108
+ items={[
109
+ homeCrumb(t('common:home')),
110
+ { label: resource.name, to: { name: 'list', resourceId } },
111
+ { label: recordLabel },
112
+ ]}
113
+ />
114
+ <Card>
115
+ <CardHeader className="flex flex-row items-center justify-between gap-3">
116
+ <CardTitle className="truncate">
117
+ {resource.name} #{recordId}
118
+ </CardTitle>
119
+ {record.data && (
120
+ <div className="flex shrink-0 flex-wrap gap-2">
121
+ {features.history && (
122
+ <RevisionsButton resourceId={resourceId} recordId={recordId} />
123
+ )}
124
+ <Tooltip>
125
+ <TooltipTrigger asChild>
126
+ {/* `asChild` + Link-as-Button keeps the rendered DOM a
127
+ * single `<a>` so it picks up the Button's `h-8` from
128
+ * `size="sm"` instead of stacking a Link wrapper that
129
+ * collapses to its anchor default height. */}
130
+ <Button variant="outline-primary" size="sm" asChild>
131
+ <Link to={{ name: 'edit', resourceId, recordId }} aria-label={t('common:edit')}>
132
+ <Pencil className="size-4" />
133
+ <span className="hidden sm:inline">{t('common:edit')}</span>
134
+ </Link>
135
+ </Button>
136
+ </TooltipTrigger>
137
+ <TooltipContent className="flex items-center gap-1.5">
138
+ <span>{t('common:edit')}</span>
139
+ <span className="inline-flex items-center gap-0.5">
140
+ <Kbd>{modLabel}</Kbd>
141
+ <span className="text-muted-foreground">+</span>
142
+ <Kbd>E</Kbd>
143
+ </span>
144
+ </TooltipContent>
145
+ </Tooltip>
146
+ <Button
147
+ variant="outline-destructive"
148
+ size="sm"
149
+ disabled={remove.isPending}
150
+ onClick={() => void handleDelete()}
151
+ aria-label={t('common:delete')}
152
+ >
153
+ <Trash2 className="size-4" />
154
+ <span className="hidden sm:inline">{t('common:delete')}</span>
155
+ </Button>
156
+ {customRecordActions.length > 0 && (
157
+ <ActionMenu
158
+ actions={customRecordActions}
159
+ onAction={async (action) => {
160
+ if (!await confirmGuard(action, dialogs)) return
161
+ void invokeRecord
162
+ .mutateAsync({ recordId, actionName: action.name })
163
+ .then((res) => {
164
+ if (res.notice) {
165
+ const type = res.notice.type === 'error' ? 'error'
166
+ : res.notice.type === 'warning' ? 'warning'
167
+ : res.notice.type === 'info' ? 'info'
168
+ : 'success'
169
+ notify[type]({ message: res.notice.message })
170
+ }
171
+ })
172
+ .catch((err: Error) =>
173
+ notify.error({ message: err.message }),
174
+ )
175
+ }}
176
+ t={t}
177
+ trigger={(
178
+ <Button
179
+ variant="outline"
180
+ size="sm"
181
+ disabled={invokeRecord.isPending}
182
+ aria-label={t('common:actions')}
183
+ >
184
+ <Zap className="size-4" />
185
+ <span className="hidden sm:inline">{t('common:actions')}</span>
186
+ </Button>
187
+ )}
188
+ />
189
+ )}
190
+ </div>
191
+ )}
192
+ </CardHeader>
193
+ <CardContent>
194
+ <div className="space-y-4">
195
+ {record.isLoading && <p className="text-muted-foreground">{t('common:loading')}</p>}
196
+ {record.isError && <PageError error={record.error} t={t} />}
197
+ {record.data && (
198
+ <dl className="[column-fill:_balance] md:columns-2">
199
+ {visibleRecordProperties(resource.properties, 'show')
200
+ .map((p) => (
201
+ <div key={p.path} className="mb-8 break-inside-avoid">
202
+ <dt className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
203
+ {p.label}
204
+ </dt>
205
+ <dd className="mt-1">
206
+ <PropertyDisplay
207
+ property={p}
208
+ value={record.data!.record.params[p.path]}
209
+ view="show"
210
+ populated={record.data!.record.populated as Record<string, unknown> | undefined}
211
+ />
212
+ </dd>
213
+ </div>
214
+ ))}
215
+ </dl>
216
+ )}
217
+ </div>
218
+ </CardContent>
219
+ </Card>
220
+ {record.data && <RelatedRecordsTabs resource={resource} recordId={recordId} />}
221
+ </div>
222
+ )
223
+ }
@@ -0,0 +1,164 @@
1
+ // ResourceWizardCreatePage — multi-step creation form for a resource.
2
+ // Analogous to ResourceEditPage but splits the fields across declarative
3
+ // `steps`, each validated independently before advancing. Submit on the final
4
+ // step creates the record and navigates to its show page.
5
+
6
+ import * as React from 'react'
7
+ import { useForm, type SubmitHandler } from 'react-hook-form'
8
+ import { zodResolver } from '@hookform/resolvers/zod'
9
+ import { Card, CardHeader, CardTitle, Form } from '@modern-admin/ui'
10
+ import { useCreateRecord, useResource } from '../hooks.js'
11
+ import { useNavigate } from '../router.js'
12
+ import { useI18n } from '../i18n.js'
13
+ import { useNotify } from '../notify.js'
14
+ import { PageBreadcrumbs, homeCrumb } from '../breadcrumbs.js'
15
+ import { buildValidationSchema, defaultValueFor } from '../validation.js'
16
+ import type { PropertyJSON } from '../types.js'
17
+ import { visibleRecordProperties } from '../relations.js'
18
+ import {
19
+ WizardForm,
20
+ type WizardStep,
21
+ type WizardFormLabels,
22
+ } from '../components/wizard-form.js'
23
+
24
+ export interface ResourceWizardCreatePageProps {
25
+ resourceId: string
26
+ /** Step definitions. Each step declares which property paths it shows. */
27
+ steps: WizardStep[]
28
+ /**
29
+ * Override wizard button labels. Falls back to locale translations.
30
+ * Useful when embedding the page with a custom i18n setup.
31
+ */
32
+ labels?: WizardFormLabels
33
+ }
34
+
35
+ type FormValues = Record<string, unknown>
36
+
37
+ export function ResourceWizardCreatePage({
38
+ resourceId,
39
+ steps,
40
+ labels: labelsProp,
41
+ }: ResourceWizardCreatePageProps): React.ReactElement {
42
+ const resource = useResource(resourceId)
43
+ const create = useCreateRecord(resourceId)
44
+ const navigate = useNavigate()
45
+ const { t, locale } = useI18n()
46
+ const notify = useNotify()
47
+
48
+ const editable = React.useMemo<PropertyJSON[]>(
49
+ () =>
50
+ resource
51
+ ? visibleRecordProperties(resource.properties, 'edit').filter(
52
+ (p) => !p.isDisabled,
53
+ )
54
+ : [],
55
+ [resource],
56
+ )
57
+
58
+ // Route live form values into the validation schema (same pattern as edit-page).
59
+ const getValuesRef = React.useRef<() => FormValues>(() => ({}))
60
+
61
+ const schema = React.useMemo(
62
+ () => buildValidationSchema(editable, t, () => getValuesRef.current()),
63
+ // eslint-disable-next-line react-hooks/exhaustive-deps
64
+ [editable, locale],
65
+ )
66
+
67
+ const defaults = React.useMemo<FormValues>(() => {
68
+ const out: FormValues = {}
69
+ for (const p of editable) out[p.path] = defaultValueFor(p)
70
+ return out
71
+ }, [editable])
72
+
73
+ const form = useForm<FormValues>({
74
+ resolver: zodResolver(schema),
75
+ defaultValues: defaults,
76
+ })
77
+
78
+ getValuesRef.current = form.getValues
79
+
80
+ // Reset when the resource schema arrives (resource loaded after mount).
81
+ React.useEffect(() => {
82
+ form.reset(defaults)
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ }, [defaults])
85
+
86
+ const [submitError, setSubmitError] = React.useState<string | null>(null)
87
+
88
+ const onSubmit: SubmitHandler<FormValues> = async (values) => {
89
+ setSubmitError(null)
90
+ try {
91
+ const result = await create.mutateAsync(values)
92
+ const errors = result.record.errors as Record<
93
+ string,
94
+ { message?: string } | string
95
+ >
96
+ if (errors && Object.keys(errors).length > 0) {
97
+ for (const [path, err] of Object.entries(errors)) {
98
+ const message =
99
+ typeof err === 'string' ? err : (err?.message ?? 'Invalid value')
100
+ form.setError(path, { type: 'server', message })
101
+ }
102
+ if (result.record.baseError) setSubmitError(String(result.record.baseError))
103
+ notify.error({ key: 'toast:validationFailed' })
104
+ return
105
+ }
106
+ notify.success({ key: 'toast:created' })
107
+ navigate({ name: 'show', resourceId, recordId: String(result.record.id) })
108
+ } catch (err) {
109
+ const message = err instanceof Error ? err.message : String(err)
110
+ setSubmitError(message)
111
+ notify.error({ key: 'toast:createFailed' }, { description: message })
112
+ }
113
+ }
114
+
115
+ const handleSubmit = (): void => {
116
+ void form.handleSubmit(onSubmit, () => {
117
+ notify.error({ key: 'toast:validationFailed' })
118
+ })()
119
+ }
120
+
121
+ if (!resource) return <div className="p-6">{t('common:loading')}</div>
122
+
123
+ const labels: WizardFormLabels = {
124
+ back: t('common:back'),
125
+ next: t('common:next'),
126
+ submit: t('common:create'),
127
+ cancel: t('common:cancel'),
128
+ stepOf: t('wizard:stepOf'),
129
+ ...labelsProp,
130
+ }
131
+
132
+ const crumbs = [
133
+ homeCrumb(t('common:home')),
134
+ { label: resource.name, to: { name: 'list' as const, resourceId } },
135
+ { label: t('common:new') },
136
+ ]
137
+
138
+ return (
139
+ <div className="flex min-h-full flex-col gap-4">
140
+ <PageBreadcrumbs items={crumbs} />
141
+ <Card className="flex-1">
142
+ <CardHeader>
143
+ <CardTitle className="truncate">
144
+ {t('common:newRecord', { name: resource.name })}
145
+ </CardTitle>
146
+ </CardHeader>
147
+ <Form {...form}>
148
+ <WizardForm
149
+ steps={steps}
150
+ properties={editable}
151
+ resourceId={resourceId}
152
+ control={form.control}
153
+ trigger={form.trigger}
154
+ onSubmit={handleSubmit}
155
+ onCancel={() => navigate({ name: 'list', resourceId })}
156
+ isSubmitting={form.formState.isSubmitting}
157
+ submitError={submitError}
158
+ labels={labels}
159
+ />
160
+ </Form>
161
+ </Card>
162
+ </div>
163
+ )
164
+ }