@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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +418 -0
  3. package/package.json +84 -0
  4. package/src/client.ts +25 -0
  5. package/src/components/address-form-dialog.tsx +376 -0
  6. package/src/components/app-sidebar.tsx +70 -0
  7. package/src/components/bulk-action-bar.tsx +504 -0
  8. package/src/components/can.tsx +30 -0
  9. package/src/components/country-combobox.tsx +238 -0
  10. package/src/components/country-state-fields.tsx +86 -0
  11. package/src/components/currency-select.tsx +181 -0
  12. package/src/components/export-button.tsx +156 -0
  13. package/src/components/file-upload-field.tsx +265 -0
  14. package/src/components/image-upload-field.tsx +53 -0
  15. package/src/components/import-button.tsx +202 -0
  16. package/src/components/locale-select.tsx +268 -0
  17. package/src/components/market-combobox.tsx +89 -0
  18. package/src/components/nav-main.tsx +186 -0
  19. package/src/components/page-header.tsx +286 -0
  20. package/src/components/page-tabs.tsx +78 -0
  21. package/src/components/preferences-form.tsx +404 -0
  22. package/src/components/resource-combobox.tsx +126 -0
  23. package/src/components/resource-multi-autocomplete.tsx +176 -0
  24. package/src/components/resource-picker-sheet.tsx +235 -0
  25. package/src/components/resource-table.tsx +652 -0
  26. package/src/components/settings-sidebar.tsx +139 -0
  27. package/src/components/slot.tsx +50 -0
  28. package/src/components/store-date-picker.tsx +18 -0
  29. package/src/components/store-switcher.tsx +70 -0
  30. package/src/components/table-toolbar.tsx +818 -0
  31. package/src/components/tag-combobox.tsx +99 -0
  32. package/src/components/top-bar.tsx +210 -0
  33. package/src/hooks/use-auth.ts +10 -0
  34. package/src/hooks/use-command-palette.tsx +34 -0
  35. package/src/hooks/use-countries.ts +15 -0
  36. package/src/hooks/use-custom-fields.ts +142 -0
  37. package/src/hooks/use-direct-upload.ts +104 -0
  38. package/src/hooks/use-display-name.ts +42 -0
  39. package/src/hooks/use-export.ts +75 -0
  40. package/src/hooks/use-global-search.ts +79 -0
  41. package/src/hooks/use-host-form.ts +34 -0
  42. package/src/hooks/use-import.ts +62 -0
  43. package/src/hooks/use-resource-mutation.ts +99 -0
  44. package/src/hooks/use-switch-admin-locale.ts +55 -0
  45. package/src/index.ts +97 -0
  46. package/src/lib/custom-field-components.ts +56 -0
  47. package/src/lib/download.ts +49 -0
  48. package/src/lib/filters-to-ransack.ts +47 -0
  49. package/src/lib/form-errors.ts +103 -0
  50. package/src/lib/form-fields-registry.ts +102 -0
  51. package/src/lib/form-mappers.ts +30 -0
  52. package/src/lib/formatters.ts +19 -0
  53. package/src/lib/i18n.ts +210 -0
  54. package/src/lib/nav-registry.ts +224 -0
  55. package/src/lib/permissions.ts +55 -0
  56. package/src/lib/query-client.ts +11 -0
  57. package/src/lib/query-keys.ts +69 -0
  58. package/src/lib/route-registry.ts +158 -0
  59. package/src/lib/search-registry.ts +129 -0
  60. package/src/lib/settings-nav-registry.ts +158 -0
  61. package/src/lib/slot-registry.ts +116 -0
  62. package/src/lib/table-registry.ts +274 -0
  63. package/src/lib/timestamp-columns.tsx +58 -0
  64. package/src/locales/ar.json +629 -0
  65. package/src/locales/de.json +629 -0
  66. package/src/locales/en.json +629 -0
  67. package/src/locales/fr.json +629 -0
  68. package/src/locales/pl.json +629 -0
  69. package/src/locales/zh-CN.json +629 -0
  70. package/src/plugin.ts +222 -0
  71. package/src/providers/auth-provider.tsx +188 -0
  72. package/src/providers/permission-provider.tsx +118 -0
  73. package/src/providers/store-provider.tsx +111 -0
  74. package/src/virtual.d.ts +5 -0
  75. package/src/vite/discover.ts +170 -0
  76. package/src/vite/index.ts +270 -0
