@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 +3 -3
- package/src/components/import-button.tsx +48 -12
- package/src/components/resource-table.tsx +45 -7
- package/src/components/store-switcher.tsx +51 -15
- package/src/components/table-toolbar.tsx +17 -3
- package/src/components/top-bar.tsx +3 -4
- package/src/hooks/use-import.ts +22 -0
- package/src/lib/sample-csv.ts +18 -0
- package/src/lib/storefront.ts +19 -0
- package/src/lib/table-registry.ts +6 -0
- package/src/locales/ar.json +2 -1
- package/src/locales/de.json +2 -1
- package/src/locales/en.json +4 -1
- package/src/locales/fr.json +2 -1
- package/src/locales/pl.json +2 -1
- package/src/locales/zh-CN.json +2 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spree/dashboard-core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.13.
|
|
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.
|
|
83
|
-
"@spree/dashboard-ui": "^0.13.
|
|
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 {
|
|
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
|
-
<
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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 =
|
|
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[],
|
|
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:
|
|
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={
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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">
|
package/src/hooks/use-import.ts
CHANGED
|
@@ -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. */
|
package/src/locales/ar.json
CHANGED
|
@@ -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": "جارٍ التحميل…",
|
package/src/locales/de.json
CHANGED
|
@@ -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…",
|
package/src/locales/en.json
CHANGED
|
@@ -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
|
},
|
package/src/locales/fr.json
CHANGED
|
@@ -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…",
|
package/src/locales/pl.json
CHANGED
|
@@ -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…",
|
package/src/locales/zh-CN.json
CHANGED