@spree/dashboard-core 6.0.2 → 6.0.3

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": "6.0.2",
4
+ "version": "6.0.3",
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.",
6
6
  "author": "Vendo Connect Inc.",
7
7
  "homepage": "https://spreecommerce.org",
@@ -49,8 +49,8 @@
49
49
  "LICENSE"
50
50
  ],
51
51
  "peerDependencies": {
52
- "@spree/admin-sdk": ">=6.0.2 <7.0.0-0",
53
- "@spree/dashboard-ui": ">=6.0.2 <7.0.0-0",
52
+ "@spree/admin-sdk": ">=6.0.3 <7.0.0-0",
53
+ "@spree/dashboard-ui": ">=6.0.3 <7.0.0-0",
54
54
  "@tanstack/react-query": "^5.100.9",
55
55
  "@tanstack/react-router": "^1.169.2",
56
56
  "i18next": "^26.2.0",
@@ -60,17 +60,17 @@
60
60
  "react-i18next": "^17.0.8"
61
61
  },
62
62
  "dependencies": {
63
- "@dnd-kit/core": "^6.3.1",
64
- "@dnd-kit/sortable": "^10.0.0",
65
- "@dnd-kit/utilities": "^3.2.2",
63
+ "@dnd-kit/core": "6.3.1",
64
+ "@dnd-kit/sortable": "10.0.0",
65
+ "@dnd-kit/utilities": "3.2.2",
66
66
  "@hookform/resolvers": "^6.0.0",
67
67
  "@tailwindcss/vite": "^4.2.4",
68
- "@tanstack/react-hotkeys": "^0.16.0",
69
- "date-fns": "^4.4.0",
70
- "date-fns-tz": "^3.2.0",
71
- "lucide-react": "^1.60.0",
68
+ "@tanstack/react-hotkeys": "0.16.0",
69
+ "date-fns": "4.4.0",
70
+ "date-fns-tz": "3.2.0",
71
+ "lucide-react": "1.60.0",
72
72
  "zod": "^4.6.7",
73
- "@spree/core": "^6.0.2"
73
+ "@spree/core": "^6.0.3"
74
74
  },
75
75
  "devDependencies": {
76
76
  "@tanstack/react-query": "^5.104.5",
@@ -86,8 +86,8 @@
86
86
  "typescript": "~7.0.2",
87
87
  "vite": "^8.3.4",
88
88
  "vitest": "^5.0.5",
89
- "@spree/admin-sdk": "^6.0.2",
90
- "@spree/dashboard-ui": "^6.0.2"
89
+ "@spree/dashboard-ui": "^6.0.3",
90
+ "@spree/admin-sdk": "^6.0.3"
91
91
  },
