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 14:21:27 +00:00
committed by GitHub
parent 6d6856552a
commit d5fc46b222
5 changed files with 58 additions and 66 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/dashboard": patch
---
feat(dashboard): variant images list thumbnail + refactor form state management
@@ -530,8 +530,8 @@
"successToast": "Media was successfully updated.", "successToast": "Media was successfully updated.",
"variantImages": "Variant images", "variantImages": "Variant images",
"showAvailableImages": "Show available images", "showAvailableImages": "Show available images",
"availableImages": "Available images", "availableImages": "Select images",
"selectToAdd": "Select to add to variant", "selectToAdd": "Add product images to the variant. To add new images, add them to the product first.",
"removeSelected": "Remove Selected" "removeSelected": "Remove Selected"
}, },
"variantMedia": { "variantMedia": {
@@ -51,24 +51,9 @@ export const EditProductVariantMediaForm = ({
(image) => !image.variants?.some((variant) => variant.id === variant.id) (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 [selection, setSelection] = useState<Record<string, true>>({})
const [isSidebarOpen, setIsSidebarOpen] = useState(false) const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const availableImages = unassociatedImages.filter(
(image) => !variantImages[image.id!]
)
const form = useForm<MediaSchemaType>({ const form = useForm<MediaSchemaType>({
defaultValues: { defaultValues: {
image_ids: allVariantImages.map((image) => image.id!), image_ids: allVariantImages.map((image) => image.id!),
@@ -77,6 +62,11 @@ export const EditProductVariantMediaForm = ({
resolver: zodResolver(MediaSchema), resolver: zodResolver(MediaSchema),
}) })
const formImageIds = form.watch("image_ids")
const availableImages = unassociatedImages.filter(
(image) => !formImageIds.includes(image.id!)
)
const { mutateAsync: updateVariant } = useUpdateProductVariant( const { mutateAsync: updateVariant } = useUpdateProductVariant(
variant.product_id!, variant.product_id!,
variant.id! variant.id!
@@ -88,10 +78,8 @@ export const EditProductVariantMediaForm = ({
) )
const handleSubmit = form.handleSubmit(async (data) => { const handleSubmit = form.handleSubmit(async (data) => {
const currentVariantImageIds = data.image_ids const currentVariantImageIds = allVariantImages.map((image) => image.id!)
const newVariantImageIds = Object.keys(variantImages).filter( const newVariantImageIds = data.image_ids
(id) => variantImages[id]
)
const imagesToAdd = newVariantImageIds.filter( const imagesToAdd = newVariantImageIds.filter(
(id) => !currentVariantImageIds.includes(id) (id) => !currentVariantImageIds.includes(id)
@@ -134,10 +122,11 @@ export const EditProductVariantMediaForm = ({
}) })
const handleAddImageToVariant = (imageId: string) => { const handleAddImageToVariant = (imageId: string) => {
setVariantImages((prev) => ({ const currentImageIds = form.getValues("image_ids")
...prev, form.setValue("image_ids", [...currentImageIds, imageId], {
[imageId]: true, shouldDirty: true,
})) shouldTouch: true,
})
} }
const handleCheckedChange = useCallback( const handleCheckedChange = useCallback(
@@ -163,7 +152,10 @@ export const EditProductVariantMediaForm = ({
const selectedImage = allProductImages.find((image) => image.id === ids[0]) const selectedImage = allProductImages.find((image) => image.id === ids[0])
if (selectedImage) { 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 return
} }
setVariantImages((prev) => { const currentImageIds = form.getValues("image_ids")
const newVariantImages = { ...prev } const newImageIds = currentImageIds.filter(
selectedIds.forEach((id) => { (id) => !selectedIds.includes(id)
delete newVariantImages[id] )
}) form.setValue("image_ids", newImageIds, {
return newVariantImages shouldDirty: true,
shouldTouch: true,
}) })
setSelection({}) setSelection({})
@@ -218,7 +211,7 @@ export const EditProductVariantMediaForm = ({
</div> </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"> <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 {allProductImages
.filter((image) => variantImages[image.id!]) .filter((image) => formImageIds.includes(image.id!))
.map((image) => ( .map((image) => (
<MediaGridItem <MediaGridItem
key={image.id} key={image.id}
@@ -238,7 +231,7 @@ export const EditProductVariantMediaForm = ({
<h3 className="ui-fg-base "> <h3 className="ui-fg-base ">
{t("products.media.availableImages")} {t("products.media.availableImages")}
</h3> </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")} {t("products.media.selectToAdd")}
</p> </p>
</div> </div>
@@ -272,7 +265,7 @@ export const EditProductVariantMediaForm = ({
<h3 className="ui-fg-base text-sm font-medium"> <h3 className="ui-fg-base text-sm font-medium">
{t("products.media.availableImages")} {t("products.media.availableImages")}
</h3> </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")} {t("products.media.selectToAdd")}
</p> </p>
</div> </div>
@@ -12,13 +12,15 @@ type ProductMediaVariantsReponse = HttpTypes.AdminProductVariant & {
export const ProductVariantMedia = () => { export const ProductVariantMedia = () => {
const { id, variant_id } = useParams() const { id, variant_id } = useParams()
const { variant, isLoading, isError, error } = useProductVariant( const { variant, isFetching, isError, error } = useProductVariant(
id!, id!,
variant_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) { if (isError) {
throw error throw error
@@ -18,7 +18,6 @@ import { useTranslation } from "react-i18next"
import { CellContext } from "@tanstack/react-table" import { CellContext } from "@tanstack/react-table"
import { useNavigate, useSearchParams } from "react-router-dom" import { useNavigate, useSearchParams } from "react-router-dom"
import { DataTable } from "../../../../../components/data-table" 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 { useDataTableDateFilters } from "../../../../../components/data-table/helpers/general/use-data-table-date-filters"
import { import {
useDeleteVariantLazy, useDeleteVariantLazy,
@@ -26,6 +25,7 @@ import {
} from "../../../../../hooks/api/products" } from "../../../../../hooks/api/products"
import { useQueryParams } from "../../../../../hooks/use-query-params" import { useQueryParams } from "../../../../../hooks/use-query-params"
import { PRODUCT_VARIANT_IDS_KEY } from "../../../common/constants" import { PRODUCT_VARIANT_IDS_KEY } from "../../../common/constants"
import { Thumbnail } from "../../../../../components/common/thumbnail"
type ProductVariantSectionProps = { type ProductVariantSectionProps = {
product: HttpTypes.AdminProduct product: HttpTypes.AdminProduct
@@ -39,24 +39,9 @@ export const ProductVariantSection = ({
}: ProductVariantSectionProps) => { }: ProductVariantSectionProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { const { q, order, offset, allow_backorder, manage_inventory } =
q, useQueryParams(
order, ["q", "order", "offset", "manage_inventory", "allow_backorder"],
offset,
allow_backorder,
manage_inventory,
created_at,
updated_at,
} = useQueryParams(
[
"q",
"order",
"offset",
"manage_inventory",
"allow_backorder",
"created_at",
"updated_at",
],
PREFIX PREFIX
) )
@@ -77,10 +62,8 @@ export const ProductVariantSection = ({
manage_inventory: manage_inventory manage_inventory: manage_inventory
? JSON.parse(manage_inventory) ? JSON.parse(manage_inventory)
: undefined, : undefined,
created_at: created_at ? JSON.parse(created_at) : undefined,
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
fields: 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, placeholderData: keepPreviousData,
@@ -163,8 +146,6 @@ const useColumns = (product: HttpTypes.AdminProduct) => {
return filtered return filtered
}, [searchParams]) }, [searchParams])
const dateColumns = useDataTableDateColumns<HttpTypes.AdminProductVariant>()
const handleDelete = useCallback( const handleDelete = useCallback(
async (id: string, title: string) => { async (id: string, title: string) => {
const res = await prompt({ const res = await prompt({
@@ -349,6 +330,18 @@ const useColumns = (product: HttpTypes.AdminProduct) => {
return useMemo(() => { return useMemo(() => {
return [ 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", { columnHelper.accessor("title", {
header: t("fields.title"), header: t("fields.title"),
enableSorting: true, enableSorting: true,
@@ -387,12 +380,11 @@ const useColumns = (product: HttpTypes.AdminProduct) => {
}, },
maxSize: 250, maxSize: 250,
}), }),
...dateColumns,
columnHelper.action({ columnHelper.action({
actions: getActions, actions: getActions,
}), }),
] ]
}, [t, optionColumns, dateColumns, getActions, getInventory]) }, [t, optionColumns, getActions, getInventory])
} }
const filterHelper = const filterHelper =