@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,130 @@
1
+ // Tabs of records that reference the open record through a foreign key.
2
+ // Each tab embeds the full ResourceListPage filtered by a locked
3
+ // `{ [foreignKey]: parentRecordId }` so it gets the same table, filters,
4
+ // bulk actions, pagination, and row-click behaviour as the main list.
5
+ //
6
+ // Designed to live below the property card on the show page.
7
+
8
+ import * as React from 'react'
9
+ import {
10
+ Card,
11
+ CardContent,
12
+ CardHeader,
13
+ CardTitle,
14
+ Tabs,
15
+ TabsContent,
16
+ TabsList,
17
+ TabsTrigger,
18
+ } from '@modern-admin/ui'
19
+ import { useResources } from '../hooks.js'
20
+ import { useI18n } from '../i18n.js'
21
+ import { ResourceListPage } from '../pages/list-page.js'
22
+ import { resolveRelatedResources } from '../relations.js'
23
+ import type { ListQueryState } from '../router.js'
24
+ import type { RelatedResource, ResourceJSON } from '../types.js'
25
+
26
+ interface RelatedRecordsTabProps {
27
+ parentRecordId: string
28
+ related: RelatedResource
29
+ active: boolean
30
+ }
31
+
32
+ function RelatedRecordsTab({
33
+ parentRecordId,
34
+ related,
35
+ active,
36
+ }: RelatedRecordsTabProps): React.ReactElement | null {
37
+ // Each tab keeps its own page/sort/filter state. Defaults: page 1, perPage 10
38
+ // so embedded tables stay compact compared to the main list (default 20).
39
+ const [query, setQuery] = React.useState<ListQueryState>({ perPage: 10 })
40
+
41
+ // Lazy-load tab content: only mount the (heavy) ResourceListPage once the
42
+ // user actually visits the tab. Subsequent toggles keep state thanks to
43
+ // Radix's mount-on-activate semantics on TabsContent (we don't force unmount).
44
+ const [hasBeenActive, setHasBeenActive] = React.useState(active)
45
+ React.useEffect(() => {
46
+ if (active) setHasBeenActive(true)
47
+ }, [active])
48
+
49
+ if (!hasBeenActive) return null
50
+
51
+ return (
52
+ <ResourceListPage
53
+ resourceId={related.resourceId}
54
+ query={query}
55
+ onQueryChange={setQuery}
56
+ lockedFilters={{ [related.foreignKey]: parentRecordId }}
57
+ features={{
58
+ breadcrumbs: false,
59
+ title: false,
60
+ create: false,
61
+ export: false,
62
+ card: false,
63
+ }}
64
+ />
65
+ )
66
+ }
67
+
68
+ export interface RelatedRecordsTabsProps {
69
+ resource: ResourceJSON
70
+ recordId: string
71
+ }
72
+
73
+ export function RelatedRecordsTabs({
74
+ resource,
75
+ recordId,
76
+ }: RelatedRecordsTabsProps): React.ReactElement | null {
77
+ const { t } = useI18n()
78
+ const allResources = useResources()
79
+ const tabs = React.useMemo(
80
+ () => resolveRelatedResources(resource, allResources),
81
+ [resource, allResources],
82
+ )
83
+ // Hooks must run before any early return — call useState unconditionally.
84
+ const [active, setActive] = React.useState<string>(
85
+ () => tabs[0] ? `${tabs[0].resourceId}::${tabs[0].foreignKey}` : '',
86
+ )
87
+ React.useEffect(() => {
88
+ if (tabs.length === 0) return
89
+ if (tabs.some((r) => `${r.resourceId}::${r.foreignKey}` === active)) return
90
+ setActive(`${tabs[0]!.resourceId}::${tabs[0]!.foreignKey}`)
91
+ }, [active, tabs])
92
+ if (tabs.length === 0) return null
93
+
94
+ const labelFor = (r: RelatedResource): string =>
95
+ r.label ?? allResources.find((x) => x.id === r.resourceId)?.name ?? r.resourceId
96
+
97
+ return (
98
+ <Card>
99
+ <CardHeader>
100
+ <CardTitle className="text-base">{t('common:relatedRecords')}</CardTitle>
101
+ </CardHeader>
102
+ <CardContent>
103
+ <Tabs value={active} onValueChange={setActive}>
104
+ <TabsList className="w-full justify-start">
105
+ {tabs.map((r) => {
106
+ const key = `${r.resourceId}::${r.foreignKey}`
107
+ return (
108
+ <TabsTrigger key={key} value={key}>
109
+ {labelFor(r)}
110
+ </TabsTrigger>
111
+ )
112
+ })}
113
+ </TabsList>
114
+ {tabs.map((r) => {
115
+ const key = `${r.resourceId}::${r.foreignKey}`
116
+ return (
117
+ <TabsContent key={key} value={key} className="mt-4">
118
+ <RelatedRecordsTab
119
+ parentRecordId={recordId}
120
+ related={r}
121
+ active={active === key}
122
+ />
123
+ </TabsContent>
124
+ )
125
+ })}
126
+ </Tabs>
127
+ </CardContent>
128
+ </Card>
129
+ )
130
+ }
@@ -0,0 +1,237 @@
1
+ import * as React from 'react'
2
+ import {
3
+ Button,
4
+ DiffView,
5
+ RevisionTimeline,
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ Sheet,
12
+ SheetContent,
13
+ SheetHeader,
14
+ SheetTitle,
15
+ SheetTrigger,
16
+ Skeleton,
17
+ } from '@modern-admin/ui'
18
+ import { History, RotateCcw } from 'lucide-react'
19
+ import { diffSnapshots } from '@modern-admin/core'
20
+ import { useRecordHistory, useResource, useRevertRevision } from '../hooks.js'
21
+ import { useI18n } from '../i18n.js'
22
+ import { useDialogs } from '../dialogs.js'
23
+ import { useNotify } from '../notify.js'
24
+ import { useUserDirectory, userLabelOf } from '../user-directory.js'
25
+ import type { HistoryDiffEntry, HistoryRevision } from '../client.js'
26
+
27
+ export interface RevisionsButtonProps {
28
+ resourceId: string
29
+ recordId: string
30
+ }
31
+
32
+ export function RevisionsButton({
33
+ resourceId,
34
+ recordId,
35
+ }: RevisionsButtonProps): React.ReactElement {
36
+ const { t, locale } = useI18n()
37
+ const resource = useResource(resourceId)
38
+ const history = useRecordHistory(resourceId, recordId, { limit: 50 })
39
+ const revert = useRevertRevision(resourceId, recordId)
40
+ const dialogs = useDialogs()
41
+ const notify = useNotify()
42
+ const [open, setOpen] = React.useState(false)
43
+ const revisions = React.useMemo(
44
+ () => history.data?.revisions ?? [],
45
+ [history.data?.revisions],
46
+ )
47
+ // Resolve revision authors to human-readable labels (email / name)
48
+ // instead of showing raw user UUIDs.
49
+ const userIds = React.useMemo(
50
+ () => Array.from(new Set(revisions.map((r) => r.userId).filter((v): v is string => !!v))),
51
+ [revisions],
52
+ )
53
+ const users = useUserDirectory(userIds)
54
+ const labelForUser = React.useCallback(
55
+ (userId: string | undefined): string | undefined =>
56
+ userId ? userLabelOf(users.get(userId), userId) : undefined,
57
+ [users],
58
+ )
59
+ const [selectedId, setSelectedId] = React.useState<string | undefined>()
60
+ const [compareToId, setCompareToId] = React.useState<string>('')
61
+ const selected = revisions.find((r) => r.id === selectedId) ?? revisions[0]
62
+ const compareTo = compareToId
63
+ ? revisions.find((r) => r.id === compareToId)
64
+ : undefined
65
+
66
+ // Build a path → label map from the resource schema so each diff field
67
+ // can show a human-readable name before its technical path.
68
+ const labelByPath = React.useMemo<Record<string, string>>(() => {
69
+ const map: Record<string, string> = {}
70
+ for (const p of resource?.properties ?? []) map[p.path] = p.label
71
+ return map
72
+ }, [resource])
73
+
74
+ const withLabels = React.useCallback(
75
+ (fields: ReturnType<typeof diffSnapshots>) =>
76
+ fields.map((f) => ({ ...f, label: labelByPath[f.path] })),
77
+ [labelByPath],
78
+ )
79
+
80
+ const visibleFields = selected
81
+ ? withLabels(
82
+ compareTo
83
+ ? diffSnapshots(compareTo.snapshot, selected.snapshot)
84
+ : fieldsFor(selected),
85
+ )
86
+ : []
87
+
88
+ React.useEffect(() => {
89
+ if (!selectedId && revisions[0]) setSelectedId(revisions[0].id)
90
+ }, [revisions, selectedId])
91
+
92
+ React.useEffect(() => {
93
+ if (selected?.id && compareToId === selected.id) setCompareToId('')
94
+ }, [compareToId, selected?.id])
95
+
96
+ const formatDate = React.useCallback(
97
+ (value: string) => new Intl.DateTimeFormat(locale, {
98
+ dateStyle: 'medium',
99
+ timeStyle: 'short',
100
+ }).format(new Date(value)),
101
+ [locale],
102
+ )
103
+
104
+ const handleRevert = async (revision: HistoryRevision): Promise<void> => {
105
+ const ok = await dialogs.confirm({
106
+ title: t('history:confirmRevert'),
107
+ confirmLabel: t('history:revert'),
108
+ destructive: true,
109
+ })
110
+ if (!ok) return
111
+ try {
112
+ await revert.mutateAsync({ revisionId: revision.id })
113
+ notify.success({ key: 'history:revertSuccess' })
114
+ setSelectedId(undefined)
115
+ setCompareToId('')
116
+ setOpen(false)
117
+ } catch (err) {
118
+ notify.error(
119
+ { key: 'history:revertFailed' },
120
+ { description: err instanceof Error ? err.message : String(err) },
121
+ )
122
+ }
123
+ }
124
+
125
+ return (
126
+ <Sheet open={open} onOpenChange={setOpen}>
127
+ <SheetTrigger asChild>
128
+ <Button variant="outline" size="sm" aria-label={t('history:revisions')}>
129
+ <History className="size-4" />
130
+ <span className="hidden sm:inline">{t('history:revisions')}</span>
131
+ </Button>
132
+ </SheetTrigger>
133
+ <SheetContent side="right" className="w-full overflow-y-auto sm:max-w-5xl">
134
+ <SheetHeader>
135
+ <SheetTitle>{t('history:revisions')}</SheetTitle>
136
+ </SheetHeader>
137
+ {history.isLoading ? (
138
+ <div className="mt-6 space-y-3">
139
+ <Skeleton className="h-16 w-full" />
140
+ <Skeleton className="h-64 w-full" />
141
+ </div>
142
+ ) : history.isError ? (
143
+ <div className="mt-6 rounded-md border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
144
+ {t('history:loadError')}
145
+ </div>
146
+ ) : (
147
+ <div className="mt-6 grid gap-4 lg:grid-cols-[20rem_1fr]">
148
+ <RevisionTimeline
149
+ items={revisions.map((r) => ({
150
+ id: r.id,
151
+ op: r.op,
152
+ userId: r.userId,
153
+ userLabel: labelForUser(r.userId),
154
+ createdAt: r.createdAt,
155
+ changes: fieldsFor(r).length,
156
+ }))}
157
+ selectedId={selected?.id}
158
+ onSelect={(item) => setSelectedId(item.id)}
159
+ formatDate={formatDate}
160
+ labels={{
161
+ create: t('history:op.create'),
162
+ update: t('history:op.update'),
163
+ delete: t('history:op.delete'),
164
+ unknownUser: t('history:unknownUser'),
165
+ changes: t('history:changes'),
166
+ }}
167
+ />
168
+ <div className="min-w-0 space-y-4">
169
+ {selected ? (
170
+ <>
171
+ <div className="flex flex-wrap items-center justify-between gap-3 rounded-md border bg-card p-3">
172
+ <div>
173
+ <p className="text-sm font-medium">{formatDate(selected.createdAt)}</p>
174
+ <p className="text-xs text-muted-foreground">
175
+ {labelForUser(selected.userId) ?? t('history:unknownUser')}
176
+ </p>
177
+ </div>
178
+ <div className="flex flex-wrap items-center gap-2">
179
+ <Select
180
+ value={compareToId || '_none_'}
181
+ onValueChange={(v) => setCompareToId(v === '_none_' ? '' : v)}
182
+ >
183
+ <SelectTrigger
184
+ className="h-8 w-auto text-xs"
185
+ aria-label={t('history:compareTo')}
186
+ >
187
+ <SelectValue />
188
+ </SelectTrigger>
189
+ <SelectContent>
190
+ <SelectItem value="_none_">{t('history:storedDiff')}</SelectItem>
191
+ {revisions
192
+ .filter((r) => r.id !== selected.id)
193
+ .map((revision) => (
194
+ <SelectItem key={revision.id} value={revision.id}>
195
+ {formatDate(revision.createdAt)}
196
+ </SelectItem>
197
+ ))}
198
+ </SelectContent>
199
+ </Select>
200
+ <Button
201
+ variant="destructive"
202
+ size="sm"
203
+ disabled={revert.isPending || selected.op === 'delete'}
204
+ onClick={() => void handleRevert(selected)}
205
+ >
206
+ <RotateCcw className="size-4" />
207
+ {t('history:revert')}
208
+ </Button>
209
+ </div>
210
+ </div>
211
+ <DiffView
212
+ fields={visibleFields}
213
+ labels={{
214
+ added: t('diff:added'),
215
+ changed: t('diff:changed'),
216
+ removed: t('diff:removed'),
217
+ before: t('diff:before'),
218
+ after: t('diff:after'),
219
+ noChanges: t('diff:noChanges'),
220
+ }}
221
+ />
222
+ </>
223
+ ) : (
224
+ <div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
225
+ {t('history:noRevisions')}
226
+ </div>
227
+ )}
228
+ </div>
229
+ </div>
230
+ )}
231
+ </SheetContent>
232
+ </Sheet>
233
+ )
234
+ }
235
+
236
+ const fieldsFor = (revision: HistoryRevision): HistoryDiffEntry[] =>
237
+ diffSnapshots(revision.snapshotBefore ?? {}, revision.snapshot)
@@ -0,0 +1,302 @@
1
+ // WizardForm — multi-step create form. Each step shows a subset of the
2
+ // resource's editable properties. Clicking "Next" validates only the current
3
+ // step's fields before advancing; "Back" never re-validates. "Create" on the
4
+ // final step triggers the caller-supplied submit handler.
5
+ //
6
+ // The component is i18n-unaware: all visible strings are passed via the
7
+ // `labels` prop with English defaults. The ResourceWizardCreatePage calls
8
+ // t() and wires the results in.
9
+
10
+ import * as React from 'react'
11
+ import { useWatch, type Control, type UseFormTrigger } from 'react-hook-form'
12
+ import {
13
+ Button,
14
+ Field,
15
+ FieldError,
16
+ FieldLabel,
17
+ FormField,
18
+ InfoTooltip,
19
+ cn,
20
+ } from '@modern-admin/ui'
21
+ import { Check, ChevronLeft, ChevronRight, Plus, X } from 'lucide-react'
22
+ import { PropertyEditor } from '../property-renderer.js'
23
+ import { evaluateShowWhen } from '../show-when.js'
24
+ import type { PropertyJSON } from '../types.js'
25
+
26
+ // ── Public types ──────────────────────────────────────────────────────────────
27
+
28
+ export interface WizardStep {
29
+ /** Short label shown in the step indicator circles */
30
+ label: string
31
+ /** Optional longer description rendered below the step indicator */
32
+ description?: string
33
+ /**
34
+ * Property paths (from the resource) to show in this step.
35
+ * If omitted on exactly one step, that step receives all properties not
36
+ * claimed by the other steps (catch-all).
37
+ */
38
+ properties?: string[]
39
+ }
40
+
41
+ export interface WizardFormLabels {
42
+ back?: string
43
+ next?: string
44
+ submit?: string
45
+ cancel?: string
46
+ /** Template: 'Step {current} of {total}' — shown only on mobile */
47
+ stepOf?: string
48
+ }
49
+
50
+ type FormValues = Record<string, unknown>
51
+
52
+ export interface WizardFormProps {
53
+ steps: WizardStep[]
54
+ /** All editable properties of the resource. */
55
+ properties: PropertyJSON[]
56
+ resourceId: string
57
+ control: Control<FormValues>
58
+ trigger: UseFormTrigger<FormValues>
59
+ onSubmit: () => void
60
+ onCancel: () => void
61
+ isSubmitting?: boolean
62
+ submitError?: string | null
63
+ labels?: WizardFormLabels
64
+ }
65
+
66
+ // ── WizardForm ────────────────────────────────────────────────────────────────
67
+
68
+ export function WizardForm({
69
+ steps,
70
+ properties,
71
+ resourceId,
72
+ control,
73
+ trigger,
74
+ onSubmit,
75
+ onCancel,
76
+ isSubmitting = false,
77
+ submitError,
78
+ labels = {},
79
+ }: WizardFormProps): React.ReactElement {
80
+ const backLabel = labels.back ?? 'Back'
81
+ const nextLabel = labels.next ?? 'Next'
82
+ const submitLabel = labels.submit ?? 'Create'
83
+ const cancelLabel = labels.cancel ?? 'Cancel'
84
+ const stepOfTemplate = labels.stepOf ?? 'Step {current} of {total}'
85
+
86
+ const [currentStep, setCurrentStep] = React.useState(0)
87
+
88
+ // ── Distribute properties across steps ───────────────────────────────────
89
+ const stepProperties = React.useMemo<PropertyJSON[][]>(() => {
90
+ const claimedPaths = new Set(steps.flatMap((s) => s.properties ?? []))
91
+ const unclaimed = properties.filter((p) => !claimedPaths.has(p.path))
92
+ return steps.map((step) => {
93
+ if (step.properties) {
94
+ return step.properties
95
+ .map((path) => properties.find((p) => p.path === path))
96
+ .filter((p): p is PropertyJSON => p != null)
97
+ }
98
+ return unclaimed
99
+ })
100
+ }, [steps, properties])
101
+
102
+ const totalSteps = steps.length
103
+ const isFirst = currentStep === 0
104
+ const isLast = currentStep === totalSteps - 1
105
+
106
+ const handleNext = async (): Promise<void> => {
107
+ const paths = (stepProperties[currentStep] ?? []).map((p) => p.path)
108
+ // When the step has no properties, advance without validation.
109
+ const valid =
110
+ paths.length === 0 ||
111
+ (await trigger(paths as Parameters<typeof trigger>[0]))
112
+ if (valid) setCurrentStep((s) => Math.min(s + 1, totalSteps - 1))
113
+ }
114
+
115
+ const currentStepDef = steps[currentStep]!
116
+ const currentProperties = stepProperties[currentStep] ?? []
117
+
118
+ const stepOfLabel = stepOfTemplate
119
+ .replace('{current}', String(currentStep + 1))
120
+ .replace('{total}', String(totalSteps))
121
+
122
+ return (
123
+ <div>
124
+ {/* ── Step indicator ──────────────────────────────────────────── */}
125
+ <div className="px-6 pt-6 pb-5">
126
+ <div className="flex w-full max-w-xl items-start mx-auto">
127
+ {steps.map((step, index) => (
128
+ <React.Fragment key={index}>
129
+ {/* Step node */}
130
+ <div className="flex shrink-0 flex-col items-center">
131
+ <div
132
+ className={cn(
133
+ 'flex h-8 w-8 items-center justify-center rounded-full border-2 text-sm font-semibold transition-colors',
134
+ index < currentStep &&
135
+ 'border-primary bg-primary text-primary-foreground',
136
+ index === currentStep &&
137
+ 'border-primary bg-background text-primary',
138
+ index > currentStep &&
139
+ 'border-border bg-background text-muted-foreground',
140
+ )}
141
+ >
142
+ {index < currentStep ? (
143
+ <Check className="size-3.5" />
144
+ ) : (
145
+ index + 1
146
+ )}
147
+ </div>
148
+ <span
149
+ className={cn(
150
+ 'mt-1.5 hidden max-w-[6rem] text-center text-xs leading-tight sm:block',
151
+ index === currentStep
152
+ ? 'font-medium text-foreground'
153
+ : index < currentStep
154
+ ? 'text-primary'
155
+ : 'text-muted-foreground',
156
+ )}
157
+ >
158
+ {step.label}
159
+ </span>
160
+ </div>
161
+ {/* Connector line between nodes */}
162
+ {index < steps.length - 1 && (
163
+ <div
164
+ className={cn(
165
+ 'mx-2 mt-3.5 h-0.5 flex-1 transition-colors',
166
+ index < currentStep ? 'bg-primary' : 'bg-border',
167
+ )}
168
+ />
169
+ )}
170
+ </React.Fragment>
171
+ ))}
172
+ </div>
173
+ {/* Mobile: compact "Step N of M · Label" summary */}
174
+ <p className="mt-2 text-center text-xs text-muted-foreground sm:hidden">
175
+ {stepOfLabel}
176
+ {currentStepDef.label ? ` · ${currentStepDef.label}` : ''}
177
+ </p>
178
+ </div>
179
+
180
+ {/* ── Step description ────────────────────────────────────────── */}
181
+ {currentStepDef.description && (
182
+ <p className="px-6 pb-2 text-sm text-muted-foreground">
183
+ {currentStepDef.description}
184
+ </p>
185
+ )}
186
+
187
+ {/* ── Fields ──────────────────────────────────────────────────── */}
188
+ <div className="gap-4 px-6 pb-4 [column-fill:_balance] md:columns-2">
189
+ {currentProperties.map((property) => (
190
+ <WizardConditionalField
191
+ key={property.path}
192
+ control={control}
193
+ property={property}
194
+ >
195
+ <FormField
196
+ control={control}
197
+ name={property.path}
198
+ render={({ field, fieldState }) => (
199
+ <Field
200
+ data-invalid={fieldState.error ? true : undefined}
201
+ className="mb-8 break-inside-avoid"
202
+ >
203
+ <FieldLabel htmlFor={field.name}>
204
+ {property.label}
205
+ {property.description ? (
206
+ <InfoTooltip
207
+ content={property.description}
208
+ ariaLabel={property.description}
209
+ />
210
+ ) : null}
211
+ {property.isRequired && (
212
+ <span className="ml-1 text-destructive">*</span>
213
+ )}
214
+ </FieldLabel>
215
+ <PropertyEditor
216
+ property={property}
217
+ value={field.value}
218
+ onChange={field.onChange}
219
+ disabled={isSubmitting}
220
+ resourceId={resourceId}
221
+ />
222
+ {fieldState.error?.message && (
223
+ <FieldError>{fieldState.error.message}</FieldError>
224
+ )}
225
+ </Field>
226
+ )}
227
+ />
228
+ </WizardConditionalField>
229
+ ))}
230
+ </div>
231
+
232
+ {/* ── Navigation footer ───────────────────────────────────────── */}
233
+ <div className="flex items-center justify-between border-t border-border px-6 py-4">
234
+ <div>
235
+ {submitError && (
236
+ <span className="text-sm text-destructive">{submitError}</span>
237
+ )}
238
+ </div>
239
+ <div className="flex gap-2">
240
+ {isFirst ? (
241
+ <Button
242
+ type="button"
243
+ variant="ghost"
244
+ onClick={onCancel}
245
+ disabled={isSubmitting}
246
+ >
247
+ <X className="size-4" />
248
+ {cancelLabel}
249
+ </Button>
250
+ ) : (
251
+ <Button
252
+ type="button"
253
+ variant="outline"
254
+ onClick={() => setCurrentStep((s) => s - 1)}
255
+ disabled={isSubmitting}
256
+ >
257
+ <ChevronLeft className="size-4" />
258
+ {backLabel}
259
+ </Button>
260
+ )}
261
+ {isLast ? (
262
+ <Button type="button" onClick={onSubmit} disabled={isSubmitting}>
263
+ <Plus className="size-4" />
264
+ {submitLabel}
265
+ </Button>
266
+ ) : (
267
+ <Button
268
+ type="button"
269
+ onClick={() => void handleNext()}
270
+ disabled={isSubmitting}
271
+ >
272
+ {nextLabel}
273
+ <ChevronRight className="size-4" />
274
+ </Button>
275
+ )}
276
+ </div>
277
+ </div>
278
+ </div>
279
+ )
280
+ }
281
+
282
+ WizardForm.displayName = 'WizardForm'
283
+
284
+ // ── WizardConditionalField ────────────────────────────────────────────────────
285
+ // Same showWhen evaluation as ConditionalField in edit-page, scoped here.
286
+
287
+ interface WizardConditionalFieldProps {
288
+ control: Control<FormValues>
289
+ property: PropertyJSON
290
+ children: React.ReactNode
291
+ }
292
+
293
+ function WizardConditionalField({
294
+ control,
295
+ property,
296
+ children,
297
+ }: WizardConditionalFieldProps): React.ReactElement | null {
298
+ const rule = property.showWhen
299
+ const watched = useWatch({ control, name: rule?.field ?? property.path })
300
+ if (!rule) return <>{children}</>
301
+ return evaluateShowWhen(rule, { [rule.field]: watched }) ? <>{children}</> : null
302
+ }