@spree/dashboard-core 1.0.0-rc.3 → 1.0.0-rc.4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spree/dashboard-core",
3
3
  "license": "MIT",
4
- "version": "1.0.0-rc.3",
4
+ "version": "1.0.0-rc.4",
5
5
  "description": "Spree Dashboard framework — registries (table, nav, slot, settings-nav), providers (auth, permission, store), admin SDK client, generic infra hooks, and the defineDashboardPlugin extension API. Developer Preview.",
6
6
  "author": "Vendo Connect Inc.",
7
7
  "homepage": "https://spreecommerce.org",
@@ -49,13 +49,10 @@
49
49
  "LICENSE"
50
50
  ],
51
51
  "peerDependencies": {
52
- "@hookform/resolvers": "^5.2.2",
53
- "@spree/admin-sdk": ">=0.7.0 <1.0.0-0 || >=1.0.0-0 <2.0.0-0",
52
+ "@spree/admin-sdk": ">=1.0.0-rc.4 <2.0.0-0",
54
53
  "@spree/dashboard-ui": ">=0.10.0 <1.0.0-0 || >=1.0.0-0 <2.0.0-0",
55
- "@tanstack/react-hotkeys": "^0.10.0",
56
54
  "@tanstack/react-query": "^5.100.9",
57
55
  "@tanstack/react-router": "^1.169.2",
58
- "@tanstack/react-table": "^8.21.3",
59
56
  "i18next": "^26.2.0",
60
57
  "react": "^19.2.6",
61
58
  "react-dom": "^19.2.6",
@@ -66,21 +63,23 @@
66
63
  "@dnd-kit/core": "^6.3.1",
67
64
  "@dnd-kit/sortable": "^10.0.0",
68
65
  "@dnd-kit/utilities": "^3.2.2",
66
+ "@hookform/resolvers": "^5.2.2",
69
67
  "@tailwindcss/vite": "^4.2.4",
70
- "date-fns": "^4.1.0",
68
+ "@tanstack/react-hotkeys": "^0.14.3",
69
+ "date-fns": "^4.4.0",
71
70
  "date-fns-tz": "^3.2.0",
72
- "lucide-react": "^1.47.0",
73
- "zod": "^4.4.3"
71
+ "lucide-react": "^1.58.0",
72
+ "zod": "^4.6.7"
74
73
  },
75
74
  "devDependencies": {
76
75
  "@types/react": "^19.2.14",
77
76
  "@types/react-dom": "^19.2.3",
78
- "tsup": "^8.5.1",
79
- "typescript": "~6.0.3",
80
- "vite": "^8.0.16",
81
- "vitest": "^4.1.5",
82
- "@spree/dashboard-ui": "^1.0.0-rc.3",
83
- "@spree/admin-sdk": "^1.0.0-rc.2"
77
+ "tsup": "^9.0.0",
78
+ "typescript": "~7.0.2",
79
+ "vite": "^8.3.4",
80
+ "vitest": "^5.0.5",
81
+ "@spree/admin-sdk": "^1.0.0-rc.4",
82
+ "@spree/dashboard-ui": "^1.0.0-rc.4"
84
83
  },
