@modern-admin/react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action-guard.d.ts +13 -0
- package/dist/action-guard.d.ts.map +1 -0
- package/dist/action-guard.js +15 -0
- package/dist/action-guard.js.map +1 -0
- package/dist/action-menu.d.ts +17 -0
- package/dist/action-menu.d.ts.map +1 -0
- package/dist/action-menu.jsx +80 -0
- package/dist/action-menu.jsx.map +1 -0
- package/dist/admin-app.d.ts +23 -0
- package/dist/admin-app.d.ts.map +1 -0
- package/dist/admin-app.jsx +407 -0
- package/dist/admin-app.jsx.map +1 -0
- package/dist/admin-router.d.ts +29 -0
- package/dist/admin-router.d.ts.map +1 -0
- package/dist/admin-router.jsx +215 -0
- package/dist/admin-router.jsx.map +1 -0
- package/dist/breadcrumbs.d.ts +17 -0
- package/dist/breadcrumbs.d.ts.map +1 -0
- package/dist/breadcrumbs.jsx +40 -0
- package/dist/breadcrumbs.jsx.map +1 -0
- package/dist/client.d.ts +526 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +582 -0
- package/dist/client.js.map +1 -0
- package/dist/component-loader.d.ts +10 -0
- package/dist/component-loader.d.ts.map +1 -0
- package/dist/component-loader.js +23 -0
- package/dist/component-loader.js.map +1 -0
- package/dist/components/ai-assistant-widget.d.ts +3 -0
- package/dist/components/ai-assistant-widget.d.ts.map +1 -0
- package/dist/components/ai-assistant-widget.jsx +390 -0
- package/dist/components/ai-assistant-widget.jsx.map +1 -0
- package/dist/components/ai-fill-dialog.d.ts +9 -0
- package/dist/components/ai-fill-dialog.d.ts.map +1 -0
- package/dist/components/ai-fill-dialog.jsx +105 -0
- package/dist/components/ai-fill-dialog.jsx.map +1 -0
- package/dist/components/chart-builder-dialog.d.ts +10 -0
- package/dist/components/chart-builder-dialog.d.ts.map +1 -0
- package/dist/components/chart-builder-dialog.jsx +433 -0
- package/dist/components/chart-builder-dialog.jsx.map +1 -0
- package/dist/components/chart-widget.d.ts +12 -0
- package/dist/components/chart-widget.d.ts.map +1 -0
- package/dist/components/chart-widget.jsx +365 -0
- package/dist/components/chart-widget.jsx.map +1 -0
- package/dist/components/global-search-dialog.d.ts +7 -0
- package/dist/components/global-search-dialog.d.ts.map +1 -0
- package/dist/components/global-search-dialog.jsx +187 -0
- package/dist/components/global-search-dialog.jsx.map +1 -0
- package/dist/components/group-settings-dialog.d.ts +13 -0
- package/dist/components/group-settings-dialog.d.ts.map +1 -0
- package/dist/components/group-settings-dialog.jsx +53 -0
- package/dist/components/group-settings-dialog.jsx.map +1 -0
- package/dist/components/move-chart-dialog.d.ts +18 -0
- package/dist/components/move-chart-dialog.d.ts.map +1 -0
- package/dist/components/move-chart-dialog.jsx +68 -0
- package/dist/components/move-chart-dialog.jsx.map +1 -0
- package/dist/components/reference-multi-table-dialog.d.ts +12 -0
- package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
- package/dist/components/reference-multi-table-dialog.jsx +126 -0
- package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
- package/dist/components/related-records-tabs.d.ts +8 -0
- package/dist/components/related-records-tabs.d.ts.map +1 -0
- package/dist/components/related-records-tabs.jsx +75 -0
- package/dist/components/related-records-tabs.jsx.map +1 -0
- package/dist/components/revisions-button.d.ts +7 -0
- package/dist/components/revisions-button.d.ts.map +1 -0
- package/dist/components/revisions-button.jsx +152 -0
- package/dist/components/revisions-button.jsx.map +1 -0
- package/dist/components/wizard-form.d.ts +43 -0
- package/dist/components/wizard-form.d.ts.map +1 -0
- package/dist/components/wizard-form.jsx +136 -0
- package/dist/components/wizard-form.jsx.map +1 -0
- package/dist/dashboard/time-series.d.ts +20 -0
- package/dist/dashboard/time-series.d.ts.map +1 -0
- package/dist/dashboard/time-series.js +108 -0
- package/dist/dashboard/time-series.js.map +1 -0
- package/dist/dialogs.d.ts +35 -0
- package/dist/dialogs.d.ts.map +1 -0
- package/dist/dialogs.jsx +152 -0
- package/dist/dialogs.jsx.map +1 -0
- package/dist/export.d.ts +39 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +114 -0
- package/dist/export.js.map +1 -0
- package/dist/extension-registry.d.ts +122 -0
- package/dist/extension-registry.d.ts.map +1 -0
- package/dist/extension-registry.js +93 -0
- package/dist/extension-registry.js.map +1 -0
- package/dist/header-controls.d.ts +4 -0
- package/dist/header-controls.d.ts.map +1 -0
- package/dist/header-controls.jsx +70 -0
- package/dist/header-controls.jsx.map +1 -0
- package/dist/hooks.d.ts +104 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +374 -0
- package/dist/hooks.js.map +1 -0
- package/dist/hotkey-help.d.ts +3 -0
- package/dist/hotkey-help.d.ts.map +1 -0
- package/dist/hotkey-help.jsx +32 -0
- package/dist/hotkey-help.jsx.map +1 -0
- package/dist/hotkey-registry.d.ts +18 -0
- package/dist/hotkey-registry.d.ts.map +1 -0
- package/dist/hotkey-registry.jsx +34 -0
- package/dist/hotkey-registry.jsx.map +1 -0
- package/dist/i18n.d.ts +74 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.jsx +127 -0
- package/dist/i18n.jsx.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/notify.d.ts +41 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.jsx +58 -0
- package/dist/notify.jsx.map +1 -0
- package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
- package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
- package/dist/pages/ai-assistant-settings-section.jsx +126 -0
- package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
- package/dist/pages/audit-log-page.d.ts +3 -0
- package/dist/pages/audit-log-page.d.ts.map +1 -0
- package/dist/pages/audit-log-page.jsx +354 -0
- package/dist/pages/audit-log-page.jsx.map +1 -0
- package/dist/pages/edit-page.d.ts +7 -0
- package/dist/pages/edit-page.d.ts.map +1 -0
- package/dist/pages/edit-page.jsx +614 -0
- package/dist/pages/edit-page.jsx.map +1 -0
- package/dist/pages/export-dialog.d.ts +11 -0
- package/dist/pages/export-dialog.d.ts.map +1 -0
- package/dist/pages/export-dialog.jsx +102 -0
- package/dist/pages/export-dialog.jsx.map +1 -0
- package/dist/pages/home-page.d.ts +3 -0
- package/dist/pages/home-page.d.ts.map +1 -0
- package/dist/pages/home-page.jsx +211 -0
- package/dist/pages/home-page.jsx.map +1 -0
- package/dist/pages/list-page.d.ts +42 -0
- package/dist/pages/list-page.d.ts.map +1 -0
- package/dist/pages/list-page.jsx +1596 -0
- package/dist/pages/list-page.jsx.map +1 -0
- package/dist/pages/login-page.d.ts +11 -0
- package/dist/pages/login-page.d.ts.map +1 -0
- package/dist/pages/login-page.jsx +157 -0
- package/dist/pages/login-page.jsx.map +1 -0
- package/dist/pages/settings-page.d.ts +5 -0
- package/dist/pages/settings-page.d.ts.map +1 -0
- package/dist/pages/settings-page.jsx +787 -0
- package/dist/pages/settings-page.jsx.map +1 -0
- package/dist/pages/settings-shared.d.ts +51 -0
- package/dist/pages/settings-shared.d.ts.map +1 -0
- package/dist/pages/settings-shared.jsx +66 -0
- package/dist/pages/settings-shared.jsx.map +1 -0
- package/dist/pages/show-page.d.ts +7 -0
- package/dist/pages/show-page.d.ts.map +1 -0
- package/dist/pages/show-page.jsx +147 -0
- package/dist/pages/show-page.jsx.map +1 -0
- package/dist/pages/wizard-create-page.d.ts +14 -0
- package/dist/pages/wizard-create-page.d.ts.map +1 -0
- package/dist/pages/wizard-create-page.jsx +106 -0
- package/dist/pages/wizard-create-page.jsx.map +1 -0
- package/dist/property-renderer.d.ts +8 -0
- package/dist/property-renderer.d.ts.map +1 -0
- package/dist/property-renderer.jsx +690 -0
- package/dist/property-renderer.jsx.map +1 -0
- package/dist/provider.d.ts +20 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.jsx +32 -0
- package/dist/provider.jsx.map +1 -0
- package/dist/realtime.d.ts +22 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +38 -0
- package/dist/realtime.js.map +1 -0
- package/dist/reference.d.ts +52 -0
- package/dist/reference.d.ts.map +1 -0
- package/dist/reference.jsx +224 -0
- package/dist/reference.jsx.map +1 -0
- package/dist/relations.d.ts +11 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +36 -0
- package/dist/relations.js.map +1 -0
- package/dist/router.d.ts +82 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.jsx +187 -0
- package/dist/router.jsx.map +1 -0
- package/dist/show-when.d.ts +7 -0
- package/dist/show-when.d.ts.map +1 -0
- package/dist/show-when.js +77 -0
- package/dist/show-when.js.map +1 -0
- package/dist/types.d.ts +194 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/dist/use-dashboard-charts.d.ts +93 -0
- package/dist/use-dashboard-charts.d.ts.map +1 -0
- package/dist/use-dashboard-charts.js +263 -0
- package/dist/use-dashboard-charts.js.map +1 -0
- package/dist/use-hotkey.d.ts +17 -0
- package/dist/use-hotkey.d.ts.map +1 -0
- package/dist/use-hotkey.js +103 -0
- package/dist/use-hotkey.js.map +1 -0
- package/dist/user-directory.d.ts +18 -0
- package/dist/user-directory.d.ts.map +1 -0
- package/dist/user-directory.js +51 -0
- package/dist/user-directory.js.map +1 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +338 -0
- package/dist/validation.js.map +1 -0
- package/package.json +59 -0
- package/src/action-guard.ts +20 -0
- package/src/action-menu.tsx +161 -0
- package/src/admin-app.tsx +630 -0
- package/src/admin-router.tsx +273 -0
- package/src/breadcrumbs.tsx +75 -0
- package/src/client.ts +1093 -0
- package/src/component-loader.ts +33 -0
- package/src/components/ai-assistant-widget.tsx +565 -0
- package/src/components/ai-fill-dialog.tsx +143 -0
- package/src/components/chart-builder-dialog.tsx +618 -0
- package/src/components/chart-widget.tsx +654 -0
- package/src/components/global-search-dialog.tsx +272 -0
- package/src/components/group-settings-dialog.tsx +93 -0
- package/src/components/move-chart-dialog.tsx +130 -0
- package/src/components/reference-multi-table-dialog.tsx +196 -0
- package/src/components/related-records-tabs.tsx +130 -0
- package/src/components/revisions-button.tsx +237 -0
- package/src/components/wizard-form.tsx +302 -0
- package/src/dashboard/time-series.ts +125 -0
- package/src/dialogs.tsx +265 -0
- package/src/export.ts +140 -0
- package/src/extension-registry.ts +195 -0
- package/src/header-controls.tsx +125 -0
- package/src/hooks.ts +509 -0
- package/src/hotkey-help.tsx +56 -0
- package/src/hotkey-registry.tsx +60 -0
- package/src/i18n.tsx +267 -0
- package/src/index.ts +192 -0
- package/src/notify.tsx +94 -0
- package/src/pages/ai-assistant-settings-section.tsx +167 -0
- package/src/pages/audit-log-page.tsx +580 -0
- package/src/pages/edit-page.tsx +743 -0
- package/src/pages/export-dialog.tsx +154 -0
- package/src/pages/home-page.tsx +318 -0
- package/src/pages/list-page.tsx +2645 -0
- package/src/pages/login-page.tsx +242 -0
- package/src/pages/settings-page.tsx +1143 -0
- package/src/pages/settings-shared.tsx +134 -0
- package/src/pages/show-page.tsx +223 -0
- package/src/pages/wizard-create-page.tsx +164 -0
- package/src/property-renderer.tsx +1143 -0
- package/src/provider.tsx +70 -0
- package/src/realtime.ts +55 -0
- package/src/reference.tsx +386 -0
- package/src/relations.ts +55 -0
- package/src/router.tsx +211 -0
- package/src/show-when.ts +76 -0
- package/src/types.ts +198 -0
- package/src/use-dashboard-charts.ts +362 -0
- package/src/use-hotkey.ts +128 -0
- package/src/user-directory.ts +56 -0
- package/src/validation.ts +361 -0
package/src/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
|
+
}
|
package/src/show-when.ts
ADDED
|
@@ -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
|
+
}
|