@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
package/src/lib/money.ts
CHANGED
|
@@ -1,99 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* writes the result without trailing zeros ("0.077" shifted by 2 is "7.7").
|
|
11
|
-
* Anything that is not a decimal string comes back unchanged.
|
|
12
|
-
*/
|
|
13
|
-
export function shiftDecimalPoint(value: string, places: number): string {
|
|
14
|
-
if (!isDecimalString(value)) return value
|
|
15
|
-
|
|
16
|
-
const negative = value.startsWith('-')
|
|
17
|
-
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.')
|
|
18
|
-
let digits = whole + fraction
|
|
19
|
-
let point = whole.length + places
|
|
20
|
-
if (point < 0) {
|
|
21
|
-
digits = '0'.repeat(-point) + digits
|
|
22
|
-
point = 0
|
|
23
|
-
}
|
|
24
|
-
digits = digits.padEnd(point, '0')
|
|
25
|
-
|
|
26
|
-
const integer = digits.slice(0, point).replace(/^0+(?=\d)/, '') || '0'
|
|
27
|
-
const decimals = digits.slice(point).replace(/0+$/, '')
|
|
28
|
-
const text = decimals ? `${integer}.${decimals}` : integer
|
|
29
|
-
return negative && /[1-9]/.test(text) ? `-${text}` : text
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** A fraction rate ("0.23") as the percentage a merchant reads ("23"). Blank stays blank. */
|
|
33
|
-
export function fractionToPercent(fraction: string | null | undefined): string {
|
|
34
|
-
return fraction == null || fraction === '' ? '' : shiftDecimalPoint(fraction, 2)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** A typed percentage ("23") as the fraction rate the API stores ("0.23"). Blank stays blank. */
|
|
38
|
-
export function percentToFraction(percent: string | null | undefined): string {
|
|
39
|
-
return percent == null || percent === '' ? '' : shiftDecimalPoint(percent.trim(), -2)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function scaled(value: string): { units: bigint; scale: number } {
|
|
43
|
-
const negative = value.startsWith('-')
|
|
44
|
-
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.')
|
|
45
|
-
const units = BigInt(whole + fraction)
|
|
46
|
-
return { units: negative ? -units : units, scale: fraction.length }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* numerator / denominator, where the numerator carries `scale` decimals,
|
|
51
|
-
* rounded half away from zero to the currency's decimals.
|
|
52
|
-
*/
|
|
53
|
-
function roundToCurrency(
|
|
54
|
-
numerator: bigint,
|
|
55
|
-
scale: number,
|
|
56
|
-
denominator: bigint,
|
|
57
|
-
currency: string,
|
|
58
|
-
): string {
|
|
59
|
-
const places = decimalPlaces(currency)
|
|
60
|
-
const negative = numerator < 0n !== denominator < 0n
|
|
61
|
-
let dividend = numerator < 0n ? -numerator : numerator
|
|
62
|
-
let divisor = denominator < 0n ? -denominator : denominator
|
|
63
|
-
if (scale > places) divisor *= 10n ** BigInt(scale - places)
|
|
64
|
-
else dividend *= 10n ** BigInt(places - scale)
|
|
65
|
-
|
|
66
|
-
let units = dividend / divisor
|
|
67
|
-
if ((dividend % divisor) * 2n >= divisor) units += 1n
|
|
68
|
-
|
|
69
|
-
const text = shiftDecimalPoint((negative ? -units : units).toString(), -places)
|
|
70
|
-
return sumMoney([text], currency)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* A percentage of an amount, exactly, rounded half away from zero to the
|
|
75
|
-
* currency's decimals ("10" percent of "19.99" USD is "2.00").
|
|
76
|
-
*/
|
|
77
|
-
export function percentOf(amount: string, percent: string, currency: string): string {
|
|
78
|
-
const left = scaled(amount)
|
|
79
|
-
const right = scaled(percent)
|
|
80
|
-
// The percentage is a hundredth, so the product carries two more places.
|
|
81
|
-
return roundToCurrency(left.units * right.units, left.scale + right.scale + 2, 1n, currency)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* The share of an amount that `part` of `whole` units carries ("10.00" for 1
|
|
86
|
-
* of 3 is "3.33"), rounded half away from zero to the currency's decimals.
|
|
87
|
-
*/
|
|
88
|
-
export function prorateMoney(
|
|
89
|
-
amount: string,
|
|
90
|
-
part: number,
|
|
91
|
-
whole: number,
|
|
92
|
-
currency: string,
|
|
93
|
-
): string {
|
|
94
|
-
if (!Number.isInteger(part) || !Number.isInteger(whole) || whole === 0) {
|
|
95
|
-
throw new Error(`Cannot prorate ${part} of ${whole}`)
|
|
96
|
-
}
|
|
97
|
-
const { units, scale } = scaled(amount)
|
|
98
|
-
return roundToCurrency(units * BigInt(part), scale, BigInt(whole), currency)
|
|
99
|
-
}
|
|
1
|
+
/** @deprecated Import these from `@spree/core`. Removed in 6.1. */
|
|
2
|
+
export {
|
|
3
|
+
fractionToPercent,
|
|
4
|
+
isPositiveMoney,
|
|
5
|
+
percentOf,
|
|
6
|
+
percentToFraction,
|
|
7
|
+
prorateMoney,
|
|
8
|
+
shiftDecimalPoint,
|
|
9
|
+
} from '@spree/core'
|
|
@@ -1,85 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
// grouping). Industry-standard: localization is a presentation concern, so the
|
|
4
|
-
// dashboard converts here, in the browser, and never relies on the server to
|
|
5
|
-
// parse comma-vs-period. See docs/plans/5.5-client-side-money-normalization.md.
|
|
6
|
-
//
|
|
7
|
-
// The locale is the admin's own (`useMoneyLocale`), the same one the field is
|
|
8
|
-
// displayed in, so a value reads back exactly as it was shown.
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Derives a locale's decimal and group separators via `Intl`. `fr` groups with
|
|
12
|
-
* a narrow no-break space (U+202F), so we read the real characters rather than
|
|
13
|
-
* assuming `,`/`.`/`" "`.
|
|
14
|
-
*/
|
|
15
|
-
function separatorsFor(locale: string): { decimal: string; group: string } {
|
|
16
|
-
try {
|
|
17
|
-
const parts = new Intl.NumberFormat(locale, { useGrouping: true }).formatToParts(11111.1)
|
|
18
|
-
return {
|
|
19
|
-
decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
|
|
20
|
-
group: parts.find((p) => p.type === 'group')?.value ?? '',
|
|
21
|
-
}
|
|
22
|
-
} catch {
|
|
23
|
-
return { decimal: '.', group: '' }
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Converts a locale-formatted amount string to a canonical decimal string.
|
|
29
|
-
*
|
|
30
|
-
* - `"1.234,56"` under `de` → `"1234.56"`
|
|
31
|
-
* - `"1 234,56"` under `fr` → `"1234.56"` (narrow-space grouping)
|
|
32
|
-
* - `"19.99"` under `en` → `"19.99"`
|
|
33
|
-
* - `"19.50"` under `de` → `"19.50"` (a period can't be grouping before two digits)
|
|
34
|
-
* - `""` / whitespace → `""`
|
|
35
|
-
*
|
|
36
|
-
* Strips the locale's group separator and any whitespace, standardizes the
|
|
37
|
-
* decimal separator to `.`, and drops stray characters. Keeps the value as a
|
|
38
|
-
* string throughout — never `Number()`-coerces — to preserve precision.
|
|
39
|
-
*
|
|
40
|
-
* @param raw the merchant's typed value
|
|
41
|
-
* @param locale the display locale the value was entered in (e.g. `de`)
|
|
42
|
-
* @returns canonical `"1234.56"`-form string, or `''` for blank input
|
|
43
|
-
*/
|
|
44
|
-
export function normalizeMoneyInput(raw: string | null | undefined, locale: string): string {
|
|
45
|
-
if (raw == null) return ''
|
|
46
|
-
const trimmed = raw.trim()
|
|
47
|
-
if (trimmed === '') return ''
|
|
48
|
-
|
|
49
|
-
const { decimal, group } = separatorsFor(locale)
|
|
50
|
-
|
|
51
|
-
let out = trimmed
|
|
52
|
-
// A group separator is always followed by exactly three digits, so a lone
|
|
53
|
-
// one that is not (`19.50` under `de`) is the merchant typing the other
|
|
54
|
-
// decimal mark. `1.500` stays ambiguous and is read the locale's way.
|
|
55
|
-
const groupParts = group.trim() === '' ? [] : out.split(group)
|
|
56
|
-
if (
|
|
57
|
-
groupParts.length === 2 &&
|
|
58
|
-
!out.includes(decimal) &&
|
|
59
|
-
groupParts[1].replace(/\D/g, '').length !== 3
|
|
60
|
-
) {
|
|
61
|
-
out = groupParts.join(decimal)
|
|
62
|
-
}
|
|
63
|
-
// Remove the locale's grouping separator (explicit char) plus any whitespace
|
|
64
|
-
// (covers narrow/no-break spaces used by some locales as the group char).
|
|
65
|
-
if (group) out = out.split(group).join('')
|
|
66
|
-
out = out.replace(/\s/g, '')
|
|
67
|
-
// Standardize the decimal separator to `.`.
|
|
68
|
-
if (decimal !== '.') out = out.split(decimal).join('.')
|
|
69
|
-
// Drop anything that isn't a digit, dot, or minus.
|
|
70
|
-
out = out.replace(/[^0-9.-]/g, '')
|
|
71
|
-
|
|
72
|
-
// Enforce the canonical shape: a single optional leading minus, then digits
|
|
73
|
-
// with at most one decimal point. Stray separators (`1.2.3`) or interior
|
|
74
|
-
// minus signs (`12-3.4`) would otherwise reach the API as malformed values.
|
|
75
|
-
const negative = out.startsWith('-')
|
|
76
|
-
const digitsAndDots = out.replace(/-/g, '')
|
|
77
|
-
const firstDot = digitsAndDots.indexOf('.')
|
|
78
|
-
const canonical =
|
|
79
|
-
firstDot === -1
|
|
80
|
-
? digitsAndDots
|
|
81
|
-
: `${digitsAndDots.slice(0, firstDot)}.${digitsAndDots.slice(firstDot + 1).replace(/\./g, '')}`
|
|
82
|
-
|
|
83
|
-
if (canonical === '' || canonical === '.') return ''
|
|
84
|
-
return negative ? `-${canonical}` : canonical
|
|
85
|
-
}
|
|
1
|
+
/** @deprecated Import from `@spree/core`. Removed in 6.1. */
|
|
2
|
+
export { normalizeMoneyInput } from '@spree/core'
|
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',
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { compareMoney, isDecimalString, isZeroMoney, sumMoney } from '@spree/
|
|
1
|
+
import { compareMoney, isDecimalString, isZeroMoney, prorateMoney, sumMoney } from '@spree/core'
|
|
2
2
|
import { formatMoney } from './formatters'
|
|
3
|
-
import { prorateMoney } from './money'
|
|
4
3
|
|
|
5
4
|
/** True when a received return is owed nothing, so completing it moves no money. */
|
|
6
5
|
export function returnOwesNothing(refundableTotal: string): boolean {
|
|
@@ -183,6 +183,35 @@ export function matchPluginRoute(
|
|
|
183
183
|
return null
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Whether a registry route outranks the file route the router matched for the
|
|
188
|
+
* same URL, so it renders in that route's place. The router sends
|
|
189
|
+
* `/sellers/shopify` to `/sellers/$sellerId`, never to the catch-all, which
|
|
190
|
+
* would leave a plugin page under a first-party section unreachable.
|
|
191
|
+
*
|
|
192
|
+
* Uses the router's own ranking: segment by segment, a fixed segment beats a
|
|
193
|
+
* `$param`. A registry route therefore takes over only an address where it is
|
|
194
|
+
* more specific, never a first-party page at its own path or through a
|
|
195
|
+
* pattern like `/sellers/$id`.
|
|
196
|
+
*
|
|
197
|
+
* @param pluginPath the registry route's path, relative to `/$storeId`
|
|
198
|
+
* @param routerPath the matched file route's path, relative to `/$storeId`
|
|
199
|
+
*/
|
|
200
|
+
export function outranksRouterMatch(pluginPath: string, routerPath: string): boolean {
|
|
201
|
+
const pluginSegments = pathSegments(pluginPath)
|
|
202
|
+
const routerSegments = pathSegments(routerPath)
|
|
203
|
+
for (const [index, pluginSegment] of pluginSegments.entries()) {
|
|
204
|
+
const pluginFixed = !pluginSegment.startsWith('$')
|
|
205
|
+
const routerFixed = !(routerSegments[index] ?? '$').startsWith('$')
|
|
206
|
+
if (pluginFixed !== routerFixed) return pluginFixed
|
|
207
|
+
}
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function pathSegments(path: string): string[] {
|
|
212
|
+
return path.split('/').filter(Boolean)
|
|
213
|
+
}
|
|
214
|
+
|
|
186
215
|
function isStoreRoute(entry: RouteEntry): entry is StoreRouteEntry {
|
|
187
216
|
return entry.scope === undefined || entry.scope === 'store'
|
|
188
217
|
}
|
|
@@ -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
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LucideIcon } from 'lucide-react'
|
|
2
|
-
import { useSyncExternalStore } from 'react'
|
|
2
|
+
import { type ComponentType, useSyncExternalStore } from 'react'
|
|
3
3
|
import { ensureLabelled, resolveNavDescription, resolveNavLabel } from './nav-registry'
|
|
4
4
|
import type { ActionName, SubjectName } from './permissions'
|
|
5
5
|
|
|
@@ -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
|
|
@@ -69,6 +75,11 @@ export interface SettingsNavEntry {
|
|
|
69
75
|
action?: ActionName
|
|
70
76
|
/** When true, the page is disabled in the sidebar with a "Soon" badge. */
|
|
71
77
|
comingSoon?: boolean
|
|
78
|
+
/**
|
|
79
|
+
* Rendered after the label in the sidebar and on the settings landing page.
|
|
80
|
+
* A component — not an element — so it can call hooks and return null.
|
|
81
|
+
*/
|
|
82
|
+
badge?: ComponentType
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
export interface SettingsNavGroup {
|
|
@@ -220,6 +231,19 @@ export function settingsEntryMatches(
|
|
|
220
231
|
return haystack.some((value) => value?.toLowerCase().includes(needle))
|
|
221
232
|
}
|
|
222
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Whether a staff member may reach this settings entry. The sidebar, the
|
|
236
|
+
* settings landing page and the command palette all ask this, so they agree.
|
|
237
|
+
*/
|
|
238
|
+
export function canSeeSettingsEntry(
|
|
239
|
+
entry: SettingsNavEntry,
|
|
240
|
+
can: (action: string, subject: string) => boolean,
|
|
241
|
+
): boolean {
|
|
242
|
+
const action = entry.action ?? 'read'
|
|
243
|
+
if (entry.subjects?.length) return entry.subjects.some((subject) => can(action, subject))
|
|
244
|
+
return !entry.subject || can(action, entry.subject)
|
|
245
|
+
}
|
|
246
|
+
|
|
223
247
|
/**
|
|
224
248
|
* Drop entries the current staff member cannot reach. Exported because the
|
|
225
249
|
* sidebar and the settings landing page must agree on what a role can see —
|
|
@@ -231,7 +255,7 @@ export function filterSettingsByPermissions(
|
|
|
231
255
|
permissions: { can: (action: string, subject: string) => boolean },
|
|
232
256
|
): SettingsNavSnapshot {
|
|
233
257
|
return regroup(snapshot, (entry) =>
|
|
234
|
-
|
|
258
|
+
canSeeSettingsEntry(entry, (action, subject) => permissions.can(action, subject)),
|
|
235
259
|
)
|
|
236
260
|
}
|
|
237
261
|
|
|
@@ -265,9 +289,7 @@ export function hasVisibleSettingsEntries(permissions: unknown): boolean {
|
|
|
265
289
|
?.can
|
|
266
290
|
if (typeof can !== 'function') return false
|
|
267
291
|
|
|
268
|
-
return getSnapshot().all.some(
|
|
269
|
-
(entry) => !entry.subject || can(entry.action ?? 'read', entry.subject),
|
|
270
|
-
)
|
|
292
|
+
return getSnapshot().all.some((entry) => canSeeSettingsEntry(entry, can))
|
|
271
293
|
}
|
|
272
294
|
|
|
273
295
|
/** Test-only: clear the registry. */
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Market } from '@spree/admin-sdk'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { adminClient } from '../client'
|
|
4
|
+
import { listAllMarkets, marketLocalization } from './store-config'
|
|
5
|
+
|
|
6
|
+
vi.mock('../client', () => ({ adminClient: { markets: { list: vi.fn() } } }))
|
|
7
|
+
|
|
8
|
+
function market(attributes: Partial<Market>): Market {
|
|
9
|
+
return {
|
|
10
|
+
id: 'mkt_1',
|
|
11
|
+
name: 'Market',
|
|
12
|
+
currency: 'USD',
|
|
13
|
+
default_locale: 'en',
|
|
14
|
+
tax_inclusive: false,
|
|
15
|
+
default: false,
|
|
16
|
+
country_codes: [],
|
|
17
|
+
supported_locales: [],
|
|
18
|
+
created_at: '',
|
|
19
|
+
updated_at: '',
|
|
20
|
+
tax_provider: null,
|
|
21
|
+
...attributes,
|
|
22
|
+
} as Market
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('marketLocalization', () => {
|
|
26
|
+
const markets = [
|
|
27
|
+
market({
|
|
28
|
+
id: 'mkt_eu',
|
|
29
|
+
currency: 'EUR',
|
|
30
|
+
default_locale: 'de',
|
|
31
|
+
supported_locales: ['de', 'fr'],
|
|
32
|
+
}),
|
|
33
|
+
market({ id: 'mkt_us', currency: 'USD', default_locale: 'en', default: true }),
|
|
34
|
+
market({ id: 'mkt_ie', currency: 'EUR', default_locale: 'en' }),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
it('takes the defaults from the default market', () => {
|
|
38
|
+
const { defaultCurrency, defaultLocale } = marketLocalization(markets)
|
|
39
|
+
|
|
40
|
+
expect(defaultCurrency).toBe('USD')
|
|
41
|
+
expect(defaultLocale).toBe('en')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('lists each market currency once, the default market first', () => {
|
|
45
|
+
expect(marketLocalization(markets).currencies).toEqual(['USD', 'EUR'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('lists every language any market offers', () => {
|
|
49
|
+
expect(marketLocalization(markets).locales).toEqual(['de', 'en', 'fr'])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('falls back to the first market when none is flagged default', () => {
|
|
53
|
+
const { defaultCurrency } = marketLocalization([market({ currency: 'GBP' })])
|
|
54
|
+
|
|
55
|
+
expect(defaultCurrency).toBe('GBP')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns empty lists before markets load', () => {
|
|
59
|
+
expect(marketLocalization([])).toEqual({
|
|
60
|
+
currencies: [],
|
|
61
|
+
locales: [],
|
|
62
|
+
defaultCurrency: undefined,
|
|
63
|
+
defaultLocale: undefined,
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('listAllMarkets', () => {
|
|
69
|
+
it('joins every page so markets past the first page are not lost', async () => {
|
|
70
|
+
const page = (number: number, pages: number) => ({
|
|
71
|
+
data: [market({ id: `mkt_${number}` })],
|
|
72
|
+
meta: {
|
|
73
|
+
page: number,
|
|
74
|
+
pages,
|
|
75
|
+
limit: 100,
|
|
76
|
+
count: pages,
|
|
77
|
+
from: 1,
|
|
78
|
+
to: 1,
|
|
79
|
+
in: 1,
|
|
80
|
+
previous: null,
|
|
81
|
+
next: null,
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
vi.mocked(adminClient.markets.list).mockImplementation(async (params) =>
|
|
85
|
+
page(params?.page ?? 1, 3),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const result = await listAllMarkets()
|
|
89
|
+
|
|
90
|
+
expect(result.data.map((record) => record.id)).toEqual(['mkt_1', 'mkt_2', 'mkt_3'])
|
|
91
|
+
expect(adminClient.markets.list).toHaveBeenCalledTimes(3)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Market, PaginatedResponse } from '@spree/admin-sdk'
|
|
2
|
+
import type { CurrencyCode, LocaleCode } from '@spree/core'
|
|
3
|
+
import { adminClient } from '../client'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Markets and channels are store configuration that rarely changes, so the
|
|
7
|
+
* dashboard fetches each list once and keeps it for an hour. Their create,
|
|
8
|
+
* update and delete mutations invalidate `[resource, storeId]`, which refreshes
|
|
9
|
+
* the cached list immediately.
|
|
10
|
+
*/
|
|
11
|
+
export const STORE_CONFIG_STALE_TIME = 60 * 60_000
|
|
12
|
+
|
|
13
|
+
const PAGE_SIZE = 100
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fetches every page of a list and joins them, so a store with more records
|
|
17
|
+
* than one page holds still gets all of them.
|
|
18
|
+
*/
|
|
19
|
+
async function listEveryPage<T>(
|
|
20
|
+
fetchPage: (page: number) => Promise<PaginatedResponse<T>>,
|
|
21
|
+
): Promise<PaginatedResponse<T>> {
|
|
22
|
+
const first = await fetchPage(1)
|
|
23
|
+
if (first.meta.pages <= 1) return first
|
|
24
|
+
|
|
25
|
+
const rest = await Promise.all(
|
|
26
|
+
Array.from({ length: first.meta.pages - 1 }, (_, index) => fetchPage(index + 2)),
|
|
27
|
+
)
|
|
28
|
+
return { ...first, data: [first, ...rest].flatMap((response) => response.data) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every market of the current store, under `['markets', storeId, 'all']`. */
|
|
32
|
+
export function listAllMarkets() {
|
|
33
|
+
return listEveryPage((page) =>
|
|
34
|
+
adminClient.markets.list({ page, limit: PAGE_SIZE, sort: 'position' }),
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Every channel of the current store, under `['channels', storeId, 'all']`. */
|
|
39
|
+
export function listAllChannels() {
|
|
40
|
+
return listEveryPage((page) => adminClient.channels.list({ page, limit: PAGE_SIZE }))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MarketLocalization {
|
|
44
|
+
/** The default market's currency first, then every other market's. */
|
|
45
|
+
currencies: CurrencyCode[]
|
|
46
|
+
/** Every language any market offers, sorted. */
|
|
47
|
+
locales: LocaleCode[]
|
|
48
|
+
defaultCurrency: CurrencyCode | undefined
|
|
49
|
+
defaultLocale: LocaleCode | undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The currencies and languages a store sells in, read from its markets. The
|
|
54
|
+
* default market's currency and language are the store's defaults.
|
|
55
|
+
*/
|
|
56
|
+
export function marketLocalization(markets: readonly Market[]): MarketLocalization {
|
|
57
|
+
const defaultMarket = markets.find((market) => market.default) ?? markets[0]
|
|
58
|
+
const currencies = [
|
|
59
|
+
...new Set([
|
|
60
|
+
...(defaultMarket ? [defaultMarket.currency] : []),
|
|
61
|
+
...markets.map((market) => market.currency),
|
|
62
|
+
]),
|
|
63
|
+
]
|
|
64
|
+
const locales = [
|
|
65
|
+
...new Set(
|
|
66
|
+
markets.flatMap((market) => [market.default_locale, ...(market.supported_locales ?? [])]),
|
|
67
|
+
),
|
|
68
|
+
].sort()
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
currencies,
|
|
72
|
+
locales,
|
|
73
|
+
defaultCurrency: defaultMarket?.currency,
|
|
74
|
+
defaultLocale: defaultMarket?.default_locale,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -67,6 +67,11 @@ interface ColumnDefBase<T = any> {
|
|
|
67
67
|
* can show human-readable names instead of raw prefixed IDs.
|
|
68
68
|
*/
|
|
69
69
|
export interface ResourceFilterConfig<R extends { id: string } = { id: string }> {
|
|
70
|
+
/**
|
|
71
|
+
* Leads every cache entry the filter creates, ahead of the tenant. Naming
|
|
72
|
+
* it after the resource (`'markets'`) lets that resource's writes, which
|
|
73
|
+
* invalidate `[resource, tenant]`, refresh the filter's lists too.
|
|
74
|
+
*/
|
|
70
75
|
queryKey: string
|
|
71
76
|
search: (query: string) => Promise<{ data: R[] }>
|
|
72
77
|
hydrate: (ids: string[]) => Promise<{ data: R[] }>
|
|
@@ -75,12 +80,18 @@ export interface ResourceFilterConfig<R extends { id: string } = { id: string }>
|
|
|
75
80
|
emptyText?: string
|
|
76
81
|
/**
|
|
77
82
|
* Fetches the whole list, for a resource small enough to show in full —
|
|
78
|
-
* a store's warehouses, its channels.
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
83
|
+
* a store's warehouses, its channels. It is fetched once and cached under
|
|
84
|
+
* `[queryKey, tenant, 'all']`; the filter narrows it as the operator types
|
|
85
|
+
* and labels its chips from it, without further requests. Use the same
|
|
86
|
+
* request as the resource's own full-list hook, which shares that key.
|
|
87
|
+
* Leave it off for anything that could run to hundreds of rows.
|
|
82
88
|
*/
|
|
83
89
|
listAll?: () => Promise<{ data: R[] }>
|
|
90
|
+
/**
|
|
91
|
+
* How long fetched options stay fresh, in milliseconds. Raise it for
|
|
92
|
+
* rarely-changing resources whose writes invalidate `queryKey`.
|
|
93
|
+
*/
|
|
94
|
+
staleTime?: number
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
/**
|
|
@@ -100,7 +111,7 @@ export type TaggableType = 'product' | 'customer' | 'order' | (string & {})
|
|
|
100
111
|
*/
|
|
101
112
|
export type ColumnDef<T = any> =
|
|
102
113
|
| (ColumnDefBase<T> & {
|
|
103
|
-
filterType?: 'string' | 'boolean' | 'number' | 'date' | 'currency'
|
|
114
|
+
filterType?: 'string' | 'boolean' | 'number' | 'date' | 'currency' | 'locale'
|
|
104
115
|
filterOptions?: never
|
|
105
116
|
filterResource?: never
|
|
106
117
|
taggableType?: never
|
package/src/locales/ar.json
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"admin": {
|
|
3
|
+
"address_select": {
|
|
4
|
+
"none": "لم يتم اختيار عنوان"
|
|
5
|
+
},
|
|
3
6
|
"app_name": "Spree Admin",
|
|
4
7
|
"a11y": {
|
|
5
8
|
"breadcrumb": "مسار التنقل",
|
|
@@ -97,6 +100,7 @@
|
|
|
97
100
|
"address_book": {
|
|
98
101
|
"default_billing": "الفوترة الافتراضية",
|
|
99
102
|
"default_shipping": "الشحن الافتراضي",
|
|
103
|
+
"inherited_from": "موروث من {{name}}",
|
|
100
104
|
"set_default_billing": "تعيين كعنوان فوترة افتراضي",
|
|
101
105
|
"set_default_shipping": "تعيين كعنوان شحن افتراضي"
|
|
102
106
|
},
|
|
@@ -183,7 +187,9 @@
|
|
|
183
187
|
"searching": "جارٍ البحث…",
|
|
184
188
|
"select_placeholder": "اختر…",
|
|
185
189
|
"unsaved_changes": "لديك تغييرات غير محفوظة.",
|
|
186
|
-
"yes": "نعم"
|
|
190
|
+
"yes": "نعم",
|
|
191
|
+
"done": "تم",
|
|
192
|
+
"error": "حدث خطأ ما."
|
|
187
193
|
},
|
|
188
194
|
"components": {
|
|
189
195
|
"address_block": {
|
package/src/locales/de.json
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"admin": {
|
|
3
|
+
"address_select": {
|
|
4
|
+
"none": "Keine Adresse ausgewählt"
|
|
5
|
+
},
|
|
3
6
|
"app_name": "Spree Admin",
|
|
4
7
|
"a11y": {
|
|
5
8
|
"breadcrumb": "Breadcrumb",
|
|
@@ -97,6 +100,7 @@
|
|
|
97
100
|
"address_book": {
|
|
98
101
|
"default_billing": "Standard-Rechnung",
|
|
99
102
|
"default_shipping": "Standard-Versand",
|
|
103
|
+
"inherited_from": "Geerbt von {{name}}",
|
|
100
104
|
"set_default_billing": "Als Standard-Rechnungsadresse festlegen",
|
|
101
105
|
"set_default_shipping": "Als Standard-Versandadresse festlegen"
|
|
102
106
|
},
|
|
@@ -183,7 +187,9 @@
|
|
|
183
187
|
"searching": "Wird gesucht…",
|
|
184
188
|
"select_placeholder": "Auswählen…",
|
|
185
189
|
"unsaved_changes": "Sie haben ungespeicherte Änderungen.",
|
|
186
|
-
"yes": "Ja"
|
|
190
|
+
"yes": "Ja",
|
|
191
|
+
"done": "Fertig",
|
|
192
|
+
"error": "Etwas ist schiefgelaufen."
|
|
187
193
|
},
|
|
188
194
|
"components": {
|
|
189
195
|
"address_block": {
|
package/src/locales/en.json
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"admin": {
|
|
3
|
+
"address_select": {
|
|
4
|
+
"none": "No address selected"
|
|
5
|
+
},
|
|
3
6
|
"app_name": "Spree Admin",
|
|
4
7
|
"a11y": {
|
|
5
8
|
"breadcrumb": "Breadcrumb",
|
|
@@ -97,6 +100,7 @@
|
|
|
97
100
|
"address_book": {
|
|
98
101
|
"default_billing": "Default billing",
|
|
99
102
|
"default_shipping": "Default shipping",
|
|
103
|
+
"inherited_from": "Inherited from {{name}}",
|
|
100
104
|
"set_default_billing": "Set as default billing",
|
|
101
105
|
"set_default_shipping": "Set as default shipping"
|
|
102
106
|
},
|
|
@@ -183,7 +187,9 @@
|
|
|
183
187
|
"searching": "Searching…",
|
|
184
188
|
"select_placeholder": "Select…",
|
|
185
189
|
"unsaved_changes": "You have unsaved changes.",
|
|
186
|
-
"yes": "Yes"
|
|
190
|
+
"yes": "Yes",
|
|
191
|
+
"done": "Done",
|
|
192
|
+
"error": "Something went wrong."
|
|
187
193
|
},
|
|
188
194
|
"components": {
|
|
189
195
|
"address_block": {
|