85
84
  "scripts": {
86
85
  "lint": "biome check src",
@@ -103,9 +103,8 @@ export function CurrencySelect({
103
103
 
104
104
  // Union of the option list and the current value so editing a record whose
105
105
  // currency isn't in the list (a store-supported list that later narrowed)
106
- // never silently drops the selection. An empty `options` (e.g. a runtime
107
- // without `Intl.supportedValuesOf` yields an empty `ALL_CURRENCY_CODES`)
108
- // falls back to the store's currencies rather than an empty picker.
106
+ // never silently drops the selection. An empty `options` falls back to the
107
+ // store's currencies rather than an empty picker.
109
108
  const items = useMemo(() => {
110
109
  const base = options?.length ? options : currencies
111
110
  return isCurrencyCode(value) && !base.includes(value) ? [value, ...base] : base
@@ -1,4 +1,6 @@
1
1
  import {
2
+ Alert,
3
+ AlertDescription,
2
4
  Badge,
3
5
  Button,
4
6
  Card,
@@ -33,6 +35,7 @@ import {
33
35
  CheckCircle2Icon,
34
36
  ChevronLeftIcon,
35
37
  ChevronRightIcon,
38
+ CircleAlertIcon,
36
39
  DownloadIcon,
37
40
  RotateCcwIcon,
38
41
  XIcon,
@@ -107,13 +110,7 @@ export function ImportWizardDialog({ importId, onClose, onViewRecords }: ImportW
107
110
  }}
108
111
  modal
109
112
  >
110
- <DialogContent
111
- // Edge-to-edge minus a 3-unit gutter — see BulkPriceEditorDialog for
112
- // why every inset/translate/max is overridden.
113
- className="!inset-3 !w-auto !max-w-none !translate-x-0 !translate-y-0 flex flex-col p-0"
114
- style={{ maxHeight: 'none' }}
115
- showCloseButton={false}
116
- >
113
+ <DialogContent fullScreen showCloseButton={false}>
117
114
  {importId && (
118
115
  <ImportWizard
119
116
  importId={importId}
@@ -472,10 +469,10 @@ function MappingStep({
472
469
  </div>
473
470
 
474
471
  {error && (
475
- <div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-destructive text-sm">
476
- <AlertTriangleIcon className="size-4 shrink-0" />
477
- <span>{error.message}</span>
478
- </div>
472
+ <Alert variant="destructive">
473
+ <CircleAlertIcon />
474
+ <AlertDescription>{error.message}</AlertDescription>
475
+ </Alert>
479
476
  )}
480
477
  </CardContent>
481
478
  </Card>
@@ -46,6 +46,8 @@ export interface PreferenceFieldDef {
46
46
  format?: string
47
47
  default: unknown
48
48
  choices?: string[]
49
+ /** Labels the declaration gave its choices, for values that cannot name themselves (an endpoint URL). */
50
+ choiceLabels?: Record<string, string>
49
51
  /** Whether the setting may be left empty (it reads back as null). */
50
52
  nullable: boolean
51
53
  /** The property's own JSON Schema, for widgets that need more (list items, object properties). */
@@ -101,6 +103,7 @@ export function preferenceFields(
101
103
  format: property.format ?? property.items?.format,
102
104
  default: property.default ?? null,
103
105
  choices: property.enum?.filter((choice): choice is string => typeof choice === 'string'),
106
+ choiceLabels: property['x-spree-choice-labels'],
104
107
  nullable: Array.isArray(property.type) && property.type.includes('null'),
105
108
  schema: property,
106
109
  }))
@@ -432,12 +435,23 @@ export function PreferenceField({
432
435
  // the set is what the server validates against, so typing anything
433
436
  // else can only fail on save.
434
437
  if (field.choices?.length) {
435
- const options = field.choices.map((choice) => ({
438
+ // A translation wins where one exists, so the locale files stay in
439
+ // charge of the values that read as words; then the label the
440
+ // declaration supplied, for the ones that do not — a URL cannot serve
441
+ // as its own i18n key, let alone as a label.
442
+ const declared = field.choices.map((choice) => ({
436
443
  value: choice,
437
444
  label: i18n.exists(`${preferenceKey}_options.${choice}`)
438
445
  ? t(`${preferenceKey}_options.${choice}`)
439
- : humanizeKey(choice),
446
+ : (field.choiceLabels?.[choice] ?? humanizeKey(choice)),
440
447
  }))
448
+ // A stored value outside the set is offered as its own option rather
449
+ // than dropped, so one written past the picker by an API client is not
450
+ // silently rewritten to the first choice.
451
+ const options =
452
+ typeof value === 'string' && value !== '' && !declared.some((o) => o.value === value)
453
+ ? [...declared, { value, label: value }]
454
+ : declared
441
455
 
442
456
  return (
443
457
  <Field>
@@ -109,6 +109,11 @@ export function ResourceCombobox<T extends ComboboxOption>({
109
109
  // elsewhere (a customer created on another page) never invalidates it.
110
110
  // Every search refetches; cached rows for a repeated term still paint first.
111
111
  staleTime: 0,
112
+ // Keeps the last results on screen while the next query loads, so the
113
+ // list does not blank on every keystroke. Only within one tenant: the
114
+ // second argument is the query those results came from.
115
+ placeholderData: (previous, previousQuery) =>
116
+ previousQuery?.queryKey[1] === tenantId ? previous : undefined,
112
117
  })
113
118
 
114
119
  // Hydrate the currently selected ID into a record so the trigger shows
@@ -1,6 +1,11 @@
1
1
  import {
2
2
  Button,
3
3
  Checkbox,
4
+ Item,
5
+ ItemActions,
6
+ ItemContent,
7
+ ItemDescription,
8
+ ItemTitle,
4
9
  type PaginationMeta,
5
10
  SearchInput,
6
11
  Sheet,
@@ -308,11 +313,10 @@ export function ResourcePickerSheet<T extends PickerOption>({
308
313
  const subtitle = getOptionSubtitle?.(option)
309
314
  return (
310
315
  <li key={option.id}>
311
- <button
312
- type="button"
313
- disabled={inList}
316
+ <Item
317
+ render={<button type="button" disabled={inList} />}
314
318
  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"
319
+ className="flex-nowrap gap-3 rounded-none px-4 py-2.5 text-left hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
316
320
  >
317
321
  <Checkbox
318
322
  checked={checked}
@@ -320,20 +324,22 @@ export function ResourcePickerSheet<T extends PickerOption>({
320
324
  className="pointer-events-none"
321
325
  />
322
326
  {getOptionImageUrl && <Thumbnail src={imageUrl} size="sm" />}
323
- <span className="min-w-0 flex-1">
324
- <span className="block truncate text-sm">{getOptionLabel(option)}</span>
327
+ <ItemContent className="min-w-0 gap-0">
328
+ <ItemTitle className="block truncate font-normal">
329
+ {getOptionLabel(option)}
330
+ </ItemTitle>
325
331
  {subtitle && (
326
- <span className="block truncate text-muted-foreground text-xs">
332
+ <ItemDescription className="line-clamp-1 text-xs">
327
333
  {subtitle}
328
- </span>
334
+ </ItemDescription>
329
335
  )}
330
- </span>
336
+ </ItemContent>
331
337
  {inList && (
332
- <span className="shrink-0 text-muted-foreground text-xs">
338
+ <ItemActions className="shrink-0 text-muted-foreground text-xs">
333
339
  {t('admin.resource_picker.already_added')}
334
- </span>
340
+ </ItemActions>
335
341
  )}
336
- </button>
342
+ </Item>
337
343
  </li>
338
344
  )
339
345
  })}
@@ -1177,17 +1177,19 @@ function InlineValueList({
1177
1177
  }))
1178
1178
  }, [isCurrency, currencies, isTags, tags.data, resources.data, column.filterResource, query])
1179
1179
 
1180
- // Selected values ride at the top so a choice made before searching does not
1181
- // disappear the moment the query stops matching it.
1180
+ // Values already selected when the list opens ride at the top, so an active
1181
+ // filter is visible without scrolling. The set is frozen at mount: ticking a
1182
+ // box must not move rows under the pointer.
1183
+ const [pinned] = useState(() => selected)
1182
1184
  const ordered = useMemo(() => {
1183
- const chosen = options.filter((option) => selected.includes(option.value))
1184
- const rest = options.filter((option) => !selected.includes(option.value))
1185
+ const chosen = options.filter((option) => pinned.includes(option.value))
1186
+ const rest = options.filter((option) => !pinned.includes(option.value))
1185
1187
  // Not truncated: a list cut at ten looks like a list of ten, and a
1186
1188
  // vocabulary of forty tags would silently lose thirty with nothing on
1187
1189
  // screen to say so. The container scrolls instead, so the scrollbar
1188
1190
  // reports how much more there is — and searching narrows it.
1189
1191
  return [...chosen, ...rest]
1190
- }, [options, selected])
1192
+ }, [options, pinned])
1191
1193
 
1192
1194
  if (!isCurrency && (tags.isLoading || resources.isLoading)) {
1193
1195
  return (
@@ -1507,6 +1509,7 @@ function FilterPanel({
1507
1509
  behind the panel is already showing the result. */}
1508
1510
  {field && multi && column && (
1509
1511
  <InlineValueList
1512
+ key={column.key}
1510
1513
  column={column}
1511
1514
  tenantId={tenantId}
1512
1515
  query={query}
@@ -1,18 +1,34 @@
1
- import type { CurrencyCode } from '@spree/admin-sdk'
2
- import { isCurrencyCode } from './iso-codes'
1
+ import { CurrencyCode } from '@spree/admin-sdk'
2
+
3
+ // Replaced currencies Spree still accepts so orders priced in them stay
4
+ // valid, but which no merchant should pick for new pricing.
5
+ const RETIRED_CURRENCY_CODES: ReadonlySet<string> = new Set([
6
+ 'BYR',
7
+ 'CUC',
8
+ 'EEK',
9
+ 'HRK',
10
+ 'LTL',
11
+ 'LVL',
12
+ 'MRO',
13
+ 'MTL',
14
+ 'SKK',
15
+ 'SLL',
16
+ 'STD',
17
+ 'TMM',
18
+ 'VEF',
19
+ 'ZMK',
20
+ 'ZWD',
21
+ 'ZWL',
22
+ 'ZWN',
23
+ 'ZWR',
24
+ ])
3
25
 
4
26
  /**
5
- * Every ISO 4217 currency code the runtime knows about — the active-currency
6
- * counterpart to the Rails admin's `Money::Currency.table` list. Used for
7
- * contexts where the merchant can pick *any* currency (a market's currency)
8
- * rather than one the store already supports. Degrades to an empty list on
9
- * runtimes without `Intl.supportedValuesOf`, so callers fall back to
10
- * `supported_currencies`.
27
+ * Every currency Spree accepts that is still in use. Used for contexts where
28
+ * the merchant can pick *any* currency (a market's currency) rather than one
29
+ * the store already supports. The list ships with the SDK, so a currency a
30
+ * newer server adds appears once the dashboard is upgraded with it.
11
31
  */
12
- export const ALL_CURRENCY_CODES: CurrencyCode[] = (() => {
13
- try {
14
- return Intl.supportedValuesOf('currency').filter(isCurrencyCode)
15
- } catch {
16
- return []
17
- }
18
- })()
32
+ export const ALL_CURRENCY_CODES: readonly CurrencyCode[] = Object.values(CurrencyCode).filter(
33
+ (code) => !RETIRED_CURRENCY_CODES.has(code),
34
+ )
@@ -5,9 +5,11 @@ import { z } from 'zod/v4'
5
5
  /**
6
6
  * Whether a value has the shape of an ISO 3166-1 alpha-2 country code.
7
7
  *
8
- * Shape only: the server holds the authoritative list and rejects a code it
9
- * does not know, so the dashboard does not ship a second copy of it. This is
10
- * the one place a plain string becomes a `CountryCode`.
8
+ * Shape only: the connected server holds the authoritative list and rejects
9
+ * a code it does not know, while the SDK's `CountryCode` list is fixed when it
10
+ * is built. Checking against that copy would hide or reject a code an older or
11
+ * newer server accepts. This is the one place a plain string becomes a
12
+ * `CountryCode`.
11
13
  */
12
14
  export function isCountryCode(value: unknown): value is CountryCode {
13
15
  return typeof value === 'string' && /^[A-Z]{2}$/.test(value)
@@ -20,6 +20,7 @@ export const Subject = {
20
20
  CustomerGroup: 'customer_group',
21
21
  AdminUser: 'admin_user',
22
22
  ApiKey: 'api_key',
23
+ OauthApplication: 'oauth_application',
23
24
  AllowedOrigin: 'allowed_origin',
24
25
  Store: 'store',
25
26
  // The store's packaging vocabulary: the box it ships parcels in, and the
@@ -45,6 +46,7 @@ export const Subject = {
45
46
  ClaimReason: 'claim_reason',
46
47
  RefundReason: 'refund_reason',
47
48
  OrderCancellationReason: 'order_cancellation_reason',
49
+ TaxExemptionReason: 'tax_exemption_reason',
48
50
  CustomFieldDefinition: 'custom_field_definition',
49
51
  Integration: 'integration',
50
52
  PaymentMethod: 'payment_method',
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { canSeeSettingsEntry, type SettingsNavEntry } from './settings-nav-registry'
3
+
4
+ const entry = (overrides: Partial<SettingsNavEntry>): SettingsNavEntry => ({
5
+ key: 'settings.example',
6
+ label: 'Example',
7
+ path: '/example',
8
+ group: 'store',
9
+ ...overrides,
10
+ })
11
+
12
+ const allowing =
13
+ (...subjects: string[]) =>
14
+ (_action: string, subject: string) =>
15
+ subjects.includes(subject)
16
+
17
+ describe('canSeeSettingsEntry', () => {
18
+ it('shows an entry without a subject to everyone', () => {
19
+ expect(canSeeSettingsEntry(entry({}), allowing())).toBe(true)
20
+ })
21
+
22
+ it('checks a single subject', () => {
23
+ expect(canSeeSettingsEntry(entry({ subject: 'tax_rate' }), allowing('tax_rate'))).toBe(true)
24
+ expect(canSeeSettingsEntry(entry({ subject: 'tax_rate' }), allowing())).toBe(false)
25
+ })
26
+
27
+ // A page of tabs is reachable through any one of them.
28
+ it('shows an entry with several subjects when any one is allowed', () => {
29
+ const taxes = entry({ subjects: ['tax_category', 'tax_rate', 'tax_exemption_reason'] })
30
+
31
+ expect(canSeeSettingsEntry(taxes, allowing('tax_exemption_reason'))).toBe(true)
32
+ expect(canSeeSettingsEntry(taxes, allowing('product'))).toBe(false)
33
+ })
34
+ })
@@ -60,6 +60,12 @@ export interface SettingsNavEntry {
60
60
  position?: number
61
61
  /** CanCanCan subject required to see this item. Omit for always-visible. */
62
62
  subject?: SubjectName
63
+ /**
64
+ * For a page holding several resources, as tabs: the entry is shown when
65
+ * `action` is allowed on any of these, so a role reaching one tab still
66
+ * finds the page. Takes precedence over `subject`.
67
+ */
68
+ subjects?: SubjectName[]
63
69
  /**
64
70
  * Action checked against `subject`. Defaults to `'read'`. Settings pages
65
71
  * that only exist to *change* something (store settings, emails) should
@@ -220,6 +226,19 @@ export function settingsEntryMatches(
220
226
  return haystack.some((value) => value?.toLowerCase().includes(needle))
221
227
  }
222
228
 
229
+ /**
230
+ * Whether a staff member may reach this settings entry. The sidebar, the
231
+ * settings landing page and the command palette all ask this, so they agree.
232
+ */
233
+ export function canSeeSettingsEntry(
234
+ entry: SettingsNavEntry,
235
+ can: (action: string, subject: string) => boolean,
236
+ ): boolean {
237
+ const action = entry.action ?? 'read'
238
+ if (entry.subjects?.length) return entry.subjects.some((subject) => can(action, subject))
239
+ return !entry.subject || can(action, entry.subject)
240
+ }
241
+
223
242
  /**
224
243
  * Drop entries the current staff member cannot reach. Exported because the
225
244
  * sidebar and the settings landing page must agree on what a role can see —
@@ -231,7 +250,7 @@ export function filterSettingsByPermissions(
231
250
  permissions: { can: (action: string, subject: string) => boolean },
232
251
  ): SettingsNavSnapshot {
233
252
  return regroup(snapshot, (entry) =>
234
- !entry.subject ? true : permissions.can(entry.action ?? 'read', entry.subject),
253
+ canSeeSettingsEntry(entry, (action, subject) => permissions.can(action, subject)),
235
254
  )
236
255
  }
237
256
 
@@ -265,9 +284,7 @@ export function hasVisibleSettingsEntries(permissions: unknown): boolean {
265
284
  ?.can
266
285
  if (typeof can !== 'function') return false
267
286
 
268
- return getSnapshot().all.some(
269
- (entry) => !entry.subject || can(entry.action ?? 'read', entry.subject),
270
- )
287
+ return getSnapshot().all.some((entry) => canSeeSettingsEntry(entry, can))
271
288
  }
272
289
 
273
290
  /** Test-only: clear the registry. */
@@ -183,7 +183,9 @@
183
183
  "searching": "جارٍ البحث…",
184
184
  "select_placeholder": "اختر…",
185
185
  "unsaved_changes": "لديك تغييرات غير محفوظة.",
186
- "yes": "نعم"
186
+ "yes": "نعم",
187
+ "done": "تم",
188
+ "error": "حدث خطأ ما."
187
189
  },
188
190
  "components": {
189
191
  "address_block": {
@@ -183,7 +183,9 @@
183
183
  "searching": "Wird gesucht…",
184
184
  "select_placeholder": "Auswählen…",
185
185
  "unsaved_changes": "Sie haben ungespeicherte Änderungen.",
186
- "yes": "Ja"
186
+ "yes": "Ja",
187
+ "done": "Fertig",
188
+ "error": "Etwas ist schiefgelaufen."
187
189
  },
188
190
  "components": {
189
191
  "address_block": {
@@ -183,7 +183,9 @@
183
183
  "searching": "Searching…",
184
184
  "select_placeholder": "Select…",
185
185
  "unsaved_changes": "You have unsaved changes.",
186
- "yes": "Yes"
186
+ "yes": "Yes",
187
+ "done": "Done",
188
+ "error": "Something went wrong."
187
189
  },
188
190
  "components": {
189
191
  "address_block": {
@@ -1665,7 +1665,9 @@
1665
1665
  "searching": "Buscando…",
1666
1666
  "select_placeholder": "Seleccionar…",
1667
1667
  "unsaved_changes": "Tenés cambios sin guardar.",
1668
- "yes": "Sí"
1668
+ "yes": "Sí",
1669
+ "done": "Listo",
1670
+ "error": "Algo salió mal."
1669
1671
  },
1670
1672
  "messages": {
1671
1673
  "address_removed": "Dirección eliminada",
@@ -183,7 +183,9 @@
183
183
  "searching": "Recherche…",
184
184
  "select_placeholder": "Sélectionner…",
185
185
  "unsaved_changes": "Vous avez des modifications non enregistrées.",
186
- "yes": "Oui"
186
+ "yes": "Oui",
187
+ "done": "Terminé",
188
+ "error": "Une erreur s'est produite."
187
189
  },
188
190
  "components": {
189
191
  "address_block": {
@@ -184,7 +184,9 @@
184
184
  "searching": "Wyszukiwanie…",
185
185
  "select_placeholder": "Wybierz…",
186
186
  "unsaved_changes": "Masz niezapisane zmiany.",
187
- "yes": "Tak"
187
+ "yes": "Tak",
188
+ "done": "Gotowe",
189
+ "error": "Coś poszło nie tak."
188
190
  },
189
191
  "components": {
190
192
  "address_block": {
@@ -183,7 +183,9 @@
183
183
  "searching": "搜索中…",
184
184
  "select_placeholder": "请选择…",
185
185
  "unsaved_changes": "您有未保存的更改。",
186
- "yes": "是"
186
+ "yes": "是",
187
+ "done": "完成",
188
+ "error": "出了点问题。"
187
189
  },
188
190
  "components": {
189
191
  "address_block": {
@@ -215,13 +215,7 @@ export function BulkVariantsDialog({ form, open, onOpenChange }: Props) {
215
215
 
216
216
  return (
217
217
  <Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : handleCancel())} modal>
218
- <DialogContent
219
- // Edge-to-edge minus a 3-unit gutter, same overrides as the bulk
220
- // price dialog (see BulkPriceEditorDialog for why each is needed).
221
- className="!inset-3 !w-auto !max-w-none !translate-x-0 !translate-y-0 flex flex-col p-0"
222
- style={{ maxHeight: 'none' }}
223
- showCloseButton={false}
224
- >
218
+ <DialogContent fullScreen showCloseButton={false}>
225
219
  <DialogHeader className="flex flex-row items-center justify-between gap-3 space-y-0 border-b p-3">
226
220
  <DialogTitle className="truncate">
227
221
  {t('admin.products.variants.bulk_edit.title')}
@@ -2,13 +2,13 @@ import type { ProductFormValues, StockLevelFormValues } from '@spree/dashboard-c
2
2
  import { useStockLocations } from '@spree/dashboard-core'
3
3
  import {
4
4
  DataGrid,
5
+ type DataGridColumn,
5
6
  editableRowIndex,
6
7
  NumberCell,
7
8
  ReadOnlyCell,
8
9
  SwitchCell,
9
10
  } from '@spree/dashboard-ui'
10
11
  import { ExternalLinkIcon } from '@spree/dashboard-ui/icons'
11
- import type { ColumnDef } from '@tanstack/react-table'
12
12
  import { useCallback, useMemo } from 'react'
13
13
  import { type UseFormReturn, useWatch } from 'react-hook-form'
14
14
  import { useTranslation } from 'react-i18next'
@@ -135,7 +135,7 @@ export function InventorySection({ form, stockLocationHref }: InventorySectionPr
135
135
  [form],
136
136
  )
137
137
 
138
- const columns = useMemo<ColumnDef<InventoryRow>[]>(
138
+ const columns = useMemo<DataGridColumn<InventoryRow>[]>(
139
139
  () => [
140
140
  {
141
141
  id: 'location',
@@ -4,6 +4,10 @@ import {
4
4
  Field,
5
5
  FieldLabel,
6
6
  Input,
7
+ Item,
8
+ ItemActions,
9
+ ItemContent,
10
+ ItemTitle,
7
11
  Select,
8
12
  SelectContent,
9
13
  SelectItem,
@@ -130,39 +134,38 @@ export function VariantsOptionsBuilder({ selected, onChange, extraOptionTypes }:
130
134
  )
131
135
  }
132
136
  return (
133
- <li
134
- key={ot.id}
135
- className="flex items-center gap-3 rounded-lg border border-border-subtle px-3 py-2"
136
- >
137
- <div className="min-w-0 flex-1">
138
- <div className="text-sm font-medium">{ot.label}</div>
139
- <div className="mt-1 flex flex-wrap gap-1">
137
+ <Item key={ot.id} variant="outline" render={<li />} className="border-border-subtle">
138
+ <ItemContent className="min-w-0">
139
+ <ItemTitle>{ot.label}</ItemTitle>
140
+ <div className="flex flex-wrap gap-1">
140
141
  {ot.values.map((v) => (
141
142
  <Badge key={v.name} variant="secondary">
142
143
  {v.label ?? v.name}
143
144
  </Badge>
144
145
  ))}
145
146
  </div>
146
- </div>
147
- <Button
148
- type="button"
149
- variant="ghost"
150
- size="icon-sm"
151
- aria-label={t('admin.actions.edit')}
152
- onClick={() => setEditingIndex(i)}
153
- >
154
- <PencilIcon />
155
- </Button>
156
- <Button
157
- type="button"
158
- variant="ghost"
159
- size="icon-sm"
160
- aria-label={t('admin.actions.remove')}
161
- onClick={() => removeAt(i)}
162
- >
163
- <XIcon />
164
- </Button>
165
- </li>
147
+ </ItemContent>
148
+ <ItemActions>
149
+ <Button
150
+ type="button"
151
+ variant="ghost"
152
+ size="icon-sm"
153
+ aria-label={t('admin.actions.edit')}
154
+ onClick={() => setEditingIndex(i)}
155
+ >
156
+ <PencilIcon />
157
+ </Button>
158
+ <Button
159
+ type="button"
160
+ variant="ghost"
161
+ size="icon-sm"
162
+ aria-label={t('admin.actions.remove')}
163
+ onClick={() => removeAt(i)}
164
+ >
165
+ <XIcon />
166
+ </Button>
167
+ </ItemActions>
168
+ </Item>
166
169
  )
167
170
  })}
168
171
  </ul>
@@ -16,6 +16,9 @@ import {
16
16
  import { CSS } from '@dnd-kit/utilities'
17
17
  import type { ProductFormValues, VariantFormValues } from '@spree/dashboard-core'
18
18
  import {
19
+ Alert,
20
+ AlertDescription,
21
+ AlertTitle,
19
22
  Button,
20
23
  Card,
21
24
  CardContent,
@@ -31,7 +34,7 @@ import {
31
34
  TableHeader,
32
35
  TableRow,
33
36
  } from '@spree/dashboard-ui'
34
- import { PencilIcon, SwatchBookIcon, XIcon } from '@spree/dashboard-ui/icons'
37
+ import { PencilIcon, SwatchBookIcon, TriangleAlertIcon, XIcon } from '@spree/dashboard-ui/icons'
35
38
  import { type CSSProperties, useEffect, useMemo, useState } from 'react'
36
39
  import { type UseFormReturn, useFieldArray, useWatch } from 'react-hook-form'
37
40
  import { useTranslation } from 'react-i18next'
@@ -361,31 +364,34 @@ export function VariantsSection({ form, seedFromType = false }: Props) {
361
364
  />
362
365
 
363
366
  {orphanedKeys.length > 0 && (
364
- <div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm dark:border-amber-700 dark:bg-amber-950/40">
365
- <p className="font-medium text-amber-900 dark:text-amber-100">
367
+ <Alert variant="warning">
368
+ <TriangleAlertIcon />
369
+ <AlertTitle>
366
370
  {t('admin.products.variants.orphan_warning', { count: orphanedKeys.length })}
367
- </p>
368
- {orphanedEntries.length > 0 && (
369
- <ul className="mt-1 list-disc pl-5 text-amber-900 dark:text-amber-200">
370
- {orphanedEntries.map((entry) => (
371
- <li key={entry.key}>{entry.label}</li>
372
- ))}
373
- </ul>
374
- )}
375
- <div className="mt-2 flex items-center justify-end gap-2">
376
- <Button type="button" variant="ghost" size="sm" onClick={handleKeepOrphans}>
377
- {t('admin.products.variants.keep')}
378
- </Button>
379
- <Button
380
- type="button"
381
- variant="destructive"
382
- size="sm"
383
- onClick={handleConfirmDropOrphans}
384
- >
385
- {t('admin.actions.remove')}
386
- </Button>
387
- </div>
388
- </div>
371
+ </AlertTitle>
372
+ <AlertDescription>
373
+ {orphanedEntries.length > 0 && (
374
+ <ul className="list-disc pl-5">
375
+ {orphanedEntries.map((entry) => (
376
+ <li key={entry.key}>{entry.label}</li>
377
+ ))}
378
+ </ul>
379
+ )}
380
+ <div className="mt-2 flex items-center justify-end gap-2">
381
+ <Button type="button" variant="ghost" size="sm" onClick={handleKeepOrphans}>
382
+ {t('admin.products.variants.keep')}
383
+ </Button>
384
+ <Button
385
+ type="button"
386
+ variant="destructive"
387
+ size="sm"
388
+ onClick={handleConfirmDropOrphans}
389
+ >
390
+ {t('admin.actions.remove')}
391
+ </Button>
392
+ </div>
393
+ </AlertDescription>
394
+ </Alert>
389
395
  )}
390
396
 
391
397
  {fields.length > 0 && !awaitingOptionValues && (