@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
|
@@ -1,70 +1,227 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Sidebar,
|
|
3
|
+
SidebarContent,
|
|
4
|
+
SidebarFooter,
|
|
5
|
+
SidebarGroup,
|
|
6
|
+
SidebarHeader,
|
|
7
|
+
SidebarMenu,
|
|
8
|
+
SidebarMenuItem,
|
|
9
|
+
SidebarTrigger,
|
|
10
|
+
Skeleton,
|
|
11
|
+
useSidebar,
|
|
12
|
+
} from '@spree/dashboard-ui'
|
|
13
|
+
import { PackageIcon } from '@spree/dashboard-ui/icons'
|
|
2
14
|
import { useParams } from '@tanstack/react-router'
|
|
3
|
-
import {
|
|
4
|
-
import type { ComponentProps } from 'react'
|
|
15
|
+
import type { ComponentProps, ReactNode } from 'react'
|
|
5
16
|
import { useAuth } from '../hooks/use-auth'
|
|
6
17
|
import { primarySidebarSide, useTranslation } from '../lib/i18n'
|
|
7
|
-
import { type NavEntry, useNavEntries } from '../lib/nav-registry'
|
|
18
|
+
import { type NavEntry, resolveNavLabel, useNavEntries } from '../lib/nav-registry'
|
|
19
|
+
import type { ActionName, SubjectName } from '../lib/permissions'
|
|
8
20
|
import { type Permissions, usePermissions } from '../providers/permission-provider'
|
|
9
|
-
import {
|
|
21
|
+
import { useOptionalStore } from '../providers/store-provider'
|
|
10
22
|
import { type NavItem, NavMain } from './nav-main'
|
|
23
|
+
import { SidebarSearch } from './sidebar-search'
|
|
24
|
+
import { SidebarUser } from './sidebar-user'
|
|
11
25
|
import { StoreSwitcher } from './store-switcher'
|
|
12
26
|
|
|
13
|
-
|
|
14
|
-
|
|
27
|
+
/**
|
|
28
|
+
* `labelKey` is resolved here rather than at registration so labels follow a
|
|
29
|
+
* language change — a literal `label` is frozen at import time.
|
|
30
|
+
*/
|
|
31
|
+
function entryToNavItem(entry: NavEntry, tenantId: string, t: (key: string) => string): NavItem {
|
|
32
|
+
const pathFor = (path: string) => (path === '/' ? `/${tenantId}` : `/${tenantId}${path}`)
|
|
15
33
|
return {
|
|
16
|
-
title: entry
|
|
34
|
+
title: resolveNavLabel(entry, t),
|
|
17
35
|
url: pathFor(entry.path),
|
|
18
36
|
icon: entry.icon ?? PackageIcon,
|
|
19
37
|
subject: entry.subject,
|
|
38
|
+
action: entry.action,
|
|
20
39
|
badge: entry.badge,
|
|
21
40
|
items: entry.children?.map((child) => ({
|
|
22
|
-
title: child
|
|
41
|
+
title: resolveNavLabel(child, t),
|
|
23
42
|
url: pathFor(child.path),
|
|
24
43
|
subject: child.subject,
|
|
44
|
+
action: child.action,
|
|
45
|
+
badge: child.badge,
|
|
25
46
|
})),
|
|
26
47
|
}
|
|
27
48
|
}
|
|
28
49
|
|
|
29
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Hide items the user can't act on — `read` unless the entry says otherwise.
|
|
52
|
+
*
|
|
53
|
+
* A group that declares no `subject` of its own is gated by its children
|
|
54
|
+
* instead: it has no page of its own, so its own link points at whichever
|
|
55
|
+
* child it lists first. Once permissions remove some of those children, both
|
|
56
|
+
* halves of that arrangement need fixing — the group's link has to follow the
|
|
57
|
+
* first child that survived, and a group with nothing left has to go.
|
|
58
|
+
*/
|
|
30
59
|
function filterByPermissions(items: NavItem[], permissions: Permissions): NavItem[] {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
60
|
+
const allowed = (entry: { subject?: SubjectName; action?: ActionName }) =>
|
|
61
|
+
!entry.subject || permissions.can(entry.action ?? 'read', entry.subject)
|
|
62
|
+
|
|
63
|
+
return items.flatMap((item) => {
|
|
64
|
+
if (!allowed(item)) return []
|
|
65
|
+
|
|
66
|
+
const children = item.items?.filter(allowed)
|
|
67
|
+
if (item.subject) return [{ ...item, items: children }]
|
|
68
|
+
|
|
69
|
+
// Subject-less: gated by, and pointed at, its children.
|
|
70
|
+
const first = children?.[0]
|
|
71
|
+
if (item.items && !first) return []
|
|
72
|
+
|
|
73
|
+
return [{ ...item, items: children, url: first?.url ?? item.url }]
|
|
74
|
+
})
|
|
37
75
|
}
|
|
38
76
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
77
|
+
/**
|
|
78
|
+
* The nav registry resolved for one tenant: entries prefixed with the tenant
|
|
79
|
+
* segment, `if` gates evaluated, and items the user cannot act on removed.
|
|
80
|
+
*
|
|
81
|
+
* Exported so a panel scoped by something other than a store composes the
|
|
82
|
+
* same registry — the seller panel prefixes with a seller id instead — rather
|
|
83
|
+
* than re-deriving the prefixing and permission filtering, which is what a
|
|
84
|
+
* second sidebar would otherwise copy.
|
|
85
|
+
*/
|
|
86
|
+
export function useNavItems(tenantId: string): {
|
|
87
|
+
navItems: NavItem[]
|
|
88
|
+
bottomItems: NavItem[]
|
|
89
|
+
/** True while permissions are still loading, so the nav is not yet knowable. */
|
|
90
|
+
isLoading: boolean
|
|
91
|
+
} {
|
|
92
|
+
const { t } = useTranslation()
|
|
93
|
+
const { permissions, isLoading } = usePermissions()
|
|
94
|
+
const store = useOptionalStore()?.store ?? null
|
|
44
95
|
const { user } = useAuth()
|
|
45
|
-
const id = storeId || 'default'
|
|
46
96
|
const { main, bottom } = useNavEntries()
|
|
47
97
|
|
|
48
98
|
const visibilityContext = { permissions, store, user }
|
|
49
99
|
const visible = (entry: NavEntry) => !entry.if || entry.if(visibilityContext)
|
|
50
100
|
|
|
51
101
|
const navItems = filterByPermissions(
|
|
52
|
-
main.filter(visible).map((e) => entryToNavItem(e,
|
|
102
|
+
main.filter(visible).map((e) => entryToNavItem(e, tenantId, t)),
|
|
53
103
|
permissions,
|
|
54
104
|
)
|
|
55
105
|
const bottomItems = filterByPermissions(
|
|
56
|
-
bottom.filter(visible).map((e) => entryToNavItem(e,
|
|
106
|
+
bottom.filter(visible).map((e) => entryToNavItem(e, tenantId, t)),
|
|
57
107
|
permissions,
|
|
58
108
|
)
|
|
59
109
|
|
|
110
|
+
return { navItems, bottomItems, isLoading }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The primary sidebar: registry-driven nav under a tenant switcher.
|
|
115
|
+
*
|
|
116
|
+
* Shared by every panel. The two things that genuinely differ are which
|
|
117
|
+
* tenant the links are built under and what sits in the header — a store
|
|
118
|
+
* switcher for the operator, a seller switcher for the marketplace panel — so
|
|
119
|
+
* both are props. Everything else (side-by-language, collapsible rail,
|
|
120
|
+
* permission filtering) is the same in either, and a panel that copied this to
|
|
121
|
+
* change the header would silently miss every later fix to the rest.
|
|
122
|
+
*
|
|
123
|
+
* The rail carries the whole of the app's chrome: the tenant switcher and
|
|
124
|
+
* search above the nav, the account menu at its foot. There is no top bar —
|
|
125
|
+
* the page's own header is the only thing above the content.
|
|
126
|
+
*/
|
|
127
|
+
export function AppSidebar({
|
|
128
|
+
tenantId,
|
|
129
|
+
header,
|
|
130
|
+
uiLocales,
|
|
131
|
+
onEditProfile,
|
|
132
|
+
...props
|
|
133
|
+
}: ComponentProps<typeof Sidebar> & {
|
|
134
|
+
/**
|
|
135
|
+
* The id the nav links are prefixed with. Defaults to the route's `storeId`,
|
|
136
|
+
* so the operator's dashboard passes nothing.
|
|
137
|
+
*/
|
|
138
|
+
tenantId?: string
|
|
139
|
+
/** Rendered in the header. Defaults to the store switcher. */
|
|
140
|
+
header?: ReactNode
|
|
141
|
+
/** Admin UI languages offered by the account menu's language switcher. */
|
|
142
|
+
uiLocales?: ReadonlyArray<{ code: string; name: string }>
|
|
143
|
+
/** Opens the app's edit-profile dialog from the account menu. */
|
|
144
|
+
onEditProfile?: () => void
|
|
145
|
+
}) {
|
|
146
|
+
const { i18n } = useTranslation()
|
|
147
|
+
const { storeId } = useParams({ strict: false }) as { storeId?: string }
|
|
148
|
+
const { navItems, bottomItems, isLoading } = useNavItems(tenantId ?? storeId ?? 'default')
|
|
149
|
+
|
|
60
150
|
return (
|
|
61
|
-
<Sidebar collapsible="icon" side={primarySidebarSide(i18n.language)} {...props}>
|
|
151
|
+
<Sidebar collapsible="icon" variant="inset" side={primarySidebarSide(i18n.language)} {...props}>
|
|
62
152
|
<SidebarHeader>
|
|
63
|
-
|
|
153
|
+
{/* The switcher, search and the account row are all hidden on a phone:
|
|
154
|
+
the top bar already names the store and carries both search and the
|
|
155
|
+
account menu, so repeating them inside the drawer spends rows of a
|
|
156
|
+
small screen saying what is visible behind it. The drawer is for
|
|
157
|
+
navigating. */}
|
|
158
|
+
<div className="hidden items-center gap-1 md:flex">
|
|
159
|
+
<div className="min-w-0 flex-1">{header ?? <StoreSwitcher />}</div>
|
|
160
|
+
<CollapseTrigger />
|
|
161
|
+
</div>
|
|
162
|
+
<div className="hidden md:block">
|
|
163
|
+
<SidebarSearch />
|
|
164
|
+
</div>
|
|
64
165
|
</SidebarHeader>
|
|
65
166
|
<SidebarContent>
|
|
66
|
-
|
|
167
|
+
{/* Permissions decide which links exist, and until they arrive every
|
|
168
|
+
`can()` answers false — so the real nav is not "empty", it is not
|
|
169
|
+
yet known. Rendering the filtered list during that window shows a
|
|
170
|
+
near-empty rail that then pops to a full one; skeleton rows keep the
|
|
171
|
+
shell's shape steady and say the difference honestly.
|
|
172
|
+
|
|
173
|
+
Keyed on `isLoading` alone, not on an empty list: entries without a
|
|
174
|
+
`subject` (Home, Getting Started) skip the permission filter, so the
|
|
175
|
+
list is never actually empty and a length check would never fire. */}
|
|
176
|
+
{isLoading ? <NavSkeleton /> : <NavMain items={navItems} bottomItems={bottomItems} />}
|
|
67
177
|
</SidebarContent>
|
|
178
|
+
<SidebarFooter className="hidden md:flex">
|
|
179
|
+
<SidebarUser uiLocales={uiLocales} onEditProfile={onEditProfile} />
|
|
180
|
+
</SidebarFooter>
|
|
68
181
|
</Sidebar>
|
|
69
182
|
)
|
|
70
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Placeholder rows for the primary nav while permissions load.
|
|
187
|
+
*
|
|
188
|
+
* The count is deliberate rather than arbitrary: it approximates a typical
|
|
189
|
+
* operator's nav so the rail does not visibly resize when the real list
|
|
190
|
+
* replaces it. Marked `aria-hidden` — a screen reader gains nothing from
|
|
191
|
+
* placeholder rows, and the shell announces the page itself.
|
|
192
|
+
*/
|
|
193
|
+
function NavSkeleton() {
|
|
194
|
+
return (
|
|
195
|
+
<SidebarGroup aria-hidden>
|
|
196
|
+
<SidebarMenu>
|
|
197
|
+
{Array.from({ length: 8 }, (_, i) => (
|
|
198
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length placeholder list
|
|
199
|
+
<SidebarMenuItem key={i}>
|
|
200
|
+
<div className="flex h-8 items-center gap-2 px-2">
|
|
201
|
+
<Skeleton className="size-4 shrink-0 rounded" />
|
|
202
|
+
<Skeleton className="h-3 w-24 group-data-[collapsible=icon]:hidden" />
|
|
203
|
+
</div>
|
|
204
|
+
</SidebarMenuItem>
|
|
205
|
+
))}
|
|
206
|
+
</SidebarMenu>
|
|
207
|
+
</SidebarGroup>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Collapses the rail to its icon width, beside the tenant switcher.
|
|
213
|
+
*
|
|
214
|
+
* It lives here because the rail is now the app's only chrome — the top bar
|
|
215
|
+
* that used to carry this control is gone, and without it the rail could be
|
|
216
|
+
* collapsed by keyboard alone. Hidden once collapsed: at icon width there is
|
|
217
|
+
* no room beside the avatar, and the rail's own edge handle (`SidebarRail`)
|
|
218
|
+
* expands it again.
|
|
219
|
+
*/
|
|
220
|
+
function CollapseTrigger() {
|
|
221
|
+
const { isMobile, state } = useSidebar()
|
|
222
|
+
if (isMobile || state === 'collapsed') return null
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<SidebarTrigger className="shrink-0 text-muted-foreground hover:bg-sidebar-accent hover:text-foreground" />
|
|
226
|
+
)
|
|
227
|
+
}
|
|
@@ -4,16 +4,15 @@ import {
|
|
|
4
4
|
DropdownMenuContent,
|
|
5
5
|
DropdownMenuItem,
|
|
6
6
|
DropdownMenuTrigger,
|
|
7
|
+
toastManager,
|
|
7
8
|
useConfirm,
|
|
8
9
|
} from '@spree/dashboard-ui'
|
|
10
|
+
import { EllipsisVerticalIcon } from '@spree/dashboard-ui/icons'
|
|
9
11
|
import type { QueryKey } from '@tanstack/react-query'
|
|
10
12
|
import { useQueryClient } from '@tanstack/react-query'
|
|
11
|
-
import { MoreHorizontalIcon, XIcon } from 'lucide-react'
|
|
12
13
|
import type { ReactNode } from 'react'
|
|
13
|
-
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
14
|
-
import { createPortal } from 'react-dom'
|
|
14
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
15
15
|
import { useTranslation } from 'react-i18next'
|
|
16
|
-
import { toast } from 'sonner'
|
|
17
16
|
import { usePermissions } from '../providers/permission-provider'
|
|
18
17
|
|
|
19
18
|
/**
|
|
@@ -32,6 +31,25 @@ export interface BulkActionFormProps<TFormValues = unknown> {
|
|
|
32
31
|
onCancel: () => void
|
|
33
32
|
}
|
|
34
33
|
|
|
34
|
+
/**
|
|
35
|
+
* What a bulk action actually did, when that differs from what was selected.
|
|
36
|
+
*
|
|
37
|
+
* `count` is the number of records the action really affected. Reporting the
|
|
38
|
+
* selection size instead is wrong wherever the server skips rows it cannot
|
|
39
|
+
* move: the merchant is told fifteen products were submitted while a second
|
|
40
|
+
* toast says twelve were left alone.
|
|
41
|
+
*/
|
|
42
|
+
export interface BulkActionOutcome {
|
|
43
|
+
count: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function outcomeCount(result: unknown): number | null {
|
|
47
|
+
if (typeof result !== 'object' || result === null) return null
|
|
48
|
+
|
|
49
|
+
const count = (result as BulkActionOutcome).count
|
|
50
|
+
return typeof count === 'number' ? count : null
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
/**
|
|
36
54
|
* Declarative bulk action passed to `<ResourceTable>`. Three shapes:
|
|
37
55
|
*
|
|
@@ -63,7 +81,19 @@ export interface BulkAction<TFormValues = unknown> {
|
|
|
63
81
|
}
|
|
64
82
|
/** Render-prop for actions that need to collect form values before running. */
|
|
65
83
|
form?: (props: BulkActionFormProps<TFormValues>) => ReactNode
|
|
66
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* The mutation. Errors surface a toast.
|
|
86
|
+
*
|
|
87
|
+
* Resolve with a `BulkActionOutcome` when the server may act on fewer rows
|
|
88
|
+
* than were selected — a bulk endpoint that skips what it cannot move
|
|
89
|
+
* returns its own count, and `{n}` in `successMessage` is then that count
|
|
90
|
+
* rather than the size of the selection. Resolving with anything else (the
|
|
91
|
+
* common case) leaves `{n}` as the number of selected rows.
|
|
92
|
+
*
|
|
93
|
+
* Typed `unknown` rather than the union, which a narrower type cannot
|
|
94
|
+
* express: most actions resolve with their raw mutation payload, so the
|
|
95
|
+
* outcome shape is recognised at runtime, not enforced here.
|
|
96
|
+
*/
|
|
67
97
|
run: (ctx: BulkActionRunContext<TFormValues>) => Promise<unknown>
|
|
68
98
|
/**
|
|
69
99
|
* Extra query keys to invalidate after `run` succeeds. The table's own
|
|
@@ -85,30 +115,15 @@ interface BulkActionBarProps {
|
|
|
85
115
|
// the precise type via `BulkAction<MyFormValues>`.
|
|
86
116
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
87
117
|
actions: BulkAction<any>[]
|
|
88
|
-
onClear: () => void
|
|
89
118
|
/** Called after a successful run to refresh the table / clear selection. */
|
|
90
119
|
onDone: () => void
|
|
91
|
-
/**
|
|
92
|
-
* When set, the bar centers itself horizontally inside this element's
|
|
93
|
-
* bounding rect on viewports wide enough to comfortably show it inside
|
|
94
|
-
* (>= +md+ breakpoint). On smaller viewports it falls back to centering
|
|
95
|
-
* against the visual viewport. Without this the bar always anchors to
|
|
96
|
-
* the viewport.
|
|
97
|
-
*/
|
|
98
|
-
anchorRef?: React.RefObject<HTMLElement | null>
|
|
99
120
|
}
|
|
100
121
|
|
|
101
122
|
function interpolate(template: string, n: number) {
|
|
102
123
|
return template.replace(/\{n\}/g, String(n))
|
|
103
124
|
}
|
|
104
125
|
|
|
105
|
-
export function BulkActionBar({
|
|
106
|
-
selectedIds,
|
|
107
|
-
actions,
|
|
108
|
-
onClear,
|
|
109
|
-
onDone,
|
|
110
|
-
anchorRef,
|
|
111
|
-
}: BulkActionBarProps) {
|
|
126
|
+
export function BulkActionBar({ selectedIds, actions, onDone }: BulkActionBarProps) {
|
|
112
127
|
const { t } = useTranslation()
|
|
113
128
|
const confirm = useConfirm()
|
|
114
129
|
const queryClient = useQueryClient()
|
|
@@ -126,18 +141,24 @@ export function BulkActionBar({
|
|
|
126
141
|
if (count === 0) setPendingForm(null)
|
|
127
142
|
}, [count])
|
|
128
143
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
144
|
+
// Filter by CanCanCan when subject is declared. Memoized so the layout below
|
|
145
|
+
// can treat a change here as a real change: it re-measures the overflow
|
|
146
|
+
// whenever the action set does, and a fresh array every render would make
|
|
147
|
+
// that run on every render instead. Must stay above the early return —
|
|
148
|
+
// hooks cannot be called conditionally.
|
|
149
|
+
const visibleActions = useMemo(
|
|
150
|
+
() =>
|
|
151
|
+
actions.filter((a) => (a.subject ? permissions.can(a.action ?? 'update', a.subject) : true)),
|
|
152
|
+
[actions, permissions],
|
|
134
153
|
)
|
|
135
154
|
|
|
155
|
+
if (count === 0) return null
|
|
156
|
+
|
|
136
157
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
137
158
|
async function execute(action: BulkAction<any>, formValues?: unknown) {
|
|
138
159
|
setRunning(true)
|
|
139
160
|
try {
|
|
140
|
-
await action.run({ ids: selectedIds, formValues })
|
|
161
|
+
const result = await action.run({ ids: selectedIds, formValues })
|
|
141
162
|
// Invalidate the action's declared cross-resource keys BEFORE `onDone`
|
|
142
163
|
// (which invalidates the host table's own key). Without this, pages
|
|
143
164
|
// like Customer Groups that cache `customers_count` won't refresh
|
|
@@ -146,17 +167,19 @@ export function BulkActionBar({
|
|
|
146
167
|
for (const key of action.invalidate ?? []) {
|
|
147
168
|
queryClient.invalidateQueries({ queryKey: key })
|
|
148
169
|
}
|
|
149
|
-
|
|
150
|
-
|
|
170
|
+
toastManager.add({
|
|
171
|
+
type: 'success',
|
|
172
|
+
title: interpolate(
|
|
151
173
|
action.successMessage ?? t('admin.components.bulk_action_bar.default_success'),
|
|
152
|
-
|
|
174
|
+
// What the server did, when it said — otherwise what was selected.
|
|
175
|
+
outcomeCount(result) ?? count,
|
|
153
176
|
),
|
|
154
|
-
)
|
|
177
|
+
})
|
|
155
178
|
onDone()
|
|
156
179
|
} catch (err) {
|
|
157
180
|
const message =
|
|
158
181
|
err instanceof Error ? err.message : t('admin.components.bulk_action_bar.default_error')
|
|
159
|
-
|
|
182
|
+
toastManager.add({ type: 'error', title: action.errorMessage ?? message })
|
|
160
183
|
} finally {
|
|
161
184
|
setRunning(false)
|
|
162
185
|
}
|
|
@@ -184,8 +207,6 @@ export function BulkActionBar({
|
|
|
184
207
|
<BulkActionBarLayout
|
|
185
208
|
count={count}
|
|
186
209
|
actions={visibleActions}
|
|
187
|
-
anchorRef={anchorRef}
|
|
188
|
-
onClear={onClear}
|
|
189
210
|
onClickAction={handleClick}
|
|
190
211
|
running={running}
|
|
191
212
|
pendingForm={pendingForm}
|
|
@@ -205,8 +226,6 @@ interface BulkActionBarLayoutProps {
|
|
|
205
226
|
count: number
|
|
206
227
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
207
228
|
actions: BulkAction<any>[]
|
|
208
|
-
anchorRef?: React.RefObject<HTMLElement | null>
|
|
209
|
-
onClear: () => void
|
|
210
229
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
211
230
|
onClickAction: (action: BulkAction<any>) => void
|
|
212
231
|
running: boolean
|
|
@@ -228,8 +247,6 @@ interface BulkActionBarLayoutProps {
|
|
|
228
247
|
function BulkActionBarLayout({
|
|
229
248
|
count,
|
|
230
249
|
actions,
|
|
231
|
-
anchorRef,
|
|
232
|
-
onClear,
|
|
233
250
|
onClickAction,
|
|
234
251
|
running,
|
|
235
252
|
pendingForm,
|
|
@@ -239,104 +256,30 @@ function BulkActionBarLayout({
|
|
|
239
256
|
}: BulkActionBarLayoutProps) {
|
|
240
257
|
const { t } = useTranslation()
|
|
241
258
|
|
|
242
|
-
// The bar is centered (+w-fit+) so the parent doesn't constrain it; instead
|
|
243
|
-
// we cap against the viewport with a sensible margin.
|
|
244
259
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
245
260
|
const measureRef = useRef<HTMLDivElement | null>(null)
|
|
246
261
|
// Width of the bar's leading section (clear button + label + divider).
|
|
247
262
|
const leadingRef = useRef<HTMLDivElement | null>(null)
|
|
248
263
|
const [visibleCount, setVisibleCount] = useState(actions.length)
|
|
249
|
-
// JS-computed position so it's immune to transformed ancestors.
|
|
250
|
-
const [barLeft, setBarLeft] = useState(0)
|
|
251
|
-
const [barTop, setBarTop] = useState(0)
|
|
252
|
-
|
|
253
|
-
// Max-width state so the bar never extends past its host region (the
|
|
254
|
-
// table card on desktop, the visual viewport on mobile).
|
|
255
|
-
const [maxWidth, setMaxWidth] = useState<number | null>(null)
|
|
256
|
-
|
|
257
|
-
// Reposition + remeasure on viewport resize, scroll, and font load.
|
|
258
|
-
useLayoutEffect(() => {
|
|
259
|
-
function reposition() {
|
|
260
|
-
const el = containerRef.current
|
|
261
|
-
if (!el) return
|
|
262
|
-
const width = el.offsetWidth
|
|
263
|
-
const height = el.offsetHeight
|
|
264
|
-
// visualViewport accounts for the mobile browser URL bar collapsing;
|
|
265
|
-
// falls back to window dimensions on browsers without the API.
|
|
266
|
-
const vv = window.visualViewport
|
|
267
|
-
const vpWidth = vv?.width ?? window.innerWidth
|
|
268
|
-
const vpHeight = vv?.height ?? window.innerHeight
|
|
269
|
-
const vpLeft = vv?.offsetLeft ?? 0
|
|
270
|
-
const vpTop = vv?.offsetTop ?? 0
|
|
271
|
-
|
|
272
|
-
// Pin to viewport bottom. The bar should always stay reachable —
|
|
273
|
-
// anchoring to the table card pinned it to the table's bottom, which
|
|
274
|
-
// sits off-screen when the table is taller than the viewport.
|
|
275
|
-
let left = vpLeft + Math.round((vpWidth - width) / 2)
|
|
276
|
-
// Cap width at viewport - 2rem on mobile.
|
|
277
|
-
let cap = vpWidth - 32
|
|
278
|
-
// If an anchor element is provided, clamp the horizontal center AND
|
|
279
|
-
// the max width to the anchor's bounding rect, so on desktop the bar
|
|
280
|
-
// reads as part of the table region rather than free-floating across
|
|
281
|
-
// the whole page.
|
|
282
|
-
const anchor = anchorRef?.current
|
|
283
|
-
if (anchor && vpWidth >= 768) {
|
|
284
|
-
const rect = anchor.getBoundingClientRect()
|
|
285
|
-
left = rect.left + Math.round((rect.width - width) / 2)
|
|
286
|
-
cap = Math.round(rect.width * 0.9) // 90% of the table card width
|
|
287
|
-
}
|
|
288
|
-
setBarLeft(left)
|
|
289
|
-
setBarTop(vpTop + vpHeight - height - 16) // 16px from bottom
|
|
290
|
-
setMaxWidth(cap)
|
|
291
|
-
}
|
|
292
|
-
reposition()
|
|
293
|
-
window.addEventListener('resize', reposition)
|
|
294
|
-
window.addEventListener('scroll', reposition, true)
|
|
295
|
-
window.visualViewport?.addEventListener('resize', reposition)
|
|
296
|
-
window.visualViewport?.addEventListener('scroll', reposition)
|
|
297
|
-
// Track the anchor's own size/position changes (sidebar collapse,
|
|
298
|
-
// pagination footer height swaps, etc.).
|
|
299
|
-
let ro: ResizeObserver | undefined
|
|
300
|
-
if (anchorRef?.current) {
|
|
301
|
-
ro = new ResizeObserver(reposition)
|
|
302
|
-
ro.observe(anchorRef.current)
|
|
303
|
-
}
|
|
304
|
-
return () => {
|
|
305
|
-
window.removeEventListener('resize', reposition)
|
|
306
|
-
window.removeEventListener('scroll', reposition, true)
|
|
307
|
-
window.visualViewport?.removeEventListener('resize', reposition)
|
|
308
|
-
window.visualViewport?.removeEventListener('scroll', reposition)
|
|
309
|
-
ro?.disconnect()
|
|
310
|
-
}
|
|
311
|
-
// Re-run when the anchor element resolves (refs are null on first render
|
|
312
|
-
// and populated on the next commit). The +ResizeObserver+ + window
|
|
313
|
-
// listeners re-attach to whatever anchor the consumer passed.
|
|
314
|
-
}, [anchorRef?.current])
|
|
315
264
|
|
|
316
265
|
// Measure once on mount + whenever the container or action set changes.
|
|
317
266
|
// +useLayoutEffect+ so the user never sees a flash of overflowing actions.
|
|
318
267
|
useLayoutEffect(() => {
|
|
319
268
|
function recompute() {
|
|
269
|
+
const container = containerRef.current
|
|
320
270
|
const measure = measureRef.current
|
|
321
271
|
const leading = leadingRef.current
|
|
322
|
-
if (!measure || !leading) return
|
|
272
|
+
if (!container || !measure || !leading) return
|
|
323
273
|
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
const cap =
|
|
330
|
-
anchor && vpWidth >= 768
|
|
331
|
-
? Math.round(anchor.getBoundingClientRect().width * 0.9)
|
|
332
|
-
: vpWidth - 32
|
|
333
|
-
// 16px for the bar's own +p-1+ + border + subpixel safety pad.
|
|
334
|
-
const available = cap - 16 - leading.offsetWidth
|
|
274
|
+
// The bar spans the header row, so the budget is its own width less the
|
|
275
|
+
// leading section (the "n selected" count + divider). The container sits
|
|
276
|
+
// inside the host cell's padding and after the select-all checkbox, so
|
|
277
|
+
// neither needs subtracting here.
|
|
278
|
+
const available = container.clientWidth - leading.offsetWidth
|
|
335
279
|
// +⋯+ button (icon-sm = 28px) + gap on each side + safety margin.
|
|
336
|
-
// 64px keeps the dropdown comfortably visible on narrow viewports
|
|
337
|
-
// and gives the menu room to position without spilling off-screen.
|
|
338
280
|
const moreWidth = 64
|
|
339
|
-
|
|
281
|
+
// One measured child per action, in order.
|
|
282
|
+
const children = Array.from(measure.children).slice(0, actions.length) as HTMLElement[]
|
|
340
283
|
const widths = children.map((el) => el.offsetWidth)
|
|
341
284
|
|
|
342
285
|
let used = 0
|
|
@@ -357,50 +300,34 @@ function BulkActionBarLayout({
|
|
|
357
300
|
recompute()
|
|
358
301
|
const ro = new ResizeObserver(recompute)
|
|
359
302
|
if (containerRef.current) ro.observe(containerRef.current)
|
|
360
|
-
if (anchorRef?.current) ro.observe(anchorRef.current)
|
|
361
303
|
window.addEventListener('resize', recompute)
|
|
362
304
|
return () => {
|
|
363
305
|
ro.disconnect()
|
|
364
306
|
window.removeEventListener('resize', recompute)
|
|
365
307
|
}
|
|
366
|
-
// Re-
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
|
|
308
|
+
// Re-measure when the action set changes, not only when the container
|
|
309
|
+
// resizes: the list is filtered by permission, so it can gain or lose
|
|
310
|
+
// buttons — and a language switch relabels them — with the bar's own width
|
|
311
|
+
// unchanged. The measuring row is zero-width by design, so observing it
|
|
312
|
+
// would not catch either case.
|
|
313
|
+
}, [actions])
|
|
371
314
|
|
|
372
315
|
const visible = actions.slice(0, visibleCount)
|
|
373
316
|
const overflow = actions.slice(visibleCount)
|
|
374
317
|
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
// frame the viewport changes. No CSS positioning, no ambiguity.
|
|
381
|
-
const bar = (
|
|
318
|
+
// `w-full` matters for more than layout: this is the element the overflow
|
|
319
|
+
// calculation measures, and as a bare flex item it would size to its own
|
|
320
|
+
// content instead of the header cell, leaving almost no budget and pushing
|
|
321
|
+
// every action into the `⋯` menu.
|
|
322
|
+
return (
|
|
382
323
|
<>
|
|
383
324
|
<div
|
|
384
325
|
ref={containerRef}
|
|
385
|
-
|
|
386
|
-
position: 'fixed',
|
|
387
|
-
left: `${barLeft}px`,
|
|
388
|
-
top: `${barTop}px`,
|
|
389
|
-
maxWidth: maxWidth != null ? `${maxWidth}px` : 'calc(100vw - 2rem)',
|
|
390
|
-
}}
|
|
391
|
-
className="z-50 flex w-fit items-center gap-0.5 rounded-xl border bg-popover p-1 shadow-md"
|
|
326
|
+
className="flex w-full items-center gap-0.5 overflow-hidden animate-in fade-in slide-in-from-left-2 duration-150 ease-out"
|
|
392
327
|
>
|
|
328
|
+
{/* No clear button: the bar sits beside the header's select-all
|
|
329
|
+
checkbox, which already clears the selection. */}
|
|
393
330
|
<div ref={leadingRef} className="flex items-center gap-0.5">
|
|
394
|
-
<Button
|
|
395
|
-
type="button"
|
|
396
|
-
variant="ghost"
|
|
397
|
-
size="icon-sm"
|
|
398
|
-
onClick={onClear}
|
|
399
|
-
aria-label={t('admin.actions.clear')}
|
|
400
|
-
className="text-muted-foreground"
|
|
401
|
-
>
|
|
402
|
-
<XIcon className="size-4" />
|
|
403
|
-
</Button>
|
|
404
331
|
<span className="whitespace-nowrap px-1.5 text-sm">
|
|
405
332
|
{t('admin.components.bulk_action_bar.selected', { count })}
|
|
406
333
|
</span>
|
|
@@ -425,7 +352,7 @@ function BulkActionBarLayout({
|
|
|
425
352
|
size="icon-sm"
|
|
426
353
|
aria-label={t('admin.actions.more_actions')}
|
|
427
354
|
>
|
|
428
|
-
<
|
|
355
|
+
<EllipsisVerticalIcon className="size-4" />
|
|
429
356
|
</Button>
|
|
430
357
|
</DropdownMenuTrigger>
|
|
431
358
|
<DropdownMenuContent align="end">
|
|
@@ -450,7 +377,11 @@ function BulkActionBarLayout({
|
|
|
450
377
|
<div
|
|
451
378
|
ref={measureRef}
|
|
452
379
|
aria-hidden
|
|
453
|
-
|
|
380
|
+
// `overflow-hidden` on a zero-width box: the buttons keep their
|
|
381
|
+
// natural `offsetWidth` for measurement, but nothing escapes to
|
|
382
|
+
// stretch the table's scroll width — without it every list becomes
|
|
383
|
+
// horizontally scrollable whenever rows are selected.
|
|
384
|
+
className="pointer-events-none invisible absolute left-0 top-0 flex w-0 items-center gap-0.5 overflow-hidden"
|
|
454
385
|
>
|
|
455
386
|
{actions.map((action) => (
|
|
456
387
|
<BulkActionButton
|
|
@@ -470,9 +401,6 @@ function BulkActionBarLayout({
|
|
|
470
401
|
})}
|
|
471
402
|
</>
|
|
472
403
|
)
|
|
473
|
-
|
|
474
|
-
if (typeof document === 'undefined') return bar
|
|
475
|
-
return createPortal(bar, document.body)
|
|
476
404
|
}
|
|
477
405
|
|
|
478
406
|
function BulkActionButton({
|
|
@@ -495,7 +423,11 @@ function BulkActionButton({
|
|
|
495
423
|
onClick={onClick}
|
|
496
424
|
disabled={disabled}
|
|
497
425
|
tabIndex={tabIndex}
|
|
498
|
-
|
|
426
|
+
// `shrink-0` so the zero-width measuring layer can't squeeze these and
|
|
427
|
+
// report a width smaller than the button really renders at. Hover is the
|
|
428
|
+
// ghost variant's own `bg-accent` — no override needed now that the bar's
|
|
429
|
+
// muted background sits well clear of it.
|
|
430
|
+
className="shrink-0 gap-1.5 whitespace-nowrap px-2"
|
|
499
431
|
>
|
|
500
432
|
{action.icon}
|
|
501
433
|
{action.label}
|