@spree/dashboard-core 0.13.0 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +97 -36
- 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 +425 -131
- 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 +63 -22
- package/src/components/table-toolbar.tsx +1373 -442
- 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 +261 -14
- 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 +21 -0
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/storefront.ts +19 -0
- package/src/lib/table-registry.ts +56 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -516
- package/src/locales/de.json +1883 -515
- package/src/locales/en.json +1886 -516
- package/src/locales/fr.json +1884 -516
- package/src/locales/pl.json +1913 -516
- package/src/locales/zh-CN.json +1883 -516
- 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 -210
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
import { CSS } from '@dnd-kit/utilities'
|
|
18
18
|
import {
|
|
19
19
|
Card,
|
|
20
|
-
CardContent,
|
|
21
20
|
Checkbox,
|
|
22
21
|
cn,
|
|
23
22
|
DragHandle,
|
|
@@ -28,14 +27,21 @@ import {
|
|
|
28
27
|
EmptyTitle,
|
|
29
28
|
Pagination,
|
|
30
29
|
type PaginationMeta,
|
|
30
|
+
Skeleton,
|
|
31
31
|
Table,
|
|
32
32
|
TableBody,
|
|
33
33
|
TableCell,
|
|
34
34
|
TableEmpty,
|
|
35
35
|
TableHead,
|
|
36
36
|
TableHeader,
|
|
37
|
+
TableHeaderRow,
|
|
37
38
|
TableRow,
|
|
39
|
+
Tooltip,
|
|
40
|
+
TooltipContent,
|
|
41
|
+
TooltipTrigger,
|
|
42
|
+
useIsMobile,
|
|
38
43
|
} from '@spree/dashboard-ui'
|
|
44
|
+
import { InfoIcon } from '@spree/dashboard-ui/icons'
|
|
39
45
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
40
46
|
import { useNavigate } from '@tanstack/react-router'
|
|
41
47
|
import {
|
|
@@ -45,24 +51,25 @@ import {
|
|
|
45
51
|
useDeferredValue,
|
|
46
52
|
useEffect,
|
|
47
53
|
useMemo,
|
|
48
|
-
useRef,
|
|
49
54
|
useState,
|
|
50
55
|
} from 'react'
|
|
56
|
+
import { createPortal } from 'react-dom'
|
|
51
57
|
import { useTranslation } from 'react-i18next'
|
|
52
58
|
import { z } from 'zod/v4'
|
|
53
59
|
import { useAuth } from '../hooks/use-auth'
|
|
60
|
+
import { useVisualViewportOffset } from '../hooks/use-visual-viewport-offset'
|
|
54
61
|
import { filtersToRansack } from '../lib/filters-to-ransack'
|
|
55
62
|
import { withStoreScope } from '../lib/query-keys'
|
|
56
63
|
import {
|
|
57
64
|
type ColumnDef,
|
|
58
65
|
type FilterRule,
|
|
59
66
|
getDefaultColumnKeys,
|
|
60
|
-
getDisplayableColumns,
|
|
61
67
|
getTable,
|
|
62
68
|
type SortOption,
|
|
63
69
|
} from '../lib/table-registry'
|
|
64
|
-
import {
|
|
70
|
+
import { useTenantId } from '../providers/tenant-provider'
|
|
65
71
|
import { type BulkAction, BulkActionBar } from './bulk-action-bar'
|
|
72
|
+
import { PageHeader } from './page-header'
|
|
66
73
|
import { TableToolbar } from './table-toolbar'
|
|
67
74
|
|
|
68
75
|
// ============================================================================
|
|
@@ -76,6 +83,39 @@ const filterSchema = z.object({
|
|
|
76
83
|
value: z.string(),
|
|
77
84
|
})
|
|
78
85
|
|
|
86
|
+
/**
|
|
87
|
+
* A column header, with its explanation behind an info icon when it has one.
|
|
88
|
+
*
|
|
89
|
+
* Figures whose meaning is a definition rather than a word — available,
|
|
90
|
+
* reserved, incoming — need somewhere to say what they count. A tooltip keeps
|
|
91
|
+
* that out of the header row until it is asked for.
|
|
92
|
+
*/
|
|
93
|
+
function ColumnLabel({ column, hideHint }: { column: ColumnDef; hideHint?: boolean }) {
|
|
94
|
+
if (!column.labelHint || hideHint) return column.label
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<span className="inline-flex items-center gap-1">
|
|
98
|
+
{column.label}
|
|
99
|
+
<Tooltip>
|
|
100
|
+
<TooltipTrigger asChild>
|
|
101
|
+
{/* Focusable and named, so the explanation opens on focus as well as
|
|
102
|
+
hover and reaches a screen reader the same way. */}
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
className="cursor-help rounded-sm text-muted-foreground focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2"
|
|
106
|
+
aria-label={column.labelHint}
|
|
107
|
+
>
|
|
108
|
+
<InfoIcon className="size-3.5" />
|
|
109
|
+
</button>
|
|
110
|
+
</TooltipTrigger>
|
|
111
|
+
<TooltipContent className="max-w-xs font-normal normal-case">
|
|
112
|
+
{column.labelHint}
|
|
113
|
+
</TooltipContent>
|
|
114
|
+
</Tooltip>
|
|
115
|
+
</span>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
79
119
|
export const resourceSearchSchema = z.object({
|
|
80
120
|
page: z.coerce.number().optional().default(1),
|
|
81
121
|
limit: z.coerce.number().optional(),
|
|
@@ -129,9 +169,9 @@ interface ResourceTableProps<T> {
|
|
|
129
169
|
/** Registry key (e.g., 'products') */
|
|
130
170
|
tableKey: string
|
|
131
171
|
/**
|
|
132
|
-
* TanStack Query key prefix. The table auto-injects the current
|
|
133
|
-
* mutation hooks that invalidate `['<key>',
|
|
134
|
-
* never need to thread
|
|
172
|
+
* TanStack Query key prefix. The table auto-injects the current tenant id so
|
|
173
|
+
* mutation hooks that invalidate `['<key>', tenantId]` always match — pages
|
|
174
|
+
* never need to thread it through themselves. A plain string is
|
|
135
175
|
* shorthand for `[string]`; pass an array to add further scope (e.g.
|
|
136
176
|
* `[webhookDeliveriesTableKey(endpointId)]`).
|
|
137
177
|
*/
|
|
@@ -142,6 +182,21 @@ interface ResourceTableProps<T> {
|
|
|
142
182
|
searchParams: ResourceSearch
|
|
143
183
|
/** Title displayed in the toolbar header. Overrides the table definition's title. */
|
|
144
184
|
title?: string
|
|
185
|
+
/**
|
|
186
|
+
* Set when the PAGE already renders its own `PageHeader` and this table is a
|
|
187
|
+
* panel inside it, so the table renders no heading of its own.
|
|
188
|
+
*
|
|
189
|
+
* Declared rather than detected. The shell tracks how many headers are
|
|
190
|
+
* mounted, but during a route change the outgoing page's header has not
|
|
191
|
+
* unregistered by the time the incoming table first renders — so a table
|
|
192
|
+
* reading that count saw a header that was on its way out and silently
|
|
193
|
+
* dropped its own. Only the page knows this, and it knows it statically.
|
|
194
|
+
*/
|
|
195
|
+
hideHeader?: boolean
|
|
196
|
+
/** One line under the title. Overrides the table definition's description. */
|
|
197
|
+
description?: string
|
|
198
|
+
/** Docs for the feature, linked after the description. Overrides the table definition's. */
|
|
199
|
+
docsPath?: string
|
|
145
200
|
/** Default params always sent with every request (e.g., { complete: 1 } for orders) */
|
|
146
201
|
defaultParams?: Record<string, unknown>
|
|
147
202
|
/**
|
|
@@ -174,6 +229,16 @@ interface ResourceTableProps<T> {
|
|
|
174
229
|
* menu on the trailing edge.
|
|
175
230
|
*/
|
|
176
231
|
rowActions?: (row: T) => ReactNode
|
|
232
|
+
/**
|
|
233
|
+
* Dynamic per-store columns derived from custom field definitions,
|
|
234
|
+
* merged into the registry columns for display (column selector +
|
|
235
|
+
* cells), sorting, and filtering. Keys are the definitions'
|
|
236
|
+
* `filter_key` (`cf_*`), which the backend accepts as sort and
|
|
237
|
+
* filter attributes.
|
|
238
|
+
*/
|
|
239
|
+
customFieldColumns?: ColumnDef<T>[]
|
|
240
|
+
/** @deprecated Use `customFieldColumns` — removed in Spree 6.1. */
|
|
241
|
+
metafieldColumns?: ColumnDef<T>[]
|
|
177
242
|
}
|
|
178
243
|
|
|
179
244
|
export interface ReorderConfig<T> {
|
|
@@ -192,11 +257,16 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
192
257
|
queryFn,
|
|
193
258
|
searchParams,
|
|
194
259
|
title,
|
|
260
|
+
hideHeader = false,
|
|
261
|
+
description,
|
|
262
|
+
docsPath,
|
|
195
263
|
defaultParams,
|
|
196
264
|
actions,
|
|
197
265
|
reorder,
|
|
198
266
|
bulkActions,
|
|
199
267
|
rowActions,
|
|
268
|
+
customFieldColumns,
|
|
269
|
+
metafieldColumns,
|
|
200
270
|
}: ResourceTableProps<T>) {
|
|
201
271
|
const table = getTable<T>(tableKey)
|
|
202
272
|
const { t } = useTranslation()
|
|
@@ -207,9 +277,12 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
207
277
|
const selectionEnabled = !!bulkActions?.length && !reorder
|
|
208
278
|
const rowActionsEnabled = !!rowActions
|
|
209
279
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
280
|
+
// Whether the bulk bar is covering the column labels.
|
|
281
|
+
const bulkActive = selectionEnabled && selectedIds.size > 0
|
|
282
|
+
// Only one bulk bar is mounted at a time. Rendering both and hiding one
|
|
283
|
+
// with `md:` leaves its text in the accessibility tree, so "2 selected"
|
|
284
|
+
// and every action label match twice.
|
|
285
|
+
const isMobile = useIsMobile()
|
|
213
286
|
|
|
214
287
|
const {
|
|
215
288
|
page,
|
|
@@ -236,27 +309,69 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
236
309
|
const [searchInput, setSearchInput] = useState(search ?? '')
|
|
237
310
|
const deferredSearch = useDeferredValue(searchInput)
|
|
238
311
|
|
|
239
|
-
|
|
312
|
+
// Registry columns + dynamic custom-field columns. Memoized so the toolbar's
|
|
313
|
+
// internal memos (FilterPanel `items` stability) keep stable deps.
|
|
314
|
+
const extraColumns = customFieldColumns ?? metafieldColumns
|
|
315
|
+
const allColumns = useMemo(
|
|
316
|
+
() => [...table.columns, ...(extraColumns ?? [])],
|
|
317
|
+
[table, extraColumns],
|
|
318
|
+
)
|
|
319
|
+
const displayableColumns = useMemo(
|
|
320
|
+
() => allColumns.filter((c) => c.displayable !== false),
|
|
321
|
+
[allColumns],
|
|
322
|
+
)
|
|
240
323
|
const defaultColumnKeys = getDefaultColumnKeys(table)
|
|
241
324
|
const visibleColumnKeys = urlColumns ?? defaultColumnKeys
|
|
242
325
|
|
|
243
|
-
const visibleColumns =
|
|
326
|
+
const visibleColumns = useMemo(
|
|
327
|
+
() => displayableColumns.filter((c) => visibleColumnKeys.includes(c.key)),
|
|
328
|
+
[displayableColumns, visibleColumnKeys],
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
// Built from every displayable column, not the visible subset: the column
|
|
332
|
+
// picker is a desktop control (it is hidden on a phone), so the card should
|
|
333
|
+
// not inherit a selection the merchant cannot see or change there.
|
|
334
|
+
|
|
335
|
+
// Expands requested by currently-visible columns (e.g. custom field
|
|
336
|
+
// columns need `expand=custom_fields` to render their values). Sorted so
|
|
337
|
+
// the query key stays stable regardless of column order.
|
|
338
|
+
const columnExpand = useMemo(
|
|
339
|
+
() => [...new Set(visibleColumns.flatMap((c) => (c.expand ? [c.expand] : [])))].sort(),
|
|
340
|
+
[visibleColumns],
|
|
341
|
+
)
|
|
244
342
|
|
|
245
343
|
// Build API params
|
|
246
344
|
const sortString = dir === 'desc' ? `-${sort}` : sort
|
|
247
345
|
|
|
248
|
-
const
|
|
249
|
-
//
|
|
250
|
-
//
|
|
346
|
+
const tenantId = useTenantId()
|
|
347
|
+
// Whether the PAGE mounted a header of its own, in which case this table is
|
|
348
|
+
// a panel inside it and renders no heading.
|
|
349
|
+
//
|
|
350
|
+
// Latched from the first render, before this component's own `PageHeader`
|
|
351
|
+
// can register: the flag counts every mounted header, so reading it live
|
|
352
|
+
// would see the one rendered below, conclude the page already had one, and
|
|
353
|
+
// unmount it — which re-clears the flag and mounts it again, forever.
|
|
354
|
+
const pageOwnsHeader = hideHeader
|
|
355
|
+
// Auto-inject the tenant id so every list query — and the matching mutation
|
|
356
|
+
// invalidation — is tenant-scoped without each page re-implementing it.
|
|
251
357
|
// Goes through +withStoreScope+ so the slot ordering matches +useResourceKey+
|
|
252
|
-
// and +useResourceMutation+'s invalidations (
|
|
358
|
+
// and +useResourceMutation+'s invalidations (tenant id at position 1).
|
|
253
359
|
const userPrefix = Array.isArray(queryKey) ? queryKey : [queryKey]
|
|
254
|
-
const queryKeyPrefix = withStoreScope(userPrefix,
|
|
360
|
+
const queryKeyPrefix = withStoreScope(userPrefix, tenantId)
|
|
361
|
+
|
|
362
|
+
// Both bulk bars (desktop overlay, mobile bottom bar) reset through here so
|
|
363
|
+
// they cannot drift apart.
|
|
364
|
+
const visualViewportOffset = useVisualViewportOffset(bulkActive)
|
|
365
|
+
|
|
366
|
+
const clearSelection = useCallback(() => {
|
|
367
|
+
setSelectedIds(new Set())
|
|
368
|
+
queryClient.invalidateQueries({ queryKey: queryKeyPrefix })
|
|
369
|
+
}, [queryClient, queryKeyPrefix])
|
|
255
370
|
|
|
256
371
|
const { data, isLoading } = useQuery({
|
|
257
372
|
queryKey: [
|
|
258
373
|
...queryKeyPrefix,
|
|
259
|
-
{ page, limit, sort: sortString, search: deferredSearch, filters },
|
|
374
|
+
{ page, limit, sort: sortString, search: deferredSearch, filters, expand: columnExpand },
|
|
260
375
|
],
|
|
261
376
|
queryFn: () => {
|
|
262
377
|
const params: Record<string, unknown> = {
|
|
@@ -269,12 +384,22 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
269
384
|
params.limit = limit
|
|
270
385
|
}
|
|
271
386
|
|
|
387
|
+
if (columnExpand.length) {
|
|
388
|
+
const base = defaultParams?.expand
|
|
389
|
+
const baseList = Array.isArray(base)
|
|
390
|
+
? base
|
|
391
|
+
: typeof base === 'string'
|
|
392
|
+
? base.split(',')
|
|
393
|
+
: []
|
|
394
|
+
params.expand = [...new Set([...baseList, ...columnExpand])]
|
|
395
|
+
}
|
|
396
|
+
|
|
272
397
|
if (deferredSearch) {
|
|
273
398
|
const searchParam = table.searchParam ?? 'name_cont'
|
|
274
399
|
params[searchParam] = deferredSearch
|
|
275
400
|
}
|
|
276
401
|
|
|
277
|
-
Object.assign(params, filtersToRansack(filters as FilterRule[],
|
|
402
|
+
Object.assign(params, filtersToRansack(filters as FilterRule[], allColumns))
|
|
278
403
|
|
|
279
404
|
return queryFn(params)
|
|
280
405
|
},
|
|
@@ -409,13 +534,57 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
409
534
|
filters: filters as FilterRule[],
|
|
410
535
|
search: deferredSearch,
|
|
411
536
|
searchParam: table.searchParam ?? 'name_cont',
|
|
412
|
-
columns:
|
|
537
|
+
columns: allColumns,
|
|
413
538
|
totalCount: meta?.count,
|
|
414
539
|
})
|
|
415
540
|
: actions
|
|
416
541
|
|
|
417
542
|
return (
|
|
418
|
-
|
|
543
|
+
// Below `sm` the table spans the full width: the page gutter plus the
|
|
544
|
+
// card's own border spends ~34px of a 390px screen on chrome, and a table
|
|
545
|
+
// is the one place that width is worth reclaiming. Negative margins cancel
|
|
546
|
+
// The frame belongs to the ROWS, not to the screen. The toolbar sits above
|
|
547
|
+
// it and the pagination below it, both on the sheet itself — so the border
|
|
548
|
+
// encloses exactly the tabular data, and the controls that act on it read
|
|
549
|
+
// as chrome rather than as its first and last rows.
|
|
550
|
+
//
|
|
551
|
+
// Below `sm` the table loses its frame and spans the gutter the page
|
|
552
|
+
// container applies, so narrow screens spend no width on a box.
|
|
553
|
+
//
|
|
554
|
+
// No shadow at any width: the sheet under it is already a raised surface,
|
|
555
|
+
// so the table is a region drawn on it rather than a second card floating
|
|
556
|
+
// above one. Its hairline is `--border`, the same weight as the rules
|
|
557
|
+
// between its own rows, so the frame and its contents read as one table.
|
|
558
|
+
<div className="flex flex-col gap-3">
|
|
559
|
+
{/* The page's own header, not a title bar of the table's own: a list page
|
|
560
|
+
IS this table, so its title and actions are the page's. Rendering
|
|
561
|
+
`PageHeader` here rather than a parallel implementation means a list
|
|
562
|
+
page and a detail page share one heading — the same type, spacing,
|
|
563
|
+
sticky behaviour and action slot — and the `page.actions` slot a
|
|
564
|
+
plugin registers into reaches both.
|
|
565
|
+
|
|
566
|
+
Skipped via `hideHeader` when the page already mounts its own
|
|
567
|
+
`PageHeader` (a table embedded as a panel), which would otherwise
|
|
568
|
+
give the page two. */}
|
|
569
|
+
{!pageOwnsHeader && (
|
|
570
|
+
<PageHeader
|
|
571
|
+
// Not pinned: on a list page the table's own column row is the band
|
|
572
|
+
// worth keeping on screen, and stacking a sticky title above it
|
|
573
|
+
// spends a third of a short viewport on chrome.
|
|
574
|
+
sticky={false}
|
|
575
|
+
title={title ?? table.title}
|
|
576
|
+
description={description ?? table.description}
|
|
577
|
+
docsPath={docsPath ?? table.docsPath}
|
|
578
|
+
actions={resolvedActions}
|
|
579
|
+
/>
|
|
580
|
+
)}
|
|
581
|
+
{/* A page that owns its header still passes `actions` here, and they
|
|
582
|
+
would otherwise vanish with the suppressed `PageHeader` — taking the
|
|
583
|
+
orders export and the policies Add button with them. Render them
|
|
584
|
+
above the toolbar so the control keeps a home either way. */}
|
|
585
|
+
{pageOwnsHeader && resolvedActions && (
|
|
586
|
+
<div className="flex flex-wrap items-center justify-end gap-2">{resolvedActions}</div>
|
|
587
|
+
)}
|
|
419
588
|
<TableToolbar
|
|
420
589
|
columns={displayableColumns}
|
|
421
590
|
visibleColumns={visibleColumnKeys}
|
|
@@ -427,12 +596,15 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
427
596
|
onSortChange={handleSortChange}
|
|
428
597
|
filters={filters as FilterRule[]}
|
|
429
598
|
onFiltersChange={handleFiltersChange}
|
|
430
|
-
allColumns={
|
|
431
|
-
title={title ?? table.title}
|
|
432
|
-
actions={resolvedActions}
|
|
599
|
+
allColumns={allColumns}
|
|
433
600
|
hideSort={reorderActive}
|
|
434
601
|
/>
|
|
435
|
-
|
|
602
|
+
{/* `overflow-x-clip`, never `overflow-hidden`: the rows clip to the
|
|
603
|
+
rounded frame horizontally, but a `hidden` overflow on either axis
|
|
604
|
+
makes this a scroll container, and the pinned column row inside
|
|
605
|
+
would then stick to THIS box rather than to the page — which is to
|
|
606
|
+
say, scroll away with it. */}
|
|
607
|
+
<Card className="-mx-4 gap-0 overflow-x-clip rounded-none border-0 bg-transparent py-0 shadow-none sm:mx-0 sm:rounded-xl sm:border">
|
|
436
608
|
{reorderActive ? (
|
|
437
609
|
<DndContext
|
|
438
610
|
sensors={sensors}
|
|
@@ -443,13 +615,13 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
443
615
|
items={rows.map((r) => (r as any).id)}
|
|
444
616
|
strategy={verticalListSortingStrategy}
|
|
445
617
|
>
|
|
446
|
-
<Table>
|
|
618
|
+
<Table stickyHeader roundedTop roundedBottom>
|
|
447
619
|
<TableHeader>
|
|
448
|
-
<
|
|
620
|
+
<TableHeaderRow>
|
|
449
621
|
<TableHead className="w-8" />
|
|
450
622
|
{headerColumns.map((col) => (
|
|
451
623
|
<TableHead key={col.key} className={col.headerClassName}>
|
|
452
|
-
{col
|
|
624
|
+
<ColumnLabel column={col} />
|
|
453
625
|
</TableHead>
|
|
454
626
|
))}
|
|
455
627
|
{rowActionsEnabled && (
|
|
@@ -457,13 +629,13 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
457
629
|
<span className="sr-only">{t('admin.row_actions.menu_label')}</span>
|
|
458
630
|
</TableHead>
|
|
459
631
|
)}
|
|
460
|
-
</
|
|
632
|
+
</TableHeaderRow>
|
|
461
633
|
</TableHeader>
|
|
462
|
-
<TableBody>
|
|
634
|
+
<TableBody className="bg-card">
|
|
463
635
|
{isLoading ? (
|
|
464
|
-
<
|
|
465
|
-
{
|
|
466
|
-
|
|
636
|
+
<TableSkeletonRows
|
|
637
|
+
colSpan={visibleColumns.length + 1 + (rowActionsEnabled ? 1 : 0)}
|
|
638
|
+
/>
|
|
467
639
|
) : rows.length === 0 ? (
|
|
468
640
|
<TableEmpty colSpan={visibleColumns.length + 1 + (rowActionsEnabled ? 1 : 0)}>
|
|
469
641
|
<Empty className="border-0 p-0">
|
|
@@ -497,112 +669,234 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
497
669
|
</SortableContext>
|
|
498
670
|
</DndContext>
|
|
499
671
|
) : (
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
672
|
+
<>
|
|
673
|
+
{/* The bulk actions overlay the header row but live OUTSIDE the
|
|
674
|
+
table's horizontal scroller: out there their width is the
|
|
675
|
+
visible card rather than the table's full scroll width, so the
|
|
676
|
+
overflow-into-⋯ budget is right and the controls hold still
|
|
677
|
+
while the columns scroll sideways. A zero-height wrapper with
|
|
678
|
+
the same sticky offset as the pinned header keeps the two
|
|
679
|
+
travelling together; the start padding clears the select-all
|
|
680
|
+
checkbox, which stays visible and interactive underneath. */}
|
|
681
|
+
{bulkActive && !isMobile && (
|
|
682
|
+
// `pointer-events-none` on the overlay, re-enabled only on the
|
|
683
|
+
// bar itself: the start-padding gutter is part of this div and
|
|
684
|
+
// would otherwise swallow every click aimed at the select-all
|
|
685
|
+
// checkbox sitting beneath it — checking once, then never
|
|
686
|
+
// unchecking.
|
|
687
|
+
<div className="pointer-events-none sticky top-header-height z-30 h-0">
|
|
688
|
+
<div className="flex h-[2.1875rem] items-center ps-10 pe-4">
|
|
689
|
+
<div className="pointer-events-auto min-w-0 flex-1">
|
|
690
|
+
<BulkActionBar
|
|
691
|
+
selectedIds={Array.from(selectedIds)}
|
|
692
|
+
actions={bulkActions!}
|
|
693
|
+
onDone={clearSelection}
|
|
694
|
+
/>
|
|
695
|
+
</div>
|
|
696
|
+
</div>
|
|
697
|
+
</div>
|
|
698
|
+
)}
|
|
699
|
+
|
|
700
|
+
{/* On a phone the header-row overlay is the wrong home for this: it
|
|
701
|
+
scrolls sideways with a wide table and sits at the top, far from
|
|
702
|
+
the thumb. A fixed bottom bar stays put and in reach while the
|
|
703
|
+
merchant scrolls the list picking rows. */}
|
|
704
|
+
{bulkActive &&
|
|
705
|
+
isMobile &&
|
|
706
|
+
// Portalled to `document.body`: `SidebarInset` is `relative z-0`,
|
|
707
|
+
// which opens a stacking context, and a `fixed` child of it can
|
|
708
|
+
// only stack against its siblings there — page content painted
|
|
709
|
+
// later covered the bar until the merchant scrolled past it.
|
|
710
|
+
createPortal(
|
|
711
|
+
<section
|
|
712
|
+
className="fixed inset-x-0 z-50 border-t border-border bg-card px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] shadow-lg"
|
|
713
|
+
// `bottom` anchors to the *layout* viewport. Pinch-zoom (and
|
|
714
|
+
// a mobile URL bar) makes the visual viewport a smaller
|
|
715
|
+
// window onto it, leaving the bar off-screen until the
|
|
716
|
+
// merchant pans down — the offset lifts it back into view.
|
|
717
|
+
style={{ bottom: visualViewportOffset }}
|
|
718
|
+
aria-label={t('admin.a11y.bulk_actions')}
|
|
719
|
+
>
|
|
720
|
+
<div className="flex items-center gap-3">
|
|
505
721
|
<Checkbox
|
|
506
722
|
checked={allPageSelected}
|
|
507
723
|
indeterminate={somePageSelected}
|
|
508
724
|
onCheckedChange={togglePage}
|
|
509
725
|
aria-label={t('admin.a11y.select_all_rows')}
|
|
510
726
|
/>
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
<
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
727
|
+
<div className="min-w-0 flex-1">
|
|
728
|
+
<BulkActionBar
|
|
729
|
+
selectedIds={Array.from(selectedIds)}
|
|
730
|
+
actions={bulkActions!}
|
|
731
|
+
onDone={clearSelection}
|
|
732
|
+
/>
|
|
733
|
+
</div>
|
|
734
|
+
</div>
|
|
735
|
+
</section>,
|
|
736
|
+
document.body,
|
|
737
|
+
)}
|
|
738
|
+
<Table stickyHeader roundedTop roundedBottom>
|
|
739
|
+
<TableHeader>
|
|
740
|
+
{/* Column headers stay mounted with rows selected, and the bulk
|
|
741
|
+
bar is laid over them. Swapping them for one `colSpan` cell
|
|
742
|
+
instead would drop the width constraints they impose on an
|
|
743
|
+
auto-layout table, and every column would resize the moment a
|
|
744
|
+
row was ticked. `bulkActive` only hides the labels. */}
|
|
745
|
+
<TableHeaderRow>
|
|
746
|
+
{selectionEnabled && (
|
|
747
|
+
<TableHead className="w-8">
|
|
748
|
+
<Checkbox
|
|
749
|
+
checked={allPageSelected}
|
|
750
|
+
indeterminate={somePageSelected}
|
|
751
|
+
onCheckedChange={togglePage}
|
|
752
|
+
aria-label={t('admin.a11y.select_all_rows')}
|
|
753
|
+
/>
|
|
754
|
+
</TableHead>
|
|
755
|
+
)}
|
|
756
|
+
{headerColumns.map((col) => (
|
|
757
|
+
<TableHead
|
|
758
|
+
key={col.key}
|
|
759
|
+
className={cn(
|
|
760
|
+
col.headerClassName,
|
|
761
|
+
'transition-colors duration-150 ease-out',
|
|
762
|
+
// Hide the text, not the cell: `opacity-0` would erase
|
|
763
|
+
// the cell's muted background and bottom rule too,
|
|
764
|
+
// leaving rows visible through the band behind the
|
|
765
|
+
// bulk actions overlay. Only where that overlay is
|
|
766
|
+
// drawn — on a phone the bar is at the foot of the
|
|
767
|
+
// screen and the column labels stay readable.
|
|
768
|
+
bulkActive && !isMobile && 'text-transparent select-none',
|
|
548
769
|
)}
|
|
549
|
-
</EmptyHeader>
|
|
550
|
-
</Empty>
|
|
551
|
-
</TableEmpty>
|
|
552
|
-
) : (
|
|
553
|
-
rows.map((row, i) => {
|
|
554
|
-
const rowId = String((row as any).id ?? i)
|
|
555
|
-
const isSelected = selectionEnabled && selectedIds.has(rowId)
|
|
556
|
-
return (
|
|
557
|
-
<TableRow
|
|
558
|
-
key={(row as any).id ?? i}
|
|
559
|
-
className={isSelected ? 'bg-muted/40' : undefined}
|
|
560
770
|
>
|
|
561
|
-
{
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
)}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
771
|
+
{/* The hint hides with the label: an info icon floating
|
|
772
|
+
over the bulk-actions band would be the only thing
|
|
773
|
+
left in the row. */}
|
|
774
|
+
<ColumnLabel column={col} hideHint={bulkActive && !isMobile} />
|
|
775
|
+
</TableHead>
|
|
776
|
+
))}
|
|
777
|
+
{rowActionsEnabled && (
|
|
778
|
+
<TableHead className="w-12">
|
|
779
|
+
<span className="sr-only">{t('admin.row_actions.menu_label')}</span>
|
|
780
|
+
</TableHead>
|
|
781
|
+
)}
|
|
782
|
+
</TableHeaderRow>
|
|
783
|
+
</TableHeader>
|
|
784
|
+
<TableBody className="bg-card">
|
|
785
|
+
{isLoading ? (
|
|
786
|
+
<TableSkeletonRows
|
|
787
|
+
colSpan={
|
|
788
|
+
visibleColumns.length +
|
|
789
|
+
(selectionEnabled ? 1 : 0) +
|
|
790
|
+
(rowActionsEnabled ? 1 : 0)
|
|
791
|
+
}
|
|
792
|
+
/>
|
|
793
|
+
) : rows.length === 0 ? (
|
|
794
|
+
<TableEmpty
|
|
795
|
+
colSpan={
|
|
796
|
+
visibleColumns.length +
|
|
797
|
+
(selectionEnabled ? 1 : 0) +
|
|
798
|
+
(rowActionsEnabled ? 1 : 0)
|
|
799
|
+
}
|
|
800
|
+
>
|
|
801
|
+
<Empty className="border-0 p-0">
|
|
802
|
+
<EmptyHeader>
|
|
803
|
+
{table.emptyIcon && (
|
|
804
|
+
<EmptyMedia variant="icon">{table.emptyIcon}</EmptyMedia>
|
|
805
|
+
)}
|
|
806
|
+
<EmptyTitle>
|
|
807
|
+
{table.emptyMessage ?? t('admin.common.no_results')}
|
|
808
|
+
</EmptyTitle>
|
|
809
|
+
{(deferredSearch || (filters as FilterRule[]).length > 0) && (
|
|
810
|
+
<EmptyDescription>
|
|
811
|
+
{t('admin.common.adjust_search_or_filters')}
|
|
812
|
+
</EmptyDescription>
|
|
813
|
+
)}
|
|
814
|
+
</EmptyHeader>
|
|
815
|
+
</Empty>
|
|
816
|
+
</TableEmpty>
|
|
817
|
+
) : (
|
|
818
|
+
rows.map((row, i) => {
|
|
819
|
+
const rowId = String((row as any).id ?? i)
|
|
820
|
+
const isSelected = selectionEnabled && selectedIds.has(rowId)
|
|
821
|
+
return (
|
|
822
|
+
<TableRow
|
|
823
|
+
key={(row as any).id ?? i}
|
|
824
|
+
// Selected rows carry the hover tint so the selection is
|
|
825
|
+
// visible at rest; hovering one goes a step deeper so the
|
|
826
|
+
// row still answers the pointer.
|
|
827
|
+
//
|
|
828
|
+
// A row holding an open popover is shaded the same way:
|
|
829
|
+
// the panel floats over its neighbours, and without this
|
|
830
|
+
// a merchant editing a figure has to count rows to see
|
|
831
|
+
// which record they are editing.
|
|
832
|
+
className={cn(
|
|
833
|
+
'has-data-[popup-open]:bg-accent-strong/40',
|
|
834
|
+
isSelected && 'bg-accent-strong/75 hover:bg-accent-strong-hover',
|
|
835
|
+
)}
|
|
836
|
+
>
|
|
837
|
+
{selectionEnabled && (
|
|
838
|
+
<TableCell className="w-8">
|
|
839
|
+
<Checkbox
|
|
840
|
+
checked={isSelected}
|
|
841
|
+
onCheckedChange={() => toggleRow(rowId)}
|
|
842
|
+
aria-label={t('admin.a11y.select_row')}
|
|
843
|
+
/>
|
|
844
|
+
</TableCell>
|
|
845
|
+
)}
|
|
846
|
+
{visibleColumns.map((col) => (
|
|
847
|
+
<TableCell key={col.key} className={col.className}>
|
|
848
|
+
{col.render ? col.render(row) : String((row as any)[col.key] ?? '—')}
|
|
849
|
+
</TableCell>
|
|
850
|
+
))}
|
|
851
|
+
{rowActionsEnabled && (
|
|
852
|
+
<TableCell className="w-12 text-right">{rowActions?.(row)}</TableCell>
|
|
853
|
+
)}
|
|
854
|
+
</TableRow>
|
|
855
|
+
)
|
|
856
|
+
})
|
|
857
|
+
)}
|
|
858
|
+
</TableBody>
|
|
859
|
+
</Table>
|
|
860
|
+
</>
|
|
603
861
|
)}
|
|
604
|
-
</
|
|
605
|
-
|
|
862
|
+
</Card>
|
|
863
|
+
{meta && (
|
|
864
|
+
<Pagination
|
|
865
|
+
meta={meta}
|
|
866
|
+
onPageChange={(p) => updateSearch({ page: p })}
|
|
867
|
+
onPageSizeChange={(size) => updateSearch({ limit: size, page: 1 })}
|
|
868
|
+
/>
|
|
869
|
+
)}
|
|
870
|
+
{/* Clears the fixed bottom bar so it can't cover the pagination or the
|
|
871
|
+
last row while a selection is active. Carries the same safe-area
|
|
872
|
+
inset the bar pads itself by, or the home indicator's worth of bar
|
|
873
|
+
still overlaps the last row. Sits after the pagination, which is now
|
|
874
|
+
the last thing the bar can cover. */}
|
|
875
|
+
{bulkActive && isMobile && (
|
|
876
|
+
<div className="h-[calc(4rem+max(0px,env(safe-area-inset-bottom)-0.75rem))]" aria-hidden />
|
|
877
|
+
)}
|
|
878
|
+
</div>
|
|
879
|
+
)
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// ============================================================================
|
|
883
|
+
// TableSkeletonRows — placeholder rows while the first page loads. Rows rather
|
|
884
|
+
// than a "Loading…" line so the table keeps its height and the page doesn't
|
|
885
|
+
// jump when data lands.
|
|
886
|
+
// ============================================================================
|
|
887
|
+
|
|
888
|
+
function TableSkeletonRows({ colSpan, rows = 8 }: { colSpan: number; rows?: number }) {
|
|
889
|
+
return (
|
|
890
|
+
<>
|
|
891
|
+
{Array.from({ length: rows }, (_, i) => (
|
|
892
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length placeholder list
|
|
893
|
+
<tr key={i} className="last:*:border-b-0">
|
|
894
|
+
<TableCell colSpan={colSpan}>
|
|
895
|
+
<Skeleton className="h-5 w-full" />
|
|
896
|
+
</TableCell>
|
|
897
|
+
</tr>
|
|
898
|
+
))}
|
|
899
|
+
</>
|
|
606
900
|
)
|
|
607
901
|
}
|
|
608
902
|
|
|
@@ -634,7 +928,7 @@ function SortableRow<T extends Record<string, any>>({
|
|
|
634
928
|
ref={setNodeRef}
|
|
635
929
|
style={style}
|
|
636
930
|
className={cn(
|
|
637
|
-
'group/row hover:bg-
|
|
931
|
+
'group/row hover:bg-accent-strong/50 last:*:border-b-0',
|
|
638
932
|
isDragging && 'relative z-10 opacity-70',
|
|
639
933
|
)}
|
|
640
934
|
>
|