@spree/dashboard-core 0.1.0
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/LICENSE +21 -0
- package/README.md +418 -0
- package/package.json +84 -0
- package/src/client.ts +25 -0
- package/src/components/address-form-dialog.tsx +376 -0
- package/src/components/app-sidebar.tsx +70 -0
- package/src/components/bulk-action-bar.tsx +504 -0
- package/src/components/can.tsx +30 -0
- package/src/components/country-combobox.tsx +238 -0
- package/src/components/country-state-fields.tsx +86 -0
- package/src/components/currency-select.tsx +181 -0
- package/src/components/export-button.tsx +156 -0
- package/src/components/file-upload-field.tsx +265 -0
- package/src/components/image-upload-field.tsx +53 -0
- package/src/components/import-button.tsx +202 -0
- package/src/components/locale-select.tsx +268 -0
- package/src/components/market-combobox.tsx +89 -0
- package/src/components/nav-main.tsx +186 -0
- package/src/components/page-header.tsx +286 -0
- package/src/components/page-tabs.tsx +78 -0
- package/src/components/preferences-form.tsx +404 -0
- package/src/components/resource-combobox.tsx +126 -0
- package/src/components/resource-multi-autocomplete.tsx +176 -0
- package/src/components/resource-picker-sheet.tsx +235 -0
- package/src/components/resource-table.tsx +652 -0
- package/src/components/settings-sidebar.tsx +139 -0
- package/src/components/slot.tsx +50 -0
- package/src/components/store-date-picker.tsx +18 -0
- package/src/components/store-switcher.tsx +70 -0
- package/src/components/table-toolbar.tsx +818 -0
- package/src/components/tag-combobox.tsx +99 -0
- package/src/components/top-bar.tsx +210 -0
- package/src/hooks/use-auth.ts +10 -0
- package/src/hooks/use-command-palette.tsx +34 -0
- package/src/hooks/use-countries.ts +15 -0
- package/src/hooks/use-custom-fields.ts +142 -0
- package/src/hooks/use-direct-upload.ts +104 -0
- package/src/hooks/use-display-name.ts +42 -0
- package/src/hooks/use-export.ts +75 -0
- package/src/hooks/use-global-search.ts +79 -0
- package/src/hooks/use-host-form.ts +34 -0
- package/src/hooks/use-import.ts +62 -0
- package/src/hooks/use-resource-mutation.ts +99 -0
- package/src/hooks/use-switch-admin-locale.ts +55 -0
- package/src/index.ts +97 -0
- package/src/lib/custom-field-components.ts +56 -0
- package/src/lib/download.ts +49 -0
- package/src/lib/filters-to-ransack.ts +47 -0
- package/src/lib/form-errors.ts +103 -0
- package/src/lib/form-fields-registry.ts +102 -0
- package/src/lib/form-mappers.ts +30 -0
- package/src/lib/formatters.ts +19 -0
- package/src/lib/i18n.ts +210 -0
- package/src/lib/nav-registry.ts +224 -0
- package/src/lib/permissions.ts +55 -0
- package/src/lib/query-client.ts +11 -0
- package/src/lib/query-keys.ts +69 -0
- package/src/lib/route-registry.ts +158 -0
- package/src/lib/search-registry.ts +129 -0
- package/src/lib/settings-nav-registry.ts +158 -0
- package/src/lib/slot-registry.ts +116 -0
- package/src/lib/table-registry.ts +274 -0
- package/src/lib/timestamp-columns.tsx +58 -0
- package/src/locales/ar.json +629 -0
- package/src/locales/de.json +629 -0
- package/src/locales/en.json +629 -0
- package/src/locales/fr.json +629 -0
- package/src/locales/pl.json +629 -0
- package/src/locales/zh-CN.json +629 -0
- package/src/plugin.ts +222 -0
- package/src/providers/auth-provider.tsx +188 -0
- package/src/providers/permission-provider.tsx +118 -0
- package/src/providers/store-provider.tsx +111 -0
- package/src/virtual.d.ts +5 -0
- package/src/vite/discover.ts +170 -0
- package/src/vite/index.ts +270 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
DropdownMenu,
|
|
4
|
+
DropdownMenuContent,
|
|
5
|
+
DropdownMenuItem,
|
|
6
|
+
DropdownMenuTrigger,
|
|
7
|
+
useConfirm,
|
|
8
|
+
} from '@spree/dashboard-ui'
|
|
9
|
+
import type { QueryKey } from '@tanstack/react-query'
|
|
10
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
11
|
+
import { MoreHorizontalIcon, XIcon } from 'lucide-react'
|
|
12
|
+
import type { ReactNode } from 'react'
|
|
13
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
14
|
+
import { createPortal } from 'react-dom'
|
|
15
|
+
import { useTranslation } from 'react-i18next'
|
|
16
|
+
import { toast } from 'sonner'
|
|
17
|
+
import { usePermissions } from '../providers/permission-provider'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Context handed to a bulk action's `run` and `form` callbacks. `ids` is the
|
|
21
|
+
* set of selected row IDs (prefixed). `formValues` is whatever the action's
|
|
22
|
+
* `form` component resolved with — `undefined` for actions without a form.
|
|
23
|
+
*/
|
|
24
|
+
export interface BulkActionRunContext<TFormValues = unknown> {
|
|
25
|
+
ids: string[]
|
|
26
|
+
formValues?: TFormValues
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BulkActionFormProps<TFormValues = unknown> {
|
|
30
|
+
ids: string[]
|
|
31
|
+
onSubmit: (values: TFormValues) => void
|
|
32
|
+
onCancel: () => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Declarative bulk action passed to `<ResourceTable>`. Three shapes:
|
|
37
|
+
*
|
|
38
|
+
* 1. Immediate — `run` is called as soon as the button is clicked. Use for
|
|
39
|
+
* one-click ops that don't need confirmation (rare).
|
|
40
|
+
* 2. Confirm — `confirm` opens the standard confirm dialog before `run`.
|
|
41
|
+
* Use for destructive or otherwise unambiguous actions.
|
|
42
|
+
* 3. Form — `form` renders a component (usually a Sheet) that collects
|
|
43
|
+
* parameters and resolves with `formValues` passed to `run`. Use for
|
|
44
|
+
* parameterised actions like "add tags…" or "move to group…".
|
|
45
|
+
*
|
|
46
|
+
* Visibility is gated by `subject` + action via CanCanCan. Actions without
|
|
47
|
+
* a subject are always shown.
|
|
48
|
+
*/
|
|
49
|
+
export interface BulkAction<TFormValues = unknown> {
|
|
50
|
+
key: string
|
|
51
|
+
label: string
|
|
52
|
+
icon?: ReactNode
|
|
53
|
+
/** Optional Subject for CanCanCan visibility check. Pairs with `action`. */
|
|
54
|
+
subject?: string
|
|
55
|
+
/** CanCanCan action keyword (default: `'update'`). */
|
|
56
|
+
action?: string
|
|
57
|
+
/** Confirm dialog options. `{n}` in `title`/`message` is replaced by the count. */
|
|
58
|
+
confirm?: {
|
|
59
|
+
title?: string
|
|
60
|
+
message: string
|
|
61
|
+
confirmLabel?: string
|
|
62
|
+
variant?: 'default' | 'destructive'
|
|
63
|
+
}
|
|
64
|
+
/** Render-prop for actions that need to collect form values before running. */
|
|
65
|
+
form?: (props: BulkActionFormProps<TFormValues>) => ReactNode
|
|
66
|
+
/** The mutation. Resolves with anything; errors surface a toast. */
|
|
67
|
+
run: (ctx: BulkActionRunContext<TFormValues>) => Promise<unknown>
|
|
68
|
+
/**
|
|
69
|
+
* Extra query keys to invalidate after `run` succeeds. The table's own
|
|
70
|
+
* `queryKey` is always invalidated — list this when the mutation also
|
|
71
|
+
* affects records in other resources (e.g. assigning customers to a
|
|
72
|
+
* group mutates the customer rows AND every group's `customers_count`).
|
|
73
|
+
*/
|
|
74
|
+
invalidate?: QueryKey[]
|
|
75
|
+
/** Toast message on success. Supports `{n}` substitution. */
|
|
76
|
+
successMessage?: string
|
|
77
|
+
/** Toast message on failure. */
|
|
78
|
+
errorMessage?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface BulkActionBarProps {
|
|
82
|
+
selectedIds: string[]
|
|
83
|
+
// The bar treats `formValues` opaquely (forwards from `form` to `run`) so it
|
|
84
|
+
// doesn't care what the action's type parameter is. Concrete callers keep
|
|
85
|
+
// the precise type via `BulkAction<MyFormValues>`.
|
|
86
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
87
|
+
actions: BulkAction<any>[]
|
|
88
|
+
onClear: () => void
|
|
89
|
+
/** Called after a successful run to refresh the table / clear selection. */
|
|
90
|
+
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
|
+
}
|
|
100
|
+
|
|
101
|
+
function interpolate(template: string, n: number) {
|
|
102
|
+
return template.replace(/\{n\}/g, String(n))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function BulkActionBar({
|
|
106
|
+
selectedIds,
|
|
107
|
+
actions,
|
|
108
|
+
onClear,
|
|
109
|
+
onDone,
|
|
110
|
+
anchorRef,
|
|
111
|
+
}: BulkActionBarProps) {
|
|
112
|
+
const { t } = useTranslation()
|
|
113
|
+
const confirm = useConfirm()
|
|
114
|
+
const queryClient = useQueryClient()
|
|
115
|
+
const { permissions } = usePermissions()
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
117
|
+
const [pendingForm, setPendingForm] = useState<BulkAction<any> | null>(null)
|
|
118
|
+
const [running, setRunning] = useState(false)
|
|
119
|
+
|
|
120
|
+
const count = selectedIds.length
|
|
121
|
+
|
|
122
|
+
// The bar unmounts visually when nothing is selected, but state survives
|
|
123
|
+
// across mounts. Drop any pending form sheet so it doesn't resurface when
|
|
124
|
+
// the user re-selects a *different* set of rows.
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (count === 0) setPendingForm(null)
|
|
127
|
+
}, [count])
|
|
128
|
+
|
|
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,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
137
|
+
async function execute(action: BulkAction<any>, formValues?: unknown) {
|
|
138
|
+
setRunning(true)
|
|
139
|
+
try {
|
|
140
|
+
await action.run({ ids: selectedIds, formValues })
|
|
141
|
+
// Invalidate the action's declared cross-resource keys BEFORE `onDone`
|
|
142
|
+
// (which invalidates the host table's own key). Without this, pages
|
|
143
|
+
// like Customer Groups that cache `customers_count` won't refresh
|
|
144
|
+
// when the user navigates back — they'd show stale counts until
|
|
145
|
+
// either `staleTime` elapses or the user reloads.
|
|
146
|
+
for (const key of action.invalidate ?? []) {
|
|
147
|
+
queryClient.invalidateQueries({ queryKey: key })
|
|
148
|
+
}
|
|
149
|
+
toast.success(
|
|
150
|
+
interpolate(
|
|
151
|
+
action.successMessage ?? t('admin.components.bulk_action_bar.default_success'),
|
|
152
|
+
count,
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
onDone()
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const message =
|
|
158
|
+
err instanceof Error ? err.message : t('admin.components.bulk_action_bar.default_error')
|
|
159
|
+
toast.error(action.errorMessage ?? message)
|
|
160
|
+
} finally {
|
|
161
|
+
setRunning(false)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
166
|
+
async function handleClick(action: BulkAction<any>) {
|
|
167
|
+
if (action.form) {
|
|
168
|
+
setPendingForm(action)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
if (action.confirm) {
|
|
172
|
+
const ok = await confirm({
|
|
173
|
+
title: action.confirm.title ? interpolate(action.confirm.title, count) : undefined,
|
|
174
|
+
message: interpolate(action.confirm.message, count),
|
|
175
|
+
confirmLabel: action.confirm.confirmLabel,
|
|
176
|
+
variant: action.confirm.variant,
|
|
177
|
+
})
|
|
178
|
+
if (!ok) return
|
|
179
|
+
}
|
|
180
|
+
await execute(action)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<BulkActionBarLayout
|
|
185
|
+
count={count}
|
|
186
|
+
actions={visibleActions}
|
|
187
|
+
anchorRef={anchorRef}
|
|
188
|
+
onClear={onClear}
|
|
189
|
+
onClickAction={handleClick}
|
|
190
|
+
running={running}
|
|
191
|
+
pendingForm={pendingForm}
|
|
192
|
+
selectedIds={selectedIds}
|
|
193
|
+
onClosePendingForm={() => setPendingForm(null)}
|
|
194
|
+
onSubmitPendingForm={async (values) => {
|
|
195
|
+
const action = pendingForm
|
|
196
|
+
if (!action) return
|
|
197
|
+
setPendingForm(null)
|
|
198
|
+
await execute(action, values)
|
|
199
|
+
}}
|
|
200
|
+
/>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface BulkActionBarLayoutProps {
|
|
205
|
+
count: number
|
|
206
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
207
|
+
actions: BulkAction<any>[]
|
|
208
|
+
anchorRef?: React.RefObject<HTMLElement | null>
|
|
209
|
+
onClear: () => void
|
|
210
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
211
|
+
onClickAction: (action: BulkAction<any>) => void
|
|
212
|
+
running: boolean
|
|
213
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
214
|
+
pendingForm: BulkAction<any> | null
|
|
215
|
+
selectedIds: string[]
|
|
216
|
+
onClosePendingForm: () => void
|
|
217
|
+
onSubmitPendingForm: (values: unknown) => void
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Splits the action list into "fits in the bar" + "overflow menu" based on
|
|
222
|
+
* the bar's actual rendered width.
|
|
223
|
+
*
|
|
224
|
+
* Why a separate component: hoisting the measurement effects + refs out of
|
|
225
|
+
* +BulkActionBar+ keeps the action-running logic readable and confines
|
|
226
|
+
* resize-observer setup to one place.
|
|
227
|
+
*/
|
|
228
|
+
function BulkActionBarLayout({
|
|
229
|
+
count,
|
|
230
|
+
actions,
|
|
231
|
+
anchorRef,
|
|
232
|
+
onClear,
|
|
233
|
+
onClickAction,
|
|
234
|
+
running,
|
|
235
|
+
pendingForm,
|
|
236
|
+
selectedIds,
|
|
237
|
+
onClosePendingForm,
|
|
238
|
+
onSubmitPendingForm,
|
|
239
|
+
}: BulkActionBarLayoutProps) {
|
|
240
|
+
const { t } = useTranslation()
|
|
241
|
+
|
|
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
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
245
|
+
const measureRef = useRef<HTMLDivElement | null>(null)
|
|
246
|
+
// Width of the bar's leading section (clear button + label + divider).
|
|
247
|
+
const leadingRef = useRef<HTMLDivElement | null>(null)
|
|
248
|
+
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
|
+
|
|
316
|
+
// Measure once on mount + whenever the container or action set changes.
|
|
317
|
+
// +useLayoutEffect+ so the user never sees a flash of overflowing actions.
|
|
318
|
+
useLayoutEffect(() => {
|
|
319
|
+
function recompute() {
|
|
320
|
+
const measure = measureRef.current
|
|
321
|
+
const leading = leadingRef.current
|
|
322
|
+
if (!measure || !leading) return
|
|
323
|
+
|
|
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
|
|
335
|
+
// +⋯+ 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
|
+
const moreWidth = 64
|
|
339
|
+
const children = Array.from(measure.children) as HTMLElement[]
|
|
340
|
+
const widths = children.map((el) => el.offsetWidth)
|
|
341
|
+
|
|
342
|
+
let used = 0
|
|
343
|
+
let n = 0
|
|
344
|
+
for (let i = 0; i < widths.length; i++) {
|
|
345
|
+
// If this is the last action and the rest fit without +⋯+, take all.
|
|
346
|
+
const reserve = i === widths.length - 1 ? 0 : moreWidth
|
|
347
|
+
if (used + widths[i] + reserve <= available) {
|
|
348
|
+
used += widths[i]
|
|
349
|
+
n = i + 1
|
|
350
|
+
} else {
|
|
351
|
+
break
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
setVisibleCount(n)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
recompute()
|
|
358
|
+
const ro = new ResizeObserver(recompute)
|
|
359
|
+
if (containerRef.current) ro.observe(containerRef.current)
|
|
360
|
+
if (anchorRef?.current) ro.observe(anchorRef.current)
|
|
361
|
+
window.addEventListener('resize', recompute)
|
|
362
|
+
return () => {
|
|
363
|
+
ro.disconnect()
|
|
364
|
+
window.removeEventListener('resize', recompute)
|
|
365
|
+
}
|
|
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])
|
|
371
|
+
|
|
372
|
+
const visible = actions.slice(0, visibleCount)
|
|
373
|
+
const overflow = actions.slice(visibleCount)
|
|
374
|
+
|
|
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 = (
|
|
382
|
+
<>
|
|
383
|
+
<div
|
|
384
|
+
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"
|
|
392
|
+
>
|
|
393
|
+
<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
|
+
<span className="whitespace-nowrap px-1.5 text-sm">
|
|
405
|
+
{t('admin.components.bulk_action_bar.selected', { count })}
|
|
406
|
+
</span>
|
|
407
|
+
<div className="mx-1 h-5 w-px bg-border" aria-hidden />
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
{visible.map((action) => (
|
|
411
|
+
<BulkActionButton
|
|
412
|
+
key={action.key}
|
|
413
|
+
action={action}
|
|
414
|
+
disabled={running}
|
|
415
|
+
onClick={() => onClickAction(action)}
|
|
416
|
+
/>
|
|
417
|
+
))}
|
|
418
|
+
|
|
419
|
+
{overflow.length > 0 && (
|
|
420
|
+
<DropdownMenu>
|
|
421
|
+
<DropdownMenuTrigger asChild>
|
|
422
|
+
<Button
|
|
423
|
+
type="button"
|
|
424
|
+
variant="ghost"
|
|
425
|
+
size="icon-sm"
|
|
426
|
+
aria-label={t('admin.actions.more_actions')}
|
|
427
|
+
>
|
|
428
|
+
<MoreHorizontalIcon className="size-4" />
|
|
429
|
+
</Button>
|
|
430
|
+
</DropdownMenuTrigger>
|
|
431
|
+
<DropdownMenuContent align="end">
|
|
432
|
+
{overflow.map((action) => (
|
|
433
|
+
<DropdownMenuItem
|
|
434
|
+
key={action.key}
|
|
435
|
+
onClick={() => onClickAction(action)}
|
|
436
|
+
disabled={running}
|
|
437
|
+
className="py-1.5"
|
|
438
|
+
>
|
|
439
|
+
{action.icon}
|
|
440
|
+
{action.label}
|
|
441
|
+
</DropdownMenuItem>
|
|
442
|
+
))}
|
|
443
|
+
</DropdownMenuContent>
|
|
444
|
+
</DropdownMenu>
|
|
445
|
+
)}
|
|
446
|
+
|
|
447
|
+
{/* Off-screen measuring layer: rendered with the same button styling
|
|
448
|
+
as the visible row so +offsetWidth+ reflects the real width.
|
|
449
|
+
+aria-hidden+ + +inert+-equivalent (no pointer events, no focus). */}
|
|
450
|
+
<div
|
|
451
|
+
ref={measureRef}
|
|
452
|
+
aria-hidden
|
|
453
|
+
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-0.5"
|
|
454
|
+
>
|
|
455
|
+
{actions.map((action) => (
|
|
456
|
+
<BulkActionButton
|
|
457
|
+
key={action.key}
|
|
458
|
+
action={action}
|
|
459
|
+
disabled
|
|
460
|
+
onClick={() => undefined}
|
|
461
|
+
tabIndex={-1}
|
|
462
|
+
/>
|
|
463
|
+
))}
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
{pendingForm?.form?.({
|
|
467
|
+
ids: selectedIds,
|
|
468
|
+
onCancel: onClosePendingForm,
|
|
469
|
+
onSubmit: onSubmitPendingForm,
|
|
470
|
+
})}
|
|
471
|
+
</>
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
if (typeof document === 'undefined') return bar
|
|
475
|
+
return createPortal(bar, document.body)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function BulkActionButton({
|
|
479
|
+
action,
|
|
480
|
+
disabled,
|
|
481
|
+
onClick,
|
|
482
|
+
tabIndex,
|
|
483
|
+
}: {
|
|
484
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
485
|
+
action: BulkAction<any>
|
|
486
|
+
disabled: boolean
|
|
487
|
+
onClick: () => void
|
|
488
|
+
tabIndex?: number
|
|
489
|
+
}) {
|
|
490
|
+
return (
|
|
491
|
+
<Button
|
|
492
|
+
type="button"
|
|
493
|
+
variant="ghost"
|
|
494
|
+
size="sm"
|
|
495
|
+
onClick={onClick}
|
|
496
|
+
disabled={disabled}
|
|
497
|
+
tabIndex={tabIndex}
|
|
498
|
+
className="gap-1.5 whitespace-nowrap px-2"
|
|
499
|
+
>
|
|
500
|
+
{action.icon}
|
|
501
|
+
{action.label}
|
|
502
|
+
</Button>
|
|
503
|
+
)
|
|
504
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { ActionName, SubjectName } from '../lib/permissions'
|
|
3
|
+
import { usePermissions } from '../providers/permission-provider'
|
|
4
|
+
|
|
5
|
+
interface CanProps {
|
|
6
|
+
/** Action name (e.g. "update", "destroy", "manage") */
|
|
7
|
+
I: ActionName
|
|
8
|
+
/** Subject class name (e.g. "Spree::Product") */
|
|
9
|
+
a: SubjectName
|
|
10
|
+
children: ReactNode
|
|
11
|
+
fallback?: ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Declarative permission check. Renders `children` if the current user
|
|
16
|
+
* can perform `I` on `a`, otherwise renders `fallback` (or nothing).
|
|
17
|
+
*
|
|
18
|
+
* Example:
|
|
19
|
+
* <Can I="destroy" a="Spree::Product">
|
|
20
|
+
* <Button onClick={handleDelete}>Delete</Button>
|
|
21
|
+
* </Can>
|
|
22
|
+
*
|
|
23
|
+
* Note: This is purely for UX. The backend still enforces CanCanCan
|
|
24
|
+
* `authorize!` on every request — never rely on the frontend check
|
|
25
|
+
* for security.
|
|
26
|
+
*/
|
|
27
|
+
export function Can({ I, a, children, fallback = null }: CanProps) {
|
|
28
|
+
const { permissions } = usePermissions()
|
|
29
|
+
return <>{permissions.can(I, a) ? children : fallback}</>
|
|
30
|
+
}
|