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:
@@ -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": {
|
||||||
|
|||||||
+26
-33
@@ -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>
|
||||||
|
|||||||
+5
-3
@@ -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
|
||||||
|
|||||||
+20
-28
@@ -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,26 +39,11 @@ 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,
|
PREFIX
|
||||||
allow_backorder,
|
)
|
||||||
manage_inventory,
|
|
||||||
created_at,
|
|
||||||
updated_at,
|
|
||||||
} = useQueryParams(
|
|
||||||
[
|
|
||||||
"q",
|
|
||||||
"order",
|
|
||||||
"offset",
|
|
||||||
"manage_inventory",
|
|
||||||
"allow_backorder",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
],
|
|
||||||
PREFIX
|
|
||||||
)
|
|
||||||
|
|
||||||
const columns = useColumns(product)
|
const columns = useColumns(product)
|
||||||
const filters = useFilters()
|
const filters = useFilters()
|
||||||
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user