@spree/dashboard-core 0.13.1 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +52 -27
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +383 -127
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +19 -14
- package/src/components/table-toolbar.tsx +1357 -440
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +241 -16
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +9 -6
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/table-registry.ts +50 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -517
- package/src/locales/de.json +1884 -517
- package/src/locales/en.json +1886 -519
- package/src/locales/fr.json +1884 -517
- package/src/locales/pl.json +1913 -517
- package/src/locales/zh-CN.json +1883 -517
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- package/src/components/top-bar.tsx +0 -209
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { matchDatePreset, resolveDatePreset } from './date-presets'
|
|
3
|
+
|
|
4
|
+
// A fixed instant with a deliberately awkward property: 23:30 UTC on the 15th
|
|
5
|
+
// is already the 16th in Sydney and still the 15th in Los Angeles. Every
|
|
6
|
+
// assertion below turns on which calendar the range is computed against.
|
|
7
|
+
const INSTANT = new Date('2026-03-15T23:30:00Z')
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.useFakeTimers()
|
|
11
|
+
vi.setSystemTime(INSTANT)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.useRealTimers()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('resolveDatePreset', () => {
|
|
19
|
+
it('reads "today" on the store\'s calendar, not the viewer\'s', () => {
|
|
20
|
+
// Same instant, three zones, three different answers for "today". This is
|
|
21
|
+
// the whole reason the timezone is threaded through.
|
|
22
|
+
expect(resolveDatePreset('today', 'America/Los_Angeles')).toEqual({
|
|
23
|
+
from: '2026-03-15',
|
|
24
|
+
to: '2026-03-15',
|
|
25
|
+
})
|
|
26
|
+
expect(resolveDatePreset('today', 'UTC')).toEqual({ from: '2026-03-15', to: '2026-03-15' })
|
|
27
|
+
expect(resolveDatePreset('today', 'Australia/Sydney')).toEqual({
|
|
28
|
+
from: '2026-03-16',
|
|
29
|
+
to: '2026-03-16',
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('counts "last 7 days" inclusively, so it spans exactly 7 dates', () => {
|
|
34
|
+
expect(resolveDatePreset('last_7_days', 'UTC')).toEqual({
|
|
35
|
+
from: '2026-03-09',
|
|
36
|
+
to: '2026-03-15',
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('runs "this month" from the 1st to today', () => {
|
|
41
|
+
expect(resolveDatePreset('this_month', 'UTC')).toEqual({
|
|
42
|
+
from: '2026-03-01',
|
|
43
|
+
to: '2026-03-15',
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('ends "last month" the day before this one starts, whatever its length', () => {
|
|
48
|
+
// February 2026 has 28 days — derived, never assumed.
|
|
49
|
+
expect(resolveDatePreset('last_month', 'UTC')).toEqual({
|
|
50
|
+
from: '2026-02-01',
|
|
51
|
+
to: '2026-02-28',
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('treats "all" as unbounded on both sides', () => {
|
|
56
|
+
expect(resolveDatePreset('all', 'UTC')).toEqual({ from: null, to: null })
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('matchDatePreset', () => {
|
|
61
|
+
it('recognises a range it produced, so a restored filter re-selects its entry', () => {
|
|
62
|
+
const range = resolveDatePreset('last_30_days', 'UTC')
|
|
63
|
+
expect(matchDatePreset(range, 'UTC')).toBe('last_30_days')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('reads empty bounds as "all"', () => {
|
|
67
|
+
expect(matchDatePreset({ from: null, to: null }, 'UTC')).toBe('all')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('reads a hand-picked range as custom', () => {
|
|
71
|
+
expect(matchDatePreset({ from: '2026-01-02', to: '2026-01-09' }, 'UTC')).toBe('custom')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('reads a stale preset as custom once it no longer matches', () => {
|
|
75
|
+
// Yesterday's "last 7 days", reopened today, is a fixed range that no
|
|
76
|
+
// preset would produce now — calling it "Last 7 days" would misdescribe
|
|
77
|
+
// what the list is actually filtered to.
|
|
78
|
+
const stale = { from: '2026-03-08', to: '2026-03-14' }
|
|
79
|
+
expect(matchDatePreset(stale, 'UTC')).toBe('custom')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('does not match a range computed in another zone', () => {
|
|
83
|
+
const sydney = resolveDatePreset('today', 'Australia/Sydney')
|
|
84
|
+
expect(matchDatePreset(sydney, 'America/Los_Angeles')).toBe('custom')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { format, startOfMonth, subDays, subMonths } from 'date-fns'
|
|
2
|
+
import { toZonedTime } from 'date-fns-tz'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Relative date ranges offered by the toolbar's quick date filter.
|
|
6
|
+
*
|
|
7
|
+
* `null` bounds mean "unbounded on that side", which is how `all` clears the
|
|
8
|
+
* filter without needing a separate signal.
|
|
9
|
+
*/
|
|
10
|
+
export type DatePresetKey =
|
|
11
|
+
| 'all'
|
|
12
|
+
| 'today'
|
|
13
|
+
| 'last_7_days'
|
|
14
|
+
| 'last_30_days'
|
|
15
|
+
| 'this_month'
|
|
16
|
+
| 'last_month'
|
|
17
|
+
| 'custom'
|
|
18
|
+
|
|
19
|
+
/** Presets in menu order. `custom` is handled by the panel, not by this table. */
|
|
20
|
+
export const DATE_PRESET_KEYS: DatePresetKey[] = [
|
|
21
|
+
'all',
|
|
22
|
+
'today',
|
|
23
|
+
'last_7_days',
|
|
24
|
+
'last_30_days',
|
|
25
|
+
'this_month',
|
|
26
|
+
'last_month',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
export interface DateRange {
|
|
30
|
+
/** Inclusive lower bound as `yyyy-MM-dd`, or null for unbounded. */
|
|
31
|
+
from: string | null
|
|
32
|
+
/** Inclusive upper bound as `yyyy-MM-dd`, or null for unbounded. */
|
|
33
|
+
to: string | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a preset into concrete `yyyy-MM-dd` bounds.
|
|
38
|
+
*
|
|
39
|
+
* The timezone argument is not decoration: these ranges are relative to *now*,
|
|
40
|
+
* and "today" means today where the merchant trades. Computing them in the
|
|
41
|
+
* browser's zone puts the boundary hours off for anyone checking a store from
|
|
42
|
+
* another country — and it moves depending on who is looking, which makes a
|
|
43
|
+
* shared link mean different things to two people.
|
|
44
|
+
*
|
|
45
|
+
* Bounds are whole dates because that is what the filter stores and what
|
|
46
|
+
* Ransack compares against; the day itself is the smallest unit a merchant
|
|
47
|
+
* reasons about here.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveDatePreset(preset: DatePresetKey, timezone: string): DateRange {
|
|
50
|
+
if (preset === 'all' || preset === 'custom') return { from: null, to: null }
|
|
51
|
+
|
|
52
|
+
// "Now" shifted so that reading its *local* fields yields the store's
|
|
53
|
+
// wall-clock date. All arithmetic below then happens on the merchant's
|
|
54
|
+
// calendar, and `format` reads those same local fields back out.
|
|
55
|
+
//
|
|
56
|
+
// `formatInTimeZone` must not be used on these values: it would convert a
|
|
57
|
+
// second time, moving every bound by the offset that was already applied.
|
|
58
|
+
const now = toZonedTime(new Date(), timezone)
|
|
59
|
+
const day = (date: Date) => format(date, 'yyyy-MM-dd')
|
|
60
|
+
|
|
61
|
+
switch (preset) {
|
|
62
|
+
case 'today':
|
|
63
|
+
return { from: day(now), to: day(now) }
|
|
64
|
+
case 'last_7_days':
|
|
65
|
+
return { from: day(subDays(now, 6)), to: day(now) }
|
|
66
|
+
case 'last_30_days':
|
|
67
|
+
return { from: day(subDays(now, 29)), to: day(now) }
|
|
68
|
+
case 'this_month':
|
|
69
|
+
return { from: day(startOfMonth(now)), to: day(now) }
|
|
70
|
+
case 'last_month': {
|
|
71
|
+
const previous = subMonths(now, 1)
|
|
72
|
+
return {
|
|
73
|
+
from: day(startOfMonth(previous)),
|
|
74
|
+
// The day before this month starts — avoids month-length arithmetic.
|
|
75
|
+
to: day(subDays(startOfMonth(now), 1)),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Recognise which preset a pair of bounds came from, so a filter restored from
|
|
83
|
+
* the URL re-selects its menu entry instead of always reading as "Custom".
|
|
84
|
+
*
|
|
85
|
+
* Matching by value rather than storing the preset key keeps the filter a plain
|
|
86
|
+
* `FilterRule` — the preset is a way of writing a range, not a thing the rest
|
|
87
|
+
* of the pipeline has to know about. A range that no longer matches any preset
|
|
88
|
+
* (yesterday's "last 7 days", reopened today) correctly reads as custom.
|
|
89
|
+
*/
|
|
90
|
+
export function matchDatePreset(range: DateRange, timezone: string): DatePresetKey {
|
|
91
|
+
if (!range.from && !range.to) return 'all'
|
|
92
|
+
for (const key of DATE_PRESET_KEYS) {
|
|
93
|
+
if (key === 'all') continue
|
|
94
|
+
const candidate = resolveDatePreset(key, timezone)
|
|
95
|
+
if (candidate.from === range.from && candidate.to === range.to) return key
|
|
96
|
+
}
|
|
97
|
+
return 'custom'
|
|
98
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One consignment's editable fields.
|
|
5
|
+
*
|
|
6
|
+
* The carrier is free text: a forwarder's own name is as valid as a
|
|
7
|
+
* registered carrier, and an empty value asks the server to detect one from
|
|
8
|
+
* the number.
|
|
9
|
+
*/
|
|
10
|
+
export const deliveryFormSchema = z.object({
|
|
11
|
+
tracking_number: z.string().min(1),
|
|
12
|
+
carrier: z.string(),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export type DeliveryFormValues = z.infer<typeof deliveryFormSchema>
|
package/src/lib/docs.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base for documentation links surfaced in the dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Kept in one place so a docs restructure, a domain change or a locale prefix
|
|
5
|
+
* is a single edit rather than a sweep through every table and settings page.
|
|
6
|
+
*/
|
|
7
|
+
export const DOCS_BASE_URL = 'https://spreecommerce.org/docs'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The other two destinations the account menu offers, kept here for the same
|
|
11
|
+
* reason as the docs base: one edit if a domain moves.
|
|
12
|
+
*/
|
|
13
|
+
export const COMMUNITY_URL = 'https://discord.spreecommerce.org'
|
|
14
|
+
export const CONTACT_URL = 'https://spreecommerce.org/contact'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolves a documentation path to a full URL.
|
|
18
|
+
*
|
|
19
|
+
* A bare path is taken as relative to the user guide, which is where the
|
|
20
|
+
* merchant-facing pages live; an absolute URL is returned untouched, so a
|
|
21
|
+
* plugin can point at its own documentation.
|
|
22
|
+
*
|
|
23
|
+
* @param path - `'catalogs'`, `'developer/providers/erp'`, or a full URL
|
|
24
|
+
* @returns the URL to link to
|
|
25
|
+
*/
|
|
26
|
+
export function docsUrl(path: string): string {
|
|
27
|
+
if (/^https?:\/\//.test(path)) return path
|
|
28
|
+
|
|
29
|
+
const trimmed = path.replace(/^\/+/, '')
|
|
30
|
+
const section = /^(user|developer)\//.test(trimmed) ? trimmed : `user/${trimmed}`
|
|
31
|
+
return `${DOCS_BASE_URL}/${section}`
|
|
32
|
+
}
|
package/src/lib/download.ts
CHANGED
|
@@ -18,15 +18,27 @@ function resolveApiUrl(urlOrPath: string): { url: string; sameOrigin: boolean }
|
|
|
18
18
|
* origin get the `Authorization` header and cookies, so the token can't leak
|
|
19
19
|
* to a third-party host (e.g. a URL echoed back by the server). The filename
|
|
20
20
|
* comes from `Content-Disposition` when present, else `fallbackName`.
|
|
21
|
+
*
|
|
22
|
+
* `extraHeaders` carries whatever else the panel's API needs to identify the
|
|
23
|
+
* request — the seller panel's `X-Spree-Seller-Id`, without which every Seller
|
|
24
|
+
* API request is refused before the action runs. Prefer
|
|
25
|
+
* `useDownloadHeaders()`, which reads them off the registered client.
|
|
21
26
|
*/
|
|
22
27
|
export async function downloadFromApi(
|
|
23
28
|
token: string | null,
|
|
24
29
|
urlOrPath: string,
|
|
25
30
|
fallbackName: string,
|
|
31
|
+
extraHeaders: Record<string, string> = {},
|
|
26
32
|
): Promise<void> {
|
|
27
33
|
const { url, sameOrigin } = resolveApiUrl(urlOrPath)
|
|
28
34
|
const headers: Record<string, string> = {}
|
|
29
|
-
if (sameOrigin && token)
|
|
35
|
+
if (sameOrigin && token) {
|
|
36
|
+
headers.Authorization = `Bearer ${token}`
|
|
37
|
+
// Tenant headers ride only to the API origin, for the same reason the
|
|
38
|
+
// token does — a URL echoed back by the server must not carry them
|
|
39
|
+
// anywhere else.
|
|
40
|
+
Object.assign(headers, extraHeaders)
|
|
41
|
+
}
|
|
30
42
|
|
|
31
43
|
const response = await fetch(url, {
|
|
32
44
|
headers,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { filtersToRansack } from './filters-to-ransack'
|
|
3
|
+
import type { ColumnDef, FilterRule } from './table-registry'
|
|
4
|
+
|
|
5
|
+
const columns: ColumnDef[] = [
|
|
6
|
+
{ key: 'completed_at', label: 'Date', filterType: 'date' },
|
|
7
|
+
{ key: 'status', label: 'Status', filterType: 'enum', filterOptions: [] },
|
|
8
|
+
{ key: 'tags', label: 'Tags', filterType: 'tags', taggableType: 'Spree::Product' },
|
|
9
|
+
{ key: 'sku', label: 'SKU', ransackAttribute: 'master_sku' },
|
|
10
|
+
{
|
|
11
|
+
key: 'companies',
|
|
12
|
+
label: 'Companies',
|
|
13
|
+
filterType: 'resource',
|
|
14
|
+
ransackAttribute: 'with_standing_for_company',
|
|
15
|
+
ransackScope: true,
|
|
16
|
+
filterResource: {
|
|
17
|
+
queryKey: 'companies',
|
|
18
|
+
search: async () => ({ data: [], meta: { count: 0, page: 1, pages: 0 } }),
|
|
19
|
+
hydrate: async () => ({ data: [], meta: { count: 0, page: 1, pages: 0 } }),
|
|
20
|
+
getOptionLabel: () => '',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
const rule = (partial: Partial<FilterRule>): FilterRule => ({
|
|
26
|
+
id: 'r1',
|
|
27
|
+
field: 'completed_at',
|
|
28
|
+
operator: 'lteq',
|
|
29
|
+
value: '2026-08-29',
|
|
30
|
+
...partial,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('filtersToRansack', () => {
|
|
34
|
+
it('carries a date upper bound to the end of its day', () => {
|
|
35
|
+
// The columns these target are datetimes, so a bare date casts to
|
|
36
|
+
// midnight — "up to the 29th" would drop everything that happened on the
|
|
37
|
+
// 29th, which is usually the very rows the operator is looking for.
|
|
38
|
+
expect(filtersToRansack([rule({})], columns)).toEqual({
|
|
39
|
+
completed_at_lteq: '2026-08-29 23:59:59.999999',
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('leaves a date lower bound alone, since midnight already starts the day', () => {
|
|
44
|
+
expect(filtersToRansack([rule({ operator: 'gteq', value: '2026-08-01' })], columns)).toEqual({
|
|
45
|
+
completed_at_gteq: '2026-08-01',
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('emits both ends of a range', () => {
|
|
50
|
+
const filters = [
|
|
51
|
+
rule({ id: 'a', operator: 'gteq', value: '2026-08-01' }),
|
|
52
|
+
rule({ id: 'b', operator: 'lteq', value: '2026-08-29' }),
|
|
53
|
+
]
|
|
54
|
+
expect(filtersToRansack(filters, columns)).toEqual({
|
|
55
|
+
completed_at_gteq: '2026-08-01',
|
|
56
|
+
completed_at_lteq: '2026-08-29 23:59:59.999999',
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does not widen a non-date column that happens to use lteq', () => {
|
|
61
|
+
const columnsWithNumber: ColumnDef[] = [
|
|
62
|
+
...columns,
|
|
63
|
+
{ key: 'total', label: 'Total', filterType: 'number' },
|
|
64
|
+
]
|
|
65
|
+
const filters = [rule({ field: 'total', operator: 'lteq', value: '100' })]
|
|
66
|
+
expect(filtersToRansack(filters, columnsWithNumber)).toEqual({ total_lteq: '100' })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('splits array operators into a list', () => {
|
|
70
|
+
const filters = [rule({ field: 'status', operator: 'in', value: 'active,draft' })]
|
|
71
|
+
expect(filtersToRansack(filters, columns)).toEqual({ status_in: ['active', 'draft'] })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('drops an array operator with no values rather than filtering by nothing', () => {
|
|
75
|
+
const filters = [rule({ field: 'status', operator: 'in', value: '' })]
|
|
76
|
+
expect(filtersToRansack(filters, columns)).toEqual({})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('targets the join column for tags', () => {
|
|
80
|
+
const filters = [rule({ field: 'tags', operator: 'in', value: 'sale' })]
|
|
81
|
+
expect(filtersToRansack(filters, columns)).toEqual({ tags_name_in: ['sale'] })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('honours an explicit ransack alias', () => {
|
|
85
|
+
const filters = [rule({ field: 'sku', operator: 'i_cont', value: 'ABC' })]
|
|
86
|
+
expect(filtersToRansack(filters, columns)).toEqual({ master_sku_i_cont: 'ABC' })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// A scope is invoked by its bare name and takes the value as its argument,
|
|
90
|
+
// so an operator suffix would name a predicate that does not exist and
|
|
91
|
+
// silently filter nothing.
|
|
92
|
+
it('sends a scope key without an operator suffix', () => {
|
|
93
|
+
const filters = [rule({ field: 'companies', operator: 'in', value: 'comp_1,comp_2' })]
|
|
94
|
+
|
|
95
|
+
expect(filtersToRansack(filters, columns)).toEqual({
|
|
96
|
+
with_standing_for_company: ['comp_1', 'comp_2'],
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// A scope takes its value as an argument and cannot express negation, so
|
|
101
|
+
// emitting the same bare key would run it as an inclusion — the exact
|
|
102
|
+
// opposite of what the merchant picked.
|
|
103
|
+
it('drops a negating operator on a scope column rather than inverting it', () => {
|
|
104
|
+
const filters = [rule({ field: 'companies', operator: 'not_in', value: 'comp_1' })]
|
|
105
|
+
|
|
106
|
+
expect(filtersToRansack(filters, columns)).toEqual({})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('still drops an empty scope filter', () => {
|
|
110
|
+
const filters = [rule({ field: 'companies', operator: 'in', value: '' })]
|
|
111
|
+
expect(filtersToRansack(filters, columns)).toEqual({})
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -22,6 +22,8 @@ import { type ColumnDef, type FilterRule, parseFilterIds } from './table-registr
|
|
|
22
22
|
// FilterRule[]) stays simple.
|
|
23
23
|
const ARRAY_OPERATORS = new Set(['in', 'not_in'])
|
|
24
24
|
|
|
25
|
+
const NEGATING_OPERATORS = new Set(['not_in', 'not_eq'])
|
|
26
|
+
|
|
25
27
|
export function filtersToRansack(
|
|
26
28
|
filters: FilterRule[],
|
|
27
29
|
columns: ColumnDef[],
|
|
@@ -35,10 +37,29 @@ export function filtersToRansack(
|
|
|
35
37
|
// ransack alias here when one isn't explicitly set.
|
|
36
38
|
const fallback = col?.filterType === 'tags' ? 'tags_name' : filter.field
|
|
37
39
|
const ransackKey = col?.ransackAttribute ?? fallback
|
|
38
|
-
|
|
40
|
+
// A scope is invoked by its bare name and takes the value as an argument,
|
|
41
|
+
// so it cannot express negation — emitting the same key for `not_in` would
|
|
42
|
+
// run it as an inclusion.
|
|
43
|
+
if (col?.ransackScope && NEGATING_OPERATORS.has(filter.operator)) continue
|
|
44
|
+
|
|
45
|
+
const key = col?.ransackScope ? ransackKey : `${ransackKey}_${filter.operator}`
|
|
39
46
|
if (ARRAY_OPERATORS.has(filter.operator)) {
|
|
40
47
|
const ids = parseFilterIds(filter.value)
|
|
41
48
|
if (ids.length > 0) out[key] = ids
|
|
49
|
+
} else if (col?.filterType === 'date' && filter.operator === 'lteq') {
|
|
50
|
+
// A date filter's upper bound is a whole day, but the columns it targets
|
|
51
|
+
// are datetimes: Ransack casts `2026-08-29` to midnight, so "up to the
|
|
52
|
+
// 29th" would drop everything that happened during the 29th. Carry the
|
|
53
|
+
// day's last instant instead — to the microsecond, since Rails defaults
|
|
54
|
+
// datetime columns to that precision and a bound of `23:59:59` would
|
|
55
|
+
// drop the final second of the day. The lower bound needs no such
|
|
56
|
+
// treatment: midnight is already the start of its day.
|
|
57
|
+
//
|
|
58
|
+
// Both ends are read in the server's zone, so a store trading far from
|
|
59
|
+
// it sees the same edge-of-day skew its other date filters already have.
|
|
60
|
+
// Fixing that means sending zoned bounds, which is a change to the
|
|
61
|
+
// filter wire format rather than to this conversion.
|
|
62
|
+
out[key] = `${filter.value} 23:59:59.999999`
|
|
42
63
|
} else {
|
|
43
64
|
out[key] = filter.value
|
|
44
65
|
}
|
package/src/lib/form-errors.ts
CHANGED
|
@@ -1,6 +1,109 @@
|
|
|
1
|
+
import type { ValidationErrorDetail } from '@spree/admin-sdk'
|
|
1
2
|
import { SpreeError } from '@spree/admin-sdk'
|
|
3
|
+
import i18n from 'i18next'
|
|
2
4
|
import type { FieldValues, Path, UseFormSetError } from 'react-hook-form'
|
|
3
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Text for one validation failure, in the admin's own language.
|
|
8
|
+
*
|
|
9
|
+
* Server codes live under `admin.validation.codes`, apart from the client-side
|
|
10
|
+
* keys the forms' own schemas read (`admin.validation.required`, …). The two
|
|
11
|
+
* vocabularies overlap — Rails' `required` is a missing association, the
|
|
12
|
+
* client's is an empty input — so they cannot share a namespace.
|
|
13
|
+
*
|
|
14
|
+
* The Admin API reports the Rails error `code` and the values the validation
|
|
15
|
+
* interpolates, so the copy comes from the dashboard's locale files rather
|
|
16
|
+
* than from a message the server resolved in the store's locale. Keys are
|
|
17
|
+
* tried per-attribute first (`admin.validation.codes.quantity.greater_than`), then
|
|
18
|
+
* generally (`admin.validation.codes.greater_than`); the server's `message` is the
|
|
19
|
+
* fallback, which is what an extension's own error code gets.
|
|
20
|
+
*
|
|
21
|
+
* @param field the attribute the failure is attached to
|
|
22
|
+
* @param entry the API's detail entry, or a plain string from an older payload
|
|
23
|
+
* @returns the message to render
|
|
24
|
+
*/
|
|
25
|
+
function resolveDetailMessage(field: string, entry: string | ValidationErrorDetail): string {
|
|
26
|
+
if (typeof entry === 'string') return entry
|
|
27
|
+
|
|
28
|
+
const { code, message, specific, ...interpolation } = entry
|
|
29
|
+
if (!code) return message
|
|
30
|
+
|
|
31
|
+
// Per-attribute first: `admin.validation.codes.url.invalid` is written for this
|
|
32
|
+
// one field and beats both the generic key and the server.
|
|
33
|
+
const attributeKey = `admin.validation.codes.${field}.${code}`
|
|
34
|
+
if (i18n.exists(attributeKey)) return i18n.t(attributeKey, interpolation)
|
|
35
|
+
|
|
36
|
+
// `specific` means the model overrode the code's own wording with something
|
|
37
|
+
// more useful — the webhook URL validation reports `invalid` but answers
|
|
38
|
+
// "must be a valid http or https URL". Translating the code would throw
|
|
39
|
+
// that away. The server decides this, because only it can compare against
|
|
40
|
+
// the code's default in the locale the message was resolved in.
|
|
41
|
+
if (specific) return message
|
|
42
|
+
|
|
43
|
+
const genericKey = `admin.validation.codes.${code}`
|
|
44
|
+
return i18n.exists(genericKey) ? i18n.t(genericKey, interpolation) : message
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether {@link resolveDetailMessage} will translate this entry rather than
|
|
49
|
+
* fall back to the server's message.
|
|
50
|
+
*
|
|
51
|
+
* A per-attribute key is written for this one field and wins even over a
|
|
52
|
+
* message the model worded itself; the generic key only applies when the
|
|
53
|
+
* server did not mark the message as its own.
|
|
54
|
+
*/
|
|
55
|
+
function hasTranslation(field: string, entry: ValidationErrorDetail): boolean {
|
|
56
|
+
if (i18n.exists(`admin.validation.codes.${field}.${entry.code}`)) return true
|
|
57
|
+
if (entry.specific) return false
|
|
58
|
+
return i18n.exists(`admin.validation.codes.${entry.code}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The failure summary in the admin's own language: "Name can't be blank,
|
|
63
|
+
* Quantity must be greater than 0", assembled from the translated entries and
|
|
64
|
+
* the attribute names the dashboard already publishes.
|
|
65
|
+
*
|
|
66
|
+
* Returns null when any entry has no translation, in which case the server's
|
|
67
|
+
* sentence is left alone rather than half-rebuilt.
|
|
68
|
+
*
|
|
69
|
+
* @param details the API's per-attribute failures
|
|
70
|
+
* @returns the assembled summary, or null to keep the server's
|
|
71
|
+
*/
|
|
72
|
+
function translatedSummary(
|
|
73
|
+
details: Record<string, Array<string | ValidationErrorDetail>>,
|
|
74
|
+
): string | null {
|
|
75
|
+
const sentences: string[] = []
|
|
76
|
+
|
|
77
|
+
for (const [field, entries] of Object.entries(details)) {
|
|
78
|
+
for (const entry of entries ?? []) {
|
|
79
|
+
// A plain string predates the coded shape and carries nothing to
|
|
80
|
+
// translate; a missing code is a message added as bare text.
|
|
81
|
+
if (typeof entry === 'string' || !entry.code) return null
|
|
82
|
+
|
|
83
|
+
// Resolved exactly as the field error is, so the banner and the input
|
|
84
|
+
// beneath it never disagree, and skipped only when that resolution
|
|
85
|
+
// would fall through to the server's own sentence.
|
|
86
|
+
if (!hasTranslation(field, entry)) return null
|
|
87
|
+
|
|
88
|
+
sentences.push(`${attributeLabel(field)} ${resolveDetailMessage(field, entry)}`.trim())
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return sentences.length > 0 ? sentences.join(', ') : null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Display name for an attribute, from the same keys the forms label their
|
|
97
|
+
* inputs with. Falls back to the humanized attribute, which is what Rails
|
|
98
|
+
* would have produced anyway.
|
|
99
|
+
*/
|
|
100
|
+
function attributeLabel(field: string): string {
|
|
101
|
+
const key = `admin.fields.${field}.label`
|
|
102
|
+
if (i18n.exists(key)) return i18n.t(key)
|
|
103
|
+
if (field === 'base') return ''
|
|
104
|
+
return field.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
|
|
105
|
+
}
|
|
106
|
+
|
|
4
107
|
/**
|
|
5
108
|
* Map a thrown error from a Spree Admin API mutation onto a react-hook-form
|
|
6
109
|
* instance. Two things happen on every call:
|
|
@@ -43,11 +146,18 @@ import type { FieldValues, Path, UseFormSetError } from 'react-hook-form'
|
|
|
43
146
|
* ```json
|
|
44
147
|
* { "error": {
|
|
45
148
|
* "code": "validation_error",
|
|
46
|
-
* "message": "Code can't be blank,
|
|
47
|
-
* "details": {
|
|
149
|
+
* "message": "Code can't be blank, Quantity must be greater than 0",
|
|
150
|
+
* "details": {
|
|
151
|
+
* "code": [{ "code": "blank", "message": "can't be blank" }],
|
|
152
|
+
* "quantity": [{ "code": "greater_than", "message": "must be greater than 0", "count": 0 }]
|
|
153
|
+
* }
|
|
48
154
|
* } }
|
|
49
155
|
* ```
|
|
50
156
|
*
|
|
157
|
+
* Each entry's `code` is translated from the dashboard's own locale files so
|
|
158
|
+
* the admin reads their interface language rather than the store's; `message`
|
|
159
|
+
* is the fallback for codes with no translation.
|
|
160
|
+
*
|
|
51
161
|
* `details` keys are AR attribute names. `<form-field>.base` is AR's
|
|
52
162
|
* record-level errors collection on a nested association — we treat both
|
|
53
163
|
* the bare `base` key and any `*.base` key as record-level and route them
|
|
@@ -61,11 +171,13 @@ export function mapSpreeErrorsToForm<TFieldValues extends FieldValues>(
|
|
|
61
171
|
|
|
62
172
|
const { details, message } = error
|
|
63
173
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
174
|
+
// The summary banner is the line a merchant actually reads, so it follows
|
|
175
|
+
// their interface language too. It is rebuilt from the translated entries
|
|
176
|
+
// when every one of them translated; a single untranslated code means the
|
|
177
|
+
// server's own sentence is still the more complete summary, and it stays.
|
|
178
|
+
const summary = (details && translatedSummary(details)) || message
|
|
179
|
+
if (summary) {
|
|
180
|
+
setError('root' as Path<TFieldValues>, { type: 'server', message: summary })
|
|
69
181
|
}
|
|
70
182
|
|
|
71
183
|
if (!details) return true
|
|
@@ -79,13 +191,13 @@ export function mapSpreeErrorsToForm<TFieldValues extends FieldValues>(
|
|
|
79
191
|
// nested or associative falls back to the root summary that we already
|
|
80
192
|
// set above. Silently dropping errors onto unrendered field paths is a
|
|
81
193
|
// worse failure mode than redundant root messages.
|
|
82
|
-
for (const [field,
|
|
83
|
-
if (!
|
|
194
|
+
for (const [field, entries] of Object.entries(details)) {
|
|
195
|
+
if (!entries?.length) continue
|
|
84
196
|
if (!isRenderableFieldKey(field)) continue
|
|
85
197
|
|
|
86
198
|
setError(field as Path<TFieldValues>, {
|
|
87
199
|
type: 'server',
|
|
88
|
-
message:
|
|
200
|
+
message: entries.map((entry) => resolveDetailMessage(field, entry)).join(', '),
|
|
89
201
|
})
|
|
90
202
|
}
|
|
91
203
|
|
package/src/lib/form-mappers.ts
CHANGED
|
@@ -28,3 +28,79 @@ export function emptyToUndefined(value: unknown): unknown {
|
|
|
28
28
|
if (value === '' || value === null) return undefined
|
|
29
29
|
return value
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The address columns a stock location carries, in either panel's SDK shape.
|
|
34
|
+
* Structural rather than either `StockLocation` type so both the admin and
|
|
35
|
+
* seller panels can map their own rows through the helpers below.
|
|
36
|
+
*/
|
|
37
|
+
export interface StockLocationAddressFields {
|
|
38
|
+
company?: string | null
|
|
39
|
+
address1?: string | null
|
|
40
|
+
address2?: string | null
|
|
41
|
+
city?: string | null
|
|
42
|
+
zipcode?: string | null
|
|
43
|
+
state_code?: string | null
|
|
44
|
+
state_text?: string | null
|
|
45
|
+
country_code?: string | null
|
|
46
|
+
country_name?: string | null
|
|
47
|
+
phone?: string | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The three shapes a stock location's address sits between. A location stores
|
|
52
|
+
* `zipcode` where an address says `postal_code`, and carries no personal name
|
|
53
|
+
* — mapping the fields explicitly keeps that difference in one place instead
|
|
54
|
+
* of leaking a cast into every call.
|
|
55
|
+
*/
|
|
56
|
+
export function stockLocationToAddressBlock(location: StockLocationAddressFields) {
|
|
57
|
+
return {
|
|
58
|
+
company: location.company,
|
|
59
|
+
address1: location.address1,
|
|
60
|
+
address2: location.address2,
|
|
61
|
+
city: location.city,
|
|
62
|
+
state_text: location.state_text,
|
|
63
|
+
postal_code: location.zipcode,
|
|
64
|
+
country_code: location.country_code,
|
|
65
|
+
country_name: location.country_name,
|
|
66
|
+
phone: location.phone,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function stockLocationToEditableAddress(
|
|
71
|
+
location: StockLocationAddressFields & { id: string },
|
|
72
|
+
) {
|
|
73
|
+
return {
|
|
74
|
+
id: location.id,
|
|
75
|
+
company: location.company,
|
|
76
|
+
address1: location.address1,
|
|
77
|
+
address2: location.address2,
|
|
78
|
+
city: location.city,
|
|
79
|
+
postal_code: location.zipcode,
|
|
80
|
+
country_code: location.country_code,
|
|
81
|
+
state_code: location.state_code,
|
|
82
|
+
phone: location.phone,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function editableAddressToStockLocationParams(values: {
|
|
87
|
+
company?: string | null
|
|
88
|
+
address1?: string | null
|
|
89
|
+
address2?: string | null
|
|
90
|
+
city?: string | null
|
|
91
|
+
postal_code?: string | null
|
|
92
|
+
country_code?: string | null
|
|
93
|
+
state_code?: string | null
|
|
94
|
+
phone?: string | null
|
|
95
|
+
}) {
|
|
96
|
+
return {
|
|
97
|
+
company: values.company,
|
|
98
|
+
address1: values.address1,
|
|
99
|
+
address2: values.address2,
|
|
100
|
+
city: values.city,
|
|
101
|
+
zipcode: values.postal_code,
|
|
102
|
+
country_code: values.country_code,
|
|
103
|
+
state_code: values.state_code,
|
|
104
|
+
phone: values.phone,
|
|
105
|
+
}
|
|
106
|
+
}
|