@spree/dashboard-core 6.0.3 → 6.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/vite/index.js +81 -5
- package/package.json +19 -19
- package/src/api-client.ts +7 -1
- package/src/client.ts +2 -0
- package/src/components/address-form-dialog.tsx +75 -28
- package/src/components/app-shell.tsx +5 -4
- package/src/components/app-sidebar.tsx +2 -1
- package/src/components/bulk-action-bar.tsx +6 -5
- package/src/components/country-state-fields.tsx +3 -0
- package/src/components/currency-select.tsx +6 -4
- package/src/components/developer-inspector-drawer.tsx +322 -0
- package/src/components/file-upload-field.tsx +64 -37
- package/src/components/image-upload-field.tsx +4 -0
- package/src/components/import-wizard-dialog.tsx +14 -12
- package/src/components/invitation-acceptance.tsx +15 -11
- package/src/components/media-editor-dialog.tsx +131 -0
- package/src/components/media-picker-dialog.tsx +866 -0
- package/src/components/media-picker-sheet.tsx +13 -300
- package/src/components/mobile-top-bar.tsx +1 -1
- package/src/components/nav-main.tsx +13 -2
- package/src/components/page-header.tsx +57 -13
- package/src/components/page-tabs.tsx +1 -1
- package/src/components/product-stock-summary.tsx +1 -1
- package/src/components/read-row.tsx +38 -0
- package/src/components/resource-combobox.tsx +1 -1
- package/src/components/resource-image-field.tsx +150 -0
- package/src/components/resource-picker-sheet.tsx +2 -2
- package/src/components/resource-table.tsx +161 -40
- package/src/components/section-heading.tsx +2 -2
- package/src/components/settings-container.tsx +19 -0
- package/src/components/settings-fields.tsx +165 -0
- package/src/components/settings-section.tsx +46 -0
- package/src/components/settings-sidebar.tsx +35 -18
- package/src/components/sheet-top-bar.tsx +7 -8
- package/src/components/sidebar-search.tsx +11 -12
- package/src/components/sidebar-user.tsx +39 -44
- package/src/components/skip-link.tsx +1 -1
- package/src/components/store-switcher.tsx +1 -1
- package/src/components/table-toolbar.tsx +189 -323
- package/src/components/timezone-combobox.tsx +119 -0
- package/src/components/top-bar-breadcrumbs.tsx +1 -1
- package/src/hooks/use-stock-locations.ts +6 -2
- package/src/hooks/use-table-density.ts +50 -0
- package/src/index.ts +19 -2
- package/src/lib/curl.test.ts +18 -0
- package/src/lib/curl.ts +28 -0
- package/src/lib/developer-inspector.test.ts +169 -0
- package/src/lib/developer-inspector.ts +134 -0
- package/src/lib/docs.ts +3 -2
- package/src/lib/fulfillment-items.test.ts +51 -0
- package/src/lib/fulfillment-items.ts +53 -0
- package/src/lib/request-snippets.ts +41 -0
- package/src/lib/table-registry.ts +13 -6
- package/src/lib/timestamp-columns.tsx +1 -1
- package/src/locales/ar.json +120 -14
- package/src/locales/de.json +120 -14
- package/src/locales/en.json +120 -14
- package/src/locales/es.json +120 -14
- package/src/locales/fr.json +120 -14
- package/src/locales/ja.json +120 -14
- package/src/locales/pl.json +120 -14
- package/src/locales/vi.json +120 -14
- package/src/locales/zh-CN.json +120 -14
- package/src/pages/settings-index.tsx +79 -48
- package/src/pages/stock-locations.tsx +14 -6
- package/src/products/digital-asset-edit-sheet.tsx +1 -1
- package/src/products/digital-asset-provider-sheet.tsx +1 -1
- package/src/products/digital-assets-card.tsx +7 -7
- package/src/products/inventory-section.tsx +2 -2
- package/src/products/media-edit-sheet.tsx +136 -133
- package/src/products/product-form-cards.tsx +201 -70
- package/src/products/product-form-mapping.ts +1 -0
- package/src/products/product-schema.ts +8 -0
- package/src/products/product-types.ts +2 -0
- package/src/products/use-product-form-data.ts +30 -3
- package/src/products/variant-edit-sheet.tsx +3 -3
- package/src/products/variants-options-builder.tsx +13 -8
- package/src/products/variants-section.tsx +196 -4
- package/src/providers/auth-provider.tsx +2 -0
- package/src/providers/developer-inspector-provider.tsx +145 -0
- package/src/providers/json-link-resolver-provider.tsx +27 -0
- package/src/sellers/seller-schema.ts +107 -0
- package/src/tables/package-types.tsx +3 -3
- package/src/tables/stock-locations.tsx +15 -14
- package/src/virtual.d.ts +4 -0
- package/src/vite/index.ts +103 -5
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { Media } from '@spree/admin-sdk'
|
|
2
|
+
import { Button } from '@spree/dashboard-ui'
|
|
3
|
+
import { LibraryIcon } from '@spree/dashboard-ui/icons'
|
|
4
|
+
import { useMemo, useState } from 'react'
|
|
5
|
+
import type { UseFormReturn } from 'react-hook-form'
|
|
6
|
+
import { useTranslation } from 'react-i18next'
|
|
7
|
+
import { ImageUploadField } from './image-upload-field'
|
|
8
|
+
import { MediaPickerDialog, type MediaPickerDialogProps } from './media-picker-dialog'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where "Choose from library" looks and uploads. Only the operator's
|
|
12
|
+
* dashboard has a media library, so a panel without one passes nothing and
|
|
13
|
+
* the field offers an upload alone.
|
|
14
|
+
*/
|
|
15
|
+
export type MediaLibrarySource = Pick<MediaPickerDialogProps<Media>, 'search' | 'onUpload'>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The form fields backing one image: a freshly uploaded file
|
|
19
|
+
* (`<name>_signed_id`), a transient object URL for the just-picked file
|
|
20
|
+
* (`<name>_preview_url`), and a flag marking the persisted attachment for
|
|
21
|
+
* removal (`<name>_cleared`). Together they form the three-state machine the
|
|
22
|
+
* API expects — see the `imageParam` mapper in the schema files.
|
|
23
|
+
*
|
|
24
|
+
* The triple is keyed by the attachment's own name, so a resource carrying
|
|
25
|
+
* `logo`/`cover_photo` composes exactly like one carrying `image`.
|
|
26
|
+
*/
|
|
27
|
+
export type ImageFieldsFor<Name extends string> = {
|
|
28
|
+
[K in `${Name}_signed_id` | `${Name}_preview_url`]: K extends `${string}_signed_id`
|
|
29
|
+
? string | null
|
|
30
|
+
: string | null
|
|
31
|
+
} & { [K in `${Name}_cleared`]: boolean }
|
|
32
|
+
|
|
33
|
+
/** The pair categories and collections carry. */
|
|
34
|
+
export type ImageFieldsShape = ImageFieldsFor<'image'> & ImageFieldsFor<'square_image'>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Erased shape used inside the adapter only. The public API stays generic over
|
|
38
|
+
* the attachment name; this is what the three field paths are resolved against.
|
|
39
|
+
*/
|
|
40
|
+
interface ConcreteImageFields {
|
|
41
|
+
image_signed_id: string | null
|
|
42
|
+
image_preview_url: string | null
|
|
43
|
+
image_cleared: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ResourceImageFieldProps<Name extends string, T extends ImageFieldsFor<Name>> {
|
|
47
|
+
form: UseFormReturn<T>
|
|
48
|
+
/** The attachment's name on the model, e.g. `image`, `logo`, `cover_photo`. */
|
|
49
|
+
kind: Name
|
|
50
|
+
serverUrl: string | null
|
|
51
|
+
square?: boolean
|
|
52
|
+
/** Locale namespace holding the `images.*` copy, e.g. `admin.collections`. */
|
|
53
|
+
translationNamespace: string
|
|
54
|
+
/**
|
|
55
|
+
* Copy keys within `<translationNamespace>.images`. Default to the legacy
|
|
56
|
+
* `label`/`square_label` pair the two-image resources use; a resource with
|
|
57
|
+
* more than two attachments names them per field instead.
|
|
58
|
+
*/
|
|
59
|
+
labelKey?: string
|
|
60
|
+
helpKey?: string
|
|
61
|
+
mediaLibrary?: MediaLibrarySource
|
|
62
|
+
/** Called with true while a picked image uploads; see `FileUploadField`. */
|
|
63
|
+
onUploadingChange?: (uploading: boolean) => void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Form adapter over the reusable {@link ImageUploadField}, shared by every
|
|
68
|
+
* resource whose form carries the image triple (categories, collections,
|
|
69
|
+
* sellers). Maps the triple onto the generic controlled `ImageUploadValue`
|
|
70
|
+
* and resolves labels from the owning resource's locale namespace.
|
|
71
|
+
*/
|
|
72
|
+
export function ResourceImageField<Name extends string, T extends ImageFieldsFor<Name>>({
|
|
73
|
+
form,
|
|
74
|
+
kind,
|
|
75
|
+
serverUrl,
|
|
76
|
+
square = false,
|
|
77
|
+
translationNamespace,
|
|
78
|
+
labelKey,
|
|
79
|
+
helpKey,
|
|
80
|
+
mediaLibrary,
|
|
81
|
+
onUploadingChange,
|
|
82
|
+
}: ResourceImageFieldProps<Name, T>) {
|
|
83
|
+
const { t } = useTranslation()
|
|
84
|
+
const [picking, setPicking] = useState(false)
|
|
85
|
+
|
|
86
|
+
// The adapter only ever touches this one image triple, and always by the
|
|
87
|
+
// three names derived from `kind`. Narrow to a concrete triple so the
|
|
88
|
+
// field-path operations type-check: RHF's `Path<T>` cannot resolve a
|
|
89
|
+
// template-literal key against a generic mapped type, and `UseFormReturn`
|
|
90
|
+
// is invariant in its field-values type either way.
|
|
91
|
+
const imageForm = useMemo(() => form as unknown as UseFormReturn<ConcreteImageFields>, [form])
|
|
92
|
+
|
|
93
|
+
const signedIdField = `${kind}_signed_id` as 'image_signed_id'
|
|
94
|
+
const previewField = `${kind}_preview_url` as 'image_preview_url'
|
|
95
|
+
const clearedField = `${kind}_cleared` as 'image_cleared'
|
|
96
|
+
|
|
97
|
+
const resolvedLabelKey = labelKey ?? (square ? 'square_label' : 'label')
|
|
98
|
+
const resolvedHelpKey = helpKey ?? (square ? 'square_help' : 'help')
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="space-y-2">
|
|
102
|
+
<ImageUploadField
|
|
103
|
+
square={square}
|
|
104
|
+
serverUrl={serverUrl}
|
|
105
|
+
label={t(`${translationNamespace}.images.${resolvedLabelKey}`)}
|
|
106
|
+
help={t(`${translationNamespace}.images.${resolvedHelpKey}`)}
|
|
107
|
+
onUploadingChange={onUploadingChange}
|
|
108
|
+
value={{
|
|
109
|
+
signedId: imageForm.watch(signedIdField),
|
|
110
|
+
previewUrl: imageForm.watch(previewField),
|
|
111
|
+
cleared: imageForm.watch(clearedField),
|
|
112
|
+
}}
|
|
113
|
+
onChange={(next) => {
|
|
114
|
+
imageForm.setValue(signedIdField, next.signedId, { shouldDirty: true })
|
|
115
|
+
imageForm.setValue(previewField, next.previewUrl, { shouldDirty: true })
|
|
116
|
+
imageForm.setValue(clearedField, next.cleared, { shouldDirty: true })
|
|
117
|
+
}}
|
|
118
|
+
/>
|
|
119
|
+
|
|
120
|
+
{mediaLibrary && (
|
|
121
|
+
<>
|
|
122
|
+
<Button type="button" variant="outline" size="sm" onClick={() => setPicking(true)}>
|
|
123
|
+
<LibraryIcon className="size-4" />
|
|
124
|
+
{t('admin.media_library.choose_from_library')}
|
|
125
|
+
</Button>
|
|
126
|
+
|
|
127
|
+
<MediaPickerDialog<Media>
|
|
128
|
+
open={picking}
|
|
129
|
+
onOpenChange={setPicking}
|
|
130
|
+
{...mediaLibrary}
|
|
131
|
+
multiple={false}
|
|
132
|
+
queryKey={`image-field-${kind}`}
|
|
133
|
+
// Picking hands over the file's own blob, so this field and the library
|
|
134
|
+
// row point at one file — no upload, no second copy in storage.
|
|
135
|
+
onConfirm={(picked) => {
|
|
136
|
+
const media = picked[0]
|
|
137
|
+
if (!media?.signed_id) return
|
|
138
|
+
|
|
139
|
+
imageForm.setValue(signedIdField, media.signed_id, { shouldDirty: true })
|
|
140
|
+
imageForm.setValue(previewField, media.small_url ?? media.original_url ?? null, {
|
|
141
|
+
shouldDirty: true,
|
|
142
|
+
})
|
|
143
|
+
imageForm.setValue(clearedField, false, { shouldDirty: true })
|
|
144
|
+
}}
|
|
145
|
+
/>
|
|
146
|
+
</>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
@@ -267,7 +267,7 @@ export function ResourcePickerSheet<T extends PickerOption>({
|
|
|
267
267
|
<Loader2Icon className="size-5 animate-spin" />
|
|
268
268
|
</div>
|
|
269
269
|
) : results.length === 0 ? (
|
|
270
|
-
<p className="p-6 text-center text-muted-foreground text-
|
|
270
|
+
<p className="p-6 text-center text-muted-foreground text-base">
|
|
271
271
|
{t('admin.resource_picker.empty')}
|
|
272
272
|
</p>
|
|
273
273
|
) : (
|
|
@@ -285,7 +285,7 @@ export function ResourcePickerSheet<T extends PickerOption>({
|
|
|
285
285
|
disabled={selectableOnPage.length === 0 || selectingAll}
|
|
286
286
|
className="pointer-events-none"
|
|
287
287
|
/>
|
|
288
|
-
<span className="truncate text-
|
|
288
|
+
<span className="truncate text-base">
|
|
289
289
|
{t('admin.resource_picker.select_page', { count: selectableOnPage.length })}
|
|
290
290
|
</span>
|
|
291
291
|
</button>
|
|
@@ -36,6 +36,9 @@ import {
|
|
|
36
36
|
TableHeader,
|
|
37
37
|
TableHeaderRow,
|
|
38
38
|
TableRow,
|
|
39
|
+
Tabs,
|
|
40
|
+
TabsList,
|
|
41
|
+
TabsTrigger,
|
|
39
42
|
Tooltip,
|
|
40
43
|
TooltipContent,
|
|
41
44
|
TooltipTrigger,
|
|
@@ -142,10 +145,27 @@ export const resourceSearchSchema = z.object({
|
|
|
142
145
|
return val
|
|
143
146
|
}, z.array(z.string()))
|
|
144
147
|
.optional(),
|
|
148
|
+
/** The open tab of a table with `views`; absent means the first. */
|
|
149
|
+
view: z.string().optional(),
|
|
145
150
|
})
|
|
146
151
|
|
|
147
152
|
export type ResourceSearch = z.infer<typeof resourceSearchSchema>
|
|
148
153
|
|
|
154
|
+
/**
|
|
155
|
+
* State for a `ResourceTable` held in the component rather than the URL, so
|
|
156
|
+
* several tables can sit on one page. Spread the result onto the table.
|
|
157
|
+
*/
|
|
158
|
+
export function useEmbeddedTableSearch(initial: Partial<ResourceSearch> = {}) {
|
|
159
|
+
const [search, setSearch] = useState<Record<string, unknown>>(initial)
|
|
160
|
+
// Parsed once per change: the table memoizes on the parsed arrays.
|
|
161
|
+
const searchParams = useMemo(() => resourceSearchSchema.parse(search), [search])
|
|
162
|
+
return {
|
|
163
|
+
searchParams,
|
|
164
|
+
onSearchChange: (updates: Record<string, unknown>) =>
|
|
165
|
+
setSearch((previous) => ({ ...previous, ...updates })),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
149
169
|
// ============================================================================
|
|
150
170
|
// Props
|
|
151
171
|
// ============================================================================
|
|
@@ -166,6 +186,20 @@ export interface ResourceActionsContext {
|
|
|
166
186
|
totalCount: number | undefined
|
|
167
187
|
}
|
|
168
188
|
|
|
189
|
+
/**
|
|
190
|
+
* One tab of a list split into parts that are never read together, such as
|
|
191
|
+
* the operator's own stock locations and their sellers'. Each tab is a fixed
|
|
192
|
+
* slice of the list, not a filter the merchant can clear.
|
|
193
|
+
*/
|
|
194
|
+
export interface TableView {
|
|
195
|
+
key: string
|
|
196
|
+
label: string
|
|
197
|
+
/** Request params sent while this tab is open, on top of `defaultParams`. */
|
|
198
|
+
params?: Record<string, unknown>
|
|
199
|
+
/** Columns this tab leaves out of the table, the column picker and the filters. */
|
|
200
|
+
hiddenColumns?: string[]
|
|
201
|
+
}
|
|
202
|
+
|
|
169
203
|
interface ResourceTableProps<T> {
|
|
170
204
|
/** Registry key (e.g., 'products') */
|
|
171
205
|
tableKey: string
|
|
@@ -184,6 +218,12 @@ interface ResourceTableProps<T> {
|
|
|
184
218
|
queryFn: (params: never) => Promise<{ data: T[]; meta: PaginationMeta }>
|
|
185
219
|
/** Current search params from the route */
|
|
186
220
|
searchParams: ResourceSearch
|
|
221
|
+
/**
|
|
222
|
+
* Takes the table's state changes instead of the URL — for a table embedded
|
|
223
|
+
* in a record page beside others, which would otherwise share one `page`
|
|
224
|
+
* and `filters`. Pair with `useEmbeddedTableSearch`.
|
|
225
|
+
*/
|
|
226
|
+
onSearchChange?: (updates: Record<string, unknown>) => void
|
|
187
227
|
/** Title displayed in the toolbar header. Overrides the table definition's title. */
|
|
188
228
|
title?: string
|
|
189
229
|
/**
|
|
@@ -203,6 +243,19 @@ interface ResourceTableProps<T> {
|
|
|
203
243
|
docsPath?: string
|
|
204
244
|
/** Default params always sent with every request (e.g., { complete: 1 } for orders) */
|
|
205
245
|
defaultParams?: Record<string, unknown>
|
|
246
|
+
/** Splits the list into tabs; the first opens by default. */
|
|
247
|
+
views?: TableView[]
|
|
248
|
+
/**
|
|
249
|
+
* Columns left out entirely — not shown, not offered as filters — such as
|
|
250
|
+
* the seller on a seller's own page. Adds to the open view's.
|
|
251
|
+
*/
|
|
252
|
+
hiddenColumns?: string[]
|
|
253
|
+
/**
|
|
254
|
+
* Draws the table as a card — border, corners and lift — holding its
|
|
255
|
+
* toolbar and pagination, with a plain, unpinned header row: for a table
|
|
256
|
+
* embedded among a record page's cards.
|
|
257
|
+
*/
|
|
258
|
+
framed?: boolean
|
|
206
259
|
/**
|
|
207
260
|
* Actions to render in the toolbar (e.g., "Add Product" button). Either a
|
|
208
261
|
* static node, or a function that receives the current table state — use
|
|
@@ -260,11 +313,15 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
260
313
|
queryKey,
|
|
261
314
|
queryFn,
|
|
262
315
|
searchParams,
|
|
316
|
+
onSearchChange,
|
|
263
317
|
title,
|
|
264
318
|
hideHeader = false,
|
|
265
319
|
description,
|
|
266
320
|
docsPath,
|
|
267
|
-
defaultParams,
|
|
321
|
+
defaultParams: baseParams,
|
|
322
|
+
views,
|
|
323
|
+
hiddenColumns: tableHiddenColumns,
|
|
324
|
+
framed = false,
|
|
268
325
|
actions,
|
|
269
326
|
reorder,
|
|
270
327
|
bulkActions,
|
|
@@ -296,8 +353,19 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
296
353
|
search,
|
|
297
354
|
filters,
|
|
298
355
|
columns: urlColumns,
|
|
356
|
+
view: urlView,
|
|
299
357
|
} = searchParams
|
|
300
358
|
|
|
359
|
+
const activeView = views?.find((v) => v.key === urlView) ?? views?.[0]
|
|
360
|
+
const hiddenColumns = useMemo(
|
|
361
|
+
() => [...(tableHiddenColumns ?? []), ...(activeView?.hiddenColumns ?? [])],
|
|
362
|
+
[tableHiddenColumns, activeView],
|
|
363
|
+
)
|
|
364
|
+
const defaultParams = useMemo(
|
|
365
|
+
() => (activeView?.params ? { ...baseParams, ...activeView.params } : baseParams),
|
|
366
|
+
[baseParams, activeView],
|
|
367
|
+
)
|
|
368
|
+
|
|
301
369
|
// When the table is reorderable, sort is locked to the position field
|
|
302
370
|
// ascending — anything else makes drag-and-drop meaningless. Otherwise
|
|
303
371
|
// fall through to the URL params, then the table's declared default,
|
|
@@ -310,21 +378,24 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
310
378
|
const sort = reorder ? positionField : (urlSort ?? defaultSort.field)
|
|
311
379
|
const dir: 'asc' | 'desc' = reorder ? 'asc' : (urlDir ?? defaultSort.direction)
|
|
312
380
|
|
|
313
|
-
const
|
|
381
|
+
const searchable = table.searchable !== false
|
|
382
|
+
const [searchInput, setSearchInput] = useState(searchable ? (search ?? '') : '')
|
|
314
383
|
const deferredSearch = useDeferredValue(searchInput)
|
|
315
384
|
|
|
316
385
|
// Registry columns + dynamic custom-field columns. Memoized so the toolbar's
|
|
317
386
|
// internal memos (FilterPanel `items` stability) keep stable deps.
|
|
318
387
|
const extraColumns = customFieldColumns ?? metafieldColumns
|
|
319
388
|
const allColumns = useMemo(
|
|
320
|
-
() => [...table.columns, ...(extraColumns ?? [])],
|
|
321
|
-
[table, extraColumns],
|
|
389
|
+
() => [...table.columns, ...(extraColumns ?? [])].filter((c) => !hiddenColumns.includes(c.key)),
|
|
390
|
+
[table, extraColumns, hiddenColumns],
|
|
322
391
|
)
|
|
323
392
|
const displayableColumns = useMemo(
|
|
324
393
|
() => allColumns.filter((c) => c.displayable !== false),
|
|
325
394
|
[allColumns],
|
|
326
395
|
)
|
|
327
|
-
const defaultColumnKeys = getDefaultColumnKeys(table)
|
|
396
|
+
const defaultColumnKeys = getDefaultColumnKeys(table).filter(
|
|
397
|
+
(key) => !hiddenColumns.includes(key),
|
|
398
|
+
)
|
|
328
399
|
const visibleColumnKeys = urlColumns ?? defaultColumnKeys
|
|
329
400
|
|
|
330
401
|
const visibleColumns = useMemo(
|
|
@@ -375,7 +446,17 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
375
446
|
const { data, isLoading } = useQuery({
|
|
376
447
|
queryKey: [
|
|
377
448
|
...queryKeyPrefix,
|
|
378
|
-
|
|
449
|
+
// The fixed params are part of the key: a tab changing them would
|
|
450
|
+
// otherwise be answered from the previous tab's cache.
|
|
451
|
+
{
|
|
452
|
+
page,
|
|
453
|
+
limit,
|
|
454
|
+
sort: sortString,
|
|
455
|
+
search: deferredSearch,
|
|
456
|
+
filters,
|
|
457
|
+
expand: columnExpand,
|
|
458
|
+
params: defaultParams,
|
|
459
|
+
},
|
|
379
460
|
],
|
|
380
461
|
queryFn: () => {
|
|
381
462
|
const params: Record<string, unknown> = {
|
|
@@ -470,6 +551,10 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
470
551
|
|
|
471
552
|
// Navigation helpers
|
|
472
553
|
function updateSearch(updates: Record<string, unknown>) {
|
|
554
|
+
if (onSearchChange) {
|
|
555
|
+
onSearchChange(updates)
|
|
556
|
+
return
|
|
557
|
+
}
|
|
473
558
|
navigate({
|
|
474
559
|
search: (prev: Record<string, unknown>) => ({ ...prev, ...updates }) as never,
|
|
475
560
|
})
|
|
@@ -491,6 +576,16 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
491
576
|
})
|
|
492
577
|
}
|
|
493
578
|
|
|
579
|
+
// A filter set on one tab can name a column the next one hides, so a tab
|
|
580
|
+
// starts unfiltered.
|
|
581
|
+
function handleViewChange(key: string) {
|
|
582
|
+
updateSearch({
|
|
583
|
+
view: key === views?.[0]?.key ? undefined : key,
|
|
584
|
+
filters: undefined,
|
|
585
|
+
page: 1,
|
|
586
|
+
})
|
|
587
|
+
}
|
|
588
|
+
|
|
494
589
|
function handleColumnsChange(cols: string[]) {
|
|
495
590
|
const isDefault =
|
|
496
591
|
cols.length === defaultColumnKeys.length && cols.every((c) => defaultColumnKeys.includes(c))
|
|
@@ -543,6 +638,31 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
543
638
|
})
|
|
544
639
|
: actions
|
|
545
640
|
|
|
641
|
+
const toolbar = (
|
|
642
|
+
<TableToolbar
|
|
643
|
+
columns={displayableColumns}
|
|
644
|
+
visibleColumns={visibleColumnKeys}
|
|
645
|
+
onVisibleColumnsChange={handleColumnsChange}
|
|
646
|
+
search={searchInput}
|
|
647
|
+
onSearchChange={handleSearchChange}
|
|
648
|
+
searchable={searchable}
|
|
649
|
+
searchPlaceholder={table.searchPlaceholder ?? t('admin.common.search_placeholder')}
|
|
650
|
+
sort={{ field: sort, direction: dir }}
|
|
651
|
+
onSortChange={handleSortChange}
|
|
652
|
+
filters={filters as FilterRule[]}
|
|
653
|
+
onFiltersChange={handleFiltersChange}
|
|
654
|
+
allColumns={allColumns}
|
|
655
|
+
hideSort={reorderActive}
|
|
656
|
+
/>
|
|
657
|
+
)
|
|
658
|
+
const pagination = meta && (
|
|
659
|
+
<Pagination
|
|
660
|
+
meta={meta}
|
|
661
|
+
onPageChange={(p) => updateSearch({ page: p })}
|
|
662
|
+
onPageSizeChange={(size) => updateSearch({ limit: size, page: 1 })}
|
|
663
|
+
/>
|
|
664
|
+
)
|
|
665
|
+
|
|
546
666
|
return (
|
|
547
667
|
// Below `sm` the table spans the full width: the page gutter plus the
|
|
548
668
|
// card's own border spends ~34px of a 390px screen on chrome, and a table
|
|
@@ -589,26 +709,31 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
589
709
|
{pageOwnsHeader && resolvedActions && (
|
|
590
710
|
<div className="flex flex-wrap items-center justify-end gap-2">{resolvedActions}</div>
|
|
591
711
|
)}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
712
|
+
{views && views.length > 1 && (
|
|
713
|
+
<Tabs value={activeView?.key} onValueChange={(v) => handleViewChange(v as string)}>
|
|
714
|
+
<TabsList>
|
|
715
|
+
{views.map((v) => (
|
|
716
|
+
<TabsTrigger key={v.key} value={v.key}>
|
|
717
|
+
{v.label}
|
|
718
|
+
</TabsTrigger>
|
|
719
|
+
))}
|
|
720
|
+
</TabsList>
|
|
721
|
+
</Tabs>
|
|
722
|
+
)}
|
|
723
|
+
{!framed && toolbar}
|
|
724
|
+
{/* Borderless: the list sits on the page with no card around it, its
|
|
725
|
+
header a rounded band and its rows divided by their own rules.
|
|
726
|
+
`overflow-x-clip`, never `overflow-hidden`: a `hidden` overflow on
|
|
727
|
+
either axis makes this a scroll container, and the pinned column row
|
|
728
|
+
inside would then stick to THIS box rather than to the page — which
|
|
729
|
+
is to say, scroll away with it. */}
|
|
730
|
+
<Card
|
|
731
|
+
className={cn(
|
|
732
|
+
'gap-0 overflow-x-clip',
|
|
733
|
+
!framed && 'rounded-none border-0 bg-transparent shadow-none',
|
|
734
|
+
)}
|
|
735
|
+
>
|
|
736
|
+
{framed && <div className="p-3">{toolbar}</div>}
|
|
612
737
|
{reorderActive ? (
|
|
613
738
|
<DndContext
|
|
614
739
|
sensors={sensors}
|
|
@@ -619,7 +744,7 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
619
744
|
items={rows.map((r) => (r as any).id)}
|
|
620
745
|
strategy={verticalListSortingStrategy}
|
|
621
746
|
>
|
|
622
|
-
<Table stickyHeader
|
|
747
|
+
<Table stickyHeader={!framed} band={!framed}>
|
|
623
748
|
<TableHeader>
|
|
624
749
|
<TableHeaderRow>
|
|
625
750
|
<TableHead className="w-8" />
|
|
@@ -689,7 +814,8 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
689
814
|
// checkbox sitting beneath it — checking once, then never
|
|
690
815
|
// unchecking.
|
|
691
816
|
<div className="pointer-events-none sticky top-header-height z-30 h-0">
|
|
692
|
-
|
|
817
|
+
{/* The header band's height, so the actions centre on its checkbox. */}
|
|
818
|
+
<div className="flex h-10 items-center ps-10 pe-4">
|
|
693
819
|
<div className="pointer-events-auto min-w-0 flex-1">
|
|
694
820
|
<BulkActionBar
|
|
695
821
|
selectedIds={Array.from(selectedIds)}
|
|
@@ -739,7 +865,7 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
739
865
|
</section>,
|
|
740
866
|
document.body,
|
|
741
867
|
)}
|
|
742
|
-
<Table stickyHeader
|
|
868
|
+
<Table stickyHeader={!framed} band={!framed}>
|
|
743
869
|
<TableHeader>
|
|
744
870
|
{/* Column headers stay mounted with rows selected, and the bulk
|
|
745
871
|
bar is laid over them. Swapping them for one `colSpan` cell
|
|
@@ -833,10 +959,8 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
833
959
|
// the panel floats over its neighbours, and without this
|
|
834
960
|
// a merchant editing a figure has to count rows to see
|
|
835
961
|
// which record they are editing.
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
isSelected && 'bg-accent-strong/75 hover:bg-accent-strong-hover',
|
|
839
|
-
)}
|
|
962
|
+
// The table's `band` style paints both from these.
|
|
963
|
+
data-selected={isSelected || undefined}
|
|
840
964
|
>
|
|
841
965
|
{selectionEnabled && (
|
|
842
966
|
<TableCell className="w-8">
|
|
@@ -863,14 +987,11 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
863
987
|
</Table>
|
|
864
988
|
</>
|
|
865
989
|
)}
|
|
990
|
+
{/* Nothing to page through in an empty table. No rule of its own: the
|
|
991
|
+
last row's border already divides the two. */}
|
|
992
|
+
{framed && !!meta?.count && <div className="px-3 py-2">{pagination}</div>}
|
|
866
993
|
</Card>
|
|
867
|
-
{
|
|
868
|
-
<Pagination
|
|
869
|
-
meta={meta}
|
|
870
|
-
onPageChange={(p) => updateSearch({ page: p })}
|
|
871
|
-
onPageSizeChange={(size) => updateSearch({ limit: size, page: 1 })}
|
|
872
|
-
/>
|
|
873
|
-
)}
|
|
994
|
+
{!framed && pagination}
|
|
874
995
|
{/* Clears the fixed bottom bar so it can't cover the pagination or the
|
|
875
996
|
last row while a selection is active. Carries the same safe-area
|
|
876
997
|
inset the bar pads itself by, or the home indicator's worth of bar
|
|
@@ -49,12 +49,12 @@ export function SectionHeading({
|
|
|
49
49
|
<CardTitle className="min-w-0 truncate text-lg">{title}</CardTitle>
|
|
50
50
|
)}
|
|
51
51
|
{(description || docsPath) && (
|
|
52
|
-
<p className="text-muted-foreground text-
|
|
52
|
+
<p className="text-muted-foreground text-base">
|
|
53
53
|
{description}
|
|
54
54
|
{docsPath && (
|
|
55
55
|
<>
|
|
56
56
|
{description ? ' ' : null}
|
|
57
|
-
<ExternalLink href={docsUrl(docsPath)} className="text-
|
|
57
|
+
<ExternalLink href={docsUrl(docsPath)} className="text-base">
|
|
58
58
|
{t('admin.common.learn_more')}
|
|
59
59
|
</ExternalLink>
|
|
60
60
|
</>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Settings content padding. The settings nav lives one level up, in the shared
|
|
5
|
+
* app shell's chrome, so this container owns only the content's padding.
|
|
6
|
+
*
|
|
7
|
+
* Every settings page inherits it — a page must NOT add its own
|
|
8
|
+
* `p-4`/`container`/`mx-auto`, or it double-pads and drifts from the rest of
|
|
9
|
+
* the area. Start a settings page at `<div className="flex flex-col gap-6">`.
|
|
10
|
+
*/
|
|
11
|
+
export function SettingsContainer({ children }: { children: ReactNode }) {
|
|
12
|
+
return (
|
|
13
|
+
// The same `container` as every other page, so settings content lines up
|
|
14
|
+
// with them.
|
|
15
|
+
<div className="container mx-auto flex flex-1 flex-col gap-4 p-4 lg:gap-6 lg:p-6">
|
|
16
|
+
{children}
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
}
|