@@ -0,0 +1,404 @@
1
+ import type { PreferenceField as PreferenceFieldDef } from '@spree/admin-sdk'
2
+ import {
3
+ Button,
4
+ Field,
5
+ FieldGroup,
6
+ FieldLabel,
7
+ Input,
8
+ SecretInput,
9
+ Switch,
10
+ Textarea,
11
+ } from '@spree/dashboard-ui'
12
+ import { PlusIcon, TrashIcon } from 'lucide-react'
13
+ import { useId, useMemo, useState } from 'react'
14
+ import { useTranslation } from 'react-i18next'
15
+ import { CurrencySelect } from './currency-select'
16
+
17
+ /**
18
+ * Hydrates a `preferences` hash with each field's default. Used to seed
19
+ * create-mode forms when the user picks a provider/calculator so the
20
+ * `<PreferencesForm>` shows sensible starting values instead of blanks.
21
+ *
22
+ * `:password` fields are always skipped — the server returns their
23
+ * defaults as `null`, and even when it doesn't, autofilling a password
24
+ * field is hostile UX. `contextDefaults` fills schema-declared keys whose
25
+ * own default is null/undefined — used by callers that know about runtime
26
+ * context the schema can't express (e.g. the store's default currency).
27
+ */
28
+ export function defaultPreferences(
29
+ schema: PreferenceFieldDef[],
30
+ contextDefaults: Record<string, unknown> = {},
31
+ ): Record<string, unknown> {
32
+ const out: Record<string, unknown> = {}
33
+ for (const field of schema) {
34
+ if (field.type === 'password') continue
35
+ if (field.default !== null && field.default !== undefined) {
36
+ out[field.key] = field.default
37
+ continue
38
+ }
39
+ const ctx = contextDefaults[field.key]
40
+ if (ctx !== undefined && ctx !== null && ctx !== '') out[field.key] = ctx
41
+ }
42
+ return out
43
+ }
44
+
45
+ interface PreferencesFormProps {
46
+ schema: PreferenceFieldDef[]
47
+ values: Record<string, unknown>
48
+ onChange: (next: Record<string, unknown>) => void
49
+ /** When true, replaces sensitive `password`-typed fields with a stub label. */
50
+ redactPasswords?: boolean
51
+ /** Optional human-readable name overrides keyed by preference key. */
52
+ labelOverrides?: Record<string, string>
53
+ }
54
+
55
+ /**
56
+ * Renders a generic configuration form from a `preference_schema` payload.
57
+ * Used by the Payment Methods edit sheet and the Promotion editor's action
58
+ * and rule cards — anywhere we let admins tune a STI subclass's settings
59
+ * without hard-coding a per-subclass form.
60
+ *
61
+ * The schema itself is the source of truth; this component intentionally
62
+ * stays "dumb" — server-side validation surfaces errors back via the
63
+ * mutation hook's error handling. Booleans are switches, strings are
64
+ * inputs, integers/decimals get number inputs, and unknown types fall
65
+ * back to a plain text input.
66
+ */
67
+ export function PreferencesForm({
68
+ schema,
69
+ values,
70
+ onChange,
71
+ redactPasswords = false,
72
+ labelOverrides,
73
+ }: PreferencesFormProps) {
74
+ if (!schema?.length) return null
75
+
76
+ function setValue(key: string, value: unknown) {
77
+ if (Object.is(values[key], value)) return
78
+ onChange({ ...values, [key]: value })
79
+ }
80
+
81
+ return (
82
+ <FieldGroup>
83
+ {schema.map((field) => (
84
+ <PreferenceField
85
+ key={field.key}
86
+ field={field}
87
+ value={values[field.key] ?? field.default}
88
+ label={labelOverrides?.[field.key]}
89
+ onChange={(v) => setValue(field.key, v)}
90
+ redactPasswords={redactPasswords}
91
+ />
92
+ ))}
93
+ </FieldGroup>
94
+ )
95
+ }
96
+
97
+ interface PreferenceFieldProps {
98
+ field: PreferenceFieldDef
99
+ value: unknown
100
+ label?: string
101
+ onChange: (value: unknown) => void
102
+ redactPasswords?: boolean
103
+ }
104
+
105
+ export function PreferenceField({
106
+ field,
107
+ value,
108
+ label,
109
+ onChange,
110
+ redactPasswords,
111
+ }: PreferenceFieldProps) {
112
+ const { t, i18n } = useTranslation()
113
+ const id = `preference-${field.key}`
114
+ // Localize the field label from the preference key when no explicit
115
+ // override is given, falling back to a humanized key for custom/extension
116
+ // preferences that ship no translation.
117
+ const preferenceKey = `admin.preferences.${field.key}`
118
+ const displayLabel =
119
+ label ?? (i18n.exists(preferenceKey) ? t(preferenceKey) : humanizeKey(field.key))
120
+
121
+ // Currency-typed preferences (`currency`, `default_currency`,
122
+ // `display_currency`, …) get the store's CurrencySelect — same
123
+ // localized `CODE — Full Name` rendering as the rest of admin.
124
+ if (isCurrencyKey(field.key)) {
125
+ return (
126
+ <Field>
127
+ <FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
128
+ <CurrencySelect id={id} value={(value as string) ?? ''} onChange={onChange} />
129
+ </Field>
130
+ )
131
+ }
132
+
133
+ // `tiers` preference (Spree::Calculator::TieredPercent /
134
+ // TieredFlatRate) — a Hash<threshold, value> that the default
135
+ // hash-as-text input would render as `[object Object]`. Render a row
136
+ // editor so the merchant can add tier breakpoints directly.
137
+ if (field.key === 'tiers') {
138
+ return <TiersEditor value={value} onChange={onChange} />
139
+ }
140
+
141
+ switch (field.type) {
142
+ case 'boolean':
143
+ return (
144
+ <Field>
145
+ <div className="flex items-start justify-between gap-4">
146
+ <FieldLabel htmlFor={id} className="cursor-pointer">
147
+ {displayLabel}
148
+ </FieldLabel>
149
+ <Switch id={id} checked={!!value} onCheckedChange={(checked) => onChange(checked)} />
150
+ </div>
151
+ </Field>
152
+ )
153
+
154
+ case 'text':
155
+ return (
156
+ <Field>
157
+ <FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
158
+ <Textarea
159
+ id={id}
160
+ rows={4}
161
+ value={(value as string) ?? ''}
162
+ onChange={(e) => onChange(e.target.value)}
163
+ />
164
+ </Field>
165
+ )
166
+
167
+ case 'integer':
168
+ case 'decimal':
169
+ return (
170
+ <Field>
171
+ <FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
172
+ <Input
173
+ id={id}
174
+ type="number"
175
+ step={field.type === 'integer' ? 1 : 'any'}
176
+ value={value === null || value === undefined ? '' : String(value)}
177
+ onChange={(e) => {
178
+ const raw = e.target.value
179
+ if (raw === '') return onChange(null)
180
+ const parsed = field.type === 'integer' ? parseInt(raw, 10) : parseFloat(raw)
181
+ onChange(Number.isNaN(parsed) ? null : parsed)
182
+ }}
183
+ />
184
+ </Field>
185
+ )
186
+
187
+ case 'array':
188
+ // A generic "comma-separated list" works for the common case where
189
+ // arrays hold IDs or short tokens. Subclasses with structured
190
+ // arrays (option values, eligible products) ship with custom
191
+ // editors and don't reach here.
192
+ return (
193
+ <Field>
194
+ <FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
195
+ <Input
196
+ id={id}
197
+ value={Array.isArray(value) ? value.join(', ') : ((value as string) ?? '')}
198
+ placeholder={t('admin.components.preferences_form.comma_separated_hint')}
199
+ onChange={(e) =>
200
+ onChange(
201
+ e.target.value
202
+ .split(',')
203
+ .map((s) => s.trim())
204
+ .filter(Boolean),
205
+ )
206
+ }
207
+ />
208
+ <span className="text-xs text-muted-foreground">
209
+ {t('admin.components.preferences_form.comma_separated_hint')}
210
+ </span>
211
+ </Field>
212
+ )
213
+
214
+ case 'password':
215
+ return (
216
+ <SecretInput
217
+ id={id}
218
+ label={displayLabel}
219
+ value={value}
220
+ onChange={onChange}
221
+ redactWhenMasked={!!redactPasswords}
222
+ />
223
+ )
224
+
225
+ default:
226
+ return (
227
+ <Field>
228
+ <FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
229
+ <Input
230
+ id={id}
231
+ value={(value as string) ?? ''}
232
+ onChange={(e) => onChange(e.target.value)}
233
+ />
234
+ </Field>
235
+ )
236
+ }
237
+ }
238
+
239
+ function humanizeKey(key: string): string {
240
+ return key
241
+ .split('_')
242
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
243
+ .join(' ')
244
+ }
245
+
246
+ function isCurrencyKey(key: string): boolean {
247
+ return key === 'currency' || key.endsWith('_currency')
248
+ }
249
+
250
+ interface TierRowState {
251
+ /** Stable across edits — used for React keys so input focus survives reorder. */
252
+ uid: string
253
+ threshold: string
254
+ value: string
255
+ }
256
+
257
+ /**
258
+ * Editor for `Spree::Calculator::TieredPercent` / `TieredFlatRate`
259
+ * preferences. The underlying shape is `Hash<threshold, value>` — orders
260
+ * at or above `threshold` get `value` (% or $ depending on the
261
+ * calculator).
262
+ *
263
+ * Owns its row list locally so the user can add an empty row and fill
264
+ * it in afterwards — projecting to the hash on every change would drop
265
+ * empty-threshold rows the moment they're added. Empty rows are
266
+ * filtered when projecting to the parent's hash value.
267
+ */
268
+ function TiersEditor({
269
+ value,
270
+ onChange,
271
+ }: {
272
+ value: unknown
273
+ onChange: (next: Record<string, number>) => void
274
+ }) {
275
+ const { t } = useTranslation()
276
+ const idPrefix = useId()
277
+ // Seed once from the initial value. Subsequent rerenders driven by
278
+ // parent state (e.g. another preference field changes) keep our row
279
+ // list intact so the user doesn't lose their in-progress empty rows.
280
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentional one-time seed
281
+ const initialRows = useMemo(() => parseTiers(value, idPrefix), [])
282
+ const [rows, setRows] = useState<TierRowState[]>(initialRows)
283
+
284
+ function commit(next: TierRowState[]) {
285
+ setRows(next)
286
+ const out: Record<string, number> = {}
287
+ for (const row of next) {
288
+ const t = row.threshold.trim()
289
+ if (!t) continue
290
+ const parsed = Number(row.value)
291
+ out[t] = Number.isFinite(parsed) ? parsed : 0
292
+ }
293
+ onChange(out)
294
+ }
295
+
296
+ function updateRow(uid: string, patch: Partial<Pick<TierRowState, 'threshold' | 'value'>>) {
297
+ commit(rows.map((row) => (row.uid === uid ? { ...row, ...patch } : row)))
298
+ }
299
+
300
+ function addRow() {
301
+ commit([
302
+ ...rows,
303
+ { uid: `${idPrefix}-${rows.length + 1}-${Date.now()}`, threshold: '', value: '' },
304
+ ])
305
+ }
306
+
307
+ function removeRow(uid: string) {
308
+ commit(rows.filter((row) => row.uid !== uid))
309
+ }
310
+
311
+ return (
312
+ <Field>
313
+ <FieldLabel>{t('admin.components.preferences_form.tiers.label')}</FieldLabel>
314
+ <div className="space-y-2">
315
+ {rows.length === 0 ? (
316
+ <p className="text-xs text-muted-foreground">
317
+ {t('admin.components.preferences_form.tiers.empty')}
318
+ </p>
319
+ ) : (
320
+ <div className="grid grid-cols-[1fr_1fr_auto] gap-2">
321
+ <span className="text-xs font-medium text-muted-foreground">
322
+ {t('admin.components.preferences_form.tiers.header_threshold')}
323
+ </span>
324
+ <span className="text-xs font-medium text-muted-foreground">
325
+ {t('admin.components.preferences_form.tiers.header_value')}
326
+ </span>
327
+ <span />
328
+ {rows.map((row) => (
329
+ <TierRow
330
+ key={row.uid}
331
+ row={row}
332
+ onChange={(patch) => updateRow(row.uid, patch)}
333
+ onRemove={() => removeRow(row.uid)}
334
+ />
335
+ ))}
336
+ </div>
337
+ )}
338
+ <Button type="button" variant="outline" size="sm" onClick={addRow}>
339
+ <PlusIcon className="size-4" />
340
+ {t('admin.components.preferences_form.tiers.add_tier')}
341
+ </Button>
342
+ </div>
343
+ </Field>
344
+ )
345
+ }
346
+
347
+ function TierRow({
348
+ row,
349
+ onChange,
350
+ onRemove,
351
+ }: {
352
+ row: TierRowState
353
+ onChange: (patch: Partial<Pick<TierRowState, 'threshold' | 'value'>>) => void
354
+ onRemove: () => void
355
+ }) {
356
+ const { t } = useTranslation()
357
+ return (
358
+ <>
359
+ <Input
360
+ type="number"
361
+ step="any"
362
+ min={0}
363
+ value={row.threshold}
364
+ placeholder="100"
365
+ onChange={(e) => onChange({ threshold: e.target.value })}
366
+ />
367
+ <Input
368
+ type="number"
369
+ step="any"
370
+ min={0}
371
+ value={row.value}
372
+ placeholder="10"
373
+ onChange={(e) => onChange({ value: e.target.value })}
374
+ />
375
+ <Button
376
+ type="button"
377
+ size="icon-sm"
378
+ variant="ghost"
379
+ onClick={onRemove}
380
+ aria-label={t('admin.components.preferences_form.tiers.remove_tier')}
381
+ className="text-destructive hover:bg-destructive/10 hover:text-destructive"
382
+ >
383
+ <TrashIcon className="size-4" />
384
+ </Button>
385
+ </>
386
+ )
387
+ }
388
+
389
+ /**
390
+ * Normalize whatever shape the server sent — typically `Hash<number,
391
+ * number>` but JSON serialization stringifies the keys, so we may
392
+ * receive `{ "100": 10 }` or `{ 100: 10 }`. Either form converts to
393
+ * `TierRowState[]` for editor state.
394
+ */
395
+ function parseTiers(value: unknown, idPrefix: string): TierRowState[] {
396
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return []
397
+ return Object.entries(value as Record<string, unknown>)
398
+ .map(([threshold, v], i) => ({
399
+ uid: `${idPrefix}-seed-${i}`,
400
+ threshold: String(threshold),
401
+ value: v === null || v === undefined ? '' : String(v),
402
+ }))
403
+ .sort((a, b) => Number(a.threshold) - Number(b.threshold))
404
+ }
@@ -0,0 +1,126 @@
1
+ import {
2
+ type ComboboxOption,
3
+ ResourceCombobox as HeadlessResourceCombobox,
4
+ type ResourceComboboxProps as HeadlessResourceComboboxProps,
5
+ } from '@spree/dashboard-ui'
6
+ import { useQuery } from '@tanstack/react-query'
7
+ import { useDeferredValue, useMemo, useState } from 'react'
8
+ import { useTranslation } from 'react-i18next'
9
+
10
+ export type { ComboboxOption } from '@spree/dashboard-ui'
11
+
12
+ export interface ResourceComboboxProps<T extends ComboboxOption>
13
+ extends Pick<
14
+ HeadlessResourceComboboxProps<T>,
15
+ | 'value'
16
+ | 'onChange'
17
+ | 'getOptionLabel'
18
+ | 'renderOption'
19
+ | 'placeholder'
20
+ | 'emptyText'
21
+ | 'disabled'
22
+ > {
23
+ /**
24
+ * Server-side search. Called as the user types (debounced via React's
25
+ * `useDeferredValue`). Receives the trimmed query string — returning
26
+ * `{ data: [] }` for empty queries is a fine default, as is showing
27
+ * "recent records".
28
+ */
29
+ search: (query: string) => Promise<{ data: T[] }>
30
+
31
+ /**
32
+ * Hydrate the currently-selected ID into a record so the trigger can show
33
+ * its label (e.g. on first render after deep-link reload). Called once
34
+ * per `value` change. Should return the record listed by ID.
35
+ */
36
+ hydrate: (ids: string[]) => Promise<{ data: T[] }>
37
+
38
+ /** Stable cache prefix — e.g. `'gift-card-customer-picker'`. */
39
+ queryKey: string
40
+
41
+ /**
42
+ * Optional client-side predicate applied after fetch/hydrate to hide rows
43
+ * that should never be selectable (e.g. a category can't be its own parent).
44
+ * Returning `false` drops the row from the dropdown.
45
+ */
46
+ filterOption?: (option: T) => boolean
47
+ }
48
+
49
+ /**
50
+ * Search-driven single-select for a remote resource (customers, variants,
51
+ * stores, …). Wraps `@spree/dashboard-ui`'s pure `<ResourceCombobox>` with
52
+ * TanStack Query: `search` is called as the user types, `hydrate` resolves
53
+ * the currently-selected ID into a record on first render.
54
+ *
55
+ * Most callsites use this convenience wrapper. Reach for the headless version
56
+ * in `@spree/dashboard-ui` when the data flow doesn't match this shape
57
+ * (custom caching layer, local-only filtering, an externally-managed query).
58
+ */
59
+ export function ResourceCombobox<T extends ComboboxOption>({
60
+ value,
61
+ onChange,
62
+ search,
63
+ hydrate,
64
+ queryKey,
65
+ getOptionLabel,
66
+ renderOption,
67
+ placeholder,
68
+ emptyText,
69
+ disabled,
70
+ filterOption,
71
+ }: ResourceComboboxProps<T>) {
72
+ const { t } = useTranslation()
73
+ const placeholderLabel = placeholder ?? t('admin.common.search_placeholder')
74
+ const emptyLabel = emptyText ?? t('admin.common.no_results')
75
+
76
+ // Base UI's Combobox owns the input element so it can write the selected
77
+ // option's label into it on pick. We observe the typed query via
78
+ // `onInputChange` on the Root rather than controlling
79
+ // `<ComboboxInput value>` directly — controlling the input blocks Base UI
80
+ // from updating it on selection.
81
+ const [input, setInput] = useState('')
82
+ // Defer the search query so a fast typist doesn't fire one request per
83
+ // keystroke — React batches the search to the next idle paint.
84
+ const deferredInput = useDeferredValue(input)
85
+ const trimmedQuery = deferredInput.trim()
86
+
87
+ const { data: searchData } = useQuery({
88
+ queryKey: [queryKey, 'search', trimmedQuery],
89
+ queryFn: () => search(trimmedQuery),
90
+ staleTime: 30_000,
91
+ })
92
+
93
+ // Hydrate the currently selected ID into a record so the trigger shows
94
+ // its label after a deep-link reload (before the user types anything).
95
+ // Skipped when the search results already include the ID.
96
+ const searchHasValue = !!(value && searchData?.data.some((r) => r.id === value))
97
+ const { data: hydratedData } = useQuery({
98
+ queryKey: [queryKey, 'hydrate', value],
99
+ queryFn: () => hydrate([value as string]),
100
+ enabled: !!value && !searchHasValue,
101
+ staleTime: 60_000,
102
+ })
103
+
104
+ // Merge: prefer the (possibly fresher) search result if both have the ID.
105
+ const items = useMemo(() => {
106
+ const map = new Map<string, T>()
107
+ for (const r of hydratedData?.data ?? []) map.set(r.id, r)
108
+ for (const r of searchData?.data ?? []) map.set(r.id, r)
109
+ const merged = Array.from(map.values())
110
+ return filterOption ? merged.filter(filterOption) : merged
111
+ }, [searchData, hydratedData, filterOption])
112
+
113
+ return (
114
+ <HeadlessResourceCombobox
115
+ value={value}
116
+ onChange={onChange}
117
+ items={items}
118
+ onInputChange={setInput}
119
+ getOptionLabel={getOptionLabel}
120
+ renderOption={renderOption}
121
+ placeholder={placeholderLabel}
122
+ emptyText={emptyLabel}
123
+ disabled={disabled}
124
+ />
125
+ )
126
+ }
@@ -0,0 +1,176 @@
1
+ import {
2
+ type AutocompleteOption,
3
+ ResourceMultiAutocomplete as HeadlessResourceMultiAutocomplete,
4
+ type ResourceMultiAutocompleteProps as HeadlessResourceMultiAutocompleteProps,
5
+ } from '@spree/dashboard-ui'
6
+ import { useQuery } from '@tanstack/react-query'
7
+ import { useCallback, useEffect, useMemo, useState } from 'react'
8
+
9
+ export type { AutocompleteOption } from '@spree/dashboard-ui'
10
+
11
+ export interface ResourceMultiAutocompleteProps<T extends AutocompleteOption>
12
+ extends Pick<
13
+ HeadlessResourceMultiAutocompleteProps<T>,
14
+ | 'value'
15
+ | 'onChange'
16
+ | 'onResolvedOptionsChange'
17
+ | 'getOptionLabel'
18
+ | 'placeholder'
19
+ | 'emptyText'
20
+ | 'disabled'
21
+ > {
22
+ /**
23
+ * Server-side search. Receives the trimmed query string. Should return
24
+ * `{ data: T[] }`. Called any time the user types (with debouncing
25
+ * provided by the caller's queryClient if needed).
26
+ */
27
+ search: (query: string) => Promise<{ data: T[] }>
28
+
29
+ /**
30
+ * Hydration query for the IDs in `value` that we don't yet have
31
+ * details for. Called once per `idsToHydrate` change. Should return
32
+ * the listed records, in any order — they're keyed by `id`.
33
+ */
34
+ hydrate: (ids: string[]) => Promise<{ data: T[] }>
35
+
36
+ /** Stable cache key for this picker instance — e.g. `'rule-products'`. */
37
+ queryKey: string
38
+
39
+ /**
40
+ * Records to seed the dropdown with — shown before the user types
41
+ * anything. Use when the resource is small enough that the caller
42
+ * already holds the full list in a cached query (channels, markets,
43
+ * categories) and the "type to search" UX is overkill. Merges with
44
+ * `search` results once the user starts typing.
45
+ */
46
+ initialItems?: T[]
47
+ }
48
+
49
+ /**
50
+ * Search-driven multi-select for a remote resource. Wraps
51
+ * `@spree/dashboard-ui`'s headless `<ResourceMultiAutocomplete>` with
52
+ * TanStack Query: `search` fires as the user types; `hydrate` resolves any
53
+ * selected IDs not yet in the cache so chips render with labels even after
54
+ * a deep-link reload.
55
+ *
56
+ * Most callsites use this convenience wrapper. Reach for the headless
57
+ * version in `@spree/dashboard-ui` when the data flow doesn't match this
58
+ * shape (custom caching layer, local-only filtering, externally-managed
59
+ * query state).
60
+ */
61
+ export function ResourceMultiAutocomplete<T extends AutocompleteOption>({
62
+ value,
63
+ onChange,
64
+ onResolvedOptionsChange,
65
+ search,
66
+ hydrate,
67
+ queryKey,
68
+ initialItems,
69
+ getOptionLabel,
70
+ placeholder,
71
+ emptyText,
72
+ disabled,
73
+ }: ResourceMultiAutocompleteProps<T>) {
74
+ const [inputValue, setInputValue] = useState('')
75
+
76
+ // id → option cache. Survives across search queries so chips don't
77
+ // flicker when `value` changes. Stored as a `Map` in state so we never
78
+ // miss a render — TanStack Query's settle re-renders the component,
79
+ // which re-derives `selectedOptions`/`items` from this.
80
+ const [cache, setCache] = useState<Map<string, T>>(() => new Map())
81
+
82
+ const cacheItems = useCallback((items: T[]) => {
83
+ setCache((prev) => {
84
+ let next: Map<string, T> | null = null
85
+ for (const item of items) {
86
+ if (prev.get(item.id) === item) continue
87
+ next ??= new Map(prev)
88
+ next.set(item.id, item)
89
+ }
90
+ return next ?? prev
91
+ })
92
+ }, [])
93
+
94
+ // Hydrate any selected ID not yet in the cache. Recomputed when `value`
95
+ // or `cache` changes; the query key only contains the missing ids so
96
+ // re-toggling a known id doesn't refetch.
97
+ const idsToHydrate = useMemo(() => value.filter((id) => !cache.has(id)), [value, cache])
98
+
99
+ const { data: hydrateData } = useQuery({
100
+ queryKey: [queryKey, 'hydrate', idsToHydrate],
101
+ queryFn: () => hydrate(idsToHydrate),
102
+ enabled: idsToHydrate.length > 0,
103
+ staleTime: Number.POSITIVE_INFINITY,
104
+ })
105
+
106
+ const trimmedInput = inputValue.trim()
107
+
108
+ const { data: searchData, isFetching } = useQuery({
109
+ queryKey: [queryKey, 'search', trimmedInput],
110
+ queryFn: () => search(trimmedInput),
111
+ enabled: trimmedInput.length > 0,
112
+ })
113
+
114
+ // Mirror query results into the local id→option cache via an effect, not
115
+ // inside `queryFn`. When the picker remounts (filter panel reopens) with
116
+ // `staleTime: Infinity`, TanStack Query returns the cached result without
117
+ // re-running `queryFn`, so writes inside `queryFn` are skipped. Reading
118
+ // `data` here re-fires on every remount.
119
+ useEffect(() => {
120
+ if (hydrateData?.data?.length) cacheItems(hydrateData.data)
121
+ }, [hydrateData, cacheItems])
122
+
123
+ useEffect(() => {
124
+ if (searchData?.data?.length) cacheItems(searchData.data)
125
+ }, [searchData, cacheItems])
126
+
127
+ useEffect(() => {
128
+ if (initialItems?.length) cacheItems(initialItems)
129
+ }, [initialItems, cacheItems])
130
+
131
+ // Dropdown items — search results (or +initialItems+ when nothing has
132
+ // been typed yet), then any selected IDs not in results so the user
133
+ // can deselect chips via the dropdown.
134
+ const items = useMemo<T[]>(() => {
135
+ const seen = new Set<string>()
136
+ const list: T[] = []
137
+ const base = searchData?.data ?? (trimmedInput.length === 0 ? initialItems : null) ?? []
138
+ for (const item of base) {
139
+ if (seen.has(item.id)) continue
140
+ seen.add(item.id)
141
+ list.push(item)
142
+ }
143
+ for (const id of value) {
144
+ if (seen.has(id)) continue
145
+ const cached = cache.get(id)
146
+ if (cached) {
147
+ seen.add(id)
148
+ list.push(cached)
149
+ }
150
+ }
151
+ return list
152
+ }, [searchData, value, cache, initialItems, trimmedInput])
153
+
154
+ // Stub-fallback so chips show their ID until hydration lands.
155
+ const selectedOptions = useMemo<T[]>(
156
+ () => value.map((id) => cache.get(id) ?? ({ id } as T)),
157
+ [value, cache],
158
+ )
159
+
160
+ return (
161
+ <HeadlessResourceMultiAutocomplete
162
+ value={value}
163
+ onChange={onChange}
164
+ onResolvedOptionsChange={onResolvedOptionsChange}
165
+ items={items}
166
+ selectedOptions={selectedOptions}
167
+ inputValue={inputValue}
168
+ onInputChange={setInputValue}
169
+ getOptionLabel={getOptionLabel}
170
+ placeholder={placeholder}
171
+ emptyText={emptyText}
172
+ isFetching={isFetching}
173
+ disabled={disabled}
174
+ />
175
+ )
176
+ }