92
92
  "scripts": {
93
93
  "lint": "biome check src",
@@ -0,0 +1,58 @@
1
+ import { useTranslation } from 'react-i18next'
2
+
3
+ /** The fields both panels' product payloads carry for a stock summary. */
4
+ export interface ProductStockFields {
5
+ in_stock: boolean
6
+ backorderable: boolean
7
+ total_on_hand?: number | null
8
+ variant_count: number
9
+ }
10
+
11
+ /**
12
+ * Whether a product has stock, by its total rather than `in_stock`. Null is
13
+ * untracked stock, which is unlimited; undefined is a server too old to send
14
+ * a total, which leaves only the sellable flag to go by. Shared so every place
15
+ * that labels a product's stock agrees.
16
+ */
17
+ export function productInStock(product: Pick<ProductStockFields, 'in_stock' | 'total_on_hand'>) {
18
+ const total = product.total_on_hand
19
+ return total === undefined ? product.in_stock : total === null || total > 0
20
+ }
21
+
22
+ /**
23
+ * A product's stock in a products list: how many units are on hand, on
24
+ * backorder, or out of stock, followed by the variant count when there are
25
+ * several. Shared by the admin and seller product lists.
26
+ *
27
+ * It reads the stock total rather than `in_stock`, which leaves out variants
28
+ * whose seller cannot sell yet: a pending seller's units on the shelf are in
29
+ * stock as far as inventory goes.
30
+ */
31
+ export function ProductStockSummary({ product }: { product: ProductStockFields }) {
32
+ const { t } = useTranslation()
33
+ const total = product.total_on_hand
34
+
35
+ const status = productInStock(product) ? (
36
+ typeof total === 'number' ? (
37
+ t('admin.components.product_stock.in_stock', { count: total })
38
+ ) : (
39
+ t('admin.components.product_stock.in_stock_short')
40
+ )
41
+ ) : product.backorderable ? (
42
+ t('admin.components.product_stock.on_backorder')
43
+ ) : (
44
+ <span className="text-destructive">{t('admin.components.product_stock.out_of_stock')}</span>
45
+ )
46
+
47
+ return (
48
+ <span className="text-sm text-muted-foreground">
49
+ {status}
50
+ {product.variant_count > 1 && (
51
+ <>
52
+ {' · '}
53
+ {t('admin.components.product_stock.variants', { count: product.variant_count })}
54
+ </>
55
+ )}
56
+ </span>
57
+ )
58
+ }
@@ -1,4 +1,4 @@
1
- import type { FilterPredicate } from '@spree/core'
1
+ import { type FilterPredicate, randomUUID } from '@spree/core'
2
2
  import {
3
3
  Button,
4
4
  Checkbox,
@@ -865,7 +865,7 @@ function QuickEnumFilter({
865
865
  onFiltersChange([
866
866
  ...others,
867
867
  {
868
- id: existing?.id ?? crypto.randomUUID(),
868
+ id: existing?.id ?? randomUUID(),
869
869
  field: column.key,
870
870
  operator: 'in',
871
871
  value: values.join(','),
@@ -956,7 +956,7 @@ function QuickResourceFilter({
956
956
  onFiltersChange([
957
957
  ...others,
958
958
  {
959
- id: existing?.id ?? crypto.randomUUID(),
959
+ id: existing?.id ?? randomUUID(),
960
960
  field: column.key,
961
961
  operator: 'in',
962
962
  value: values.join(','),
@@ -1039,14 +1039,14 @@ function QuickDateFilter({
1039
1039
  const next: FilterRule[] = [...others]
1040
1040
  if (rangeFrom) {
1041
1041
  next.push({
1042
- id: crypto.randomUUID(),
1042
+ id: randomUUID(),
1043
1043
  field: column.key,
1044
1044
  operator: 'gteq',
1045
1045
  value: rangeFrom,
1046
1046
  })
1047
1047
  }
1048
1048
  if (rangeTo) {
1049
- next.push({ id: crypto.randomUUID(), field: column.key, operator: 'lteq', value: rangeTo })
1049
+ next.push({ id: randomUUID(), field: column.key, operator: 'lteq', value: rangeTo })
1050
1050
  }
1051
1051
  onFiltersChange(next)
1052
1052
  },
@@ -1626,10 +1626,7 @@ function FilterPanel({
1626
1626
  const others = filters.filter(
1627
1627
  (f) => !(f.field === field && f.operator === nextOperator) && f.id !== editing,
1628
1628
  )
1629
- onApply([
1630
- ...others,
1631
- { id: crypto.randomUUID(), field, operator: nextOperator, value: nextValue },
1632
- ])
1629
+ onApply([...others, { id: randomUUID(), field, operator: nextOperator, value: nextValue }])
1633
1630
  }
1634
1631
 
1635
1632
  /** Replace this field's bounds with a preset's, and close. */
@@ -1641,10 +1638,10 @@ function FilterPanel({
1641
1638
  )
1642
1639
  const next = [...others]
1643
1640
  if (range.from) {
1644
- next.push({ id: crypto.randomUUID(), field, operator: 'gteq', value: range.from })
1641
+ next.push({ id: randomUUID(), field, operator: 'gteq', value: range.from })
1645
1642
  }
1646
1643
  if (range.to) {
1647
- next.push({ id: crypto.randomUUID(), field, operator: 'lteq', value: range.to })
1644
+ next.push({ id: randomUUID(), field, operator: 'lteq', value: range.to })
1648
1645
  }
1649
1646
  onApply(next)
1650
1647
  }
@@ -1672,7 +1669,7 @@ function FilterPanel({
1672
1669
  onChange(
1673
1670
  next.length === 0
1674
1671
  ? others
1675
- : [...others, { id: crypto.randomUUID(), field, operator: 'in', value: next.join(',') }],
1672
+ : [...others, { id: randomUUID(), field, operator: 'in', value: next.join(',') }],
1676
1673
  )
1677
1674
  }
1678
1675
 
@@ -1911,7 +1908,7 @@ function FilterPanel({
1911
1908
  const next = [...others]
1912
1909
  if (customRange.from) {
1913
1910
  next.push({
1914
- id: crypto.randomUUID(),
1911
+ id: randomUUID(),
1915
1912
  field,
1916
1913
  operator: 'gteq',
1917
1914
  value: customRange.from,
@@ -1919,7 +1916,7 @@ function FilterPanel({
1919
1916
  }
1920
1917
  if (customRange.to) {
1921
1918
  next.push({
1922
- id: crypto.randomUUID(),
1919
+ id: randomUUID(),
1923
1920
  field,
1924
1921
  operator: 'lteq',
1925
1922
  value: customRange.to,
package/src/index.ts CHANGED
@@ -51,6 +51,7 @@ export * from './components/nav-main'
51
51
  export * from './components/page-header'
52
52
  export * from './components/page-tabs'
53
53
  export * from './components/preferences-form'
54
+ export * from './components/product-stock-summary'
54
55
  export * from './components/record-metadata'
55
56
  export * from './components/resource-combobox'
56
57
  export * from './components/resource-multi-autocomplete'
@@ -493,6 +493,14 @@
493
493
  "discard_confirm": "تجاهل",
494
494
  "discard_message": "سيُفقد كل ما أدخلته حتى الآن، ولا يمكن التراجع عن ذلك.",
495
495
  "discard_title": "تجاهل هذه المسودة؟"
496
+ },
497
+ "product_stock": {
498
+ "out_of_stock": "إنتهى من المخزن",
499
+ "on_backorder": "على المؤجل",
500
+ "in_stock": "{{count}} في المخزون",
501
+ "in_stock_short": "في المخزون",
502
+ "variants_one": "{{count}} البديل",
503
+ "variants_other": "{{count}} متغيرات"
496
504
  }
497
505
  },
498
506
  "currency_amounts": {
@@ -493,6 +493,14 @@
493
493
  "discard_confirm": "Verwerfen",
494
494
  "discard_message": "Alles bisher Eingegebene geht verloren. Das lässt sich nicht rückgängig machen.",
495
495
  "discard_title": "Entwurf verwerfen?"
496
+ },
497
+ "product_stock": {
498
+ "out_of_stock": "Nicht auf Lager",
499
+ "on_backorder": "Im Rückstand",
500
+ "in_stock": "{{count}} auf Lager",
501
+ "in_stock_short": "Auf Lager",
502
+ "variants_one": "{{count}} Variante",
503
+ "variants_other": "{{count}} Varianten"
496
504
  }
497
505
  },
498
506
  "currency_amounts": {
@@ -495,6 +495,14 @@
495
495
  "discard_confirm": "Discard",
496
496
  "discard_message": "Everything you have filled in so far will be lost. This cannot be undone.",
497
497
  "discard_title": "Discard this draft?"
498
+ },
499
+ "product_stock": {
500
+ "out_of_stock": "Out of stock",
501
+ "on_backorder": "On backorder",
502
+ "in_stock": "{{count}} in stock",
503
+ "in_stock_short": "In stock",
504
+ "variants_one": "{{count}} variant",
505
+ "variants_other": "{{count}} variants"
498
506
  }
499
507
  },
500
508
  "currency_amounts": {
@@ -306,6 +306,14 @@
306
306
  "discard_confirm": "Descartar",
307
307
  "discard_message": "Todo lo que completaste hasta ahora se perderá. Esta acción no se puede deshacer.",
308
308
  "discard_title": "¿Descartar este borrador?"
309
+ },
310
+ "product_stock": {
311
+ "out_of_stock": "Sin stock",
312
+ "on_backorder": "En contrarreserva",
313
+ "in_stock": "{{count}} en stock",
314
+ "in_stock_short": "Con stock",
315
+ "variants_one": "{{count}} variante",
316
+ "variants_other": "{{count}} variantes"
309
317
  }
310
318
  },
311
319
  "currency_amounts": {
@@ -493,6 +493,14 @@
493
493
  "discard_confirm": "Abandonner",
494
494
  "discard_message": "Tout ce que vous avez saisi sera perdu. Cette action est irréversible.",
495
495
  "discard_title": "Abandonner ce brouillon ?"
496
+ },
497
+ "product_stock": {
498
+ "out_of_stock": "En rupture de stock",
499
+ "on_backorder": "En réapprovisionnement",
500
+ "in_stock": "{{count}} en stock",
501
+ "in_stock_short": "En stock",
502
+ "variants_one": "{{count}} variante",
503
+ "variants_other": "{{count}} variantes"
496
504
  }
497
505
  },
498
506
  "currency_amounts": {
@@ -495,6 +495,14 @@
495
495
  "discard_confirm": "破棄",
496
496
  "discard_message": "ここまで入力した内容はすべて失われます。元に戻すことはできません。",
497
497
  "discard_title": "この下書きを破棄しますか?"
498
+ },
499
+ "product_stock": {
500
+ "out_of_stock": "在庫切れ",
501
+ "on_backorder": "入荷待ち",
502
+ "in_stock": "在庫 {{count}}",
503
+ "in_stock_short": "在庫あり",
504
+ "variants_one": "バリアント {{count}} 件",
505
+ "variants_other": "バリアント {{count}} 件"
498
506
  }
499
507
  },
500
508
  "currency_amounts": {
@@ -494,6 +494,16 @@
494
494
  "discard_confirm": "Odrzuć",
495
495
  "discard_message": "Wszystko, co zostało wprowadzone, zostanie utracone. Nie można tego cofnąć.",
496
496
  "discard_title": "Odrzucić szkic?"
497
+ },
498
+ "product_stock": {
499
+ "out_of_stock": "Brak w magazynie",
500
+ "on_backorder": "Na zamówienie",
501
+ "in_stock": "{{count}} w magazynie",
502
+ "in_stock_short": "W magazynie",
503
+ "variants_few": "{{count}} warianty",
504
+ "variants_many": "{{count}} wariantów",
505
+ "variants_one": "{{count}} wariant",
506
+ "variants_other": "{{count}} wariantów"
497
507
  }
498
508
  },
499
509
  "currency_amounts": {
@@ -495,6 +495,14 @@
495
495
  "discard_confirm": "Loại bỏ",
496
496
  "discard_message": "Mọi thứ bạn đã điền cho đến nay sẽ bị mất. Điều này không thể hoàn tác được.",
497
497
  "discard_title": "Hủy bản nháp này?"
498
+ },
499
+ "product_stock": {
500
+ "out_of_stock": "Hết hàng",
501
+ "on_backorder": "Đang đặt hàng trước",
502
+ "in_stock": "{{count}} còn hàng",
503
+ "in_stock_short": "Còn hàng",
504
+ "variants_one": "{{count}} biến thể",
505
+ "variants_other": "{{count}} biến thể"
498
506
  }
499
507
  },
500
508
  "currency_amounts": {
@@ -493,6 +493,14 @@
493
493
  "discard_confirm": "放弃",
494
494
  "discard_message": "已填写的内容将全部丢失,且无法恢复。",
495
495
  "discard_title": "放弃此草稿?"
496
+ },
497
+ "product_stock": {
498
+ "out_of_stock": "缺货",
499
+ "on_backorder": "可延期交货",
500
+ "in_stock": "库存 {{count}} 件",
501
+ "in_stock_short": "有库存",
502
+ "variants_one": "{{count}} 个变体",
503
+ "variants_other": "{{count}} 个变体"
496
504
  }
497
505
  },
498
506
  "currency_amounts": {
@@ -13,7 +13,7 @@ import {
13
13
  useSortable,
14
14
  } from '@dnd-kit/sortable'
15
15
  import { CSS } from '@dnd-kit/utilities'
16
- import type { CurrencyCode } from '@spree/core'
16
+ import { type CurrencyCode, randomUUID } from '@spree/core'
17
17
  import type { MediaType, ProductFormValues } from '@spree/dashboard-core'
18
18
  import {
19
19
  getApiClient,
@@ -368,7 +368,7 @@ export function MediaCard({
368
368
  async (files: FileList | File[]) => {
369
369
  const fileArray = Array.from(files)
370
370
  for (const file of fileArray) {
371
- const uploadId = crypto.randomUUID()
371
+ const uploadId = randomUUID()
372
372
  const preview = URL.createObjectURL(file)
373
373
  setPending((prev) => [...prev, { id: uploadId, file, preview, progress: 'uploading' }])
374
374
  try {
@@ -445,7 +445,7 @@ export function MediaCard({
445
445
  ...entries.map((entry, index) => ({
446
446
  ...entry,
447
447
  position: current.length + index + 1,
448
- uploadId: crypto.randomUUID(),
448
+ uploadId: randomUUID(),
449
449
  })),
450
450
  ],
451
451
  { shouldDirty: true },
@@ -1,11 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import {
3
+ omitUnchangedListFields,
3
4
  prepareVariantsTaxForProductSave,
4
5
  variantHasTaxCategoryOverride,
5
6
  variantToFormValues,
6
7
  variantToWirePayload,
7
8
  } from './product-form-mapping'
8
- import type { VariantFormValues } from './product-schema'
9
+ import type { ProductFormValues, VariantFormValues } from './product-schema'
9
10
  import type { PanelVariant } from './product-types'
10
11
 
11
12
  // variantToFormValues reads a handful of fields; cast a minimal fixture rather
@@ -129,3 +130,43 @@ describe('variant tax category overrides', () => {
129
130
  expect(variantToWirePayload(prepared[0], 0).tax_category_id).toBeNull()
130
131
  })
131
132
  })
133
+
134
+ describe('omitUnchangedListFields', () => {
135
+ const payload = {
136
+ name: 'Linen shirt',
137
+ category_ids: ['ctg_1'],
138
+ collection_ids: ['coll_1'],
139
+ tags: ['summer'],
140
+ }
141
+
142
+ it('sends a list when its field changed', () => {
143
+ expect(omitUnchangedListFields(payload, { category_ids: true })).toEqual({
144
+ name: 'Linen shirt',
145
+ category_ids: ['ctg_1'],
146
+ })
147
+ expect(omitUnchangedListFields(payload, { tags: true, collection_ids: true })).toEqual({
148
+ name: 'Linen shirt',
149
+ collection_ids: ['coll_1'],
150
+ tags: ['summer'],
151
+ })
152
+ })
153
+
154
+ // React Hook Form marks an array field dirty per item when it compares item by item.
155
+ it('reads a per-item dirty flag', () => {
156
+ expect(omitUnchangedListFields(payload, { category_ids: [false, true] })).toEqual({
157
+ name: 'Linen shirt',
158
+ category_ids: ['ctg_1'],
159
+ })
160
+ expect(omitUnchangedListFields(payload, { category_ids: [false] })).toEqual({
161
+ name: 'Linen shirt',
162
+ })
163
+ })
164
+
165
+ // Another tab, a bulk action or an import may have added to a list since the
166
+ // page loaded; sending the loaded list back would remove those additions.
167
+ it('leaves the lists out when their fields did not change', () => {
168
+ const dirtyFields: Partial<Record<keyof ProductFormValues, boolean>> = { name: true }
169
+
170
+ expect(omitUnchangedListFields(payload, dirtyFields)).toEqual({ name: 'Linen shirt' })
171
+ })
172
+ })
@@ -195,6 +195,25 @@ export function withExtensionRowValues(
195
195
  }
196
196
  }
197
197
 
198
+ // The API replaces each of these lists with what a save sends.
199
+ const LIST_FIELDS = ['category_ids', 'collection_ids', 'tags'] as const
200
+
201
+ /**
202
+ * The save payload without the category, collection and tag lists the merchant
203
+ * left unchanged, so a save keeps what something else added since the page loaded.
204
+ */
205
+ export function omitUnchangedListFields(
206
+ payload: Record<string, unknown>,
207
+ dirtyFields: Partial<Record<(typeof LIST_FIELDS)[number], unknown>>,
208
+ ): Record<string, unknown> {
209
+ const trimmed = { ...payload }
210
+ for (const field of LIST_FIELDS) {
211
+ const dirty = dirtyFields[field]
212
+ if (!(Array.isArray(dirty) ? dirty.some(Boolean) : dirty)) delete trimmed[field]
213
+ }
214
+ return trimmed
215
+ }
216
+
198
217
  // Strip UI-only fields (stock_location_name) and undefined entries so the
199
218
  // PATCH body matches the Admin API VariantUpdateParams shape exactly. The
200
219
  // Spree::Product#variants= setter reconciles by id, creates new entries,