@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
package/src/router.tsx ADDED
@@ -0,0 +1,211 @@
1
+ // Routing engine compat layer over @tanstack/react-router with `createBrowserHistory()`.
2
+ //
3
+ // Why browser history? Clean, standard path-based URLs (`/resources/:id?page=2`).
4
+ // Works with server-side analytics and deep links. Requires an SPA fallback rule
5
+ // on the server (`try_files ... index.html` in nginx, `historyApiFallback` in
6
+ // Vite preview). This is the one-line standard config for any modern static host
7
+ // (Vercel, Netlify, nginx). No SSR is used — admin pages are auth-walled.
8
+ //
9
+ // Why TSR? Future-proof primitives — devtools, search-param schemas, per-route
10
+ // loaders, code-splitting — without paying for SSR/Nitro (TanStack Start).
11
+ //
12
+ // Public API (Route discriminated union, `Link`, `useRoute`, `useNavigate`,
13
+ // `buildHref`) is kept stable. `Route` is the canonical surface; the underlying
14
+ // TSR state is mapped to it via `parseLocation`. Search params are kept
15
+ // opaque to TSR (`parseSearch`/`stringifySearch` are no-ops in `admin-router`):
16
+ // `ListQueryState` (with `filters[<key>]=<v>` keys) is parsed manually from
17
+ // the raw `searchStr` so the URL format doesn't depend on TSR's JSON-search encoding.
18
+
19
+ import * as React from 'react'
20
+ import { useRouter, useRouterState } from '@tanstack/react-router'
21
+
22
+ /**
23
+ * Provides the SPA mount basepath (e.g. `/admin`) to all navigation
24
+ * primitives (`Link`, `useNavigate`, `useRoute`). Set by
25
+ * `AdminRouterProvider` from `window.__MODERN_ADMIN__.basePath`. Defaults
26
+ * to `''` (root mount).
27
+ */
28
+ export const BasepathContext = React.createContext<string>('')
29
+
30
+ /** Returns the normalised basepath (never has a trailing slash; `''` at root). */
31
+ export const useBasepath = (): string => React.useContext(BasepathContext)
32
+
33
+ /** URL-persisted state for the resource list page. */
34
+ export interface ListQueryState {
35
+ page?: number
36
+ perPage?: number
37
+ sortBy?: string
38
+ direction?: 'asc' | 'desc'
39
+ /** Per-column filter values keyed by property path. */
40
+ filters?: Record<string, string>
41
+ }
42
+
43
+ export type Route =
44
+ | { name: 'home' }
45
+ | { name: 'audit-log' }
46
+ | { name: 'list'; resourceId: string; query?: ListQueryState }
47
+ | { name: 'show'; resourceId: string; recordId: string }
48
+ | { name: 'edit'; resourceId: string; recordId: string }
49
+ | { name: 'new'; resourceId: string }
50
+ /** Settings hub. Sub-section selected via `section` (e.g. 'api-keys'). */
51
+ | { name: 'settings'; section?: string }
52
+ /**
53
+ * Extension page registered by a Pro plugin via `registerExtensionRoute`.
54
+ * Renders at `/ext/<key>` inside the authenticated admin shell.
55
+ */
56
+ | { name: 'extension'; key: string }
57
+
58
+ const parseListQuery = (search: string): ListQueryState | undefined => {
59
+ if (!search) return undefined
60
+ const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
61
+ const out: ListQueryState = {}
62
+ const page = params.get('page')
63
+ if (page) {
64
+ const n = Number(page)
65
+ if (Number.isFinite(n) && n >= 1) out.page = n
66
+ }
67
+ const perPage = params.get('perPage')
68
+ if (perPage) {
69
+ const n = Number(perPage)
70
+ if (Number.isFinite(n) && n >= 1) out.perPage = n
71
+ }
72
+ const sortBy = params.get('sortBy')
73
+ if (sortBy) out.sortBy = sortBy
74
+ const direction = params.get('direction')
75
+ if (direction === 'asc' || direction === 'desc') out.direction = direction
76
+ const filters: Record<string, string> = {}
77
+ params.forEach((value, key) => {
78
+ const m = key.match(/^filters\[(.+)\]$/)
79
+ if (m && m[1] != null && value !== '') filters[m[1]] = value
80
+ })
81
+ if (Object.keys(filters).length > 0) out.filters = filters
82
+ return Object.keys(out).length > 0 ? out : undefined
83
+ }
84
+
85
+ const buildListQuery = (q: ListQueryState | undefined): string => {
86
+ if (!q) return ''
87
+ const params = new URLSearchParams()
88
+ if (q.page != null && q.page !== 1) params.set('page', String(q.page))
89
+ if (q.perPage != null && q.perPage !== 20) params.set('perPage', String(q.perPage))
90
+ if (q.sortBy) params.set('sortBy', q.sortBy)
91
+ if (q.direction) params.set('direction', q.direction)
92
+ if (q.filters) {
93
+ for (const [k, v] of Object.entries(q.filters)) {
94
+ if (v != null && v !== '') params.set(`filters[${k}]`, v)
95
+ }
96
+ }
97
+ const s = params.toString()
98
+ return s ? `?${s}` : ''
99
+ }
100
+
101
+ /** Map a TSR location (pathname + raw searchStr) to the canonical `Route`
102
+ * union the rest of the codebase consumes. Pure — used both at render
103
+ * time (via `useRoute`) and outside the React tree if ever needed. */
104
+ export const parseLocation = (pathname: string, searchStr: string): Route => {
105
+ const parts = pathname.split('/').filter(Boolean)
106
+ if (parts.length === 0) return { name: 'home' }
107
+ if (parts[0] === 'audit-log') return { name: 'audit-log' }
108
+ if (parts[0] === 'settings') {
109
+ const section = parts[1] ? decodeURIComponent(parts[1]) : undefined
110
+ return section ? { name: 'settings', section } : { name: 'settings' }
111
+ }
112
+ if (parts[0] === 'ext' && parts[1]) {
113
+ return { name: 'extension', key: decodeURIComponent(parts[1]) }
114
+ }
115
+ if (parts[0] === 'resources' && parts[1]) {
116
+ const resourceId = decodeURIComponent(parts[1])
117
+ if (parts[2] === 'new') return { name: 'new', resourceId }
118
+ if (parts[2] && parts[3] === 'edit') {
119
+ return { name: 'edit', resourceId, recordId: decodeURIComponent(parts[2]) }
120
+ }
121
+ if (parts[2]) return { name: 'show', resourceId, recordId: decodeURIComponent(parts[2]) }
122
+ const query = parseListQuery(searchStr)
123
+ return query ? { name: 'list', resourceId, query } : { name: 'list', resourceId }
124
+ }
125
+ return { name: 'home' }
126
+ }
127
+
128
+ /** Build a path URL for the given route. Pure — kept for tests and for
129
+ * `<Link>` href generation. */
130
+ export const buildHref = (route: Route): string => {
131
+ switch (route.name) {
132
+ case 'home':
133
+ return '/'
134
+ case 'audit-log':
135
+ return '/audit-log'
136
+ case 'list':
137
+ return `/resources/${encodeURIComponent(route.resourceId)}${buildListQuery(route.query)}`
138
+ case 'show':
139
+ return `/resources/${encodeURIComponent(route.resourceId)}/${encodeURIComponent(route.recordId)}`
140
+ case 'edit':
141
+ return `/resources/${encodeURIComponent(route.resourceId)}/${encodeURIComponent(route.recordId)}/edit`
142
+ case 'new':
143
+ return `/resources/${encodeURIComponent(route.resourceId)}/new`
144
+ case 'settings':
145
+ return route.section ? `/settings/${encodeURIComponent(route.section)}` : '/settings'
146
+ case 'extension':
147
+ return `/ext/${encodeURIComponent(route.key)}`
148
+ }
149
+ }
150
+
151
+ /** Current canonical route, derived from the live TSR state.
152
+ * When the router has a basepath, TSR strips it from `location.pathname`
153
+ * before exposing it in state — so `parseLocation` sees only the
154
+ * basepath-relative portion. */
155
+ export const useRoute = (): Route =>
156
+ useRouterState({
157
+ select: (s) => parseLocation(s.location.pathname, s.location.searchStr ?? ''),
158
+ })
159
+
160
+ /** Imperative navigator. Same signature as the legacy custom router so
161
+ * call-sites don't change. Goes through TSR history → re-routing flows
162
+ * through TSR's lifecycle. */
163
+ export const useNavigate = (): ((route: Route) => void) => {
164
+ const router = useRouter()
165
+ const basepath = useBasepath()
166
+ return React.useCallback(
167
+ (next: Route) => {
168
+ router.history.push(basepath + buildHref(next))
169
+ },
170
+ [router, basepath],
171
+ )
172
+ }
173
+
174
+ /** Open an in-app route in a new browser tab, honouring the admin's
175
+ * `basepath`. `buildHref` returns a basepath-relative path, so we prepend
176
+ * the basepath the same way `Link`/`useNavigate` do — otherwise the new tab
177
+ * loads a URL outside the admin mount (e.g. `/resources/...` instead of
178
+ * `/admin/resources/...`). */
179
+ export const useOpenInNewTab = (): ((route: Route) => void) => {
180
+ const basepath = useBasepath()
181
+ return React.useCallback((route: Route) => {
182
+ if (typeof window === 'undefined') return
183
+ window.open(basepath + buildHref(route), '_blank', 'noopener,noreferrer')
184
+ }, [basepath])
185
+ }
186
+
187
+ export interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
188
+ to: Route
189
+ }
190
+
191
+ /** Anchor with path href + client-side navigation on plain left-click.
192
+ * Modifier clicks (cmd/ctrl/shift/alt, middle button) fall through to
193
+ * default browser behaviour so "open in new tab" keeps working. */
194
+ export const Link = ({ to, onClick, ...rest }: LinkProps): React.ReactElement => {
195
+ const router = useRouter()
196
+ const basepath = useBasepath()
197
+ const href = basepath + buildHref(to)
198
+ const handleClick = React.useCallback(
199
+ (event: React.MouseEvent<HTMLAnchorElement>) => {
200
+ onClick?.(event)
201
+ if (event.defaultPrevented) return
202
+ if (event.button !== 0) return
203
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
204
+ if (rest.target && rest.target !== '_self') return
205
+ event.preventDefault()
206
+ router.history.push(href)
207
+ },
208
+ [onClick, router, href, rest.target],
209
+ )
210
+ return <a href={href} onClick={handleClick} {...rest} />
211
+ }
@@ -0,0 +1,76 @@
1
+ // Pure helper that evaluates a `ShowWhenSpec` against a snapshot of form
2
+ // values. Lives outside React/RHF on purpose so both the edit page renderer
3
+ // and the validation builder can share the exact same semantics.
4
+ //
5
+ // Operator semantics:
6
+ // - `equals` — control value === provided value
7
+ // - `notEquals` — control value !== provided value
8
+ // - `in` — control value matches any item in array
9
+ // - `notIn` — control value matches none of the items
10
+ // - `isEmpty:true` — control value is null / undefined / ''
11
+ // - `isEmpty:false` — control value is NOT null / undefined / ''
12
+ // - `defaultWhenEmpty` — fallback: shows the field when control is empty,
13
+ // regardless of the other operators
14
+ //
15
+ // Operators combine with OR — the field shows when **any** of them passes.
16
+ // When no operator is configured, the rule trivially passes (visible).
17
+
18
+ import type { ShowWhenSpec } from './types.js'
19
+
20
+ const isBlank = (v: unknown): boolean =>
21
+ v === undefined || v === null || (typeof v === 'string' && v.trim() === '')
22
+
23
+ /** Loose equality that handles primitives + dates + arrays of primitives. */
24
+ const sameValue = (a: unknown, b: unknown): boolean => {
25
+ if (a === b) return true
26
+ if (a == null || b == null) return false
27
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
28
+ // Coerce numbers vs string-numbers (HTML inputs ship strings).
29
+ if (typeof a === 'number' && typeof b === 'string') return String(a) === b
30
+ if (typeof a === 'string' && typeof b === 'number') return a === String(b)
31
+ if (typeof a === 'boolean' && typeof b === 'string') return String(a) === b
32
+ return false
33
+ }
34
+
35
+ /**
36
+ * Evaluate a `ShowWhenSpec` against the live form values.
37
+ * Returns `true` (visible) when no rule is configured.
38
+ */
39
+ export function evaluateShowWhen(
40
+ rule: ShowWhenSpec | undefined,
41
+ values: Record<string, unknown>,
42
+ ): boolean {
43
+ if (!rule) return true
44
+ const control = values[rule.field]
45
+ const empty = isBlank(control)
46
+
47
+ // Default branch: when control is empty AND defaultWhenEmpty is set, show.
48
+ if (rule.defaultWhenEmpty && empty) return true
49
+
50
+ let anyOperator = false
51
+
52
+ if ('equals' in rule && rule.equals !== undefined) {
53
+ anyOperator = true
54
+ if (sameValue(control, rule.equals)) return true
55
+ }
56
+ if ('notEquals' in rule && rule.notEquals !== undefined) {
57
+ anyOperator = true
58
+ if (!sameValue(control, rule.notEquals)) return true
59
+ }
60
+ if (rule.in && rule.in.length > 0) {
61
+ anyOperator = true
62
+ if (rule.in.some((v) => sameValue(control, v))) return true
63
+ }
64
+ if (rule.notIn && rule.notIn.length > 0) {
65
+ anyOperator = true
66
+ if (!rule.notIn.some((v) => sameValue(control, v))) return true
67
+ }
68
+ if (rule.isEmpty !== undefined) {
69
+ anyOperator = true
70
+ if (rule.isEmpty === empty) return true
71
+ }
72
+
73
+ // Rule with operators that all failed → hidden.
74
+ // Rule with no operators (only `field` declared) → visible.
75
+ return !anyOperator
76
+ }
package/src/types.ts ADDED
@@ -0,0 +1,198 @@
1
+ // Wire-shape mirror of `ResourceDecorator#toJSON()` and friends. We re-declare
2
+ // rather than re-import so the React bundle doesn't drag in the full core
3
+ // (which references Node-only deps in a few corners).
4
+
5
+ export type View = 'list' | 'show' | 'edit' | 'filter'
6
+
7
+ /**
8
+ * Mirror of `core` `ShowWhen` — declarative rule that conditionally hides a
9
+ * field on the edit form based on the current value of another form field.
10
+ * Operators combine with OR semantics; `defaultWhenEmpty` triggers when the
11
+ * control field is null / undefined / ''.
12
+ */
13
+ export interface ShowWhenSpec {
14
+ field: string
15
+ equals?: unknown
16
+ notEquals?: unknown
17
+ in?: unknown[]
18
+ notIn?: unknown[]
19
+ isEmpty?: boolean
20
+ defaultWhenEmpty?: boolean
21
+ }
22
+
23
+ /**
24
+ * Mirror of `core` `KeyValueField` — declares one row in the key-value
25
+ * editor used as a friendly alternative to the raw JSON editor.
26
+ */
27
+ export interface KeyValueFieldSpec {
28
+ key: string
29
+ label?: string
30
+ type?: 'string' | 'number' | 'boolean' | 'textarea' | 'select' | 'autocomplete'
31
+ description?: string
32
+ placeholder?: string
33
+ isRequired?: boolean
34
+ availableValues?: ReadonlyArray<string | { value: string; label: string }>
35
+ /** For `type: 'autocomplete'`: pull dynamic suggestions from another resource. */
36
+ suggestionsResource?: string
37
+ /** Path of the field on `suggestionsResource` to project. */
38
+ suggestionsField?: string
39
+ }
40
+
41
+ export interface PropertyJSON {
42
+ path: string
43
+ label: string
44
+ type: string
45
+ isId: boolean
46
+ isSortable: boolean
47
+ isRequired: boolean
48
+ isDisabled: boolean
49
+ isArray: boolean
50
+ reference: string | null
51
+ availableValues: Array<{ value: string; label: string }> | null
52
+ components: { list?: string; edit?: string; show?: string; filter?: string } | Record<string, string>
53
+ visibility: Record<View, boolean>
54
+ position: number
55
+ description?: string
56
+ showWhen?: ShowWhenSpec
57
+ keyValueFields?: KeyValueFieldSpec[]
58
+ custom: Record<string, unknown>
59
+ }
60
+
61
+ export interface ActionGroup {
62
+ name: string
63
+ icon?: string
64
+ }
65
+
66
+ export interface ActionDescriptor {
67
+ name: string
68
+ actionType: 'resource' | 'record' | 'bulk'
69
+ resourceId: string
70
+ nesting?: ActionGroup[]
71
+ guard?: string
72
+ component?: string | null
73
+ custom?: Record<string, unknown>
74
+ }
75
+
76
+ export interface RelatedResource {
77
+ resourceId: string
78
+ foreignKey: string
79
+ label?: string
80
+ }
81
+
82
+ export interface ResourceJSON {
83
+ id: string
84
+ name: string
85
+ navigation: { name?: string; icon?: string; group?: string } | null
86
+ relatedResources?: RelatedResource[]
87
+ properties: PropertyJSON[]
88
+ actions: ActionDescriptor[]
89
+ }
90
+
91
+ export interface CurrentUser {
92
+ id: string
93
+ email?: string
94
+ name?: string
95
+ role?: string
96
+ avatarUrl?: string
97
+ [claim: string]: unknown
98
+ }
99
+
100
+ /**
101
+ * Mirror of `core` `AdminFeatures`. Each flag is `true` iff the
102
+ * corresponding backend subsystem is wired and ready. The SPA uses these
103
+ * to hide UI surfaces (audit-log link, settings sections, revisions
104
+ * button, AI assistant widget) for features the host hasn't enabled.
105
+ */
106
+ export interface AdminFeatures {
107
+ auditLog: boolean
108
+ history: boolean
109
+ webhooks: boolean
110
+ apiKeys: boolean
111
+ aiAssistant: boolean
112
+ }
113
+
114
+ const ALL_FEATURES_OFF: AdminFeatures = {
115
+ auditLog: false,
116
+ history: false,
117
+ webhooks: false,
118
+ apiKeys: false,
119
+ aiAssistant: false,
120
+ }
121
+
122
+ /** Defensive resolver for older API servers that don't yet surface
123
+ * `features` in their `/admin/api/config` payload — every flag falls back
124
+ * to `false`, so optional surfaces stay hidden until the backend opts in. */
125
+ export const resolveFeatures = (raw?: Partial<AdminFeatures>): AdminFeatures => ({
126
+ ...ALL_FEATURES_OFF,
127
+ ...(raw ?? {}),
128
+ })
129
+
130
+ export interface AdminConfig {
131
+ rootPath: string
132
+ branding?: { companyName?: string; logo?: string; theme?: string }
133
+ auth: Record<string, unknown>
134
+ resources: ResourceJSON[]
135
+ features?: Partial<AdminFeatures>
136
+ }
137
+
138
+ export interface RecordJSON {
139
+ id: string
140
+ title: string
141
+ params: Record<string, unknown>
142
+ populated: Record<string, unknown>
143
+ errors: Record<string, unknown>
144
+ baseError: unknown | null
145
+ }
146
+
147
+ export interface ListResponse {
148
+ records: RecordJSON[]
149
+ meta: { total: number; page: number; perPage: number; sortBy?: string; direction?: 'asc' | 'desc' }
150
+ }
151
+
152
+ export interface RecordResponse {
153
+ record: RecordJSON
154
+ }
155
+
156
+ /** Generic response from a custom action invocation (record / bulk / resource). */
157
+ export interface CustomActionResponse {
158
+ record?: RecordJSON
159
+ records?: RecordJSON[]
160
+ notice?: { message: string; type: 'success' | 'info' | 'error' | 'warning' }
161
+ redirectUrl?: string
162
+ [key: string]: unknown
163
+ }
164
+
165
+ export interface ListQuery {
166
+ page?: number
167
+ perPage?: number
168
+ sortBy?: string
169
+ direction?: 'asc' | 'desc'
170
+ filters?: Record<string, string>
171
+ }
172
+
173
+ /**
174
+ * Props contract for a property **display** component. Shared between the
175
+ * built-in `PropertyDisplay` and any custom property extension registered
176
+ * via `registerPropertyExtension`.
177
+ */
178
+ export interface PropertyDisplayProps {
179
+ property: PropertyJSON
180
+ value: unknown
181
+ view?: 'list' | 'show'
182
+ /** The record's `populated` map (pre-fetched reference titles). */
183
+ populated?: Record<string, unknown>
184
+ }
185
+
186
+ /**
187
+ * Props contract for a property **editor** component. Shared between the
188
+ * built-in `PropertyEditor` and any custom property extension registered
189
+ * via `registerPropertyExtension`.
190
+ */
191
+ export interface PropertyEditorProps {
192
+ property: PropertyJSON
193
+ value: unknown
194
+ onChange(next: unknown): void
195
+ disabled?: boolean
196
+ /** Required for `type: 'file'` properties to route uploads correctly. */
197
+ resourceId?: string
198
+ }