@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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +418 -0
  3. package/package.json +84 -0
  4. package/src/client.ts +25 -0
  5. package/src/components/address-form-dialog.tsx +376 -0
  6. package/src/components/app-sidebar.tsx +70 -0
  7. package/src/components/bulk-action-bar.tsx +504 -0
  8. package/src/components/can.tsx +30 -0
  9. package/src/components/country-combobox.tsx +238 -0
  10. package/src/components/country-state-fields.tsx +86 -0
  11. package/src/components/currency-select.tsx +181 -0
  12. package/src/components/export-button.tsx +156 -0
  13. package/src/components/file-upload-field.tsx +265 -0
  14. package/src/components/image-upload-field.tsx +53 -0
  15. package/src/components/import-button.tsx +202 -0
  16. package/src/components/locale-select.tsx +268 -0
  17. package/src/components/market-combobox.tsx +89 -0
  18. package/src/components/nav-main.tsx +186 -0
  19. package/src/components/page-header.tsx +286 -0
  20. package/src/components/page-tabs.tsx +78 -0
  21. package/src/components/preferences-form.tsx +404 -0
  22. package/src/components/resource-combobox.tsx +126 -0
  23. package/src/components/resource-multi-autocomplete.tsx +176 -0
  24. package/src/components/resource-picker-sheet.tsx +235 -0
  25. package/src/components/resource-table.tsx +652 -0
  26. package/src/components/settings-sidebar.tsx +139 -0
  27. package/src/components/slot.tsx +50 -0
  28. package/src/components/store-date-picker.tsx +18 -0
  29. package/src/components/store-switcher.tsx +70 -0
  30. package/src/components/table-toolbar.tsx +818 -0
  31. package/src/components/tag-combobox.tsx +99 -0
  32. package/src/components/top-bar.tsx +210 -0
  33. package/src/hooks/use-auth.ts +10 -0
  34. package/src/hooks/use-command-palette.tsx +34 -0
  35. package/src/hooks/use-countries.ts +15 -0
  36. package/src/hooks/use-custom-fields.ts +142 -0
  37. package/src/hooks/use-direct-upload.ts +104 -0
  38. package/src/hooks/use-display-name.ts +42 -0
  39. package/src/hooks/use-export.ts +75 -0
  40. package/src/hooks/use-global-search.ts +79 -0
  41. package/src/hooks/use-host-form.ts +34 -0
  42. package/src/hooks/use-import.ts +62 -0
  43. package/src/hooks/use-resource-mutation.ts +99 -0
  44. package/src/hooks/use-switch-admin-locale.ts +55 -0
  45. package/src/index.ts +97 -0
  46. package/src/lib/custom-field-components.ts +56 -0
  47. package/src/lib/download.ts +49 -0
  48. package/src/lib/filters-to-ransack.ts +47 -0
  49. package/src/lib/form-errors.ts +103 -0
  50. package/src/lib/form-fields-registry.ts +102 -0
  51. package/src/lib/form-mappers.ts +30 -0
  52. package/src/lib/formatters.ts +19 -0
  53. package/src/lib/i18n.ts +210 -0
  54. package/src/lib/nav-registry.ts +224 -0
  55. package/src/lib/permissions.ts +55 -0
  56. package/src/lib/query-client.ts +11 -0
  57. package/src/lib/query-keys.ts +69 -0
  58. package/src/lib/route-registry.ts +158 -0
  59. package/src/lib/search-registry.ts +129 -0
  60. package/src/lib/settings-nav-registry.ts +158 -0
  61. package/src/lib/slot-registry.ts +116 -0
  62. package/src/lib/table-registry.ts +274 -0
  63. package/src/lib/timestamp-columns.tsx +58 -0
  64. package/src/locales/ar.json +629 -0
  65. package/src/locales/de.json +629 -0
  66. package/src/locales/en.json +629 -0
  67. package/src/locales/fr.json +629 -0
  68. package/src/locales/pl.json +629 -0
  69. package/src/locales/zh-CN.json +629 -0
  70. package/src/plugin.ts +222 -0
  71. package/src/providers/auth-provider.tsx +188 -0
  72. package/src/providers/permission-provider.tsx +118 -0
  73. package/src/providers/store-provider.tsx +111 -0
  74. package/src/virtual.d.ts +5 -0
  75. package/src/vite/discover.ts +170 -0
  76. 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
+ }