@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.
Files changed (126) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +97 -36
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +425 -131
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +63 -22
  36. package/src/components/table-toolbar.tsx +1373 -442
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +261 -14
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +21 -0
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/storefront.ts +19 -0
  77. package/src/lib/table-registry.ts +56 -0
  78. package/src/lib/type-labels.test.ts +68 -0
  79. package/src/lib/type-labels.ts +78 -0
  80. package/src/locales/ar.json +1889 -516
  81. package/src/locales/de.json +1883 -515
  82. package/src/locales/en.json +1886 -516
  83. package/src/locales/fr.json +1884 -516
  84. package/src/locales/pl.json +1913 -516
  85. package/src/locales/zh-CN.json +1883 -516
  86. package/src/pages/package-types.tsx +484 -0
  87. package/src/pages/settings-index.tsx +112 -0
  88. package/src/pages/stock-locations.tsx +674 -0
  89. package/src/plugin.ts +29 -0
  90. package/src/products/add-video-dialog.tsx +100 -0
  91. package/src/products/bulk-variants-dialog.tsx +301 -0
  92. package/src/products/carton-size.test.ts +51 -0
  93. package/src/products/carton-size.ts +20 -0
  94. package/src/products/currency-parts.ts +21 -0
  95. package/src/products/inventory-section.tsx +259 -0
  96. package/src/products/media-edit-sheet.tsx +336 -0
  97. package/src/products/normalize-customs.test.ts +39 -0
  98. package/src/products/normalize-customs.ts +27 -0
  99. package/src/products/normalize-money.test.ts +58 -0
  100. package/src/products/normalize-money.ts +73 -0
  101. package/src/products/normalize-quantity.test.ts +32 -0
  102. package/src/products/normalize-quantity.ts +31 -0
  103. package/src/products/product-bulk-price-editor.tsx +165 -0
  104. package/src/products/product-form-cards.tsx +1167 -0
  105. package/src/products/product-form-mapping.ts +212 -0
  106. package/src/products/product-schema.ts +318 -0
  107. package/src/products/product-types.ts +130 -0
  108. package/src/products/use-product-form-data.ts +273 -0
  109. package/src/products/variant-edit-sheet.tsx +725 -0
  110. package/src/products/variants-matrix.test.ts +154 -0
  111. package/src/products/variants-matrix.ts +244 -0
  112. package/src/products/variants-options-builder.tsx +571 -0
  113. package/src/products/variants-section.tsx +535 -0
  114. package/src/products/video-url.test.ts +71 -0
  115. package/src/products/video-url.ts +83 -0
  116. package/src/providers/auth-provider.tsx +70 -12
  117. package/src/providers/permission-provider.tsx +68 -28
  118. package/src/providers/sticky-header-provider.tsx +134 -0
  119. package/src/providers/store-provider.tsx +36 -3
  120. package/src/providers/tenant-provider.tsx +33 -0
  121. package/src/schemas/package-type.ts +95 -0
  122. package/src/schemas/stock-location.ts +126 -0
  123. package/src/tables/package-types.tsx +74 -0
  124. package/src/tables/stock-locations.tsx +124 -0
  125. package/src/vite/index.ts +40 -7
  126. package/src/components/top-bar.tsx +0 -210
@@ -1,70 +1,227 @@
1
- import { Sidebar, SidebarContent, SidebarHeader } from '@spree/dashboard-ui'
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 { PackageIcon } from 'lucide-react'
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 { useStore } from '../providers/store-provider'
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
- function entryToNavItem(entry: NavEntry, storeId: string): NavItem {
14
- const pathFor = (path: string) => (path === '/' ? `/${storeId}` : `/${storeId}${path}`)
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.label,
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.label,
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
- /** Hide items the user can't `read`. */
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
- return items
32
- .filter((item) => !item.subject || permissions.can('read', item.subject))
33
- .map((item) => ({
34
- ...item,
35
- items: item.items?.filter((sub) => !sub.subject || permissions.can('read', sub.subject)),
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
- export function AppSidebar(props: ComponentProps<typeof Sidebar>) {
40
- const { i18n } = useTranslation()
41
- const { storeId } = useParams({ strict: false }) as { storeId?: string }
42
- const { permissions } = usePermissions()
43
- const { store } = useStore()
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, id)),
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, id)),
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
- <StoreSwitcher />
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
- <NavMain items={navItems} bottomItems={bottomItems} />
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
- /** The mutation. Resolves with anything; errors surface a toast. */
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
- if (count === 0) return null
130
-
131
- // Filter by CanCanCan when subject is declared.
132
- const visibleActions = actions.filter((a) =>
133
- a.subject ? permissions.can(a.action ?? 'update', a.subject) : true,
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
- toast.success(
150
- interpolate(
170
+ toastManager.add({
171
+ type: 'success',
172
+ title: interpolate(
151
173
  action.successMessage ?? t('admin.components.bulk_action_bar.default_success'),
152
- count,
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
- toast.error(action.errorMessage ?? message)
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
- // Compute available action-area width against the same cap that the
325
- // bar's +max-width+ uses (table card width on desktop, viewport on
326
- // mobile) so the overflow count matches what the user will see.
327
- const anchor = anchorRef?.current
328
- const vpWidth = document.documentElement.clientWidth
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
- const children = Array.from(measure.children) as HTMLElement[]
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-run only when the anchor element resolves (refs are null on first
367
- // render and populated on commit). Changes to +actions+ don't need an
368
- // explicit dep — the +ResizeObserver+ on the container fires when
369
- // children render/unrender, which re-triggers +recompute+.
370
- }, [anchorRef?.current])
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
- // JS-driven positioning: +position: fixed+ silently re-anchors to a
376
- // transformed/contained ancestor (sidebar wrapper, Card, etc.) on this
377
- // app. Portaling to +document.body+ should sidestep that, but in
378
- // practice the bar still landed at the table's bottom on mobile. So we
379
- // set explicit +top/left+ in pixels from +window+ measurements every
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
- style={{
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
- <MoreHorizontalIcon className="size-4" />
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
- className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-0.5"
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
- className="gap-1.5 whitespace-nowrap px-2"
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}