@spree/dashboard-core 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/LICENSE +21 -0
- package/README.md +418 -0
- package/package.json +84 -0
- package/src/client.ts +25 -0
- package/src/components/address-form-dialog.tsx +376 -0
- package/src/components/app-sidebar.tsx +70 -0
- package/src/components/bulk-action-bar.tsx +504 -0
- package/src/components/can.tsx +30 -0
- package/src/components/country-combobox.tsx +238 -0
- package/src/components/country-state-fields.tsx +86 -0
- package/src/components/currency-select.tsx +181 -0
- package/src/components/export-button.tsx +156 -0
- package/src/components/file-upload-field.tsx +265 -0
- package/src/components/image-upload-field.tsx +53 -0
- package/src/components/import-button.tsx +202 -0
- package/src/components/locale-select.tsx +268 -0
- package/src/components/market-combobox.tsx +89 -0
- package/src/components/nav-main.tsx +186 -0
- package/src/components/page-header.tsx +286 -0
- package/src/components/page-tabs.tsx +78 -0
- package/src/components/preferences-form.tsx +404 -0
- package/src/components/resource-combobox.tsx +126 -0
- package/src/components/resource-multi-autocomplete.tsx +176 -0
- package/src/components/resource-picker-sheet.tsx +235 -0
- package/src/components/resource-table.tsx +652 -0
- package/src/components/settings-sidebar.tsx +139 -0
- package/src/components/slot.tsx +50 -0
- package/src/components/store-date-picker.tsx +18 -0
- package/src/components/store-switcher.tsx +70 -0
- package/src/components/table-toolbar.tsx +818 -0
- package/src/components/tag-combobox.tsx +99 -0
- package/src/components/top-bar.tsx +210 -0
- package/src/hooks/use-auth.ts +10 -0
- package/src/hooks/use-command-palette.tsx +34 -0
- package/src/hooks/use-countries.ts +15 -0
- package/src/hooks/use-custom-fields.ts +142 -0
- package/src/hooks/use-direct-upload.ts +104 -0
- package/src/hooks/use-display-name.ts +42 -0
- package/src/hooks/use-export.ts +75 -0
- package/src/hooks/use-global-search.ts +79 -0
- package/src/hooks/use-host-form.ts +34 -0
- package/src/hooks/use-import.ts +62 -0
- package/src/hooks/use-resource-mutation.ts +99 -0
- package/src/hooks/use-switch-admin-locale.ts +55 -0
- package/src/index.ts +97 -0
- package/src/lib/custom-field-components.ts +56 -0
- package/src/lib/download.ts +49 -0
- package/src/lib/filters-to-ransack.ts +47 -0
- package/src/lib/form-errors.ts +103 -0
- package/src/lib/form-fields-registry.ts +102 -0
- package/src/lib/form-mappers.ts +30 -0
- package/src/lib/formatters.ts +19 -0
- package/src/lib/i18n.ts +210 -0
- package/src/lib/nav-registry.ts +224 -0
- package/src/lib/permissions.ts +55 -0
- package/src/lib/query-client.ts +11 -0
- package/src/lib/query-keys.ts +69 -0
- package/src/lib/route-registry.ts +158 -0
- package/src/lib/search-registry.ts +129 -0
- package/src/lib/settings-nav-registry.ts +158 -0
- package/src/lib/slot-registry.ts +116 -0
- package/src/lib/table-registry.ts +274 -0
- package/src/lib/timestamp-columns.tsx +58 -0
- package/src/locales/ar.json +629 -0
- package/src/locales/de.json +629 -0
- package/src/locales/en.json +629 -0
- package/src/locales/fr.json +629 -0
- package/src/locales/pl.json +629 -0
- package/src/locales/zh-CN.json +629 -0
- package/src/plugin.ts +222 -0
- package/src/providers/auth-provider.tsx +188 -0
- package/src/providers/permission-provider.tsx +118 -0
- package/src/providers/store-provider.tsx +111 -0
- package/src/virtual.d.ts +5 -0
- package/src/vite/discover.ts +170 -0
- package/src/vite/index.ts +270 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CanCanCan subject class names — use these constants instead of raw strings
|
|
3
|
+
* to avoid typos in permission checks.
|
|
4
|
+
*
|
|
5
|
+
* Matches the Ruby class names serialized by the /api/v3/admin/me endpoint.
|
|
6
|
+
*/
|
|
7
|
+
export const Subject = {
|
|
8
|
+
All: 'all',
|
|
9
|
+
Product: 'Spree::Product',
|
|
10
|
+
Variant: 'Spree::Variant',
|
|
11
|
+
Order: 'Spree::Order',
|
|
12
|
+
Customer: 'Spree::User',
|
|
13
|
+
CustomerGroup: 'Spree::CustomerGroup',
|
|
14
|
+
AdminUser: 'Spree::AdminUser',
|
|
15
|
+
ApiKey: 'Spree::ApiKey',
|
|
16
|
+
AllowedOrigin: 'Spree::AllowedOrigin',
|
|
17
|
+
Store: 'Spree::Store',
|
|
18
|
+
Channel: 'Spree::Channel',
|
|
19
|
+
// Categories are Spree::Category < Spree::Taxon; abilities are published under
|
|
20
|
+
// the Spree::Taxon subject, so the value stays 'Spree::Taxon' while the key
|
|
21
|
+
// reads as the user-facing resource name.
|
|
22
|
+
Category: 'Spree::Taxon',
|
|
23
|
+
OptionType: 'Spree::OptionType',
|
|
24
|
+
OptionValue: 'Spree::OptionValue',
|
|
25
|
+
TaxCategory: 'Spree::TaxCategory',
|
|
26
|
+
CustomFieldDefinition: 'Spree::MetafieldDefinition',
|
|
27
|
+
PaymentMethod: 'Spree::PaymentMethod',
|
|
28
|
+
ShippingMethod: 'Spree::ShippingMethod',
|
|
29
|
+
StockLocation: 'Spree::StockLocation',
|
|
30
|
+
StockItem: 'Spree::StockItem',
|
|
31
|
+
StockTransfer: 'Spree::StockTransfer',
|
|
32
|
+
PriceList: 'Spree::PriceList',
|
|
33
|
+
PriceRule: 'Spree::PriceRule',
|
|
34
|
+
Promotion: 'Spree::Promotion',
|
|
35
|
+
PromotionAction: 'Spree::PromotionAction',
|
|
36
|
+
PromotionRule: 'Spree::PromotionRule',
|
|
37
|
+
GiftCard: 'Spree::GiftCard',
|
|
38
|
+
Market: 'Spree::Market',
|
|
39
|
+
WebhookEndpoint: 'Spree::WebhookEndpoint',
|
|
40
|
+
WebhookDelivery: 'Spree::WebhookDelivery',
|
|
41
|
+
Wishlist: 'Spree::Wishlist',
|
|
42
|
+
} as const
|
|
43
|
+
|
|
44
|
+
export type SubjectName = (typeof Subject)[keyof typeof Subject] | string
|
|
45
|
+
|
|
46
|
+
/** CanCanCan standard actions */
|
|
47
|
+
export const Action = {
|
|
48
|
+
Manage: 'manage',
|
|
49
|
+
Read: 'read',
|
|
50
|
+
Create: 'create',
|
|
51
|
+
Update: 'update',
|
|
52
|
+
Destroy: 'destroy',
|
|
53
|
+
} as const
|
|
54
|
+
|
|
55
|
+
export type ActionName = (typeof Action)[keyof typeof Action] | string
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { QueryKey } from '@tanstack/react-query'
|
|
2
|
+
import { useCallback } from 'react'
|
|
3
|
+
import { useStore } from '../providers/store-provider'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Canonical TanStack query-key shape for every dashboard resource:
|
|
7
|
+
*
|
|
8
|
+
* [resource, storeId, ...rest]
|
|
9
|
+
*
|
|
10
|
+
* Use `useResourceKey` (the hook) from inside React components to build
|
|
11
|
+
* store-scoped keys — it reads `storeId` from `<StoreProvider>` so callers
|
|
12
|
+
* never spell it themselves:
|
|
13
|
+
*
|
|
14
|
+
* useQuery({ queryKey: useResourceKey('channels', id), ... })
|
|
15
|
+
*
|
|
16
|
+
* The bare `resourceKey()` helper is the same builder without the storeId
|
|
17
|
+
* dependency — keep it for non-hook contexts (tests, build-time defaults).
|
|
18
|
+
*
|
|
19
|
+
* Lists: `useResourceKey('channels')`
|
|
20
|
+
* Singletons: `useResourceKey('channels', id)`
|
|
21
|
+
* Nested: `useResourceKey('products', productId, 'variants')`
|
|
22
|
+
*
|
|
23
|
+
* For invalidation inside `useResourceMutation`, the storeId is auto-injected
|
|
24
|
+
* — pass the logical key without it:
|
|
25
|
+
*
|
|
26
|
+
* invalidate: [['channels'], ['channels', id]]
|
|
27
|
+
*/
|
|
28
|
+
export function resourceKey(resource: string, ...rest: ReadonlyArray<unknown>): QueryKey {
|
|
29
|
+
return [resource, ...rest]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Hook variant of `resourceKey` that auto-injects the current `storeId` from
|
|
34
|
+
* `<StoreProvider>`. The default way to build query keys in dashboard hooks.
|
|
35
|
+
*/
|
|
36
|
+
export function useResourceKey(resource: string, ...rest: ReadonlyArray<unknown>): QueryKey {
|
|
37
|
+
const { storeId } = useStore()
|
|
38
|
+
return [resource, storeId, ...rest]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns a stable closure that builds store-scoped query keys. Use this when
|
|
43
|
+
* the key parts (typically an id) are only known later — e.g. inside a
|
|
44
|
+
* mutation's `onSuccess` where `variables.id` is per-call.
|
|
45
|
+
*
|
|
46
|
+
* const buildKey = useResourceKeyBuilder()
|
|
47
|
+
* return useResourceMutation({
|
|
48
|
+
* onSuccess: (_data, id) =>
|
|
49
|
+
* queryClient.removeQueries({ queryKey: buildKey('channels', id) }),
|
|
50
|
+
* })
|
|
51
|
+
*/
|
|
52
|
+
export function useResourceKeyBuilder() {
|
|
53
|
+
const { storeId } = useStore()
|
|
54
|
+
return useCallback(
|
|
55
|
+
(resource: string, ...rest: ReadonlyArray<unknown>): QueryKey => [resource, storeId, ...rest],
|
|
56
|
+
[storeId],
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Inject `storeId` at position 1 of a query key, leaving the resource name at
|
|
62
|
+
* position 0 and any further scope after it. Idempotent — if the storeId is
|
|
63
|
+
* already at position 1, returns the input unchanged.
|
|
64
|
+
*/
|
|
65
|
+
export function withStoreScope(key: QueryKey, storeId: string): QueryKey {
|
|
66
|
+
if (!Array.isArray(key) || key.length === 0) return key
|
|
67
|
+
if (key[1] === storeId) return key
|
|
68
|
+
return [key[0], storeId, ...key.slice(1)]
|
|
69
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { ComponentType } from 'react'
|
|
2
|
+
import { useSyncExternalStore } from 'react'
|
|
3
|
+
import type { SubjectName } from './permissions'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Dashboard route contributed by a plugin. Mounted under the dashboard's
|
|
7
|
+
* `/_authenticated/$storeId/_plugins/$` catch-all, which reads this registry
|
|
8
|
+
* at render time and dispatches based on the splat path.
|
|
9
|
+
*
|
|
10
|
+
* Path patterns use TanStack-Router-style param tokens (`$brandId`) inside the
|
|
11
|
+
* plugin's namespace. The dashboard strips the `/$storeId/` prefix before
|
|
12
|
+
* matching, so a plugin pattern of `/brands/$brandId` matches the URL
|
|
13
|
+
* `/store_xyz/brands/br_abc123` with `params: { brandId: 'br_abc123' }`.
|
|
14
|
+
*/
|
|
15
|
+
export interface RouteEntry {
|
|
16
|
+
/**
|
|
17
|
+
* Stable identifier — used for register/remove/update and as the React key.
|
|
18
|
+
* Mirrors the slug a plugin would use in its `nav.add({ key })` call so the
|
|
19
|
+
* sidebar entry and route stay paired.
|
|
20
|
+
*/
|
|
21
|
+
key: string
|
|
22
|
+
/**
|
|
23
|
+
* Path pattern relative to `/$storeId`. Examples: `/brands`,
|
|
24
|
+
* `/brands/$brandId`, `/wishlists/$wishlistId/items`.
|
|
25
|
+
* Must start with `/`. Do NOT include the `/$storeId` prefix; the dashboard
|
|
26
|
+
* adds it at render time.
|
|
27
|
+
*/
|
|
28
|
+
path: string
|
|
29
|
+
/**
|
|
30
|
+
* The component to render. Receives:
|
|
31
|
+
* - `params`: extracted path params (e.g. matching `/brands/$brandId`
|
|
32
|
+
* against `/brands/br_abc` yields `{ brandId: 'br_abc' }`)
|
|
33
|
+
* - `storeId`: the current store — every plugin route is implicitly scoped
|
|
34
|
+
* under `/$storeId/...`
|
|
35
|
+
* - `searchParams`: the URL search-state object from TanStack Router. Use
|
|
36
|
+
* this with `<ResourceTable searchParams={searchParams} ... />` so
|
|
37
|
+
* filter/sort/pagination round-trip through the URL.
|
|
38
|
+
*/
|
|
39
|
+
component: ComponentType<{
|
|
40
|
+
params: Record<string, string>
|
|
41
|
+
storeId: string
|
|
42
|
+
searchParams: Record<string, unknown>
|
|
43
|
+
}>
|
|
44
|
+
/**
|
|
45
|
+
* Optional CanCanCan subject required to view the route. The catch-all
|
|
46
|
+
* renderer checks `permissions.can('read', subject)` and renders a 403
|
|
47
|
+
* fallback when denied. Omit for routes that don't need permission gating.
|
|
48
|
+
*/
|
|
49
|
+
subject?: SubjectName
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const entries: RouteEntry[] = []
|
|
53
|
+
const listeners = new Set<() => void>()
|
|
54
|
+
|
|
55
|
+
function notify() {
|
|
56
|
+
for (const l of listeners) l()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function subscribe(listener: () => void): () => void {
|
|
60
|
+
listeners.add(listener)
|
|
61
|
+
return () => {
|
|
62
|
+
listeners.delete(listener)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getSnapshot(): readonly RouteEntry[] {
|
|
67
|
+
return entries
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface RouteMutator {
|
|
71
|
+
/** Register a route. Throws if `key` is already registered. */
|
|
72
|
+
add(entry: RouteEntry): void
|
|
73
|
+
/** Remove a route by key. No-op when missing. */
|
|
74
|
+
remove(key: string): void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const pluginRoutes: RouteMutator = {
|
|
78
|
+
add(entry) {
|
|
79
|
+
if (entries.some((e) => e.key === entry.key)) {
|
|
80
|
+
throw new Error(`Plugin route "${entry.key}" already registered.`)
|
|
81
|
+
}
|
|
82
|
+
if (!entry.path.startsWith('/')) {
|
|
83
|
+
throw new Error(`Plugin route "${entry.key}" path must start with "/", got "${entry.path}".`)
|
|
84
|
+
}
|
|
85
|
+
entries.push(entry)
|
|
86
|
+
notify()
|
|
87
|
+
},
|
|
88
|
+
remove(key) {
|
|
89
|
+
const i = entries.findIndex((e) => e.key === key)
|
|
90
|
+
if (i === -1) return
|
|
91
|
+
entries.splice(i, 1)
|
|
92
|
+
notify()
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Subscribe to plugin route updates. Returns the full list of registered
|
|
98
|
+
* routes; the catch-all renderer matches against this on every navigation.
|
|
99
|
+
*/
|
|
100
|
+
export function usePluginRoutes(): readonly RouteEntry[] {
|
|
101
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Match a URL splat (the bit after `/$storeId/`) against the registered
|
|
106
|
+
* routes. Returns the matched entry plus the extracted path params, or
|
|
107
|
+
* `null` if nothing matched.
|
|
108
|
+
*
|
|
109
|
+
* Path patterns support TanStack-style `$param` tokens. A pattern segment
|
|
110
|
+
* of `$brandId` matches any non-empty URL segment and binds it to
|
|
111
|
+
* `params.brandId`.
|
|
112
|
+
*
|
|
113
|
+
* More-specific patterns win regardless of registration order: `/brands/new`
|
|
114
|
+
* beats `/brands/$brandId` for the URL `/brands/new` because it has fewer
|
|
115
|
+
* dynamic segments.
|
|
116
|
+
*/
|
|
117
|
+
export function matchPluginRoute(
|
|
118
|
+
splat: string,
|
|
119
|
+
routes: readonly RouteEntry[],
|
|
120
|
+
): { entry: RouteEntry; params: Record<string, string> } | null {
|
|
121
|
+
// Normalize: strip leading slash, drop empty trailing segments.
|
|
122
|
+
const urlSegments = splat.replace(/^\/+/, '').split('/').filter(Boolean)
|
|
123
|
+
const bySpecificity = [...routes].sort(
|
|
124
|
+
(a, b) => countDynamicSegments(a.path) - countDynamicSegments(b.path),
|
|
125
|
+
)
|
|
126
|
+
for (const entry of bySpecificity) {
|
|
127
|
+
const patternSegments = entry.path.replace(/^\/+/, '').split('/').filter(Boolean)
|
|
128
|
+
if (patternSegments.length !== urlSegments.length) continue
|
|
129
|
+
|
|
130
|
+
const params: Record<string, string> = {}
|
|
131
|
+
let matched = true
|
|
132
|
+
for (let i = 0; i < patternSegments.length; i++) {
|
|
133
|
+
const p = patternSegments[i]
|
|
134
|
+
const u = urlSegments[i]
|
|
135
|
+
if (p.startsWith('$')) {
|
|
136
|
+
params[p.slice(1)] = u
|
|
137
|
+
} else if (p !== u) {
|
|
138
|
+
matched = false
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (matched) return { entry, params }
|
|
143
|
+
}
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function countDynamicSegments(path: string): number {
|
|
148
|
+
return path
|
|
149
|
+
.replace(/^\/+/, '')
|
|
150
|
+
.split('/')
|
|
151
|
+
.filter((segment) => segment.startsWith('$')).length
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Test-only: clear the registry. */
|
|
155
|
+
export function __resetPluginRoutes(): void {
|
|
156
|
+
entries.length = 0
|
|
157
|
+
notify()
|
|
158
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useSyncExternalStore } from 'react'
|
|
3
|
+
import type { SubjectName } from './permissions'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Registry for the command-palette global resource search. Mirrors
|
|
7
|
+
* `nav-registry.ts`: a module-singleton populated at app boot by side-effect
|
|
8
|
+
* modules (see `@/search/default` in the dashboard app), read reactively via
|
|
9
|
+
* `useSearchEntries`.
|
|
10
|
+
*
|
|
11
|
+
* Each entry teaches the palette how to search one resource type: how to fetch
|
|
12
|
+
* matches, how to render a result row, and where a row navigates. Adding a new
|
|
13
|
+
* searchable resource is one `searchRegistry.add(...)` call — no edits to the
|
|
14
|
+
* palette component or the search hook. Plugins extend search the same way they
|
|
15
|
+
* extend the nav.
|
|
16
|
+
*
|
|
17
|
+
* The result type is erased at the registry boundary (`unknown`): the palette
|
|
18
|
+
* never inspects a result directly, it only hands each one back to the entry's
|
|
19
|
+
* own `renderRow`/`getRoute`/`getKey`, which are typed against the resource.
|
|
20
|
+
* Use `defineSearchEntry` to keep that wiring type-safe at the call site.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface SearchResultRow {
|
|
24
|
+
/** cmdk value — must be unique across the whole list; prefix with the entry key. */
|
|
25
|
+
value: string
|
|
26
|
+
/** Row contents rendered inside the `CommandItem`. */
|
|
27
|
+
content: ReactNode
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SearchEntry<T = unknown> {
|
|
31
|
+
/** Stable identifier — register/remove key, React key, and result-value prefix. */
|
|
32
|
+
key: string
|
|
33
|
+
/** i18n key for the group heading (resolved with `t` at render time). */
|
|
34
|
+
headingKey: string
|
|
35
|
+
/** CanCanCan subject gating both the query and the rendered group. Omit for always-on. */
|
|
36
|
+
subject?: SubjectName
|
|
37
|
+
/**
|
|
38
|
+
* Lower numbers render first. Built-ins use 100/200/300… so plugins can slot
|
|
39
|
+
* between them. Also orders the parallel queries, purely cosmetically.
|
|
40
|
+
*/
|
|
41
|
+
position?: number
|
|
42
|
+
/**
|
|
43
|
+
* Fetch up to `limit` matches for `query`. Returns the raw resource records;
|
|
44
|
+
* the palette passes each back through this entry's `renderRow`/`getRoute`.
|
|
45
|
+
*/
|
|
46
|
+
fetch: (query: string, limit: number) => Promise<T[]>
|
|
47
|
+
/** Stable React key for a single result (typically the prefixed `id`). */
|
|
48
|
+
getKey: (item: T) => string
|
|
49
|
+
/** Render one result row's inner content (icon/thumbnail + label + badges). */
|
|
50
|
+
renderRow: (item: T) => ReactNode
|
|
51
|
+
/** Resolve the destination for a clicked result, given the active store. */
|
|
52
|
+
getRoute: (item: T, storeId: string) => { to: string }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface SearchMutator {
|
|
56
|
+
/** Register an entry. Throws if the key is already registered. */
|
|
57
|
+
add<T>(entry: SearchEntry<T>): void
|
|
58
|
+
/** Remove an entry by key. No-op when missing. */
|
|
59
|
+
remove(key: string): void
|
|
60
|
+
/** Patch an existing entry. Throws if the key is missing. */
|
|
61
|
+
update<T>(key: string, patch: Partial<Omit<SearchEntry<T>, 'key'>>): void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const entries: SearchEntry[] = []
|
|
65
|
+
const listeners = new Set<() => void>()
|
|
66
|
+
let snapshotCache: SearchEntry[] | null = null
|
|
67
|
+
|
|
68
|
+
function notify() {
|
|
69
|
+
snapshotCache = null
|
|
70
|
+
for (const l of listeners) l()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const searchRegistry: SearchMutator = {
|
|
74
|
+
add(entry) {
|
|
75
|
+
if (entries.some((e) => e.key === entry.key)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Search entry "${entry.key}" already registered. Use searchRegistry.update().`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
entries.push(entry as SearchEntry)
|
|
81
|
+
notify()
|
|
82
|
+
},
|
|
83
|
+
remove(key) {
|
|
84
|
+
const i = entries.findIndex((e) => e.key === key)
|
|
85
|
+
if (i === -1) return
|
|
86
|
+
entries.splice(i, 1)
|
|
87
|
+
notify()
|
|
88
|
+
},
|
|
89
|
+
update(key, patch) {
|
|
90
|
+
const e = entries.find((x) => x.key === key)
|
|
91
|
+
if (!e) throw new Error(`Search entry "${key}" not found.`)
|
|
92
|
+
Object.assign(e, patch)
|
|
93
|
+
notify()
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function subscribe(listener: () => void): () => void {
|
|
98
|
+
listeners.add(listener)
|
|
99
|
+
return () => {
|
|
100
|
+
listeners.delete(listener)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getSnapshot(): SearchEntry[] {
|
|
105
|
+
if (!snapshotCache) {
|
|
106
|
+
snapshotCache = [...entries].sort((a, b) => (a.position ?? 100) - (b.position ?? 100))
|
|
107
|
+
}
|
|
108
|
+
return snapshotCache
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Reactively read the sorted search entries. Re-renders on registry mutation. */
|
|
112
|
+
export function useSearchEntries(): SearchEntry[] {
|
|
113
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Identity helper that infers `T` from `fetch`/`getKey`/`renderRow`/`getRoute`
|
|
118
|
+
* so the callbacks are typed against the resource at the registration site,
|
|
119
|
+
* while the registry stores the erased `SearchEntry`.
|
|
120
|
+
*/
|
|
121
|
+
export function defineSearchEntry<T>(entry: SearchEntry<T>): SearchEntry<T> {
|
|
122
|
+
return entry
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Test-only: clear the registry. */
|
|
126
|
+
export function __resetSearchRegistry(): void {
|
|
127
|
+
entries.length = 0
|
|
128
|
+
notify()
|
|
129
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { LucideIcon } from 'lucide-react'
|
|
2
|
+
import { useSyncExternalStore } from 'react'
|
|
3
|
+
import type { SubjectName } from './permissions'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Registry for the settings sub-shell sidebar. Mirrors `nav-registry.ts` but
|
|
7
|
+
* adds a `group` key so entries cluster under labelled section headers
|
|
8
|
+
* (Store · Localization · Team & Access · Developer …).
|
|
9
|
+
*
|
|
10
|
+
* Plugins extend the settings nav by importing `settingsNav` and calling
|
|
11
|
+
* `settingsNav.add(...)` from a side-effect module loaded at app boot.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface SettingsNavEntry {
|
|
15
|
+
/** Stable identifier — used for register/remove/update and as the React key. */
|
|
16
|
+
key: string
|
|
17
|
+
/**
|
|
18
|
+
* Visible label. Either `label` (literal) or `labelKey` (i18n key) must be
|
|
19
|
+
* set; built-in entries use `labelKey` so the sidebar re-renders on
|
|
20
|
+
* language change. Plugins can use either.
|
|
21
|
+
*/
|
|
22
|
+
label?: string
|
|
23
|
+
/** i18n key passed to `t(...)` at render time. Takes precedence over `label`. */
|
|
24
|
+
labelKey?: string
|
|
25
|
+
/**
|
|
26
|
+
* Path template, prefixed with `/$storeId/settings` at render time. Pass paths
|
|
27
|
+
* like `'/general'` or `'/staff'`, NOT `/store_abc/settings/general`. The
|
|
28
|
+
* leading slash is required.
|
|
29
|
+
*/
|
|
30
|
+
path: string
|
|
31
|
+
/** Icon shown next to the label. */
|
|
32
|
+
icon?: LucideIcon
|
|
33
|
+
/** Group identifier — entries with the same group cluster under one header. */
|
|
34
|
+
group: string
|
|
35
|
+
/** Position within the group. Lower numbers render first. Defaults to 100. */
|
|
36
|
+
position?: number
|
|
37
|
+
/** CanCanCan subject required to see this item. Omit for always-visible. */
|
|
38
|
+
subject?: SubjectName
|
|
39
|
+
/** When true, the page is disabled in the sidebar with a "Soon" badge. */
|
|
40
|
+
comingSoon?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SettingsNavGroup {
|
|
44
|
+
/** Group identifier referenced by entries. */
|
|
45
|
+
key: string
|
|
46
|
+
/** Visible header label. Mirrors `SettingsNavEntry.label` — pair with `labelKey` when localising. */
|
|
47
|
+
label?: string
|
|
48
|
+
/** i18n key passed to `t(...)` at render time. Takes precedence over `label`. */
|
|
49
|
+
labelKey?: string
|
|
50
|
+
/** Group ordering. Lower numbers render first. Defaults to 100. */
|
|
51
|
+
position?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface SettingsNavMutator {
|
|
55
|
+
/** Register an entry. Throws if the key is already registered. */
|
|
56
|
+
add(entry: SettingsNavEntry): void
|
|
57
|
+
/** Remove an entry by key. No-op when missing. */
|
|
58
|
+
remove(key: string): void
|
|
59
|
+
/** Patch an existing entry. Throws if the key is missing. */
|
|
60
|
+
update(key: string, patch: Partial<Omit<SettingsNavEntry, 'key'>>): void
|
|
61
|
+
/** Register a group. Throws if the key is already registered. */
|
|
62
|
+
addGroup(group: SettingsNavGroup): void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const entries: SettingsNavEntry[] = []
|
|
66
|
+
const groups: SettingsNavGroup[] = []
|
|
67
|
+
const listeners = new Set<() => void>()
|
|
68
|
+
let snapshotCache: SettingsNavSnapshot | null = null
|
|
69
|
+
|
|
70
|
+
export interface SettingsNavSnapshot {
|
|
71
|
+
/** Groups, sorted by `position`, each with their entries (also sorted). */
|
|
72
|
+
groups: Array<{ group: SettingsNavGroup; entries: SettingsNavEntry[] }>
|
|
73
|
+
/** Flat list of every entry, sorted by group position then entry position. */
|
|
74
|
+
all: SettingsNavEntry[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function notify() {
|
|
78
|
+
snapshotCache = null
|
|
79
|
+
for (const l of listeners) l()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const settingsNav: SettingsNavMutator = {
|
|
83
|
+
add(entry) {
|
|
84
|
+
if (!entry.label && !entry.labelKey) {
|
|
85
|
+
throw new Error(`Settings nav entry "${entry.key}" must define either "label" or "labelKey".`)
|
|
86
|
+
}
|
|
87
|
+
if (entries.some((e) => e.key === entry.key)) {
|
|
88
|
+
throw new Error(`Settings nav entry "${entry.key}" already registered.`)
|
|
89
|
+
}
|
|
90
|
+
entries.push(entry)
|
|
91
|
+
notify()
|
|
92
|
+
},
|
|
93
|
+
remove(key) {
|
|
94
|
+
const i = entries.findIndex((e) => e.key === key)
|
|
95
|
+
if (i === -1) return
|
|
96
|
+
entries.splice(i, 1)
|
|
97
|
+
notify()
|
|
98
|
+
},
|
|
99
|
+
update(key, patch) {
|
|
100
|
+
const e = entries.find((x) => x.key === key)
|
|
101
|
+
if (!e) throw new Error(`Settings nav entry "${key}" not found.`)
|
|
102
|
+
Object.assign(e, patch)
|
|
103
|
+
// Re-validate the merged entry — the patch could remove both label fields.
|
|
104
|
+
if (!e.label && !e.labelKey) {
|
|
105
|
+
throw new Error(`Settings nav entry "${e.key}" must define either "label" or "labelKey".`)
|
|
106
|
+
}
|
|
107
|
+
notify()
|
|
108
|
+
},
|
|
109
|
+
addGroup(group) {
|
|
110
|
+
if (!group.label && !group.labelKey) {
|
|
111
|
+
throw new Error(`Settings nav group "${group.key}" must define either "label" or "labelKey".`)
|
|
112
|
+
}
|
|
113
|
+
if (groups.some((g) => g.key === group.key)) {
|
|
114
|
+
throw new Error(`Settings nav group "${group.key}" already registered.`)
|
|
115
|
+
}
|
|
116
|
+
groups.push(group)
|
|
117
|
+
notify()
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildSnapshot(): SettingsNavSnapshot {
|
|
122
|
+
const sortedGroups = [...groups].sort((a, b) => (a.position ?? 100) - (b.position ?? 100))
|
|
123
|
+
const grouped = sortedGroups
|
|
124
|
+
.map((group) => ({
|
|
125
|
+
group,
|
|
126
|
+
entries: entries
|
|
127
|
+
.filter((e) => e.group === group.key)
|
|
128
|
+
.sort((a, b) => (a.position ?? 100) - (b.position ?? 100)),
|
|
129
|
+
}))
|
|
130
|
+
.filter((g) => g.entries.length > 0)
|
|
131
|
+
return {
|
|
132
|
+
groups: grouped,
|
|
133
|
+
all: grouped.flatMap((g) => g.entries),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function subscribe(listener: () => void): () => void {
|
|
138
|
+
listeners.add(listener)
|
|
139
|
+
return () => {
|
|
140
|
+
listeners.delete(listener)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getSnapshot() {
|
|
145
|
+
if (!snapshotCache) snapshotCache = buildSnapshot()
|
|
146
|
+
return snapshotCache
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function useSettingsNav() {
|
|
150
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Test-only: clear the registry. */
|
|
154
|
+
export function __resetSettingsNavRegistry(): void {
|
|
155
|
+
entries.length = 0
|
|
156
|
+
groups.length = 0
|
|
157
|
+
notify()
|
|
158
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ComponentType } from 'react'
|
|
2
|
+
import { useSyncExternalStore } from 'react'
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Types
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ambient context every slot receives, on top of slot-specific context.
|
|
10
|
+
* Populated by SlotProvider (see slot.tsx). Plugins reading these don't need
|
|
11
|
+
* to import auth/permission hooks — the slot machinery passes them through.
|
|
12
|
+
*/
|
|
13
|
+
export interface SlotAmbientContext {
|
|
14
|
+
permissions?: unknown
|
|
15
|
+
store?: unknown
|
|
16
|
+
user?: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SlotEntry<TContext = unknown> {
|
|
20
|
+
/** Unique within a slot. Required for removeSlot/updateSlot. */
|
|
21
|
+
id: string
|
|
22
|
+
component: ComponentType<TContext & SlotAmbientContext>
|
|
23
|
+
/** Lower numbers render first. Built-ins use 100/200/300 to leave gaps. */
|
|
24
|
+
position?: number
|
|
25
|
+
/** Visibility predicate. Receives the merged context. */
|
|
26
|
+
if?: (ctx: TContext & SlotAmbientContext) => boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Registry — module-singleton keyed by slot name
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
const registry = new Map<string, SlotEntry[]>()
|
|
34
|
+
const listeners = new Set<() => void>()
|
|
35
|
+
|
|
36
|
+
function notify() {
|
|
37
|
+
for (const listener of listeners) listener()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function subscribe(listener: () => void) {
|
|
41
|
+
listeners.add(listener)
|
|
42
|
+
return () => {
|
|
43
|
+
listeners.delete(listener)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getSnapshot(name: string): readonly SlotEntry[] {
|
|
48
|
+
return registry.get(name) ?? EMPTY
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const EMPTY: readonly SlotEntry[] = Object.freeze([])
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// Public API
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
export function registerSlot<TContext = unknown>(name: string, entry: SlotEntry<TContext>): void {
|
|
58
|
+
const list = registry.get(name) ?? []
|
|
59
|
+
if (list.some((e) => e.id === entry.id)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Slot "${name}" already has an entry with id "${entry.id}". Use updateSlot() instead.`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
registry.set(name, [...list, entry as SlotEntry])
|
|
65
|
+
notify()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function removeSlot(name: string, id: string): void {
|
|
69
|
+
const list = registry.get(name)
|
|
70
|
+
if (!list) return
|
|
71
|
+
const next = list.filter((e) => e.id !== id)
|
|
72
|
+
if (next.length === list.length) return
|
|
73
|
+
registry.set(name, next)
|
|
74
|
+
notify()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function updateSlot<TContext = unknown>(
|
|
78
|
+
name: string,
|
|
79
|
+
id: string,
|
|
80
|
+
patch: Partial<Omit<SlotEntry<TContext>, 'id'>>,
|
|
81
|
+
): void {
|
|
82
|
+
const list = registry.get(name)
|
|
83
|
+
const entry = list?.find((e) => e.id === id)
|
|
84
|
+
if (!list || !entry) {
|
|
85
|
+
throw new Error(`Slot entry "${name}#${id}" not found.`)
|
|
86
|
+
}
|
|
87
|
+
registry.set(
|
|
88
|
+
name,
|
|
89
|
+
list.map((e) => (e.id === id ? ({ ...e, ...patch } as SlotEntry) : e)),
|
|
90
|
+
)
|
|
91
|
+
notify()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Subscribe to a slot's entries. Re-renders when entries are added/removed/updated
|
|
96
|
+
* so plugins registered after first render still appear.
|
|
97
|
+
*
|
|
98
|
+
* Returned entries are sorted by position (default 100) and stable within ties.
|
|
99
|
+
* Filtering by `if` happens at render time inside <Slot> — the hook returns the
|
|
100
|
+
* full registered list so the caller can decide how to combine context.
|
|
101
|
+
*/
|
|
102
|
+
export function useSlotEntries(name: string): readonly SlotEntry[] {
|
|
103
|
+
const entries = useSyncExternalStore(
|
|
104
|
+
subscribe,
|
|
105
|
+
() => getSnapshot(name),
|
|
106
|
+
() => getSnapshot(name),
|
|
107
|
+
)
|
|
108
|
+
// Sort by position (stable). Returned array is fresh; safe to reference in render.
|
|
109
|
+
return [...entries].sort((a, b) => (a.position ?? 100) - (b.position ?? 100))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Test-only: clear all registered slots. Not exported from the package index. */
|
|
113
|
+
export function __resetSlotRegistry(): void {
|
|
114
|
+
registry.clear()
|
|
115
|
+
notify()
|
|
116
|
+
}
|