@spree/dashboard-core 0.13.1 → 1.0.0-beta.2
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
|
@@ -13,9 +13,10 @@ import {
|
|
|
13
13
|
import { useQuery } from '@tanstack/react-query'
|
|
14
14
|
import { useMemo, useState } from 'react'
|
|
15
15
|
import { useTranslation } from 'react-i18next'
|
|
16
|
-
import {
|
|
16
|
+
import { getApiClient } from '../api-client'
|
|
17
17
|
import { useAuth } from '../hooks/use-auth'
|
|
18
18
|
import type { TaggableType } from '../lib/table-registry'
|
|
19
|
+
import { useTenantId } from '../providers/tenant-provider'
|
|
19
20
|
|
|
20
21
|
export type { TaggableType }
|
|
21
22
|
|
|
@@ -35,11 +36,18 @@ export function TagCombobox({
|
|
|
35
36
|
const anchorRef = useComboboxAnchor()
|
|
36
37
|
const [inputValue, setInputValue] = useState('')
|
|
37
38
|
const { isAuthenticated } = useAuth()
|
|
39
|
+
const tenantId = useTenantId()
|
|
40
|
+
|
|
41
|
+
// Through the registered client, so a panel without a tag vocabulary
|
|
42
|
+
// simply has none rather than calling an API it holds no key for.
|
|
43
|
+
const tagsClient = getApiClient().tags
|
|
38
44
|
|
|
39
45
|
const { data } = useQuery({
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
// Tenant-scoped: tag vocabularies are per-store, so a shared key would
|
|
47
|
+
// serve one store's tags to the next for as long as the result stays fresh.
|
|
48
|
+
queryKey: ['panel-tags', tenantId, taggableType],
|
|
49
|
+
queryFn: () => tagsClient?.list({ taggable_type: taggableType }),
|
|
50
|
+
enabled: isAuthenticated && Boolean(tagsClient),
|
|
43
51
|
staleTime: 60_000,
|
|
44
52
|
})
|
|
45
53
|
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Breadcrumb,
|
|
3
|
+
BreadcrumbItem,
|
|
4
|
+
BreadcrumbLink,
|
|
5
|
+
BreadcrumbList,
|
|
6
|
+
BreadcrumbPage,
|
|
7
|
+
BreadcrumbSeparator,
|
|
8
|
+
} from '@spree/dashboard-ui'
|
|
9
|
+
import { MenuIcon } from '@spree/dashboard-ui/icons'
|
|
10
|
+
import { Link, useParams, useRouterState } from '@tanstack/react-router'
|
|
11
|
+
import { Fragment, type ReactNode, useMemo } from 'react'
|
|
12
|
+
import { useTranslation } from 'react-i18next'
|
|
13
|
+
import { isPathWithin, type NavEntry, resolveNavLabel, useNavEntries } from '../lib/nav-registry'
|
|
14
|
+
import { type SettingsNavEntry, useSettingsNav } from '../lib/settings-nav-registry'
|
|
15
|
+
|
|
16
|
+
interface Crumb {
|
|
17
|
+
label: string
|
|
18
|
+
/** Omitted on the final crumb, which renders as plain text. */
|
|
19
|
+
to?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Location trail for the TopBar, derived from the current path by matching it
|
|
24
|
+
* against the nav registries. Fills the empty space beside the search box and
|
|
25
|
+
* gives deep pages the orientation the collapsed sidebars stop providing.
|
|
26
|
+
*
|
|
27
|
+
* Only segments the registries know are named. A record-detail path
|
|
28
|
+
* (`/settings/delivery-profiles/dp_123`) resolves as far as its section and
|
|
29
|
+
* stops there — inventing a crumb for an id the shell can't name would be
|
|
30
|
+
* worse than a shorter trail, and the page's own `PageHeader` carries the
|
|
31
|
+
* record's title anyway.
|
|
32
|
+
*/
|
|
33
|
+
export function TopBarBreadcrumbs({ tenantId }: { tenantId?: string } = {}) {
|
|
34
|
+
const trail = useCrumbs(tenantId)
|
|
35
|
+
if (!trail) return null
|
|
36
|
+
|
|
37
|
+
return <CrumbTrail crumbs={trail.crumbs} pathname={trail.pathname} className="min-w-0" />
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Breadcrumbs for a narrow screen, as a row beneath `MobileTopBar` rather
|
|
42
|
+
* than inside it — that bar has no width to spare on a phone, so the trail
|
|
43
|
+
* lives on its own line and scrolls sideways instead of wrapping.
|
|
44
|
+
*
|
|
45
|
+
* Only rendered where it earns the vertical space: inside settings, and on
|
|
46
|
+
* record pages. A top-level list page would only repeat its own heading.
|
|
47
|
+
*
|
|
48
|
+
* Inside settings the leading crumb is a button rather than a link — it opens
|
|
49
|
+
* the settings nav sheet, which is the fastest way between two settings pages
|
|
50
|
+
* on a screen with no settings sidebar.
|
|
51
|
+
*/
|
|
52
|
+
export function MobileBreadcrumbBar({
|
|
53
|
+
onOpenSettingsNav,
|
|
54
|
+
tenantId,
|
|
55
|
+
}: {
|
|
56
|
+
onOpenSettingsNav?: () => void
|
|
57
|
+
/** See `SettingsSidebar` — a seller id on the marketplace panel. */
|
|
58
|
+
tenantId?: string
|
|
59
|
+
}) {
|
|
60
|
+
const trail = useCrumbs(tenantId)
|
|
61
|
+
if (!trail) return null
|
|
62
|
+
|
|
63
|
+
const { crumbs, pathname, inSettings } = trail
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex h-11 shrink-0 items-center border-b border-border bg-background px-4 md:hidden">
|
|
67
|
+
<CrumbTrail
|
|
68
|
+
crumbs={crumbs}
|
|
69
|
+
pathname={pathname}
|
|
70
|
+
className="min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
71
|
+
// In settings the leading crumb opens the nav sheet instead of
|
|
72
|
+
// navigating — it is the only way between settings pages on a phone.
|
|
73
|
+
renderFirst={
|
|
74
|
+
inSettings && onOpenSettingsNav
|
|
75
|
+
? (crumb) => (
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={onOpenSettingsNav}
|
|
79
|
+
// 44px tall via the row; the negative inline margin keeps the
|
|
80
|
+
// text on the page gutter while the tap area overhangs it.
|
|
81
|
+
className="-ms-2 inline-flex h-11 items-center gap-1 rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
82
|
+
>
|
|
83
|
+
<MenuIcon className="size-4" />
|
|
84
|
+
{crumb.label}
|
|
85
|
+
</button>
|
|
86
|
+
)
|
|
87
|
+
: undefined
|
|
88
|
+
}
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function CrumbTrail({
|
|
95
|
+
crumbs,
|
|
96
|
+
pathname,
|
|
97
|
+
className,
|
|
98
|
+
renderFirst,
|
|
99
|
+
}: {
|
|
100
|
+
crumbs: Crumb[]
|
|
101
|
+
pathname: string
|
|
102
|
+
className?: string
|
|
103
|
+
/** Replaces the leading crumb's content — used for the settings nav trigger. */
|
|
104
|
+
renderFirst?: (crumb: Crumb) => ReactNode
|
|
105
|
+
}) {
|
|
106
|
+
return (
|
|
107
|
+
<Breadcrumb className={className}>
|
|
108
|
+
<BreadcrumbList className="flex-nowrap">
|
|
109
|
+
{crumbs.map((crumb, index) => {
|
|
110
|
+
// The trail ends in a link, not plain text, when the last crumb names
|
|
111
|
+
// a section the current page sits *inside* (a record page, whose own
|
|
112
|
+
// name the registries can't supply) — there it is the way back up.
|
|
113
|
+
const isCurrentPage = crumb.to === pathname
|
|
114
|
+
const custom = index === 0 ? renderFirst?.(crumb) : undefined
|
|
115
|
+
return (
|
|
116
|
+
// Crumbs are a fixed trail for one path — no stable id beyond
|
|
117
|
+
// position. The separator is a sibling `<li>`, never a child of
|
|
118
|
+
// BreadcrumbItem — nesting one list item inside another is invalid.
|
|
119
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: positional by nature
|
|
120
|
+
<Fragment key={index}>
|
|
121
|
+
<BreadcrumbItem className="min-w-0">
|
|
122
|
+
{custom ??
|
|
123
|
+
(isCurrentPage || !crumb.to ? (
|
|
124
|
+
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
|
|
125
|
+
) : (
|
|
126
|
+
<BreadcrumbLink asChild>
|
|
127
|
+
<Link to={crumb.to} className="truncate">
|
|
128
|
+
{crumb.label}
|
|
129
|
+
</Link>
|
|
130
|
+
</BreadcrumbLink>
|
|
131
|
+
))}
|
|
132
|
+
</BreadcrumbItem>
|
|
133
|
+
{index < crumbs.length - 1 && <BreadcrumbSeparator />}
|
|
134
|
+
</Fragment>
|
|
135
|
+
)
|
|
136
|
+
})}
|
|
137
|
+
</BreadcrumbList>
|
|
138
|
+
</Breadcrumb>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Resolves the trail for the current path, or null when there is nothing worth showing. */
|
|
143
|
+
function useCrumbs(tenantId?: string): {
|
|
144
|
+
crumbs: Crumb[]
|
|
145
|
+
pathname: string
|
|
146
|
+
inSettings: boolean
|
|
147
|
+
} | null {
|
|
148
|
+
const { t } = useTranslation()
|
|
149
|
+
const { storeId: routeStoreId } = useParams({ strict: false }) as { storeId?: string }
|
|
150
|
+
const storeId = tenantId ?? routeStoreId
|
|
151
|
+
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
152
|
+
const { main, bottom } = useNavEntries()
|
|
153
|
+
const settings = useSettingsNav()
|
|
154
|
+
|
|
155
|
+
// Memoized because both breadcrumb surfaces are mounted at once (the
|
|
156
|
+
// responsive split is CSS, not conditional mounting), so an unmemoized pass
|
|
157
|
+
// would run twice on every render of the app-wide TopBar.
|
|
158
|
+
return useMemo(() => {
|
|
159
|
+
if (!storeId) return null
|
|
160
|
+
|
|
161
|
+
const crumbs = buildCrumbs({ pathname, storeId, main, bottom, settings: settings.all, t })
|
|
162
|
+
// A lone crumb naming the page you are already on says nothing the
|
|
163
|
+
// PageHeader doesn't. A lone crumb naming the *section* of a record page
|
|
164
|
+
// does — it is the way back up — so that one stays.
|
|
165
|
+
if (crumbs.length === 0) return null
|
|
166
|
+
if (crumbs.length === 1 && crumbs[0].to === pathname) return null
|
|
167
|
+
|
|
168
|
+
return { crumbs, pathname, inSettings: isPathWithin(pathname, `/${storeId}/settings`) }
|
|
169
|
+
}, [pathname, storeId, main, bottom, settings.all, t])
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function buildCrumbs({
|
|
173
|
+
pathname,
|
|
174
|
+
storeId,
|
|
175
|
+
main,
|
|
176
|
+
bottom,
|
|
177
|
+
settings,
|
|
178
|
+
t,
|
|
179
|
+
}: {
|
|
180
|
+
pathname: string
|
|
181
|
+
storeId: string
|
|
182
|
+
main: NavEntry[]
|
|
183
|
+
bottom: NavEntry[]
|
|
184
|
+
settings: SettingsNavEntry[]
|
|
185
|
+
t: (key: string) => string
|
|
186
|
+
}): Crumb[] {
|
|
187
|
+
const settingsRoot = `/${storeId}/settings`
|
|
188
|
+
|
|
189
|
+
if (isPathWithin(pathname, settingsRoot)) {
|
|
190
|
+
const crumbs: Crumb[] = [{ label: t('admin.settings_page.title'), to: settingsRoot }]
|
|
191
|
+
const entry = bestMatch(settings, (e) => `${settingsRoot}${e.path}`, pathname)
|
|
192
|
+
if (entry) {
|
|
193
|
+
crumbs.push({ label: resolveNavLabel(entry, t), to: `${settingsRoot}${entry.path}` })
|
|
194
|
+
}
|
|
195
|
+
return crumbs
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// One pass handles both a page under its parent's path and a child that sits
|
|
199
|
+
// outside it (Returns lives at `/returns` but nests under Orders), so a
|
|
200
|
+
// matching child alone is enough to claim the trail.
|
|
201
|
+
for (const parent of [...main, ...bottom]) {
|
|
202
|
+
const parentUrl = `/${storeId}${parent.path}`
|
|
203
|
+
const child = bestMatch(parent.children ?? [], (c) => `/${storeId}${c.path}`, pathname)
|
|
204
|
+
const parentMatches = parent.path !== '/' && isPathWithin(pathname, parentUrl)
|
|
205
|
+
if (!child && !parentMatches) continue
|
|
206
|
+
|
|
207
|
+
const crumbs: Crumb[] = [{ label: resolveNavLabel(parent, t), to: parentUrl }]
|
|
208
|
+
if (child) crumbs.push({ label: resolveNavLabel(child, t), to: `/${storeId}${child.path}` })
|
|
209
|
+
return crumbs
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return []
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The matching entry whose URL is longest, so `/webhooks/wh_1` prefers
|
|
217
|
+
* `/webhooks` over a shorter entry that also prefixes it. Written as a scan
|
|
218
|
+
* rather than sort-then-find: this runs for every entry on every navigation,
|
|
219
|
+
* and the ordering is needed only to break ties.
|
|
220
|
+
*/
|
|
221
|
+
function bestMatch<T>(
|
|
222
|
+
entries: readonly T[],
|
|
223
|
+
urlOf: (entry: T) => string,
|
|
224
|
+
pathname: string,
|
|
225
|
+
): T | undefined {
|
|
226
|
+
let best: T | undefined
|
|
227
|
+
let bestLength = -1
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
const url = urlOf(entry)
|
|
230
|
+
if (!isPathWithin(pathname, url) || url.length <= bestLength) continue
|
|
231
|
+
best = entry
|
|
232
|
+
bestLength = url.length
|
|
233
|
+
}
|
|
234
|
+
return best
|
|
235
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// The decision table both wizards close by: which reasons ask before
|
|
4
|
+
// discarding and which pass straight through. The components wire this to Base
|
|
5
|
+
// UI's `onOpenChange` reason; `catalogs.spec.ts` covers that wiring in a
|
|
6
|
+
// browser, and this pins the rules themselves.
|
|
7
|
+
function makeHandler(
|
|
8
|
+
hasUnsavedChanges: boolean,
|
|
9
|
+
confirm: () => Promise<boolean>,
|
|
10
|
+
close: () => void,
|
|
11
|
+
) {
|
|
12
|
+
return async (next: boolean, details?: { reason?: string }) => {
|
|
13
|
+
if (next) return
|
|
14
|
+
if (details?.reason === 'escape-key' || details?.reason === 'outside-press') {
|
|
15
|
+
if (hasUnsavedChanges && !(await confirm())) return
|
|
16
|
+
close()
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
close()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('wizard close guard', () => {
|
|
24
|
+
it('asks before Escape discards unsaved work', async () => {
|
|
25
|
+
const confirm = vi.fn().mockResolvedValue(false)
|
|
26
|
+
const close = vi.fn()
|
|
27
|
+
await makeHandler(true, confirm, close)(false, { reason: 'escape-key' })
|
|
28
|
+
expect(confirm).toHaveBeenCalledOnce()
|
|
29
|
+
expect(close).not.toHaveBeenCalled()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('closes on Escape once discarding is confirmed', async () => {
|
|
33
|
+
const confirm = vi.fn().mockResolvedValue(true)
|
|
34
|
+
const close = vi.fn()
|
|
35
|
+
await makeHandler(true, confirm, close)(false, { reason: 'escape-key' })
|
|
36
|
+
expect(close).toHaveBeenCalledOnce()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('guards a click outside the wizard too', async () => {
|
|
40
|
+
const confirm = vi.fn().mockResolvedValue(false)
|
|
41
|
+
const close = vi.fn()
|
|
42
|
+
await makeHandler(true, confirm, close)(false, { reason: 'outside-press' })
|
|
43
|
+
expect(confirm).toHaveBeenCalledOnce()
|
|
44
|
+
expect(close).not.toHaveBeenCalled()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('does not nag when nothing has been entered', async () => {
|
|
48
|
+
const confirm = vi.fn()
|
|
49
|
+
const close = vi.fn()
|
|
50
|
+
await makeHandler(false, confirm, close)(false, { reason: 'escape-key' })
|
|
51
|
+
expect(confirm).not.toHaveBeenCalled()
|
|
52
|
+
expect(close).toHaveBeenCalledOnce()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useSidebar } from '@spree/dashboard-ui'
|
|
2
|
+
import { useEffect, useRef } from 'react'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Collapses the primary sidebar to its icon rail while `active` is true, and
|
|
6
|
+
* restores the previous state when it goes false.
|
|
7
|
+
*
|
|
8
|
+
* Used by the settings area, where a second full-width nav appears beside the
|
|
9
|
+
* primary one: two stacked 220px columns leave the settings forms squeezed, so
|
|
10
|
+
* the primary nav folds down to icons for the duration.
|
|
11
|
+
*
|
|
12
|
+
* Only the entering and leaving transitions are driven — a manual toggle while
|
|
13
|
+
* `active` stays true is left alone, and is what gets remembered on the way
|
|
14
|
+
* out, so the merchant always keeps the last word.
|
|
15
|
+
*/
|
|
16
|
+
export function useAutoCollapseSidebar(active: boolean) {
|
|
17
|
+
// Transient on purpose: this collapse is the app's doing, not the
|
|
18
|
+
// merchant's, so it must not overwrite their remembered preference.
|
|
19
|
+
const { open, setOpenTransient, isMobile } = useSidebar()
|
|
20
|
+
// Reading `open` through a ref keeps it out of the effect's dependencies:
|
|
21
|
+
// the effect must run on the `active` edges only, never on every toggle.
|
|
22
|
+
const openRef = useRef(open)
|
|
23
|
+
openRef.current = open
|
|
24
|
+
const restoreTo = useRef<boolean | null>(null)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
// On mobile the primary nav is an overlay sheet, not a column competing
|
|
28
|
+
// for width, so there is nothing to reclaim. Undo any collapse this hook
|
|
29
|
+
// already made before bailing out: `useIsMobile` reports false on its
|
|
30
|
+
// first render, so a phone loading straight into settings collapses the
|
|
31
|
+
// rail and only then learns it is mobile. Dropping the snapshot without
|
|
32
|
+
// restoring would strand the rail collapsed for the whole session.
|
|
33
|
+
if (isMobile) {
|
|
34
|
+
if (restoreTo.current) setOpenTransient(true)
|
|
35
|
+
restoreTo.current = null
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (active) {
|
|
40
|
+
// Only snapshot on the way in. Re-running while already active (e.g.
|
|
41
|
+
// navigating between two settings pages) must not re-capture a state
|
|
42
|
+
// this hook itself imposed.
|
|
43
|
+
if (restoreTo.current === null) restoreTo.current = openRef.current
|
|
44
|
+
if (openRef.current) setOpenTransient(false)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (restoreTo.current !== null) {
|
|
49
|
+
const wasOpenOnEntry = restoreTo.current
|
|
50
|
+
restoreTo.current = null
|
|
51
|
+
// Restore only if the rail is still collapsed — i.e. still as this hook
|
|
52
|
+
// left it. A merchant who expanded it during settings has said what they
|
|
53
|
+
// want, and forcing the entry-time value back would overrule them.
|
|
54
|
+
if (wasOpenOnEntry && !openRef.current) setOpenTransient(true)
|
|
55
|
+
}
|
|
56
|
+
}, [active, isMobile, setOpenTransient])
|
|
57
|
+
}
|
|
@@ -32,3 +32,16 @@ export function useCommandPalette(): CommandPaletteState {
|
|
|
32
32
|
}
|
|
33
33
|
return ctx
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The palette state when one is mounted, `null` otherwise.
|
|
38
|
+
*
|
|
39
|
+
* The shared app shell offers a search affordance, but not every panel has a
|
|
40
|
+
* palette behind it — the seller panel deliberately mounts none. This lets the
|
|
41
|
+
* shell omit the trigger there rather than render a button that throws on
|
|
42
|
+
* click, and keeps `useCommandPalette` strict for callers that genuinely
|
|
43
|
+
* require the provider.
|
|
44
|
+
*/
|
|
45
|
+
export function useOptionalCommandPalette(): CommandPaletteState | null {
|
|
46
|
+
return useContext(CommandPaletteContext)
|
|
47
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { type PanelApiClient, setApiClient } from '../api-client'
|
|
3
|
+
import { loadCountries, resetCountriesCache } from './use-countries'
|
|
4
|
+
|
|
5
|
+
function registerClient(listCountries?: PanelApiClient['listCountries']) {
|
|
6
|
+
setApiClient({ listCountries } as PanelApiClient)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
resetCountriesCache()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('loadCountries', () => {
|
|
14
|
+
it('calls the API once and reuses the stored list', async () => {
|
|
15
|
+
const data = [{ iso: 'US', iso3: 'USA', name: 'United States' }]
|
|
16
|
+
const listCountries = vi.fn().mockResolvedValue({ data })
|
|
17
|
+
registerClient(listCountries)
|
|
18
|
+
|
|
19
|
+
const first = await loadCountries()
|
|
20
|
+
const second = await loadCountries()
|
|
21
|
+
|
|
22
|
+
expect(listCountries).toHaveBeenCalledTimes(1)
|
|
23
|
+
expect(first).toBe(second)
|
|
24
|
+
expect(first.data).toEqual(data)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('shares one in-flight request across overlapping callers', async () => {
|
|
28
|
+
let resolveList!: (value: { data: [] }) => void
|
|
29
|
+
const listCountries = vi.fn(
|
|
30
|
+
() =>
|
|
31
|
+
new Promise<{ data: [] }>((resolve) => {
|
|
32
|
+
resolveList = resolve
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
registerClient(listCountries)
|
|
36
|
+
|
|
37
|
+
const first = loadCountries()
|
|
38
|
+
const second = loadCountries()
|
|
39
|
+
resolveList({ data: [] })
|
|
40
|
+
|
|
41
|
+
expect(await first).toBe(await second)
|
|
42
|
+
expect(listCountries).toHaveBeenCalledTimes(1)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('retries after a failed load', async () => {
|
|
46
|
+
const listCountries = vi
|
|
47
|
+
.fn()
|
|
48
|
+
.mockRejectedValueOnce(new Error('network'))
|
|
49
|
+
.mockResolvedValueOnce({ data: [] })
|
|
50
|
+
registerClient(listCountries)
|
|
51
|
+
|
|
52
|
+
await expect(loadCountries()).rejects.toThrow('network')
|
|
53
|
+
await expect(loadCountries()).resolves.toEqual({ data: [] })
|
|
54
|
+
expect(listCountries).toHaveBeenCalledTimes(2)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -1,15 +1,61 @@
|
|
|
1
|
-
import type { Country } from '@spree/admin-sdk'
|
|
2
1
|
import { useQuery } from '@tanstack/react-query'
|
|
3
|
-
import {
|
|
2
|
+
import { getApiClient, type PanelCountry } from '../api-client'
|
|
4
3
|
|
|
4
|
+
/**
|
|
5
|
+
* One process-wide load of the country table.
|
|
6
|
+
*
|
|
7
|
+
* Countries are reference data — the same ~250 rows for every store, every
|
|
8
|
+
* panel, every session — so the first successful fetch is reused for the
|
|
9
|
+
* rest of the page. React Query's cache alone is not enough: it is dropped
|
|
10
|
+
* when the last picker unmounts (`gcTime`) and wiped on logout
|
|
11
|
+
* (`queryClient.clear()`), which is why a country field on every sheet was
|
|
12
|
+
* hitting the API again.
|
|
13
|
+
*
|
|
14
|
+
* A failed load is not stored, so the next picker can retry.
|
|
15
|
+
*/
|
|
16
|
+
let countriesRequest: Promise<{ data: PanelCountry[] }> | null = null
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The country list, loaded at most once per page. Subsequent callers share
|
|
20
|
+
* the same in-flight or resolved promise.
|
|
21
|
+
*
|
|
22
|
+
* @returns The panel's country payload (`{ data }`).
|
|
23
|
+
*/
|
|
24
|
+
export function loadCountries(): Promise<{ data: PanelCountry[] }> {
|
|
25
|
+
if (!countriesRequest) {
|
|
26
|
+
countriesRequest = (getApiClient().listCountries?.() ?? Promise.resolve({ data: [] })).catch(
|
|
27
|
+
(error: unknown) => {
|
|
28
|
+
countriesRequest = null
|
|
29
|
+
throw error
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return countriesRequest
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Drops the stored country list so the next read hits the API. For tests. */
|
|
38
|
+
export function resetCountriesCache(): void {
|
|
39
|
+
countriesRequest = null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Countries for the shared address form, through the registered panel client.
|
|
44
|
+
*
|
|
45
|
+
* Deliberately not `adminClient`: this hook backs the one address form both
|
|
46
|
+
* panels use, and importing the operator's client made a seller's country
|
|
47
|
+
* list come back empty — the form rendered, and "No countries found" was the
|
|
48
|
+
* only symptom.
|
|
49
|
+
*/
|
|
5
50
|
export function useCountries() {
|
|
6
51
|
const { data, isLoading } = useQuery({
|
|
7
52
|
queryKey: ['countries'],
|
|
8
|
-
queryFn:
|
|
9
|
-
staleTime:
|
|
53
|
+
queryFn: loadCountries,
|
|
54
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
55
|
+
gcTime: Number.POSITIVE_INFINITY,
|
|
10
56
|
})
|
|
11
57
|
|
|
12
|
-
const countries:
|
|
58
|
+
const countries: PanelCountry[] = data?.data ?? []
|
|
13
59
|
|
|
14
60
|
return { countries, isLoading }
|
|
15
61
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useMutation } from '@tanstack/react-query'
|
|
2
2
|
import SparkMD5 from 'spark-md5'
|
|
3
|
-
import {
|
|
3
|
+
import { getApiClient } from '../api-client'
|
|
4
4
|
|
|
5
5
|
function computeMD5Checksum(file: File): Promise<string> {
|
|
6
6
|
return new Promise((resolve, reject) => {
|
|
@@ -51,7 +51,16 @@ function sameOriginIfRailsDisk(url: string): string {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
interface DirectUploadOptions {
|
|
55
|
+
/**
|
|
56
|
+
* Upload to private storage instead of the public bucket. Required for files
|
|
57
|
+
* that are only ever served through an authorized, signed link — attaching a
|
|
58
|
+
* blob never moves it between services, so this has to be decided up front.
|
|
59
|
+
*/
|
|
60
|
+
private?: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useDirectUpload(options: DirectUploadOptions = {}) {
|
|
55
64
|
return useMutation({
|
|
56
65
|
mutationFn: async (file: File): Promise<UploadResult> => {
|
|
57
66
|
const checksum = await computeMD5Checksum(file)
|
|
@@ -62,13 +71,17 @@ export function useDirectUpload() {
|
|
|
62
71
|
signed_id: string
|
|
63
72
|
}
|
|
64
73
|
try {
|
|
65
|
-
|
|
74
|
+
const presign = getApiClient().createDirectUpload
|
|
75
|
+
if (!presign) throw new Error('This panel cannot upload files.')
|
|
76
|
+
|
|
77
|
+
response = await presign({
|
|
66
78
|
blob: {
|
|
67
79
|
filename: file.name,
|
|
68
80
|
byte_size: file.size,
|
|
69
81
|
checksum,
|
|
70
82
|
content_type: file.type || 'application/octet-stream',
|
|
71
83
|
},
|
|
84
|
+
...(options.private ? { private: true } : {}),
|
|
72
85
|
})
|
|
73
86
|
} catch (err) {
|
|
74
87
|
throw new Error(`Presign failed: ${err instanceof Error ? err.message : err}`)
|
package/src/hooks/use-export.ts
CHANGED
|
@@ -1,11 +1,30 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { toastManager } from '@spree/dashboard-ui'
|
|
2
2
|
import { useMutation } from '@tanstack/react-query'
|
|
3
|
-
import {
|
|
4
|
-
import { adminClient } from '../client'
|
|
3
|
+
import { getApiClient, type PanelExport, type PanelExportCreateParams } from '../api-client'
|
|
5
4
|
import { downloadFromApi } from '../lib/download'
|
|
6
5
|
import { i18n } from '../lib/i18n'
|
|
7
6
|
import { useAuth } from './use-auth'
|
|
8
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The registered client's export methods.
|
|
10
|
+
*
|
|
11
|
+
* Throws rather than returning undefined: reaching here means a panel rendered
|
|
12
|
+
* the export dialog without registering the methods behind it, and every call
|
|
13
|
+
* would otherwise fail further away with a less useful message.
|
|
14
|
+
*/
|
|
15
|
+
function exportsApi() {
|
|
16
|
+
const api = getApiClient().exports
|
|
17
|
+
|
|
18
|
+
if (!api) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
'@spree/dashboard-core: this panel registered no `exports` on its API client, so it ' +
|
|
21
|
+
'cannot queue an export. Register one, or do not render <ExportButton>.',
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return api
|
|
26
|
+
}
|
|
27
|
+
|
|
9
28
|
const POLL_INTERVAL_MS = 2000
|
|
10
29
|
const POLL_TIMEOUT_MS = 5 * 60 * 1000
|
|
11
30
|
|
|
@@ -16,11 +35,11 @@ class ExportTimeoutError extends Error {
|
|
|
16
35
|
}
|
|
17
36
|
}
|
|
18
37
|
|
|
19
|
-
async function pollUntilDone(id: string): Promise<
|
|
38
|
+
async function pollUntilDone(id: string): Promise<PanelExport> {
|
|
20
39
|
const deadline = Date.now() + POLL_TIMEOUT_MS
|
|
21
40
|
|
|
22
41
|
while (Date.now() < deadline) {
|
|
23
|
-
const exp = await
|
|
42
|
+
const exp = await exportsApi().get(id)
|
|
24
43
|
if (exp.done) return exp
|
|
25
44
|
await new Promise<void>((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
|
|
26
45
|
}
|
|
@@ -28,10 +47,15 @@ async function pollUntilDone(id: string): Promise<Export> {
|
|
|
28
47
|
throw new ExportTimeoutError()
|
|
29
48
|
}
|
|
30
49
|
|
|
31
|
-
async function downloadExportFile(exp:
|
|
50
|
+
async function downloadExportFile(exp: PanelExport, token: string | null): Promise<void> {
|
|
32
51
|
if (!exp.download_url) throw new Error('Export has no download_url')
|
|
33
52
|
|
|
34
|
-
await downloadFromApi(
|
|
53
|
+
await downloadFromApi(
|
|
54
|
+
token,
|
|
55
|
+
exp.download_url,
|
|
56
|
+
exp.filename ?? 'export.csv',
|
|
57
|
+
getApiClient().downloadHeaders?.() ?? {},
|
|
58
|
+
)
|
|
35
59
|
}
|
|
36
60
|
|
|
37
61
|
/**
|
|
@@ -43,30 +67,41 @@ export function useExport() {
|
|
|
43
67
|
const { token } = useAuth()
|
|
44
68
|
|
|
45
69
|
return useMutation({
|
|
46
|
-
mutationFn: async (params:
|
|
70
|
+
mutationFn: async (params: PanelExportCreateParams) => {
|
|
47
71
|
// Per-invocation id so concurrent exports don't collide on a single
|
|
48
72
|
// sticky toast.
|
|
49
73
|
const toastId = `export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
50
|
-
|
|
74
|
+
toastManager.add({
|
|
75
|
+
type: 'loading',
|
|
76
|
+
title: i18n.t('admin.components.export_button.preparing'),
|
|
77
|
+
id: toastId,
|
|
78
|
+
})
|
|
51
79
|
|
|
52
80
|
try {
|
|
53
|
-
const created = await
|
|
81
|
+
const created = await exportsApi().create(params)
|
|
54
82
|
const finished = await pollUntilDone(created.id)
|
|
55
83
|
await downloadExportFile(finished, token)
|
|
56
|
-
|
|
84
|
+
toastManager.add({
|
|
85
|
+
type: 'success',
|
|
86
|
+
title: i18n.t('admin.components.export_button.downloaded'),
|
|
87
|
+
id: toastId,
|
|
88
|
+
})
|
|
57
89
|
return finished
|
|
58
90
|
} catch (err) {
|
|
59
91
|
if (err instanceof ExportTimeoutError) {
|
|
60
|
-
|
|
92
|
+
toastManager.add({
|
|
93
|
+
type: 'info',
|
|
94
|
+
title: i18n.t('admin.components.export_button.email_fallback'),
|
|
61
95
|
id: toastId,
|
|
62
96
|
})
|
|
63
97
|
} else {
|
|
64
|
-
|
|
65
|
-
|
|
98
|
+
toastManager.add({
|
|
99
|
+
type: 'error',
|
|
100
|
+
title: i18n.t('admin.components.export_button.failed', {
|
|
66
101
|
message: err instanceof Error ? err.message : String(err),
|
|
67
102
|
}),
|
|
68
|
-
|
|
69
|
-
)
|
|
103
|
+
id: toastId,
|
|
104
|
+
})
|
|
70
105
|
}
|
|
71
106
|
throw err
|
|
72
107
|
}
|