@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/provider.tsx
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// React context exposing the AdminClient + a shared QueryClient. Apps wrap
|
|
2
|
+
// their tree once with <ModernAdminProvider client={...}>.
|
|
3
|
+
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
6
|
+
import { AdminClient, type AdminClientOptions } from './client.js'
|
|
7
|
+
import type { ComponentLoader } from './component-loader.js'
|
|
8
|
+
|
|
9
|
+
interface ContextShape {
|
|
10
|
+
client: AdminClient
|
|
11
|
+
components: ComponentLoader | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ModernAdminContext = React.createContext<ContextShape | null>(null)
|
|
15
|
+
|
|
16
|
+
export interface ModernAdminProviderProps {
|
|
17
|
+
client?: AdminClient
|
|
18
|
+
clientOptions?: AdminClientOptions
|
|
19
|
+
queryClient?: QueryClient
|
|
20
|
+
components?: ComponentLoader
|
|
21
|
+
children: React.ReactNode
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const defaultQueryClient = (): QueryClient =>
|
|
25
|
+
new QueryClient({
|
|
26
|
+
defaultOptions: {
|
|
27
|
+
queries: {
|
|
28
|
+
staleTime: 5_000,
|
|
29
|
+
refetchOnWindowFocus: false,
|
|
30
|
+
retry: 1,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export function ModernAdminProvider({
|
|
36
|
+
client,
|
|
37
|
+
clientOptions,
|
|
38
|
+
queryClient,
|
|
39
|
+
components,
|
|
40
|
+
children,
|
|
41
|
+
}: ModernAdminProviderProps): React.ReactElement {
|
|
42
|
+
const resolvedClient = React.useMemo(
|
|
43
|
+
() => client ?? new AdminClient(clientOptions),
|
|
44
|
+
[client, clientOptions],
|
|
45
|
+
)
|
|
46
|
+
const resolvedQueryClient = React.useMemo(
|
|
47
|
+
() => queryClient ?? defaultQueryClient(),
|
|
48
|
+
[queryClient],
|
|
49
|
+
)
|
|
50
|
+
const value = React.useMemo<ContextShape>(
|
|
51
|
+
() => ({ client: resolvedClient, components: components ?? null }),
|
|
52
|
+
[resolvedClient, components],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<QueryClientProvider client={resolvedQueryClient}>
|
|
57
|
+
<ModernAdminContext.Provider value={value}>{children}</ModernAdminContext.Provider>
|
|
58
|
+
</QueryClientProvider>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const useAdminContext = (): ContextShape => {
|
|
63
|
+
const ctx = React.useContext(ModernAdminContext)
|
|
64
|
+
if (!ctx) {
|
|
65
|
+
throw new Error('useAdminContext must be used inside <ModernAdminProvider />')
|
|
66
|
+
}
|
|
67
|
+
return ctx
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const useAdminClient = (): AdminClient => useAdminContext().client
|
package/src/realtime.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Realtime helper for the React client. Stays transport-agnostic — host apps
|
|
2
|
+
// wire up socket.io / SSE / WebSocket and pass an `onEvent` subscriber here.
|
|
3
|
+
|
|
4
|
+
import { useEffect } from 'react'
|
|
5
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
6
|
+
|
|
7
|
+
export interface RealtimeWireEvent {
|
|
8
|
+
kind: 'created' | 'updated' | 'deleted'
|
|
9
|
+
resourceId: string
|
|
10
|
+
recordId?: string
|
|
11
|
+
record?: Record<string, unknown>
|
|
12
|
+
actorId?: string
|
|
13
|
+
at: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type RealtimeSubscriber = (
|
|
17
|
+
handler: (event: RealtimeWireEvent) => void,
|
|
18
|
+
) => () => void
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Subscribe to wire events from the host transport and invalidate the
|
|
22
|
+
* matching TanStack Query keys. Mutations on a resource invalidate every
|
|
23
|
+
* `[resourceId, ...]` query, which covers list/show/count.
|
|
24
|
+
*/
|
|
25
|
+
export function useRealtimeInvalidation(subscriber: RealtimeSubscriber): void {
|
|
26
|
+
const queryClient = useQueryClient()
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
return subscriber((event) => {
|
|
29
|
+
const queryKey = [event.resourceId] as const
|
|
30
|
+
void queryClient.invalidateQueries({ queryKey })
|
|
31
|
+
})
|
|
32
|
+
}, [queryClient, subscriber])
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Optimistic local update — apply a deletion immediately to a list query so
|
|
37
|
+
* the row disappears before the round-trip refetch finishes.
|
|
38
|
+
*/
|
|
39
|
+
export function applyDeletionLocally(
|
|
40
|
+
queryClient: ReturnType<typeof useQueryClient>,
|
|
41
|
+
resourceId: string,
|
|
42
|
+
recordId: string,
|
|
43
|
+
): void {
|
|
44
|
+
queryClient.setQueriesData<unknown>({ queryKey: [resourceId, 'list'] }, (data: unknown) => {
|
|
45
|
+
const list = data as { records?: Array<{ id: string }>; meta?: { total: number } } | undefined
|
|
46
|
+
if (!list || !Array.isArray(list.records)) return data
|
|
47
|
+
const next = list.records.filter((r) => r.id !== recordId)
|
|
48
|
+
if (next.length === list.records.length) return data
|
|
49
|
+
return {
|
|
50
|
+
...list,
|
|
51
|
+
records: next,
|
|
52
|
+
meta: list.meta ? { ...list.meta, total: Math.max(0, list.meta.total - 1) } : list.meta,
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// Reference-field helpers. Three surfaces:
|
|
2
|
+
// - <ReferenceLink>: read-only display (list/show views) — fetches the
|
|
3
|
+
// referenced record's title and renders it as a hyperlink to its show page.
|
|
4
|
+
// - <ReferenceCombobox>: single-value edit control — Command-driven popover
|
|
5
|
+
// with live search against the referenced resource's `search` action.
|
|
6
|
+
// - <ReferenceMultiCombobox>: multi-value variant for many-to-many fields
|
|
7
|
+
// (an array of foreign IDs); selected items render as removable chips.
|
|
8
|
+
|
|
9
|
+
import * as React from 'react'
|
|
10
|
+
import {
|
|
11
|
+
Badge,
|
|
12
|
+
Button,
|
|
13
|
+
Command,
|
|
14
|
+
CommandEmpty,
|
|
15
|
+
CommandGroup,
|
|
16
|
+
CommandInput,
|
|
17
|
+
CommandItem,
|
|
18
|
+
CommandList,
|
|
19
|
+
Popover,
|
|
20
|
+
PopoverContent,
|
|
21
|
+
PopoverTrigger,
|
|
22
|
+
cn,
|
|
23
|
+
} from '@modern-admin/ui'
|
|
24
|
+
import { Check, ChevronsUpDown, ExternalLink, X } from 'lucide-react'
|
|
25
|
+
import { useQueries, useQuery } from '@tanstack/react-query'
|
|
26
|
+
import { useAdminClient } from './provider.js'
|
|
27
|
+
import { useResource, useSearchRecords } from './hooks.js'
|
|
28
|
+
import { useI18n } from './i18n.js'
|
|
29
|
+
import { Link } from './router.js'
|
|
30
|
+
|
|
31
|
+
/** Read-only badge that links to the referenced record's show page.
|
|
32
|
+
*
|
|
33
|
+
* When `populated` is provided (e.g. supplied by the list/show endpoint via
|
|
34
|
+
* `record.populated[propertyPath]`) the title is rendered directly from it
|
|
35
|
+
* and no `show` request is fired — this is what prevents the N+1 fetch
|
|
36
|
+
* storm on list pages with reference columns. */
|
|
37
|
+
export function ReferenceLink({
|
|
38
|
+
resourceId,
|
|
39
|
+
recordId,
|
|
40
|
+
fallback,
|
|
41
|
+
showIcon = false,
|
|
42
|
+
className,
|
|
43
|
+
populated,
|
|
44
|
+
}: {
|
|
45
|
+
resourceId: string
|
|
46
|
+
recordId: string | number | null | undefined
|
|
47
|
+
fallback?: React.ReactNode
|
|
48
|
+
showIcon?: boolean
|
|
49
|
+
className?: string
|
|
50
|
+
populated?: { id?: string; title?: string } | null
|
|
51
|
+
}): React.ReactElement | null {
|
|
52
|
+
const client = useAdminClient()
|
|
53
|
+
const id = recordId == null ? '' : String(recordId)
|
|
54
|
+
const hasPopulated = !!(id && populated && populated.title)
|
|
55
|
+
const referencedResource = useResource(resourceId)
|
|
56
|
+
// When the SPA config has been loaded, its `actions` list is already
|
|
57
|
+
// filtered against the current admin's access. Missing `show` = no
|
|
58
|
+
// permission to view the referenced record → render plain text instead
|
|
59
|
+
// of a clickable link. While the config is still loading (`undefined`)
|
|
60
|
+
// we default to "linkable" so the first paint matches the steady state
|
|
61
|
+
// for the common case.
|
|
62
|
+
const canShow =
|
|
63
|
+
referencedResource === undefined
|
|
64
|
+
? true
|
|
65
|
+
: referencedResource.actions.some((a) => a.name === 'show')
|
|
66
|
+
const { data } = useQuery({
|
|
67
|
+
queryKey: ['modern-admin', resourceId, 'show', id],
|
|
68
|
+
queryFn: () => client.show(resourceId, id),
|
|
69
|
+
enabled: !!id && !hasPopulated && canShow,
|
|
70
|
+
staleTime: 30_000,
|
|
71
|
+
})
|
|
72
|
+
if (!id) return (fallback as React.ReactElement | null) ?? null
|
|
73
|
+
const title = (hasPopulated ? populated!.title : data?.record?.title) || `#${id}`
|
|
74
|
+
if (!canShow) {
|
|
75
|
+
return (
|
|
76
|
+
<span className={cn('inline-flex items-center', className)}>
|
|
77
|
+
<Badge variant="secondary">{title}</Badge>
|
|
78
|
+
</span>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
return (
|
|
82
|
+
<Link
|
|
83
|
+
to={{ name: 'show', resourceId, recordId: id }}
|
|
84
|
+
className={cn('inline-flex items-center gap-1 hover:underline', className)}
|
|
85
|
+
onClick={(e) => e.stopPropagation()}
|
|
86
|
+
>
|
|
87
|
+
<Badge variant="secondary">{title}</Badge>
|
|
88
|
+
{showIcon && <ExternalLink className="size-3 opacity-50" />}
|
|
89
|
+
</Link>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Combobox bound to a referenced resource's `search` action. */
|
|
94
|
+
export function ReferenceCombobox({
|
|
95
|
+
referenceResourceId,
|
|
96
|
+
value,
|
|
97
|
+
onChange,
|
|
98
|
+
disabled,
|
|
99
|
+
placeholder,
|
|
100
|
+
className,
|
|
101
|
+
}: {
|
|
102
|
+
referenceResourceId: string
|
|
103
|
+
value: string | number | null | undefined
|
|
104
|
+
onChange(next: string | null): void
|
|
105
|
+
disabled?: boolean
|
|
106
|
+
placeholder?: string
|
|
107
|
+
/** Extra classes applied to the trigger button (e.g. height override). */
|
|
108
|
+
className?: string
|
|
109
|
+
}): React.ReactElement {
|
|
110
|
+
const [open, setOpen] = React.useState(false)
|
|
111
|
+
const [query, setQuery] = React.useState('')
|
|
112
|
+
const debounced = useDebounced(query, 250)
|
|
113
|
+
const client = useAdminClient()
|
|
114
|
+
const { t } = useI18n()
|
|
115
|
+
const resolvedPlaceholder = placeholder ?? t('common:select')
|
|
116
|
+
|
|
117
|
+
// The currently-selected record (loaded once for the trigger label).
|
|
118
|
+
const selected = useQuery({
|
|
119
|
+
queryKey: ['modern-admin', referenceResourceId, 'show', value],
|
|
120
|
+
queryFn: () => client.show(referenceResourceId, String(value)),
|
|
121
|
+
enabled: value != null && value !== '',
|
|
122
|
+
staleTime: 30_000,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const search = useSearchRecords(referenceResourceId, debounced, open)
|
|
126
|
+
const items = search.data?.records ?? []
|
|
127
|
+
|
|
128
|
+
const _title = selected.data?.record?.title
|
|
129
|
+
const selectedLabel =
|
|
130
|
+
_title
|
|
131
|
+
? `${_title} <${value}>`
|
|
132
|
+
: value != null && value !== ''
|
|
133
|
+
? `#${value}`
|
|
134
|
+
: ''
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
138
|
+
<PopoverTrigger asChild>
|
|
139
|
+
<Button
|
|
140
|
+
variant="outline"
|
|
141
|
+
role="combobox"
|
|
142
|
+
aria-expanded={open}
|
|
143
|
+
disabled={disabled}
|
|
144
|
+
className={cn('w-full justify-between font-normal', className)}
|
|
145
|
+
>
|
|
146
|
+
<span
|
|
147
|
+
className={cn('truncate', !selectedLabel && 'text-muted-foreground')}
|
|
148
|
+
title={selectedLabel || undefined}
|
|
149
|
+
>
|
|
150
|
+
{selectedLabel || resolvedPlaceholder}
|
|
151
|
+
</span>
|
|
152
|
+
<ChevronsUpDown className="size-4 shrink-0 opacity-50" />
|
|
153
|
+
</Button>
|
|
154
|
+
</PopoverTrigger>
|
|
155
|
+
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
156
|
+
<Command shouldFilter={false}>
|
|
157
|
+
<CommandInput
|
|
158
|
+
placeholder={t('common:searchPlaceholder')}
|
|
159
|
+
value={query}
|
|
160
|
+
onValueChange={setQuery}
|
|
161
|
+
/>
|
|
162
|
+
<CommandList>
|
|
163
|
+
{search.isLoading && <div className="p-3 text-sm text-muted-foreground">{t('common:loading')}</div>}
|
|
164
|
+
{!search.isLoading && items.length === 0 && (
|
|
165
|
+
<CommandEmpty>{t('common:noRecords')}</CommandEmpty>
|
|
166
|
+
)}
|
|
167
|
+
{value != null && value !== '' && (
|
|
168
|
+
<CommandGroup>
|
|
169
|
+
<CommandItem
|
|
170
|
+
onSelect={() => {
|
|
171
|
+
onChange(null)
|
|
172
|
+
setOpen(false)
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
<Check className="size-4 opacity-0" />
|
|
176
|
+
<span className="text-muted-foreground">{t('common:clearSelection')}</span>
|
|
177
|
+
</CommandItem>
|
|
178
|
+
</CommandGroup>
|
|
179
|
+
)}
|
|
180
|
+
<CommandGroup>
|
|
181
|
+
{items.map((r) => {
|
|
182
|
+
const isSelected = String(r.id) === String(value ?? '')
|
|
183
|
+
return (
|
|
184
|
+
<CommandItem
|
|
185
|
+
key={r.id}
|
|
186
|
+
value={r.id}
|
|
187
|
+
onSelect={() => {
|
|
188
|
+
onChange(r.id)
|
|
189
|
+
setOpen(false)
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
<Check className={cn('size-4', isSelected ? 'opacity-100' : 'opacity-0')} />
|
|
193
|
+
<span className="truncate" title={r.title ? `${r.title} <${r.id}>` : `#${r.id}`}>
|
|
194
|
+
{r.title ? `${r.title} <${r.id}>` : `#${r.id}`}
|
|
195
|
+
</span>
|
|
196
|
+
</CommandItem>
|
|
197
|
+
)
|
|
198
|
+
})}
|
|
199
|
+
</CommandGroup>
|
|
200
|
+
</CommandList>
|
|
201
|
+
</Command>
|
|
202
|
+
</PopoverContent>
|
|
203
|
+
</Popover>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Renders a list of comma-separated badge links, one per foreign key.
|
|
208
|
+
*
|
|
209
|
+
* When `populated` + `populatedKeyPrefix` are provided, each item is looked up
|
|
210
|
+
* via `populated[`${prefix}.${id}`]` and threaded into its `<ReferenceLink>`
|
|
211
|
+
* to suppress per-row `show` requests. The key shape matches what the m2m
|
|
212
|
+
* feature's read-hook writes (see `packages/feature-m2m`) and what the list
|
|
213
|
+
* action's `populateReferences` helper writes for array references. */
|
|
214
|
+
export function ReferenceLinkList({
|
|
215
|
+
resourceId,
|
|
216
|
+
recordIds,
|
|
217
|
+
className,
|
|
218
|
+
populated,
|
|
219
|
+
populatedKeyPrefix,
|
|
220
|
+
}: {
|
|
221
|
+
resourceId: string
|
|
222
|
+
recordIds: ReadonlyArray<string | number>
|
|
223
|
+
className?: string
|
|
224
|
+
populated?: Record<string, unknown>
|
|
225
|
+
populatedKeyPrefix?: string
|
|
226
|
+
}): React.ReactElement {
|
|
227
|
+
if (!recordIds || recordIds.length === 0) {
|
|
228
|
+
return <span className="text-muted-foreground">—</span>
|
|
229
|
+
}
|
|
230
|
+
return (
|
|
231
|
+
<div className={cn('flex flex-wrap gap-1', className)}>
|
|
232
|
+
{recordIds.map((id) => {
|
|
233
|
+
const entry =
|
|
234
|
+
populated && populatedKeyPrefix
|
|
235
|
+
? (populated[`${populatedKeyPrefix}.${id}`] as
|
|
236
|
+
| { id?: string; title?: string }
|
|
237
|
+
| undefined)
|
|
238
|
+
: undefined
|
|
239
|
+
return (
|
|
240
|
+
<ReferenceLink
|
|
241
|
+
key={String(id)}
|
|
242
|
+
resourceId={resourceId}
|
|
243
|
+
recordId={id}
|
|
244
|
+
populated={entry}
|
|
245
|
+
/>
|
|
246
|
+
)
|
|
247
|
+
})}
|
|
248
|
+
</div>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Multi-select combobox for many-to-many references. Renders selected items
|
|
253
|
+
* as removable chips and feeds search results into a checkable command list. */
|
|
254
|
+
export function ReferenceMultiCombobox({
|
|
255
|
+
referenceResourceId,
|
|
256
|
+
value,
|
|
257
|
+
onChange,
|
|
258
|
+
disabled,
|
|
259
|
+
placeholder,
|
|
260
|
+
}: {
|
|
261
|
+
referenceResourceId: string
|
|
262
|
+
value: ReadonlyArray<string | number> | null | undefined
|
|
263
|
+
onChange(next: Array<string | number>): void
|
|
264
|
+
disabled?: boolean
|
|
265
|
+
placeholder?: string
|
|
266
|
+
}): React.ReactElement {
|
|
267
|
+
const [open, setOpen] = React.useState(false)
|
|
268
|
+
const [query, setQuery] = React.useState('')
|
|
269
|
+
const debounced = useDebounced(query, 250)
|
|
270
|
+
const client = useAdminClient()
|
|
271
|
+
const { t } = useI18n()
|
|
272
|
+
const resolvedPlaceholder = placeholder ?? t('common:select')
|
|
273
|
+
const ids = React.useMemo(() => (value ?? []).map(String), [value])
|
|
274
|
+
|
|
275
|
+
// Resolve labels per-id so adding/removing one item only fetches the new
|
|
276
|
+
// one. Sharing the cache key with <ReferenceLink>'s single-record query
|
|
277
|
+
// means already-known titles render instantly without flicker.
|
|
278
|
+
const titleQueries = useQueries({
|
|
279
|
+
queries: ids.map((id) => ({
|
|
280
|
+
queryKey: ['modern-admin', referenceResourceId, 'show', id],
|
|
281
|
+
queryFn: () => client.show(referenceResourceId, id),
|
|
282
|
+
staleTime: 30_000,
|
|
283
|
+
})),
|
|
284
|
+
})
|
|
285
|
+
const chips = React.useMemo(
|
|
286
|
+
() =>
|
|
287
|
+
ids.map((id, i) => {
|
|
288
|
+
const t = titleQueries[i]?.data?.record?.title
|
|
289
|
+
return { id, title: t ? `${t} <${id}>` : `#${id}` }
|
|
290
|
+
}),
|
|
291
|
+
[ids, titleQueries],
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
const search = useSearchRecords(referenceResourceId, debounced, open)
|
|
295
|
+
const items = search.data?.records ?? []
|
|
296
|
+
|
|
297
|
+
const toggle = (id: string | number): void => {
|
|
298
|
+
const sid = String(id)
|
|
299
|
+
const next = ids.includes(sid) ? ids.filter((x) => x !== sid) : [...ids, sid]
|
|
300
|
+
onChange(next)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const remove = (id: string): void => {
|
|
304
|
+
onChange(ids.filter((x) => x !== id))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<div className="space-y-2">
|
|
309
|
+
{chips.length > 0 && (
|
|
310
|
+
<div className="flex flex-wrap gap-1">
|
|
311
|
+
{chips.map((s) => (
|
|
312
|
+
<Badge key={s.id} variant="secondary" className="gap-1 pr-1">
|
|
313
|
+
{s.title}
|
|
314
|
+
<button
|
|
315
|
+
type="button"
|
|
316
|
+
aria-label={t('common:removeItem', { title: s.title })}
|
|
317
|
+
disabled={disabled}
|
|
318
|
+
onClick={() => remove(s.id)}
|
|
319
|
+
className="rounded-sm opacity-60 hover:opacity-100"
|
|
320
|
+
>
|
|
321
|
+
<X className="size-3" />
|
|
322
|
+
</button>
|
|
323
|
+
</Badge>
|
|
324
|
+
))}
|
|
325
|
+
</div>
|
|
326
|
+
)}
|
|
327
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
328
|
+
<PopoverTrigger asChild>
|
|
329
|
+
<Button
|
|
330
|
+
variant="outline"
|
|
331
|
+
role="combobox"
|
|
332
|
+
aria-expanded={open}
|
|
333
|
+
disabled={disabled}
|
|
334
|
+
className="w-full justify-between font-normal"
|
|
335
|
+
>
|
|
336
|
+
<span className="truncate text-muted-foreground">
|
|
337
|
+
{ids.length > 0 ? t('common:nSelectedAddMore', { count: ids.length }) : resolvedPlaceholder}
|
|
338
|
+
</span>
|
|
339
|
+
<ChevronsUpDown className="size-4 shrink-0 opacity-50" />
|
|
340
|
+
</Button>
|
|
341
|
+
</PopoverTrigger>
|
|
342
|
+
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
343
|
+
<Command shouldFilter={false}>
|
|
344
|
+
<CommandInput placeholder={t('common:searchPlaceholder')} value={query} onValueChange={setQuery} />
|
|
345
|
+
<CommandList>
|
|
346
|
+
{search.isLoading && (
|
|
347
|
+
<div className="p-3 text-sm text-muted-foreground">{t('common:loading')}</div>
|
|
348
|
+
)}
|
|
349
|
+
{!search.isLoading && items.length === 0 && (
|
|
350
|
+
<CommandEmpty>{t('common:noRecords')}</CommandEmpty>
|
|
351
|
+
)}
|
|
352
|
+
<CommandGroup>
|
|
353
|
+
{items.map((r) => {
|
|
354
|
+
const isSelected = ids.includes(String(r.id))
|
|
355
|
+
return (
|
|
356
|
+
<CommandItem
|
|
357
|
+
key={r.id}
|
|
358
|
+
value={String(r.id)}
|
|
359
|
+
onSelect={() => toggle(r.id)}
|
|
360
|
+
>
|
|
361
|
+
<Check
|
|
362
|
+
className={cn('size-4', isSelected ? 'opacity-100' : 'opacity-0')}
|
|
363
|
+
/>
|
|
364
|
+
<span className="truncate" title={r.title ? `${r.title} <${r.id}>` : `#${r.id}`}>
|
|
365
|
+
{r.title ? `${r.title} <${r.id}>` : `#${r.id}`}
|
|
366
|
+
</span>
|
|
367
|
+
</CommandItem>
|
|
368
|
+
)
|
|
369
|
+
})}
|
|
370
|
+
</CommandGroup>
|
|
371
|
+
</CommandList>
|
|
372
|
+
</Command>
|
|
373
|
+
</PopoverContent>
|
|
374
|
+
</Popover>
|
|
375
|
+
</div>
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const useDebounced = <T,>(value: T, ms: number): T => {
|
|
380
|
+
const [v, setV] = React.useState(value)
|
|
381
|
+
React.useEffect(() => {
|
|
382
|
+
const id = setTimeout(() => setV(value), ms)
|
|
383
|
+
return () => clearTimeout(id)
|
|
384
|
+
}, [value, ms])
|
|
385
|
+
return v
|
|
386
|
+
}
|
package/src/relations.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { PropertyJSON, RelatedResource, ResourceJSON } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Prisma/ORM reverse one-to-many relation fields arrive as array references.
|
|
5
|
+
* Their values are usually not included in record/list payloads, so rendering
|
|
6
|
+
* them as normal fields produces empty placeholders. They belong in related
|
|
7
|
+
* record tables instead.
|
|
8
|
+
*/
|
|
9
|
+
export const isToManyReferenceProperty = (property: PropertyJSON): boolean =>
|
|
10
|
+
property.type === 'reference' && property.isArray && property.reference !== null
|
|
11
|
+
|
|
12
|
+
export const visibleRecordProperties = (
|
|
13
|
+
properties: ReadonlyArray<PropertyJSON>,
|
|
14
|
+
view: 'list' | 'show' | 'edit' | 'filter',
|
|
15
|
+
): PropertyJSON[] =>
|
|
16
|
+
properties.filter((property) =>
|
|
17
|
+
property.visibility[view] && !isToManyReferenceProperty(property),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const relatedKey = (related: RelatedResource): string =>
|
|
21
|
+
`${related.resourceId}::${related.foreignKey}`
|
|
22
|
+
|
|
23
|
+
export const resolveRelatedResources = (
|
|
24
|
+
resource: ResourceJSON,
|
|
25
|
+
allResources: ReadonlyArray<ResourceJSON>,
|
|
26
|
+
): RelatedResource[] => {
|
|
27
|
+
const byId = new Map(allResources.map((item) => [item.id, item]))
|
|
28
|
+
const result = [...(resource.relatedResources ?? [])]
|
|
29
|
+
const seen = new Set(result.map(relatedKey))
|
|
30
|
+
|
|
31
|
+
for (const property of resource.properties) {
|
|
32
|
+
if (!isToManyReferenceProperty(property) || !property.reference) continue
|
|
33
|
+
|
|
34
|
+
const target = byId.get(property.reference)
|
|
35
|
+
if (!target) continue
|
|
36
|
+
|
|
37
|
+
const foreignKey = target.properties.find((candidate) =>
|
|
38
|
+
!candidate.isArray && candidate.reference === resource.id,
|
|
39
|
+
)
|
|
40
|
+
if (!foreignKey) continue
|
|
41
|
+
|
|
42
|
+
const related: RelatedResource = {
|
|
43
|
+
resourceId: target.id,
|
|
44
|
+
foreignKey: foreignKey.path,
|
|
45
|
+
label: property.label,
|
|
46
|
+
}
|
|
47
|
+
const key = relatedKey(related)
|
|
48
|
+
if (seen.has(key)) continue
|
|
49
|
+
|
|
50
|
+
seen.add(key)
|
|
51
|
+
result.push(related)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return result
|
|
55
|
+
}
|