@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,274 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { ensureTimestampColumns } from './timestamp-columns'
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Types
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
/** Shared shape — every column has these regardless of filter type. */
|
|
9
|
+
interface ColumnDefBase<T = any> {
|
|
10
|
+
key: string
|
|
11
|
+
label: string
|
|
12
|
+
sortable?: boolean
|
|
13
|
+
filterable?: boolean
|
|
14
|
+
default?: boolean
|
|
15
|
+
/** Ransack attribute name if different from key (e.g., 'master_sku' for 'sku') */
|
|
16
|
+
ransackAttribute?: string
|
|
17
|
+
/** Custom cell renderer. If omitted, renders `row[key]` as text. */
|
|
18
|
+
render?: (row: T) => ReactNode
|
|
19
|
+
/** Cell className */
|
|
20
|
+
className?: string
|
|
21
|
+
/** Whether this column is displayable in the table. Set to false for filter-only columns. */
|
|
22
|
+
displayable?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Config for `filterType: 'resource'` — drives a `<ResourceMultiAutocomplete>`
|
|
27
|
+
* inside the filter panel and hydrates labels in the active-filter chip.
|
|
28
|
+
*
|
|
29
|
+
* `search` is called as the user types. `hydrate` resolves currently-selected
|
|
30
|
+
* IDs (deep-link reload, navigating back to the page) to records so the chip
|
|
31
|
+
* can show human-readable names instead of raw prefixed IDs.
|
|
32
|
+
*/
|
|
33
|
+
export interface ResourceFilterConfig<R extends { id: string } = { id: string }> {
|
|
34
|
+
queryKey: string
|
|
35
|
+
search: (query: string) => Promise<{ data: R[] }>
|
|
36
|
+
hydrate: (ids: string[]) => Promise<{ data: R[] }>
|
|
37
|
+
getOptionLabel: (option: R) => string
|
|
38
|
+
placeholder?: string
|
|
39
|
+
emptyText?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Models that `TagCombobox` can target for tag autocomplete. The three
|
|
44
|
+
* first-class taggables (Product, User, Order) get autocomplete hints, but
|
|
45
|
+
* the union is open — apps can pass any Ruby class string the backend's
|
|
46
|
+
* `TagsController#allowed_taggable_types` accepts (override that method
|
|
47
|
+
* server-side to extend). Use `Subject.Product` etc. to avoid stringly-typed
|
|
48
|
+
* callsites for the built-ins.
|
|
49
|
+
*/
|
|
50
|
+
export type TaggableType = 'Spree::Product' | 'Spree::User' | 'Spree::Order' | (string & {})
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Column definition. Discriminated union on `filterType`:
|
|
54
|
+
* - `'enum'` → `filterOptions` is **required**
|
|
55
|
+
* - `'resource'` → `filterResource` is **required** (multi-select picker)
|
|
56
|
+
* - `'tags'` → `taggableType` is **required** (drives TagCombobox)
|
|
57
|
+
* - other types → all variant fields must be omitted
|
|
58
|
+
*/
|
|
59
|
+
export type ColumnDef<T = any> =
|
|
60
|
+
| (ColumnDefBase<T> & {
|
|
61
|
+
filterType?: 'string' | 'boolean' | 'number' | 'date' | 'currency'
|
|
62
|
+
filterOptions?: never
|
|
63
|
+
filterResource?: never
|
|
64
|
+
taggableType?: never
|
|
65
|
+
})
|
|
66
|
+
| (ColumnDefBase<T> & {
|
|
67
|
+
filterType: 'enum'
|
|
68
|
+
filterOptions: { value: string; label: string }[]
|
|
69
|
+
filterResource?: never
|
|
70
|
+
taggableType?: never
|
|
71
|
+
})
|
|
72
|
+
| (ColumnDefBase<T> & {
|
|
73
|
+
filterType: 'resource'
|
|
74
|
+
filterOptions?: never
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
76
|
+
filterResource: ResourceFilterConfig<any>
|
|
77
|
+
taggableType?: never
|
|
78
|
+
})
|
|
79
|
+
| (ColumnDefBase<T> & {
|
|
80
|
+
filterType: 'tags'
|
|
81
|
+
filterOptions?: never
|
|
82
|
+
filterResource?: never
|
|
83
|
+
taggableType: TaggableType
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
export interface FilterRule {
|
|
87
|
+
id: string
|
|
88
|
+
field: string
|
|
89
|
+
operator: string
|
|
90
|
+
/**
|
|
91
|
+
* For array-valued operators (`in`, `not_in`) and the `'resource'` filter
|
|
92
|
+
* type, the IDs are encoded as a trimmed CSV string. Use `parseFilterIds`
|
|
93
|
+
* to decode and `ids.join(',')` to encode — keeps URL serialization simple.
|
|
94
|
+
*/
|
|
95
|
+
value: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function parseFilterIds(value: string): string[] {
|
|
99
|
+
return value
|
|
100
|
+
.split(',')
|
|
101
|
+
.map((s) => s.trim())
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface SortOption {
|
|
106
|
+
field: string
|
|
107
|
+
direction: 'asc' | 'desc'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface TableDef<T = any> {
|
|
111
|
+
key: string
|
|
112
|
+
title?: string
|
|
113
|
+
columns: ColumnDef<T>[]
|
|
114
|
+
searchParam?: string
|
|
115
|
+
searchPlaceholder?: string
|
|
116
|
+
defaultSort?: SortOption
|
|
117
|
+
emptyIcon?: ReactNode
|
|
118
|
+
emptyMessage?: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ============================================================================
|
|
122
|
+
// Registry
|
|
123
|
+
// ============================================================================
|
|
124
|
+
|
|
125
|
+
const registry = new Map<string, TableDef>()
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Queued mutations against tables that haven't been `defineTable`-d yet.
|
|
129
|
+
*
|
|
130
|
+
* Built-in tables register via side-effect imports in route files
|
|
131
|
+
* (`import '@/tables/products'`) — they only execute when the route loads.
|
|
132
|
+
* Plugin authors who call `tables.products.addColumn(...)` from their
|
|
133
|
+
* boot-time entry module would race with that lazy registration. To avoid
|
|
134
|
+
* forcing every plugin to know which routes have loaded, mutations on an
|
|
135
|
+
* unregistered table are queued and replayed by `defineTable` when the
|
|
136
|
+
* table appears.
|
|
137
|
+
*
|
|
138
|
+
* Mutations on tables that NEVER register stay in the queue and never fire,
|
|
139
|
+
* which is the right semantics: a plugin extending an optional feature
|
|
140
|
+
* shouldn't crash when that feature isn't installed.
|
|
141
|
+
*/
|
|
142
|
+
const pending = new Map<string, Array<(t: TableDef) => void>>()
|
|
143
|
+
|
|
144
|
+
function enqueue(tableKey: string, fn: (t: TableDef) => void) {
|
|
145
|
+
const list = pending.get(tableKey) ?? []
|
|
146
|
+
list.push(fn)
|
|
147
|
+
pending.set(tableKey, list)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function flushPending(tableKey: string, table: TableDef) {
|
|
151
|
+
const queue = pending.get(tableKey)
|
|
152
|
+
if (!queue) return
|
|
153
|
+
pending.delete(tableKey)
|
|
154
|
+
// Iterate the full queue even if individual mutations throw. A plugin that
|
|
155
|
+
// registers a duplicate column shouldn't silently drop every later mutation
|
|
156
|
+
// for the same table — collect errors and surface them all.
|
|
157
|
+
const errors: unknown[] = []
|
|
158
|
+
for (const fn of queue) {
|
|
159
|
+
try {
|
|
160
|
+
fn(table)
|
|
161
|
+
} catch (err) {
|
|
162
|
+
errors.push(err)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (errors.length === 1) throw errors[0]
|
|
166
|
+
if (errors.length > 1) {
|
|
167
|
+
throw new AggregateError(errors, `${errors.length} mutation(s) failed for table "${tableKey}"`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function defineTable<T = any>(key: string, def: Omit<TableDef<T>, 'key'>): TableDef<T> {
|
|
172
|
+
const tableDef = {
|
|
173
|
+
...def,
|
|
174
|
+
key,
|
|
175
|
+
columns: ensureTimestampColumns(def.columns),
|
|
176
|
+
} as TableDef<T>
|
|
177
|
+
registry.set(key, tableDef)
|
|
178
|
+
flushPending(key, tableDef as TableDef)
|
|
179
|
+
return tableDef
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function getTable<T = any>(key: string): TableDef<T> {
|
|
183
|
+
const table = registry.get(key)
|
|
184
|
+
if (!table)
|
|
185
|
+
throw new Error(`Table "${key}" is not registered. Call defineTable("${key}", ...) first.`)
|
|
186
|
+
return table as TableDef<T>
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ============================================================================
|
|
190
|
+
// Mutation API — allows extensions to modify registered tables
|
|
191
|
+
// ============================================================================
|
|
192
|
+
|
|
193
|
+
export const tables = new Proxy({} as Record<string, TableMutator>, {
|
|
194
|
+
get(_target, key: string) {
|
|
195
|
+
return createMutator(key)
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
interface TableMutator {
|
|
200
|
+
addColumn<T = any>(column: ColumnDef<T>): void
|
|
201
|
+
removeColumn(key: string): void
|
|
202
|
+
updateColumn<T = any>(key: string, updates: Partial<ColumnDef<T>>): void
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function applyAddColumn(table: TableDef, column: ColumnDef) {
|
|
206
|
+
if (table.columns.some((c) => c.key === column.key)) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Column "${column.key}" already exists in table "${table.key}". Use updateColumn() instead.`,
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
table.columns.push(column)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function applyRemoveColumn(table: TableDef, key: string) {
|
|
215
|
+
table.columns = table.columns.filter((c) => c.key !== key)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function applyUpdateColumn(table: TableDef, key: string, updates: Partial<ColumnDef>) {
|
|
219
|
+
const col = table.columns.find((c) => c.key === key)
|
|
220
|
+
if (!col) throw new Error(`Column "${key}" not found in table "${table.key}".`)
|
|
221
|
+
Object.assign(col, updates)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function createMutator(tableKey: string): TableMutator {
|
|
225
|
+
return {
|
|
226
|
+
addColumn(column) {
|
|
227
|
+
const table = registry.get(tableKey)
|
|
228
|
+
if (table) applyAddColumn(table, column as ColumnDef)
|
|
229
|
+
else enqueue(tableKey, (t) => applyAddColumn(t, column as ColumnDef))
|
|
230
|
+
},
|
|
231
|
+
removeColumn(key) {
|
|
232
|
+
const table = registry.get(tableKey)
|
|
233
|
+
if (table) applyRemoveColumn(table, key)
|
|
234
|
+
else enqueue(tableKey, (t) => applyRemoveColumn(t, key))
|
|
235
|
+
},
|
|
236
|
+
updateColumn(key, updates) {
|
|
237
|
+
const table = registry.get(tableKey)
|
|
238
|
+
if (table) applyUpdateColumn(table, key, updates as Partial<ColumnDef>)
|
|
239
|
+
else enqueue(tableKey, (t) => applyUpdateColumn(t, key, updates as Partial<ColumnDef>))
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Test-only: clear the registry and any pending mutations. */
|
|
245
|
+
export function __resetTableRegistry(): void {
|
|
246
|
+
registry.clear()
|
|
247
|
+
pending.clear()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ============================================================================
|
|
251
|
+
// Helpers
|
|
252
|
+
// ============================================================================
|
|
253
|
+
|
|
254
|
+
/** Get columns that are displayable (not filter-only) */
|
|
255
|
+
export function getDisplayableColumns<T = any>(table: TableDef<T>): ColumnDef<T>[] {
|
|
256
|
+
return table.columns.filter((c) => c.displayable !== false)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Get default visible column keys */
|
|
260
|
+
export function getDefaultColumnKeys(table: TableDef): string[] {
|
|
261
|
+
return getDisplayableColumns(table)
|
|
262
|
+
.filter((c) => c.default)
|
|
263
|
+
.map((c) => c.key)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Get columns that can be filtered */
|
|
267
|
+
export function getFilterableColumns(table: TableDef): ColumnDef[] {
|
|
268
|
+
return table.columns.filter((c) => c.filterable)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Get columns that can be sorted */
|
|
272
|
+
export function getSortableColumns(table: TableDef): ColumnDef[] {
|
|
273
|
+
return table.columns.filter((c) => c.sortable)
|
|
274
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { RelativeTime } from '@spree/dashboard-ui'
|
|
2
|
+
import i18n from 'i18next'
|
|
3
|
+
import type { ColumnDef } from './table-registry'
|
|
4
|
+
|
|
5
|
+
const TIMESTAMP_KEYS = ['created_at', 'updated_at'] as const
|
|
6
|
+
|
|
7
|
+
type TimestampKey = (typeof TIMESTAMP_KEYS)[number]
|
|
8
|
+
|
|
9
|
+
const TIMESTAMP_CLASS = 'text-sm text-muted-foreground whitespace-nowrap'
|
|
10
|
+
|
|
11
|
+
function createTimestampColumn(key: TimestampKey, visibleByDefault = false): ColumnDef {
|
|
12
|
+
return {
|
|
13
|
+
key,
|
|
14
|
+
label: i18n.t(`admin.fields.${key}.label`),
|
|
15
|
+
sortable: true,
|
|
16
|
+
filterable: true,
|
|
17
|
+
default: visibleByDefault,
|
|
18
|
+
filterType: 'date',
|
|
19
|
+
className: TIMESTAMP_CLASS,
|
|
20
|
+
render: (row) => <RelativeTime iso={row[key]} />,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function enhanceTimestampColumn(col: ColumnDef, key: TimestampKey): ColumnDef {
|
|
25
|
+
const sortable = col.sortable !== false
|
|
26
|
+
const filterable = col.filterable !== false
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
...col,
|
|
30
|
+
label: col.label || i18n.t(`admin.fields.${key}.label`),
|
|
31
|
+
sortable,
|
|
32
|
+
filterable,
|
|
33
|
+
...(filterable ? { filterType: 'date' as const } : {}),
|
|
34
|
+
default: col.default ?? false,
|
|
35
|
+
className: col.className ?? TIMESTAMP_CLASS,
|
|
36
|
+
render: col.render ?? ((row) => <RelativeTime iso={row[key]} />),
|
|
37
|
+
} as ColumnDef
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Ensures every table exposes `created_at` and `updated_at` as sortable,
|
|
42
|
+
* filterable date columns. Missing columns are appended (hidden by default);
|
|
43
|
+
* existing definitions are preserved and augmented with sort/filter when absent.
|
|
44
|
+
*/
|
|
45
|
+
export function ensureTimestampColumns(columns: ColumnDef[]): ColumnDef[] {
|
|
46
|
+
const result = [...columns]
|
|
47
|
+
|
|
48
|
+
for (const key of TIMESTAMP_KEYS) {
|
|
49
|
+
const index = result.findIndex((col) => col.key === key)
|
|
50
|
+
if (index === -1) {
|
|
51
|
+
result.push(createTimestampColumn(key))
|
|
52
|
+
} else {
|
|
53
|
+
result[index] = enhanceTimestampColumn(result[index], key)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return result
|
|
58
|
+
}
|