@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.
- package/dist/action-guard.d.ts +13 -0
- package/dist/action-guard.d.ts.map +1 -0
- package/dist/action-guard.js +15 -0
- package/dist/action-guard.js.map +1 -0
- package/dist/action-menu.d.ts +17 -0
- package/dist/action-menu.d.ts.map +1 -0
- package/dist/action-menu.jsx +80 -0
- package/dist/action-menu.jsx.map +1 -0
- package/dist/admin-app.d.ts +23 -0
- package/dist/admin-app.d.ts.map +1 -0
- package/dist/admin-app.jsx +407 -0
- package/dist/admin-app.jsx.map +1 -0
- package/dist/admin-router.d.ts +29 -0
- package/dist/admin-router.d.ts.map +1 -0
- package/dist/admin-router.jsx +215 -0
- package/dist/admin-router.jsx.map +1 -0
- package/dist/breadcrumbs.d.ts +17 -0
- package/dist/breadcrumbs.d.ts.map +1 -0
- package/dist/breadcrumbs.jsx +40 -0
- package/dist/breadcrumbs.jsx.map +1 -0
- package/dist/client.d.ts +526 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +582 -0
- package/dist/client.js.map +1 -0
- package/dist/component-loader.d.ts +10 -0
- package/dist/component-loader.d.ts.map +1 -0
- package/dist/component-loader.js +23 -0
- package/dist/component-loader.js.map +1 -0
- package/dist/components/ai-assistant-widget.d.ts +3 -0
- package/dist/components/ai-assistant-widget.d.ts.map +1 -0
- package/dist/components/ai-assistant-widget.jsx +390 -0
- package/dist/components/ai-assistant-widget.jsx.map +1 -0
- package/dist/components/ai-fill-dialog.d.ts +9 -0
- package/dist/components/ai-fill-dialog.d.ts.map +1 -0
- package/dist/components/ai-fill-dialog.jsx +105 -0
- package/dist/components/ai-fill-dialog.jsx.map +1 -0
- package/dist/components/chart-builder-dialog.d.ts +10 -0
- package/dist/components/chart-builder-dialog.d.ts.map +1 -0
- package/dist/components/chart-builder-dialog.jsx +433 -0
- package/dist/components/chart-builder-dialog.jsx.map +1 -0
- package/dist/components/chart-widget.d.ts +12 -0
- package/dist/components/chart-widget.d.ts.map +1 -0
- package/dist/components/chart-widget.jsx +365 -0
- package/dist/components/chart-widget.jsx.map +1 -0
- package/dist/components/global-search-dialog.d.ts +7 -0
- package/dist/components/global-search-dialog.d.ts.map +1 -0
- package/dist/components/global-search-dialog.jsx +187 -0
- package/dist/components/global-search-dialog.jsx.map +1 -0
- package/dist/components/group-settings-dialog.d.ts +13 -0
- package/dist/components/group-settings-dialog.d.ts.map +1 -0
- package/dist/components/group-settings-dialog.jsx +53 -0
- package/dist/components/group-settings-dialog.jsx.map +1 -0
- package/dist/components/move-chart-dialog.d.ts +18 -0
- package/dist/components/move-chart-dialog.d.ts.map +1 -0
- package/dist/components/move-chart-dialog.jsx +68 -0
- package/dist/components/move-chart-dialog.jsx.map +1 -0
- package/dist/components/reference-multi-table-dialog.d.ts +12 -0
- package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
- package/dist/components/reference-multi-table-dialog.jsx +126 -0
- package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
- package/dist/components/related-records-tabs.d.ts +8 -0
- package/dist/components/related-records-tabs.d.ts.map +1 -0
- package/dist/components/related-records-tabs.jsx +75 -0
- package/dist/components/related-records-tabs.jsx.map +1 -0
- package/dist/components/revisions-button.d.ts +7 -0
- package/dist/components/revisions-button.d.ts.map +1 -0
- package/dist/components/revisions-button.jsx +152 -0
- package/dist/components/revisions-button.jsx.map +1 -0
- package/dist/components/wizard-form.d.ts +43 -0
- package/dist/components/wizard-form.d.ts.map +1 -0
- package/dist/components/wizard-form.jsx +136 -0
- package/dist/components/wizard-form.jsx.map +1 -0
- package/dist/dashboard/time-series.d.ts +20 -0
- package/dist/dashboard/time-series.d.ts.map +1 -0
- package/dist/dashboard/time-series.js +108 -0
- package/dist/dashboard/time-series.js.map +1 -0
- package/dist/dialogs.d.ts +35 -0
- package/dist/dialogs.d.ts.map +1 -0
- package/dist/dialogs.jsx +152 -0
- package/dist/dialogs.jsx.map +1 -0
- package/dist/export.d.ts +39 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +114 -0
- package/dist/export.js.map +1 -0
- package/dist/extension-registry.d.ts +122 -0
- package/dist/extension-registry.d.ts.map +1 -0
- package/dist/extension-registry.js +93 -0
- package/dist/extension-registry.js.map +1 -0
- package/dist/header-controls.d.ts +4 -0
- package/dist/header-controls.d.ts.map +1 -0
- package/dist/header-controls.jsx +70 -0
- package/dist/header-controls.jsx.map +1 -0
- package/dist/hooks.d.ts +104 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +374 -0
- package/dist/hooks.js.map +1 -0
- package/dist/hotkey-help.d.ts +3 -0
- package/dist/hotkey-help.d.ts.map +1 -0
- package/dist/hotkey-help.jsx +32 -0
- package/dist/hotkey-help.jsx.map +1 -0
- package/dist/hotkey-registry.d.ts +18 -0
- package/dist/hotkey-registry.d.ts.map +1 -0
- package/dist/hotkey-registry.jsx +34 -0
- package/dist/hotkey-registry.jsx.map +1 -0
- package/dist/i18n.d.ts +74 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.jsx +127 -0
- package/dist/i18n.jsx.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/notify.d.ts +41 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.jsx +58 -0
- package/dist/notify.jsx.map +1 -0
- package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
- package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
- package/dist/pages/ai-assistant-settings-section.jsx +126 -0
- package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
- package/dist/pages/audit-log-page.d.ts +3 -0
- package/dist/pages/audit-log-page.d.ts.map +1 -0
- package/dist/pages/audit-log-page.jsx +354 -0
- package/dist/pages/audit-log-page.jsx.map +1 -0
- package/dist/pages/edit-page.d.ts +7 -0
- package/dist/pages/edit-page.d.ts.map +1 -0
- package/dist/pages/edit-page.jsx +614 -0
- package/dist/pages/edit-page.jsx.map +1 -0
- package/dist/pages/export-dialog.d.ts +11 -0
- package/dist/pages/export-dialog.d.ts.map +1 -0
- package/dist/pages/export-dialog.jsx +102 -0
- package/dist/pages/export-dialog.jsx.map +1 -0
- package/dist/pages/home-page.d.ts +3 -0
- package/dist/pages/home-page.d.ts.map +1 -0
- package/dist/pages/home-page.jsx +211 -0
- package/dist/pages/home-page.jsx.map +1 -0
- package/dist/pages/list-page.d.ts +42 -0
- package/dist/pages/list-page.d.ts.map +1 -0
- package/dist/pages/list-page.jsx +1596 -0
- package/dist/pages/list-page.jsx.map +1 -0
- package/dist/pages/login-page.d.ts +11 -0
- package/dist/pages/login-page.d.ts.map +1 -0
- package/dist/pages/login-page.jsx +157 -0
- package/dist/pages/login-page.jsx.map +1 -0
- package/dist/pages/settings-page.d.ts +5 -0
- package/dist/pages/settings-page.d.ts.map +1 -0
- package/dist/pages/settings-page.jsx +787 -0
- package/dist/pages/settings-page.jsx.map +1 -0
- package/dist/pages/settings-shared.d.ts +51 -0
- package/dist/pages/settings-shared.d.ts.map +1 -0
- package/dist/pages/settings-shared.jsx +66 -0
- package/dist/pages/settings-shared.jsx.map +1 -0
- package/dist/pages/show-page.d.ts +7 -0
- package/dist/pages/show-page.d.ts.map +1 -0
- package/dist/pages/show-page.jsx +147 -0
- package/dist/pages/show-page.jsx.map +1 -0
- package/dist/pages/wizard-create-page.d.ts +14 -0
- package/dist/pages/wizard-create-page.d.ts.map +1 -0
- package/dist/pages/wizard-create-page.jsx +106 -0
- package/dist/pages/wizard-create-page.jsx.map +1 -0
- package/dist/property-renderer.d.ts +8 -0
- package/dist/property-renderer.d.ts.map +1 -0
- package/dist/property-renderer.jsx +690 -0
- package/dist/property-renderer.jsx.map +1 -0
- package/dist/provider.d.ts +20 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.jsx +32 -0
- package/dist/provider.jsx.map +1 -0
- package/dist/realtime.d.ts +22 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +38 -0
- package/dist/realtime.js.map +1 -0
- package/dist/reference.d.ts +52 -0
- package/dist/reference.d.ts.map +1 -0
- package/dist/reference.jsx +224 -0
- package/dist/reference.jsx.map +1 -0
- package/dist/relations.d.ts +11 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +36 -0
- package/dist/relations.js.map +1 -0
- package/dist/router.d.ts +82 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.jsx +187 -0
- package/dist/router.jsx.map +1 -0
- package/dist/show-when.d.ts +7 -0
- package/dist/show-when.d.ts.map +1 -0
- package/dist/show-when.js +77 -0
- package/dist/show-when.js.map +1 -0
- package/dist/types.d.ts +194 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/dist/use-dashboard-charts.d.ts +93 -0
- package/dist/use-dashboard-charts.d.ts.map +1 -0
- package/dist/use-dashboard-charts.js +263 -0
- package/dist/use-dashboard-charts.js.map +1 -0
- package/dist/use-hotkey.d.ts +17 -0
- package/dist/use-hotkey.d.ts.map +1 -0
- package/dist/use-hotkey.js +103 -0
- package/dist/use-hotkey.js.map +1 -0
- package/dist/user-directory.d.ts +18 -0
- package/dist/user-directory.d.ts.map +1 -0
- package/dist/user-directory.js +51 -0
- package/dist/user-directory.js.map +1 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +338 -0
- package/dist/validation.js.map +1 -0
- package/package.json +59 -0
- package/src/action-guard.ts +20 -0
- package/src/action-menu.tsx +161 -0
- package/src/admin-app.tsx +630 -0
- package/src/admin-router.tsx +273 -0
- package/src/breadcrumbs.tsx +75 -0
- package/src/client.ts +1093 -0
- package/src/component-loader.ts +33 -0
- package/src/components/ai-assistant-widget.tsx +565 -0
- package/src/components/ai-fill-dialog.tsx +143 -0
- package/src/components/chart-builder-dialog.tsx +618 -0
- package/src/components/chart-widget.tsx +654 -0
- package/src/components/global-search-dialog.tsx +272 -0
- package/src/components/group-settings-dialog.tsx +93 -0
- package/src/components/move-chart-dialog.tsx +130 -0
- package/src/components/reference-multi-table-dialog.tsx +196 -0
- package/src/components/related-records-tabs.tsx +130 -0
- package/src/components/revisions-button.tsx +237 -0
- package/src/components/wizard-form.tsx +302 -0
- package/src/dashboard/time-series.ts +125 -0
- package/src/dialogs.tsx +265 -0
- package/src/export.ts +140 -0
- package/src/extension-registry.ts +195 -0
- package/src/header-controls.tsx +125 -0
- package/src/hooks.ts +509 -0
- package/src/hotkey-help.tsx +56 -0
- package/src/hotkey-registry.tsx +60 -0
- package/src/i18n.tsx +267 -0
- package/src/index.ts +192 -0
- package/src/notify.tsx +94 -0
- package/src/pages/ai-assistant-settings-section.tsx +167 -0
- package/src/pages/audit-log-page.tsx +580 -0
- package/src/pages/edit-page.tsx +743 -0
- package/src/pages/export-dialog.tsx +154 -0
- package/src/pages/home-page.tsx +318 -0
- package/src/pages/list-page.tsx +2645 -0
- package/src/pages/login-page.tsx +242 -0
- package/src/pages/settings-page.tsx +1143 -0
- package/src/pages/settings-shared.tsx +134 -0
- package/src/pages/show-page.tsx +223 -0
- package/src/pages/wizard-create-page.tsx +164 -0
- package/src/property-renderer.tsx +1143 -0
- package/src/provider.tsx +70 -0
- package/src/realtime.ts +55 -0
- package/src/reference.tsx +386 -0
- package/src/relations.ts +55 -0
- package/src/router.tsx +211 -0
- package/src/show-when.ts +76 -0
- package/src/types.ts +198 -0
- package/src/use-dashboard-charts.ts +362 -0
- package/src/use-hotkey.ts +128 -0
- package/src/user-directory.ts +56 -0
- 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
|
+
}
|