@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
package/src/hooks/use-import.ts
CHANGED
|
@@ -1,15 +1,113 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { toastManager } from '@spree/dashboard-ui'
|
|
2
|
+
import {
|
|
3
|
+
type QueryClient,
|
|
4
|
+
type QueryKey,
|
|
5
|
+
useMutation,
|
|
6
|
+
useQuery,
|
|
7
|
+
useQueryClient,
|
|
8
|
+
} from '@tanstack/react-query'
|
|
9
|
+
import { useEffect, useRef } from 'react'
|
|
10
|
+
import {
|
|
11
|
+
getApiClient,
|
|
12
|
+
type PanelImport,
|
|
13
|
+
type PanelImportCompleteMappingParams,
|
|
14
|
+
type PanelImportCreateParams,
|
|
15
|
+
type PanelImportDelimiter,
|
|
16
|
+
} from '../api-client'
|
|
4
17
|
import { downloadFromApi } from '../lib/download'
|
|
5
|
-
import {
|
|
18
|
+
import { useResourceKey, useResourceKeyBuilder } from '../lib/query-keys'
|
|
19
|
+
import { useTenantId } from '../providers/tenant-provider'
|
|
6
20
|
import { useAuth } from './use-auth'
|
|
7
21
|
|
|
22
|
+
/** Statuses the wizard polls through — row creation and processing. */
|
|
23
|
+
const ACTIVE_STATUSES = new Set(['completed_mapping', 'processing'])
|
|
24
|
+
|
|
25
|
+
const IMPORT_POLL_INTERVAL_MS = 2000
|
|
26
|
+
const ROWS_POLL_INTERVAL_MS = 5000
|
|
27
|
+
|
|
28
|
+
// Caches any import may have written to, plus `imports` itself (history table
|
|
29
|
+
// status column) and `translations` (product-translation CSV updates the
|
|
30
|
+
// coverage grid). Deliberately not per-type — imports are rare, so a few
|
|
31
|
+
// extra refetches beat maintaining a type → resource map (product rows alone
|
|
32
|
+
// fan out to option types/values and categories created on the fly).
|
|
33
|
+
//
|
|
34
|
+
// These are the operator dashboard's key names; a panel that names its lists
|
|
35
|
+
// differently adds its own through the client's `invalidateKeys`.
|
|
36
|
+
// Everything an import type can write. `price-lists` itself stays out: a
|
|
37
|
+
// list's own record does not change when its prices do, and refetching it
|
|
38
|
+
// would reset the form under a merchant's unsaved edits.
|
|
39
|
+
const IMPORT_TOUCHED_RESOURCES = [
|
|
40
|
+
'products',
|
|
41
|
+
'option-types',
|
|
42
|
+
'categories',
|
|
43
|
+
'customers',
|
|
44
|
+
'imports',
|
|
45
|
+
'translations',
|
|
46
|
+
'prices',
|
|
47
|
+
'catalogs',
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
/** Whether the import's pipeline is still running (the poll's continue predicate). */
|
|
51
|
+
export function isImportActive(status: string | undefined): boolean {
|
|
52
|
+
return !!status && ACTIVE_STATUSES.has(status)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Marks every cache an import can have written as stale. The pipeline writes
|
|
57
|
+
* records server-side, outside any tracked mutation, so nothing else ever
|
|
58
|
+
* does; called when an import finishes, and when its wizard is closed while
|
|
59
|
+
* it is still running (after which nothing is left to see it finish).
|
|
60
|
+
*/
|
|
61
|
+
export function invalidateImportTouchedResources(
|
|
62
|
+
queryClient: QueryClient,
|
|
63
|
+
buildKey: (resource: string) => QueryKey,
|
|
64
|
+
) {
|
|
65
|
+
const extraKeys = getApiClient().imports?.invalidateKeys
|
|
66
|
+
for (const resource of new Set([...IMPORT_TOUCHED_RESOURCES, ...(extraKeys ?? [])])) {
|
|
67
|
+
queryClient.invalidateQueries({ queryKey: buildKey(resource) })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The panel's import resource.
|
|
73
|
+
*
|
|
74
|
+
* Throws rather than returning undefined: every hook here is reached from UI
|
|
75
|
+
* that only renders when the panel registered one, so a miss is a boot
|
|
76
|
+
* misconfiguration and should say so where it happens.
|
|
77
|
+
*/
|
|
78
|
+
function imports() {
|
|
79
|
+
const resource = getApiClient().imports
|
|
80
|
+
if (!resource) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
'@spree/dashboard-core: this panel registered no `imports` client. ' +
|
|
83
|
+
'Add one in setApiClient(...) before rendering import UI.',
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return resource
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Create-time parameters beyond the ones every import shares. */
|
|
91
|
+
export type ImportCreateExtras = Omit<
|
|
92
|
+
PanelImportCreateParams,
|
|
93
|
+
'type' | 'attachment' | 'preferred_delimiter' | 'results_url'
|
|
94
|
+
>
|
|
95
|
+
|
|
8
96
|
export interface CreateImportInput {
|
|
9
|
-
type:
|
|
97
|
+
type: string
|
|
10
98
|
/** Signed blob id of the already direct-uploaded CSV (see `FileUploadField`). */
|
|
11
99
|
signedId: string
|
|
12
|
-
preferredDelimiter?:
|
|
100
|
+
preferredDelimiter?: PanelImportDelimiter
|
|
101
|
+
/**
|
|
102
|
+
* Whatever else the import type needs at create — the price list a
|
|
103
|
+
* `price_list_prices` import merges into, say. Passed through untouched.
|
|
104
|
+
*/
|
|
105
|
+
params?: ImportCreateExtras
|
|
106
|
+
/**
|
|
107
|
+
* Where the import-done email should link back to. Defaults to this panel's
|
|
108
|
+
* own imports view under the current tenant.
|
|
109
|
+
*/
|
|
110
|
+
resultsPath?: string
|
|
13
111
|
}
|
|
14
112
|
|
|
15
113
|
/**
|
|
@@ -17,32 +115,157 @@ export interface CreateImportInput {
|
|
|
17
115
|
* `mapping` state and carries the mapping payload.
|
|
18
116
|
*/
|
|
19
117
|
export function useCreateImport() {
|
|
20
|
-
const
|
|
118
|
+
const tenantId = useTenantId()
|
|
21
119
|
|
|
22
120
|
return useMutation({
|
|
23
|
-
mutationFn: ({
|
|
24
|
-
|
|
121
|
+
mutationFn: ({
|
|
122
|
+
type,
|
|
123
|
+
signedId,
|
|
124
|
+
preferredDelimiter,
|
|
125
|
+
params,
|
|
126
|
+
resultsPath,
|
|
127
|
+
}: CreateImportInput): Promise<PanelImport> =>
|
|
128
|
+
imports().create({
|
|
129
|
+
...params,
|
|
25
130
|
type,
|
|
26
131
|
attachment: signedId,
|
|
27
132
|
preferred_delimiter: preferredDelimiter,
|
|
28
133
|
// The import-done email deep-links back to the wizard (`?import=<id>`
|
|
29
134
|
// appended server-side). Only honored when this origin is on the
|
|
30
135
|
// store's allowed-origins list.
|
|
31
|
-
results_url: `${window.location.origin}
|
|
136
|
+
results_url: `${window.location.origin}${resultsPath ?? `/${tenantId}/settings/imports`}`,
|
|
32
137
|
}),
|
|
33
138
|
})
|
|
34
139
|
}
|
|
35
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Single import, polled every 2s while the pipeline is running. `mapping`
|
|
143
|
+
* (user-driven) and terminal statuses don't poll — a mapping-state refetch
|
|
144
|
+
* would re-read the attached CSV server-side on every tick.
|
|
145
|
+
*
|
|
146
|
+
* A finished import (completed, or failed — earlier rows may still have been
|
|
147
|
+
* written) invalidates every cache imports can touch: the pipeline writes
|
|
148
|
+
* records server-side, outside any tracked mutation, so nothing else ever
|
|
149
|
+
* marks those lists stale and "View products" would keep serving the
|
|
150
|
+
* pre-import cache.
|
|
151
|
+
*/
|
|
152
|
+
export function useImport(id: string) {
|
|
153
|
+
const queryClient = useQueryClient()
|
|
154
|
+
const buildKey = useResourceKeyBuilder()
|
|
155
|
+
|
|
156
|
+
const query = useQuery({
|
|
157
|
+
queryKey: useResourceKey('imports', id),
|
|
158
|
+
queryFn: () => imports().get(id),
|
|
159
|
+
enabled: !!id,
|
|
160
|
+
refetchInterval: (query) =>
|
|
161
|
+
isImportActive(query.state.data?.status) ? IMPORT_POLL_INTERVAL_MS : false,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// Fires when the poll lands on a finished status — including again after
|
|
165
|
+
// each retry pass (`finished` flips false → true anew). Reopening an
|
|
166
|
+
// already-finished import refires it; harmless, invalidation is idempotent.
|
|
167
|
+
const status = query.data?.status
|
|
168
|
+
const finished = status === 'completed' || status === 'failed'
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (finished) invalidateImportTouchedResources(queryClient, buildKey)
|
|
171
|
+
}, [finished, queryClient, buildKey])
|
|
172
|
+
|
|
173
|
+
// Unmounted while the import is still running — the wizard closed, or the
|
|
174
|
+
// merchant navigated away — nothing is left to see it finish, so the caches
|
|
175
|
+
// it will have written are marked stale now. An import the poll has not
|
|
176
|
+
// answered for yet counts as running: nothing says it is finished.
|
|
177
|
+
const unfinished = useRef(true)
|
|
178
|
+
unfinished.current = !finished
|
|
179
|
+
useEffect(
|
|
180
|
+
() => () => {
|
|
181
|
+
if (unfinished.current) invalidateImportTouchedResources(queryClient, buildKey)
|
|
182
|
+
},
|
|
183
|
+
[queryClient, buildKey],
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return query
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Rows of an import (the failure report), optionally polled while processing. */
|
|
190
|
+
export function useImportRows(
|
|
191
|
+
importId: string,
|
|
192
|
+
params: Record<string, unknown>,
|
|
193
|
+
options?: { poll?: boolean },
|
|
194
|
+
) {
|
|
195
|
+
return useQuery({
|
|
196
|
+
queryKey: useResourceKey('imports', importId, 'rows', params),
|
|
197
|
+
queryFn: () => imports().rows.list(importId, params),
|
|
198
|
+
enabled: !!importId,
|
|
199
|
+
refetchInterval: options?.poll ? ROWS_POLL_INTERVAL_MS : false,
|
|
200
|
+
placeholderData: (previous) => previous,
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function useCompleteMapping(id: string) {
|
|
205
|
+
const queryClient = useQueryClient()
|
|
206
|
+
const buildKey = useResourceKeyBuilder()
|
|
207
|
+
|
|
208
|
+
return useMutation({
|
|
209
|
+
mutationFn: (params?: PanelImportCompleteMappingParams) =>
|
|
210
|
+
imports().completeMapping(id, params),
|
|
211
|
+
onSuccess: (imp) => {
|
|
212
|
+
queryClient.setQueryData<PanelImport>(buildKey('imports', id), imp)
|
|
213
|
+
// Keep the history table's status column in sync.
|
|
214
|
+
queryClient.invalidateQueries({ queryKey: buildKey('imports') })
|
|
215
|
+
},
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function useRetryFailedRows(id: string) {
|
|
220
|
+
const queryClient = useQueryClient()
|
|
221
|
+
const buildKey = useResourceKeyBuilder()
|
|
222
|
+
|
|
223
|
+
return useMutation({
|
|
224
|
+
mutationFn: () => imports().retryFailedRows(id),
|
|
225
|
+
onSuccess: (imp) => {
|
|
226
|
+
queryClient.setQueryData<PanelImport>(buildKey('imports', id), imp)
|
|
227
|
+
// `refetchType: 'none'` refreshes the history list on its next mount
|
|
228
|
+
// without refetching the active wizard detail query: the retry response
|
|
229
|
+
// already carried the fresh `processing` status we just cached, and an
|
|
230
|
+
// immediate refetch would race the (fast) background retry job — often
|
|
231
|
+
// returning `completed` before the "Retrying failed rows" state is ever
|
|
232
|
+
// rendered. Marking stale keeps the poll as the single source of the
|
|
233
|
+
// completion transition.
|
|
234
|
+
queryClient.invalidateQueries({ queryKey: buildKey('imports'), refetchType: 'none' })
|
|
235
|
+
},
|
|
236
|
+
onError: (err) => {
|
|
237
|
+
toastManager.add({ type: 'error', title: err instanceof Error ? err.message : String(err) })
|
|
238
|
+
},
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function useDeleteImport() {
|
|
243
|
+
const queryClient = useQueryClient()
|
|
244
|
+
const buildKey = useResourceKeyBuilder()
|
|
245
|
+
|
|
246
|
+
return useMutation({
|
|
247
|
+
mutationFn: (id: string) => imports().delete(id),
|
|
248
|
+
onSuccess: (_data, id) => {
|
|
249
|
+
queryClient.removeQueries({ queryKey: buildKey('imports', id) })
|
|
250
|
+
queryClient.invalidateQueries({ queryKey: buildKey('imports') })
|
|
251
|
+
},
|
|
252
|
+
onError: (err) => {
|
|
253
|
+
toastManager.add({ type: 'error', title: err instanceof Error ? err.message : String(err) })
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
36
258
|
/** Downloads the CSV template for an import type. */
|
|
37
259
|
export function useDownloadImportTemplate() {
|
|
38
260
|
const { token } = useAuth()
|
|
39
261
|
|
|
40
262
|
return useMutation({
|
|
41
|
-
mutationFn: (type:
|
|
263
|
+
mutationFn: (type: string) =>
|
|
42
264
|
downloadFromApi(
|
|
43
265
|
token,
|
|
44
|
-
|
|
266
|
+
imports().templateUrl(type),
|
|
45
267
|
'import_template.csv',
|
|
268
|
+
getApiClient().downloadHeaders?.() ?? {},
|
|
46
269
|
),
|
|
47
270
|
})
|
|
48
271
|
}
|
|
@@ -60,11 +283,12 @@ export function useDownloadImportExample() {
|
|
|
60
283
|
const { token } = useAuth()
|
|
61
284
|
|
|
62
285
|
return useMutation({
|
|
63
|
-
mutationFn: (type:
|
|
286
|
+
mutationFn: (type: string) =>
|
|
64
287
|
downloadFromApi(
|
|
65
288
|
token,
|
|
66
|
-
|
|
289
|
+
imports().exampleUrl(type),
|
|
67
290
|
`${type}.csv`,
|
|
291
|
+
getApiClient().downloadHeaders?.() ?? {},
|
|
68
292
|
),
|
|
69
293
|
})
|
|
70
294
|
}
|
|
@@ -74,11 +298,12 @@ export function useDownloadImportOriginal() {
|
|
|
74
298
|
const { token } = useAuth()
|
|
75
299
|
|
|
76
300
|
return useMutation({
|
|
77
|
-
mutationFn: (imp:
|
|
301
|
+
mutationFn: (imp: PanelImport) =>
|
|
78
302
|
downloadFromApi(
|
|
79
303
|
token,
|
|
80
|
-
imp.original_file_url ??
|
|
304
|
+
imp.original_file_url ?? imports().downloadUrl(imp.id),
|
|
81
305
|
imp.original_filename ?? 'import.csv',
|
|
306
|
+
getApiClient().downloadHeaders?.() ?? {},
|
|
82
307
|
),
|
|
83
308
|
})
|
|
84
309
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { PaginationMeta } from '@spree/dashboard-ui'
|
|
2
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
3
|
+
import i18n from 'i18next'
|
|
4
|
+
import {
|
|
5
|
+
getApiClient,
|
|
6
|
+
type PanelPackageType,
|
|
7
|
+
type PanelPackageTypeCreateParams,
|
|
8
|
+
type PanelPackageTypeParams,
|
|
9
|
+
type PanelPackageTypeWrites,
|
|
10
|
+
} from '../api-client'
|
|
11
|
+
import { useResourceKey, useResourceKeyBuilder } from '../lib/query-keys'
|
|
12
|
+
import { useResourceMutation } from './use-resource-mutation'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The registered panel's package-type resource.
|
|
16
|
+
*
|
|
17
|
+
* Throws rather than answering undefined: every hook here is only reachable
|
|
18
|
+
* from the packaging page, and a panel that routes to it without registering
|
|
19
|
+
* the resource is misconfigured — failing at the call site says so far more
|
|
20
|
+
* clearly than a handful of "cannot read property of undefined" errors.
|
|
21
|
+
*/
|
|
22
|
+
function resource() {
|
|
23
|
+
const packageTypes = getApiClient().packageTypes
|
|
24
|
+
if (!packageTypes) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'@spree/dashboard-core: this panel registered no `packageTypes` resource, so the ' +
|
|
27
|
+
'package-types page cannot load. Add one in setApiClient(), or drop the route.',
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
return packageTypes
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The same resource narrowed to the writing shape.
|
|
35
|
+
*
|
|
36
|
+
* The narrowing is checked once here rather than at each call site: the page
|
|
37
|
+
* only renders its sheets when `canWritePackageTypes()` is true, and the
|
|
38
|
+
* panel's registration is what makes that so.
|
|
39
|
+
*/
|
|
40
|
+
function writableResource(): PanelPackageTypeWrites {
|
|
41
|
+
const packageTypes = resource()
|
|
42
|
+
if (!('create' in packageTypes)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'@spree/dashboard-core: this panel registered `packageTypes` reads only, so packaging ' +
|
|
45
|
+
'cannot be written. Register get/create/update, or drop the settings route.',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return packageTypes
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function usePackageType(id: string | undefined) {
|
|
52
|
+
return useQuery({
|
|
53
|
+
queryKey: useResourceKey('package-types', id ?? 'noop'),
|
|
54
|
+
queryFn: () => writableResource().get(id as string),
|
|
55
|
+
enabled: !!id,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useCreatePackageType() {
|
|
60
|
+
return useResourceMutation<PanelPackageType, Error, PanelPackageTypeCreateParams>({
|
|
61
|
+
mutationFn: (params) => writableResource().create(params),
|
|
62
|
+
invalidate: [['package-types'], ['panel-form-carton-package-types']],
|
|
63
|
+
successMessage: i18n.t('admin.package_types.messages.added'),
|
|
64
|
+
errorMessage: i18n.t('admin.errors.failed_to_create'),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function useUpdatePackageType(id: string) {
|
|
69
|
+
return useResourceMutation<PanelPackageType, Error, PanelPackageTypeParams>({
|
|
70
|
+
mutationFn: (params) => writableResource().update(id, params),
|
|
71
|
+
invalidate: [['package-types'], ['package-types', id], ['panel-form-carton-package-types']],
|
|
72
|
+
successMessage: i18n.t('admin.package_types.messages.updated'),
|
|
73
|
+
errorMessage: i18n.t('admin.errors.failed_to_update'),
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function useDeletePackageType() {
|
|
78
|
+
const queryClient = useQueryClient()
|
|
79
|
+
const buildKey = useResourceKeyBuilder()
|
|
80
|
+
|
|
81
|
+
return useResourceMutation<void, Error, string>({
|
|
82
|
+
mutationFn: (id) => {
|
|
83
|
+
const remove = writableResource().delete
|
|
84
|
+
if (!remove) throw new Error('This panel cannot delete package types.')
|
|
85
|
+
return remove(id)
|
|
86
|
+
},
|
|
87
|
+
invalidate: [['package-types'], ['panel-form-carton-package-types']],
|
|
88
|
+
successMessage: i18n.t('admin.package_types.messages.deleted'),
|
|
89
|
+
errorMessage: i18n.t('admin.errors.failed_to_delete'),
|
|
90
|
+
onSuccess: (_data, id) => {
|
|
91
|
+
queryClient.removeQueries({ queryKey: buildKey('package-types', id) })
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether this panel's API can write packaging at all. A panel registering
|
|
98
|
+
* only `list` gets the variant editor's carton picker and no settings page,
|
|
99
|
+
* so the page hides everything that would write.
|
|
100
|
+
*/
|
|
101
|
+
export function canWritePackageTypes(): boolean {
|
|
102
|
+
const packageTypes = getApiClient().packageTypes
|
|
103
|
+
if (packageTypes === undefined) return false
|
|
104
|
+
|
|
105
|
+
// All three, not just `create`: the page renders Edit off this answer, and
|
|
106
|
+
// a plain JavaScript host can register a partial object the write shape's
|
|
107
|
+
// type would have refused.
|
|
108
|
+
return ['get', 'create', 'update'].every((method) => method in packageTypes)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether this panel's API offers deletion, which not every one does. */
|
|
112
|
+
export function canDeletePackageTypes(): boolean {
|
|
113
|
+
const packageTypes = getApiClient().packageTypes
|
|
114
|
+
return packageTypes !== undefined && 'delete' in packageTypes
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One page of packaging, for `ResourceTable`'s own query. Not a hook — the
|
|
119
|
+
* table calls it with the search params it has already parsed.
|
|
120
|
+
*/
|
|
121
|
+
export function listPackageTypes(params: Record<string, unknown>) {
|
|
122
|
+
// `owner: 'mine'` — the settings page shows what this panel's principal
|
|
123
|
+
// owns, never another owner's rows. On the operator's panel every row is
|
|
124
|
+
// theirs and the param is ignored; on a seller's it hides the
|
|
125
|
+
// marketplace's shared packaging, which the seller reads through the
|
|
126
|
+
// variant editor's carton picker instead. A mixed list read as "packaging
|
|
127
|
+
// is configured" while the seller had recorded none.
|
|
128
|
+
return resource().list({ ...params, owner: 'mine' }) as Promise<{
|
|
129
|
+
data: PanelPackageType[]
|
|
130
|
+
meta: PaginationMeta
|
|
131
|
+
}>
|
|
132
|
+
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { SpreeError } from '@spree/admin-sdk'
|
|
2
|
+
import { toastManager } from '@spree/dashboard-ui'
|
|
2
3
|
import {
|
|
3
4
|
type QueryKey,
|
|
4
5
|
type UseMutationOptions,
|
|
5
6
|
useMutation,
|
|
6
7
|
useQueryClient,
|
|
7
8
|
} from '@tanstack/react-query'
|
|
8
|
-
import { toast } from 'sonner'
|
|
9
9
|
import { i18n } from '../lib/i18n'
|
|
10
10
|
import { withStoreScope } from '../lib/query-keys'
|
|
11
|
-
import {
|
|
11
|
+
import { useTenantId } from '../providers/tenant-provider'
|
|
12
12
|
|
|
13
13
|
interface UseResourceMutationOptions<TData, TError, TVariables>
|
|
14
14
|
extends Omit<UseMutationOptions<TData, TError, TVariables>, 'onSuccess' | 'onError'> {
|
|
15
15
|
/**
|
|
16
16
|
* Query keys to invalidate after success. Pass logical keys without
|
|
17
|
-
*
|
|
18
|
-
* current
|
|
17
|
+
* tenant id (e.g. `[['channels'], ['channels', id]]`) — the hook injects the
|
|
18
|
+
* current tenant id at position 1 automatically so invalidation matches the
|
|
19
19
|
* store-scoped keys used by `ResourceTable` and other queries.
|
|
20
20
|
*
|
|
21
21
|
* Mutations that change setup-task state (see `Spree.store_setup_tasks` on
|
|
@@ -23,6 +23,18 @@ interface UseResourceMutationOptions<TData, TError, TVariables>
|
|
|
23
23
|
* Started checklist and the nav progress badge stay live.
|
|
24
24
|
*/
|
|
25
25
|
invalidate?: QueryKey[]
|
|
26
|
+
/**
|
|
27
|
+
* Key segments that must NOT be refreshed, even when `invalidate` matches
|
|
28
|
+
* them by prefix. `invalidate: [['categories', id]]` also sweeps nested
|
|
29
|
+
* lists like `['categories', id, 'products']`; passing `['products']` here
|
|
30
|
+
* holds those back.
|
|
31
|
+
*
|
|
32
|
+
* Use it where a parent's own save runs alongside writes to one of its
|
|
33
|
+
* nested collections: refreshing the collection from the parent's mutation
|
|
34
|
+
* lands mid-save, painting the pre-save rows for a frame before the real
|
|
35
|
+
* state arrives.
|
|
36
|
+
*/
|
|
37
|
+
doNotInvalidate?: string[]
|
|
26
38
|
/** Toast on success. Pass `false` to disable. Default `'Saved'`. */
|
|
27
39
|
successMessage?: string | false
|
|
28
40
|
/**
|
|
@@ -34,6 +46,12 @@ interface UseResourceMutationOptions<TData, TError, TVariables>
|
|
|
34
46
|
* auth, gateway).
|
|
35
47
|
*/
|
|
36
48
|
errorMessage?: string | false
|
|
49
|
+
/**
|
|
50
|
+
* Toast a field-level 422 too, joining its messages. Set this on a page
|
|
51
|
+
* with no form to render them inline — otherwise the rejection is silent
|
|
52
|
+
* and the merchant sees a button re-enable with no explanation.
|
|
53
|
+
*/
|
|
54
|
+
showValidationErrors?: boolean
|
|
37
55
|
/** Forwarded onSuccess callback. Runs after invalidation + toast. */
|
|
38
56
|
onSuccess?: UseMutationOptions<TData, TError, TVariables>['onSuccess']
|
|
39
57
|
/** Forwarded onError callback. Runs after error toast. */
|
|
@@ -59,11 +77,13 @@ export function useResourceMutation<TData = unknown, TError = Error, TVariables
|
|
|
59
77
|
options: UseResourceMutationOptions<TData, TError, TVariables>,
|
|
60
78
|
) {
|
|
61
79
|
const queryClient = useQueryClient()
|
|
62
|
-
const
|
|
80
|
+
const tenantId = useTenantId()
|
|
63
81
|
const {
|
|
64
82
|
invalidate,
|
|
83
|
+
doNotInvalidate,
|
|
65
84
|
successMessage = i18n.t('admin.messages.saved'),
|
|
66
85
|
errorMessage = i18n.t('admin.errors.generic'),
|
|
86
|
+
showValidationErrors = false,
|
|
67
87
|
onSuccess,
|
|
68
88
|
onError,
|
|
69
89
|
...rest
|
|
@@ -74,26 +94,67 @@ export function useResourceMutation<TData = unknown, TError = Error, TVariables
|
|
|
74
94
|
onSuccess: (data, variables, onMutateResult, ctx) => {
|
|
75
95
|
if (invalidate) {
|
|
76
96
|
for (const key of invalidate) {
|
|
77
|
-
|
|
97
|
+
const scoped = withStoreScope(key, tenantId)
|
|
98
|
+
queryClient.invalidateQueries({
|
|
99
|
+
queryKey: scoped,
|
|
100
|
+
predicate: doNotInvalidate?.length
|
|
101
|
+
? (query) => !query.queryKey.some((part) => doNotInvalidate.includes(part as string))
|
|
102
|
+
: undefined,
|
|
103
|
+
})
|
|
78
104
|
}
|
|
79
105
|
}
|
|
80
106
|
if (successMessage !== false) {
|
|
81
|
-
|
|
107
|
+
toastManager.add({ type: 'success', title: successMessage })
|
|
82
108
|
}
|
|
83
109
|
return onSuccess?.(data, variables, onMutateResult, ctx)
|
|
84
110
|
},
|
|
85
111
|
onError: (error, variables, onMutateResult, ctx) => {
|
|
86
|
-
if (errorMessage !== false &&
|
|
87
|
-
|
|
112
|
+
if (errorMessage !== false && showValidationErrors && isValidationError(error)) {
|
|
113
|
+
toastManager.add({ type: 'error', title: fieldMessages(error) ?? errorMessage })
|
|
114
|
+
} else if (errorMessage !== false && !isValidationError(error)) {
|
|
115
|
+
// A refusal with no field to hang off — a workflow saying the source
|
|
116
|
+
// shelf cannot cover this transfer — says something the caller's
|
|
117
|
+
// generic message does not, so it speaks for itself.
|
|
118
|
+
toastManager.add({ type: 'error', title: refusalMessage(error) ?? errorMessage })
|
|
88
119
|
}
|
|
89
120
|
return onError?.(error, variables, onMutateResult, ctx)
|
|
90
121
|
},
|
|
91
122
|
})
|
|
92
123
|
}
|
|
93
124
|
|
|
94
|
-
// 422 with `details` means the model rejected the payload
|
|
95
|
-
// be blank"). The form renders
|
|
96
|
-
// `mapSpreeErrorsToForm`, so toasting
|
|
125
|
+
// 422 *with* `details` means the model rejected the payload field by field
|
|
126
|
+
// (e.g. "Code can't be blank"). The form renders those next to the offending
|
|
127
|
+
// input via `mapSpreeErrorsToForm`, so toasting them again would be noise.
|
|
128
|
+
//
|
|
129
|
+
// A 422 without `details` is a different animal: a workflow declining the
|
|
130
|
+
// whole operation ("Only a transfer in transit can be received"). Nothing
|
|
131
|
+
// renders it inline, so suppressing it leaves the merchant staring at a page
|
|
132
|
+
// that silently did nothing.
|
|
97
133
|
function isValidationError(error: unknown): boolean {
|
|
98
|
-
return error instanceof SpreeError && error.status === 422
|
|
134
|
+
return error instanceof SpreeError && error.status === 422 && hasFieldDetails(error)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function hasFieldDetails(error: SpreeError): boolean {
|
|
138
|
+
return !!error.details && Object.keys(error.details).length > 0
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Every field message the server sent, as one line. */
|
|
142
|
+
function fieldMessages(error: unknown): string | undefined {
|
|
143
|
+
if (!(error instanceof SpreeError) || !error.details) return undefined
|
|
144
|
+
|
|
145
|
+
const lines = Object.values(error.details).flatMap((entry) =>
|
|
146
|
+
(entry as Array<string | { message?: string }>).map((item) =>
|
|
147
|
+
typeof item === 'string' ? item : item?.message,
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
const joined = lines.filter(Boolean).join('. ')
|
|
151
|
+
|
|
152
|
+
return joined || undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The server's own wording for a refusal that names no field. */
|
|
156
|
+
function refusalMessage(error: unknown): string | undefined {
|
|
157
|
+
if (!(error instanceof SpreeError) || hasFieldDetails(error)) return undefined
|
|
158
|
+
|
|
159
|
+
return error.message || undefined
|
|
99
160
|
}
|