@modern-admin/react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/dist/action-guard.d.ts +13 -0
  2. package/dist/action-guard.d.ts.map +1 -0
  3. package/dist/action-guard.js +15 -0
  4. package/dist/action-guard.js.map +1 -0
  5. package/dist/action-menu.d.ts +17 -0
  6. package/dist/action-menu.d.ts.map +1 -0
  7. package/dist/action-menu.jsx +80 -0
  8. package/dist/action-menu.jsx.map +1 -0
  9. package/dist/admin-app.d.ts +23 -0
  10. package/dist/admin-app.d.ts.map +1 -0
  11. package/dist/admin-app.jsx +407 -0
  12. package/dist/admin-app.jsx.map +1 -0
  13. package/dist/admin-router.d.ts +29 -0
  14. package/dist/admin-router.d.ts.map +1 -0
  15. package/dist/admin-router.jsx +215 -0
  16. package/dist/admin-router.jsx.map +1 -0
  17. package/dist/breadcrumbs.d.ts +17 -0
  18. package/dist/breadcrumbs.d.ts.map +1 -0
  19. package/dist/breadcrumbs.jsx +40 -0
  20. package/dist/breadcrumbs.jsx.map +1 -0
  21. package/dist/client.d.ts +526 -0
  22. package/dist/client.d.ts.map +1 -0
  23. package/dist/client.js +582 -0
  24. package/dist/client.js.map +1 -0
  25. package/dist/component-loader.d.ts +10 -0
  26. package/dist/component-loader.d.ts.map +1 -0
  27. package/dist/component-loader.js +23 -0
  28. package/dist/component-loader.js.map +1 -0
  29. package/dist/components/ai-assistant-widget.d.ts +3 -0
  30. package/dist/components/ai-assistant-widget.d.ts.map +1 -0
  31. package/dist/components/ai-assistant-widget.jsx +390 -0
  32. package/dist/components/ai-assistant-widget.jsx.map +1 -0
  33. package/dist/components/ai-fill-dialog.d.ts +9 -0
  34. package/dist/components/ai-fill-dialog.d.ts.map +1 -0
  35. package/dist/components/ai-fill-dialog.jsx +105 -0
  36. package/dist/components/ai-fill-dialog.jsx.map +1 -0
  37. package/dist/components/chart-builder-dialog.d.ts +10 -0
  38. package/dist/components/chart-builder-dialog.d.ts.map +1 -0
  39. package/dist/components/chart-builder-dialog.jsx +433 -0
  40. package/dist/components/chart-builder-dialog.jsx.map +1 -0
  41. package/dist/components/chart-widget.d.ts +12 -0
  42. package/dist/components/chart-widget.d.ts.map +1 -0
  43. package/dist/components/chart-widget.jsx +365 -0
  44. package/dist/components/chart-widget.jsx.map +1 -0
  45. package/dist/components/global-search-dialog.d.ts +7 -0
  46. package/dist/components/global-search-dialog.d.ts.map +1 -0
  47. package/dist/components/global-search-dialog.jsx +187 -0
  48. package/dist/components/global-search-dialog.jsx.map +1 -0
  49. package/dist/components/group-settings-dialog.d.ts +13 -0
  50. package/dist/components/group-settings-dialog.d.ts.map +1 -0
  51. package/dist/components/group-settings-dialog.jsx +53 -0
  52. package/dist/components/group-settings-dialog.jsx.map +1 -0
  53. package/dist/components/move-chart-dialog.d.ts +18 -0
  54. package/dist/components/move-chart-dialog.d.ts.map +1 -0
  55. package/dist/components/move-chart-dialog.jsx +68 -0
  56. package/dist/components/move-chart-dialog.jsx.map +1 -0
  57. package/dist/components/reference-multi-table-dialog.d.ts +12 -0
  58. package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
  59. package/dist/components/reference-multi-table-dialog.jsx +126 -0
  60. package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
  61. package/dist/components/related-records-tabs.d.ts +8 -0
  62. package/dist/components/related-records-tabs.d.ts.map +1 -0
  63. package/dist/components/related-records-tabs.jsx +75 -0
  64. package/dist/components/related-records-tabs.jsx.map +1 -0
  65. package/dist/components/revisions-button.d.ts +7 -0
  66. package/dist/components/revisions-button.d.ts.map +1 -0
  67. package/dist/components/revisions-button.jsx +152 -0
  68. package/dist/components/revisions-button.jsx.map +1 -0
  69. package/dist/components/wizard-form.d.ts +43 -0
  70. package/dist/components/wizard-form.d.ts.map +1 -0
  71. package/dist/components/wizard-form.jsx +136 -0
  72. package/dist/components/wizard-form.jsx.map +1 -0
  73. package/dist/dashboard/time-series.d.ts +20 -0
  74. package/dist/dashboard/time-series.d.ts.map +1 -0
  75. package/dist/dashboard/time-series.js +108 -0
  76. package/dist/dashboard/time-series.js.map +1 -0
  77. package/dist/dialogs.d.ts +35 -0
  78. package/dist/dialogs.d.ts.map +1 -0
  79. package/dist/dialogs.jsx +152 -0
  80. package/dist/dialogs.jsx.map +1 -0
  81. package/dist/export.d.ts +39 -0
  82. package/dist/export.d.ts.map +1 -0
  83. package/dist/export.js +114 -0
  84. package/dist/export.js.map +1 -0
  85. package/dist/extension-registry.d.ts +122 -0
  86. package/dist/extension-registry.d.ts.map +1 -0
  87. package/dist/extension-registry.js +93 -0
  88. package/dist/extension-registry.js.map +1 -0
  89. package/dist/header-controls.d.ts +4 -0
  90. package/dist/header-controls.d.ts.map +1 -0
  91. package/dist/header-controls.jsx +70 -0
  92. package/dist/header-controls.jsx.map +1 -0
  93. package/dist/hooks.d.ts +104 -0
  94. package/dist/hooks.d.ts.map +1 -0
  95. package/dist/hooks.js +374 -0
  96. package/dist/hooks.js.map +1 -0
  97. package/dist/hotkey-help.d.ts +3 -0
  98. package/dist/hotkey-help.d.ts.map +1 -0
  99. package/dist/hotkey-help.jsx +32 -0
  100. package/dist/hotkey-help.jsx.map +1 -0
  101. package/dist/hotkey-registry.d.ts +18 -0
  102. package/dist/hotkey-registry.d.ts.map +1 -0
  103. package/dist/hotkey-registry.jsx +34 -0
  104. package/dist/hotkey-registry.jsx.map +1 -0
  105. package/dist/i18n.d.ts +74 -0
  106. package/dist/i18n.d.ts.map +1 -0
  107. package/dist/i18n.jsx +127 -0
  108. package/dist/i18n.jsx.map +1 -0
  109. package/dist/index.d.ts +35 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +36 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/notify.d.ts +41 -0
  114. package/dist/notify.d.ts.map +1 -0
  115. package/dist/notify.jsx +58 -0
  116. package/dist/notify.jsx.map +1 -0
  117. package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
  118. package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
  119. package/dist/pages/ai-assistant-settings-section.jsx +126 -0
  120. package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
  121. package/dist/pages/audit-log-page.d.ts +3 -0
  122. package/dist/pages/audit-log-page.d.ts.map +1 -0
  123. package/dist/pages/audit-log-page.jsx +354 -0
  124. package/dist/pages/audit-log-page.jsx.map +1 -0
  125. package/dist/pages/edit-page.d.ts +7 -0
  126. package/dist/pages/edit-page.d.ts.map +1 -0
  127. package/dist/pages/edit-page.jsx +614 -0
  128. package/dist/pages/edit-page.jsx.map +1 -0
  129. package/dist/pages/export-dialog.d.ts +11 -0
  130. package/dist/pages/export-dialog.d.ts.map +1 -0
  131. package/dist/pages/export-dialog.jsx +102 -0
  132. package/dist/pages/export-dialog.jsx.map +1 -0
  133. package/dist/pages/home-page.d.ts +3 -0
  134. package/dist/pages/home-page.d.ts.map +1 -0
  135. package/dist/pages/home-page.jsx +211 -0
  136. package/dist/pages/home-page.jsx.map +1 -0
  137. package/dist/pages/list-page.d.ts +42 -0
  138. package/dist/pages/list-page.d.ts.map +1 -0
  139. package/dist/pages/list-page.jsx +1596 -0
  140. package/dist/pages/list-page.jsx.map +1 -0
  141. package/dist/pages/login-page.d.ts +11 -0
  142. package/dist/pages/login-page.d.ts.map +1 -0
  143. package/dist/pages/login-page.jsx +157 -0
  144. package/dist/pages/login-page.jsx.map +1 -0
  145. package/dist/pages/settings-page.d.ts +5 -0
  146. package/dist/pages/settings-page.d.ts.map +1 -0
  147. package/dist/pages/settings-page.jsx +787 -0
  148. package/dist/pages/settings-page.jsx.map +1 -0
  149. package/dist/pages/settings-shared.d.ts +51 -0
  150. package/dist/pages/settings-shared.d.ts.map +1 -0
  151. package/dist/pages/settings-shared.jsx +66 -0
  152. package/dist/pages/settings-shared.jsx.map +1 -0
  153. package/dist/pages/show-page.d.ts +7 -0
  154. package/dist/pages/show-page.d.ts.map +1 -0
  155. package/dist/pages/show-page.jsx +147 -0
  156. package/dist/pages/show-page.jsx.map +1 -0
  157. package/dist/pages/wizard-create-page.d.ts +14 -0
  158. package/dist/pages/wizard-create-page.d.ts.map +1 -0
  159. package/dist/pages/wizard-create-page.jsx +106 -0
  160. package/dist/pages/wizard-create-page.jsx.map +1 -0
  161. package/dist/property-renderer.d.ts +8 -0
  162. package/dist/property-renderer.d.ts.map +1 -0
  163. package/dist/property-renderer.jsx +690 -0
  164. package/dist/property-renderer.jsx.map +1 -0
  165. package/dist/provider.d.ts +20 -0
  166. package/dist/provider.d.ts.map +1 -0
  167. package/dist/provider.jsx +32 -0
  168. package/dist/provider.jsx.map +1 -0
  169. package/dist/realtime.d.ts +22 -0
  170. package/dist/realtime.d.ts.map +1 -0
  171. package/dist/realtime.js +38 -0
  172. package/dist/realtime.js.map +1 -0
  173. package/dist/reference.d.ts +52 -0
  174. package/dist/reference.d.ts.map +1 -0
  175. package/dist/reference.jsx +224 -0
  176. package/dist/reference.jsx.map +1 -0
  177. package/dist/relations.d.ts +11 -0
  178. package/dist/relations.d.ts.map +1 -0
  179. package/dist/relations.js +36 -0
  180. package/dist/relations.js.map +1 -0
  181. package/dist/router.d.ts +82 -0
  182. package/dist/router.d.ts.map +1 -0
  183. package/dist/router.jsx +187 -0
  184. package/dist/router.jsx.map +1 -0
  185. package/dist/show-when.d.ts +7 -0
  186. package/dist/show-when.d.ts.map +1 -0
  187. package/dist/show-when.js +77 -0
  188. package/dist/show-when.js.map +1 -0
  189. package/dist/types.d.ts +194 -0
  190. package/dist/types.d.ts.map +1 -0
  191. package/dist/types.js +18 -0
  192. package/dist/types.js.map +1 -0
  193. package/dist/use-dashboard-charts.d.ts +93 -0
  194. package/dist/use-dashboard-charts.d.ts.map +1 -0
  195. package/dist/use-dashboard-charts.js +263 -0
  196. package/dist/use-dashboard-charts.js.map +1 -0
  197. package/dist/use-hotkey.d.ts +17 -0
  198. package/dist/use-hotkey.d.ts.map +1 -0
  199. package/dist/use-hotkey.js +103 -0
  200. package/dist/use-hotkey.js.map +1 -0
  201. package/dist/user-directory.d.ts +18 -0
  202. package/dist/user-directory.d.ts.map +1 -0
  203. package/dist/user-directory.js +51 -0
  204. package/dist/user-directory.js.map +1 -0
  205. package/dist/validation.d.ts +22 -0
  206. package/dist/validation.d.ts.map +1 -0
  207. package/dist/validation.js +338 -0
  208. package/dist/validation.js.map +1 -0
  209. package/package.json +59 -0
  210. package/src/action-guard.ts +20 -0
  211. package/src/action-menu.tsx +161 -0
  212. package/src/admin-app.tsx +630 -0
  213. package/src/admin-router.tsx +273 -0
  214. package/src/breadcrumbs.tsx +75 -0
  215. package/src/client.ts +1093 -0
  216. package/src/component-loader.ts +33 -0
  217. package/src/components/ai-assistant-widget.tsx +565 -0
  218. package/src/components/ai-fill-dialog.tsx +143 -0
  219. package/src/components/chart-builder-dialog.tsx +618 -0
  220. package/src/components/chart-widget.tsx +654 -0
  221. package/src/components/global-search-dialog.tsx +272 -0
  222. package/src/components/group-settings-dialog.tsx +93 -0
  223. package/src/components/move-chart-dialog.tsx +130 -0
  224. package/src/components/reference-multi-table-dialog.tsx +196 -0
  225. package/src/components/related-records-tabs.tsx +130 -0
  226. package/src/components/revisions-button.tsx +237 -0
  227. package/src/components/wizard-form.tsx +302 -0
  228. package/src/dashboard/time-series.ts +125 -0
  229. package/src/dialogs.tsx +265 -0
  230. package/src/export.ts +140 -0
  231. package/src/extension-registry.ts +195 -0
  232. package/src/header-controls.tsx +125 -0
  233. package/src/hooks.ts +509 -0
  234. package/src/hotkey-help.tsx +56 -0
  235. package/src/hotkey-registry.tsx +60 -0
  236. package/src/i18n.tsx +267 -0
  237. package/src/index.ts +192 -0
  238. package/src/notify.tsx +94 -0
  239. package/src/pages/ai-assistant-settings-section.tsx +167 -0
  240. package/src/pages/audit-log-page.tsx +580 -0
  241. package/src/pages/edit-page.tsx +743 -0
  242. package/src/pages/export-dialog.tsx +154 -0
  243. package/src/pages/home-page.tsx +318 -0
  244. package/src/pages/list-page.tsx +2645 -0
  245. package/src/pages/login-page.tsx +242 -0
  246. package/src/pages/settings-page.tsx +1143 -0
  247. package/src/pages/settings-shared.tsx +134 -0
  248. package/src/pages/show-page.tsx +223 -0
  249. package/src/pages/wizard-create-page.tsx +164 -0
  250. package/src/property-renderer.tsx +1143 -0
  251. package/src/provider.tsx +70 -0
  252. package/src/realtime.ts +55 -0
  253. package/src/reference.tsx +386 -0
  254. package/src/relations.ts +55 -0
  255. package/src/router.tsx +211 -0
  256. package/src/show-when.ts +76 -0
  257. package/src/types.ts +198 -0
  258. package/src/use-dashboard-charts.ts +362 -0
  259. package/src/use-hotkey.ts +128 -0
  260. package/src/user-directory.ts +56 -0
  261. package/src/validation.ts +361 -0
@@ -0,0 +1,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
@@ -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
+ }
@@ -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
+ }