@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
|
@@ -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,12 +51,13 @@ 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 {
|
|
@@ -60,8 +67,9 @@ import {
|
|
|
60
67
|
getTable,
|
|
61
68
|
type SortOption,
|
|
62
69
|
} from '../lib/table-registry'
|
|
63
|
-
import {
|
|
70
|
+
import { useTenantId } from '../providers/tenant-provider'
|
|
64
71
|
import { type BulkAction, BulkActionBar } from './bulk-action-bar'
|
|
72
|
+
import { PageHeader } from './page-header'
|
|
65
73
|
import { TableToolbar } from './table-toolbar'
|
|
66
74
|
|
|
67
75
|
// ============================================================================
|
|
@@ -75,6 +83,39 @@ const filterSchema = z.object({
|
|
|
75
83
|
value: z.string(),
|
|
76
84
|
})
|
|
77
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
|
+
|
|
78
119
|
export const resourceSearchSchema = z.object({
|
|
79
120
|
page: z.coerce.number().optional().default(1),
|
|
80
121
|
limit: z.coerce.number().optional(),
|
|
@@ -128,9 +169,9 @@ interface ResourceTableProps<T> {
|
|
|
128
169
|
/** Registry key (e.g., 'products') */
|
|
129
170
|
tableKey: string
|
|
130
171
|
/**
|
|
131
|
-
* TanStack Query key prefix. The table auto-injects the current
|
|
132
|
-
* mutation hooks that invalidate `['<key>',
|
|
133
|
-
* 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
|
|
134
175
|
* shorthand for `[string]`; pass an array to add further scope (e.g.
|
|
135
176
|
* `[webhookDeliveriesTableKey(endpointId)]`).
|
|
136
177
|
*/
|
|
@@ -141,6 +182,21 @@ interface ResourceTableProps<T> {
|
|
|
141
182
|
searchParams: ResourceSearch
|
|
142
183
|
/** Title displayed in the toolbar header. Overrides the table definition's title. */
|
|
143
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
|
|
144
200
|
/** Default params always sent with every request (e.g., { complete: 1 } for orders) */
|
|
145
201
|
defaultParams?: Record<string, unknown>
|
|
146
202
|
/**
|
|
@@ -180,6 +236,8 @@ interface ResourceTableProps<T> {
|
|
|
180
236
|
* `filter_key` (`cf_*`), which the backend accepts as sort and
|
|
181
237
|
* filter attributes.
|
|
182
238
|
*/
|
|
239
|
+
customFieldColumns?: ColumnDef<T>[]
|
|
240
|
+
/** @deprecated Use `customFieldColumns` — removed in Spree 6.1. */
|
|
183
241
|
metafieldColumns?: ColumnDef<T>[]
|
|
184
242
|
}
|
|
185
243
|
|
|
@@ -199,11 +257,15 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
199
257
|
queryFn,
|
|
200
258
|
searchParams,
|
|
201
259
|
title,
|
|
260
|
+
hideHeader = false,
|
|
261
|
+
description,
|
|
262
|
+
docsPath,
|
|
202
263
|
defaultParams,
|
|
203
264
|
actions,
|
|
204
265
|
reorder,
|
|
205
266
|
bulkActions,
|
|
206
267
|
rowActions,
|
|
268
|
+
customFieldColumns,
|
|
207
269
|
metafieldColumns,
|
|
208
270
|
}: ResourceTableProps<T>) {
|
|
209
271
|
const table = getTable<T>(tableKey)
|
|
@@ -215,9 +277,12 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
215
277
|
const selectionEnabled = !!bulkActions?.length && !reorder
|
|
216
278
|
const rowActionsEnabled = !!rowActions
|
|
217
279
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
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()
|
|
221
286
|
|
|
222
287
|
const {
|
|
223
288
|
page,
|
|
@@ -244,11 +309,12 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
244
309
|
const [searchInput, setSearchInput] = useState(search ?? '')
|
|
245
310
|
const deferredSearch = useDeferredValue(searchInput)
|
|
246
311
|
|
|
247
|
-
// Registry columns + dynamic
|
|
312
|
+
// Registry columns + dynamic custom-field columns. Memoized so the toolbar's
|
|
248
313
|
// internal memos (FilterPanel `items` stability) keep stable deps.
|
|
314
|
+
const extraColumns = customFieldColumns ?? metafieldColumns
|
|
249
315
|
const allColumns = useMemo(
|
|
250
|
-
() => [...table.columns, ...(
|
|
251
|
-
[table,
|
|
316
|
+
() => [...table.columns, ...(extraColumns ?? [])],
|
|
317
|
+
[table, extraColumns],
|
|
252
318
|
)
|
|
253
319
|
const displayableColumns = useMemo(
|
|
254
320
|
() => allColumns.filter((c) => c.displayable !== false),
|
|
@@ -262,6 +328,10 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
262
328
|
[displayableColumns, visibleColumnKeys],
|
|
263
329
|
)
|
|
264
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
|
+
|
|
265
335
|
// Expands requested by currently-visible columns (e.g. custom field
|
|
266
336
|
// columns need `expand=custom_fields` to render their values). Sorted so
|
|
267
337
|
// the query key stays stable regardless of column order.
|
|
@@ -273,13 +343,30 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
273
343
|
// Build API params
|
|
274
344
|
const sortString = dir === 'desc' ? `-${sort}` : sort
|
|
275
345
|
|
|
276
|
-
const
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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.
|
|
279
357
|
// Goes through +withStoreScope+ so the slot ordering matches +useResourceKey+
|
|
280
|
-
// and +useResourceMutation+'s invalidations (
|
|
358
|
+
// and +useResourceMutation+'s invalidations (tenant id at position 1).
|
|
281
359
|
const userPrefix = Array.isArray(queryKey) ? queryKey : [queryKey]
|
|
282
|
-
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])
|
|
283
370
|
|
|
284
371
|
const { data, isLoading } = useQuery({
|
|
285
372
|
queryKey: [
|
|
@@ -453,7 +540,51 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
453
540
|
: actions
|
|
454
541
|
|
|
455
542
|
return (
|
|
456
|
-
|
|
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
|
+
)}
|
|
457
588
|
<TableToolbar
|
|
458
589
|
columns={displayableColumns}
|
|
459
590
|
visibleColumns={visibleColumnKeys}
|
|
@@ -466,11 +597,14 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
466
597
|
filters={filters as FilterRule[]}
|
|
467
598
|
onFiltersChange={handleFiltersChange}
|
|
468
599
|
allColumns={allColumns}
|
|
469
|
-
title={title ?? table.title}
|
|
470
|
-
actions={resolvedActions}
|
|
471
600
|
hideSort={reorderActive}
|
|
472
601
|
/>
|
|
473
|
-
|
|
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">
|
|
474
608
|
{reorderActive ? (
|
|
475
609
|
<DndContext
|
|
476
610
|
sensors={sensors}
|
|
@@ -481,13 +615,13 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
481
615
|
items={rows.map((r) => (r as any).id)}
|
|
482
616
|
strategy={verticalListSortingStrategy}
|
|
483
617
|
>
|
|
484
|
-
<Table>
|
|
618
|
+
<Table stickyHeader roundedTop roundedBottom>
|
|
485
619
|
<TableHeader>
|
|
486
|
-
<
|
|
620
|
+
<TableHeaderRow>
|
|
487
621
|
<TableHead className="w-8" />
|
|
488
622
|
{headerColumns.map((col) => (
|
|
489
623
|
<TableHead key={col.key} className={col.headerClassName}>
|
|
490
|
-
{col
|
|
624
|
+
<ColumnLabel column={col} />
|
|
491
625
|
</TableHead>
|
|
492
626
|
))}
|
|
493
627
|
{rowActionsEnabled && (
|
|
@@ -495,13 +629,13 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
495
629
|
<span className="sr-only">{t('admin.row_actions.menu_label')}</span>
|
|
496
630
|
</TableHead>
|
|
497
631
|
)}
|
|
498
|
-
</
|
|
632
|
+
</TableHeaderRow>
|
|
499
633
|
</TableHeader>
|
|
500
|
-
<TableBody>
|
|
634
|
+
<TableBody className="bg-card">
|
|
501
635
|
{isLoading ? (
|
|
502
|
-
<
|
|
503
|
-
{
|
|
504
|
-
|
|
636
|
+
<TableSkeletonRows
|
|
637
|
+
colSpan={visibleColumns.length + 1 + (rowActionsEnabled ? 1 : 0)}
|
|
638
|
+
/>
|
|
505
639
|
) : rows.length === 0 ? (
|
|
506
640
|
<TableEmpty colSpan={visibleColumns.length + 1 + (rowActionsEnabled ? 1 : 0)}>
|
|
507
641
|
<Empty className="border-0 p-0">
|
|
@@ -535,112 +669,234 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
535
669
|
</SortableContext>
|
|
536
670
|
</DndContext>
|
|
537
671
|
) : (
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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">
|
|
543
721
|
<Checkbox
|
|
544
722
|
checked={allPageSelected}
|
|
545
723
|
indeterminate={somePageSelected}
|
|
546
724
|
onCheckedChange={togglePage}
|
|
547
725
|
aria-label={t('admin.a11y.select_all_rows')}
|
|
548
726
|
/>
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
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',
|
|
586
769
|
)}
|
|
587
|
-
</EmptyHeader>
|
|
588
|
-
</Empty>
|
|
589
|
-
</TableEmpty>
|
|
590
|
-
) : (
|
|
591
|
-
rows.map((row, i) => {
|
|
592
|
-
const rowId = String((row as any).id ?? i)
|
|
593
|
-
const isSelected = selectionEnabled && selectedIds.has(rowId)
|
|
594
|
-
return (
|
|
595
|
-
<TableRow
|
|
596
|
-
key={(row as any).id ?? i}
|
|
597
|
-
className={isSelected ? 'bg-muted/40' : undefined}
|
|
598
770
|
>
|
|
599
|
-
{
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
)}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
+
</>
|
|
641
861
|
)}
|
|
642
|
-
</
|
|
643
|
-
|
|
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
|
+
</>
|
|
644
900
|
)
|
|
645
901
|
}
|
|
646
902
|
|
|
@@ -672,7 +928,7 @@ function SortableRow<T extends Record<string, any>>({
|
|
|
672
928
|
ref={setNodeRef}
|
|
673
929
|
style={style}
|
|
674
930
|
className={cn(
|
|
675
|
-
'group/row hover:bg-
|
|
931
|
+
'group/row hover:bg-accent-strong/50 last:*:border-b-0',
|
|
676
932
|
isDragging && 'relative z-10 opacity-70',
|
|
677
933
|
)}
|
|
678
934
|
>
|