@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
|
@@ -3,9 +3,11 @@ import {
|
|
|
3
3
|
ResourceCombobox as HeadlessResourceCombobox,
|
|
4
4
|
type ResourceComboboxProps as HeadlessResourceComboboxProps,
|
|
5
5
|
} from '@spree/dashboard-ui'
|
|
6
|
+
import { PlusIcon } from '@spree/dashboard-ui/icons'
|
|
6
7
|
import { useQuery } from '@tanstack/react-query'
|
|
7
8
|
import { useDeferredValue, useMemo, useState } from 'react'
|
|
8
9
|
import { useTranslation } from 'react-i18next'
|
|
10
|
+
import { useTenantId } from '../providers/tenant-provider'
|
|
9
11
|
|
|
10
12
|
export type { ComboboxOption } from '@spree/dashboard-ui'
|
|
11
13
|
|
|
@@ -19,14 +21,16 @@ export interface ResourceComboboxProps<T extends ComboboxOption>
|
|
|
19
21
|
| 'placeholder'
|
|
20
22
|
| 'emptyText'
|
|
21
23
|
| 'disabled'
|
|
24
|
+
| 'id'
|
|
22
25
|
> {
|
|
23
26
|
/**
|
|
24
27
|
* Server-side search. Called as the user types (debounced via React's
|
|
25
28
|
* `useDeferredValue`). Receives the trimmed query string — returning
|
|
26
29
|
* `{ data: [] }` for empty queries is a fine default, as is showing
|
|
27
|
-
* "recent records".
|
|
30
|
+
* "recent records". When the response includes `meta.count`, rows beyond
|
|
31
|
+
* `data.length` trigger a footer hint so a capped list does not read complete.
|
|
28
32
|
*/
|
|
29
|
-
search: (query: string) => Promise<{ data: T[] }>
|
|
33
|
+
search: (query: string) => Promise<{ data: T[]; meta?: { count: number } }>
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
36
|
* Hydrate the currently-selected ID into a record so the trigger can show
|
|
@@ -44,6 +48,14 @@ export interface ResourceComboboxProps<T extends ComboboxOption>
|
|
|
44
48
|
* Returning `false` drops the row from the dropdown.
|
|
45
49
|
*/
|
|
46
50
|
filterOption?: (option: T) => boolean
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A row pinned to the bottom of the dropdown, for creating what the search
|
|
54
|
+
* could not find — "New supplier" on a purchase order, say. It sits with the
|
|
55
|
+
* results rather than beside the field so it is found where the merchant is
|
|
56
|
+
* already looking, and it closes the dropdown when chosen.
|
|
57
|
+
*/
|
|
58
|
+
action?: { label: string; onSelect: () => void }
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
/**
|
|
@@ -68,6 +80,8 @@ export function ResourceCombobox<T extends ComboboxOption>({
|
|
|
68
80
|
emptyText,
|
|
69
81
|
disabled,
|
|
70
82
|
filterOption,
|
|
83
|
+
action,
|
|
84
|
+
id,
|
|
71
85
|
}: ResourceComboboxProps<T>) {
|
|
72
86
|
const { t } = useTranslation()
|
|
73
87
|
const placeholderLabel = placeholder ?? t('admin.common.search_placeholder')
|
|
@@ -78,6 +92,10 @@ export function ResourceCombobox<T extends ComboboxOption>({
|
|
|
78
92
|
// `onInputChange` on the Root rather than controlling
|
|
79
93
|
// `<ComboboxInput value>` directly — controlling the input blocks Base UI
|
|
80
94
|
// from updating it on selection.
|
|
95
|
+
// Scoped here, not at the call site: results are tenant-specific (a store in
|
|
96
|
+
// the dashboard, a seller in the seller panel), and a caller passing a
|
|
97
|
+
// constant key would otherwise serve one tenant's records in another.
|
|
98
|
+
const tenantId = useTenantId()
|
|
81
99
|
const [input, setInput] = useState('')
|
|
82
100
|
// Defer the search query so a fast typist doesn't fire one request per
|
|
83
101
|
// keystroke — React batches the search to the next idle paint.
|
|
@@ -85,7 +103,7 @@ export function ResourceCombobox<T extends ComboboxOption>({
|
|
|
85
103
|
const trimmedQuery = deferredInput.trim()
|
|
86
104
|
|
|
87
105
|
const { data: searchData } = useQuery({
|
|
88
|
-
queryKey: [queryKey, 'search', trimmedQuery],
|
|
106
|
+
queryKey: [queryKey, tenantId, 'search', trimmedQuery],
|
|
89
107
|
queryFn: () => search(trimmedQuery),
|
|
90
108
|
staleTime: 30_000,
|
|
91
109
|
})
|
|
@@ -95,7 +113,7 @@ export function ResourceCombobox<T extends ComboboxOption>({
|
|
|
95
113
|
// Skipped when the search results already include the ID.
|
|
96
114
|
const searchHasValue = !!(value && searchData?.data.some((r) => r.id === value))
|
|
97
115
|
const { data: hydratedData } = useQuery({
|
|
98
|
-
queryKey: [queryKey, 'hydrate', value],
|
|
116
|
+
queryKey: [queryKey, tenantId, 'hydrate', value],
|
|
99
117
|
queryFn: () => hydrate([value as string]),
|
|
100
118
|
enabled: !!value && !searchHasValue,
|
|
101
119
|
staleTime: 60_000,
|
|
@@ -110,6 +128,9 @@ export function ResourceCombobox<T extends ComboboxOption>({
|
|
|
110
128
|
return filterOption ? merged.filter(filterOption) : merged
|
|
111
129
|
}, [searchData, hydratedData, filterOption])
|
|
112
130
|
|
|
131
|
+
const visibleSearchCount = searchData?.data.length ?? 0
|
|
132
|
+
const hiddenCount = Math.max(0, (searchData?.meta?.count ?? 0) - visibleSearchCount)
|
|
133
|
+
|
|
113
134
|
return (
|
|
114
135
|
<HeadlessResourceCombobox
|
|
115
136
|
value={value}
|
|
@@ -120,7 +141,34 @@ export function ResourceCombobox<T extends ComboboxOption>({
|
|
|
120
141
|
renderOption={renderOption}
|
|
121
142
|
placeholder={placeholderLabel}
|
|
122
143
|
emptyText={emptyLabel}
|
|
144
|
+
listFooter={
|
|
145
|
+
hiddenCount > 0 || action ? (
|
|
146
|
+
<>
|
|
147
|
+
{hiddenCount > 0 && (
|
|
148
|
+
<p className="border-t border-border px-2.5 py-2 text-muted-foreground text-xs">
|
|
149
|
+
{t('admin.common.combobox_more_results', { count: hiddenCount })}
|
|
150
|
+
</p>
|
|
151
|
+
)}
|
|
152
|
+
{action && (
|
|
153
|
+
// `onMouseDown` rather than `onClick`: the input blurs first and
|
|
154
|
+
// the dropdown unmounts, so a click never lands.
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
className="flex w-full cursor-pointer items-center gap-2 border-t border-border px-2.5 py-2 text-left text-sm hover:bg-accent"
|
|
158
|
+
onMouseDown={(event) => {
|
|
159
|
+
event.preventDefault()
|
|
160
|
+
action.onSelect()
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
<PlusIcon className="size-4" />
|
|
164
|
+
{action.label}
|
|
165
|
+
</button>
|
|
166
|
+
)}
|
|
167
|
+
</>
|
|
168
|
+
) : undefined
|
|
169
|
+
}
|
|
123
170
|
disabled={disabled}
|
|
171
|
+
id={id}
|
|
124
172
|
/>
|
|
125
173
|
)
|
|
126
174
|
}
|
|
@@ -1,28 +1,33 @@
|
|
|
1
1
|
import {
|
|
2
|
-
Avatar,
|
|
3
|
-
AvatarFallback,
|
|
4
|
-
AvatarImage,
|
|
5
2
|
Button,
|
|
6
3
|
Checkbox,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
InputGroupInput,
|
|
4
|
+
type PaginationMeta,
|
|
5
|
+
SearchInput,
|
|
10
6
|
Sheet,
|
|
11
7
|
SheetContent,
|
|
12
8
|
SheetDescription,
|
|
13
9
|
SheetFooter,
|
|
14
10
|
SheetHeader,
|
|
15
11
|
SheetTitle,
|
|
12
|
+
Thumbnail,
|
|
16
13
|
} from '@spree/dashboard-ui'
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import { useDeferredValue, useMemo, useState } from 'react'
|
|
14
|
+
import { Loader2Icon } from '@spree/dashboard-ui/icons'
|
|
15
|
+
import { useInfiniteQuery } from '@tanstack/react-query'
|
|
16
|
+
import { useDeferredValue, useMemo, useRef, useState } from 'react'
|
|
20
17
|
import { useTranslation } from 'react-i18next'
|
|
21
18
|
|
|
22
19
|
export interface PickerOption {
|
|
23
20
|
id: string
|
|
24
21
|
}
|
|
25
22
|
|
|
23
|
+
/** One page of async picker search results, optionally with list pagination metadata. */
|
|
24
|
+
export interface ResourcePickerSearchResult<T extends PickerOption> {
|
|
25
|
+
/** Rows returned for the requested page. */
|
|
26
|
+
data: T[]
|
|
27
|
+
/** Total match count and page cursors when the search is paginated. */
|
|
28
|
+
meta?: PaginationMeta
|
|
29
|
+
}
|
|
30
|
+
|
|
26
31
|
export interface ResourcePickerSheetProps<T extends PickerOption> {
|
|
27
32
|
open: boolean
|
|
28
33
|
onOpenChange: (open: boolean) => void
|
|
@@ -30,8 +35,8 @@ export interface ResourcePickerSheetProps<T extends PickerOption> {
|
|
|
30
35
|
selectedIds: string[]
|
|
31
36
|
/** Called with the freshly-picked IDs (excludes `selectedIds`) when the user confirms. */
|
|
32
37
|
onConfirm: (ids: string[], options: T[]) => void | Promise<void>
|
|
33
|
-
/** Paginated async search. Called with the trimmed query (empty = initial list). */
|
|
34
|
-
search: (query: string) => Promise<
|
|
38
|
+
/** Paginated async search. Called with the trimmed query (empty = initial list) and page. */
|
|
39
|
+
search: (query: string, page: number) => Promise<ResourcePickerSearchResult<T>>
|
|
35
40
|
getOptionLabel: (option: T) => string
|
|
36
41
|
/** Optional thumbnail URL shown as an avatar. */
|
|
37
42
|
getOptionImageUrl?: (option: T) => string | null | undefined
|
|
@@ -79,20 +84,61 @@ export function ResourcePickerSheet<T extends PickerOption>({
|
|
|
79
84
|
const deferredInput = useDeferredValue(input)
|
|
80
85
|
const trimmedQuery = deferredInput.trim()
|
|
81
86
|
|
|
87
|
+
const [selectAllMeta, setSelectAllMeta] = useState<PaginationMeta | undefined>()
|
|
88
|
+
const selectAllGenerationRef = useRef(0)
|
|
89
|
+
const selectAllSessionRef = useRef({ query: trimmedQuery, open })
|
|
90
|
+
|
|
91
|
+
if (
|
|
92
|
+
selectAllSessionRef.current.query !== trimmedQuery ||
|
|
93
|
+
(selectAllSessionRef.current.open && !open)
|
|
94
|
+
) {
|
|
95
|
+
selectAllGenerationRef.current += 1
|
|
96
|
+
selectAllSessionRef.current = { query: trimmedQuery, open }
|
|
97
|
+
}
|
|
98
|
+
|
|
82
99
|
// Staging selection (ids picked in this session) + a label cache so the
|
|
83
100
|
// "selected" chips keep their text even after the search results change.
|
|
84
101
|
const [staged, setStaged] = useState<Map<string, T>>(new Map())
|
|
85
102
|
const alreadyIn = useMemo(() => new Set(selectedIds), [selectedIds])
|
|
86
103
|
const [submitting, setSubmitting] = useState(false)
|
|
104
|
+
const [selectingAll, setSelectingAll] = useState(false)
|
|
87
105
|
|
|
88
|
-
|
|
106
|
+
// The accumulated pages live in the query cache rather than in local state.
|
|
107
|
+
// Mirroring them into state via an effect broke reopening the sheet: the
|
|
108
|
+
// cached page came back reference-identical, so the effect that filled the
|
|
109
|
+
// local list never re-ran and the sheet rendered "no results" over a cache
|
|
110
|
+
// that held plenty.
|
|
111
|
+
const { data, isFetching, isFetchingNextPage, fetchNextPage } = useInfiniteQuery({
|
|
89
112
|
queryKey: [queryKey, 'picker-search', trimmedQuery],
|
|
90
|
-
queryFn: () => search(trimmedQuery),
|
|
113
|
+
queryFn: ({ pageParam }) => search(trimmedQuery, pageParam),
|
|
114
|
+
initialPageParam: 1,
|
|
115
|
+
getNextPageParam: (lastPage, allPages) =>
|
|
116
|
+
lastPage.meta?.next != null ? allPages.length + 1 : undefined,
|
|
91
117
|
enabled: open,
|
|
92
118
|
staleTime: 30_000,
|
|
93
119
|
})
|
|
94
120
|
|
|
95
|
-
const results =
|
|
121
|
+
const results = useMemo(() => {
|
|
122
|
+
const seen = new Set<string>()
|
|
123
|
+
const flattened: T[] = []
|
|
124
|
+
for (const loadedPage of data?.pages ?? []) {
|
|
125
|
+
for (const option of loadedPage.data) {
|
|
126
|
+
if (seen.has(option.id)) continue
|
|
127
|
+
seen.add(option.id)
|
|
128
|
+
flattened.push(option)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return flattened
|
|
132
|
+
}, [data])
|
|
133
|
+
|
|
134
|
+
const lastPage = data?.pages.at(-1)
|
|
135
|
+
const meta = selectAllMeta ?? lastPage?.meta
|
|
136
|
+
const hasMore = lastPage?.meta?.next != null
|
|
137
|
+
const matchCount = meta?.count ?? results.length
|
|
138
|
+
const currentPageResults = lastPage?.data ?? []
|
|
139
|
+
const selectableOnPage = currentPageResults.filter((option) => !alreadyIn.has(option.id))
|
|
140
|
+
const allOnPageSelected =
|
|
141
|
+
selectableOnPage.length > 0 && selectableOnPage.every((option) => staged.has(option.id))
|
|
96
142
|
|
|
97
143
|
function toggle(option: T) {
|
|
98
144
|
if (alreadyIn.has(option.id)) return
|
|
@@ -104,6 +150,53 @@ export function ResourcePickerSheet<T extends PickerOption>({
|
|
|
104
150
|
})
|
|
105
151
|
}
|
|
106
152
|
|
|
153
|
+
function togglePageSelection() {
|
|
154
|
+
setStaged((prev) => {
|
|
155
|
+
const next = new Map(prev)
|
|
156
|
+
if (allOnPageSelected) {
|
|
157
|
+
for (const option of selectableOnPage) next.delete(option.id)
|
|
158
|
+
} else {
|
|
159
|
+
for (const option of selectableOnPage) next.set(option.id, option)
|
|
160
|
+
}
|
|
161
|
+
return next
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function selectAllMatching() {
|
|
166
|
+
if (matchCount === 0 || selectingAll) return
|
|
167
|
+
const generation = selectAllGenerationRef.current
|
|
168
|
+
setSelectingAll(true)
|
|
169
|
+
try {
|
|
170
|
+
const collected = new Map<string, T>()
|
|
171
|
+
let nextPage = 1
|
|
172
|
+
let lastMeta: PaginationMeta | undefined
|
|
173
|
+
|
|
174
|
+
while (true) {
|
|
175
|
+
const response = await search(trimmedQuery, nextPage)
|
|
176
|
+
if (generation !== selectAllGenerationRef.current) return
|
|
177
|
+
lastMeta = response.meta
|
|
178
|
+
for (const option of response.data) {
|
|
179
|
+
if (!alreadyIn.has(option.id)) collected.set(option.id, option)
|
|
180
|
+
}
|
|
181
|
+
if (!response.meta || nextPage >= response.meta.pages) break
|
|
182
|
+
nextPage += 1
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (generation !== selectAllGenerationRef.current) return
|
|
186
|
+
|
|
187
|
+
setStaged((prev) => {
|
|
188
|
+
const next = new Map(prev)
|
|
189
|
+
for (const [id, option] of collected) next.set(id, option)
|
|
190
|
+
return next
|
|
191
|
+
})
|
|
192
|
+
if (lastMeta) setSelectAllMeta(lastMeta)
|
|
193
|
+
} catch (error) {
|
|
194
|
+
onConfirmError?.(error)
|
|
195
|
+
} finally {
|
|
196
|
+
setSelectingAll(false)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
107
200
|
async function confirm() {
|
|
108
201
|
if (staged.size === 0) return
|
|
109
202
|
setSubmitting(true)
|
|
@@ -111,6 +204,7 @@ export function ResourcePickerSheet<T extends PickerOption>({
|
|
|
111
204
|
await onConfirm(Array.from(staged.keys()), Array.from(staged.values()))
|
|
112
205
|
setStaged(new Map())
|
|
113
206
|
setInput('')
|
|
207
|
+
setSelectAllMeta(undefined)
|
|
114
208
|
onOpenChange(false)
|
|
115
209
|
} catch (error) {
|
|
116
210
|
// The mutation reports its own error toast; swallow so the click handler
|
|
@@ -124,12 +218,17 @@ export function ResourcePickerSheet<T extends PickerOption>({
|
|
|
124
218
|
|
|
125
219
|
function handleOpenChange(next: boolean) {
|
|
126
220
|
if (!next) {
|
|
221
|
+
selectAllGenerationRef.current += 1
|
|
127
222
|
setStaged(new Map())
|
|
128
223
|
setInput('')
|
|
224
|
+
setSelectAllMeta(undefined)
|
|
129
225
|
}
|
|
130
226
|
onOpenChange(next)
|
|
131
227
|
}
|
|
132
228
|
|
|
229
|
+
const initialLoading = isFetching && results.length === 0
|
|
230
|
+
const loadingMore = isFetchingNextPage
|
|
231
|
+
|
|
133
232
|
return (
|
|
134
233
|
<Sheet open={open} onOpenChange={handleOpenChange}>
|
|
135
234
|
<SheetContent className="w-full gap-0 p-0 sm:max-w-lg">
|
|
@@ -138,20 +237,18 @@ export function ResourcePickerSheet<T extends PickerOption>({
|
|
|
138
237
|
{description && <SheetDescription>{description}</SheetDescription>}
|
|
139
238
|
</SheetHeader>
|
|
140
239
|
|
|
141
|
-
<div className="border-b border-border p-4">
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
/>
|
|
154
|
-
</InputGroup>
|
|
240
|
+
<div className="border-b border-border-subtle p-4">
|
|
241
|
+
<SearchInput
|
|
242
|
+
value={input}
|
|
243
|
+
onValueChange={(value) => {
|
|
244
|
+
setInput(value)
|
|
245
|
+
setSelectAllMeta(undefined)
|
|
246
|
+
}}
|
|
247
|
+
// Enter has no action here; swallow it so it can't submit a host form.
|
|
248
|
+
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
|
|
249
|
+
placeholder={searchPlaceholder ?? t('admin.resource_picker.search_placeholder')}
|
|
250
|
+
clearLabel={t('admin.common.clear')}
|
|
251
|
+
/>
|
|
155
252
|
{staged.size > 0 && (
|
|
156
253
|
<p className="mt-2 text-muted-foreground text-xs">
|
|
157
254
|
{t('admin.resource_picker.selected_count', { count: staged.size })}
|
|
@@ -160,7 +257,7 @@ export function ResourcePickerSheet<T extends PickerOption>({
|
|
|
160
257
|
</div>
|
|
161
258
|
|
|
162
259
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
163
|
-
{
|
|
260
|
+
{initialLoading ? (
|
|
164
261
|
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
|
165
262
|
<Loader2Icon className="size-5 animate-spin" />
|
|
166
263
|
</div>
|
|
@@ -169,59 +266,98 @@ export function ResourcePickerSheet<T extends PickerOption>({
|
|
|
169
266
|
{t('admin.resource_picker.empty')}
|
|
170
267
|
</p>
|
|
171
268
|
) : (
|
|
172
|
-
|
|
173
|
-
{
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
269
|
+
<>
|
|
270
|
+
{matchCount > 0 && (
|
|
271
|
+
<div className="flex items-center justify-between gap-3 border-b border-border-subtle px-4 py-2">
|
|
272
|
+
<button
|
|
273
|
+
type="button"
|
|
274
|
+
disabled={selectableOnPage.length === 0 || selectingAll}
|
|
275
|
+
onClick={togglePageSelection}
|
|
276
|
+
className="flex min-w-0 flex-1 items-center gap-3 text-left hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
|
|
277
|
+
>
|
|
278
|
+
<Checkbox
|
|
279
|
+
checked={allOnPageSelected}
|
|
280
|
+
disabled={selectableOnPage.length === 0 || selectingAll}
|
|
281
|
+
className="pointer-events-none"
|
|
282
|
+
/>
|
|
283
|
+
<span className="truncate text-sm">
|
|
284
|
+
{t('admin.resource_picker.select_page', { count: selectableOnPage.length })}
|
|
285
|
+
</span>
|
|
286
|
+
</button>
|
|
287
|
+
{matchCount > 0 &&
|
|
288
|
+
(hasMore || !allOnPageSelected || matchCount > selectableOnPage.length) ? (
|
|
289
|
+
<Button
|
|
181
290
|
type="button"
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
className="
|
|
291
|
+
variant="link"
|
|
292
|
+
size="sm"
|
|
293
|
+
className="h-auto shrink-0 px-0"
|
|
294
|
+
disabled={selectingAll}
|
|
295
|
+
onClick={selectAllMatching}
|
|
185
296
|
>
|
|
186
|
-
<
|
|
187
|
-
|
|
297
|
+
{selectingAll && <Loader2Icon className="size-3.5 animate-spin" />}
|
|
298
|
+
{t('admin.resource_picker.select_all_matching', { count: matchCount })}
|
|
299
|
+
</Button>
|
|
300
|
+
) : null}
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
<ul className="divide-y divide-border">
|
|
304
|
+
{results.map((option) => {
|
|
305
|
+
const inList = alreadyIn.has(option.id)
|
|
306
|
+
const checked = inList || staged.has(option.id)
|
|
307
|
+
const imageUrl = getOptionImageUrl?.(option)
|
|
308
|
+
const subtitle = getOptionSubtitle?.(option)
|
|
309
|
+
return (
|
|
310
|
+
<li key={option.id}>
|
|
311
|
+
<button
|
|
312
|
+
type="button"
|
|
188
313
|
disabled={inList}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
<
|
|
193
|
-
{
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
314
|
+
onClick={() => toggle(option)}
|
|
315
|
+
className="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
|
|
316
|
+
>
|
|
317
|
+
<Checkbox
|
|
318
|
+
checked={checked}
|
|
319
|
+
disabled={inList}
|
|
320
|
+
className="pointer-events-none"
|
|
321
|
+
/>
|
|
322
|
+
{getOptionImageUrl && <Thumbnail src={imageUrl} size="sm" />}
|
|
323
|
+
<span className="min-w-0 flex-1">
|
|
324
|
+
<span className="block truncate text-sm">{getOptionLabel(option)}</span>
|
|
325
|
+
{subtitle && (
|
|
326
|
+
<span className="block truncate text-muted-foreground text-xs">
|
|
327
|
+
{subtitle}
|
|
328
|
+
</span>
|
|
329
|
+
)}
|
|
330
|
+
</span>
|
|
331
|
+
{inList && (
|
|
332
|
+
<span className="shrink-0 text-muted-foreground text-xs">
|
|
333
|
+
{t('admin.resource_picker.already_added')}
|
|
206
334
|
</span>
|
|
207
335
|
)}
|
|
208
|
-
</
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
336
|
+
</button>
|
|
337
|
+
</li>
|
|
338
|
+
)
|
|
339
|
+
})}
|
|
340
|
+
</ul>
|
|
341
|
+
{hasMore && (
|
|
342
|
+
<div className="border-t border-border p-4">
|
|
343
|
+
<Button
|
|
344
|
+
type="button"
|
|
345
|
+
variant="outline"
|
|
346
|
+
className="w-full"
|
|
347
|
+
disabled={loadingMore}
|
|
348
|
+
onClick={() => fetchNextPage()}
|
|
349
|
+
>
|
|
350
|
+
{loadingMore && <Loader2Icon className="size-4 animate-spin" />}
|
|
351
|
+
{t('admin.resource_picker.load_more')}
|
|
352
|
+
</Button>
|
|
353
|
+
</div>
|
|
354
|
+
)}
|
|
355
|
+
</>
|
|
219
356
|
)}
|
|
220
357
|
</div>
|
|
221
358
|
|
|
222
359
|
<SheetFooter className="flex-row items-center justify-between border-t border-border">
|
|
223
|
-
<Button type="button" variant="
|
|
224
|
-
<XIcon className="size-4" />
|
|
360
|
+
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
|
225
361
|
{t('admin.actions.cancel')}
|
|
226
362
|
</Button>
|
|
227
363
|
<Button type="button" onClick={confirm} disabled={staged.size === 0 || submitting}>
|