feat(dashboard): variant images list thumbnail + refactor form state management (#13905)

## Summary

**What** — What changes are introduced in this PR?

- show thumbnail on the product variant list
- refactor variant image editor state management
- await revalidation before rendering form

**Testing** — How have these changes been tested, or how can the reviewer test the feature?

Manual testing

---

## Checklist

Please ensure the following before requesting a review:

- [x] I have added a **changeset** for this PR
    - Every non-breaking change should be marked as a **patch**
    - To add a changeset, run `yarn changeset` and follow the prompts
- [ ] The changes are covered by relevant **tests**
- [x] I have verified the code works as intended locally
- [ ] I have linked the related issue(s) if applicable
This commit is contained in:
Frane Polić
2025-10-30 15:21:27 +01:00
committed by GitHub
parent 6d6856552a
commit d5fc46b222
5 changed files with 58 additions and 66 deletions

View File

@@ -530,8 +530,8 @@
"successToast": "Media was successfully updated.",
"variantImages": "Variant images",
"showAvailableImages": "Show available images",
"availableImages": "Available images",
"selectToAdd": "Select to add to variant",
"availableImages": "Select images",
"selectToAdd": "Add product images to the variant. To add new images, add them to the product first.",
"removeSelected": "Remove Selected"
},
"variantMedia": {

View File

@@ -51,24 +51,9 @@ export const EditProductVariantMediaForm = ({
(image) => !image.variants?.some((variant) => variant.id === variant.id)
)
const [variantImages, setVariantImages] = useState<Record<string, true>>(() =>
allVariantImages.reduce(
// @eslint-disable-next-line
(acc: Record<string, true>, image) => {
acc[image.id] = true
return acc
},
{}
)
)
const [selection, setSelection] = useState<Record<string, true>>({})
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const availableImages = unassociatedImages.filter(
(image) => !variantImages[image.id!]
)
const form = useForm<MediaSchemaType>({
defaultValues: {
image_ids: allVariantImages.map((image) => image.id!),
@@ -77,6 +62,11 @@ export const EditProductVariantMediaForm = ({
resolver: zodResolver(MediaSchema),
})
const formImageIds = form.watch("image_ids")
const availableImages = unassociatedImages.filter(
(image) => !formImageIds.includes(image.id!)
)
const { mutateAsync: updateVariant } = useUpdateProductVariant(
variant.product_id!,
variant.id!
@@ -88,10 +78,8 @@ export const EditProductVariantMediaForm = ({
)
const handleSubmit = form.handleSubmit(async (data) => {
const currentVariantImageIds = data.image_ids
const newVariantImageIds = Object.keys(variantImages).filter(
(id) => variantImages[id]
)
const currentVariantImageIds = allVariantImages.map((image) => image.id!)
const newVariantImageIds = data.image_ids
const imagesToAdd = newVariantImageIds.filter(
(id) => !currentVariantImageIds.includes(id)
@@ -134,10 +122,11 @@ export const EditProductVariantMediaForm = ({
})
const handleAddImageToVariant = (imageId: string) => {
setVariantImages((prev) => ({
...prev,
[imageId]: true,
}))
const currentImageIds = form.getValues("image_ids")
form.setValue("image_ids", [...currentImageIds, imageId], {
shouldDirty: true,
shouldTouch: true,
})
}
const handleCheckedChange = useCallback(
@@ -163,7 +152,10 @@ export const EditProductVariantMediaForm = ({
const selectedImage = allProductImages.find((image) => image.id === ids[0])
if (selectedImage) {
form.setValue("thumbnail", selectedImage.url)
form.setValue("thumbnail", selectedImage.url, {
shouldDirty: selectedImage.url !== variant.thumbnail,
shouldTouch: selectedImage.url !== variant.thumbnail,
})
}
}
@@ -173,12 +165,13 @@ export const EditProductVariantMediaForm = ({
return
}
setVariantImages((prev) => {
const newVariantImages = { ...prev }
selectedIds.forEach((id) => {
delete newVariantImages[id]
})
return newVariantImages
const currentImageIds = form.getValues("image_ids")
const newImageIds = currentImageIds.filter(
(id) => !selectedIds.includes(id)
)
form.setValue("image_ids", newImageIds, {
shouldDirty: true,
shouldTouch: true,
})
setSelection({})
@@ -218,7 +211,7 @@ export const EditProductVariantMediaForm = ({
</div>
<div className="grid h-fit auto-rows-auto grid-cols-2 gap-4 p-4 sm:grid-cols-3 lg:grid-cols-6 lg:gap-6 lg:p-6">
{allProductImages
.filter((image) => variantImages[image.id!])
.filter((image) => formImageIds.includes(image.id!))
.map((image) => (
<MediaGridItem
key={image.id}
@@ -238,7 +231,7 @@ export const EditProductVariantMediaForm = ({
<h3 className="ui-fg-base ">
{t("products.media.availableImages")}
</h3>
<p className="text-ui-fg-dimmed mt-1 text-sm">
<p className="text-ui-fg-muted mt-1 text-sm">
{t("products.media.selectToAdd")}
</p>
</div>
@@ -272,7 +265,7 @@ export const EditProductVariantMediaForm = ({
<h3 className="ui-fg-base text-sm font-medium">
{t("products.media.availableImages")}
</h3>
<p className="ui-fg-muted mt-1 text-xs">
<p className="text-ui-fg-muted mt-1 pr-2 text-xs">
{t("products.media.selectToAdd")}
</p>
</div>

View File

@@ -12,13 +12,15 @@ type ProductMediaVariantsReponse = HttpTypes.AdminProductVariant & {
export const ProductVariantMedia = () => {
const { id, variant_id } = useParams()
const { variant, isLoading, isError, error } = useProductVariant(
const { variant, isFetching, isError, error } = useProductVariant(
id!,
variant_id!,
{ fields: "*product,*product.images,*images,+images.variants.id" }
{
fields: "*product,*product.images,*images,+images.variants.id",
}
)
const ready = !isLoading && variant
const ready = !isFetching && variant
if (isError) {
throw error

View File

@@ -18,7 +18,6 @@ import { useTranslation } from "react-i18next"
import { CellContext } from "@tanstack/react-table"
import { useNavigate, useSearchParams } from "react-router-dom"
import { DataTable } from "../../../../../components/data-table"
import { useDataTableDateColumns } from "../../../../../components/data-table/helpers/general/use-data-table-date-columns"
import { useDataTableDateFilters } from "../../../../../components/data-table/helpers/general/use-data-table-date-filters"
import {
useDeleteVariantLazy,
@@ -26,6 +25,7 @@ import {
} from "../../../../../hooks/api/products"
import { useQueryParams } from "../../../../../hooks/use-query-params"
import { PRODUCT_VARIANT_IDS_KEY } from "../../../common/constants"
import { Thumbnail } from "../../../../../components/common/thumbnail"
type ProductVariantSectionProps = {
product: HttpTypes.AdminProduct
@@ -39,26 +39,11 @@ export const ProductVariantSection = ({
}: ProductVariantSectionProps) => {
const { t } = useTranslation()
const {
q,
order,
offset,
allow_backorder,
manage_inventory,
created_at,
updated_at,
} = useQueryParams(
[
"q",
"order",
"offset",
"manage_inventory",
"allow_backorder",
"created_at",
"updated_at",
],
PREFIX
)
const { q, order, offset, allow_backorder, manage_inventory } =
useQueryParams(
["q", "order", "offset", "manage_inventory", "allow_backorder"],
PREFIX
)
const columns = useColumns(product)
const filters = useFilters()
@@ -77,10 +62,8 @@ export const ProductVariantSection = ({
manage_inventory: manage_inventory
? JSON.parse(manage_inventory)
: undefined,
created_at: created_at ? JSON.parse(created_at) : undefined,
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
fields:
"title,sku,*options,created_at,updated_at,*inventory_items.inventory.location_levels,inventory_quantity,manage_inventory",
"title,sku,thumbnail,*options,created_at,*inventory_items.inventory.location_levels,inventory_quantity,manage_inventory",
},
{
placeholderData: keepPreviousData,
@@ -163,8 +146,6 @@ const useColumns = (product: HttpTypes.AdminProduct) => {
return filtered
}, [searchParams])
const dateColumns = useDataTableDateColumns<HttpTypes.AdminProductVariant>()
const handleDelete = useCallback(
async (id: string, title: string) => {
const res = await prompt({
@@ -349,6 +330,18 @@ const useColumns = (product: HttpTypes.AdminProduct) => {
return useMemo(() => {
return [
columnHelper.accessor("thumbnail", {
header: "",
headerAlign: "center",
maxSize: 72,
cell: ({ row }) => {
return (
<div className="flex items-center pl-[1px]">
<Thumbnail src={row.original.thumbnail} />
</div>
)
},
}),
columnHelper.accessor("title", {
header: t("fields.title"),
enableSorting: true,
@@ -387,12 +380,11 @@ const useColumns = (product: HttpTypes.AdminProduct) => {
},
maxSize: 250,
}),
...dateColumns,
columnHelper.action({
actions: getActions,
}),
]
}, [t, optionColumns, dateColumns, getActions, getInventory])
}, [t, optionColumns, getActions, getInventory])
}
const filterHelper =