@spree/dashboard-core 0.13.1 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -0,0 +1,154 @@
1
+ import type { OptionType } from '@spree/admin-sdk'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { autoExpandIndex, composeOptionsText, type SelectedOptionType } from './variants-matrix'
4
+
5
+ // composeOptionsText only reads name/label/position/option_values; cast minimal
6
+ // fixtures rather than fabricating timestamps + ids that the helper ignores.
7
+ const COLOR = {
8
+ name: 'color',
9
+ label: 'Color',
10
+ position: 0,
11
+ option_values: [
12
+ { name: 'red', label: 'Red' },
13
+ { name: 'matte-black', label: 'Matte Black' },
14
+ ],
15
+ } as OptionType
16
+
17
+ const SIZE = {
18
+ name: 'size',
19
+ label: 'Size',
20
+ position: 1,
21
+ option_values: [
22
+ { name: 'xs', label: 'XS' },
23
+ { name: 'l', label: 'L' },
24
+ ],
25
+ } as OptionType
26
+
27
+ const MATERIAL = {
28
+ name: 'material',
29
+ label: 'Material',
30
+ position: 2,
31
+ option_values: [{ name: 'steel', label: 'Steel' }],
32
+ } as OptionType
33
+
34
+ describe('composeOptionsText', () => {
35
+ it('returns an empty string when there are no options', () => {
36
+ expect(composeOptionsText([], [COLOR, SIZE])).toBe('')
37
+ })
38
+
39
+ it('formats a single option as "Type: Value"', () => {
40
+ expect(composeOptionsText([{ name: 'color', value: 'red' }], [COLOR])).toBe('Color: Red')
41
+ })
42
+
43
+ it('joins multiple options with ", " in input order', () => {
44
+ expect(
45
+ composeOptionsText(
46
+ [
47
+ { name: 'color', value: 'matte-black' },
48
+ { name: 'size', value: 'xs' },
49
+ ],
50
+ [COLOR, SIZE],
51
+ ),
52
+ ).toBe('Color: Matte Black, Size: XS')
53
+ })
54
+
55
+ it('falls back to the type slug when the option type is not in the registry', () => {
56
+ expect(composeOptionsText([{ name: 'material', value: 'steel' }], [COLOR])).toBe(
57
+ 'material: steel',
58
+ )
59
+ })
60
+
61
+ it('falls back to the value slug when the value is not in the registry', () => {
62
+ expect(composeOptionsText([{ name: 'color', value: 'fuchsia' }], [COLOR])).toBe(
63
+ 'Color: fuchsia',
64
+ )
65
+ })
66
+
67
+ it('falls back per-component when the type matches but option_values is missing', () => {
68
+ const colorNoValues = { name: 'color', label: 'Color' } as OptionType
69
+ expect(composeOptionsText([{ name: 'color', value: 'red' }], [colorNoValues])).toBe(
70
+ 'Color: red',
71
+ )
72
+ })
73
+
74
+ it('handles registry-empty input by returning bare slugs', () => {
75
+ expect(
76
+ composeOptionsText(
77
+ [
78
+ { name: 'color', value: 'red' },
79
+ { name: 'size', value: 'xs' },
80
+ ],
81
+ [],
82
+ ),
83
+ ).toBe('color: red, size: xs')
84
+ })
85
+
86
+ it('reorders by option-type position regardless of input order (backend parity)', () => {
87
+ expect(
88
+ composeOptionsText(
89
+ [
90
+ { name: 'size', value: 'l' },
91
+ { name: 'color', value: 'red' },
92
+ ],
93
+ [COLOR, SIZE],
94
+ ),
95
+ ).toBe('Color: Red, Size: L')
96
+ })
97
+
98
+ it('uses Oxford "and" for three or more options (matches Rails to_sentence)', () => {
99
+ expect(
100
+ composeOptionsText(
101
+ [
102
+ { name: 'color', value: 'red' },
103
+ { name: 'size', value: 'l' },
104
+ { name: 'material', value: 'steel' },
105
+ ],
106
+ [COLOR, SIZE, MATERIAL],
107
+ ),
108
+ ).toBe('Color: Red, Size: L, and Material: Steel')
109
+ })
110
+
111
+ it('appends unknown-type options after registry-known ones in input order', () => {
112
+ expect(
113
+ composeOptionsText(
114
+ [
115
+ { name: 'finish', value: 'matte' },
116
+ { name: 'color', value: 'red' },
117
+ { name: 'pattern', value: 'striped' },
118
+ ],
119
+ [COLOR],
120
+ ),
121
+ ).toBe('Color: Red, finish: matte, and pattern: striped')
122
+ })
123
+ })
124
+
125
+ describe('autoExpandIndex', () => {
126
+ const row = (id: string, values: string[] = []): SelectedOptionType => ({
127
+ id,
128
+ name: id,
129
+ label: id,
130
+ position: 0,
131
+ values: values.map((name) => ({ name })),
132
+ })
133
+
134
+ it('offers the first row still awaiting values', () => {
135
+ expect(autoExpandIndex([row('color', ['red']), row('size')], new Set())).toBe(1)
136
+ })
137
+
138
+ it('offers nothing once every row has values', () => {
139
+ expect(autoExpandIndex([row('color', ['red'])], new Set())).toBe(-1)
140
+ })
141
+
142
+ // A type seeding two option types produces two empty rows. Declining the
143
+ // second must not re-open the first.
144
+ it('moves past a dismissed row to the next empty one', () => {
145
+ const rows = [row('color'), row('size')]
146
+
147
+ expect(autoExpandIndex(rows, new Set(['color']))).toBe(1)
148
+ expect(autoExpandIndex(rows, new Set(['color', 'size']))).toBe(-1)
149
+ })
150
+
151
+ it('offers nothing when there are no rows', () => {
152
+ expect(autoExpandIndex([], new Set())).toBe(-1)
153
+ })
154
+ })
@@ -0,0 +1,244 @@
1
+ // Pure helpers for the variants matrix on the product page.
2
+ //
3
+ // The matrix lets a merchant pick option types + values and writes the
4
+ // resulting cartesian product as RHF form state. These helpers stay pure
5
+ // (no React) so they can evolve independently of the UI shell.
6
+
7
+ import type { VariantFormValues } from '@spree/dashboard-core'
8
+ import type { PanelOptionType as OptionType } from '../api-client'
9
+
10
+ export interface SelectedOptionValue {
11
+ name: string
12
+ label?: string
13
+ }
14
+
15
+ export interface SelectedOptionType {
16
+ // OptionType prefixed_id — only used for picker UX (re-selecting the row).
17
+ id: string
18
+ // Canonical option name (e.g. "size"). What the API matches on.
19
+ name: string
20
+ // Display label for the option type (e.g. "Size").
21
+ label: string
22
+ position: number
23
+ // Ordered list of selected option values for this type.
24
+ values: SelectedOptionValue[]
25
+ }
26
+
27
+ /**
28
+ * Which option row the builder should open on its own: the first one still
29
+ * awaiting values that the merchant hasn't already dismissed. A product type
30
+ * seeds exactly such rows, so this puts the values picker in front of them
31
+ * without a click — while leaving them able to close it.
32
+ *
33
+ * @returns the row's index, or -1 when nothing should auto-expand.
34
+ */
35
+ export function autoExpandIndex(
36
+ selected: SelectedOptionType[],
37
+ dismissedIds: ReadonlySet<string>,
38
+ ): number {
39
+ return selected.findIndex(
40
+ (optionType) => optionType.values.length === 0 && !dismissedIds.has(optionType.id),
41
+ )
42
+ }
43
+
44
+ export interface VariantCombination {
45
+ options: { name: string; value: string }[]
46
+ }
47
+
48
+ export function cartesianProduct<T>(arrays: T[][]): T[][] {
49
+ return arrays.reduce<T[][]>(
50
+ (acc, arr) => acc.flatMap((combo) => arr.map((v) => [...combo, v])),
51
+ [[]],
52
+ )
53
+ }
54
+
55
+ // Build the cartesian product of selected option values, preserving the
56
+ // option-type order (matters for the label "Red / Small" vs "Small / Red"
57
+ // and for stable row identity across re-renders).
58
+ export function generateVariantCombinations(selected: SelectedOptionType[]): VariantCombination[] {
59
+ const ordered = [...selected]
60
+ .sort((a, b) => a.position - b.position)
61
+ .filter((ot) => ot.values.length > 0)
62
+ if (ordered.length === 0) return []
63
+
64
+ const valueMatrix = ordered.map((ot) => ot.values.map((v) => v.name))
65
+ return cartesianProduct(valueMatrix).map((combo) => ({
66
+ options: combo.map((value, idx) => ({ name: ordered[idx].name, value })),
67
+ }))
68
+ }
69
+
70
+ // Stable key for an options set, used to match existing variants against
71
+ // newly-generated combinations. Sorted by name so order of insertion can't
72
+ // affect equality.
73
+ export function optionsKey(options: { name: string; value: string }[]): string {
74
+ return [...options]
75
+ .sort((a, b) => a.name.localeCompare(b.name))
76
+ .map((o) => `${o.name}=${o.value}`)
77
+ .join('|')
78
+ }
79
+
80
+ export interface ReconcileResult {
81
+ // Carries persisted id/sku/prices/stock_levels forward for matched combos,
82
+ // and stamps new entries with defaults. Position is the array index.
83
+ next: VariantFormValues[]
84
+ // Options-keys of variants that no longer match any generated combination
85
+ // AND carry merchant data (typed SKU, prices, stock, weight, etc.). Drives
86
+ // the amber "N variants no longer match…" banner so the merchant can
87
+ // choose "Keep all" (leave them, ride the next save) or "Remove all"
88
+ // (drop them from form state). We use options-keys instead of ids so
89
+ // unsaved orphans — typed values on a single-option row that gets
90
+ // overshadowed by adding a second option type — show up in the banner
91
+ // too. Previously these were silently appended to form state.
92
+ orphanedKeys: string[]
93
+ }
94
+
95
+ // Match generated combinations against existing variants by options-set
96
+ // equality. The reconcile is non-destructive: persisted variants that no
97
+ // longer match any combination are KEPT in `next` (appended after the
98
+ // matched/new rows) and surfaced via `orphanedIds`. The UI uses that list
99
+ // to show a confirm-before-drop banner; "Keep all" dismisses the banner
100
+ // without changing the array, "Remove" filters them out.
101
+ //
102
+ // Blank variants ship with empty `prices` + `stock_levels`. The inventory
103
+ // grid renders editable rows for every (variant × location) and creates
104
+ // entries on first edit; the pricing editor does the same per currency.
105
+ // No pre-seeding here means the payload stays clean — only locations the
106
+ // merchant actually touched land in the PATCH.
107
+ export function reconcileVariants(
108
+ existing: VariantFormValues[],
109
+ combinations: VariantCombination[],
110
+ ): ReconcileResult {
111
+ const existingByKey = new Map<string, VariantFormValues>()
112
+ existing.forEach((v) => {
113
+ existingByKey.set(optionsKey(v.options), v)
114
+ })
115
+
116
+ const matchedKeys = new Set<string>()
117
+ const next: VariantFormValues[] = combinations.map((combo, index) => {
118
+ const key = optionsKey(combo.options)
119
+ const match = existingByKey.get(key)
120
+ if (match) {
121
+ matchedKeys.add(key)
122
+ return { ...match, options: combo.options, position: index }
123
+ }
124
+ return blankVariant(combo.options, index)
125
+ })
126
+
127
+ // Carry unmatched variants forward when the row has merchant data.
128
+ //
129
+ // Persisted rows (have an `id`) always carry forward — if dropped, the
130
+ // next product save would delete them server-side, blowing away SKUs,
131
+ // pricing, and inventory even when the merchant chose "Keep all".
132
+ //
133
+ // Unsaved rows (no `id`) carry forward only when they hold merchant data
134
+ // (SKU, barcode, price, stock count, positive shipping field). Without
135
+ // this, building variants from one option type then adding a second
136
+ // option type would leave every transient first-pass row hanging around
137
+ // in form state even though the matrix already replaced them with the
138
+ // 3×3 (or whatever) Cartesian product.
139
+ //
140
+ // Both persisted and unsaved carry-forwards seed the orphan banner so
141
+ // the merchant gets a chance to drop them. Banner identity is the
142
+ // options-key (uniquely identifies an orphan within the form state) —
143
+ // ids alone would miss the unsaved case.
144
+ const orphanedKeys: string[] = []
145
+ existing.forEach((v) => {
146
+ const key = optionsKey(v.options)
147
+ if (matchedKeys.has(key)) return
148
+ if (!variantHasMerchantData(v) && (v.options.length === 0 || !v.id)) return
149
+ next.push({ ...v, position: next.length })
150
+ orphanedKeys.push(key)
151
+ })
152
+
153
+ return { next, orphanedKeys }
154
+ }
155
+
156
+ // Does this variant carry merchant data we'd lose if dropped?
157
+ //
158
+ // Server-propagated default variants come back with `weight: 0` (NOT NULL
159
+ // column with default) and zero-count stock_levels for every active location.
160
+ // Treat those zero/default sentinels as "no data" — only truthy fields
161
+ // count the row as carrying intent. Without this, every saved
162
+ // single-variant product would be treated as an orphan on upgrade and the
163
+ // natural simple→multi-variant flow surfaces a confusing banner.
164
+ //
165
+ // Shipping fields (weight, dimensions) count when *positive* — `weight: 0`
166
+ // is the server's NOT NULL default and the initial form snapshot also
167
+ // writes `weight: 0`, so treating it as data would re-trigger the
168
+ // false-positive banner for every untouched product. A positive value
169
+ // means the merchant typed something (likely via the VariantEditSheet
170
+ // shipping section).
171
+ function variantHasMerchantData(v: VariantFormValues): boolean {
172
+ return (
173
+ !!v.sku ||
174
+ !!v.barcode ||
175
+ (v.prices?.length ?? 0) > 0 ||
176
+ (v.stock_levels?.some((s) => (s.count_on_hand ?? 0) > 0) ?? false) ||
177
+ (v.weight ?? 0) > 0 ||
178
+ (v.height ?? 0) > 0 ||
179
+ (v.width ?? 0) > 0 ||
180
+ (v.depth ?? 0) > 0
181
+ )
182
+ }
183
+
184
+ export function blankVariant(
185
+ options: { name: string; value: string }[],
186
+ position: number,
187
+ ): VariantFormValues {
188
+ return {
189
+ sku: null,
190
+ barcode: null,
191
+ position,
192
+ options,
193
+ weight: null,
194
+ height: null,
195
+ width: null,
196
+ depth: null,
197
+ weight_unit: null,
198
+ dimensions_unit: null,
199
+ track_inventory: true,
200
+ tax_category_id: null,
201
+ prices: [],
202
+ stock_levels: [],
203
+ }
204
+ }
205
+
206
+ // Mirrors backend `Spree::Variant#options_text`: "Color: Silver, Size: XS" or
207
+ // "Color: Silver, Size: XS, and Material: Steel". Sorts by option-type position
208
+ // (backend `option_values.sort_by(&:option_type.position)`) and joins with the
209
+ // same `to_sentence(words_connector: ', ', two_words_connector: ', ')` rules —
210
+ // `, ` between items and `, and ` before the last when there are 3+. Falls back
211
+ // to slugs when a label isn't in the registry (e.g. a value the merchant just
212
+ // created in this session) and to input order for types not in the registry.
213
+ export function composeOptionsText(
214
+ options: { name: string; value: string }[],
215
+ optionTypes: OptionType[],
216
+ ): string {
217
+ const parts = options
218
+ .map((o, idx) => {
219
+ const ot = optionTypes.find((x) => x.name === o.name)
220
+ const typeLabel = ot?.label ?? o.name
221
+ const valueLabel = ot?.option_values?.find((v) => v.name === o.value)?.label ?? o.value
222
+ // Stable position: registry value when present, otherwise the input index
223
+ // pushed above any registry value so unknown types trail in input order.
224
+ const position = ot?.position ?? Number.MAX_SAFE_INTEGER - options.length + idx
225
+ return { text: `${typeLabel}: ${valueLabel}`, position, idx }
226
+ })
227
+ .sort((a, b) => a.position - b.position || a.idx - b.idx)
228
+ .map((p) => p.text)
229
+
230
+ if (parts.length < 3) return parts.join(', ')
231
+ return `${parts.slice(0, -1).join(', ')}, and ${parts[parts.length - 1]}`
232
+ }
233
+
234
+ export function variantDisplayLabel(
235
+ variant: Pick<VariantFormValues, 'options' | 'sku'>,
236
+ fallback: string,
237
+ optionTypes: OptionType[],
238
+ ): string {
239
+ if (variant.options.length > 0) {
240
+ return composeOptionsText(variant.options, optionTypes)
241
+ }
242
+ if (variant.sku) return variant.sku
243
+ return fallback
244
+ }