@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,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
|
+
}
|