@spree/dashboard-core 1.0.0-rc.3 → 1.0.0-rc.4
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 +13 -14
- package/src/components/currency-select.tsx +2 -3
- package/src/components/import-wizard-dialog.tsx +8 -11
- package/src/components/preferences-form.tsx +16 -2
- package/src/components/resource-combobox.tsx +5 -0
- package/src/components/resource-picker-sheet.tsx +18 -12
- package/src/components/table-toolbar.tsx +8 -5
- package/src/lib/currencies.ts +31 -15
- package/src/lib/iso-codes.ts +5 -3
- package/src/lib/permissions.ts +2 -0
- package/src/lib/settings-nav-registry.test.ts +34 -0
- package/src/lib/settings-nav-registry.ts +21 -4
- package/src/locales/ar.json +3 -1
- package/src/locales/de.json +3 -1
- package/src/locales/en.json +3 -1
- package/src/locales/es.json +3 -1
- package/src/locales/fr.json +3 -1
- package/src/locales/pl.json +3 -1
- package/src/locales/zh-CN.json +3 -1
- package/src/products/bulk-variants-dialog.tsx +1 -7
- package/src/products/inventory-section.tsx +2 -2
- package/src/products/variants-options-builder.tsx +30 -27
- package/src/products/variants-section.tsx +31 -25
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.4",
|
|
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",
|
|
@@ -49,13 +49,10 @@
|
|
|
49
49
|
"LICENSE"
|
|
50
50
|
],
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@
|
|
53
|
-
"@spree/admin-sdk": ">=0.7.0 <1.0.0-0 || >=1.0.0-0 <2.0.0-0",
|
|
52
|
+
"@spree/admin-sdk": ">=1.0.0-rc.4 <2.0.0-0",
|
|
54
53
|
"@spree/dashboard-ui": ">=0.10.0 <1.0.0-0 || >=1.0.0-0 <2.0.0-0",
|
|
55
|
-
"@tanstack/react-hotkeys": "^0.10.0",
|
|
56
54
|
"@tanstack/react-query": "^5.100.9",
|
|
57
55
|
"@tanstack/react-router": "^1.169.2",
|
|
58
|
-
"@tanstack/react-table": "^8.21.3",
|
|
59
56
|
"i18next": "^26.2.0",
|
|
60
57
|
"react": "^19.2.6",
|
|
61
58
|
"react-dom": "^19.2.6",
|
|
@@ -66,21 +63,23 @@
|
|
|
66
63
|
"@dnd-kit/core": "^6.3.1",
|
|
67
64
|
"@dnd-kit/sortable": "^10.0.0",
|
|
68
65
|
"@dnd-kit/utilities": "^3.2.2",
|
|
66
|
+
"@hookform/resolvers": "^5.2.2",
|
|
69
67
|
"@tailwindcss/vite": "^4.2.4",
|
|
70
|
-
"
|
|
68
|
+
"@tanstack/react-hotkeys": "^0.14.3",
|
|
69
|
+
"date-fns": "^4.4.0",
|
|
71
70
|
"date-fns-tz": "^3.2.0",
|
|
72
|
-
"lucide-react": "^1.
|
|
73
|
-
"zod": "^4.
|
|
71
|
+
"lucide-react": "^1.58.0",
|
|
72
|
+
"zod": "^4.6.7"
|
|
74
73
|
},
|
|
75
74
|
"devDependencies": {
|
|
76
75
|
"@types/react": "^19.2.14",
|
|
77
76
|
"@types/react-dom": "^19.2.3",
|
|
78
|
-
"tsup": "^
|
|
79
|
-
"typescript": "~
|
|
80
|
-
"vite": "^8.
|
|
81
|
-
"vitest": "^
|
|
82
|
-
"@spree/
|
|
83
|
-
"@spree/
|
|
77
|
+
"tsup": "^9.0.0",
|
|
78
|
+
"typescript": "~7.0.2",
|
|
79
|
+
"vite": "^8.3.4",
|
|
80
|
+
"vitest": "^5.0.5",
|
|
81
|
+
"@spree/admin-sdk": "^1.0.0-rc.4",
|
|
82
|
+
"@spree/dashboard-ui": "^1.0.0-rc.4"
|
|
84
83
|
},
|
|
85
84
|
"scripts": {
|
|
86
85
|
"lint": "biome check src",
|
|
@@ -103,9 +103,8 @@ export function CurrencySelect({
|
|
|
103
103
|
|
|
104
104
|
// Union of the option list and the current value so editing a record whose
|
|
105
105
|
// currency isn't in the list (a store-supported list that later narrowed)
|
|
106
|
-
// never silently drops the selection. An empty `options`
|
|
107
|
-
//
|
|
108
|
-
// falls back to the store's currencies rather than an empty picker.
|
|
106
|
+
// never silently drops the selection. An empty `options` falls back to the
|
|
107
|
+
// store's currencies rather than an empty picker.
|
|
109
108
|
const items = useMemo(() => {
|
|
110
109
|
const base = options?.length ? options : currencies
|
|
111
110
|
return isCurrencyCode(value) && !base.includes(value) ? [value, ...base] : base
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
+
Alert,
|
|
3
|
+
AlertDescription,
|
|
2
4
|
Badge,
|
|
3
5
|
Button,
|
|
4
6
|
Card,
|
|
@@ -33,6 +35,7 @@ import {
|
|
|
33
35
|
CheckCircle2Icon,
|
|
34
36
|
ChevronLeftIcon,
|
|
35
37
|
ChevronRightIcon,
|
|
38
|
+
CircleAlertIcon,
|
|
36
39
|
DownloadIcon,
|
|
37
40
|
RotateCcwIcon,
|
|
38
41
|
XIcon,
|
|
@@ -107,13 +110,7 @@ export function ImportWizardDialog({ importId, onClose, onViewRecords }: ImportW
|
|
|
107
110
|
}}
|
|
108
111
|
modal
|
|
109
112
|
>
|
|
110
|
-
<DialogContent
|
|
111
|
-
// Edge-to-edge minus a 3-unit gutter — see BulkPriceEditorDialog for
|
|
112
|
-
// why every inset/translate/max is overridden.
|
|
113
|
-
className="!inset-3 !w-auto !max-w-none !translate-x-0 !translate-y-0 flex flex-col p-0"
|
|
114
|
-
style={{ maxHeight: 'none' }}
|
|
115
|
-
showCloseButton={false}
|
|
116
|
-
>
|
|
113
|
+
<DialogContent fullScreen showCloseButton={false}>
|
|
117
114
|
{importId && (
|
|
118
115
|
<ImportWizard
|
|
119
116
|
importId={importId}
|
|
@@ -472,10 +469,10 @@ function MappingStep({
|
|
|
472
469
|
</div>
|
|
473
470
|
|
|
474
471
|
{error && (
|
|
475
|
-
<
|
|
476
|
-
<
|
|
477
|
-
<
|
|
478
|
-
</
|
|
472
|
+
<Alert variant="destructive">
|
|
473
|
+
<CircleAlertIcon />
|
|
474
|
+
<AlertDescription>{error.message}</AlertDescription>
|
|
475
|
+
</Alert>
|
|
479
476
|
)}
|
|
480
477
|
</CardContent>
|
|
481
478
|
</Card>
|
|
@@ -46,6 +46,8 @@ export interface PreferenceFieldDef {
|
|
|
46
46
|
format?: string
|
|
47
47
|
default: unknown
|
|
48
48
|
choices?: string[]
|
|
49
|
+
/** Labels the declaration gave its choices, for values that cannot name themselves (an endpoint URL). */
|
|
50
|
+
choiceLabels?: Record<string, string>
|
|
49
51
|
/** Whether the setting may be left empty (it reads back as null). */
|
|
50
52
|
nullable: boolean
|
|
51
53
|
/** The property's own JSON Schema, for widgets that need more (list items, object properties). */
|
|
@@ -101,6 +103,7 @@ export function preferenceFields(
|
|
|
101
103
|
format: property.format ?? property.items?.format,
|
|
102
104
|
default: property.default ?? null,
|
|
103
105
|
choices: property.enum?.filter((choice): choice is string => typeof choice === 'string'),
|
|
106
|
+
choiceLabels: property['x-spree-choice-labels'],
|
|
104
107
|
nullable: Array.isArray(property.type) && property.type.includes('null'),
|
|
105
108
|
schema: property,
|
|
106
109
|
}))
|
|
@@ -432,12 +435,23 @@ export function PreferenceField({
|
|
|
432
435
|
// the set is what the server validates against, so typing anything
|
|
433
436
|
// else can only fail on save.
|
|
434
437
|
if (field.choices?.length) {
|
|
435
|
-
|
|
438
|
+
// A translation wins where one exists, so the locale files stay in
|
|
439
|
+
// charge of the values that read as words; then the label the
|
|
440
|
+
// declaration supplied, for the ones that do not — a URL cannot serve
|
|
441
|
+
// as its own i18n key, let alone as a label.
|
|
442
|
+
const declared = field.choices.map((choice) => ({
|
|
436
443
|
value: choice,
|
|
437
444
|
label: i18n.exists(`${preferenceKey}_options.${choice}`)
|
|
438
445
|
? t(`${preferenceKey}_options.${choice}`)
|
|
439
|
-
: humanizeKey(choice),
|
|
446
|
+
: (field.choiceLabels?.[choice] ?? humanizeKey(choice)),
|
|
440
447
|
}))
|
|
448
|
+
// A stored value outside the set is offered as its own option rather
|
|
449
|
+
// than dropped, so one written past the picker by an API client is not
|
|
450
|
+
// silently rewritten to the first choice.
|
|
451
|
+
const options =
|
|
452
|
+
typeof value === 'string' && value !== '' && !declared.some((o) => o.value === value)
|
|
453
|
+
? [...declared, { value, label: value }]
|
|
454
|
+
: declared
|
|
441
455
|
|
|
442
456
|
return (
|
|
443
457
|
<Field>
|
|
@@ -109,6 +109,11 @@ export function ResourceCombobox<T extends ComboboxOption>({
|
|
|
109
109
|
// elsewhere (a customer created on another page) never invalidates it.
|
|
110
110
|
// Every search refetches; cached rows for a repeated term still paint first.
|
|
111
111
|
staleTime: 0,
|
|
112
|
+
// Keeps the last results on screen while the next query loads, so the
|
|
113
|
+
// list does not blank on every keystroke. Only within one tenant: the
|
|
114
|
+
// second argument is the query those results came from.
|
|
115
|
+
placeholderData: (previous, previousQuery) =>
|
|
116
|
+
previousQuery?.queryKey[1] === tenantId ? previous : undefined,
|
|
112
117
|
})
|
|
113
118
|
|
|
114
119
|
// Hydrate the currently selected ID into a record so the trigger shows
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Button,
|
|
3
3
|
Checkbox,
|
|
4
|
+
Item,
|
|
5
|
+
ItemActions,
|
|
6
|
+
ItemContent,
|
|
7
|
+
ItemDescription,
|
|
8
|
+
ItemTitle,
|
|
4
9
|
type PaginationMeta,
|
|
5
10
|
SearchInput,
|
|
6
11
|
Sheet,
|
|
@@ -308,11 +313,10 @@ export function ResourcePickerSheet<T extends PickerOption>({
|
|
|
308
313
|
const subtitle = getOptionSubtitle?.(option)
|
|
309
314
|
return (
|
|
310
315
|
<li key={option.id}>
|
|
311
|
-
<
|
|
312
|
-
type="button"
|
|
313
|
-
disabled={inList}
|
|
316
|
+
<Item
|
|
317
|
+
render={<button type="button" disabled={inList} />}
|
|
314
318
|
onClick={() => toggle(option)}
|
|
315
|
-
className="flex
|
|
319
|
+
className="flex-nowrap gap-3 rounded-none px-4 py-2.5 text-left hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
|
|
316
320
|
>
|
|
317
321
|
<Checkbox
|
|
318
322
|
checked={checked}
|
|
@@ -320,20 +324,22 @@ export function ResourcePickerSheet<T extends PickerOption>({
|
|
|
320
324
|
className="pointer-events-none"
|
|
321
325
|
/>
|
|
322
326
|
{getOptionImageUrl && <Thumbnail src={imageUrl} size="sm" />}
|
|
323
|
-
<
|
|
324
|
-
<
|
|
327
|
+
<ItemContent className="min-w-0 gap-0">
|
|
328
|
+
<ItemTitle className="block truncate font-normal">
|
|
329
|
+
{getOptionLabel(option)}
|
|
330
|
+
</ItemTitle>
|
|
325
331
|
{subtitle && (
|
|
326
|
-
<
|
|
332
|
+
<ItemDescription className="line-clamp-1 text-xs">
|
|
327
333
|
{subtitle}
|
|
328
|
-
</
|
|
334
|
+
</ItemDescription>
|
|
329
335
|
)}
|
|
330
|
-
</
|
|
336
|
+
</ItemContent>
|
|
331
337
|
{inList && (
|
|
332
|
-
<
|
|
338
|
+
<ItemActions className="shrink-0 text-muted-foreground text-xs">
|
|
333
339
|
{t('admin.resource_picker.already_added')}
|
|
334
|
-
</
|
|
340
|
+
</ItemActions>
|
|
335
341
|
)}
|
|
336
|
-
</
|
|
342
|
+
</Item>
|
|
337
343
|
</li>
|
|
338
344
|
)
|
|
339
345
|
})}
|
|
@@ -1177,17 +1177,19 @@ function InlineValueList({
|
|
|
1177
1177
|
}))
|
|
1178
1178
|
}, [isCurrency, currencies, isTags, tags.data, resources.data, column.filterResource, query])
|
|
1179
1179
|
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1180
|
+
// Values already selected when the list opens ride at the top, so an active
|
|
1181
|
+
// filter is visible without scrolling. The set is frozen at mount: ticking a
|
|
1182
|
+
// box must not move rows under the pointer.
|
|
1183
|
+
const [pinned] = useState(() => selected)
|
|
1182
1184
|
const ordered = useMemo(() => {
|
|
1183
|
-
const chosen = options.filter((option) =>
|
|
1184
|
-
const rest = options.filter((option) => !
|
|
1185
|
+
const chosen = options.filter((option) => pinned.includes(option.value))
|
|
1186
|
+
const rest = options.filter((option) => !pinned.includes(option.value))
|
|
1185
1187
|
// Not truncated: a list cut at ten looks like a list of ten, and a
|
|
1186
1188
|
// vocabulary of forty tags would silently lose thirty with nothing on
|
|
1187
1189
|
// screen to say so. The container scrolls instead, so the scrollbar
|
|
1188
1190
|
// reports how much more there is — and searching narrows it.
|
|
1189
1191
|
return [...chosen, ...rest]
|
|
1190
|
-
}, [options,
|
|
1192
|
+
}, [options, pinned])
|
|
1191
1193
|
|
|
1192
1194
|
if (!isCurrency && (tags.isLoading || resources.isLoading)) {
|
|
1193
1195
|
return (
|
|
@@ -1507,6 +1509,7 @@ function FilterPanel({
|
|
|
1507
1509
|
behind the panel is already showing the result. */}
|
|
1508
1510
|
{field && multi && column && (
|
|
1509
1511
|
<InlineValueList
|
|
1512
|
+
key={column.key}
|
|
1510
1513
|
column={column}
|
|
1511
1514
|
tenantId={tenantId}
|
|
1512
1515
|
query={query}
|
package/src/lib/currencies.ts
CHANGED
|
@@ -1,18 +1,34 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { CurrencyCode } from '@spree/admin-sdk'
|
|
2
|
+
|
|
3
|
+
// Replaced currencies Spree still accepts so orders priced in them stay
|
|
4
|
+
// valid, but which no merchant should pick for new pricing.
|
|
5
|
+
const RETIRED_CURRENCY_CODES: ReadonlySet<string> = new Set([
|
|
6
|
+
'BYR',
|
|
7
|
+
'CUC',
|
|
8
|
+
'EEK',
|
|
9
|
+
'HRK',
|
|
10
|
+
'LTL',
|
|
11
|
+
'LVL',
|
|
12
|
+
'MRO',
|
|
13
|
+
'MTL',
|
|
14
|
+
'SKK',
|
|
15
|
+
'SLL',
|
|
16
|
+
'STD',
|
|
17
|
+
'TMM',
|
|
18
|
+
'VEF',
|
|
19
|
+
'ZMK',
|
|
20
|
+
'ZWD',
|
|
21
|
+
'ZWL',
|
|
22
|
+
'ZWN',
|
|
23
|
+
'ZWR',
|
|
24
|
+
])
|
|
3
25
|
|
|
4
26
|
/**
|
|
5
|
-
* Every
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* runtimes without `Intl.supportedValuesOf`, so callers fall back to
|
|
10
|
-
* `supported_currencies`.
|
|
27
|
+
* Every currency Spree accepts that is still in use. Used for contexts where
|
|
28
|
+
* the merchant can pick *any* currency (a market's currency) rather than one
|
|
29
|
+
* the store already supports. The list ships with the SDK, so a currency a
|
|
30
|
+
* newer server adds appears once the dashboard is upgraded with it.
|
|
11
31
|
*/
|
|
12
|
-
export const ALL_CURRENCY_CODES: CurrencyCode[] = ((
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
} catch {
|
|
16
|
-
return []
|
|
17
|
-
}
|
|
18
|
-
})()
|
|
32
|
+
export const ALL_CURRENCY_CODES: readonly CurrencyCode[] = Object.values(CurrencyCode).filter(
|
|
33
|
+
(code) => !RETIRED_CURRENCY_CODES.has(code),
|
|
34
|
+
)
|
package/src/lib/iso-codes.ts
CHANGED
|
@@ -5,9 +5,11 @@ import { z } from 'zod/v4'
|
|
|
5
5
|
/**
|
|
6
6
|
* Whether a value has the shape of an ISO 3166-1 alpha-2 country code.
|
|
7
7
|
*
|
|
8
|
-
* Shape only: the server holds the authoritative list and rejects
|
|
9
|
-
* does not know,
|
|
10
|
-
*
|
|
8
|
+
* Shape only: the connected server holds the authoritative list and rejects
|
|
9
|
+
* a code it does not know, while the SDK's `CountryCode` list is fixed when it
|
|
10
|
+
* is built. Checking against that copy would hide or reject a code an older or
|
|
11
|
+
* newer server accepts. This is the one place a plain string becomes a
|
|
12
|
+
* `CountryCode`.
|
|
11
13
|
*/
|
|
12
14
|
export function isCountryCode(value: unknown): value is CountryCode {
|
|
13
15
|
return typeof value === 'string' && /^[A-Z]{2}$/.test(value)
|
package/src/lib/permissions.ts
CHANGED
|
@@ -20,6 +20,7 @@ export const Subject = {
|
|
|
20
20
|
CustomerGroup: 'customer_group',
|
|
21
21
|
AdminUser: 'admin_user',
|
|
22
22
|
ApiKey: 'api_key',
|
|
23
|
+
OauthApplication: 'oauth_application',
|
|
23
24
|
AllowedOrigin: 'allowed_origin',
|
|
24
25
|
Store: 'store',
|
|
25
26
|
// The store's packaging vocabulary: the box it ships parcels in, and the
|
|
@@ -45,6 +46,7 @@ export const Subject = {
|
|
|
45
46
|
ClaimReason: 'claim_reason',
|
|
46
47
|
RefundReason: 'refund_reason',
|
|
47
48
|
OrderCancellationReason: 'order_cancellation_reason',
|
|
49
|
+
TaxExemptionReason: 'tax_exemption_reason',
|
|
48
50
|
CustomFieldDefinition: 'custom_field_definition',
|
|
49
51
|
Integration: 'integration',
|
|
50
52
|
PaymentMethod: 'payment_method',
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { canSeeSettingsEntry, type SettingsNavEntry } from './settings-nav-registry'
|
|
3
|
+
|
|
4
|
+
const entry = (overrides: Partial<SettingsNavEntry>): SettingsNavEntry => ({
|
|
5
|
+
key: 'settings.example',
|
|
6
|
+
label: 'Example',
|
|
7
|
+
path: '/example',
|
|
8
|
+
group: 'store',
|
|
9
|
+
...overrides,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const allowing =
|
|
13
|
+
(...subjects: string[]) =>
|
|
14
|
+
(_action: string, subject: string) =>
|
|
15
|
+
subjects.includes(subject)
|
|
16
|
+
|
|
17
|
+
describe('canSeeSettingsEntry', () => {
|
|
18
|
+
it('shows an entry without a subject to everyone', () => {
|
|
19
|
+
expect(canSeeSettingsEntry(entry({}), allowing())).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('checks a single subject', () => {
|
|
23
|
+
expect(canSeeSettingsEntry(entry({ subject: 'tax_rate' }), allowing('tax_rate'))).toBe(true)
|
|
24
|
+
expect(canSeeSettingsEntry(entry({ subject: 'tax_rate' }), allowing())).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// A page of tabs is reachable through any one of them.
|
|
28
|
+
it('shows an entry with several subjects when any one is allowed', () => {
|
|
29
|
+
const taxes = entry({ subjects: ['tax_category', 'tax_rate', 'tax_exemption_reason'] })
|
|
30
|
+
|
|
31
|
+
expect(canSeeSettingsEntry(taxes, allowing('tax_exemption_reason'))).toBe(true)
|
|
32
|
+
expect(canSeeSettingsEntry(taxes, allowing('product'))).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -60,6 +60,12 @@ export interface SettingsNavEntry {
|
|
|
60
60
|
position?: number
|
|
61
61
|
/** CanCanCan subject required to see this item. Omit for always-visible. */
|
|
62
62
|
subject?: SubjectName
|
|
63
|
+
/**
|
|
64
|
+
* For a page holding several resources, as tabs: the entry is shown when
|
|
65
|
+
* `action` is allowed on any of these, so a role reaching one tab still
|
|
66
|
+
* finds the page. Takes precedence over `subject`.
|
|
67
|
+
*/
|
|
68
|
+
subjects?: SubjectName[]
|
|
63
69
|
/**
|
|
64
70
|
* Action checked against `subject`. Defaults to `'read'`. Settings pages
|
|
65
71
|
* that only exist to *change* something (store settings, emails) should
|
|
@@ -220,6 +226,19 @@ export function settingsEntryMatches(
|
|
|
220
226
|
return haystack.some((value) => value?.toLowerCase().includes(needle))
|
|
221
227
|
}
|
|
222
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Whether a staff member may reach this settings entry. The sidebar, the
|
|
231
|
+
* settings landing page and the command palette all ask this, so they agree.
|
|
232
|
+
*/
|
|
233
|
+
export function canSeeSettingsEntry(
|
|
234
|
+
entry: SettingsNavEntry,
|
|
235
|
+
can: (action: string, subject: string) => boolean,
|
|
236
|
+
): boolean {
|
|
237
|
+
const action = entry.action ?? 'read'
|
|
238
|
+
if (entry.subjects?.length) return entry.subjects.some((subject) => can(action, subject))
|
|
239
|
+
return !entry.subject || can(action, entry.subject)
|
|
240
|
+
}
|
|
241
|
+
|
|
223
242
|
/**
|
|
224
243
|
* Drop entries the current staff member cannot reach. Exported because the
|
|
225
244
|
* sidebar and the settings landing page must agree on what a role can see —
|
|
@@ -231,7 +250,7 @@ export function filterSettingsByPermissions(
|
|
|
231
250
|
permissions: { can: (action: string, subject: string) => boolean },
|
|
232
251
|
): SettingsNavSnapshot {
|
|
233
252
|
return regroup(snapshot, (entry) =>
|
|
234
|
-
|
|
253
|
+
canSeeSettingsEntry(entry, (action, subject) => permissions.can(action, subject)),
|
|
235
254
|
)
|
|
236
255
|
}
|
|
237
256
|
|
|
@@ -265,9 +284,7 @@ export function hasVisibleSettingsEntries(permissions: unknown): boolean {
|
|
|
265
284
|
?.can
|
|
266
285
|
if (typeof can !== 'function') return false
|
|
267
286
|
|
|
268
|
-
return getSnapshot().all.some(
|
|
269
|
-
(entry) => !entry.subject || can(entry.action ?? 'read', entry.subject),
|
|
270
|
-
)
|
|
287
|
+
return getSnapshot().all.some((entry) => canSeeSettingsEntry(entry, can))
|
|
271
288
|
}
|
|
272
289
|
|
|
273
290
|
/** Test-only: clear the registry. */
|
package/src/locales/ar.json
CHANGED
package/src/locales/de.json
CHANGED
|
@@ -183,7 +183,9 @@
|
|
|
183
183
|
"searching": "Wird gesucht…",
|
|
184
184
|
"select_placeholder": "Auswählen…",
|
|
185
185
|
"unsaved_changes": "Sie haben ungespeicherte Änderungen.",
|
|
186
|
-
"yes": "Ja"
|
|
186
|
+
"yes": "Ja",
|
|
187
|
+
"done": "Fertig",
|
|
188
|
+
"error": "Etwas ist schiefgelaufen."
|
|
187
189
|
},
|
|
188
190
|
"components": {
|
|
189
191
|
"address_block": {
|
package/src/locales/en.json
CHANGED
|
@@ -183,7 +183,9 @@
|
|
|
183
183
|
"searching": "Searching…",
|
|
184
184
|
"select_placeholder": "Select…",
|
|
185
185
|
"unsaved_changes": "You have unsaved changes.",
|
|
186
|
-
"yes": "Yes"
|
|
186
|
+
"yes": "Yes",
|
|
187
|
+
"done": "Done",
|
|
188
|
+
"error": "Something went wrong."
|
|
187
189
|
},
|
|
188
190
|
"components": {
|
|
189
191
|
"address_block": {
|
package/src/locales/es.json
CHANGED
|
@@ -1665,7 +1665,9 @@
|
|
|
1665
1665
|
"searching": "Buscando…",
|
|
1666
1666
|
"select_placeholder": "Seleccionar…",
|
|
1667
1667
|
"unsaved_changes": "Tenés cambios sin guardar.",
|
|
1668
|
-
"yes": "Sí"
|
|
1668
|
+
"yes": "Sí",
|
|
1669
|
+
"done": "Listo",
|
|
1670
|
+
"error": "Algo salió mal."
|
|
1669
1671
|
},
|
|
1670
1672
|
"messages": {
|
|
1671
1673
|
"address_removed": "Dirección eliminada",
|
package/src/locales/fr.json
CHANGED
|
@@ -183,7 +183,9 @@
|
|
|
183
183
|
"searching": "Recherche…",
|
|
184
184
|
"select_placeholder": "Sélectionner…",
|
|
185
185
|
"unsaved_changes": "Vous avez des modifications non enregistrées.",
|
|
186
|
-
"yes": "Oui"
|
|
186
|
+
"yes": "Oui",
|
|
187
|
+
"done": "Terminé",
|
|
188
|
+
"error": "Une erreur s'est produite."
|
|
187
189
|
},
|
|
188
190
|
"components": {
|
|
189
191
|
"address_block": {
|
package/src/locales/pl.json
CHANGED
|
@@ -184,7 +184,9 @@
|
|
|
184
184
|
"searching": "Wyszukiwanie…",
|
|
185
185
|
"select_placeholder": "Wybierz…",
|
|
186
186
|
"unsaved_changes": "Masz niezapisane zmiany.",
|
|
187
|
-
"yes": "Tak"
|
|
187
|
+
"yes": "Tak",
|
|
188
|
+
"done": "Gotowe",
|
|
189
|
+
"error": "Coś poszło nie tak."
|
|
188
190
|
},
|
|
189
191
|
"components": {
|
|
190
192
|
"address_block": {
|
package/src/locales/zh-CN.json
CHANGED
|
@@ -215,13 +215,7 @@ export function BulkVariantsDialog({ form, open, onOpenChange }: Props) {
|
|
|
215
215
|
|
|
216
216
|
return (
|
|
217
217
|
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : handleCancel())} modal>
|
|
218
|
-
<DialogContent
|
|
219
|
-
// Edge-to-edge minus a 3-unit gutter, same overrides as the bulk
|
|
220
|
-
// price dialog (see BulkPriceEditorDialog for why each is needed).
|
|
221
|
-
className="!inset-3 !w-auto !max-w-none !translate-x-0 !translate-y-0 flex flex-col p-0"
|
|
222
|
-
style={{ maxHeight: 'none' }}
|
|
223
|
-
showCloseButton={false}
|
|
224
|
-
>
|
|
218
|
+
<DialogContent fullScreen showCloseButton={false}>
|
|
225
219
|
<DialogHeader className="flex flex-row items-center justify-between gap-3 space-y-0 border-b p-3">
|
|
226
220
|
<DialogTitle className="truncate">
|
|
227
221
|
{t('admin.products.variants.bulk_edit.title')}
|
|
@@ -2,13 +2,13 @@ import type { ProductFormValues, StockLevelFormValues } from '@spree/dashboard-c
|
|
|
2
2
|
import { useStockLocations } from '@spree/dashboard-core'
|
|
3
3
|
import {
|
|
4
4
|
DataGrid,
|
|
5
|
+
type DataGridColumn,
|
|
5
6
|
editableRowIndex,
|
|
6
7
|
NumberCell,
|
|
7
8
|
ReadOnlyCell,
|
|
8
9
|
SwitchCell,
|
|
9
10
|
} from '@spree/dashboard-ui'
|
|
10
11
|
import { ExternalLinkIcon } from '@spree/dashboard-ui/icons'
|
|
11
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
12
12
|
import { useCallback, useMemo } from 'react'
|
|
13
13
|
import { type UseFormReturn, useWatch } from 'react-hook-form'
|
|
14
14
|
import { useTranslation } from 'react-i18next'
|
|
@@ -135,7 +135,7 @@ export function InventorySection({ form, stockLocationHref }: InventorySectionPr
|
|
|
135
135
|
[form],
|
|
136
136
|
)
|
|
137
137
|
|
|
138
|
-
const columns = useMemo<
|
|
138
|
+
const columns = useMemo<DataGridColumn<InventoryRow>[]>(
|
|
139
139
|
() => [
|
|
140
140
|
{
|
|
141
141
|
id: 'location',
|
|
@@ -4,6 +4,10 @@ import {
|
|
|
4
4
|
Field,
|
|
5
5
|
FieldLabel,
|
|
6
6
|
Input,
|
|
7
|
+
Item,
|
|
8
|
+
ItemActions,
|
|
9
|
+
ItemContent,
|
|
10
|
+
ItemTitle,
|
|
7
11
|
Select,
|
|
8
12
|
SelectContent,
|
|
9
13
|
SelectItem,
|
|
@@ -130,39 +134,38 @@ export function VariantsOptionsBuilder({ selected, onChange, extraOptionTypes }:
|
|
|
130
134
|
)
|
|
131
135
|
}
|
|
132
136
|
return (
|
|
133
|
-
<li
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
<div className="min-w-0 flex-1">
|
|
138
|
-
<div className="text-sm font-medium">{ot.label}</div>
|
|
139
|
-
<div className="mt-1 flex flex-wrap gap-1">
|
|
137
|
+
<Item key={ot.id} variant="outline" render={<li />} className="border-border-subtle">
|
|
138
|
+
<ItemContent className="min-w-0">
|
|
139
|
+
<ItemTitle>{ot.label}</ItemTitle>
|
|
140
|
+
<div className="flex flex-wrap gap-1">
|
|
140
141
|
{ot.values.map((v) => (
|
|
141
142
|
<Badge key={v.name} variant="secondary">
|
|
142
143
|
{v.label ?? v.name}
|
|
143
144
|
</Badge>
|
|
144
145
|
))}
|
|
145
146
|
</div>
|
|
146
|
-
</
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
147
|
+
</ItemContent>
|
|
148
|
+
<ItemActions>
|
|
149
|
+
<Button
|
|
150
|
+
type="button"
|
|
151
|
+
variant="ghost"
|
|
152
|
+
size="icon-sm"
|
|
153
|
+
aria-label={t('admin.actions.edit')}
|
|
154
|
+
onClick={() => setEditingIndex(i)}
|
|
155
|
+
>
|
|
156
|
+
<PencilIcon />
|
|
157
|
+
</Button>
|
|
158
|
+
<Button
|
|
159
|
+
type="button"
|
|
160
|
+
variant="ghost"
|
|
161
|
+
size="icon-sm"
|
|
162
|
+
aria-label={t('admin.actions.remove')}
|
|
163
|
+
onClick={() => removeAt(i)}
|
|
164
|
+
>
|
|
165
|
+
<XIcon />
|
|
166
|
+
</Button>
|
|
167
|
+
</ItemActions>
|
|
168
|
+
</Item>
|
|
166
169
|
)
|
|
167
170
|
})}
|
|
168
171
|
</ul>
|
|
@@ -16,6 +16,9 @@ import {
|
|
|
16
16
|
import { CSS } from '@dnd-kit/utilities'
|
|
17
17
|
import type { ProductFormValues, VariantFormValues } from '@spree/dashboard-core'
|
|
18
18
|
import {
|
|
19
|
+
Alert,
|
|
20
|
+
AlertDescription,
|
|
21
|
+
AlertTitle,
|
|
19
22
|
Button,
|
|
20
23
|
Card,
|
|
21
24
|
CardContent,
|
|
@@ -31,7 +34,7 @@ import {
|
|
|
31
34
|
TableHeader,
|
|
32
35
|
TableRow,
|
|
33
36
|
} from '@spree/dashboard-ui'
|
|
34
|
-
import { PencilIcon, SwatchBookIcon, XIcon } from '@spree/dashboard-ui/icons'
|
|
37
|
+
import { PencilIcon, SwatchBookIcon, TriangleAlertIcon, XIcon } from '@spree/dashboard-ui/icons'
|
|
35
38
|
import { type CSSProperties, useEffect, useMemo, useState } from 'react'
|
|
36
39
|
import { type UseFormReturn, useFieldArray, useWatch } from 'react-hook-form'
|
|
37
40
|
import { useTranslation } from 'react-i18next'
|
|
@@ -361,31 +364,34 @@ export function VariantsSection({ form, seedFromType = false }: Props) {
|
|
|
361
364
|
/>
|
|
362
365
|
|
|
363
366
|
{orphanedKeys.length > 0 && (
|
|
364
|
-
<
|
|
365
|
-
<
|
|
367
|
+
<Alert variant="warning">
|
|
368
|
+
<TriangleAlertIcon />
|
|
369
|
+
<AlertTitle>
|
|
366
370
|
{t('admin.products.variants.orphan_warning', { count: orphanedKeys.length })}
|
|
367
|
-
</
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
<
|
|
377
|
-
{
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
371
|
+
</AlertTitle>
|
|
372
|
+
<AlertDescription>
|
|
373
|
+
{orphanedEntries.length > 0 && (
|
|
374
|
+
<ul className="list-disc pl-5">
|
|
375
|
+
{orphanedEntries.map((entry) => (
|
|
376
|
+
<li key={entry.key}>{entry.label}</li>
|
|
377
|
+
))}
|
|
378
|
+
</ul>
|
|
379
|
+
)}
|
|
380
|
+
<div className="mt-2 flex items-center justify-end gap-2">
|
|
381
|
+
<Button type="button" variant="ghost" size="sm" onClick={handleKeepOrphans}>
|
|
382
|
+
{t('admin.products.variants.keep')}
|
|
383
|
+
</Button>
|
|
384
|
+
<Button
|
|
385
|
+
type="button"
|
|
386
|
+
variant="destructive"
|
|
387
|
+
size="sm"
|
|
388
|
+
onClick={handleConfirmDropOrphans}
|
|
389
|
+
>
|
|
390
|
+
{t('admin.actions.remove')}
|
|
391
|
+
</Button>
|
|
392
|
+
</div>
|
|
393
|
+
</AlertDescription>
|
|
394
|
+
</Alert>
|
|
389
395
|
)}
|
|
390
396
|
|
|
391
397
|
{fields.length > 0 && !awaitingOptionValues && (
|