@spree/dashboard-core 1.0.0-rc.3 → 1.0.0-rc.5
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 +14 -14
- package/src/api-client.ts +12 -7
- package/src/components/address-form-dialog.tsx +2 -2
- package/src/components/country-combobox.tsx +1 -2
- package/src/components/currency-select.tsx +8 -10
- package/src/components/file-upload-field.tsx +1 -0
- package/src/components/import-wizard-dialog.tsx +8 -11
- package/src/components/locale-select.tsx +22 -23
- package/src/components/nav-main.tsx +7 -2
- package/src/components/preferences-form.tsx +18 -10
- package/src/components/resource-combobox.tsx +5 -0
- package/src/components/resource-picker-sheet.tsx +18 -12
- package/src/components/settings-sidebar.tsx +8 -1
- package/src/components/slot.tsx +21 -1
- package/src/components/table-toolbar.tsx +111 -45
- package/src/hooks/use-display-name.ts +1 -18
- package/src/hooks/use-file-upload.ts +2 -1
- package/src/hooks/use-import.ts +3 -2
- package/src/index.ts +13 -3
- package/src/lib/currencies.ts +2 -18
- package/src/lib/currency-parts.ts +2 -21
- package/src/lib/filters-to-ransack.ts +4 -1
- package/src/lib/form-fields-registry.ts +23 -1
- package/src/lib/form-mappers.ts +1 -1
- package/src/lib/formatters.ts +6 -10
- package/src/lib/fulfillment-items.ts +11 -0
- package/src/lib/i18n.ts +1 -12
- package/src/lib/iso-codes.ts +9 -24
- package/src/lib/money.ts +9 -99
- package/src/lib/normalize-money.ts +2 -85
- package/src/lib/permissions.ts +2 -0
- package/src/lib/return-refunds.ts +1 -2
- package/src/lib/route-registry.ts +29 -0
- package/src/lib/settings-nav-registry.test.ts +34 -0
- package/src/lib/settings-nav-registry.ts +27 -5
- package/src/lib/store-config.test.ts +93 -0
- package/src/lib/store-config.ts +76 -0
- package/src/lib/table-registry.ts +16 -5
- package/src/locales/ar.json +7 -1
- package/src/locales/de.json +7 -1
- package/src/locales/en.json +7 -1
- package/src/locales/es.json +7 -1
- package/src/locales/fr.json +7 -1
- package/src/locales/pl.json +7 -1
- package/src/locales/zh-CN.json +7 -1
- package/src/pages/package-types.tsx +50 -11
- package/src/pages/settings-index.tsx +4 -1
- package/src/pages/stock-locations.tsx +31 -6
- package/src/products/bulk-variants-dialog.tsx +28 -35
- package/src/products/inventory-section.tsx +2 -2
- package/src/products/media-edit-sheet.tsx +67 -8
- package/src/products/normalize-quantity.ts +2 -3
- package/src/products/product-bulk-price-editor.tsx +1 -3
- package/src/products/product-form-cards.tsx +67 -37
- package/src/products/product-form-mapping.ts +44 -5
- package/src/products/product-schema.test.ts +8 -0
- package/src/products/product-schema.ts +14 -7
- package/src/products/product-types.ts +1 -1
- package/src/products/sheet-save-handlers.ts +37 -0
- package/src/products/variant-edit-sheet.tsx +21 -11
- package/src/products/variants-options-builder.tsx +30 -27
- package/src/products/variants-section.tsx +35 -26
- package/src/providers/store-provider.tsx +31 -7
- package/src/schemas/package-type.ts +14 -12
- package/src/schemas/stock-location.ts +2 -3
- package/src/tables/stock-locations.tsx +5 -8
- package/src/lib/money.test.ts +0 -63
- package/src/lib/normalize-money.test.ts +0 -67
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FilterPredicate } from '@spree/core'
|
|
1
2
|
import {
|
|
2
3
|
Button,
|
|
3
4
|
Checkbox,
|
|
@@ -36,7 +37,7 @@ import {
|
|
|
36
37
|
XIcon,
|
|
37
38
|
} from '@spree/dashboard-ui/icons'
|
|
38
39
|
import { useQuery } from '@tanstack/react-query'
|
|
39
|
-
import { useCallback, useDeferredValue, useMemo, useState } from 'react'
|
|
40
|
+
import { Fragment, useCallback, useDeferredValue, useMemo, useState } from 'react'
|
|
40
41
|
import { useTranslation } from 'react-i18next'
|
|
41
42
|
import { getApiClient } from '../api-client'
|
|
42
43
|
import { useAuth } from '../hooks/use-auth'
|
|
@@ -55,6 +56,7 @@ import {
|
|
|
55
56
|
} from '../lib/table-registry'
|
|
56
57
|
import { useOptionalStore } from '../providers/store-provider'
|
|
57
58
|
import { useTenantId } from '../providers/tenant-provider'
|
|
59
|
+
import { LocaleLabel, useLocaleLabel } from './locale-select'
|
|
58
60
|
import { StoreDatePicker } from './store-date-picker'
|
|
59
61
|
|
|
60
62
|
interface TableToolbarProps {
|
|
@@ -81,7 +83,16 @@ interface TableToolbarProps {
|
|
|
81
83
|
|
|
82
84
|
// Operator labels are stored as i18n keys (`admin.components.table_toolbar.operators.*`)
|
|
83
85
|
// and resolved with `t()` at render time so they follow the active language.
|
|
84
|
-
|
|
86
|
+
type OperatorOption = { value: FilterPredicate; labelKey: string }
|
|
87
|
+
|
|
88
|
+
const storeCodeOperators: OperatorOption[] = [
|
|
89
|
+
{ value: 'eq', labelKey: 'admin.components.table_toolbar.operators.is' },
|
|
90
|
+
{ value: 'not_eq', labelKey: 'admin.components.table_toolbar.operators.is_not' },
|
|
91
|
+
{ value: 'in', labelKey: 'admin.components.table_toolbar.operators.is_any_of' },
|
|
92
|
+
{ value: 'not_in', labelKey: 'admin.components.table_toolbar.operators.is_none_of' },
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
const operatorsByType: Record<string, OperatorOption[]> = {
|
|
85
96
|
string: [
|
|
86
97
|
// `i_cont` (case-insensitive) rather than `cont` — an admin typing
|
|
87
98
|
// "2 years" expects to match "2 Years".
|
|
@@ -125,13 +136,9 @@ const operatorsByType: Record<string, { value: string; labelKey: string }[]> = {
|
|
|
125
136
|
{ value: 'not_in', labelKey: 'admin.components.table_toolbar.operators.is_none_of' },
|
|
126
137
|
],
|
|
127
138
|
// Same shape as `enum` — the value picker just sources its option list
|
|
128
|
-
// from the active store's
|
|
129
|
-
currency:
|
|
130
|
-
|
|
131
|
-
{ value: 'not_eq', labelKey: 'admin.components.table_toolbar.operators.is_not' },
|
|
132
|
-
{ value: 'in', labelKey: 'admin.components.table_toolbar.operators.is_any_of' },
|
|
133
|
-
{ value: 'not_in', labelKey: 'admin.components.table_toolbar.operators.is_none_of' },
|
|
134
|
-
],
|
|
139
|
+
// from the currencies / languages of the active store's markets.
|
|
140
|
+
currency: storeCodeOperators,
|
|
141
|
+
locale: storeCodeOperators,
|
|
135
142
|
}
|
|
136
143
|
|
|
137
144
|
function getOperators(type: string) {
|
|
@@ -155,7 +162,7 @@ const noValueOperators = ['present', 'blank']
|
|
|
155
162
|
|
|
156
163
|
/** Stable empty list, so a panel with no store doesn't rebuild `items` (and
|
|
157
164
|
* re-emit Base UI Select state) on every render. */
|
|
158
|
-
const
|
|
165
|
+
const noCodes: string[] = []
|
|
159
166
|
|
|
160
167
|
// ============================================================================
|
|
161
168
|
// TableToolbar
|
|
@@ -489,6 +496,15 @@ function FilterChip({
|
|
|
489
496
|
<ResourceFilterValue value={first.value} config={col.filterResource} />
|
|
490
497
|
) : col?.filterType === 'tags' ? (
|
|
491
498
|
<span>{parseFilterIds(first.value).join(', ')}</span>
|
|
499
|
+
) : col?.filterType === 'locale' ? (
|
|
500
|
+
<span>
|
|
501
|
+
{parseFilterIds(first.value).map((code, index) => (
|
|
502
|
+
<Fragment key={code}>
|
|
503
|
+
{index > 0 && ', '}
|
|
504
|
+
<LocaleLabel code={code} />
|
|
505
|
+
</Fragment>
|
|
506
|
+
))}
|
|
507
|
+
</span>
|
|
492
508
|
) : col?.filterType === 'enum' && col.filterOptions ? (
|
|
493
509
|
<span>
|
|
494
510
|
{parseFilterIds(first.value)
|
|
@@ -515,6 +531,25 @@ function FilterChip({
|
|
|
515
531
|
)
|
|
516
532
|
}
|
|
517
533
|
|
|
534
|
+
/**
|
|
535
|
+
* The whole option list of a resource small enough to fetch once (`listAll`),
|
|
536
|
+
* cached under `[queryKey, tenant, 'all']` — the key the resource's own
|
|
537
|
+
* full-list hook uses, so filters and the rest of the dashboard share one
|
|
538
|
+
* fetch that the resource's writes invalidate.
|
|
539
|
+
*/
|
|
540
|
+
function useListAll(
|
|
541
|
+
config: ColumnDef['filterResource'],
|
|
542
|
+
tenantId: string | undefined,
|
|
543
|
+
enabled = true,
|
|
544
|
+
) {
|
|
545
|
+
return useQuery({
|
|
546
|
+
queryKey: [config?.queryKey, tenantId, 'all'],
|
|
547
|
+
queryFn: () => config?.listAll?.() ?? Promise.resolve({ data: [] }),
|
|
548
|
+
enabled: enabled && Boolean(config?.listAll),
|
|
549
|
+
staleTime: config?.staleTime ?? 60_000,
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
|
|
518
553
|
/**
|
|
519
554
|
* Hydrates the CSV id list in a resource filter into human labels. Reuses
|
|
520
555
|
* the resource's `hydrate` callback so each chip benefits from React Query's
|
|
@@ -530,19 +565,21 @@ function ResourceFilterValue({
|
|
|
530
565
|
const tenantId = useTenantId()
|
|
531
566
|
const ids = useMemo(() => parseFilterIds(value), [value])
|
|
532
567
|
|
|
533
|
-
const
|
|
568
|
+
const all = useListAll(config, tenantId, ids.length > 0)
|
|
569
|
+
const hydrated = useQuery({
|
|
534
570
|
// Tenant-scope the cache key so chips don't resolve against another
|
|
535
571
|
// tenant's hydrate results after a store or seller switch.
|
|
536
|
-
queryKey: [
|
|
572
|
+
queryKey: [config.queryKey, tenantId, 'filter-chip', ids],
|
|
537
573
|
queryFn: () => config.hydrate(ids),
|
|
538
|
-
enabled: ids.length > 0,
|
|
539
|
-
staleTime: 60_000,
|
|
574
|
+
enabled: ids.length > 0 && !config.listAll,
|
|
575
|
+
staleTime: config.staleTime ?? 60_000,
|
|
540
576
|
})
|
|
577
|
+
const records = config.listAll ? all.data?.data : hydrated.data?.data
|
|
541
578
|
|
|
542
579
|
if (ids.length === 0) return null
|
|
543
580
|
|
|
544
581
|
const labels = ids.map((id) => {
|
|
545
|
-
const record =
|
|
582
|
+
const record = records?.find((r) => r.id === id)
|
|
546
583
|
return record ? config.getOptionLabel(record) : id
|
|
547
584
|
})
|
|
548
585
|
|
|
@@ -768,12 +805,7 @@ function QuickResourceFilter({
|
|
|
768
805
|
[existingValue],
|
|
769
806
|
)
|
|
770
807
|
|
|
771
|
-
const { data } =
|
|
772
|
-
queryKey: ['quick-filter-all', config.queryKey, tenantId],
|
|
773
|
-
queryFn: () => config.listAll?.() ?? Promise.resolve({ data: [] }),
|
|
774
|
-
enabled: !!config.listAll,
|
|
775
|
-
staleTime: 60_000,
|
|
776
|
-
})
|
|
808
|
+
const { data } = useListAll(config, tenantId)
|
|
777
809
|
const options = useMemo(
|
|
778
810
|
() =>
|
|
779
811
|
(data?.data ?? []).map((item) => ({ value: item.id, label: config.getOptionLabel(item) })),
|
|
@@ -1101,10 +1133,15 @@ function InlineValueList({
|
|
|
1101
1133
|
const tagsClient = getApiClient().tags
|
|
1102
1134
|
const isTags = column.filterType === 'tags'
|
|
1103
1135
|
const isCurrency = column.filterType === 'currency'
|
|
1104
|
-
|
|
1105
|
-
//
|
|
1106
|
-
|
|
1107
|
-
|
|
1136
|
+
const isLocale = column.filterType === 'locale'
|
|
1137
|
+
// Store codes are listed client-side from the store payload, never searched.
|
|
1138
|
+
const isStoreCode = isCurrency || isLocale
|
|
1139
|
+
// `filterType: 'currency'` / `'locale'` are only declared by tables in the
|
|
1140
|
+
// operator's dashboard, which always has a store. Reading it optionally
|
|
1141
|
+
// keeps the panel mountable in one that has none.
|
|
1142
|
+
const store = useOptionalStore()
|
|
1143
|
+
const storeCodes = (isCurrency ? store?.currencies : isLocale ? store?.locales : null) ?? noCodes
|
|
1144
|
+
const localeLabel = useLocaleLabel()
|
|
1108
1145
|
|
|
1109
1146
|
// Tags arrive as one list and are filtered client-side; resources are
|
|
1110
1147
|
// searched server-side, since their vocabulary is unbounded.
|
|
@@ -1129,18 +1166,23 @@ function InlineValueList({
|
|
|
1129
1166
|
// genuinely new query reaches the network.
|
|
1130
1167
|
const deferredQuery = useDeferredValue(query)
|
|
1131
1168
|
|
|
1169
|
+
// A resource listed whole is fetched once and narrowed here as the operator
|
|
1170
|
+
// types; anything larger is searched server-side.
|
|
1171
|
+
const listed = Boolean(column.filterResource?.listAll)
|
|
1172
|
+
const all = useListAll(column.filterResource, tenantId, !isTags && !isStoreCode)
|
|
1173
|
+
|
|
1132
1174
|
const resources = useQuery({
|
|
1133
1175
|
// Tenant-scoped so a store or seller switch cannot reuse the previous
|
|
1134
1176
|
// tenant's results.
|
|
1135
|
-
queryKey: [
|
|
1177
|
+
queryKey: [column.filterResource?.queryKey, tenantId, 'panel-resource-filter', deferredQuery],
|
|
1136
1178
|
queryFn: () => column.filterResource?.search(deferredQuery),
|
|
1137
|
-
enabled: !isTags && !
|
|
1179
|
+
enabled: !isTags && !isStoreCode && !listed && Boolean(column.filterResource),
|
|
1138
1180
|
// These lists are reference data an operator reopens repeatedly while
|
|
1139
1181
|
// narrowing one list, and the result is small now that only the displayed
|
|
1140
1182
|
// fields are requested. Holding it for the session keeps reopening the
|
|
1141
1183
|
// panel instant; `gcTime` outlives the unmount between opens.
|
|
1142
|
-
staleTime: 5 * 60_000,
|
|
1143
|
-
gcTime: 30 * 60_000,
|
|
1184
|
+
staleTime: column.filterResource?.staleTime ?? 5 * 60_000,
|
|
1185
|
+
gcTime: Math.max(30 * 60_000, column.filterResource?.staleTime ?? 0),
|
|
1144
1186
|
// Keeps the previous matches on screen while the next query resolves, so
|
|
1145
1187
|
// the list does not blank on every keystroke.
|
|
1146
1188
|
//
|
|
@@ -1149,7 +1191,7 @@ function InlineValueList({
|
|
|
1149
1191
|
// drops them instead of briefly offering another tenant's records as
|
|
1150
1192
|
// though they were this one's.
|
|
1151
1193
|
placeholderData: (previous, previousQuery) => {
|
|
1152
|
-
const [
|
|
1194
|
+
const [previousResource, previousTenant] = (previousQuery?.queryKey ?? []) as unknown[]
|
|
1153
1195
|
const sameScope =
|
|
1154
1196
|
previousResource === column.filterResource?.queryKey && previousTenant === tenantId
|
|
1155
1197
|
return sameScope ? previous : undefined
|
|
@@ -1157,11 +1199,14 @@ function InlineValueList({
|
|
|
1157
1199
|
})
|
|
1158
1200
|
|
|
1159
1201
|
const options = useMemo(() => {
|
|
1160
|
-
if (
|
|
1202
|
+
if (isStoreCode) {
|
|
1161
1203
|
const needle = query.trim().toLowerCase()
|
|
1162
|
-
return
|
|
1163
|
-
.
|
|
1164
|
-
|
|
1204
|
+
return storeCodes
|
|
1205
|
+
.map((code) => ({
|
|
1206
|
+
value: code,
|
|
1207
|
+
label: isLocale ? localeLabel(code) : code,
|
|
1208
|
+
}))
|
|
1209
|
+
.filter((option) => !needle || option.label.toLowerCase().includes(needle))
|
|
1165
1210
|
}
|
|
1166
1211
|
if (isTags) {
|
|
1167
1212
|
const names = tags.data?.data?.map((tag) => tag.name) ?? []
|
|
@@ -1171,25 +1216,45 @@ function InlineValueList({
|
|
|
1171
1216
|
}
|
|
1172
1217
|
const config = column.filterResource
|
|
1173
1218
|
if (!config) return []
|
|
1174
|
-
|
|
1219
|
+
const records = (listed ? all.data?.data : resources.data?.data) ?? []
|
|
1220
|
+
const resourceOptions = records.map((record) => ({
|
|
1175
1221
|
value: record.id,
|
|
1176
1222
|
label: config.getOptionLabel(record),
|
|
1177
1223
|
}))
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1224
|
+
if (!listed) return resourceOptions
|
|
1225
|
+
const needle = query.trim().toLowerCase()
|
|
1226
|
+
return resourceOptions.filter(
|
|
1227
|
+
(option) => !needle || option.label.toLowerCase().includes(needle),
|
|
1228
|
+
)
|
|
1229
|
+
}, [
|
|
1230
|
+
listed,
|
|
1231
|
+
all.data,
|
|
1232
|
+
isStoreCode,
|
|
1233
|
+
isLocale,
|
|
1234
|
+
storeCodes,
|
|
1235
|
+
localeLabel,
|
|
1236
|
+
isTags,
|
|
1237
|
+
tags.data,
|
|
1238
|
+
resources.data,
|
|
1239
|
+
column.filterResource,
|
|
1240
|
+
query,
|
|
1241
|
+
])
|
|
1242
|
+
|
|
1243
|
+
// Values already selected when the list opens ride at the top, so an active
|
|
1244
|
+
// filter is visible without scrolling. The set is frozen at mount: ticking a
|
|
1245
|
+
// box must not move rows under the pointer.
|
|
1246
|
+
const [pinned] = useState(() => selected)
|
|
1182
1247
|
const ordered = useMemo(() => {
|
|
1183
|
-
const chosen = options.filter((option) =>
|
|
1184
|
-
const rest = options.filter((option) => !
|
|
1248
|
+
const chosen = options.filter((option) => pinned.includes(option.value))
|
|
1249
|
+
const rest = options.filter((option) => !pinned.includes(option.value))
|
|
1185
1250
|
// Not truncated: a list cut at ten looks like a list of ten, and a
|
|
1186
1251
|
// vocabulary of forty tags would silently lose thirty with nothing on
|
|
1187
1252
|
// screen to say so. The container scrolls instead, so the scrollbar
|
|
1188
1253
|
// reports how much more there is — and searching narrows it.
|
|
1189
1254
|
return [...chosen, ...rest]
|
|
1190
|
-
}, [options,
|
|
1255
|
+
}, [options, pinned])
|
|
1191
1256
|
|
|
1192
|
-
if (!
|
|
1257
|
+
if (!isStoreCode && (tags.isLoading || resources.isLoading || all.isLoading)) {
|
|
1193
1258
|
return (
|
|
1194
1259
|
<p className="px-2 py-6 text-center text-muted-foreground">{t('admin.common.loading')}</p>
|
|
1195
1260
|
)
|
|
@@ -1272,7 +1337,7 @@ function FilterPanel({
|
|
|
1272
1337
|
// Types whose values are chosen from a list rather than typed. `multi` ones
|
|
1273
1338
|
// accumulate a set and need an explicit Apply; the single ones commit on the
|
|
1274
1339
|
// click that picks them.
|
|
1275
|
-
const multi = type === 'tags' || type === 'resource' || type === 'currency'
|
|
1340
|
+
const multi = type === 'tags' || type === 'resource' || type === 'currency' || type === 'locale'
|
|
1276
1341
|
// Dates are their own shape: a list of relative presets, with the calendar
|
|
1277
1342
|
// behind "custom". Same presets the quick filter offers, so a range means
|
|
1278
1343
|
// the same thing wherever it was set.
|
|
@@ -1507,6 +1572,7 @@ function FilterPanel({
|
|
|
1507
1572
|
behind the panel is already showing the result. */}
|
|
1508
1573
|
{field && multi && column && (
|
|
1509
1574
|
<InlineValueList
|
|
1575
|
+
key={column.key}
|
|
1510
1576
|
column={column}
|
|
1511
1577
|
tenantId={tenantId}
|
|
1512
1578
|
query={query}
|
|
@@ -1650,7 +1716,7 @@ function FilterPanel({
|
|
|
1650
1716
|
</Select>
|
|
1651
1717
|
) : null}
|
|
1652
1718
|
|
|
1653
|
-
{/* Everything picked from a list — currency, tags, resources — or
|
|
1719
|
+
{/* Everything picked from a list — currency, locale, tags, resources — or
|
|
1654
1720
|
from a calendar is handled above; this branch is only the types
|
|
1655
1721
|
you type into. */}
|
|
1656
1722
|
{!noValueOperators.includes(operator) && listOptions.length === 0 && (
|
|
@@ -1,24 +1,7 @@
|
|
|
1
1
|
import { useMemo } from 'react'
|
|
2
2
|
import { useTranslation } from 'react-i18next'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
* Resolves a code to its display name via `Intl.DisplayNames`, localized to a
|
|
6
|
-
* given UI language. Returns `undefined` when the runtime lacks coverage so
|
|
7
|
-
* callers can apply their own fallback (usually the raw code).
|
|
8
|
-
*
|
|
9
|
-
* Standalone (non-hook) variant for module-level use outside React.
|
|
10
|
-
*/
|
|
11
|
-
export function intlDisplayName(
|
|
12
|
-
type: Intl.DisplayNamesType,
|
|
13
|
-
locale: string,
|
|
14
|
-
code: string,
|
|
15
|
-
): string | undefined {
|
|
16
|
-
try {
|
|
17
|
-
return new Intl.DisplayNames([locale, 'en'], { type }).of(code)
|
|
18
|
-
} catch {
|
|
19
|
-
return undefined
|
|
20
|
-
}
|
|
21
|
-
}
|
|
4
|
+
export { intlDisplayName } from '@spree/core'
|
|
22
5
|
|
|
23
6
|
/**
|
|
24
7
|
* Hook returning a memoized `code -> name` resolver for one `Intl.DisplayNames`
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FileUploadVisibility } from '@spree/core'
|
|
1
2
|
import { useMutation } from '@tanstack/react-query'
|
|
2
3
|
import { getApiClient } from '../api-client'
|
|
3
4
|
|
|
@@ -31,7 +32,7 @@ interface FileUploadOptions {
|
|
|
31
32
|
* (documents, imports, digital products). Attaching a file never moves it
|
|
32
33
|
* between storage services, so this has to be decided up front.
|
|
33
34
|
*/
|
|
34
|
-
visibility?:
|
|
35
|
+
visibility?: FileUploadVisibility
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export function useFileUpload(options: FileUploadOptions = {}) {
|
package/src/hooks/use-import.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { IMPORT_STATUSES, subsetOf } from '@spree/core'
|
|
1
2
|
import { toastManager } from '@spree/dashboard-ui'
|
|
2
3
|
import {
|
|
3
4
|
type QueryClient,
|
|
@@ -20,7 +21,7 @@ import { useTenantId } from '../providers/tenant-provider'
|
|
|
20
21
|
import { useAuth } from './use-auth'
|
|
21
22
|
|
|
22
23
|
/** Statuses the wizard polls through — row creation and processing. */
|
|
23
|
-
const ACTIVE_STATUSES =
|
|
24
|
+
const ACTIVE_STATUSES = subsetOf(IMPORT_STATUSES, ['completed_mapping', 'processing'])
|
|
24
25
|
|
|
25
26
|
const IMPORT_POLL_INTERVAL_MS = 2000
|
|
26
27
|
const ROWS_POLL_INTERVAL_MS = 5000
|
|
@@ -49,7 +50,7 @@ const IMPORT_TOUCHED_RESOURCES = [
|
|
|
49
50
|
|
|
50
51
|
/** Whether the import's pipeline is still running (the poll's continue predicate). */
|
|
51
52
|
export function isImportActive(status: string | undefined): boolean {
|
|
52
|
-
return !!status && ACTIVE_STATUSES.
|
|
53
|
+
return !!status && ACTIVE_STATUSES.includes(status)
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
/**
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,17 @@
|
|
|
11
11
|
// import { AuthProvider } from '@spree/dashboard-core/providers/auth-provider'
|
|
12
12
|
// import { useResourceMutation } from '@spree/dashboard-core/hooks/use-resource-mutation'
|
|
13
13
|
|
|
14
|
+
// Money helpers from @spree/core, offered here as before for plugin authors
|
|
15
|
+
export {
|
|
16
|
+
currencyParts,
|
|
17
|
+
fractionToPercent,
|
|
18
|
+
isPositiveMoney,
|
|
19
|
+
normalizeMoneyInput,
|
|
20
|
+
percentOf,
|
|
21
|
+
percentToFraction,
|
|
22
|
+
prorateMoney,
|
|
23
|
+
shiftDecimalPoint,
|
|
24
|
+
} from '@spree/core'
|
|
14
25
|
export * from './api-client'
|
|
15
26
|
// ---------------------------------------------------------------------------
|
|
16
27
|
// Admin SDK client (Vite-aware singleton; reads VITE_SPREE_API_URL at build)
|
|
@@ -81,7 +92,6 @@ export * from './lib/create-action-registry'
|
|
|
81
92
|
// Registries — pluggable extension points (nav, route, slot, table,
|
|
82
93
|
// settings-nav, form fields, custom field components)
|
|
83
94
|
// ---------------------------------------------------------------------------
|
|
84
|
-
export * from './lib/currency-parts'
|
|
85
95
|
export * from './lib/custom-field-components'
|
|
86
96
|
export * from './lib/date-presets'
|
|
87
97
|
export * from './lib/delivery-form'
|
|
@@ -103,9 +113,7 @@ export * from './lib/i18n'
|
|
|
103
113
|
export * from './lib/import-types'
|
|
104
114
|
export * from './lib/iso-codes'
|
|
105
115
|
export * from './lib/label-upload-form'
|
|
106
|
-
export * from './lib/money'
|
|
107
116
|
export * from './lib/nav-registry'
|
|
108
|
-
export * from './lib/normalize-money'
|
|
109
117
|
export * from './lib/permissions'
|
|
110
118
|
export * from './lib/progress'
|
|
111
119
|
export * from './lib/query-client'
|
|
@@ -115,6 +123,7 @@ export * from './lib/route-registry'
|
|
|
115
123
|
export * from './lib/search-registry'
|
|
116
124
|
export * from './lib/settings-nav-registry'
|
|
117
125
|
export * from './lib/slot-registry'
|
|
126
|
+
export * from './lib/store-config'
|
|
118
127
|
export * from './lib/table-registry'
|
|
119
128
|
export { ensureTimestampColumns } from './lib/timestamp-columns'
|
|
120
129
|
export * from './lib/type-labels'
|
|
@@ -131,6 +140,7 @@ export * from './products/product-form-cards'
|
|
|
131
140
|
export * from './products/product-form-mapping'
|
|
132
141
|
export * from './products/product-schema'
|
|
133
142
|
export * from './products/product-types'
|
|
143
|
+
export type { SheetSaveHandler } from './products/sheet-save-handlers'
|
|
134
144
|
export * from './products/use-product-form-data'
|
|
135
145
|
export * from './products/variants-matrix'
|
|
136
146
|
export * from './products/video-url'
|
package/src/lib/currencies.ts
CHANGED
|
@@ -1,18 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Every ISO 4217 currency code the runtime knows about — the active-currency
|
|
6
|
-
* counterpart to the Rails admin's `Money::Currency.table` list. Used for
|
|
7
|
-
* contexts where the merchant can pick *any* currency (a market's currency)
|
|
8
|
-
* rather than one the store already supports. Degrades to an empty list on
|
|
9
|
-
* runtimes without `Intl.supportedValuesOf`, so callers fall back to
|
|
10
|
-
* `supported_currencies`.
|
|
11
|
-
*/
|
|
12
|
-
export const ALL_CURRENCY_CODES: CurrencyCode[] = (() => {
|
|
13
|
-
try {
|
|
14
|
-
return Intl.supportedValuesOf('currency').filter(isCurrencyCode)
|
|
15
|
-
} catch {
|
|
16
|
-
return []
|
|
17
|
-
}
|
|
18
|
-
})()
|
|
1
|
+
/** @deprecated Import `ACTIVE_CURRENCY_CODES` from `@spree/core`. Removed in 6.1. */
|
|
2
|
+
export { ACTIVE_CURRENCY_CODES as ALL_CURRENCY_CODES } from '@spree/core'
|
|
@@ -1,21 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
// Shared between the server-backed BulkPriceEditor and the form-backed
|
|
4
|
-
// ProductBulkPriceEditor.
|
|
5
|
-
export function currencyParts(
|
|
6
|
-
currencyCode: string,
|
|
7
|
-
locale: string,
|
|
8
|
-
): { symbol: string; decimal: string } {
|
|
9
|
-
try {
|
|
10
|
-
const parts = new Intl.NumberFormat(locale, {
|
|
11
|
-
style: 'currency',
|
|
12
|
-
currency: currencyCode,
|
|
13
|
-
}).formatToParts(1234.56)
|
|
14
|
-
return {
|
|
15
|
-
symbol: parts.find((p) => p.type === 'currency')?.value ?? currencyCode,
|
|
16
|
-
decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
|
|
17
|
-
}
|
|
18
|
-
} catch {
|
|
19
|
-
return { symbol: currencyCode, decimal: '.' }
|
|
20
|
-
}
|
|
21
|
-
}
|
|
1
|
+
/** @deprecated Import from `@spree/core`. Removed in 6.1. */
|
|
2
|
+
export { currencyParts } from '@spree/core'
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FILTER_PREDICATES } from '@spree/core'
|
|
1
2
|
import { type ColumnDef, type FilterRule, parseFilterIds } from './table-registry'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -20,7 +21,9 @@ import { type ColumnDef, type FilterRule, parseFilterIds } from './table-registr
|
|
|
20
21
|
// `q[xxx_in][]=...` for each item. The chip stores the IDs as a CSV string
|
|
21
22
|
// in `FilterRule.value` so the URL serializer (which already JSON-stringifies
|
|
22
23
|
// FilterRule[]) stays simple.
|
|
23
|
-
const ARRAY_OPERATORS = new Set(
|
|
24
|
+
const ARRAY_OPERATORS = new Set<string>(
|
|
25
|
+
Object.values(FILTER_PREDICATES).flatMap((predicates) => predicates.list),
|
|
26
|
+
)
|
|
24
27
|
|
|
25
28
|
const NEGATING_OPERATORS = new Set(['not_in', 'not_eq'])
|
|
26
29
|
|
|
@@ -30,7 +30,8 @@ const registrations = new Map<string, FormFieldRegistration[]>()
|
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Registry of extension fields per built-in form. Form keys match the form's
|
|
33
|
-
* slot prefix — `'product'` for the product detail form.
|
|
33
|
+
* slot prefix — `'product'` for the product detail form, `'product.media'` and
|
|
34
|
+
* `'product.variant'` for the rows of its media and variant lists. Registered names
|
|
34
35
|
* must be unique per form; duplicates throw so two plugins can't silently
|
|
35
36
|
* fight over one attribute.
|
|
36
37
|
*/
|
|
@@ -96,6 +97,27 @@ export function extensionSubmitValues(
|
|
|
96
97
|
return out
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
/** Names of every extension field registered on `formKey`. */
|
|
101
|
+
export function extensionFieldNames(formKey: string): string[] {
|
|
102
|
+
return (registrations.get(formKey) ?? []).map((registration) => registration.name)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Extension field values carried by one row of a nested list — a media or
|
|
107
|
+
* variant row of the product form, registered under `'product.media'` /
|
|
108
|
+
* `'product.variant'`. Only keys the row actually holds are returned, so a
|
|
109
|
+
* row added after hydration (a fresh upload) sends nothing it never had.
|
|
110
|
+
*/
|
|
111
|
+
export function extensionRowValues(formKey: string, row: unknown): Record<string, unknown> {
|
|
112
|
+
if (!row || typeof row !== 'object') return {}
|
|
113
|
+
|
|
114
|
+
const out: Record<string, unknown> = {}
|
|
115
|
+
for (const name of extensionFieldNames(formKey)) {
|
|
116
|
+
if (name in row) out[name] = (row as Record<string, unknown>)[name]
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
99
121
|
/** Test-only: clear the registry. Not exported from the package index. */
|
|
100
122
|
export function __resetFormFieldsRegistry(): void {
|
|
101
123
|
registrations.clear()
|
package/src/lib/form-mappers.ts
CHANGED
package/src/lib/formatters.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Price } from '@spree/admin-sdk'
|
|
2
|
+
import { formatMoney } from '@spree/core'
|
|
2
3
|
import { parseISO } from 'date-fns'
|
|
3
4
|
import { formatInTimeZone } from 'date-fns-tz'
|
|
4
5
|
import i18n from 'i18next'
|
|
@@ -19,9 +20,8 @@ export function formatPrice(
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
|
-
* Formats an API amount (a decimal string) in its currency,
|
|
23
|
-
*
|
|
24
|
-
* either is missing.
|
|
23
|
+
* Formats an API amount (a decimal string) in its currency, or answers
|
|
24
|
+
* `fallback` when either is missing or `Intl` cannot format the pair.
|
|
25
25
|
*/
|
|
26
26
|
export function formatAmount(
|
|
27
27
|
amount: string | null | undefined,
|
|
@@ -29,12 +29,8 @@ export function formatAmount(
|
|
|
29
29
|
locale?: string,
|
|
30
30
|
fallback = '—',
|
|
31
31
|
): string {
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
return formatMoney(amount, currency, locale) || fallback
|
|
35
|
-
} catch {
|
|
36
|
-
return `${amount} ${currency}`
|
|
37
|
-
}
|
|
32
|
+
if (!currency) return fallback
|
|
33
|
+
return formatMoney(amount, currency, locale) || fallback
|
|
38
34
|
}
|
|
39
35
|
|
|
40
36
|
export function formatStoreDateTime(iso: string, timezone: string) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ORDER_FULFILLMENT_STATUSES, subsetOf } from '@spree/core'
|
|
1
2
|
import i18n from 'i18next'
|
|
2
3
|
import { formatMoney } from './formatters'
|
|
3
4
|
|
|
@@ -86,6 +87,16 @@ function claimedQuantities(fulfillments: GroupableFulfillment[]): Map<string, nu
|
|
|
86
87
|
*/
|
|
87
88
|
export const GONE_STATUSES = ['fulfilled', 'delivered']
|
|
88
89
|
|
|
90
|
+
/** The order-level fulfillment statuses the order lists filter on. */
|
|
91
|
+
export const FILTERABLE_ORDER_FULFILLMENT_STATUSES = subsetOf(ORDER_FULFILLMENT_STATUSES, [
|
|
92
|
+
'backorder',
|
|
93
|
+
'canceled',
|
|
94
|
+
'partial',
|
|
95
|
+
'unfulfilled',
|
|
96
|
+
'fulfilled',
|
|
97
|
+
'delivered',
|
|
98
|
+
])
|
|
99
|
+
|
|
89
100
|
export function fulfilledQuantities(fulfillments: GroupableFulfillment[]): Map<string, number> {
|
|
90
101
|
const fulfilled = new Map<string, number>()
|
|
91
102
|
|
package/src/lib/i18n.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { localeEndonym } from '@spree/core'
|
|
1
2
|
import i18n from 'i18next'
|
|
2
3
|
import { initReactI18next } from 'react-i18next'
|
|
3
|
-
import { intlDisplayName } from '../hooks/use-display-name'
|
|
4
4
|
// Relative path, not `@/...`: dashboard-core ships source; the consuming
|
|
5
5
|
// project's `@/*` alias is theirs alone (see Phase 1's rationale).
|
|
6
6
|
import en from '../locales/en.json'
|
|
@@ -153,17 +153,6 @@ function localeCodesFromBundlePaths(paths: string[]): string[] {
|
|
|
153
153
|
return ['en', ...paths.map((path) => path.replace(/^.*\/locales\//, '').replace('.json', ''))]
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
// Each language's endonym (its own name: `Deutsch`, `中文`). `Intl.DisplayNames`
|
|
157
|
-
// answers in the language's own convention, so some come back lowercase
|
|
158
|
-
// (`français`, `polski`); capitalize the first letter by THAT language's casing
|
|
159
|
-
// rules rather than the current locale's. Scripts without case are unaffected,
|
|
160
|
-
// and an unknown code falls back to itself.
|
|
161
|
-
function localeEndonym(code: string): string {
|
|
162
|
-
const name = intlDisplayName('language', code, code) ?? code
|
|
163
|
-
|
|
164
|
-
return name.charAt(0).toLocaleUpperCase(code) + name.slice(1)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
156
|
// The languages the running panel can display, as it declared them. `null`
|
|
168
157
|
// until a panel does, which accepts every code — the behaviour before panels
|
|
169
158
|
// declared anything.
|
package/src/lib/iso-codes.ts
CHANGED
|
@@ -1,30 +1,15 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
type CountryCode,
|
|
3
|
+
type CurrencyCode,
|
|
4
|
+
isCountryCode,
|
|
5
|
+
isCurrencyCode,
|
|
6
|
+
isLocaleCode,
|
|
7
|
+
type LocaleCode,
|
|
8
|
+
} from '@spree/core'
|
|
2
9
|
import i18n from 'i18next'
|
|
3
10
|
import { z } from 'zod/v4'
|
|
4
11
|
|
|
5
|
-
|
|
6
|
-
* Whether a value has the shape of an ISO 3166-1 alpha-2 country code.
|
|
7
|
-
*
|
|
8
|
-
* Shape only: the server holds the authoritative list and rejects a code it
|
|
9
|
-
* does not know, so the dashboard does not ship a second copy of it. This is
|
|
10
|
-
* the one place a plain string becomes a `CountryCode`.
|
|
11
|
-
*/
|
|
12
|
-
export function isCountryCode(value: unknown): value is CountryCode {
|
|
13
|
-
return typeof value === 'string' && /^[A-Z]{2}$/.test(value)
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Whether a value has the shape of an ISO 4217 currency code. See `isCountryCode`. */
|
|
17
|
-
export function isCurrencyCode(value: unknown): value is CurrencyCode {
|
|
18
|
-
return typeof value === 'string' && /^[A-Z]{3}$/.test(value)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Whether a value has the shape of a locale code: an ISO 639-1 language,
|
|
23
|
-
* optionally with a region (`en`, `pt-BR`, `es-419`). See `isCountryCode`.
|
|
24
|
-
*/
|
|
25
|
-
export function isLocaleCode(value: unknown): value is LocaleCode {
|
|
26
|
-
return typeof value === 'string' && /^[a-z]{2,3}(-[A-Z0-9]{2,3})?$/.test(value)
|
|
27
|
-
}
|
|
12
|
+
export { isCountryCode, isCurrencyCode, isLocaleCode }
|
|
28
13
|
|
|
29
14
|
/**
|
|
30
15
|
* A required country field. A form that allows leaving it blank widens it
|