@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,272 @@
1
+ // Cross-resource command palette. Backed by `useGlobalSearch`, which fans
2
+ // the query out to every registered resource's `search` action server-side.
3
+ // Results are grouped by resource; selecting an entry navigates to the
4
+ // record's show page and closes the palette.
5
+ //
6
+ // Designed as a controlled component — the parent (typically the header)
7
+ // holds the `open` state so it can pair the trigger button with the
8
+ // `mod+k` hotkey.
9
+
10
+ import * as React from 'react'
11
+ import {
12
+ CommandDialog,
13
+ CommandEmpty,
14
+ CommandGroup,
15
+ CommandInput,
16
+ CommandItem,
17
+ CommandList,
18
+ CommandSeparator,
19
+ DialogDescription,
20
+ DialogTitle,
21
+ } from '@modern-admin/ui'
22
+ import { ArrowRight, Clock, Loader2, X } from 'lucide-react'
23
+ import { useGlobalSearch } from '../hooks.js'
24
+ import { useI18n } from '../i18n.js'
25
+ import { useNavigate } from '../router.js'
26
+
27
+ const DEBOUNCE_MS = 300
28
+ const RECENT_STORAGE_KEY = 'modern-admin:global-search:recent:v1'
29
+ const RECENT_MAX = 6
30
+
31
+ export interface GlobalSearchDialogProps {
32
+ open: boolean
33
+
34
+ onOpenChange(open: boolean): void
35
+ }
36
+
37
+ const readRecent = (): string[] => {
38
+ if (typeof window === 'undefined') return []
39
+ try {
40
+ const raw = window.localStorage.getItem(RECENT_STORAGE_KEY)
41
+ if (!raw) return []
42
+ const parsed = JSON.parse(raw) as unknown
43
+ if (!Array.isArray(parsed)) return []
44
+ return parsed.filter((v): v is string => typeof v === 'string').slice(0, RECENT_MAX)
45
+ } catch {
46
+ return []
47
+ }
48
+ }
49
+
50
+ const writeRecent = (entries: string[]): void => {
51
+ if (typeof window === 'undefined') return
52
+ try {
53
+ window.localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(entries.slice(0, RECENT_MAX)))
54
+ } catch {
55
+ /* quota exceeded — recent list is best-effort */
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Highlight every case-insensitive occurrence of `needle` in `text` with
61
+ * `<mark>`. Returns an array of React nodes ready to render inside any
62
+ * inline container. Empty `needle` falls back to the raw string.
63
+ */
64
+ const highlightMatch = (text: string, needle: string): React.ReactNode => {
65
+ if (!needle) return text
66
+ const lower = text.toLowerCase()
67
+ const target = needle.toLowerCase()
68
+ const nodes: React.ReactNode[] = []
69
+ let cursor = 0
70
+ let key = 0
71
+ while (cursor < text.length) {
72
+ const idx = lower.indexOf(target, cursor)
73
+ if (idx === -1) {
74
+ nodes.push(text.slice(cursor))
75
+ break
76
+ }
77
+ if (idx > cursor) nodes.push(text.slice(cursor, idx))
78
+ nodes.push(
79
+ <mark
80
+ key={key++}
81
+ className="rounded-sm bg-primary/20 px-0.5 text-foreground"
82
+ >
83
+ {text.slice(idx, idx + target.length)}
84
+ </mark>,
85
+ )
86
+ cursor = idx + target.length
87
+ }
88
+ return nodes
89
+ }
90
+
91
+ export function GlobalSearchDialog({
92
+ open,
93
+ onOpenChange,
94
+ }: GlobalSearchDialogProps): React.ReactElement {
95
+ const { t } = useI18n()
96
+ const navigate = useNavigate()
97
+ const [query, setQuery] = React.useState('')
98
+ const [debounced, setDebounced] = React.useState('')
99
+ const [recent, setRecent] = React.useState<string[]>(() => readRecent())
100
+
101
+ // Reset query each time the dialog opens so it starts empty, and rehydrate
102
+ // the recent list (other tabs may have appended entries while we were idle).
103
+ React.useEffect(() => {
104
+ if (open) {
105
+ setQuery('')
106
+ setDebounced('')
107
+ setRecent(readRecent())
108
+ }
109
+ }, [open])
110
+
111
+ React.useEffect(() => {
112
+ const timer = window.setTimeout(() => setDebounced(query.trim()), DEBOUNCE_MS)
113
+ return () => window.clearTimeout(timer)
114
+ }, [query])
115
+
116
+ const { data, isFetching } = useGlobalSearch(debounced, open)
117
+
118
+ // Capture the most recent successful query so it's available for the
119
+ // "recent" list. We only persist on user-driven navigation (not every
120
+ // keystroke) to keep the list signal-to-noise high.
121
+ const persistRecent = React.useCallback((value: string): void => {
122
+ if (!value) return
123
+ setRecent((prev) => {
124
+ const next = [value, ...prev.filter((q) => q !== value)].slice(0, RECENT_MAX)
125
+ writeRecent(next)
126
+ return next
127
+ })
128
+ }, [])
129
+
130
+ const groups = data?.groups ?? []
131
+ const hasQuery = debounced.length > 0
132
+ const showEmpty = hasQuery && !isFetching && groups.length === 0
133
+
134
+ const handleSelect = React.useCallback(
135
+ (resourceId: string, recordId: string): void => {
136
+ persistRecent(debounced)
137
+ onOpenChange(false)
138
+ navigate({ name: 'show', resourceId, recordId })
139
+ },
140
+ [debounced, navigate, onOpenChange, persistRecent],
141
+ )
142
+
143
+ const handleShowAll = React.useCallback(
144
+ (resourceId: string): void => {
145
+ persistRecent(debounced)
146
+ onOpenChange(false)
147
+ navigate({ name: 'list', resourceId })
148
+ },
149
+ [debounced, navigate, onOpenChange, persistRecent],
150
+ )
151
+
152
+ const handlePickRecent = React.useCallback((value: string): void => {
153
+ setQuery(value)
154
+ setDebounced(value)
155
+ }, [])
156
+
157
+ const handleClearRecent = React.useCallback((): void => {
158
+ setRecent([])
159
+ writeRecent([])
160
+ }, [])
161
+
162
+ return (
163
+ <CommandDialog open={open} onOpenChange={onOpenChange}>
164
+ {/* Visually-hidden title + description keep Radix Dialog accessibility
165
+ warnings quiet and provide a label for screen readers. */}
166
+ <DialogTitle className="sr-only">{t('globalSearch:title')}</DialogTitle>
167
+ <DialogDescription className="sr-only">
168
+ {t('globalSearch:description')}
169
+ </DialogDescription>
170
+ <CommandInput
171
+ placeholder={t('globalSearch:placeholder')}
172
+ value={query}
173
+ onValueChange={setQuery}
174
+ />
175
+ {/* cmdk dedupes by item value — prefix each value with `resourceId:recordId`
176
+ so identical record ids across resources keep distinct entries.
177
+ shouldFilter is left on (default) so cmdk re-orders by relevance against
178
+ the typed query — server already narrowed the set down. */}
179
+ <CommandList>
180
+ {!hasQuery && recent.length === 0 && (
181
+ <div className="py-6 text-center text-sm text-muted-foreground">
182
+ {t('globalSearch:hint')}
183
+ </div>
184
+ )}
185
+ {!hasQuery && recent.length > 0 && (
186
+ <CommandGroup
187
+ heading={
188
+ <span className="flex items-center justify-between gap-2">
189
+ <span>{t('globalSearch:recent')}</span>
190
+ <button
191
+ type="button"
192
+ className="text-xs text-muted-foreground hover:text-foreground"
193
+ onClick={handleClearRecent}
194
+ >
195
+ <X className="mr-1 inline size-3" aria-hidden="true" />
196
+ {t('globalSearch:clearRecent')}
197
+ </button>
198
+ </span>
199
+ }
200
+ >
201
+ {recent.map((entry) => (
202
+ <CommandItem
203
+ key={`recent:${entry}`}
204
+ value={`recent:${entry}`}
205
+ onSelect={() => handlePickRecent(entry)}
206
+ >
207
+ <Clock className="mr-2 size-4 text-muted-foreground" aria-hidden="true" />
208
+ <span className="flex-1 truncate">{entry}</span>
209
+ </CommandItem>
210
+ ))}
211
+ </CommandGroup>
212
+ )}
213
+ {showEmpty && <CommandEmpty>{t('globalSearch:noResults')}</CommandEmpty>}
214
+ {hasQuery && isFetching && groups.length === 0 && (
215
+ <div
216
+ className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground"
217
+ role="status"
218
+ aria-live="polite"
219
+ >
220
+ <Loader2 className="size-4 animate-spin" aria-hidden="true"/>
221
+ <span>{t('common:loading')}</span>
222
+ </div>
223
+ )}
224
+ {groups.map((group, idx) => (
225
+ <React.Fragment key={group.resourceId}>
226
+ {idx > 0 && <CommandSeparator/>}
227
+ <CommandGroup heading={group.resourceName}>
228
+ {group.records.map((hit) => (
229
+ <CommandItem
230
+ key={`${hit.resourceId}:${hit.recordId}`}
231
+ value={`${hit.resourceId}:${hit.recordId} ${hit.title} ${hit.snippet ?? ''}`}
232
+ onSelect={() => handleSelect(hit.resourceId, hit.recordId)}
233
+ className="flex-col items-start gap-0.5"
234
+ >
235
+ <div className="flex w-full items-baseline gap-2">
236
+ <span className="flex-1 truncate font-medium">
237
+ {highlightMatch(hit.title, debounced)}
238
+ </span>
239
+ {hit.matchedField && (
240
+ <span className="shrink-0 text-xs text-muted-foreground">
241
+ {t('globalSearch:matchedIn').replace('{field}', hit.matchedField)}
242
+ </span>
243
+ )}
244
+ </div>
245
+ {hit.snippet && (
246
+ <div className="line-clamp-1 text-xs text-muted-foreground">
247
+ {highlightMatch(hit.snippet, debounced)}
248
+ </div>
249
+ )}
250
+ </CommandItem>
251
+ ))}
252
+ {/* `forceMount` keeps this row visible regardless of the current
253
+ query — cmdk's default fuzzy filter would otherwise drop it
254
+ because the value `${id}:show-all` rarely contains the typed
255
+ needle. */}
256
+ <CommandItem
257
+ key={`${group.resourceId}:show-all`}
258
+ value={`${group.resourceId}:show-all`}
259
+ forceMount
260
+ onSelect={() => handleShowAll(group.resourceId)}
261
+ className="text-xs text-muted-foreground"
262
+ >
263
+ <ArrowRight className="mr-2 size-3.5" aria-hidden="true" />
264
+ {t('globalSearch:showAllIn').replace('{resource}', group.resourceName)}
265
+ </CommandItem>
266
+ </CommandGroup>
267
+ </React.Fragment>
268
+ ))}
269
+ </CommandList>
270
+ </CommandDialog>
271
+ )
272
+ }
@@ -0,0 +1,93 @@
1
+ // Create / edit dialog for a dashboard chart group. Lives in the react
2
+ // package because it is i18n-aware; the actual primitives come from
3
+ // @modern-admin/ui.
4
+
5
+ import * as React from 'react'
6
+ import {
7
+ Button,
8
+ Dialog,
9
+ DialogContent,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ InfoTooltip,
14
+ Input,
15
+ Label,
16
+ } from '@modern-admin/ui'
17
+ import type { ChartGroup } from '@modern-admin/core'
18
+ import { useI18n } from '../i18n.js'
19
+
20
+ export interface GroupSettingsDialogProps {
21
+ /** When set, the dialog is in edit mode and pre-populates from this group. */
22
+ initial?: ChartGroup
23
+ onSave(input: { name: string; order: number }): void
24
+ onClose(): void
25
+ }
26
+
27
+ export function GroupSettingsDialog({
28
+ initial,
29
+ onSave,
30
+ onClose,
31
+ }: GroupSettingsDialogProps): React.ReactElement {
32
+ const { t } = useI18n()
33
+ const [name, setName] = React.useState(initial?.name ?? '')
34
+ const [order, setOrder] = React.useState<number>(initial?.order ?? 0)
35
+ const [error, setError] = React.useState<string>('')
36
+
37
+ const handleSave = (): void => {
38
+ const trimmed = name.trim()
39
+ if (!trimmed) {
40
+ setError(t('chart:groupNameRequired'))
41
+ return
42
+ }
43
+ setError('')
44
+ onSave({ name: trimmed, order: Number.isFinite(order) ? Math.trunc(order) : 0 })
45
+ }
46
+
47
+ return (
48
+ <Dialog open onOpenChange={(open) => { if (!open) onClose() }}>
49
+ <DialogContent className="w-full max-w-md">
50
+ <DialogHeader>
51
+ <DialogTitle>
52
+ {initial ? t('chart:editGroup') : t('chart:newGroup')}
53
+ </DialogTitle>
54
+ </DialogHeader>
55
+
56
+ <div className="space-y-4 py-1">
57
+ <div className="space-y-1.5">
58
+ <Label htmlFor="group-name">{t('chart:groupName')}</Label>
59
+ <Input
60
+ id="group-name"
61
+ placeholder={t('chart:groupNamePlaceholder')}
62
+ value={name}
63
+ onChange={(e) => setName(e.target.value)}
64
+ autoFocus
65
+ />
66
+ {error && <p className="text-xs text-destructive">{error}</p>}
67
+ </div>
68
+
69
+ <div className="space-y-1.5">
70
+ <div className="flex items-center gap-1.5">
71
+ <Label htmlFor="group-order">{t('chart:groupOrder')}</Label>
72
+ <InfoTooltip content={t('chart:orderHint')} />
73
+ </div>
74
+ <Input
75
+ id="group-order"
76
+ type="number"
77
+ step={1}
78
+ value={order}
79
+ onChange={(e) =>
80
+ setOrder(Number.isFinite(Number(e.target.value)) ? Math.trunc(Number(e.target.value)) : 0)
81
+ }
82
+ />
83
+ </div>
84
+ </div>
85
+
86
+ <DialogFooter>
87
+ <Button variant="outline" onClick={onClose}>{t('common:cancel')}</Button>
88
+ <Button onClick={handleSave}>{t('chart:saveGroup')}</Button>
89
+ </DialogFooter>
90
+ </DialogContent>
91
+ </Dialog>
92
+ )
93
+ }
@@ -0,0 +1,130 @@
1
+ // Dialog for moving a chart to a different group and adjusting its order.
2
+ // Opened from the chart widget's "…" dropdown menu.
3
+
4
+ import * as React from 'react'
5
+ import { FolderPlus } from 'lucide-react'
6
+ import {
7
+ Button,
8
+ Dialog,
9
+ DialogContent,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ InfoTooltip,
14
+ Input,
15
+ Label,
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ } from '@modern-admin/ui'
22
+ import type { ChartGroup } from '@modern-admin/core'
23
+ import { useI18n } from '../i18n.js'
24
+
25
+ export interface MoveChartDialogProps {
26
+ groups: ChartGroup[]
27
+ /** Current group id of the chart being moved. */
28
+ initialGroupId?: string
29
+ /** Current order value of the chart being moved. */
30
+ initialOrder?: number
31
+ onSave(input: { groupId: string; order: number }): void
32
+ onClose(): void
33
+ /** Called when the user wants to create a group first (no groups exist). */
34
+ onCreateGroup(): void
35
+ }
36
+
37
+ export function MoveChartDialog({
38
+ groups,
39
+ initialGroupId,
40
+ initialOrder,
41
+ onSave,
42
+ onClose,
43
+ onCreateGroup,
44
+ }: MoveChartDialogProps): React.ReactElement {
45
+ const { t } = useI18n()
46
+
47
+ const sorted = React.useMemo(
48
+ () => [...groups].sort((a, b) => a.order - b.order),
49
+ [groups],
50
+ )
51
+
52
+ // Seed from the chart's current group, or the first group if unset.
53
+ const defaultGroupId = initialGroupId ?? sorted[0]?.id ?? ''
54
+ const [groupId, setGroupId] = React.useState(defaultGroupId)
55
+ const [order, setOrder] = React.useState<number>(initialOrder ?? 0)
56
+
57
+ const handleSave = (): void => {
58
+ if (!groupId) return
59
+ onSave({ groupId, order: Number.isFinite(order) ? Math.trunc(order) : 0 })
60
+ }
61
+
62
+ return (
63
+ <Dialog open onOpenChange={(open) => { if (!open) onClose() }}>
64
+ <DialogContent className="w-full max-w-md">
65
+ <DialogHeader>
66
+ <DialogTitle>{t('chart:moveChart')}</DialogTitle>
67
+ </DialogHeader>
68
+
69
+ {sorted.length === 0 ? (
70
+ // No groups yet — prompt to create one.
71
+ <div className="space-y-3 py-2">
72
+ <p className="text-sm font-medium">{t('chart:moveNoGroups')}</p>
73
+ <p className="text-sm text-muted-foreground">{t('chart:moveNoGroupsHint')}</p>
74
+ <Button
75
+ variant="outline"
76
+ className="w-full"
77
+ onClick={() => { onClose(); onCreateGroup() }}
78
+ >
79
+ <FolderPlus className="size-4 mr-2" />
80
+ {t('chart:addGroup')}
81
+ </Button>
82
+ </div>
83
+ ) : (
84
+ <div className="space-y-4 py-1">
85
+ <div className="space-y-1.5">
86
+ <Label htmlFor="move-group">{t('chart:moveGroup')}</Label>
87
+ <Select value={groupId} onValueChange={setGroupId}>
88
+ <SelectTrigger id="move-group" className="w-full">
89
+ <SelectValue />
90
+ </SelectTrigger>
91
+ <SelectContent>
92
+ {sorted.map((g) => (
93
+ <SelectItem key={g.id} value={g.id}>
94
+ {g.name}
95
+ </SelectItem>
96
+ ))}
97
+ </SelectContent>
98
+ </Select>
99
+ </div>
100
+
101
+ <div className="space-y-1.5">
102
+ <div className="flex items-center gap-1.5">
103
+ <Label htmlFor="move-order">{t('chart:order')}</Label>
104
+ <InfoTooltip content={t('chart:orderHint')} />
105
+ </div>
106
+ <Input
107
+ id="move-order"
108
+ type="number"
109
+ step={1}
110
+ value={order}
111
+ onChange={(e) =>
112
+ setOrder(Number.isFinite(Number(e.target.value)) ? Math.trunc(Number(e.target.value)) : 0)
113
+ }
114
+ />
115
+ </div>
116
+ </div>
117
+ )}
118
+
119
+ <DialogFooter>
120
+ <Button variant="outline" onClick={onClose}>{t('common:cancel')}</Button>
121
+ {sorted.length > 0 && (
122
+ <Button onClick={handleSave} disabled={!groupId}>
123
+ {t('chart:moveToGroup')}
124
+ </Button>
125
+ )}
126
+ </DialogFooter>
127
+ </DialogContent>
128
+ </Dialog>
129
+ )
130
+ }
@@ -0,0 +1,196 @@
1
+ // Picker dialog for many-to-many relation fields. Opens a modal containing
2
+ // the embedded ResourceListPage of the referenced resource in "picker" mode
3
+ // (controlled row selection, no row navigation, no toolbar create/export).
4
+ // The dialog inherits the full list UX — sorting, filtering, header
5
+ // filters, column visibility, pagination — so users can find records the
6
+ // same way they would on the main list page.
7
+ //
8
+ // Selection inside the dialog is staged locally and only committed to the
9
+ // outer form on Save, so the user can cancel without polluting the value.
10
+ //
11
+ // Used as an alternative to ReferenceMultiCombobox for m2m and
12
+ // reference-array fields. Renders existing selections as removable chips
13
+ // above the trigger button — same chip pattern as ReferenceMultiCombobox.
14
+
15
+ import * as React from 'react'
16
+ import {
17
+ Badge,
18
+ Button,
19
+ Dialog,
20
+ DialogContent,
21
+ DialogFooter,
22
+ DialogHeader,
23
+ DialogTitle,
24
+ cn,
25
+ } from '@modern-admin/ui'
26
+ import { Plus, X } from 'lucide-react'
27
+ import { useQueries } from '@tanstack/react-query'
28
+ import { useAdminClient } from '../provider.js'
29
+ import { useI18n } from '../i18n.js'
30
+ import { useResource } from '../hooks.js'
31
+ import { ResourceListPage } from '../pages/list-page.js'
32
+ import type { ListQueryState } from '../router.js'
33
+
34
+ export interface ReferenceMultiTableDialogProps {
35
+ referenceResourceId: string
36
+ value: ReadonlyArray<string | number> | null | undefined
37
+ onChange(next: Array<string | number>): void
38
+ disabled?: boolean
39
+ /** Label for the trigger button. Defaults to "Pick records". */
40
+ triggerLabel?: string
41
+ className?: string
42
+ }
43
+
44
+ export function ReferenceMultiTableDialog({
45
+ referenceResourceId,
46
+ value,
47
+ onChange,
48
+ disabled,
49
+ triggerLabel,
50
+ className,
51
+ }: ReferenceMultiTableDialogProps): React.ReactElement {
52
+ const { t } = useI18n()
53
+ const client = useAdminClient()
54
+ const resource = useResource(referenceResourceId)
55
+
56
+ const committedIds = React.useMemo(
57
+ () => (value ?? []).map(String),
58
+ [value],
59
+ )
60
+
61
+ const [open, setOpen] = React.useState(false)
62
+ // Staged selection inside the dialog. Reset to committed value each time
63
+ // the dialog opens so a previous Cancel doesn't leak state.
64
+ const [stagedIds, setStagedIds] = React.useState<string[]>(() => committedIds)
65
+ React.useEffect(() => {
66
+ if (open) setStagedIds(committedIds)
67
+ }, [open, committedIds])
68
+
69
+ // Embedded list page keeps its own page/sort/filter state. Reset to
70
+ // page 1 each open so each pick session starts fresh.
71
+ const [query, setQuery] = React.useState<ListQueryState>({ perPage: 10 })
72
+ React.useEffect(() => {
73
+ if (open) setQuery({ perPage: 10 })
74
+ }, [open])
75
+
76
+ // Resolve chip labels by fetching each currently-committed record. Sharing
77
+ // the query key with ReferenceLink keeps the cache warm — already-visible
78
+ // chips render instantly.
79
+ const titleQueries = useQueries({
80
+ queries: committedIds.map((id) => ({
81
+ queryKey: ['modern-admin', referenceResourceId, 'show', id],
82
+ queryFn: () => client.show(referenceResourceId, id),
83
+ staleTime: 30_000,
84
+ })),
85
+ })
86
+ const chips = React.useMemo(
87
+ () =>
88
+ committedIds.map((id, i) => {
89
+ const title = titleQueries[i]?.data?.record?.title
90
+ return { id, label: title ? `${title} <${id}>` : `#${id}` }
91
+ }),
92
+ [committedIds, titleQueries],
93
+ )
94
+
95
+ const remove = (id: string): void => {
96
+ onChange(committedIds.filter((x) => x !== id))
97
+ }
98
+
99
+ const handleSave = (): void => {
100
+ onChange(stagedIds)
101
+ setOpen(false)
102
+ }
103
+
104
+ const handleCancel = (): void => {
105
+ setOpen(false)
106
+ }
107
+
108
+ const resolvedTriggerLabel =
109
+ triggerLabel ??
110
+ (committedIds.length > 0
111
+ ? t('common:managePickRecords', { count: committedIds.length })
112
+ : t('common:pickRecords'))
113
+
114
+ return (
115
+ <div className={cn('space-y-2', className)}>
116
+ {chips.length > 0 && (
117
+ <div className="flex flex-wrap gap-1">
118
+ {chips.map((c) => (
119
+ <Badge key={c.id} variant="secondary" className="gap-1 pr-1">
120
+ <span className="truncate" title={c.label}>{c.label}</span>
121
+ <button
122
+ type="button"
123
+ aria-label={t('common:removeItem', { title: c.label })}
124
+ disabled={disabled}
125
+ onClick={() => remove(c.id)}
126
+ className="rounded-sm opacity-60 hover:opacity-100"
127
+ >
128
+ <X className="size-3" />
129
+ </button>
130
+ </Badge>
131
+ ))}
132
+ </div>
133
+ )}
134
+ <Button
135
+ type="button"
136
+ variant="outline"
137
+ disabled={disabled}
138
+ onClick={() => setOpen(true)}
139
+ className="w-full justify-start font-normal"
140
+ >
141
+ <Plus className="size-4" />
142
+ <span className="truncate">{resolvedTriggerLabel}</span>
143
+ </Button>
144
+
145
+ <Dialog open={open} onOpenChange={setOpen}>
146
+ <DialogContent
147
+ // Wide layout so the embedded table has room. Cap height and let
148
+ // the body scroll independently of the footer.
149
+ className="flex max-h-[90vh] w-[95vw] max-w-5xl flex-col gap-0 p-0"
150
+ >
151
+ <DialogHeader className="border-b border-border px-6 py-4">
152
+ <DialogTitle>
153
+ {t('common:pickRecordsFrom', { name: resource?.name ?? referenceResourceId })}
154
+ </DialogTitle>
155
+ </DialogHeader>
156
+ {/* The list page is in embedded mode (`card: false`) so it
157
+ manages its own internal scroll: the table area scrolls,
158
+ the paginator sits below as a flush full-width bar. The
159
+ body wrapper itself does NOT scroll, and `overflow-hidden`
160
+ guarantees its children cannot bleed visually past the
161
+ flex-1 box (and onto the DialogFooter below). */}
162
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
163
+ <ResourceListPage
164
+ resourceId={referenceResourceId}
165
+ query={query}
166
+ onQueryChange={setQuery}
167
+ selectedIds={stagedIds}
168
+ onSelectionChange={setStagedIds}
169
+ disableRowNavigation
170
+ features={{
171
+ breadcrumbs: false,
172
+ title: false,
173
+ create: false,
174
+ export: false,
175
+ bulk: false,
176
+ actions: false,
177
+ card: false,
178
+ }}
179
+ />
180
+ </div>
181
+ <DialogFooter className="gap-2 border-t border-border px-6 py-4">
182
+ <div className="mr-auto text-sm text-muted-foreground self-center">
183
+ {t('common:selectedCount', { count: stagedIds.length })}
184
+ </div>
185
+ <Button variant="outline" type="button" onClick={handleCancel}>
186
+ {t('common:cancel')}
187
+ </Button>
188
+ <Button type="button" onClick={handleSave}>
189
+ {t('common:save')}
190
+ </Button>
191
+ </DialogFooter>
192
+ </DialogContent>
193
+ </Dialog>
194
+ </div>
195
+ )
196
+ }