@spree/dashboard-core 0.13.0 → 0.13.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spree/dashboard-core",
3
3
  "license": "MIT",
4
- "version": "0.13.0",
4
+ "version": "0.13.1",
5
5
  "description": "Spree Dashboard framework — registries (table, nav, slot, settings-nav), providers (auth, permission, store), admin SDK client, generic infra hooks, and the defineDashboardPlugin extension API. Developer Preview.",
6
6
  "author": "Vendo Connect Inc.",
7
7
  "homepage": "https://spreecommerce.org",
@@ -79,8 +79,8 @@
79
79
  "typescript": "~6.0.3",
80
80
  "vite": "^8.0.16",
81
81
  "vitest": "^4.1.5",
82
- "@spree/admin-sdk": "^0.8.0",
83
- "@spree/dashboard-ui": "^0.13.0"
82
+ "@spree/admin-sdk": "^0.8.1",
83
+ "@spree/dashboard-ui": "^0.13.1"
84
84
  },
85
85
  "scripts": {
86
86
  "lint": "biome check src",
@@ -19,8 +19,13 @@ import { DownloadIcon, FileSpreadsheetIcon, UploadIcon } from 'lucide-react'
19
19
  import { useState } from 'react'
20
20
  import { useTranslation } from 'react-i18next'
21
21
  import { toast } from 'sonner'
22
- import { useCreateImport, useDownloadImportTemplate } from '../hooks/use-import'
22
+ import {
23
+ useCreateImport,
24
+ useDownloadImportExample,
25
+ useDownloadImportTemplate,
26
+ } from '../hooks/use-import'
23
27
  import type { SubjectName } from '../lib/permissions'
28
+ import { hasSampleCsv } from '../lib/sample-csv'
24
29
  import { Can } from './can'
25
30
  import { EMPTY_FILE_UPLOAD_VALUE, FileUploadField, type FileUploadValue } from './file-upload-field'
26
31
 
@@ -65,6 +70,8 @@ export function ImportButton({ type, subject, onCreated, label }: ImportButtonPr
65
70
  const [delimiter, setDelimiter] = useState<Delimiter>(',')
66
71
  const createImport = useCreateImport()
67
72
  const downloadTemplate = useDownloadImportTemplate()
73
+ const downloadExample = useDownloadImportExample()
74
+ const hasExample = hasSampleCsv(type)
68
75
 
69
76
  const delimiterOptions = DELIMITERS.map(({ value, labelKey }) => ({
70
77
  value,
@@ -109,6 +116,18 @@ export function ImportButton({ type, subject, onCreated, label }: ImportButtonPr
109
116
  })
110
117
  }
111
118
 
119
+ function handleExampleDownload() {
120
+ downloadExample.mutate(type, {
121
+ onError: (err) => {
122
+ toast.error(
123
+ t('admin.components.import_button.example_failed', {
124
+ message: err instanceof Error ? err.message : String(err),
125
+ }),
126
+ )
127
+ },
128
+ })
129
+ }
130
+
112
131
  return (
113
132
  <Can I="create" a={subject}>
114
133
  <Button
@@ -162,17 +181,34 @@ export function ImportButton({ type, subject, onCreated, label }: ImportButtonPr
162
181
  </Select>
163
182
  </Field>
164
183
 
165
- <Button
166
- type="button"
167
- variant="ghost"
168
- size="sm"
169
- className="self-start"
170
- onClick={handleTemplateDownload}
171
- disabled={downloadTemplate.isPending}
172
- >
173
- <DownloadIcon className="size-4" />
174
- {t('admin.components.import_button.download_template')}
175
- </Button>
184
+ <div className="flex flex-col items-start gap-1">
185
+ <Button
186
+ type="button"
187
+ variant="ghost"
188
+ size="sm"
189
+ onClick={handleTemplateDownload}
190
+ disabled={downloadTemplate.isPending}
191
+ >
192
+ <DownloadIcon className="size-4" />
193
+ {t('admin.components.import_button.download_template')}
194
+ </Button>
195
+
196
+ {/* The populated counterpart to the headers-only template above.
197
+ Fetched through the API so the file matches the installed
198
+ Spree version's schema. */}
199
+ {hasExample && (
200
+ <Button
201
+ type="button"
202
+ variant="ghost"
203
+ size="sm"
204
+ onClick={handleExampleDownload}
205
+ disabled={downloadExample.isPending}
206
+ >
207
+ <FileSpreadsheetIcon className="size-4" />
208
+ {t('admin.components.import_button.download_example')}
209
+ </Button>
210
+ )}
211
+ </div>
176
212
  </div>
177
213
 
178
214
  <SheetFooter>
@@ -57,7 +57,6 @@ import {
57
57
  type ColumnDef,
58
58
  type FilterRule,
59
59
  getDefaultColumnKeys,
60
- getDisplayableColumns,
61
60
  getTable,
62
61
  type SortOption,
63
62
  } from '../lib/table-registry'
@@ -174,6 +173,14 @@ interface ResourceTableProps<T> {
174
173
  * menu on the trailing edge.
175
174
  */
176
175
  rowActions?: (row: T) => ReactNode
176
+ /**
177
+ * Dynamic per-store columns derived from custom field definitions,
178
+ * merged into the registry columns for display (column selector +
179
+ * cells), sorting, and filtering. Keys are the definitions'
180
+ * `filter_key` (`cf_*`), which the backend accepts as sort and
181
+ * filter attributes.
182
+ */
183
+ metafieldColumns?: ColumnDef<T>[]
177
184
  }
178
185
 
179
186
  export interface ReorderConfig<T> {
@@ -197,6 +204,7 @@ export function ResourceTable<T extends Record<string, any>>({
197
204
  reorder,
198
205
  bulkActions,
199
206
  rowActions,
207
+ metafieldColumns,
200
208
  }: ResourceTableProps<T>) {
201
209
  const table = getTable<T>(tableKey)
202
210
  const { t } = useTranslation()
@@ -236,11 +244,31 @@ export function ResourceTable<T extends Record<string, any>>({
236
244
  const [searchInput, setSearchInput] = useState(search ?? '')
237
245
  const deferredSearch = useDeferredValue(searchInput)
238
246
 
239
- const displayableColumns = getDisplayableColumns(table)
247
+ // Registry columns + dynamic metafield columns. Memoized so the toolbar's
248
+ // internal memos (FilterPanel `items` stability) keep stable deps.
249
+ const allColumns = useMemo(
250
+ () => [...table.columns, ...(metafieldColumns ?? [])],
251
+ [table, metafieldColumns],
252
+ )
253
+ const displayableColumns = useMemo(
254
+ () => allColumns.filter((c) => c.displayable !== false),
255
+ [allColumns],
256
+ )
240
257
  const defaultColumnKeys = getDefaultColumnKeys(table)
241
258
  const visibleColumnKeys = urlColumns ?? defaultColumnKeys
242
259
 
243
- const visibleColumns = displayableColumns.filter((c) => visibleColumnKeys.includes(c.key))
260
+ const visibleColumns = useMemo(
261
+ () => displayableColumns.filter((c) => visibleColumnKeys.includes(c.key)),
262
+ [displayableColumns, visibleColumnKeys],
263
+ )
264
+
265
+ // Expands requested by currently-visible columns (e.g. custom field
266
+ // columns need `expand=custom_fields` to render their values). Sorted so
267
+ // the query key stays stable regardless of column order.
268
+ const columnExpand = useMemo(
269
+ () => [...new Set(visibleColumns.flatMap((c) => (c.expand ? [c.expand] : [])))].sort(),
270
+ [visibleColumns],
271
+ )
244
272
 
245
273
  // Build API params
246
274
  const sortString = dir === 'desc' ? `-${sort}` : sort
@@ -256,7 +284,7 @@ export function ResourceTable<T extends Record<string, any>>({
256
284
  const { data, isLoading } = useQuery({
257
285
  queryKey: [
258
286
  ...queryKeyPrefix,
259
- { page, limit, sort: sortString, search: deferredSearch, filters },
287
+ { page, limit, sort: sortString, search: deferredSearch, filters, expand: columnExpand },
260
288
  ],
261
289
  queryFn: () => {
262
290
  const params: Record<string, unknown> = {
@@ -269,12 +297,22 @@ export function ResourceTable<T extends Record<string, any>>({
269
297
  params.limit = limit
270
298
  }
271
299
 
300
+ if (columnExpand.length) {
301
+ const base = defaultParams?.expand
302
+ const baseList = Array.isArray(base)
303
+ ? base
304
+ : typeof base === 'string'
305
+ ? base.split(',')
306
+ : []
307
+ params.expand = [...new Set([...baseList, ...columnExpand])]
308
+ }
309
+
272
310
  if (deferredSearch) {
273
311
  const searchParam = table.searchParam ?? 'name_cont'
274
312
  params[searchParam] = deferredSearch
275
313
  }
276
314
 
277
- Object.assign(params, filtersToRansack(filters as FilterRule[], table.columns))
315
+ Object.assign(params, filtersToRansack(filters as FilterRule[], allColumns))
278
316
 
279
317
  return queryFn(params)
280
318
  },
@@ -409,7 +447,7 @@ export function ResourceTable<T extends Record<string, any>>({
409
447
  filters: filters as FilterRule[],
410
448
  search: deferredSearch,
411
449
  searchParam: table.searchParam ?? 'name_cont',
412
- columns: table.columns,
450
+ columns: allColumns,
413
451
  totalCount: meta?.count,
414
452
  })
415
453
  : actions
@@ -427,7 +465,7 @@ export function ResourceTable<T extends Record<string, any>>({
427
465
  onSortChange={handleSortChange}
428
466
  filters={filters as FilterRule[]}
429
467
  onFiltersChange={handleFiltersChange}
430
- allColumns={table.columns}
468
+ allColumns={allColumns}
431
469
  title={title ?? table.title}
432
470
  actions={resolvedActions}
433
471
  hideSort={reorderActive}
@@ -11,24 +11,59 @@ import {
11
11
  Skeleton,
12
12
  useSidebar,
13
13
  } from '@spree/dashboard-ui'
14
- import { ChevronsUpDownIcon, ExternalLinkIcon } from 'lucide-react'
14
+ import { Link } from '@tanstack/react-router'
15
+ import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
15
16
  import { useTranslation } from 'react-i18next'
17
+ import { useAuth } from '../hooks/use-auth'
16
18
  import { useStore } from '../providers/store-provider'
17
19
 
20
+ /**
21
+ * Sidebar store header. Renders a plain, non-interactive block when the
22
+ * signed-in admin only has access to one store; becomes a dropdown listing
23
+ * every store they hold a role on (from `user.stores`) once there are two
24
+ * or more to switch between.
25
+ */
18
26
  export function StoreSwitcher() {
19
27
  const { t } = useTranslation()
20
28
  const { isMobile, state } = useSidebar()
21
29
  const isCollapsed = state === 'collapsed'
22
30
 
23
31
  const { store, isLoading } = useStore()
32
+ const { user } = useAuth()
24
33
 
25
34
  if (isLoading) return <Skeleton className="h-header-height w-full rounded-xl" />
26
35
 
36
+ const stores = user?.stores ?? []
37
+
27
38
  const storeInitials = store?.name
28
39
  .split(' ')
29
40
  .map((name) => name[0])
30
41
  .join('')
31
42
 
43
+ const header = (
44
+ <>
45
+ <Avatar>
46
+ {store?.logo_url && <AvatarImage src={store.logo_url} />}
47
+ <AvatarFallback>{storeInitials}</AvatarFallback>
48
+ </Avatar>
49
+ {!isCollapsed && (
50
+ <div className="grid flex-1 text-left text-sm leading-tight">
51
+ <span className="truncate font-medium text-foreground">{store?.name}</span>
52
+ </div>
53
+ )}
54
+ </>
55
+ )
56
+
57
+ if (stores.length < 2) {
58
+ return (
59
+ <SidebarMenu>
60
+ <SidebarMenuItem className="h-header-height flex items-center">
61
+ <div className="flex w-full items-center gap-2 p-1.5">{header}</div>
62
+ </SidebarMenuItem>
63
+ </SidebarMenu>
64
+ )
65
+ }
66
+
32
67
  return (
33
68
  <SidebarMenu>
34
69
  <SidebarMenuItem className="h-header-height flex items-center">
@@ -36,19 +71,12 @@ export function StoreSwitcher() {
36
71
  <DropdownMenuTrigger asChild className="flex w-full items-center">
37
72
  <button
38
73
  type="button"
74
+ aria-label={t('admin.a11y.store_switcher')}
39
75
  className="rounded-xl outline-hidden transition-colors duration-100 hover:bg-sidebar-accent data-[state=open]:bg-sidebar-accent gap-2 p-1.5"
40
76
  >
41
- <Avatar>
42
- {store?.logo_url && <AvatarImage src={store.logo_url} />}
43
- <AvatarFallback>{storeInitials}</AvatarFallback>
44
- </Avatar>
77
+ {header}
45
78
  {!isCollapsed && (
46
- <>
47
- <div className="grid flex-1 text-left text-sm leading-tight">
48
- <span className="truncate font-medium text-foreground">{store?.name}</span>
49
- </div>
50
- <ChevronsUpDownIcon className="ml-auto size-4 text-muted-foreground" />
51
- </>
79
+ <ChevronsUpDownIcon className="ml-auto size-4 text-muted-foreground" />
52
80
  )}
53
81
  </button>
54
82
  </DropdownMenuTrigger>
@@ -58,10 +86,18 @@ export function StoreSwitcher() {
58
86
  align="start"
59
87
  sideOffset={8}
60
88
  >
61
- <DropdownMenuItem>
62
- <ExternalLinkIcon className="size-4" />
63
- {t('admin.account.view_store')}
64
- </DropdownMenuItem>
89
+ {stores.map((availableStore) => (
90
+ <DropdownMenuItem key={availableStore.id} asChild>
91
+ <Link
92
+ to="/$storeId"
93
+ params={{ storeId: availableStore.id }}
94
+ className="no-underline"
95
+ >
96
+ <span className="flex-1 truncate">{availableStore.name}</span>
97
+ {availableStore.id === store?.id && <CheckIcon className="size-4" />}
98
+ </Link>
99
+ </DropdownMenuItem>
100
+ ))}
65
101
  </DropdownMenuContent>
66
102
  </DropdownMenu>
67
103
  </SidebarMenuItem>
@@ -75,7 +75,9 @@ interface TableToolbarProps {
75
75
  // and resolved with `t()` at render time so they follow the active language.
76
76
  const operatorsByType: Record<string, { value: string; labelKey: string }[]> = {
77
77
  string: [
78
- { value: 'cont', labelKey: 'admin.components.table_toolbar.operators.contains' },
78
+ // `i_cont` (case-insensitive) rather than `cont` — an admin typing
79
+ // "2 years" expects to match "2 Years".
80
+ { value: 'i_cont', labelKey: 'admin.components.table_toolbar.operators.contains' },
79
81
  { value: 'eq', labelKey: 'admin.components.table_toolbar.operators.equals' },
80
82
  { value: 'not_eq', labelKey: 'admin.components.table_toolbar.operators.does_not_equal' },
81
83
  { value: 'start', labelKey: 'admin.components.table_toolbar.operators.starts_with' },
@@ -128,6 +130,19 @@ function getOperators(type: string) {
128
130
  return operatorsByType[type] || operatorsByType.string
129
131
  }
130
132
 
133
+ // Operators no longer offered in the picker but still resolvable for display,
134
+ // so filters saved in older URLs keep a readable chip label.
135
+ const legacyOperatorLabelKeys: Record<string, string> = {
136
+ cont: 'admin.components.table_toolbar.operators.contains',
137
+ }
138
+
139
+ function getOperatorLabelKey(type: string, operator: string) {
140
+ return (
141
+ getOperators(type).find((o) => o.value === operator)?.labelKey ??
142
+ legacyOperatorLabelKeys[operator]
143
+ )
144
+ }
145
+
131
146
  const noValueOperators = ['present', 'blank']
132
147
 
133
148
  // ============================================================================
@@ -283,8 +298,7 @@ function FilterChip({
283
298
  onRemove: () => void
284
299
  }) {
285
300
  const { t } = useTranslation()
286
- const ops = getOperators(col?.filterType ?? 'string')
287
- const opLabelKey = ops.find((o) => o.value === filter.operator)?.labelKey
301
+ const opLabelKey = getOperatorLabelKey(col?.filterType ?? 'string', filter.operator)
288
302
  const opLabel = opLabelKey ? t(opLabelKey) : filter.operator
289
303
  const showValue = !noValueOperators.includes(filter.operator)
290
304
 
@@ -28,6 +28,7 @@ import { useCommandPalette } from '../hooks/use-command-palette'
28
28
  import { useSwitchAdminLocale } from '../hooks/use-switch-admin-locale'
29
29
  import { getInitials } from '../lib/formatters'
30
30
  import { i18n } from '../lib/i18n'
31
+ import { storefrontHref } from '../lib/storefront'
31
32
  import { useStore } from '../providers/store-provider'
32
33
 
33
34
  const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform ?? '')
@@ -96,10 +97,8 @@ function SearchTrigger() {
96
97
  function ViewStoreLink() {
97
98
  const { t } = useTranslation()
98
99
  const { store } = useStore()
99
- if (!store?.url) return null
100
-
101
- // Best-effort: prefix with https if the URL is just a hostname.
102
- const href = /^https?:\/\//.test(store.url) ? store.url : `https://${store.url}`
100
+ const href = storefrontHref(store)
101
+ if (!href) return null
103
102
 
104
103
  return (
105
104
  <Button asChild variant="ghost" size="sm">
@@ -47,6 +47,28 @@ export function useDownloadImportTemplate() {
47
47
  })
48
48
  }
49
49
 
50
+ /**
51
+ * Downloads the populated example CSV for an import type.
52
+ *
53
+ * Goes through the API rather than linking the file directly: the URL is pinned
54
+ * to the installed Spree version so a released install never links to a newer,
55
+ * incompatible schema — and that version is deliberately not exposed over the
56
+ * API, since it would fingerprint the deployment. Returns 404 for a type that
57
+ * ships no example file.
58
+ */
59
+ export function useDownloadImportExample() {
60
+ const { token } = useAuth()
61
+
62
+ return useMutation({
63
+ mutationFn: (type: ImportType) =>
64
+ downloadFromApi(
65
+ token,
66
+ `/api/v3/admin/imports/example?type=${encodeURIComponent(type)}`,
67
+ `${type}.csv`,
68
+ ),
69
+ })
70
+ }
71
+
50
72
  /** Downloads the originally uploaded CSV of an import — the audit trail. */
51
73
  export function useDownloadImportOriginal() {
52
74
  const { token } = useAuth()
@@ -0,0 +1,18 @@
1
+ import type { ImportType } from '@spree/admin-sdk'
2
+
3
+ /**
4
+ * Import types that ship a populated example CSV in Spree's
5
+ * `core/db/sample_data`. Keyed by the API's type shorthand (`"products"`),
6
+ * which is also the CSV's filename stem.
7
+ *
8
+ * Only used to decide whether to render the download link — the file itself is
9
+ * served by `GET /api/v3/admin/imports/example`, which pins the URL to the
10
+ * installed Spree version. A type missing from this list simply hides the link;
11
+ * the endpoint is the authority and 404s for anything it has no example for.
12
+ */
13
+ const TYPES_WITH_SAMPLE_CSV = new Set(['products', 'customers', 'product_translations'])
14
+
15
+ /** Whether an import type has an example CSV to offer. */
16
+ export function hasSampleCsv(type: ImportType): boolean {
17
+ return TYPES_WITH_SAMPLE_CSV.has(type)
18
+ }
@@ -0,0 +1,19 @@
1
+ import type { Store } from '@spree/admin-sdk'
2
+
3
+ /**
4
+ * Resolves the storefront URL to link to from admin "View store" affordances,
5
+ * or `null` when no storefront URL is configured.
6
+ *
7
+ * The serialized `store.url` always resolves — the backend falls back to the
8
+ * store's formatted URL — so gate on the explicit `preferred_storefront_url`
9
+ * preference: only return a link once a real storefront has been set.
10
+ *
11
+ * @param store the current store, or null/undefined while it loads
12
+ * @returns an absolute URL, or null when the store has no configured storefront
13
+ */
14
+ export function storefrontHref(store: Store | null | undefined): string | null {
15
+ if (!store?.preferred_storefront_url) return null
16
+
17
+ // Best-effort: prefix with https if the URL is just a hostname.
18
+ return /^https?:\/\//.test(store.url) ? store.url : `https://${store.url}`
19
+ }
@@ -16,6 +16,12 @@ interface ColumnDefBase<T = any> {
16
16
  ransackAttribute?: string
17
17
  /** Custom cell renderer. If omitted, renders `row[key]` as text. */
18
18
  render?: (row: T) => ReactNode
19
+ /**
20
+ * `expand` value the list request needs while this column is visible
21
+ * (e.g. `'custom_fields'`). ResourceTable unions the values across
22
+ * visible columns into the `expand` query param.
23
+ */
24
+ expand?: string
19
25
  /** Cell className */
20
26
  className?: string
21
27
  /** Whether this column is displayable in the table. Set to false for filter-only columns. */
@@ -76,7 +76,8 @@
76
76
  "remove_value": "إزالة القيمة",
77
77
  "breadcrumb_more": "المزيد",
78
78
  "sidebar": "الشريط الجانبي",
79
- "sidebar_description": "يعرض الشريط الجانبي للجوال."
79
+ "sidebar_description": "يعرض الشريط الجانبي للجوال.",
80
+ "store_switcher": "تبديل المتجر"
80
81
  },
81
82
  "common": {
82
83
  "loading": "جارٍ التحميل…",
@@ -76,7 +76,8 @@
76
76
  "remove_value": "Wert entfernen",
77
77
  "breadcrumb_more": "Mehr",
78
78
  "sidebar": "Seitenleiste",
79
- "sidebar_description": "Zeigt die mobile Seitenleiste an."
79
+ "sidebar_description": "Zeigt die mobile Seitenleiste an.",
80
+ "store_switcher": "Store wechseln"
80
81
  },
81
82
  "common": {
82
83
  "loading": "Wird geladen…",
@@ -76,7 +76,8 @@
76
76
  "remove_value": "Remove value",
77
77
  "breadcrumb_more": "More",
78
78
  "sidebar": "Sidebar",
79
- "sidebar_description": "Displays the mobile sidebar."
79
+ "sidebar_description": "Displays the mobile sidebar.",
80
+ "store_switcher": "Switch store"
80
81
  },
81
82
  "common": {
82
83
  "loading": "Loading…",
@@ -490,7 +491,9 @@
490
491
  "tab": "Tab"
491
492
  },
492
493
  "download_template": "Download CSV template",
494
+ "download_example": "Download an example CSV",
493
495
  "template_failed": "Template download failed: {{message}}",
496
+ "example_failed": "Example download failed: {{message}}",
494
497
  "submit": "Continue",
495
498
  "failed": "Import failed: {{message}}"
496
499
  },
@@ -76,7 +76,8 @@
76
76
  "remove_value": "Retirer la valeur",
77
77
  "breadcrumb_more": "Plus",
78
78
  "sidebar": "Barre latérale",
79
- "sidebar_description": "Affiche la barre latérale mobile."
79
+ "sidebar_description": "Affiche la barre latérale mobile.",
80
+ "store_switcher": "Changer de boutique"
80
81
  },
81
82
  "common": {
82
83
  "loading": "Chargement…",
@@ -76,7 +76,8 @@
76
76
  "remove_value": "Usuń wartość",
77
77
  "breadcrumb_more": "Więcej",
78
78
  "sidebar": "Panel boczny",
79
- "sidebar_description": "Wyświetla mobilny panel boczny."
79
+ "sidebar_description": "Wyświetla mobilny panel boczny.",
80
+ "store_switcher": "Przełącz sklep"
80
81
  },
81
82
  "common": {
82
83
  "loading": "Ładowanie…",
@@ -76,7 +76,8 @@
76
76
  "remove_value": "移除值",
77
77
  "breadcrumb_more": "更多",
78
78
  "sidebar": "侧边栏",
79
- "sidebar_description": "显示移动端侧边栏。"
79
+ "sidebar_description": "显示移动端侧边栏。",
80
+ "store_switcher": "切换店铺"
80
81
  },
81
82
  "common": {
82
83
  "loading": "加载中…",