@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
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
// TanStack Query hooks that wrap AdminClient. Query keys are
|
|
2
|
+
// `[resourceId, action, params?]` so cache invalidation is precise.
|
|
3
|
+
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import {
|
|
6
|
+
useInfiniteQuery,
|
|
7
|
+
useMutation,
|
|
8
|
+
useQuery,
|
|
9
|
+
useQueryClient,
|
|
10
|
+
type InfiniteData,
|
|
11
|
+
type UseInfiniteQueryResult,
|
|
12
|
+
type UseMutationResult,
|
|
13
|
+
type UseQueryResult,
|
|
14
|
+
} from '@tanstack/react-query'
|
|
15
|
+
import { useAdminClient } from './provider.js'
|
|
16
|
+
import type {
|
|
17
|
+
AdminConfig,
|
|
18
|
+
AdminFeatures,
|
|
19
|
+
CustomActionResponse,
|
|
20
|
+
CurrentUser,
|
|
21
|
+
ListQuery,
|
|
22
|
+
ListResponse,
|
|
23
|
+
RecordResponse,
|
|
24
|
+
ResourceJSON,
|
|
25
|
+
} from './types.js'
|
|
26
|
+
import { resolveFeatures } from './types.js'
|
|
27
|
+
import {
|
|
28
|
+
AdminApiError,
|
|
29
|
+
type AuthUiProps,
|
|
30
|
+
type AuditLogQuery,
|
|
31
|
+
type AuditLogResponse,
|
|
32
|
+
type GlobalSearchResponse,
|
|
33
|
+
type HistoryListResponse,
|
|
34
|
+
type HistoryRevisionResponse,
|
|
35
|
+
type TimeSeriesQuery,
|
|
36
|
+
type TimeSeriesResponse,
|
|
37
|
+
} from './client.js'
|
|
38
|
+
import { useI18n } from './i18n.js'
|
|
39
|
+
|
|
40
|
+
const KEY_CONFIG = ['modern-admin', 'config'] as const
|
|
41
|
+
const keyList = (resourceId: string, query?: ListQuery) =>
|
|
42
|
+
['modern-admin', resourceId, 'list', query ?? null] as const
|
|
43
|
+
const keyShow = (resourceId: string, recordId: string) =>
|
|
44
|
+
['modern-admin', resourceId, 'show', recordId] as const
|
|
45
|
+
const keyHistory = (resourceId: string, recordId: string) =>
|
|
46
|
+
['modern-admin', resourceId, 'history', recordId] as const
|
|
47
|
+
const keyHistoryRevision = (resourceId: string, recordId: string, revisionId: string) =>
|
|
48
|
+
['modern-admin', resourceId, 'history', recordId, revisionId] as const
|
|
49
|
+
const keyAuditLog = (query?: AuditLogQuery) =>
|
|
50
|
+
['modern-admin', 'audit-log', query ?? null] as const
|
|
51
|
+
|
|
52
|
+
export const useAdminConfig = (): UseQueryResult<AdminConfig> => {
|
|
53
|
+
const client = useAdminClient()
|
|
54
|
+
return useQuery({ queryKey: KEY_CONFIG, queryFn: () => client.config(), staleTime: 60_000 })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const useResource = (resourceId: string | undefined): ResourceJSON | undefined => {
|
|
58
|
+
const { data } = useAdminConfig()
|
|
59
|
+
const { localizeResource } = useI18n()
|
|
60
|
+
return React.useMemo(() => {
|
|
61
|
+
const resource = data?.resources.find((r) => r.id === resourceId)
|
|
62
|
+
return resource ? localizeResource(resource) : undefined
|
|
63
|
+
}, [data?.resources, localizeResource, resourceId])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Capability flags advertised by the backend via `/admin/api/config`.
|
|
68
|
+
* Use to gate optional UI surfaces (audit-log link, settings sections,
|
|
69
|
+
* revisions button, AI assistant widget) — every flag is `false` until
|
|
70
|
+
* the bootstrap config is loaded, so consumers can render unconditionally
|
|
71
|
+
* and the gating logic short-circuits during the initial paint.
|
|
72
|
+
*/
|
|
73
|
+
export const useFeatures = (): AdminFeatures => {
|
|
74
|
+
const { data } = useAdminConfig()
|
|
75
|
+
return React.useMemo(() => resolveFeatures(data?.features), [data?.features])
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const useResources = (): ResourceJSON[] => {
|
|
79
|
+
const { data } = useAdminConfig()
|
|
80
|
+
const { localizeResource } = useI18n()
|
|
81
|
+
return React.useMemo(
|
|
82
|
+
() => (data?.resources ?? []).map((resource) => localizeResource(resource)),
|
|
83
|
+
[data?.resources, localizeResource],
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Fetch distinct values for a field, cached for 5 minutes.
|
|
89
|
+
* Used by the filter value picker to offer multi-select when cardinality is low.
|
|
90
|
+
* The `enabled` flag allows lazy loading only when the filter UI is open.
|
|
91
|
+
*/
|
|
92
|
+
export const useDistinctValues = (
|
|
93
|
+
resourceId: string,
|
|
94
|
+
field: string,
|
|
95
|
+
options?: { search?: string; limit?: number; enabled?: boolean },
|
|
96
|
+
): UseQueryResult<{ values: string[]; hasMore: boolean }> => {
|
|
97
|
+
const client = useAdminClient()
|
|
98
|
+
return useQuery({
|
|
99
|
+
queryKey: ['modern-admin', resourceId, 'values', field, options?.search ?? '', options?.limit ?? 100] as const,
|
|
100
|
+
queryFn: () => client.distinctValues(resourceId, field, {
|
|
101
|
+
search: options?.search,
|
|
102
|
+
limit: options?.limit,
|
|
103
|
+
}),
|
|
104
|
+
staleTime: 5 * 60_000, // 5 min cache
|
|
105
|
+
enabled: options?.enabled !== false,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const useRecords = (
|
|
110
|
+
resourceId: string,
|
|
111
|
+
query?: ListQuery,
|
|
112
|
+
): UseQueryResult<ListResponse> => {
|
|
113
|
+
const client = useAdminClient()
|
|
114
|
+
return useQuery({
|
|
115
|
+
queryKey: keyList(resourceId, query),
|
|
116
|
+
queryFn: () => client.list(resourceId, query),
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const useRecord = (
|
|
121
|
+
resourceId: string,
|
|
122
|
+
recordId: string | undefined,
|
|
123
|
+
): UseQueryResult<RecordResponse> => {
|
|
124
|
+
const client = useAdminClient()
|
|
125
|
+
return useQuery({
|
|
126
|
+
queryKey: keyShow(resourceId, recordId ?? ''),
|
|
127
|
+
queryFn: () => client.show(resourceId, recordId!),
|
|
128
|
+
enabled: !!recordId,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const useCreateRecord = (
|
|
133
|
+
resourceId: string,
|
|
134
|
+
): UseMutationResult<RecordResponse, Error, Record<string, unknown>> => {
|
|
135
|
+
const client = useAdminClient()
|
|
136
|
+
const qc = useQueryClient()
|
|
137
|
+
return useMutation({
|
|
138
|
+
mutationFn: (payload) => client.create(resourceId, payload),
|
|
139
|
+
onSuccess: () => {
|
|
140
|
+
qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const useUpdateRecord = (
|
|
146
|
+
resourceId: string,
|
|
147
|
+
): UseMutationResult<
|
|
148
|
+
RecordResponse,
|
|
149
|
+
Error,
|
|
150
|
+
{ id: string; payload: Record<string, unknown> }
|
|
151
|
+
> => {
|
|
152
|
+
const client = useAdminClient()
|
|
153
|
+
const qc = useQueryClient()
|
|
154
|
+
return useMutation({
|
|
155
|
+
mutationFn: ({ id, payload }) => client.update(resourceId, id, payload),
|
|
156
|
+
onSuccess: (_data, { id }) => {
|
|
157
|
+
qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
|
|
158
|
+
qc.invalidateQueries({ queryKey: keyShow(resourceId, id) })
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const useDeleteRecord = (
|
|
164
|
+
resourceId: string,
|
|
165
|
+
): UseMutationResult<void, Error, string> => {
|
|
166
|
+
const client = useAdminClient()
|
|
167
|
+
const qc = useQueryClient()
|
|
168
|
+
return useMutation({
|
|
169
|
+
mutationFn: (id) => client.delete(resourceId, id),
|
|
170
|
+
onSuccess: () => {
|
|
171
|
+
qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const useBulkDeleteRecords = (
|
|
177
|
+
resourceId: string,
|
|
178
|
+
): UseMutationResult<unknown, Error, ReadonlyArray<string>> => {
|
|
179
|
+
const client = useAdminClient()
|
|
180
|
+
const qc = useQueryClient()
|
|
181
|
+
return useMutation({
|
|
182
|
+
mutationFn: (ids) => client.bulkDelete(resourceId, ids),
|
|
183
|
+
onSuccess: () => {
|
|
184
|
+
qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const keySearch = (resourceId: string, query: string) =>
|
|
190
|
+
['modern-admin', resourceId, 'search', query] as const
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Live-search hook against a resource's `search` action. Used by reference
|
|
194
|
+
* comboboxes — debounce the input on the call site.
|
|
195
|
+
*/
|
|
196
|
+
// ─── Auth ─────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
const KEY_ME = ['modern-admin', 'auth', 'me'] as const
|
|
199
|
+
const KEY_AUTH_UI = ['modern-admin', 'auth', 'ui-props'] as const
|
|
200
|
+
|
|
201
|
+
export interface CurrentUserResult {
|
|
202
|
+
user: CurrentUser | null
|
|
203
|
+
isLoading: boolean
|
|
204
|
+
isAuthenticated: boolean
|
|
205
|
+
error: Error | null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Resolve the current admin via /admin/api/auth/me. A 401 response surfaces
|
|
209
|
+
* as `user: null` (rather than an error) so callers can branch on it to
|
|
210
|
+
* render the login screen. */
|
|
211
|
+
export const useCurrentUser = (): CurrentUserResult => {
|
|
212
|
+
const client = useAdminClient()
|
|
213
|
+
const query = useQuery<{ user: CurrentUser } | null, Error>({
|
|
214
|
+
queryKey: KEY_ME,
|
|
215
|
+
queryFn: async () => {
|
|
216
|
+
try {
|
|
217
|
+
return await client.me()
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if (err instanceof AdminApiError && err.status === 401) return null
|
|
220
|
+
throw err
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
staleTime: 30_000,
|
|
224
|
+
retry: (failureCount, err) => {
|
|
225
|
+
if (err instanceof AdminApiError && err.status === 401) return false
|
|
226
|
+
return failureCount < 1
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
return {
|
|
230
|
+
user: query.data?.user ?? null,
|
|
231
|
+
isLoading: query.isLoading,
|
|
232
|
+
isAuthenticated: !!query.data?.user,
|
|
233
|
+
error: query.error,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Fetch public auth UI metadata (enabled social providers, email/password flag).
|
|
238
|
+
* Cached indefinitely — the provider list is static for a given deployment. */
|
|
239
|
+
export const useAuthUiProps = (): UseQueryResult<AuthUiProps> => {
|
|
240
|
+
const client = useAdminClient()
|
|
241
|
+
return useQuery({
|
|
242
|
+
queryKey: KEY_AUTH_UI,
|
|
243
|
+
queryFn: () => client.getAuthUiProps(),
|
|
244
|
+
staleTime: Infinity,
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Initiate OAuth social login. Navigates the browser away to the provider;
|
|
249
|
+
* `isPending` is true while the redirect URL is being fetched. */
|
|
250
|
+
export const useSocialLogin = (): UseMutationResult<void, Error, string> => {
|
|
251
|
+
const client = useAdminClient()
|
|
252
|
+
return useMutation({
|
|
253
|
+
mutationFn: (provider: string) => client.loginSocial(provider),
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export const useLogin = (): UseMutationResult<
|
|
258
|
+
void,
|
|
259
|
+
Error,
|
|
260
|
+
{ email: string; password: string }
|
|
261
|
+
> => {
|
|
262
|
+
const client = useAdminClient()
|
|
263
|
+
const qc = useQueryClient()
|
|
264
|
+
return useMutation({
|
|
265
|
+
mutationFn: ({ email, password }) => client.login(email, password),
|
|
266
|
+
onSuccess: () => {
|
|
267
|
+
qc.invalidateQueries({ queryKey: KEY_ME })
|
|
268
|
+
qc.invalidateQueries({ queryKey: KEY_CONFIG })
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export const useLogout = (): UseMutationResult<void, Error, void> => {
|
|
274
|
+
const client = useAdminClient()
|
|
275
|
+
const qc = useQueryClient()
|
|
276
|
+
return useMutation({
|
|
277
|
+
mutationFn: () => client.logout(),
|
|
278
|
+
onSuccess: async () => {
|
|
279
|
+
// Cancel any in-flight `me` refetch so it cannot overwrite the
|
|
280
|
+
// optimistic null below and bounce the gate back to authenticated.
|
|
281
|
+
await qc.cancelQueries({ queryKey: KEY_ME })
|
|
282
|
+
// Flip the auth gate to "logged out" immediately. We deliberately
|
|
283
|
+
// do NOT invalidate KEY_ME here — Better Auth has already deleted
|
|
284
|
+
// the server-side session, but the Set-Cookie header may not be
|
|
285
|
+
// applied to outgoing requests for a tick, and a refetch in that
|
|
286
|
+
// window would return the still-valid user and cancel the logout
|
|
287
|
+
// visually. The next mount/refresh will re-check freshly.
|
|
288
|
+
qc.setQueryData(KEY_ME, null)
|
|
289
|
+
// Drop every other cached resource so list/show data doesn't
|
|
290
|
+
// linger behind the login form.
|
|
291
|
+
qc.removeQueries({
|
|
292
|
+
predicate: (q) => {
|
|
293
|
+
const k = q.queryKey as readonly unknown[]
|
|
294
|
+
return !(k[0] === 'modern-admin' && k[1] === 'auth' && k[2] === 'me')
|
|
295
|
+
},
|
|
296
|
+
})
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export const useInvokeRecordAction = (
|
|
302
|
+
resourceId: string,
|
|
303
|
+
): UseMutationResult<CustomActionResponse, Error, { recordId: string; actionName: string }> => {
|
|
304
|
+
const client = useAdminClient()
|
|
305
|
+
const qc = useQueryClient()
|
|
306
|
+
return useMutation({
|
|
307
|
+
mutationFn: ({ recordId, actionName }) =>
|
|
308
|
+
client.invokeRecordAction(resourceId, recordId, actionName),
|
|
309
|
+
onSuccess: (_data, { recordId }) => {
|
|
310
|
+
qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
|
|
311
|
+
qc.invalidateQueries({ queryKey: ['modern-admin', resourceId, 'show', recordId] })
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export const useInvokeBulkAction = (
|
|
317
|
+
resourceId: string,
|
|
318
|
+
): UseMutationResult<CustomActionResponse, Error, { actionName: string; ids: string[] }> => {
|
|
319
|
+
const client = useAdminClient()
|
|
320
|
+
const qc = useQueryClient()
|
|
321
|
+
return useMutation({
|
|
322
|
+
mutationFn: ({ actionName, ids }) => client.invokeBulkAction(resourceId, actionName, ids),
|
|
323
|
+
onSuccess: () => {
|
|
324
|
+
qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
|
|
325
|
+
},
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export const useInvokeResourceAction = (
|
|
330
|
+
resourceId: string,
|
|
331
|
+
): UseMutationResult<CustomActionResponse, Error, { actionName: string; payload?: Record<string, unknown> }> => {
|
|
332
|
+
const client = useAdminClient()
|
|
333
|
+
const qc = useQueryClient()
|
|
334
|
+
return useMutation({
|
|
335
|
+
mutationFn: ({ actionName, payload }) => client.invokeResourceAction(resourceId, actionName, payload),
|
|
336
|
+
onSuccess: () => {
|
|
337
|
+
qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
|
|
338
|
+
},
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Cross-resource search hook. Fires a single batched request that fans out
|
|
344
|
+
* to every registered resource's `search` action; results are grouped by
|
|
345
|
+
* resource. The empty-query case is handled by the caller (skip render);
|
|
346
|
+
* `enabled` allows lazy activation while the dialog is closed.
|
|
347
|
+
*/
|
|
348
|
+
export const useGlobalSearch = (
|
|
349
|
+
query: string,
|
|
350
|
+
enabled = true,
|
|
351
|
+
): UseQueryResult<GlobalSearchResponse> => {
|
|
352
|
+
const client = useAdminClient()
|
|
353
|
+
return useQuery({
|
|
354
|
+
queryKey: ['modern-admin', 'global-search', query] as const,
|
|
355
|
+
// Forward the AbortSignal TanStack Query attaches to each invocation.
|
|
356
|
+
// The signal fires when the query key changes (next keystroke) or the
|
|
357
|
+
// component unmounts, letting the server short-circuit stale work.
|
|
358
|
+
queryFn: ({ signal }) => client.globalSearch(query, undefined, { signal }),
|
|
359
|
+
enabled: enabled && query.trim().length > 0,
|
|
360
|
+
staleTime: 30_000,
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export const useSearchRecords = (
|
|
365
|
+
resourceId: string | undefined,
|
|
366
|
+
query: string,
|
|
367
|
+
enabled = true,
|
|
368
|
+
): UseQueryResult<ListResponse> => {
|
|
369
|
+
const client = useAdminClient()
|
|
370
|
+
return useQuery({
|
|
371
|
+
queryKey: keySearch(resourceId ?? '', query),
|
|
372
|
+
queryFn: () => client.search(resourceId!, query),
|
|
373
|
+
enabled: !!resourceId && enabled,
|
|
374
|
+
staleTime: 30_000,
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Distinct (deduplicated, sorted) values pulled from a single field of a
|
|
380
|
+
* resource — the data source for the autocomplete `suggestionsResource +
|
|
381
|
+
* suggestionsField` binding on `KeyValueFieldSpec`. Loads up to `perPage`
|
|
382
|
+
* records and projects `field` client-side; for typical admin resources
|
|
383
|
+
* (hundreds–low thousands of rows) this is plenty cheap. For very large
|
|
384
|
+
* tables, reach for a dedicated `distinct` endpoint.
|
|
385
|
+
*/
|
|
386
|
+
export const useFieldSuggestions = (
|
|
387
|
+
resourceId: string | undefined,
|
|
388
|
+
field: string | undefined,
|
|
389
|
+
perPage = 200,
|
|
390
|
+
): UseQueryResult<string[]> => {
|
|
391
|
+
const client = useAdminClient()
|
|
392
|
+
return useQuery({
|
|
393
|
+
queryKey: ['modern-admin', 'fieldSuggestions', resourceId ?? '', field ?? '', perPage],
|
|
394
|
+
queryFn: async (): Promise<string[]> => {
|
|
395
|
+
const res = await client.list(resourceId!, { perPage })
|
|
396
|
+
const seen = new Set<string>()
|
|
397
|
+
const out: string[] = []
|
|
398
|
+
for (const r of res.records) {
|
|
399
|
+
const raw = r.params?.[field!]
|
|
400
|
+
if (raw == null || raw === '') continue
|
|
401
|
+
const v = String(raw)
|
|
402
|
+
if (seen.has(v)) continue
|
|
403
|
+
seen.add(v)
|
|
404
|
+
out.push(v)
|
|
405
|
+
}
|
|
406
|
+
out.sort((a, b) => a.localeCompare(b))
|
|
407
|
+
return out
|
|
408
|
+
},
|
|
409
|
+
enabled: !!resourceId && !!field,
|
|
410
|
+
staleTime: 60_000,
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export const useTimeSeries = (
|
|
415
|
+
query: TimeSeriesQuery | null,
|
|
416
|
+
): UseQueryResult<TimeSeriesResponse> => {
|
|
417
|
+
const client = useAdminClient()
|
|
418
|
+
return useQuery({
|
|
419
|
+
queryKey: ['modern-admin', 'timeseries', query],
|
|
420
|
+
queryFn: () => client.timeseries(query!),
|
|
421
|
+
enabled: query !== null && !!query.resource && !!query.dateField,
|
|
422
|
+
staleTime: 60_000,
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export const useRecordHistory = (
|
|
427
|
+
resourceId: string,
|
|
428
|
+
recordId: string | undefined,
|
|
429
|
+
options: { limit?: number; offset?: number } = {},
|
|
430
|
+
): UseQueryResult<HistoryListResponse> => {
|
|
431
|
+
const client = useAdminClient()
|
|
432
|
+
return useQuery({
|
|
433
|
+
queryKey: [...keyHistory(resourceId, recordId ?? ''), options] as const,
|
|
434
|
+
queryFn: () => client.listHistory(resourceId, recordId!, options),
|
|
435
|
+
enabled: !!resourceId && !!recordId,
|
|
436
|
+
staleTime: 30_000,
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export const useHistoryRevision = (
|
|
441
|
+
resourceId: string,
|
|
442
|
+
recordId: string | undefined,
|
|
443
|
+
revisionId: string | undefined,
|
|
444
|
+
): UseQueryResult<HistoryRevisionResponse> => {
|
|
445
|
+
const client = useAdminClient()
|
|
446
|
+
return useQuery({
|
|
447
|
+
queryKey: keyHistoryRevision(resourceId, recordId ?? '', revisionId ?? ''),
|
|
448
|
+
queryFn: () => client.getHistoryRevision(resourceId, recordId!, revisionId!),
|
|
449
|
+
enabled: !!resourceId && !!recordId && !!revisionId,
|
|
450
|
+
staleTime: 30_000,
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export const useRevertRevision = (
|
|
455
|
+
resourceId: string,
|
|
456
|
+
recordId: string,
|
|
457
|
+
): UseMutationResult<RecordResponse, Error, { revisionId: string; reason?: string }> => {
|
|
458
|
+
const client = useAdminClient()
|
|
459
|
+
const qc = useQueryClient()
|
|
460
|
+
return useMutation({
|
|
461
|
+
mutationFn: ({ revisionId, reason }) =>
|
|
462
|
+
client.revertHistoryRevision(resourceId, recordId, revisionId, { reason }),
|
|
463
|
+
onSuccess: (_data, { revisionId }) => {
|
|
464
|
+
qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
|
|
465
|
+
qc.invalidateQueries({ queryKey: keyShow(resourceId, recordId) })
|
|
466
|
+
qc.invalidateQueries({ queryKey: keyHistory(resourceId, recordId) })
|
|
467
|
+
qc.invalidateQueries({ queryKey: keyHistoryRevision(resourceId, recordId, revisionId) })
|
|
468
|
+
},
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export const useAuditLog = (
|
|
473
|
+
query: AuditLogQuery = {},
|
|
474
|
+
): UseQueryResult<AuditLogResponse> => {
|
|
475
|
+
const client = useAdminClient()
|
|
476
|
+
return useQuery({
|
|
477
|
+
queryKey: keyAuditLog(query),
|
|
478
|
+
queryFn: () => client.listAuditLog(query),
|
|
479
|
+
staleTime: 30_000,
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Cursor-based infinite scroll variant of `useAuditLog`.
|
|
485
|
+
* Each page passes the `at` timestamp of the last entry as the `before` cursor.
|
|
486
|
+
* `pageSize` entries are requested; if the response is full, there are more pages.
|
|
487
|
+
*/
|
|
488
|
+
export const useInfiniteAuditLog = (
|
|
489
|
+
filters: Omit<AuditLogQuery, 'before' | 'offset' | 'limit'>,
|
|
490
|
+
pageSize: number,
|
|
491
|
+
): UseInfiniteQueryResult<InfiniteData<AuditLogResponse>, Error> => {
|
|
492
|
+
const client = useAdminClient()
|
|
493
|
+
return useInfiniteQuery({
|
|
494
|
+
queryKey: ['modern-admin', 'audit-log-infinite', filters],
|
|
495
|
+
queryFn: ({ pageParam }) =>
|
|
496
|
+
client.listAuditLog({
|
|
497
|
+
...filters,
|
|
498
|
+
limit: pageSize + 1,
|
|
499
|
+
before: pageParam as number | undefined,
|
|
500
|
+
}),
|
|
501
|
+
initialPageParam: undefined as number | undefined,
|
|
502
|
+
getNextPageParam: (lastPage) => {
|
|
503
|
+
const events = lastPage.events
|
|
504
|
+
if (events.length <= pageSize) return undefined
|
|
505
|
+
return events[pageSize - 1]!.at
|
|
506
|
+
},
|
|
507
|
+
staleTime: 30_000,
|
|
508
|
+
})
|
|
509
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Header button + dialog that surfaces every <useHotkey> entry that opted
|
|
2
|
+
// into the registry by passing `description`. Pressing `?` toggles it.
|
|
3
|
+
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import {
|
|
6
|
+
Button,
|
|
7
|
+
Kbd,
|
|
8
|
+
KeyboardShortcutsHelp,
|
|
9
|
+
Tooltip,
|
|
10
|
+
TooltipContent,
|
|
11
|
+
TooltipTrigger,
|
|
12
|
+
} from '@modern-admin/ui'
|
|
13
|
+
import { Keyboard } from 'lucide-react'
|
|
14
|
+
import { useHotkey } from './use-hotkey.js'
|
|
15
|
+
import { useRegisteredHotkeys } from './hotkey-registry.js'
|
|
16
|
+
import { useI18n } from './i18n.js'
|
|
17
|
+
|
|
18
|
+
export function HotkeyHelpButton(): React.ReactElement {
|
|
19
|
+
const [open, setOpen] = React.useState(false)
|
|
20
|
+
const items = useRegisteredHotkeys()
|
|
21
|
+
const { t } = useI18n()
|
|
22
|
+
const label = t('common:shortcutsHelp')
|
|
23
|
+
|
|
24
|
+
useHotkey('?', () => setOpen((v) => !v), {
|
|
25
|
+
description: label,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<Tooltip>
|
|
31
|
+
<TooltipTrigger asChild>
|
|
32
|
+
<Button
|
|
33
|
+
variant="ghost"
|
|
34
|
+
size="icon"
|
|
35
|
+
onClick={() => setOpen(true)}
|
|
36
|
+
aria-label={label}
|
|
37
|
+
className="hidden md:inline-flex"
|
|
38
|
+
>
|
|
39
|
+
<Keyboard className="size-4" />
|
|
40
|
+
</Button>
|
|
41
|
+
</TooltipTrigger>
|
|
42
|
+
<TooltipContent className="flex items-center gap-1.5">
|
|
43
|
+
<span>{label}</span>
|
|
44
|
+
<Kbd>?</Kbd>
|
|
45
|
+
</TooltipContent>
|
|
46
|
+
</Tooltip>
|
|
47
|
+
<KeyboardShortcutsHelp
|
|
48
|
+
open={open}
|
|
49
|
+
onOpenChange={setOpen}
|
|
50
|
+
items={items}
|
|
51
|
+
title={label}
|
|
52
|
+
emptyMessage={t('common:shortcutsEmpty')}
|
|
53
|
+
/>
|
|
54
|
+
</>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Process-local registry of currently-mounted hotkeys. `useHotkey` opts
|
|
2
|
+
// into registration by passing `description` in its options; the entry
|
|
3
|
+
// shows up in <KeyboardShortcutsHelp> until its component unmounts.
|
|
4
|
+
//
|
|
5
|
+
// Without a surrounding <HotkeyRegistryProvider> the registry is a
|
|
6
|
+
// no-op so plain `useHotkey` stays usable in isolation (tests etc.).
|
|
7
|
+
|
|
8
|
+
import * as React from 'react'
|
|
9
|
+
|
|
10
|
+
export interface HotkeyDescriptor {
|
|
11
|
+
/** Combo string in `useHotkey` syntax, e.g. `mod+s`, `shift+/`, `esc`. */
|
|
12
|
+
keys: string
|
|
13
|
+
description: string
|
|
14
|
+
group?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface HotkeyRegistryApi {
|
|
18
|
+
register(d: HotkeyDescriptor): () => void
|
|
19
|
+
list: HotkeyDescriptor[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const NOOP_REGISTER: HotkeyRegistryApi['register'] = () => () => {}
|
|
23
|
+
|
|
24
|
+
const HotkeyRegistryContext = React.createContext<HotkeyRegistryApi>({
|
|
25
|
+
register: NOOP_REGISTER,
|
|
26
|
+
list: [],
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export function HotkeyRegistryProvider({
|
|
30
|
+
children,
|
|
31
|
+
}: {
|
|
32
|
+
children: React.ReactNode
|
|
33
|
+
}): React.ReactElement {
|
|
34
|
+
const [list, setList] = React.useState<HotkeyDescriptor[]>([])
|
|
35
|
+
// Stable register: setList is stable from useState, and the closure
|
|
36
|
+
// is captured once via useRef so dependent effects don't re-fire.
|
|
37
|
+
const register = React.useRef<HotkeyRegistryApi['register']>((d) => {
|
|
38
|
+
setList((prev) => [...prev, d])
|
|
39
|
+
return () => {
|
|
40
|
+
setList((prev) => prev.filter((x) => x !== d))
|
|
41
|
+
}
|
|
42
|
+
}).current
|
|
43
|
+
const value = React.useMemo<HotkeyRegistryApi>(
|
|
44
|
+
() => ({ register, list }),
|
|
45
|
+
[register, list],
|
|
46
|
+
)
|
|
47
|
+
return (
|
|
48
|
+
<HotkeyRegistryContext.Provider value={value}>
|
|
49
|
+
{children}
|
|
50
|
+
</HotkeyRegistryContext.Provider>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useRegisteredHotkeys(): HotkeyDescriptor[] {
|
|
55
|
+
return React.useContext(HotkeyRegistryContext).list
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function useHotkeyRegister(): HotkeyRegistryApi['register'] {
|
|
59
|
+
return React.useContext(HotkeyRegistryContext).register
|
|
60
|
+
}
|