@spree/dashboard-core 6.0.2 → 6.0.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/dist/vite/index.js +81 -5
- package/package.json +24 -24
- package/src/api-client.ts +7 -1
- package/src/client.ts +2 -0
- package/src/components/address-form-dialog.tsx +75 -28
- package/src/components/app-shell.tsx +5 -4
- package/src/components/app-sidebar.tsx +2 -1
- package/src/components/bulk-action-bar.tsx +6 -5
- package/src/components/country-state-fields.tsx +3 -0
- package/src/components/currency-select.tsx +6 -4
- package/src/components/developer-inspector-drawer.tsx +322 -0
- package/src/components/file-upload-field.tsx +64 -37
- package/src/components/image-upload-field.tsx +4 -0
- package/src/components/import-wizard-dialog.tsx +14 -12
- package/src/components/invitation-acceptance.tsx +15 -11
- package/src/components/media-editor-dialog.tsx +131 -0
- package/src/components/media-picker-dialog.tsx +866 -0
- package/src/components/media-picker-sheet.tsx +13 -300
- package/src/components/mobile-top-bar.tsx +1 -1
- package/src/components/nav-main.tsx +13 -2
- package/src/components/page-header.tsx +57 -13
- package/src/components/page-tabs.tsx +1 -1
- package/src/components/product-stock-summary.tsx +58 -0
- package/src/components/read-row.tsx +38 -0
- package/src/components/resource-combobox.tsx +1 -1
- package/src/components/resource-image-field.tsx +150 -0
- package/src/components/resource-picker-sheet.tsx +2 -2
- package/src/components/resource-table.tsx +161 -40
- package/src/components/section-heading.tsx +2 -2
- package/src/components/settings-container.tsx +19 -0
- package/src/components/settings-fields.tsx +165 -0
- package/src/components/settings-section.tsx +46 -0
- package/src/components/settings-sidebar.tsx +35 -18
- package/src/components/sheet-top-bar.tsx +7 -8
- package/src/components/sidebar-search.tsx +11 -12
- package/src/components/sidebar-user.tsx +39 -44
- package/src/components/skip-link.tsx +1 -1
- package/src/components/store-switcher.tsx +1 -1
- package/src/components/table-toolbar.tsx +194 -331
- package/src/components/timezone-combobox.tsx +119 -0
- package/src/components/top-bar-breadcrumbs.tsx +1 -1
- package/src/hooks/use-stock-locations.ts +6 -2
- package/src/hooks/use-table-density.ts +50 -0
- package/src/index.ts +20 -2
- package/src/lib/curl.test.ts +18 -0
- package/src/lib/curl.ts +28 -0
- package/src/lib/developer-inspector.test.ts +169 -0
- package/src/lib/developer-inspector.ts +134 -0
- package/src/lib/docs.ts +3 -2
- package/src/lib/fulfillment-items.test.ts +51 -0
- package/src/lib/fulfillment-items.ts +53 -0
- package/src/lib/request-snippets.ts +41 -0
- package/src/lib/table-registry.ts +13 -6
- package/src/lib/timestamp-columns.tsx +1 -1
- package/src/locales/ar.json +128 -14
- package/src/locales/de.json +128 -14
- package/src/locales/en.json +128 -14
- package/src/locales/es.json +128 -14
- package/src/locales/fr.json +128 -14
- package/src/locales/ja.json +128 -14
- package/src/locales/pl.json +130 -14
- package/src/locales/vi.json +128 -14
- package/src/locales/zh-CN.json +128 -14
- package/src/pages/settings-index.tsx +79 -48
- package/src/pages/stock-locations.tsx +14 -6
- package/src/products/digital-asset-edit-sheet.tsx +1 -1
- package/src/products/digital-asset-provider-sheet.tsx +1 -1
- package/src/products/digital-assets-card.tsx +7 -7
- package/src/products/inventory-section.tsx +2 -2
- package/src/products/media-edit-sheet.tsx +136 -133
- package/src/products/product-form-cards.tsx +204 -73
- package/src/products/product-form-mapping.test.ts +42 -1
- package/src/products/product-form-mapping.ts +20 -0
- package/src/products/product-schema.ts +8 -0
- package/src/products/product-types.ts +2 -0
- package/src/products/use-product-form-data.ts +30 -3
- package/src/products/variant-edit-sheet.tsx +3 -3
- package/src/products/variants-options-builder.tsx +13 -8
- package/src/products/variants-section.tsx +196 -4
- package/src/providers/auth-provider.tsx +2 -0
- package/src/providers/developer-inspector-provider.tsx +145 -0
- package/src/providers/json-link-resolver-provider.tsx +27 -0
- package/src/sellers/seller-schema.ts +107 -0
- package/src/tables/package-types.tsx +3 -3
- package/src/tables/stock-locations.tsx +15 -14
- package/src/virtual.d.ts +4 -0
- package/src/vite/index.ts +103 -5
|
@@ -13,11 +13,12 @@ import {
|
|
|
13
13
|
useSortable,
|
|
14
14
|
} from '@dnd-kit/sortable'
|
|
15
15
|
import { CSS } from '@dnd-kit/utilities'
|
|
16
|
-
import type
|
|
16
|
+
import { type CurrencyCode, randomUUID } from '@spree/core'
|
|
17
17
|
import type { MediaType, ProductFormValues } from '@spree/dashboard-core'
|
|
18
18
|
import {
|
|
19
19
|
getApiClient,
|
|
20
|
-
|
|
20
|
+
MediaPickerDialog,
|
|
21
|
+
mediaPickerListParams,
|
|
21
22
|
parseVideoUrl,
|
|
22
23
|
ResourceMultiAutocomplete,
|
|
23
24
|
Slot,
|
|
@@ -34,16 +35,20 @@ import {
|
|
|
34
35
|
CardContent,
|
|
35
36
|
CardHeader,
|
|
36
37
|
CardTitle,
|
|
38
|
+
Checkbox,
|
|
37
39
|
ContextMenu,
|
|
38
40
|
ContextMenuContent,
|
|
39
41
|
ContextMenuItem,
|
|
40
42
|
ContextMenuSeparator,
|
|
41
43
|
ContextMenuTrigger,
|
|
44
|
+
CurrencyLabel,
|
|
45
|
+
cn,
|
|
42
46
|
DragHandle,
|
|
43
47
|
Field,
|
|
44
48
|
FieldError,
|
|
45
49
|
FieldLabel,
|
|
46
50
|
Input,
|
|
51
|
+
Item,
|
|
47
52
|
RichTextEditor,
|
|
48
53
|
Select,
|
|
49
54
|
SelectContent,
|
|
@@ -102,7 +107,6 @@ import {
|
|
|
102
107
|
collectionAutocompleteProps,
|
|
103
108
|
useFormCategories as useCategories,
|
|
104
109
|
useFormCollections as useCollections,
|
|
105
|
-
useFormDeleteProductMedia as useDeleteProductMedia,
|
|
106
110
|
useFormDeliveryProfiles as useDeliveryProfiles,
|
|
107
111
|
useFormOptionTypesByIds as useOptionTypesByIds,
|
|
108
112
|
useFormProductType as useProductType,
|
|
@@ -238,13 +242,13 @@ export function PricesCard({
|
|
|
238
242
|
value={currency}
|
|
239
243
|
onValueChange={(next) => setCurrency(currencies.find((code) => code === next) ?? null)}
|
|
240
244
|
>
|
|
241
|
-
<SelectTrigger size="sm" className="w-
|
|
242
|
-
<SelectValue />
|
|
245
|
+
<SelectTrigger size="sm" className="w-28">
|
|
246
|
+
<SelectValue>{(code: string) => <CurrencyLabel code={code} />}</SelectValue>
|
|
243
247
|
</SelectTrigger>
|
|
244
248
|
<SelectContent>
|
|
245
249
|
{currencies.map((c) => (
|
|
246
250
|
<SelectItem key={c} value={c}>
|
|
247
|
-
{c}
|
|
251
|
+
<CurrencyLabel code={c} />
|
|
248
252
|
</SelectItem>
|
|
249
253
|
))}
|
|
250
254
|
</SelectContent>
|
|
@@ -320,12 +324,14 @@ export function MediaCard({
|
|
|
320
324
|
}) {
|
|
321
325
|
const { t } = useTranslation()
|
|
322
326
|
const fileUpload = useFileUpload()
|
|
323
|
-
const deleteMedia = useDeleteProductMedia(productId ?? '')
|
|
324
327
|
const confirm = useConfirm()
|
|
325
328
|
const [pending, setPending] = useState<PendingUpload[]>([])
|
|
326
329
|
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
|
327
330
|
const [addingVideo, setAddingVideo] = useState(false)
|
|
328
331
|
const [pickingFromLibrary, setPickingFromLibrary] = useState(false)
|
|
332
|
+
// Tiles picked for a bulk action, by their stable sortable id so a reorder
|
|
333
|
+
// keeps the selection on the same files.
|
|
334
|
+
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
329
335
|
const mediaLibrary = getApiClient().mediaLibrary
|
|
330
336
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
331
337
|
|
|
@@ -368,7 +374,7 @@ export function MediaCard({
|
|
|
368
374
|
async (files: FileList | File[]) => {
|
|
369
375
|
const fileArray = Array.from(files)
|
|
370
376
|
for (const file of fileArray) {
|
|
371
|
-
const uploadId =
|
|
377
|
+
const uploadId = randomUUID()
|
|
372
378
|
const preview = URL.createObjectURL(file)
|
|
373
379
|
setPending((prev) => [...prev, { id: uploadId, file, preview, progress: 'uploading' }])
|
|
374
380
|
try {
|
|
@@ -381,6 +387,7 @@ export function MediaCard({
|
|
|
381
387
|
{
|
|
382
388
|
signed_id: result.signedId,
|
|
383
389
|
alt: file.name,
|
|
390
|
+
filename: file.name,
|
|
384
391
|
position: current.length + 1,
|
|
385
392
|
media_type: file.type.startsWith('video/')
|
|
386
393
|
? ('video' as const)
|
|
@@ -445,7 +452,7 @@ export function MediaCard({
|
|
|
445
452
|
...entries.map((entry, index) => ({
|
|
446
453
|
...entry,
|
|
447
454
|
position: current.length + index + 1,
|
|
448
|
-
uploadId:
|
|
455
|
+
uploadId: randomUUID(),
|
|
449
456
|
})),
|
|
450
457
|
],
|
|
451
458
|
{ shouldDirty: true },
|
|
@@ -475,6 +482,7 @@ export function MediaCard({
|
|
|
475
482
|
picked.map((media) => ({
|
|
476
483
|
source_media_id: media.id,
|
|
477
484
|
alt: media.alt ?? undefined,
|
|
485
|
+
filename: media.filename ?? null,
|
|
478
486
|
media_type: (media.media_type ?? 'image') as MediaType,
|
|
479
487
|
previewUrl: media.small_url ?? media.original_url ?? undefined,
|
|
480
488
|
videoUrl: media.video_url ?? null,
|
|
@@ -484,49 +492,55 @@ export function MediaCard({
|
|
|
484
492
|
[appendMedia],
|
|
485
493
|
)
|
|
486
494
|
|
|
487
|
-
|
|
488
|
-
|
|
495
|
+
/**
|
|
496
|
+
* Takes the rows at `indices` out of the form. Saved rows are only marked
|
|
497
|
+
* for removal — the product save deletes them — so a removal is an unsaved
|
|
498
|
+
* change like any other, undone by Discard. Pre-save uploads just leave.
|
|
499
|
+
*/
|
|
500
|
+
const removeEntries = useCallback(
|
|
501
|
+
(indices: number[]) => {
|
|
489
502
|
const current = form.getValues('media') ?? []
|
|
490
|
-
const
|
|
491
|
-
if (
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
// Persisted entries call the dedicated DELETE endpoint first so we
|
|
500
|
-
// don't drop the form-state entry until the server actually removed it.
|
|
501
|
-
// Pre-save entries (signed_id only, no id) just disappear from form.
|
|
502
|
-
if (entry.id && productId) {
|
|
503
|
-
try {
|
|
504
|
-
await deleteMedia.mutateAsync(entry.id)
|
|
505
|
-
} catch {
|
|
506
|
-
toastManager.add({ type: 'error', title: t('admin.errors.failed_to_delete') })
|
|
507
|
-
return
|
|
508
|
-
}
|
|
503
|
+
const removed = new Set(indices.filter((index) => current[index]))
|
|
504
|
+
if (removed.size === 0) return
|
|
505
|
+
|
|
506
|
+
// A pre-save upload's preview is a Blob URL that Discard can never bring
|
|
507
|
+
// back (it was never in the saved state), so release it now.
|
|
508
|
+
for (const index of removed) {
|
|
509
|
+
const entry = current[index]
|
|
510
|
+
if (!entry.id && entry.previewUrl?.startsWith('blob:'))
|
|
511
|
+
URL.revokeObjectURL(entry.previewUrl)
|
|
509
512
|
}
|
|
510
513
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
514
|
+
const removedIds = [...removed].flatMap((index) => current[index].id ?? [])
|
|
515
|
+
if (removedIds.length > 0) {
|
|
516
|
+
const marked = form.getValues('removed_media_ids') ?? []
|
|
517
|
+
form.setValue('removed_media_ids', [...marked, ...removedIds], { shouldDirty: true })
|
|
518
|
+
}
|
|
515
519
|
|
|
516
|
-
const next = current
|
|
520
|
+
const next = current
|
|
521
|
+
.filter((_, i) => !removed.has(i))
|
|
522
|
+
.map((m, i) => ({ ...m, position: i + 1 }))
|
|
517
523
|
form.setValue('media', next, { shouldDirty: true })
|
|
518
524
|
|
|
519
525
|
// Close or shift the edit sheet so it can't reference a stale index.
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
if (current == null) return current
|
|
524
|
-
if (current === index) return null
|
|
525
|
-
if (current > index) return current - 1
|
|
526
|
-
return current
|
|
526
|
+
setEditingIndex((open) => {
|
|
527
|
+
if (open == null || removed.has(open)) return null
|
|
528
|
+
return open - [...removed].filter((i) => i < open).length
|
|
527
529
|
})
|
|
528
530
|
},
|
|
529
|
-
[form
|
|
531
|
+
[form],
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
const handleDelete = useCallback(
|
|
535
|
+
async (index: number) => {
|
|
536
|
+
const confirmed = await confirm({
|
|
537
|
+
message: t('admin.products.media.delete_confirm'),
|
|
538
|
+
variant: 'destructive',
|
|
539
|
+
confirmLabel: t('admin.actions.remove'),
|
|
540
|
+
})
|
|
541
|
+
if (confirmed) removeEntries([index])
|
|
542
|
+
},
|
|
543
|
+
[confirm, removeEntries, t],
|
|
530
544
|
)
|
|
531
545
|
|
|
532
546
|
// Revoke any remaining blob: previewUrls when the card unmounts (the
|
|
@@ -544,14 +558,74 @@ export function MediaCard({
|
|
|
544
558
|
|
|
545
559
|
const editingEntry = editingIndex !== null ? items[editingIndex] : null
|
|
546
560
|
|
|
561
|
+
// Read against the current rows, so a file removed elsewhere drops out.
|
|
562
|
+
const selectedIndices = sortableIds.flatMap((id, i) => (selected.has(id) ? [i] : []))
|
|
563
|
+
const selecting = selectedIndices.length > 0
|
|
564
|
+
const allSelected = selecting && selectedIndices.length === items.length
|
|
565
|
+
|
|
566
|
+
function toggleSelected(id: string) {
|
|
567
|
+
setSelected((prev) => {
|
|
568
|
+
const next = new Set(prev)
|
|
569
|
+
if (next.has(id)) next.delete(id)
|
|
570
|
+
else next.add(id)
|
|
571
|
+
return next
|
|
572
|
+
})
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function handleBulkDelete() {
|
|
576
|
+
const count = selectedIndices.length
|
|
577
|
+
const confirmed = await confirm({
|
|
578
|
+
message: t('admin.products.media.delete_selected_confirm', { count }),
|
|
579
|
+
variant: 'destructive',
|
|
580
|
+
confirmLabel: t('admin.actions.remove'),
|
|
581
|
+
})
|
|
582
|
+
if (!confirmed) return
|
|
583
|
+
removeEntries(selectedIndices)
|
|
584
|
+
setSelected(new Set())
|
|
585
|
+
}
|
|
586
|
+
|
|
547
587
|
return (
|
|
548
588
|
<>
|
|
549
589
|
<Card className="scroll-mt-[calc(var(--spacing-header-height)+1.5rem)]">
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
590
|
+
{/* While selecting, the bar's checkbox, count and action sit on one
|
|
591
|
+
centred line at the title row's height. */}
|
|
592
|
+
<CardHeader className={selecting ? 'items-center' : undefined}>
|
|
593
|
+
{selecting ? (
|
|
594
|
+
<>
|
|
595
|
+
{/* The card keeps its name while the bar stands in for it. */}
|
|
596
|
+
<CardTitle className="sr-only">{t('admin.pages.products.section_media')}</CardTitle>
|
|
597
|
+
<div className="flex items-center gap-2 font-medium text-base">
|
|
598
|
+
<Checkbox
|
|
599
|
+
checked={allSelected}
|
|
600
|
+
indeterminate={!allSelected}
|
|
601
|
+
onCheckedChange={() =>
|
|
602
|
+
setSelected(allSelected ? new Set() : new Set(sortableIds))
|
|
603
|
+
}
|
|
604
|
+
aria-label={t('admin.products.media.select_all')}
|
|
605
|
+
/>
|
|
606
|
+
{t('admin.products.media.selected_count', { count: selectedIndices.length })}
|
|
607
|
+
</div>
|
|
608
|
+
<CardAction className="flex">
|
|
609
|
+
<Button
|
|
610
|
+
type="button"
|
|
611
|
+
variant="destructive-ghost"
|
|
612
|
+
size="sm"
|
|
613
|
+
// A 28px button in a row sized for the title would push the
|
|
614
|
+
// header taller than its resting height.
|
|
615
|
+
className="-my-[3.5px]"
|
|
616
|
+
onClick={handleBulkDelete}
|
|
617
|
+
>
|
|
618
|
+
<TrashIcon className="size-4" />
|
|
619
|
+
{t('admin.actions.remove')}
|
|
620
|
+
</Button>
|
|
621
|
+
</CardAction>
|
|
622
|
+
</>
|
|
623
|
+
) : (
|
|
624
|
+
<CardTitle>
|
|
625
|
+
<ImageIcon className="size-4" />
|
|
626
|
+
{t('admin.pages.products.section_media')}
|
|
627
|
+
</CardTitle>
|
|
628
|
+
)}
|
|
555
629
|
</CardHeader>
|
|
556
630
|
<CardContent className="flex flex-col gap-4">
|
|
557
631
|
{(items.length > 0 || pending.length > 0) && (
|
|
@@ -566,14 +640,17 @@ export function MediaCard({
|
|
|
566
640
|
downloadUrl={media.downloadUrl}
|
|
567
641
|
alt={media.alt ?? ''}
|
|
568
642
|
mediaType={media.media_type ?? 'image'}
|
|
569
|
-
|
|
643
|
+
selected={selected.has(sortableIds[index])}
|
|
644
|
+
selecting={selecting}
|
|
645
|
+
onToggleSelect={() => toggleSelected(sortableIds[index])}
|
|
570
646
|
onDelete={() => handleDelete(index)}
|
|
647
|
+
onEdit={() => setEditingIndex(index)}
|
|
571
648
|
/>
|
|
572
649
|
))}
|
|
573
650
|
{pending.map((upload) => (
|
|
574
651
|
<div
|
|
575
652
|
key={upload.id}
|
|
576
|
-
className="relative aspect-square overflow-hidden rounded-
|
|
653
|
+
className="relative aspect-square overflow-hidden rounded-xl border border-border bg-muted"
|
|
577
654
|
>
|
|
578
655
|
{upload.file.type.startsWith('video/') ? (
|
|
579
656
|
<video
|
|
@@ -614,7 +691,7 @@ export function MediaCard({
|
|
|
614
691
|
onClick={() => fileInputRef.current?.click()}
|
|
615
692
|
>
|
|
616
693
|
<ImagePlusIcon className="size-8 text-muted-foreground" />
|
|
617
|
-
<p className="text-
|
|
694
|
+
<p className="text-base text-muted-foreground">{t('admin.products.media.drop_hint')}</p>
|
|
618
695
|
<p className="text-xs text-muted-foreground">
|
|
619
696
|
{t('admin.products.media.file_types_hint')}
|
|
620
697
|
</p>
|
|
@@ -652,19 +729,36 @@ export function MediaCard({
|
|
|
652
729
|
</CardContent>
|
|
653
730
|
</Card>
|
|
654
731
|
<AddVideoDialog open={addingVideo} onOpenChange={setAddingVideo} onAdd={handleAddVideo} />
|
|
655
|
-
{/* Not merely closed — the
|
|
732
|
+
{/* Not merely closed — the dialog reads the store context on mount, and
|
|
656
733
|
a panel without one has no library to pick from anyway. */}
|
|
657
734
|
{mediaLibrary && (
|
|
658
|
-
<
|
|
735
|
+
<MediaPickerDialog<Media>
|
|
659
736
|
open={pickingFromLibrary}
|
|
660
737
|
onOpenChange={setPickingFromLibrary}
|
|
661
738
|
queryKey="product-media-library"
|
|
662
|
-
|
|
739
|
+
// Products take video too, which offers the picker's Type filter.
|
|
740
|
+
accept="image/png,image/jpeg,image/webp,image/gif,video/mp4,video/webm,video/quicktime"
|
|
741
|
+
search={async (query, options) =>
|
|
663
742
|
(await mediaLibrary?.list({
|
|
664
743
|
limit: 48,
|
|
665
|
-
...(query
|
|
744
|
+
...mediaPickerListParams(query, options),
|
|
666
745
|
})) ?? { data: [] }
|
|
667
746
|
}
|
|
747
|
+
// Uploads land in the library and arrive picked, ready to add.
|
|
748
|
+
onUpload={
|
|
749
|
+
mediaLibrary.create
|
|
750
|
+
? async (file) => {
|
|
751
|
+
const upload = await fileUpload.mutateAsync(file)
|
|
752
|
+
return (await mediaLibrary.create?.({
|
|
753
|
+
signed_id: upload.signedId,
|
|
754
|
+
alt: file.name,
|
|
755
|
+
// A video row validates its file as video; unset, it
|
|
756
|
+
// defaults to image and the upload is refused.
|
|
757
|
+
media_type: file.type.startsWith('video/') ? 'video' : 'image',
|
|
758
|
+
})) as Media
|
|
759
|
+
}
|
|
760
|
+
: undefined
|
|
761
|
+
}
|
|
668
762
|
onConfirm={handleAddFromLibrary}
|
|
669
763
|
/>
|
|
670
764
|
)}
|
|
@@ -691,8 +785,11 @@ function SortableMediaThumbnail({
|
|
|
691
785
|
alt,
|
|
692
786
|
mediaType,
|
|
693
787
|
downloadUrl,
|
|
694
|
-
|
|
788
|
+
selected,
|
|
789
|
+
selecting,
|
|
790
|
+
onToggleSelect,
|
|
695
791
|
onDelete,
|
|
792
|
+
onEdit,
|
|
696
793
|
}: {
|
|
697
794
|
sortableId: string
|
|
698
795
|
previewUrl: string | null
|
|
@@ -700,8 +797,12 @@ function SortableMediaThumbnail({
|
|
|
700
797
|
mediaType: MediaType
|
|
701
798
|
/** Absent until the row is saved — a pre-save upload has no server URL. */
|
|
702
799
|
downloadUrl?: string | null
|
|
703
|
-
|
|
800
|
+
selected: boolean
|
|
801
|
+
/** Any tile is selected: clicks select rather than edit, and boxes show. */
|
|
802
|
+
selecting: boolean
|
|
803
|
+
onToggleSelect: () => void
|
|
704
804
|
onDelete: () => void
|
|
805
|
+
onEdit: () => void
|
|
705
806
|
}) {
|
|
706
807
|
const { t } = useTranslation()
|
|
707
808
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
@@ -726,16 +827,21 @@ function SortableMediaThumbnail({
|
|
|
726
827
|
data-slot="media-thumbnail"
|
|
727
828
|
role="button"
|
|
728
829
|
tabIndex={0}
|
|
729
|
-
|
|
830
|
+
aria-pressed={selecting ? selected : undefined}
|
|
831
|
+
// While any tile is selected a click adds to the selection, as in
|
|
832
|
+
// a file browser; otherwise it opens the edit sheet.
|
|
833
|
+
onClick={selecting ? onToggleSelect : onEdit}
|
|
730
834
|
onKeyDown={(e) => {
|
|
731
835
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
732
836
|
e.preventDefault()
|
|
733
|
-
|
|
837
|
+
if (selecting) onToggleSelect()
|
|
838
|
+
else onEdit()
|
|
734
839
|
}
|
|
735
840
|
}}
|
|
736
|
-
className={
|
|
737
|
-
|
|
738
|
-
|
|
841
|
+
className={cn(
|
|
842
|
+
'group relative aspect-square cursor-pointer overflow-hidden rounded-xl border border-border-control bg-card outline-none transition-shadow focus-visible:shadow-field-focus',
|
|
843
|
+
isDragging && 'opacity-40 ring-2 ring-primary/40',
|
|
844
|
+
)}
|
|
739
845
|
/>
|
|
740
846
|
}
|
|
741
847
|
>
|
|
@@ -747,14 +853,14 @@ function SortableMediaThumbnail({
|
|
|
747
853
|
muted
|
|
748
854
|
playsInline
|
|
749
855
|
preload="metadata"
|
|
750
|
-
className="pointer-events-none size-full object-
|
|
856
|
+
className="pointer-events-none size-full object-contain"
|
|
751
857
|
/>
|
|
752
858
|
) : previewUrl ? (
|
|
753
859
|
<img
|
|
754
860
|
src={previewUrl}
|
|
755
861
|
alt={alt}
|
|
756
862
|
draggable={false}
|
|
757
|
-
className="pointer-events-none size-full object-
|
|
863
|
+
className="pointer-events-none size-full object-contain"
|
|
758
864
|
/>
|
|
759
865
|
) : (
|
|
760
866
|
<div className="flex size-full items-center justify-center text-muted-foreground">
|
|
@@ -766,8 +872,33 @@ function SortableMediaThumbnail({
|
|
|
766
872
|
</div>
|
|
767
873
|
)}
|
|
768
874
|
|
|
875
|
+
{/* Hover darkens the tile so its checkbox and grip stand out; a picked
|
|
876
|
+
tile is washed out instead, so the picked files read at a glance
|
|
877
|
+
without a ring around each one. */}
|
|
878
|
+
<span
|
|
879
|
+
aria-hidden
|
|
880
|
+
className={cn(
|
|
881
|
+
'pointer-events-none absolute inset-0 transition-colors',
|
|
882
|
+
selected ? 'bg-card/50' : 'bg-black/0 group-hover:bg-black/50',
|
|
883
|
+
)}
|
|
884
|
+
/>
|
|
885
|
+
|
|
886
|
+
<Checkbox
|
|
887
|
+
checked={selected}
|
|
888
|
+
onCheckedChange={onToggleSelect}
|
|
889
|
+
// Keep the click on the box from also opening the sheet.
|
|
890
|
+
onClick={(e) => e.stopPropagation()}
|
|
891
|
+
aria-label={t('admin.products.media.select_item', { name: alt })}
|
|
892
|
+
className={cn(
|
|
893
|
+
'absolute top-2 left-2 z-10 bg-card transition-opacity',
|
|
894
|
+
!selecting &&
|
|
895
|
+
!selected &&
|
|
896
|
+
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100',
|
|
897
|
+
)}
|
|
898
|
+
/>
|
|
899
|
+
|
|
769
900
|
{mediaType !== 'image' && (
|
|
770
|
-
<span className="pointer-events-none absolute
|
|
901
|
+
<span className="pointer-events-none absolute bottom-2 left-2 z-10 flex size-6 items-center justify-center rounded-full bg-black/60 text-white">
|
|
771
902
|
<PlayIcon className="size-3 fill-current" />
|
|
772
903
|
</span>
|
|
773
904
|
)}
|
|
@@ -805,7 +936,7 @@ function SortableMediaThumbnail({
|
|
|
805
936
|
<ContextMenuSeparator />
|
|
806
937
|
<ContextMenuItem variant="destructive" onClick={onDelete}>
|
|
807
938
|
<TrashIcon />
|
|
808
|
-
{t('admin.actions.
|
|
939
|
+
{t('admin.actions.remove')}
|
|
809
940
|
</ContextMenuItem>
|
|
810
941
|
</ContextMenuContent>
|
|
811
942
|
</ContextMenu>
|
|
@@ -869,13 +1000,13 @@ export function SEOCard({ form, product }: FormCardProps & { product?: Product }
|
|
|
869
1000
|
</CardTitle>
|
|
870
1001
|
</CardHeader>
|
|
871
1002
|
<CardContent className="flex flex-col gap-4">
|
|
872
|
-
<
|
|
873
|
-
<p className="text-
|
|
1003
|
+
<Item variant="outline" className="flex-col items-stretch gap-1">
|
|
1004
|
+
<p className="text-base font-medium text-blue-700 truncate">{previewTitle}</p>
|
|
874
1005
|
<p className="text-xs text-green-700 truncate">example.com/products/{previewSlug}</p>
|
|
875
1006
|
{metaDescription && (
|
|
876
1007
|
<p className="text-xs text-muted-foreground line-clamp-2">{metaDescription}</p>
|
|
877
1008
|
)}
|
|
878
|
-
</
|
|
1009
|
+
</Item>
|
|
879
1010
|
|
|
880
1011
|
<Field>
|
|
881
1012
|
<FieldLabel htmlFor="product-slug">{t('admin.fields.slug.label')}</FieldLabel>
|
|
@@ -1278,12 +1409,12 @@ export function TaxCard({ form }: FormCardProps) {
|
|
|
1278
1409
|
)}
|
|
1279
1410
|
|
|
1280
1411
|
{overrides.length > 0 && (
|
|
1281
|
-
<
|
|
1412
|
+
<Item variant="outline" className="flex-col items-stretch gap-1">
|
|
1282
1413
|
<p className="font-medium">{t('admin.products.tax.variant_overrides_heading')}</p>
|
|
1283
|
-
<p className="text-muted-foreground
|
|
1414
|
+
<p className="text-muted-foreground">
|
|
1284
1415
|
{t('admin.products.tax.variant_overrides_description')}
|
|
1285
1416
|
</p>
|
|
1286
|
-
<ul className="mt-
|
|
1417
|
+
<ul className="mt-2 space-y-2">
|
|
1287
1418
|
{overrides.map(({ variant, index }) => {
|
|
1288
1419
|
const categoryName =
|
|
1289
1420
|
taxCategories.find((c) => c.id === variant.tax_category_id)?.name ??
|
|
@@ -1309,7 +1440,7 @@ export function TaxCard({ form }: FormCardProps) {
|
|
|
1309
1440
|
)
|
|
1310
1441
|
})}
|
|
1311
1442
|
</ul>
|
|
1312
|
-
</
|
|
1443
|
+
</Item>
|
|
1313
1444
|
)}
|
|
1314
1445
|
</CardContent>
|
|
1315
1446
|
</Card>
|
|
@@ -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
|
+
})
|
|
@@ -110,6 +110,7 @@ export function mediaToFormValues(media: PanelMedia, index: number, formKey = 'p
|
|
|
110
110
|
// on a video row resolves to its poster, so none of them work here.
|
|
111
111
|
videoUrl: media.video_url,
|
|
112
112
|
downloadUrl: media.download_url,
|
|
113
|
+
filename: media.filename ?? null,
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
|
|
@@ -195,6 +196,25 @@ export function withExtensionRowValues(
|
|
|
195
196
|
}
|
|
196
197
|
}
|
|
197
198
|
|
|
199
|
+
// The API replaces each of these lists with what a save sends.
|
|
200
|
+
const LIST_FIELDS = ['category_ids', 'collection_ids', 'tags'] as const
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The save payload without the category, collection and tag lists the merchant
|
|
204
|
+
* left unchanged, so a save keeps what something else added since the page loaded.
|
|
205
|
+
*/
|
|
206
|
+
export function omitUnchangedListFields(
|
|
207
|
+
payload: Record<string, unknown>,
|
|
208
|
+
dirtyFields: Partial<Record<(typeof LIST_FIELDS)[number], unknown>>,
|
|
209
|
+
): Record<string, unknown> {
|
|
210
|
+
const trimmed = { ...payload }
|
|
211
|
+
for (const field of LIST_FIELDS) {
|
|
212
|
+
const dirty = dirtyFields[field]
|
|
213
|
+
if (!(Array.isArray(dirty) ? dirty.some(Boolean) : dirty)) delete trimmed[field]
|
|
214
|
+
}
|
|
215
|
+
return trimmed
|
|
216
|
+
}
|
|
217
|
+
|
|
198
218
|
// Strip UI-only fields (stock_location_name) and undefined entries so the
|
|
199
219
|
// PATCH body matches the Admin API VariantUpdateParams shape exactly. The
|
|
200
220
|
// Spree::Product#variants= setter reconciles by id, creates new entries,
|
|
@@ -129,6 +129,8 @@ export const mediaFormSchema = z
|
|
|
129
129
|
focal_point_y: z.number().min(0).max(1).nullable().optional(),
|
|
130
130
|
// UI-only — strip at submit.
|
|
131
131
|
previewUrl: z.string().optional(),
|
|
132
|
+
// The file's name, to tell rows apart where alt text is blank. UI-only.
|
|
133
|
+
filename: z.string().nullable().optional(),
|
|
132
134
|
// Larger rendition for the edit sheet; previewUrl is grid-sized and
|
|
133
135
|
// visibly pixelates when blown up. Absent before save, where the blob URL
|
|
134
136
|
// in previewUrl is full resolution anyway.
|
|
@@ -225,6 +227,11 @@ export const productFormSchema = z.object({
|
|
|
225
227
|
// new uploads ride the same product PATCH.
|
|
226
228
|
media: z.array(mediaFormSchema).optional(),
|
|
227
229
|
|
|
230
|
+
// Saved media the merchant removed. The product save leaves omitted media
|
|
231
|
+
// alone, so these go through the media DELETE endpoint once the save
|
|
232
|
+
// succeeds — removal stays an unsaved change, undone by Discard.
|
|
233
|
+
removed_media_ids: z.array(z.string()).optional(),
|
|
234
|
+
|
|
228
235
|
// Downloadable files, staged pre-save on the new product page and shipped in
|
|
229
236
|
// the product POST. Edit uses the live API-driven card instead, so this stays
|
|
230
237
|
// empty there.
|
|
@@ -274,6 +281,7 @@ export function newProductFormDefaults(): ProductFormValues {
|
|
|
274
281
|
],
|
|
275
282
|
custom_fields: [],
|
|
276
283
|
media: [],
|
|
284
|
+
removed_media_ids: [],
|
|
277
285
|
digital_assets: [],
|
|
278
286
|
product_publications: [],
|
|
279
287
|
}
|
|
@@ -33,6 +33,8 @@ export interface PanelMedia {
|
|
|
33
33
|
large_url?: string | null
|
|
34
34
|
xlarge_url?: string | null
|
|
35
35
|
download_url?: string | null
|
|
36
|
+
/** The file's own name; absent on a panel whose serializer omits it. */
|
|
37
|
+
filename?: string | null
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
export interface PanelVariant {
|