feat(core-flows,product,types): scoped variant images (#13623)
* wip(product): variant images * fix: return type * wip: repo and list approach * fix: redo repo method, make test pass * fix: change getVariantImages impl * feat: update test * feat: API and core flows layer * wip: integration spec * fix: deterministic test * chore: refactor and simplify, cleanup, remove repo method * wip: batch add all images to all vairants * fix: remove, expand testing * refactor: pass variants instead of refetch * chore: expand integration test * feat: test multi assign route * fix: remove `/admin/products/:id/variants/images` route * feat: batch images to variant endpoint * fix: length assertion * feat: variant thumbnail * fix: send variant thumbnail by default * fix: product export test assertion * fix: test * feat: variant thumbnail on line item * fix: add missing list and count method, update types * feat: optimise variant images lookups * feat: thumbnail management in core flows * fix: typos, type, build * feat: cascade delete to pivot table, rm unused unused fields * feat(dashboard): variant images management UI (#13670) * wip(dashboard): setup variant media form * wip: cleanup table and images, wip check handler * feat: proper sidebar functionallity * fefat: add js-sdk and hooks * feat: allow only one selection * wip: lazy load variants in the table * feat: new variants management for images on product details * chore: refactor * wip: variant details page work * fix: cleanup media section, fix issues and types * feat: correct scoped images, cleanup in edit modal * feat: js sdk and hooks, filter out product images on variant details, labels, add API call and wrap UI * chore: cleanup * refacto: rename route * feat: thumbnail functionallity * fix: refresh checked after revalidation load * fix: rm unused, refactor type * Create thirty-clocks-refuse.md * feat: new add remove variant media layout * feat: new image add UX --------- Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> * fix: table name in migration * chore: update changesets --------- Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
+1
@@ -0,0 +1 @@
|
||||
export { VariantMediaSection } from "./variant-media-section"
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { Container, Heading, Text, Tooltip } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { PencilSquare, ThumbnailBadge } from "@medusajs/icons"
|
||||
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
|
||||
type VariantMediaSectionProps = {
|
||||
variant: HttpTypes.AdminProductVariant
|
||||
}
|
||||
|
||||
export const VariantMediaSection = ({ variant }: VariantMediaSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// show only variant scoped images
|
||||
const media = (variant.images || []).filter((image) =>
|
||||
image.variants?.some((variant) => variant.id === variant.id)
|
||||
)
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading level="h2">{t("products.media.label")}</Heading>
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.editImages"),
|
||||
to: "media",
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{media.length > 0 ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-4 px-6 py-4">
|
||||
{media.map((i) => {
|
||||
return (
|
||||
<div
|
||||
className="shadow-elevation-card-rest hover:shadow-elevation-card-hover transition-fg group relative aspect-square size-full overflow-hidden rounded-[8px]"
|
||||
key={i.id}
|
||||
>
|
||||
{i.url === variant.thumbnail && (
|
||||
<div className="absolute left-2 top-2">
|
||||
<Tooltip content={t("products.media.thumbnailTooltip")}>
|
||||
<ThumbnailBadge />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<img src={i.url} className="size-full object-cover" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-y-4 pb-8 pt-6">
|
||||
<div className="flex flex-col items-center">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
weight="plus"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
{t("products.media.emptyState.header")}
|
||||
</Text>
|
||||
<Text size="small" className="text-ui-fg-muted">
|
||||
{t("products.media.emptyState.description")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export const VARIANT_DETAIL_FIELDS =
|
||||
"*inventory_items,*inventory_items.inventory,*inventory_items.inventory.location_levels,*options,*options.option,*prices,*prices.price_rules"
|
||||
"*inventory_items,*inventory_items.inventory,*inventory_items.inventory.location_levels,*options,*options.option,*prices,*prices.price_rules,+images.id,+images.url,+images.variants.id"
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ import {
|
||||
InventorySectionPlaceholder,
|
||||
VariantInventorySection,
|
||||
} from "./components/variant-inventory-section"
|
||||
import { VariantMediaSection } from "./components/variant-media-section"
|
||||
import { VariantPricesSection } from "./components/variant-prices-section"
|
||||
import { VARIANT_DETAIL_FIELDS } from "./constants"
|
||||
import { variantLoader } from "./loader"
|
||||
@@ -61,6 +62,7 @@ export const ProductVariantDetail = () => {
|
||||
>
|
||||
<TwoColumnPage.Main>
|
||||
<VariantGeneralSection variant={variant} />
|
||||
<VariantMediaSection variant={variant} />
|
||||
{!variant.manage_inventory ? (
|
||||
<InventorySectionPlaceholder />
|
||||
) : (
|
||||
|
||||
+441
@@ -0,0 +1,441 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Plus, ThumbnailBadge } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Button, Checkbox, clx, CommandBar, toast, Tooltip } from "@medusajs/ui"
|
||||
import { Fragment, useCallback, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/modals"
|
||||
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
|
||||
import {
|
||||
useBatchVariantImages,
|
||||
useUpdateProductVariant,
|
||||
} from "../../../../../hooks/api/products"
|
||||
|
||||
/**
|
||||
* Schema
|
||||
*/
|
||||
const MediaSchema = z.object({
|
||||
image_ids: z.array(z.string()),
|
||||
thumbnail: z.string().nullable(),
|
||||
})
|
||||
|
||||
type MediaSchemaType = z.infer<typeof MediaSchema>
|
||||
|
||||
/**
|
||||
* Prop types
|
||||
*/
|
||||
type ProductVariantMediaViewProps = {
|
||||
variant: HttpTypes.AdminProductVariant & {
|
||||
images: HttpTypes.AdminProductImage[]
|
||||
}
|
||||
}
|
||||
|
||||
export const EditProductVariantMediaForm = ({
|
||||
variant,
|
||||
}: ProductVariantMediaViewProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const allProductImages = variant.product?.images || []
|
||||
const allVariantImages = (variant.images || []).filter((image) =>
|
||||
image.variants?.some((variant) => variant.id === variant.id)
|
||||
)
|
||||
|
||||
const unassociatedImages = allProductImages.filter(
|
||||
(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!),
|
||||
thumbnail: variant.thumbnail,
|
||||
},
|
||||
resolver: zodResolver(MediaSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync: updateVariant } = useUpdateProductVariant(
|
||||
variant.product_id!,
|
||||
variant.id!
|
||||
)
|
||||
|
||||
const { mutateAsync, isPending } = useBatchVariantImages(
|
||||
variant.product_id!,
|
||||
variant.id!
|
||||
)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
const currentVariantImageIds = data.image_ids
|
||||
const newVariantImageIds = Object.keys(variantImages).filter(
|
||||
(id) => variantImages[id]
|
||||
)
|
||||
|
||||
const imagesToAdd = newVariantImageIds.filter(
|
||||
(id) => !currentVariantImageIds.includes(id)
|
||||
)
|
||||
const imagesToRemove = currentVariantImageIds.filter(
|
||||
(id) => !newVariantImageIds.includes(id)
|
||||
)
|
||||
|
||||
if (data.thumbnail !== variant.thumbnail) {
|
||||
let thumbnail = data.thumbnail
|
||||
if (
|
||||
thumbnail &&
|
||||
![...currentVariantImageIds, ...newVariantImageIds].includes(thumbnail)
|
||||
) {
|
||||
thumbnail = null
|
||||
}
|
||||
updateVariant({
|
||||
thumbnail: data.thumbnail,
|
||||
}).catch((error) => {
|
||||
toast.error(error.message)
|
||||
})
|
||||
}
|
||||
|
||||
// Update variant images
|
||||
await mutateAsync(
|
||||
{
|
||||
add: imagesToAdd,
|
||||
remove: imagesToRemove,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("products.media.successToast"))
|
||||
handleSuccess()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const handleAddImageToVariant = (imageId: string) => {
|
||||
setVariantImages((prev) => ({
|
||||
...prev,
|
||||
[imageId]: true,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleCheckedChange = useCallback(
|
||||
(id: string) => {
|
||||
return (val: boolean) => {
|
||||
if (!val) {
|
||||
const { [id]: _, ...rest } = selection
|
||||
setSelection(rest)
|
||||
} else {
|
||||
setSelection((prev) => ({ ...prev, [id]: true }))
|
||||
}
|
||||
}
|
||||
},
|
||||
[selection]
|
||||
)
|
||||
|
||||
const handlePromoteToThumbnail = () => {
|
||||
const ids = Object.keys(selection)
|
||||
|
||||
if (!ids.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedImage = allProductImages.find((image) => image.id === ids[0])
|
||||
if (selectedImage) {
|
||||
form.setValue("thumbnail", selectedImage.url)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveSelectedImages = () => {
|
||||
const selectedIds = Object.keys(selection)
|
||||
if (selectedIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setVariantImages((prev) => {
|
||||
const newVariantImages = { ...prev }
|
||||
selectedIds.forEach((id) => {
|
||||
delete newVariantImages[id]
|
||||
})
|
||||
return newVariantImages
|
||||
})
|
||||
|
||||
setSelection({})
|
||||
}
|
||||
|
||||
const selectedImageThumbnail = form.watch("thumbnail")
|
||||
|
||||
const isSelectedImageThumbnail =
|
||||
variant.thumbnail &&
|
||||
Object.keys(selection).length === 1 &&
|
||||
selectedImageThumbnail ===
|
||||
variant.images.find((image) => image.id === Object.keys(selection)[0])
|
||||
?.url
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form blockSearchParams form={form}>
|
||||
<KeyboundForm
|
||||
className="flex size-full flex-col overflow-hidden"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<RouteFocusModal.Header />
|
||||
<RouteFocusModal.Body className="flex flex-col overflow-hidden">
|
||||
<div className="relative flex size-full">
|
||||
<div className="bg-ui-bg-subtle flex-1 overflow-auto">
|
||||
<div className="flex items-center justify-between p-4 lg:hidden">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("products.media.variantImages")}
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
>
|
||||
{t("products.media.showAvailableImages")}
|
||||
</Button>
|
||||
</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!])
|
||||
.map((image) => (
|
||||
<MediaGridItem
|
||||
key={image.id}
|
||||
media={image}
|
||||
checked={!!selection[image.id!]}
|
||||
onCheckedChange={handleCheckedChange(image.id!)}
|
||||
isThumbnail={image.url === form.watch("thumbnail")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar - always visible */}
|
||||
<div className="border-ui-border-base bg-ui-bg-base hidden w-80 border-l lg:block">
|
||||
<div className="border-ui-border-base border-b p-4">
|
||||
<div>
|
||||
<h3 className="ui-fg-base ">
|
||||
{t("products.media.availableImages")}
|
||||
</h3>
|
||||
<p className="text-ui-fg-dimmed mt-1 text-sm">
|
||||
{t("products.media.selectToAdd")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[calc(100vh-200px)] overflow-auto">
|
||||
<div className="grid grid-cols-2 gap-4 p-4">
|
||||
{availableImages.map((image) => (
|
||||
<UnassociatedImageItem
|
||||
key={image.id}
|
||||
media={image}
|
||||
onAdd={() => handleAddImageToVariant(image.id!)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar - overlay */}
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/50 lg:hidden"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-ui-bg-base border-ui-border-base absolute right-0 top-0 h-full w-80 border-l"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="border-ui-border-base border-b p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="ui-fg-base text-sm font-medium">
|
||||
{t("products.media.availableImages")}
|
||||
</h3>
|
||||
<p className="ui-fg-muted mt-1 text-xs">
|
||||
{t("products.media.selectToAdd")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="small"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[calc(100vh-200px)] overflow-auto">
|
||||
<div className="grid grid-cols-2 gap-4 p-4">
|
||||
{availableImages.map((image) => (
|
||||
<UnassociatedImageItem
|
||||
key={image.id}
|
||||
media={image}
|
||||
onAdd={() => handleAddImageToVariant(image.id!)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</RouteFocusModal.Body>
|
||||
<CommandBar open={Object.keys(selection).length > 0}>
|
||||
<CommandBar.Bar>
|
||||
<CommandBar.Value>
|
||||
{t("general.countSelected", {
|
||||
count: Object.keys(selection).length,
|
||||
})}
|
||||
</CommandBar.Value>
|
||||
<CommandBar.Seperator />
|
||||
{Object.keys(selection).length === 1 &&
|
||||
!isSelectedImageThumbnail && (
|
||||
<Fragment>
|
||||
<CommandBar.Command
|
||||
action={handlePromoteToThumbnail}
|
||||
label={t("products.media.makeThumbnail")}
|
||||
shortcut="t"
|
||||
/>
|
||||
<CommandBar.Seperator />
|
||||
</Fragment>
|
||||
)}
|
||||
<CommandBar.Command
|
||||
action={handleRemoveSelectedImages}
|
||||
label={t("products.media.removeSelected")}
|
||||
shortcut="r"
|
||||
/>
|
||||
</CommandBar.Bar>
|
||||
</CommandBar>
|
||||
<RouteFocusModal.Footer>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button size="small" type="submit" isLoading={isPending}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteFocusModal.Footer>
|
||||
</KeyboundForm>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
|
||||
/* ******************* * MEDIA VIEW ******************* */
|
||||
|
||||
interface MediaView {
|
||||
id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface MediaGridItemProps {
|
||||
media: MediaView
|
||||
checked: boolean
|
||||
onCheckedChange: (value: boolean) => void
|
||||
isThumbnail: boolean
|
||||
}
|
||||
|
||||
const MediaGridItem = ({
|
||||
media,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
isThumbnail,
|
||||
}: MediaGridItemProps) => {
|
||||
const handleToggle = useCallback(
|
||||
(value: boolean) => {
|
||||
onCheckedChange(value)
|
||||
},
|
||||
[onCheckedChange]
|
||||
)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"shadow-elevation-card-rest hover:shadow-elevation-card-hover focus-visible:shadow-borders-focus bg-ui-bg-subtle-hover group relative aspect-square h-auto max-w-full overflow-hidden rounded-lg outline-none"
|
||||
)}
|
||||
>
|
||||
{isThumbnail && (
|
||||
<div className="absolute left-2 top-2">
|
||||
<Tooltip content={t("products.media.thumbnailTooltip")}>
|
||||
<ThumbnailBadge />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clx("transition-fg absolute right-2 top-2 opacity-0", {
|
||||
"group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100":
|
||||
!checked,
|
||||
"opacity-100": checked,
|
||||
})}
|
||||
>
|
||||
<Checkbox
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
checked={checked}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
<img src={media.url} className="size-full object-cover object-center" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface UnassociatedImageItemProps {
|
||||
media: MediaView
|
||||
onAdd: () => void
|
||||
}
|
||||
|
||||
const UnassociatedImageItem = ({
|
||||
media,
|
||||
onAdd,
|
||||
}: UnassociatedImageItemProps) => {
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"shadow-elevation-card-rest hover:shadow-elevation-card-hover focus-visible:shadow-borders-focus bg-ui-bg-subtle-hover group relative aspect-square h-auto max-w-full cursor-pointer overflow-hidden rounded-lg outline-none"
|
||||
)}
|
||||
onClick={onAdd}
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
"transition-fg absolute inset-0 flex items-center justify-center bg-black/30 opacity-0",
|
||||
{
|
||||
"group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100":
|
||||
true,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="bg-ui-bg-base border-ui-border-base flex h-12 w-12 items-center justify-center rounded-full border shadow-lg">
|
||||
<Plus />
|
||||
</div>
|
||||
</div>
|
||||
<img src={media.url} className="size-full object-cover object-center" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./edit-product-variant-media-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { ProductVariantMedia as Component } from "./product-variant-media"
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { useParams } from "react-router-dom"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
import { RouteFocusModal } from "../../../components/modals"
|
||||
import { useProductVariant } from "../../../hooks/api/products"
|
||||
import { EditProductVariantMediaForm } from "./components/edit-product-variant-media-form"
|
||||
|
||||
type ProductMediaVariantsReponse = HttpTypes.AdminProductVariant & {
|
||||
images: HttpTypes.AdminProductImage[]
|
||||
}
|
||||
|
||||
export const ProductVariantMedia = () => {
|
||||
const { id, variant_id } = useParams()
|
||||
|
||||
const { variant, isLoading, isError, error } = useProductVariant(
|
||||
id!,
|
||||
variant_id!,
|
||||
{ fields: "*product,*product.images,*images,+images.variants.id" }
|
||||
)
|
||||
|
||||
const ready = !isLoading && variant
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
{ready && (
|
||||
<EditProductVariantMediaForm
|
||||
variant={variant as ProductMediaVariantsReponse}
|
||||
/>
|
||||
)}
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
+15
-3
@@ -12,7 +12,7 @@ import {
|
||||
} from "@medusajs/ui"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Link, useNavigate } from "react-router-dom"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { useUpdateProduct } from "../../../../../hooks/api/products"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
@@ -24,6 +24,8 @@ type ProductMedisaSectionProps = {
|
||||
export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [selection, setSelection] = useState<Record<string, boolean>>({})
|
||||
|
||||
const media = getMedia(product)
|
||||
@@ -66,7 +68,7 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
|
||||
const mediaToKeep = product.images
|
||||
.filter((i) => !ids.includes(i.id))
|
||||
.map((i) => ({ url: i.url}))
|
||||
.map((i) => ({ url: i.url }))
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
@@ -90,7 +92,7 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.edit"),
|
||||
label: t("actions.editImages"),
|
||||
to: "media?view=edit",
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
@@ -175,6 +177,16 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
label={t("actions.delete")}
|
||||
shortcut="d"
|
||||
/>
|
||||
{Object.keys(selection).length === 1 && (
|
||||
<CommandBar.Command
|
||||
action={() => {
|
||||
navigate(`images/${Object.keys(selection)[0]}/variants`)
|
||||
setSelection({})
|
||||
}}
|
||||
label={t("products.media.manageImageVariants")}
|
||||
shortcut="m"
|
||||
/>
|
||||
)}
|
||||
</CommandBar.Bar>
|
||||
</CommandBar>
|
||||
</Container>
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { VariantsTableForm } from "./variants-table-form"
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
import { Button, Checkbox, toast } from "@medusajs/ui"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import {
|
||||
createColumnHelper,
|
||||
OnChangeFn,
|
||||
RowSelectionState,
|
||||
} from "@tanstack/react-table"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import * as zod from "zod"
|
||||
|
||||
import { AdminProduct } from "@medusajs/types"
|
||||
|
||||
import { _DataTable } from "../../../../../components/table/data-table"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import {
|
||||
useBatchImageVariants,
|
||||
useProductVariants,
|
||||
} from "../../../../../hooks/api"
|
||||
import { useProductVariantTableQuery } from "../../../../../hooks/table/query/use-product-variant-table-query"
|
||||
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
|
||||
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
type VariantsTableFormProps = {
|
||||
productId: string
|
||||
image: { id: string; variants: { id: string }[] }
|
||||
}
|
||||
|
||||
const BatchImageVariantsSchema = zod.object({
|
||||
variants: zod.array(zod.string()),
|
||||
})
|
||||
|
||||
const variantColumnHelper =
|
||||
createColumnHelper<NonNullable<AdminProduct["variants"]>[0]>()
|
||||
|
||||
export const VariantsTableForm = ({
|
||||
productId,
|
||||
image,
|
||||
}: VariantsTableFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const { mutateAsync, isPending } = useBatchImageVariants(productId, image.id)
|
||||
|
||||
const [variantSelection, setVariantSelection] = useState<RowSelectionState>(
|
||||
() =>
|
||||
image.variants?.reduce((acc, variant) => {
|
||||
acc[variant.id] = true
|
||||
return acc
|
||||
}, {} as RowSelectionState) || {}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setVariantSelection(
|
||||
image.variants?.reduce((acc, variant) => {
|
||||
acc[variant.id] = true
|
||||
return acc
|
||||
}, {} as RowSelectionState) || {}
|
||||
)
|
||||
}, [image.variants.length])
|
||||
|
||||
const form = useForm<zod.infer<typeof BatchImageVariantsSchema>>({
|
||||
defaultValues: {
|
||||
variants: image.variants?.map((variant) => variant.id) || [],
|
||||
},
|
||||
resolver: zodResolver(BatchImageVariantsSchema),
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
const initialVariantIds =
|
||||
image?.variants?.map((variant) => variant.id) || []
|
||||
|
||||
const newVariantIds = Object.keys(variantSelection).filter(
|
||||
(k) => variantSelection[k]
|
||||
)
|
||||
|
||||
const variantsToAdd = newVariantIds.filter(
|
||||
(id) => !initialVariantIds.includes(id)
|
||||
)
|
||||
|
||||
const variantsToRemove = initialVariantIds.filter(
|
||||
(id) => !newVariantIds.includes(id)
|
||||
)
|
||||
|
||||
// TODO: remove thumbnail if variant is removed
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
add: variantsToAdd,
|
||||
remove: variantsToRemove,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("products.variantMedia.successToast"))
|
||||
handleSuccess()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
variantColumnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
variantColumnHelper.accessor("title", {
|
||||
header: () => t("fields.title"),
|
||||
cell: ({ getValue }) => {
|
||||
const title = getValue()
|
||||
return (
|
||||
<div className="flex h-full w-full items-center">
|
||||
<span className="truncate">{title || "-"}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
variantColumnHelper.accessor("sku", {
|
||||
header: () => t("fields.sku"),
|
||||
cell: ({ getValue }) => {
|
||||
const sku = getValue()
|
||||
return (
|
||||
<div className="flex h-full w-full items-center">
|
||||
<span className="truncate font-mono text-sm">{sku || "-"}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
variantColumnHelper.accessor("thumbnail", {
|
||||
header: () => t("fields.thumbnail"),
|
||||
cell: ({ getValue }) => {
|
||||
const isThumbnail = getValue() === image.url
|
||||
return (
|
||||
<div className="flex h-full w-full items-center">
|
||||
<span className="truncate text-sm">
|
||||
{isThumbnail ? t("fields.true") : t("fields.false")}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const updater: OnChangeFn<RowSelectionState> = (value) => {
|
||||
const state = typeof value === "function" ? value(variantSelection) : value
|
||||
setVariantSelection(state)
|
||||
const formState = Object.keys(state).filter((k) => state[k])
|
||||
|
||||
form.setValue("variants", formState, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
})
|
||||
}
|
||||
|
||||
const { searchParams, raw } = useProductVariantTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
const {
|
||||
variants,
|
||||
count,
|
||||
isPending: isLoading,
|
||||
} = useProductVariants(
|
||||
productId,
|
||||
{
|
||||
...searchParams,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: variants || [],
|
||||
columns,
|
||||
count: count,
|
||||
enablePagination: true,
|
||||
enableRowSelection: true,
|
||||
pageSize: PAGE_SIZE,
|
||||
getRowId: (row) => row.id,
|
||||
rowSelection: {
|
||||
state: variantSelection,
|
||||
updater,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<RouteDrawer.Form form={form}>
|
||||
<KeyboundForm
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<RouteDrawer.Body className="flex flex-col gap-y-8 overflow-y-auto p-0">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<_DataTable
|
||||
layout="fill"
|
||||
table={table}
|
||||
columns={columns}
|
||||
count={count}
|
||||
isLoading={isLoading}
|
||||
pageSize={PAGE_SIZE}
|
||||
queryObject={raw}
|
||||
pagination
|
||||
search
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RouteDrawer.Body>
|
||||
<RouteDrawer.Footer>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteDrawer.Close asChild>
|
||||
<Button size="small" variant="secondary">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteDrawer.Close>
|
||||
<Button
|
||||
size="small"
|
||||
type="submit"
|
||||
isLoading={isPending}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</KeyboundForm>
|
||||
</RouteDrawer.Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ProductImageVariantsEdit as Component } from "./product-image-variants-edit"
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { json, useParams } from "react-router-dom"
|
||||
|
||||
import { RouteDrawer } from "../../../components/modals"
|
||||
import { VariantsTableForm } from "./components/variants-table-form/variants-table-form"
|
||||
import { useProduct } from "../../../hooks/api"
|
||||
|
||||
type VariantImagesPartial = {
|
||||
id: string
|
||||
variants: { id: string }[]
|
||||
}
|
||||
|
||||
export const ProductImageVariantsEdit = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { id: product_id, image_id } = useParams<{
|
||||
id: string
|
||||
image_id: string
|
||||
}>()
|
||||
|
||||
const { product, isPending } = useProduct(
|
||||
product_id!,
|
||||
{ fields: "images.id,images.url,images.variants.id" },
|
||||
{
|
||||
enabled: !!product_id && !!image_id,
|
||||
}
|
||||
)
|
||||
|
||||
const image = product?.images?.find((image) => image.id === image_id)
|
||||
|
||||
if (!product_id || !image_id || isPending) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isPending && !image) {
|
||||
throw json({ message: `An image with ID ${image_id} was not found` }, 404)
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteDrawer>
|
||||
<RouteDrawer.Header>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<img src={image!.url} className="h-20" />
|
||||
<div>
|
||||
<RouteDrawer.Title asChild>
|
||||
<Heading>{t("products.variantMedia.manageVariants")}</Heading>
|
||||
</RouteDrawer.Title>
|
||||
<RouteDrawer.Description>
|
||||
{t("products.variantMedia.manageVariantsDescription")}
|
||||
</RouteDrawer.Description>
|
||||
</div>
|
||||
</div>
|
||||
</RouteDrawer.Header>
|
||||
<VariantsTableForm
|
||||
productId={product_id}
|
||||
image={image! as VariantImagesPartial}
|
||||
/>
|
||||
</RouteDrawer>
|
||||
)
|
||||
}
|
||||
+1
@@ -128,6 +128,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
}
|
||||
return entry
|
||||
})
|
||||
|
||||
const thumbnail = withUpdatedUrls.find((m) => m.isThumbnail)?.url
|
||||
|
||||
await mutateAsync(
|
||||
|
||||
Reference in New Issue
Block a user