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:
Frane Polić
2025-10-26 15:15:40 +01:00
committed by GitHub
co-authored by Oli Juhl
parent bafd006094
commit 4757281677
57 changed files with 3323 additions and 80 deletions
@@ -0,0 +1 @@
export { VariantMediaSection } from "./variant-media-section"
@@ -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,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"
@@ -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 />
) : (
@@ -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>
)
}
@@ -0,0 +1 @@
export * from "./edit-product-variant-media-form"
@@ -0,0 +1 @@
export { ProductVariantMedia as Component } from "./product-variant-media"
@@ -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>
)
}
@@ -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>
@@ -0,0 +1 @@
export { VariantsTableForm } from "./variants-table-form"
@@ -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"
@@ -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>
)
}
@@ -128,6 +128,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
}
return entry
})
const thumbnail = withUpdatedUrls.find((m) => m.isThumbnail)?.url
await mutateAsync(