@spree/dashboard-core 1.0.0-rc.4 → 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 +5 -4
- 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 +6 -7
- package/src/components/file-upload-field.tsx +1 -0
- package/src/components/locale-select.tsx +22 -23
- package/src/components/nav-main.tsx +7 -2
- package/src/components/preferences-form.tsx +2 -8
- package/src/components/settings-sidebar.tsx +8 -1
- package/src/components/slot.tsx +21 -1
- package/src/components/table-toolbar.tsx +102 -39
- 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 -34
- 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 -26
- package/src/lib/money.ts +9 -99
- package/src/lib/normalize-money.ts +2 -85
- package/src/lib/return-refunds.ts +1 -2
- package/src/lib/route-registry.ts +29 -0
- package/src/lib/settings-nav-registry.ts +6 -1
- 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 +4 -0
- package/src/locales/de.json +4 -0
- package/src/locales/en.json +4 -0
- package/src/locales/es.json +4 -0
- package/src/locales/fr.json +4 -0
- package/src/locales/pl.json +4 -0
- package/src/locales/zh-CN.json +4 -0
- 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 +27 -28
- 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-section.tsx +4 -1
- 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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spree/dashboard-core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "1.0.0-rc.
|
|
4
|
+
"version": "1.0.0-rc.5",
|
|
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",
|
|
@@ -69,7 +69,8 @@
|
|
|
69
69
|
"date-fns": "^4.4.0",
|
|
70
70
|
"date-fns-tz": "^3.2.0",
|
|
71
71
|
"lucide-react": "^1.58.0",
|
|
72
|
-
"zod": "^4.6.7"
|
|
72
|
+
"zod": "^4.6.7",
|
|
73
|
+
"@spree/core": "^0.2.0"
|
|
73
74
|
},
|
|
74
75
|
"devDependencies": {
|
|
75
76
|
"@types/react": "^19.2.14",
|
|
@@ -78,8 +79,8 @@
|
|
|
78
79
|
"typescript": "~7.0.2",
|
|
79
80
|
"vite": "^8.3.4",
|
|
80
81
|
"vitest": "^5.0.5",
|
|
81
|
-
"@spree/admin-sdk": "^1.0.0-rc.
|
|
82
|
-
"@spree/dashboard-ui": "^1.0.0-rc.
|
|
82
|
+
"@spree/admin-sdk": "^1.0.0-rc.5",
|
|
83
|
+
"@spree/dashboard-ui": "^1.0.0-rc.5"
|
|
83
84
|
},
|
|
84
85
|
"scripts": {
|
|
85
86
|
"lint": "biome check src",
|
package/src/api-client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PackageTypeKind, PickupStockPolicy } from '@spree/core'
|
|
1
2
|
/**
|
|
2
3
|
* The client registry.
|
|
3
4
|
*
|
|
@@ -14,7 +15,8 @@
|
|
|
14
15
|
* promise the seller panel methods its API does not have.
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
|
-
import type {
|
|
18
|
+
import type { PermissionRule, UploadFileOptions } from '@spree/admin-sdk'
|
|
19
|
+
import type { CountryCode, DimensionUnit, WeightUnit } from '@spree/core'
|
|
18
20
|
|
|
19
21
|
/** What a panel may change on the signed-in person's own account. */
|
|
20
22
|
export interface PanelAccountParams {
|
|
@@ -354,10 +356,10 @@ export interface PanelPackageTypeParams {
|
|
|
354
356
|
length?: number | null
|
|
355
357
|
width?: number | null
|
|
356
358
|
height?: number | null
|
|
357
|
-
dimensions_unit?:
|
|
359
|
+
dimensions_unit?: DimensionUnit | null
|
|
358
360
|
weight?: number | null
|
|
359
361
|
max_weight?: number | null
|
|
360
|
-
weight_unit?:
|
|
362
|
+
weight_unit?: WeightUnit | null
|
|
361
363
|
default?: boolean
|
|
362
364
|
/**
|
|
363
365
|
* Not nullable: both SDKs take a merge, so clearing a key is sending the
|
|
@@ -386,9 +388,12 @@ export interface PanelPackageTypeWrites extends PanelPackageTypeReads {
|
|
|
386
388
|
delete?(id: string): Promise<void>
|
|
387
389
|
}
|
|
388
390
|
|
|
389
|
-
|
|
390
|
-
export type
|
|
391
|
-
|
|
391
|
+
/** @deprecated Use `PackageTypeKind` from `@spree/core`. */
|
|
392
|
+
export type PanelPackageTypeKind = PackageTypeKind
|
|
393
|
+
/** @deprecated Use `DimensionUnit` from `@spree/core`. */
|
|
394
|
+
export type PanelPackageDimensionUnit = DimensionUnit
|
|
395
|
+
/** @deprecated Use `WeightUnit` from `@spree/core`. */
|
|
396
|
+
export type PanelPackageWeightUnit = WeightUnit
|
|
392
397
|
|
|
393
398
|
/**
|
|
394
399
|
* A new row must say what it is and what to call it — packaging without
|
|
@@ -598,7 +603,7 @@ export type PanelStockLocationParams = Partial<
|
|
|
598
603
|
* because plugins register their own, but the pickup policy is a fixed pair
|
|
599
604
|
* the server validates, and both SDKs type it that way.
|
|
600
605
|
*/
|
|
601
|
-
pickup_stock_policy?:
|
|
606
|
+
pickup_stock_policy?: PickupStockPolicy
|
|
602
607
|
}
|
|
603
608
|
|
|
604
609
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
|
-
import type
|
|
2
|
+
import { type CountryCode, isCountryCode } from '@spree/core'
|
|
3
3
|
import {
|
|
4
4
|
Button,
|
|
5
5
|
Checkbox,
|
|
@@ -22,7 +22,7 @@ import { Controller, type DefaultValues, useForm } from 'react-hook-form'
|
|
|
22
22
|
import { useTranslation } from 'react-i18next'
|
|
23
23
|
import { z } from 'zod/v4'
|
|
24
24
|
import { mapSpreeErrorsToForm } from '../lib/form-errors'
|
|
25
|
-
import { countryCodeSchema
|
|
25
|
+
import { countryCodeSchema } from '../lib/iso-codes'
|
|
26
26
|
import { useOptionalStore } from '../providers/store-provider'
|
|
27
27
|
import { CountryCombobox } from './country-combobox'
|
|
28
28
|
import { StateCombobox, useCountryStates } from './country-state-fields'
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CountryCode, isCountryCode } from '@spree/core'
|
|
2
2
|
import {
|
|
3
3
|
Combobox,
|
|
4
4
|
ComboboxButtonTrigger,
|
|
@@ -19,7 +19,6 @@ import { useCallback, useMemo, useState } from 'react'
|
|
|
19
19
|
import { useTranslation } from 'react-i18next'
|
|
20
20
|
import { useCountries } from '../hooks/use-countries'
|
|
21
21
|
import { useDisplayName } from '../hooks/use-display-name'
|
|
22
|
-
import { isCountryCode } from '../lib/iso-codes'
|
|
23
22
|
|
|
24
23
|
export type CountryOption = { iso: CountryCode; iso3: string }
|
|
25
24
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CurrencyCode, isCurrencyCode } from '@spree/core'
|
|
2
2
|
import {
|
|
3
3
|
Combobox,
|
|
4
4
|
ComboboxButtonTrigger,
|
|
@@ -12,10 +12,9 @@ import {
|
|
|
12
12
|
import { useMemo, useState } from 'react'
|
|
13
13
|
import { useTranslation } from 'react-i18next'
|
|
14
14
|
import { useDisplayName } from '../hooks/use-display-name'
|
|
15
|
-
import { isCurrencyCode } from '../lib/iso-codes'
|
|
16
15
|
import { useOptionalStore } from '../providers/store-provider'
|
|
17
16
|
|
|
18
|
-
export { ALL_CURRENCY_CODES } from '
|
|
17
|
+
export { ACTIVE_CURRENCY_CODES as ALL_CURRENCY_CODES } from '@spree/core'
|
|
19
18
|
|
|
20
19
|
interface CurrencySelectProps {
|
|
21
20
|
/** Classes for the trigger — used to match a neighbouring control's height. */
|
|
@@ -33,8 +32,8 @@ interface CurrencySelectProps {
|
|
|
33
32
|
/** Fires on every selection change. */
|
|
34
33
|
onChange?: (currency: CurrencyCode) => void
|
|
35
34
|
/**
|
|
36
|
-
* Currency codes to pick from. Defaults to the current
|
|
37
|
-
*
|
|
35
|
+
* Currency codes to pick from. Defaults to the currencies of the current
|
|
36
|
+
* store's markets. Pass `ALL_CURRENCY_CODES` (or any custom list) for
|
|
38
37
|
* contexts where the merchant chooses a currency the store doesn't yet
|
|
39
38
|
* support — e.g. a market's currency.
|
|
40
39
|
*/
|
|
@@ -55,8 +54,8 @@ interface CurrencySelectProps {
|
|
|
55
54
|
}
|
|
56
55
|
|
|
57
56
|
/**
|
|
58
|
-
* Picker for a currency code. Defaults to the
|
|
59
|
-
*
|
|
57
|
+
* Picker for a currency code. Defaults to the currencies of the
|
|
58
|
+
* current store's markets (the default market's first), so callers
|
|
60
59
|
* choosing among already-configured currencies don't have to wire anything up.
|
|
61
60
|
* Pass `options` (e.g. `ALL_CURRENCY_CODES`) when the merchant may pick any
|
|
62
61
|
* currency, such as a market's currency. Each option reads `CODE — Full Name`
|
|
@@ -13,13 +13,13 @@ import {
|
|
|
13
13
|
ComboboxValue,
|
|
14
14
|
useComboboxAnchor,
|
|
15
15
|
} from '@spree/dashboard-ui'
|
|
16
|
-
import { useMemo, useState } from 'react'
|
|
16
|
+
import { useCallback, useMemo, useState } from 'react'
|
|
17
17
|
import { useTranslation } from 'react-i18next'
|
|
18
18
|
import { useDisplayName } from '../hooks/use-display-name'
|
|
19
19
|
import { useStore } from '../providers/store-provider'
|
|
20
20
|
|
|
21
21
|
interface BaseProps {
|
|
22
|
-
/** Locale codes to pick from. Defaults to the current store's
|
|
22
|
+
/** Locale codes to pick from. Defaults to the languages of the current store's markets. */
|
|
23
23
|
options?: string[]
|
|
24
24
|
/** Locale to filter out (e.g. the default locale when picking additional supported ones). */
|
|
25
25
|
excludeCode?: string
|
|
@@ -63,16 +63,6 @@ interface MultiProps extends BaseProps {
|
|
|
63
63
|
|
|
64
64
|
type LocaleSelectProps = SingleProps | MultiProps
|
|
65
65
|
|
|
66
|
-
/**
|
|
67
|
-
* Resolves a locale code (`en`, `pt-BR`) to its display name (`English`,
|
|
68
|
-
* `Portuguese (Brazil)`) in the admin UI language, falling back to the code
|
|
69
|
-
* itself when the runtime lacks coverage.
|
|
70
|
-
*/
|
|
71
|
-
function useLocaleDisplayName() {
|
|
72
|
-
const displayName = useDisplayName('language')
|
|
73
|
-
return (code: string) => displayName(code) ?? code
|
|
74
|
-
}
|
|
75
|
-
|
|
76
66
|
/**
|
|
77
67
|
* Mirrors `CurrencySelect`'s row format: `CODE — Localized Name`. Codes the
|
|
78
68
|
* runtime cannot name (`Intl.DisplayNames` returns the code back, as it does
|
|
@@ -96,6 +86,16 @@ function localeFilter(labelFor: (locale: string) => string) {
|
|
|
96
86
|
}
|
|
97
87
|
}
|
|
98
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Returns a function that labels a locale code the way the dashboard shows
|
|
91
|
+
* locales everywhere: `EN — English`, named in the admin UI language. For
|
|
92
|
+
* plain-text contexts (option lists, search); render `LocaleLabel` in JSX.
|
|
93
|
+
*/
|
|
94
|
+
export function useLocaleLabel() {
|
|
95
|
+
const displayName = useDisplayName('language')
|
|
96
|
+
return useCallback((code: string) => formatOption(code, displayName(code) ?? code), [displayName])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
99
|
/**
|
|
100
100
|
* Static text label for a locale code: `EN — English`. Use this in tables
|
|
101
101
|
* and read-only contexts where the picker isn't needed but the same
|
|
@@ -103,16 +103,16 @@ function localeFilter(labelFor: (locale: string) => string) {
|
|
|
103
103
|
* has no entry.
|
|
104
104
|
*/
|
|
105
105
|
export function LocaleLabel({ code }: { code: string }) {
|
|
106
|
-
const
|
|
106
|
+
const labelFor = useLocaleLabel()
|
|
107
107
|
if (!code) return null
|
|
108
|
-
return <>{
|
|
108
|
+
return <>{labelFor(code)}</>
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
|
-
* Picker for one of the current store's
|
|
113
|
-
* default; pass `multiple` for a chips multi-select. Each
|
|
114
|
-
* `CODE — Localized Name` (e.g. `EN — English`),
|
|
115
|
-
*
|
|
112
|
+
* Picker for one of the languages the current store's markets offer.
|
|
113
|
+
* Single-select by default; pass `multiple` for a chips multi-select. Each
|
|
114
|
+
* option reads `CODE — Localized Name` (e.g. `EN — English`), named in the
|
|
115
|
+
* admin UI language via the browser's `Intl.DisplayNames`. Use this anywhere
|
|
116
116
|
* the merchant is choosing a storefront language (market default, supported
|
|
117
117
|
* locales list, customer preference, etc.).
|
|
118
118
|
*
|
|
@@ -124,7 +124,7 @@ export function LocaleLabel({ code }: { code: string }) {
|
|
|
124
124
|
export function LocaleSelect(props: LocaleSelectProps) {
|
|
125
125
|
const { t } = useTranslation()
|
|
126
126
|
const { locales: storeLocales } = useStore()
|
|
127
|
-
const
|
|
127
|
+
const localeLabel = useLocaleLabel()
|
|
128
128
|
|
|
129
129
|
// Union of `props.options` (everything the picker should show) and the
|
|
130
130
|
// currently selected values — so editing an existing record doesn't
|
|
@@ -145,15 +145,14 @@ export function LocaleSelect(props: LocaleSelectProps) {
|
|
|
145
145
|
// per character; a lookup map keeps it to a single pass per list change.
|
|
146
146
|
const labels = useMemo(() => {
|
|
147
147
|
const map = new Map<string, string>()
|
|
148
|
-
for (const locale of items) map.set(locale,
|
|
148
|
+
for (const locale of items) map.set(locale, localeLabel(locale))
|
|
149
149
|
return map
|
|
150
|
-
}, [items,
|
|
150
|
+
}, [items, localeLabel])
|
|
151
151
|
|
|
152
152
|
// `CODE — Name` label for a locale, from the precomputed map (falls back to a
|
|
153
153
|
// direct lookup for a value not in the current option list, e.g. a selected
|
|
154
154
|
// chip the list later dropped).
|
|
155
|
-
const labelFor = (locale: string) =>
|
|
156
|
-
labels.get(locale) ?? formatOption(locale, displayNameFor(locale))
|
|
155
|
+
const labelFor = (locale: string) => labels.get(locale) ?? localeLabel(locale)
|
|
157
156
|
|
|
158
157
|
if (props.multiple) {
|
|
159
158
|
return (
|
|
@@ -183,6 +183,11 @@ function CollapsedDropdown({
|
|
|
183
183
|
<DropdownMenuItem key={subItem.title} asChild>
|
|
184
184
|
<Link to={subItem.url} className="no-underline">
|
|
185
185
|
{subItem.title}
|
|
186
|
+
{subItem.badge && (
|
|
187
|
+
<span className="ms-auto shrink-0 ps-3">
|
|
188
|
+
<subItem.badge />
|
|
189
|
+
</span>
|
|
190
|
+
)}
|
|
186
191
|
</Link>
|
|
187
192
|
</DropdownMenuItem>
|
|
188
193
|
))}
|
|
@@ -261,9 +266,9 @@ function NavItemContent({
|
|
|
261
266
|
<SidebarMenuSubItem key={subItem.title}>
|
|
262
267
|
<SidebarMenuSubButton asChild isActive={subActive}>
|
|
263
268
|
<Link to={subItem.url}>
|
|
264
|
-
<span>{subItem.title}</span>
|
|
269
|
+
<span className="truncate">{subItem.title}</span>
|
|
265
270
|
{subItem.badge && (
|
|
266
|
-
<span className="ml-auto">
|
|
271
|
+
<span className="ml-auto shrink-0">
|
|
267
272
|
<subItem.badge />
|
|
268
273
|
</span>
|
|
269
274
|
)}
|
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
compareMoney,
|
|
4
|
-
isDecimalString,
|
|
5
|
-
type PreferencePropertySchema,
|
|
6
|
-
type PreferenceSchema,
|
|
7
|
-
} from '@spree/admin-sdk'
|
|
1
|
+
import type { PreferencePropertySchema, PreferenceSchema } from '@spree/admin-sdk'
|
|
2
|
+
import { type CurrencyCode, compareMoney, currencyParts, isDecimalString } from '@spree/core'
|
|
8
3
|
import {
|
|
9
4
|
Button,
|
|
10
5
|
Field,
|
|
@@ -29,7 +24,6 @@ import { PlusIcon, TrashIcon } from '@spree/dashboard-ui/icons'
|
|
|
29
24
|
import { useId, useMemo, useState } from 'react'
|
|
30
25
|
import { useTranslation } from 'react-i18next'
|
|
31
26
|
import { useMoneyLocale } from '../hooks/use-money-locale'
|
|
32
|
-
import { currencyParts } from '../lib/currency-parts'
|
|
33
27
|
import { CountryCombobox, CountryMultiCombobox } from './country-combobox'
|
|
34
28
|
import { CurrencySelect } from './currency-select'
|
|
35
29
|
import { StoreDatePicker } from './store-date-picker'
|
|
@@ -358,8 +358,15 @@ function SettingsItem({
|
|
|
358
358
|
<SidebarMenuButton asChild isActive={isActive}>
|
|
359
359
|
<Link to={url} tabIndex={tabIndex}>
|
|
360
360
|
<NavIcon icon={Icon} isActive={isActive} />
|
|
361
|
-
|
|
361
|
+
{/* `truncate` so a long translated label gives way to the badge
|
|
362
|
+
rather than pushing it out of the sidebar. */}
|
|
363
|
+
<span className="truncate">{label}</span>
|
|
362
364
|
{entry.comingSoon && <SidebarMenuBadge className="ms-auto">Soon</SidebarMenuBadge>}
|
|
365
|
+
{entry.badge && (
|
|
366
|
+
<span className="ms-auto shrink-0">
|
|
367
|
+
<entry.badge />
|
|
368
|
+
</span>
|
|
369
|
+
)}
|
|
363
370
|
</Link>
|
|
364
371
|
</SidebarMenuButton>
|
|
365
372
|
</SidebarMenuItem>
|
package/src/components/slot.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Fragment } from 'react'
|
|
2
|
+
import { FormProvider, type UseFormReturn } from 'react-hook-form'
|
|
2
3
|
import { type SlotAmbientContext, useSlotEntries } from '../lib/slot-registry'
|
|
3
4
|
|
|
4
|
-
interface SlotProps<TContext> {
|
|
5
|
+
export interface SlotProps<TContext> {
|
|
5
6
|
/** Slot name, e.g. "product.form_sidebar" or "order.dropdown". */
|
|
6
7
|
name: string
|
|
7
8
|
/** Slot-specific context passed to every registered component. */
|
|
@@ -48,3 +49,22 @@ export function Slot<TContext extends object = object>({
|
|
|
48
49
|
</>
|
|
49
50
|
)
|
|
50
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A slot inside a form that isn't wrapped in `FormProvider` — a create or edit
|
|
55
|
+
* sheet. Widgets rendered here reach the form with `useHostForm()`, so fields
|
|
56
|
+
* they bind save with the sheet's own submit.
|
|
57
|
+
*/
|
|
58
|
+
export function HostFormSlot<TContext extends object = object>({
|
|
59
|
+
form,
|
|
60
|
+
...slot
|
|
61
|
+
}: SlotProps<TContext> & {
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
63
|
+
form: UseFormReturn<any, any, any>
|
|
64
|
+
}) {
|
|
65
|
+
return (
|
|
66
|
+
<FormProvider {...form}>
|
|
67
|
+
<Slot {...slot} />
|
|
68
|
+
</FormProvider>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -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,11 +1216,29 @@ 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
|
-
|
|
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
|
+
])
|
|
1179
1242
|
|
|
1180
1243
|
// Values already selected when the list opens ride at the top, so an active
|
|
1181
1244
|
// filter is visible without scrolling. The set is frozen at mount: ticking a
|
|
@@ -1191,7 +1254,7 @@ function InlineValueList({
|
|
|
1191
1254
|
return [...chosen, ...rest]
|
|
1192
1255
|
}, [options, pinned])
|
|
1193
1256
|
|
|
1194
|
-
if (!
|
|
1257
|
+
if (!isStoreCode && (tags.isLoading || resources.isLoading || all.isLoading)) {
|
|
1195
1258
|
return (
|
|
1196
1259
|
<p className="px-2 py-6 text-center text-muted-foreground">{t('admin.common.loading')}</p>
|
|
1197
1260
|
)
|
|
@@ -1274,7 +1337,7 @@ function FilterPanel({
|
|
|
1274
1337
|
// Types whose values are chosen from a list rather than typed. `multi` ones
|
|
1275
1338
|
// accumulate a set and need an explicit Apply; the single ones commit on the
|
|
1276
1339
|
// click that picks them.
|
|
1277
|
-
const multi = type === 'tags' || type === 'resource' || type === 'currency'
|
|
1340
|
+
const multi = type === 'tags' || type === 'resource' || type === 'currency' || type === 'locale'
|
|
1278
1341
|
// Dates are their own shape: a list of relative presets, with the calendar
|
|
1279
1342
|
// behind "custom". Same presets the quick filter offers, so a range means
|
|
1280
1343
|
// the same thing wherever it was set.
|
|
@@ -1653,7 +1716,7 @@ function FilterPanel({
|
|
|
1653
1716
|
</Select>
|
|
1654
1717
|
) : null}
|
|
1655
1718
|
|
|
1656
|
-
{/* Everything picked from a list — currency, tags, resources — or
|
|
1719
|
+
{/* Everything picked from a list — currency, locale, tags, resources — or
|
|
1657
1720
|
from a calendar is handled above; this branch is only the types
|
|
1658
1721
|
you type into. */}
|
|
1659
1722
|
{!noValueOperators.includes(operator) && listOptions.length === 0 && (
|