feat(product,dashboard): Allow re-ordering images (#10187)

* migration

* fix snapshot

* primarykey

* init work on dnd

* progress

* dnd

* undo changes

* undo changes

* undo changes

* undo changes

* fix firefox issue

* lint

* lint

* lint

* add changeset

* undo changes to product module

* set activator node

* init work on service layer

* alternative

* switch to OneToMany

* add tests

* progress

* update migration

* update approach and remove all references to images in product.ts tests

* handle delete images on empty array

* fix config and order type

* update changeset

* rm flag

* export type and fix type in test

* fix type

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Kasper Fabricius KristensenandOli Juhl authored and GitHub committed 2024-11-25 09:03:10 +01:00
1 parent b12408dbd8
commit 1659c9be5d
32 files changed
+1082 -442

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
"@medusajs/dashboard": patch
"@medusajs/product": patch
"@medusajs/types": patch
---
feat(dashboard): Allow re-ordering product images
@@ -74,6 +74,13 @@ medusaIntegrationTestRunner({
// BREAKING: Type input changed from {type: {value: string}} to {type_id: string} // BREAKING: Type input changed from {type: {value: string}} to {type_id: string}
type_id: baseType.id, type_id: baseType.id,
tags: [{ id: baseTag1.id }, { id: baseTag2.id }], tags: [{ id: baseTag1.id }, { id: baseTag2.id }],
images: [{
url: "image-one",
},
{
url: "image-two",
},
],
}), }),
adminHeaders adminHeaders
) )
@@ -116,6 +123,24 @@ medusaIntegrationTestRunner({
describe("/admin/products", () => { describe("/admin/products", () => {
describe("GET /admin/products", () => { describe("GET /admin/products", () => {
it("returns a list of products with images ordered by rank", async () => {
const res = await api.get("/admin/products", adminHeaders)
expect(res.status).toEqual(200)
expect(res.data.products).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: baseProduct.id,
images: expect.arrayContaining([
expect.objectContaining({ url: "image-one", rank: 0 }),
expect.objectContaining({ url: "image-two", rank: 1 }),
]),
}),
])
)
})
it("returns a list of products with all statuses when no status or invalid status is provided", async () => { it("returns a list of products with all statuses when no status or invalid status is provided", async () => {
const res = await api const res = await api
.get("/admin/products", adminHeaders) .get("/admin/products", adminHeaders)
@@ -965,6 +990,17 @@ medusaIntegrationTestRunner({
expect(hasPrices).toBe(true) expect(hasPrices).toBe(true)
}) })
it("should get a product with images ordered by rank", async () => {
const res = await api.get(`/admin/products/${baseProduct.id}`, adminHeaders)
expect(res.data.product.images).toEqual(
expect.arrayContaining([
expect.objectContaining({ url: "image-one", rank: 0 }),
expect.objectContaining({ url: "image-two", rank: 1 }),
])
)
})
it("should get a product with prices", async () => { it("should get a product with prices", async () => {
const res = await api const res = await api
.get( .get(
@@ -526,6 +526,14 @@ medusaIntegrationTestRunner({
prices: [{ amount: 3000, currency_code: "usd" }], prices: [{ amount: 3000, currency_code: "usd" }],
}, },
], ],
images: [
{
url: "image-one",
},
{
url: "image-two",
},
],
}) })
;[product2, [variant2]] = await createProducts({ ;[product2, [variant2]] = await createProducts({
title: "test product 2 uniquely", title: "test product 2 uniquely",
@@ -620,6 +628,22 @@ medusaIntegrationTestRunner({
]) ])
}) })
it("should list all products with images ordered by rank", async () => {
const response = await api.get("/store/products", storeHeaders)
expect(response.data.products).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: product.id,
images: expect.arrayContaining([
expect.objectContaining({ url: "image-one", rank: 0 }),
expect.objectContaining({ url: "image-two", rank: 1 }),
]),
}),
])
)
})
it("should list all products excluding variants", async () => { it("should list all products excluding variants", async () => {
let response = await api.get( let response = await api.get(
`/admin/products?fields=-variants`, `/admin/products?fields=-variants`,
@@ -1406,6 +1430,14 @@ medusaIntegrationTestRunner({
], ],
}, },
], ],
images: [
{
url: "image-one",
},
{
url: "image-two",
}
],
}) })
const defaultSalesChannel = await createSalesChannel( const defaultSalesChannel = await createSalesChannel(
@@ -1454,6 +1486,17 @@ medusaIntegrationTestRunner({
) )
}) })
it("should retrieve product with images ordered by rank", async () => {
const response = await api.get(`/store/products/${product.id}`, storeHeaders)
expect(response.data.product.images).toEqual(
expect.arrayContaining([
expect.objectContaining({ url: "image-one", rank: 0 }),
expect.objectContaining({ url: "image-two", rank: 1 }),
])
)
})
// TODO: There are 2 problems that need to be solved to enable this test // TODO: There are 2 problems that need to be solved to enable this test
// 1. When adding product to another category, the product is being removed from earlier assigned categories // 1. When adding product to another category, the product is being removed from earlier assigned categories
// 2. MikroORM seems to be doing a join strategy to load relationships, we need to send a separate query to fetch relationships // 2. MikroORM seems to be doing a join strategy to load relationships, we need to send a separate query to fetch relationships
@@ -7,13 +7,13 @@ import { Form } from "../../common/form"
type RouteModalFormProps<TFieldValues extends FieldValues> = PropsWithChildren<{ type RouteModalFormProps<TFieldValues extends FieldValues> = PropsWithChildren<{
form: UseFormReturn<TFieldValues> form: UseFormReturn<TFieldValues>
blockSearch?: boolean blockSearchParams?: boolean
onClose?: (isSubmitSuccessful: boolean) => void onClose?: (isSubmitSuccessful: boolean) => void
}> }>
export const RouteModalForm = <TFieldValues extends FieldValues = any>({ export const RouteModalForm = <TFieldValues extends FieldValues = any>({
form, form,
blockSearch = false, blockSearchParams: blockSearch = false,
children, children,
onClose, onClose,
}: RouteModalFormProps<TFieldValues>) => { }: RouteModalFormProps<TFieldValues>) => {
@@ -1578,6 +1578,12 @@
"create": { "create": {
"type": "object", "type": "object",
"properties": { "properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"header": { "header": {
"type": "string" "type": "string"
}, },
@@ -1742,6 +1748,8 @@
} }
}, },
"required": [ "required": [
"title",
"description",
"header", "header",
"tabs", "tabs",
"errors", "errors",
@@ -1990,6 +1998,9 @@
"action" "action"
], ],
"additionalProperties": false "additionalProperties": false
},
"successToast": {
"type": "string"
} }
}, },
"required": [ "required": [
@@ -2008,7 +2019,8 @@
"galleryLabel", "galleryLabel",
"downloadImageLabel", "downloadImageLabel",
"deleteImageLabel", "deleteImageLabel",
"emptyState" "emptyState",
"successToast"
], ],
"additionalProperties": false "additionalProperties": false
}, },
@@ -386,6 +386,8 @@
"successToast": "Produkz {{title}} angepasst." "successToast": "Produkz {{title}} angepasst."
}, },
"create": { "create": {
"title": "Produkt erstellen",
"description": "Erstellen Sie ein neues Produkt.",
"header": "Allgemein", "header": "Allgemein",
"tabs": { "tabs": {
"details": "Details", "details": "Details",
@@ -490,7 +492,8 @@
"header": "Noch keine Medien", "header": "Noch keine Medien",
"description": "Fügen Sie dem Produkt Medien hinzu, um es in Ihrem Schaufenster zu präsentieren.", "description": "Fügen Sie dem Produkt Medien hinzu, um es in Ihrem Schaufenster zu präsentieren.",
"action": "Medien hinzufügen" "action": "Medien hinzufügen"
} },
"successToast": "Medien wurden erfolgreich aktualisiert."
}, },
"discountableHint": "Wenn diese Option deaktiviert ist, werden auf dieses Produkt keine Rabatte gewährt.", "discountableHint": "Wenn diese Option deaktiviert ist, werden auf dieses Produkt keine Rabatte gewährt.",
"noSalesChannels": "In keinem Vertriebskanal verfügbar", "noSalesChannels": "In keinem Vertriebskanal verfügbar",
@@ -386,6 +386,8 @@
"successToast": "Product {{title}} was successfully updated." "successToast": "Product {{title}} was successfully updated."
}, },
"create": { "create": {
"title": "Create Product",
"description": "Create a new product.",
"header": "General", "header": "General",
"tabs": { "tabs": {
"details": "Details", "details": "Details",
@@ -490,7 +492,8 @@
"header": "No media yet", "header": "No media yet",
"description": "Add media to the product to showcase it in your storefront.", "description": "Add media to the product to showcase it in your storefront.",
"action": "Add media" "action": "Add media"
} },
"successToast": "Media was successfully updated."
}, },
"discountableHint": "When unchecked, discounts will not be applied to this product.", "discountableHint": "When unchecked, discounts will not be applied to this product.",
"noSalesChannels": "Not available in any sales channels", "noSalesChannels": "Not available in any sales channels",
@@ -386,6 +386,8 @@
"successToast": "Produkt {{title}} został pomyślnie zaktualizowany." "successToast": "Produkt {{title}} został pomyślnie zaktualizowany."
}, },
"create": { "create": {
"title": "Utwórz produkt",
"description": "Utwórz nowy produkt.",
"header": "Ogólne", "header": "Ogólne",
"tabs": { "tabs": {
"details": "Szczegóły", "details": "Szczegóły",
@@ -490,7 +492,8 @@
"header": "Brak mediów", "header": "Brak mediów",
"description": "Dodaj media do produktu, aby zaprezentować go w swoim sklepie.", "description": "Dodaj media do produktu, aby zaprezentować go w swoim sklepie.",
"action": "Dodaj media" "action": "Dodaj media"
} },
"successToast": "Media zostały pomyślnie zaktualizowane."
}, },
"discountableHint": "Jeśli odznaczone, rabaty nie będą stosowane do tego produktu.", "discountableHint": "Jeśli odznaczone, rabaty nie będą stosowane do tego produktu.",
"noSalesChannels": "Niedostępny w żadnych kanałach sprzedaży", "noSalesChannels": "Niedostępny w żadnych kanałach sprzedaży",
@@ -386,6 +386,8 @@
"successToast": "Ürün {{title}} başarıyla güncellendi." "successToast": "Ürün {{title}} başarıyla güncellendi."
}, },
"create": { "create": {
"title": "Ürün Oluştur",
"description": "Yeni bir ürün oluşturun.",
"header": "Genel", "header": "Genel",
"tabs": { "tabs": {
"details": "Detaylar", "details": "Detaylar",
@@ -490,7 +492,8 @@
"header": "Henüz medya yok", "header": "Henüz medya yok",
"description": "Ürünü mağazanızda sergilemek için medya ekleyin.", "description": "Ürünü mağazanızda sergilemek için medya ekleyin.",
"action": "Medya ekle" "action": "Medya ekle"
} },
"successToast": "Medya başarıyla güncellendi."
}, },
"discountableHint": "İşaretlenmediğinde, bu ürüne indirim uygulanmayacaktır.", "discountableHint": "İşaretlenmediğinde, bu ürüne indirim uygulanmayacaktır.",
"noSalesChannels": "Hiçbir satış kanalında mevcut değil", "noSalesChannels": "Hiçbir satış kanalında mevcut değil",
@@ -1 +0,0 @@
export * from "./media-grid-view"
@@ -1,125 +0,0 @@
import { CheckMini, Spinner, ThumbnailBadge } from "@medusajs/icons"
import { Tooltip, clx } from "@medusajs/ui"
import { AnimatePresence, motion } from "framer-motion"
import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
interface MediaView {
id?: string
field_id: string
url: string
isThumbnail: boolean
}
interface MediaGridProps {
media: MediaView[]
selection: Record<string, boolean>
onCheckedChange: (id: string) => (value: boolean) => void
}
export const MediaGrid = ({
media,
selection,
onCheckedChange,
}: MediaGridProps) => {
return (
<div className="bg-ui-bg-subtle size-full overflow-auto">
<div className="grid h-fit auto-rows-auto grid-cols-4 gap-6 p-6">
{media.map((m) => {
return (
<MediaGridItem
onCheckedChange={onCheckedChange(m.id!)}
checked={!!selection[m.id!]}
key={m.field_id}
media={m}
/>
)
})}
</div>
</div>
)
}
interface MediaGridItemProps {
media: MediaView
checked: boolean
onCheckedChange: (value: boolean) => void
}
const MediaGridItem = ({
media,
checked,
onCheckedChange,
}: MediaGridItemProps) => {
const [isLoading, setIsLoading] = useState(true)
const { t } = useTranslation()
const handleToggle = useCallback(() => {
onCheckedChange(!checked)
}, [checked, onCheckedChange])
return (
<button
type="button"
onClick={handleToggle}
className="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"
>
{media.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",
{
"opacity-100": checked,
}
)}
>
<div
className={clx(
"group relative inline-flex h-4 w-4 items-center justify-center outline-none "
)}
>
<div
className={clx(
"text-ui-fg-on-inverted bg-ui-bg-component shadow-borders-base [&_path]:shadow-details-contrast-on-bg-interactive group-disabled:text-ui-fg-disabled group-disabled:!bg-ui-bg-disabled group-disabled:!shadow-borders-base transition-fg h-[14px] w-[14px] rounded-[3px]",
{
"bg-ui-bg-interactive group-hover:bg-ui-bg-interactive shadow-borders-interactive-with-shadow":
checked,
}
)}
>
{checked && (
<div className="absolute inset-0">
<CheckMini />
</div>
)}
</div>
</div>
</div>
<AnimatePresence>
{isLoading && (
<motion.div
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.5 } }}
className="bg-ui-bg-subtle-hover absolute inset-0 flex items-center justify-center"
>
<Spinner className="text-ui-fg-subtle animate-spin" />
</motion.div>
)}
</AnimatePresence>
<img
src={media.url}
onLoad={() => setIsLoading(false)}
alt=""
className="size-full object-cover object-center"
/>
</button>
)
}
@@ -1,3 +1,4 @@
import { useCallback } from "react"
import { UseFormReturn } from "react-hook-form" import { UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { z } from "zod" import { z } from "zod"
@@ -45,7 +46,8 @@ export const UploadMediaFormItem = ({
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const hasInvalidFiles = (fileList: FileType[]) => { const hasInvalidFiles = useCallback(
(fileList: FileType[]) => {
const invalidFile = fileList.find( const invalidFile = fileList.find(
(f) => !SUPPORTED_FORMATS.includes(f.file.type) (f) => !SUPPORTED_FORMATS.includes(f.file.type)
) )
@@ -63,8 +65,22 @@ export const UploadMediaFormItem = ({
} }
return false return false
},
[form, t]
)
const onUploaded = useCallback(
(files: FileType[]) => {
form.clearErrors("media")
if (hasInvalidFiles(files)) {
return
} }
files.forEach((f) => append({ ...f, isThumbnail: false }))
},
[form, append, hasInvalidFiles]
)
return ( return (
<Form.Field <Form.Field
control={ control={
@@ -87,15 +103,7 @@ export const UploadMediaFormItem = ({
hint={t("products.media.uploadImagesHint")} hint={t("products.media.uploadImagesHint")}
hasError={!!form.formState.errors.media} hasError={!!form.formState.errors.media}
formats={SUPPORTED_FORMATS} formats={SUPPORTED_FORMATS}
onUploaded={(files) => { onUploaded={onUploaded}
form.clearErrors("media")
if (hasInvalidFiles(files)) {
return
}
// TODO: For now all files that get uploaded are not thumbnails, revisit this logic
files.forEach((f) => append({ ...f, isThumbnail: false }))
}}
/> />
</Form.Control> </Form.Control>
<Form.ErrorMessage /> <Form.ErrorMessage />
@@ -1,7 +1,33 @@
import { StackPerspective, ThumbnailBadge, Trash, XMark } from "@medusajs/icons" import {
defaultDropAnimationSideEffects,
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
DropAnimation,
KeyboardSensor,
PointerSensor,
UniqueIdentifier,
useSensor,
useSensors,
} from "@dnd-kit/core"
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
DotsSix,
StackPerspective,
ThumbnailBadge,
Trash,
XMark,
} from "@medusajs/icons"
import { IconButton, Text } from "@medusajs/ui" import { IconButton, Text } from "@medusajs/ui"
import { useEffect, useState } from "react" import { useState } from "react"
import { UseFormReturn, useFieldArray } from "react-hook-form" import { useFieldArray, UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../../../components/common/action-menu"
import { UploadMediaFormItem } from "../../../../../common/components/upload-media-form-item" import { UploadMediaFormItem } from "../../../../../common/components/upload-media-form-item"
@@ -11,6 +37,16 @@ type ProductCreateMediaSectionProps = {
form: UseFormReturn<ProductCreateSchemaType> form: UseFormReturn<ProductCreateSchemaType>
} }
const dropAnimationConfig: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.4",
},
},
}),
}
export const ProductCreateMediaSection = ({ export const ProductCreateMediaSection = ({
form, form,
}: ProductCreateMediaSectionProps) => { }: ProductCreateMediaSectionProps) => {
@@ -20,6 +56,38 @@ export const ProductCreateMediaSection = ({
keyName: "field_id", keyName: "field_id",
}) })
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id)
}
const handleDragEnd = (event: DragEndEvent) => {
setActiveId(null)
const { active, over } = event
if (active.id !== over?.id) {
const oldIndex = fields.findIndex((item) => item.field_id === active.id)
const newIndex = fields.findIndex((item) => item.field_id === over?.id)
form.setValue("media", arrayMove(fields, oldIndex, newIndex), {
shouldDirty: true,
shouldTouch: true,
})
}
}
const handleDragCancel = () => {
setActiveId(null)
}
const getOnDelete = (index: number) => { const getOnDelete = (index: number) => {
return () => { return () => {
remove(index) remove(index)
@@ -52,20 +120,36 @@ export const ProductCreateMediaSection = ({
return ( return (
<div id="media" className="flex flex-col gap-y-2"> <div id="media" className="flex flex-col gap-y-2">
<UploadMediaFormItem form={form} append={append} showHint={false} /> <UploadMediaFormItem form={form} append={append} showHint={false} />
<DndContext
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
onDragCancel={handleDragCancel}
>
<DragOverlay dropAnimation={dropAnimationConfig}>
{activeId ? (
<MediaGridItemOverlay
field={fields.find((m) => m.field_id === activeId)!}
/>
) : null}
</DragOverlay>
<ul className="flex flex-col gap-y-2"> <ul className="flex flex-col gap-y-2">
<SortableContext items={fields.map((field) => field.field_id)}>
{fields.map((field, index) => { {fields.map((field, index) => {
const { onDelete, onMakeThumbnail } = getItemHandlers(index) const { onDelete, onMakeThumbnail } = getItemHandlers(index)
return ( return (
<MediaItem <MediaItem
key={field.id} key={field.field_id}
field={field} field={field}
onDelete={onDelete} onDelete={onDelete}
onMakeThumbnail={onMakeThumbnail} onMakeThumbnail={onMakeThumbnail}
/> />
) )
})} })}
</SortableContext>
</ul> </ul>
</DndContext>
</div> </div>
) )
} }
@@ -87,15 +171,47 @@ type MediaItemProps = {
const MediaItem = ({ field, onDelete, onMakeThumbnail }: MediaItemProps) => { const MediaItem = ({ field, onDelete, onMakeThumbnail }: MediaItemProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: field.field_id })
const style = {
opacity: isDragging ? 0.4 : undefined,
transform: CSS.Translate.toString(transform),
transition,
}
if (!field.file) { if (!field.file) {
return null return null
} }
return ( return (
<li className="bg-ui-bg-component shadow-elevation-card-rest flex items-center justify-between rounded-lg px-3 py-2"> <li
className="bg-ui-bg-component shadow-elevation-card-rest flex items-center justify-between rounded-lg px-3 py-2"
ref={setNodeRef}
style={style}
>
<div className="flex items-center gap-x-2">
<IconButton
variant="transparent"
type="button"
size="small"
{...attributes}
{...listeners}
ref={setActivatorNodeRef}
className="cursor-grab touch-none active:cursor-grabbing"
>
<DotsSix className="text-ui-fg-muted" />
</IconButton>
<div className="flex items-center gap-x-3"> <div className="flex items-center gap-x-3">
<div className="bg-ui-bg-base h-10 w-[30px] overflow-hidden rounded-md"> <div className="bg-ui-bg-base h-10 w-[30px] overflow-hidden rounded-md">
<ThumbnailPreview file={field.file} /> <ThumbnailPreview url={field.url} />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<Text size="small" leading="compact"> <Text size="small" leading="compact">
@@ -103,12 +219,17 @@ const MediaItem = ({ field, onDelete, onMakeThumbnail }: MediaItemProps) => {
</Text> </Text>
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
{field.isThumbnail && <ThumbnailBadge />} {field.isThumbnail && <ThumbnailBadge />}
<Text size="xsmall" leading="compact" className="text-ui-fg-subtle"> <Text
size="xsmall"
leading="compact"
className="text-ui-fg-subtle"
>
{formatFileSize(field.file.size)} {formatFileSize(field.file.size)}
</Text> </Text>
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<ActionMenu <ActionMenu
groups={[ groups={[
@@ -145,28 +266,60 @@ const MediaItem = ({ field, onDelete, onMakeThumbnail }: MediaItemProps) => {
) )
} }
const ThumbnailPreview = ({ file }: { file?: File | null }) => { const MediaGridItemOverlay = ({ field }: { field: MediaField }) => {
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null) return (
<li className="bg-ui-bg-component shadow-elevation-card-rest flex items-center justify-between rounded-lg px-3 py-2">
<div className="flex items-center gap-x-2">
<IconButton
variant="transparent"
size="small"
className="cursor-grab touch-none active:cursor-grabbing"
>
<DotsSix className="text-ui-fg-muted" />
</IconButton>
<div className="flex items-center gap-x-3">
<div className="bg-ui-bg-base h-10 w-[30px] overflow-hidden rounded-md">
<ThumbnailPreview url={field.url} />
</div>
<div className="flex flex-col">
<Text size="small" leading="compact">
{field.file?.name}
</Text>
<div className="flex items-center gap-x-1">
{field.isThumbnail && <ThumbnailBadge />}
<Text
size="xsmall"
leading="compact"
className="text-ui-fg-subtle"
>
{formatFileSize(field.file?.size ?? 0)}
</Text>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-x-1">
<ActionMenu groups={[]} />
<IconButton
type="button"
size="small"
variant="transparent"
onClick={() => {}}
>
<XMark />
</IconButton>
</div>
</li>
)
}
useEffect(() => { const ThumbnailPreview = ({ url }: { url?: string | null }) => {
if (file) { if (!url) {
const objectUrl = URL.createObjectURL(file)
setThumbnailUrl(objectUrl)
return () => URL.revokeObjectURL(objectUrl)
}
}, [file])
if (!thumbnailUrl) {
return null return null
} }
return ( return (
<img <img src={url} alt="" className="size-full object-cover object-center" />
src={thumbnailUrl}
alt=""
className="size-full object-cover object-center"
/>
) )
} }
@@ -14,7 +14,6 @@ import {
} from "../../../../../extensions" } from "../../../../../extensions"
import { useCreateProduct } from "../../../../../hooks/api/products" import { useCreateProduct } from "../../../../../hooks/api/products"
import { sdk } from "../../../../../lib/client" import { sdk } from "../../../../../lib/client"
import { isFetchError } from "../../../../../lib/is-fetch-error"
import { import {
PRODUCT_CREATE_FORM_DEFAULTS, PRODUCT_CREATE_FORM_DEFAULTS,
ProductCreateSchema, ProductCreateSchema,
@@ -80,13 +79,10 @@ export const ProductCreateForm = ({
return {} return {}
} }
return regions.reduce( return regions.reduce((acc, reg) => {
(acc, reg) => {
acc[reg.id] = reg.currency_code acc[reg.id] = reg.currency_code
return acc return acc
}, }, {} as Record<string, string>)
{} as Record<string, string>
)
}, [regions]) }, [regions])
/** /**
@@ -140,32 +136,34 @@ export const ProductCreateForm = ({
uploadedMedia = (await Promise.all(fileReqs)).flat() uploadedMedia = (await Promise.all(fileReqs)).flat()
} }
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
}
const { product } = await mutateAsync( await mutateAsync(
normalizeProductFormValues({ normalizeProductFormValues({
...payload, ...payload,
media: uploadedMedia, media: uploadedMedia,
status: (isDraftSubmission ? "draft" : "published") as any, status: (isDraftSubmission ? "draft" : "published") as any,
regionsCurrencyMap, regionsCurrencyMap,
}) }),
) {
onSuccess: (data) => {
toast.success( toast.success(
t("products.create.successToast", { t("products.create.successToast", {
title: product.title, title: data.product.title,
}) })
) )
handleSuccess(`../${product.id}`) handleSuccess(`../${data.product.id}`)
} catch (error) { },
if (isFetchError(error) && error.status === 400) { onError: (error) => {
toast.error(error.message) toast.error(error.message)
} else { },
toast.error(t("general.error"), {
description: error.message,
})
}
} }
)
}) })
const onNext = async (currentTab: Tab) => { const onNext = async (currentTab: Tab) => {
@@ -210,10 +208,9 @@ export const ProductCreateForm = ({
} }
setTabState({ ...currentState }) setTabState({ ...currentState })
}, [tab]) }, [tab, tabState])
return ( return (
<RouteFocusModal>
<RouteFocusModal.Form form={form}> <RouteFocusModal.Form form={form}>
<KeyboundForm <KeyboundForm
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -346,7 +343,6 @@ export const ProductCreateForm = ({
</RouteFocusModal.Footer> </RouteFocusModal.Footer>
</KeyboundForm> </KeyboundForm>
</RouteFocusModal.Form> </RouteFocusModal.Form>
</RouteFocusModal>
) )
} }
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next"
import { RouteFocusModal } from "../../../components/modals" import { RouteFocusModal } from "../../../components/modals"
import { useRegions } from "../../../hooks/api" import { useRegions } from "../../../hooks/api"
import { usePricePreferences } from "../../../hooks/api/price-preferences" import { usePricePreferences } from "../../../hooks/api/price-preferences"
@@ -6,13 +7,15 @@ import { useStore } from "../../../hooks/api/store"
import { ProductCreateForm } from "./components/product-create-form/product-create-form" import { ProductCreateForm } from "./components/product-create-form/product-create-form"
export const ProductCreate = () => { export const ProductCreate = () => {
const { t } = useTranslation()
const { const {
store, store,
isPending: isStorePending, isPending: isStorePending,
isError: isStoreError, isError: isStoreError,
error: storeError, error: storeError,
} = useStore({ } = useStore({
fields: "default_sales_channel", fields: "+default_sales_channel",
}) })
const { const {
@@ -68,6 +71,12 @@ export const ProductCreate = () => {
return ( return (
<RouteFocusModal> <RouteFocusModal>
<RouteFocusModal.Title asChild>
<span className="sr-only">{t("products.create.title")}</span>
</RouteFocusModal.Title>
<RouteFocusModal.Description asChild>
<span className="sr-only">{t("products.create.description")}</span>
</RouteFocusModal.Description>
{ready && ( {ready && (
<ProductCreateForm <ProductCreateForm
defaultChannel={sales_channel} defaultChannel={sales_channel}
@@ -7,7 +7,7 @@ export const normalizeProductFormValues = (
status: HttpTypes.AdminProductStatus status: HttpTypes.AdminProductStatus
regionsCurrencyMap: Record<string, string> regionsCurrencyMap: Record<string, string>
} }
) => { ): HttpTypes.AdminCreateProduct => {
const thumbnail = values.media?.find((media) => media.isThumbnail)?.url const thumbnail = values.media?.find((media) => media.isThumbnail)?.url
const images = values.media const images = values.media
?.filter((media) => !media.isThumbnail) ?.filter((media) => !media.isThumbnail)
@@ -51,7 +51,7 @@ export const normalizeProductFormValues = (
export const normalizeVariants = ( export const normalizeVariants = (
variants: ProductCreateSchemaType["variants"], variants: ProductCreateSchemaType["variants"],
regionsCurrencyMap: Record<string, string> regionsCurrencyMap: Record<string, string>
) => { ): HttpTypes.AdminCreateProductVariant[] => {
return variants.map((variant) => ({ return variants.map((variant) => ({
title: variant.title || Object.values(variant.options || {}).join(" / "), title: variant.title || Object.values(variant.options || {}).join(" / "),
options: variant.options, options: variant.options,
@@ -60,7 +60,9 @@ export const normalizeVariants = (
allow_backorder: !!variant.allow_backorder, allow_backorder: !!variant.allow_backorder,
inventory_items: variant inventory_items: variant
.inventory!.map((i) => { .inventory!.map((i) => {
const quantity = castNumber(i.required_quantity) const quantity = i.required_quantity
? castNumber(i.required_quantity)
: null
if (!i.inventory_item_id || !quantity) { if (!i.inventory_item_id || !quantity) {
return false return false
@@ -71,7 +73,12 @@ export const normalizeVariants = (
required_quantity: quantity, required_quantity: quantity,
} }
}) })
.filter(Boolean), .filter(
(
item
): item is { required_quantity: number; inventory_item_id: string } =>
item !== false
),
prices: Object.entries(variant.prices || {}) prices: Object.entries(variant.prices || {})
.map(([key, value]: any) => { .map(([key, value]: any) => {
if (value === "" || value === undefined) { if (value === "" || value === undefined) {
@@ -1,12 +1,34 @@
import {
defaultDropAnimationSideEffects,
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
DropAnimation,
KeyboardSensor,
PointerSensor,
UniqueIdentifier,
useSensor,
useSensors,
} from "@dnd-kit/core"
import {
arrayMove,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { Button, CommandBar } from "@medusajs/ui" import { 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 { Fragment, useCallback, useState } from "react"
import { useFieldArray, useForm } from "react-hook-form" import { useFieldArray, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { z } from "zod" import { z } from "zod"
import { HttpTypes } from "@medusajs/types"
import { Link } from "react-router-dom"
import { import {
RouteFocusModal, RouteFocusModal,
useRouteModal, useRouteModal,
@@ -14,7 +36,6 @@ import {
import { KeyboundForm } from "../../../../../components/utilities/keybound-form" import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useUpdateProduct } from "../../../../../hooks/api/products" import { useUpdateProduct } from "../../../../../hooks/api/products"
import { sdk } from "../../../../../lib/client" import { sdk } from "../../../../../lib/client"
import { MediaGrid } from "../../../common/components/media-grid-view"
import { UploadMediaFormItem } from "../../../common/components/upload-media-form-item" import { UploadMediaFormItem } from "../../../common/components/upload-media-form-item"
import { import {
EditProductMediaSchema, EditProductMediaSchema,
@@ -46,6 +67,38 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
keyName: "field_id", keyName: "field_id",
}) })
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id)
}
const handleDragEnd = (event: DragEndEvent) => {
setActiveId(null)
const { active, over } = event
if (active.id !== over?.id) {
const oldIndex = fields.findIndex((item) => item.field_id === active.id)
const newIndex = fields.findIndex((item) => item.field_id === over?.id)
form.setValue("media", arrayMove(fields, oldIndex, newIndex), {
shouldDirty: true,
shouldTouch: true,
})
}
}
const handleDragCancel = () => {
setActiveId(null)
}
const { mutateAsync, isPending } = useUpdateProduct(product.id!) const { mutateAsync, isPending } = useUpdateProduct(product.id!)
const handleSubmit = form.handleSubmit(async ({ media }) => { const handleSubmit = form.handleSubmit(async ({ media }) => {
@@ -80,13 +133,16 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
await mutateAsync( await mutateAsync(
{ {
images: withUpdatedUrls.map((file) => ({ url: file.url })), images: withUpdatedUrls.map((file) => ({ url: file.url })),
// Set thumbnail to empty string if no thumbnail is selected, as the API does not accept null thumbnail: thumbnail,
thumbnail: thumbnail || "",
}, },
{ {
onSuccess: () => { onSuccess: () => {
toast.success(t("products.media.successToast"))
handleSuccess() handleSuccess()
}, },
onError: (error) => {
toast.error(error.message)
},
} }
) )
}) })
@@ -142,7 +198,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
const selectionCount = Object.keys(selection).length const selectionCount = Object.keys(selection).length
return ( return (
<RouteFocusModal.Form blockSearch form={form}> <RouteFocusModal.Form blockSearchParams form={form}>
<KeyboundForm <KeyboundForm
className="flex size-full flex-col overflow-hidden" className="flex size-full flex-col overflow-hidden"
onSubmit={handleSubmit} onSubmit={handleSubmit}
@@ -158,11 +214,44 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
</RouteFocusModal.Header> </RouteFocusModal.Header>
<RouteFocusModal.Body className="flex flex-col overflow-hidden"> <RouteFocusModal.Body className="flex flex-col overflow-hidden">
<div className="flex size-full flex-col-reverse lg:grid lg:grid-cols-[1fr_560px]"> <div className="flex size-full flex-col-reverse lg:grid lg:grid-cols-[1fr_560px]">
<MediaGrid <DndContext
media={fields} sensors={sensors}
onCheckedChange={handleCheckedChange} onDragEnd={handleDragEnd}
selection={selection} onDragStart={handleDragStart}
onDragCancel={handleDragCancel}
>
<div className="bg-ui-bg-subtle size-full overflow-auto">
<div className="grid h-fit auto-rows-auto grid-cols-4 gap-6 p-6">
<SortableContext
items={fields.map((m) => m.field_id)}
strategy={rectSortingStrategy}
>
{fields.map((m) => {
return (
<MediaGridItem
onCheckedChange={handleCheckedChange(m.id!)}
checked={!!selection[m.id!]}
key={m.field_id}
media={m}
/> />
)
})}
</SortableContext>
<DragOverlay dropAnimation={dropAnimationConfig}>
{activeId ? (
<MediaGridItemOverlay
media={fields.find((m) => m.field_id === activeId)!}
checked={
!!selection[
fields.find((m) => m.field_id === activeId)!.id!
]
}
/>
) : null}
</DragOverlay>
</div>
</div>
</DndContext>
<div className="bg-ui-bg-base overflow-auto border-b px-6 py-4 lg:border-b-0 lg:border-l"> <div className="bg-ui-bg-base overflow-auto border-b px-6 py-4 lg:border-b-0 lg:border-l">
<UploadMediaFormItem form={form} append={append} /> <UploadMediaFormItem form={form} append={append} />
</div> </div>
@@ -211,8 +300,8 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
} }
const getDefaultValues = ( const getDefaultValues = (
images: HttpTypes.AdminProductImage[] | undefined, images: HttpTypes.AdminProductImage[] | null | undefined,
thumbnail: string | undefined thumbnail: string | null | undefined
) => { ) => {
const media: Media[] = const media: Media[] =
images?.map((image) => ({ images?.map((image) => ({
@@ -235,3 +324,133 @@ const getDefaultValues = (
return media return media
} }
interface MediaView {
id?: string
field_id: string
url: string
isThumbnail: boolean
}
const dropAnimationConfig: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.4",
},
},
}),
}
interface MediaGridItemProps {
media: MediaView
checked: boolean
onCheckedChange: (value: boolean) => void
}
const MediaGridItem = ({
media,
checked,
onCheckedChange,
}: MediaGridItemProps) => {
const { t } = useTranslation()
const handleToggle = useCallback(
(value: boolean) => {
onCheckedChange(value)
},
[onCheckedChange]
)
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: media.field_id })
const style = {
opacity: isDragging ? 0.4 : undefined,
transform: CSS.Transform.toString(transform),
transition,
}
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"
)}
style={style}
ref={setNodeRef}
>
{media.isThumbnail && (
<div className="absolute left-2 top-2">
<Tooltip content={t("products.media.thumbnailTooltip")}>
<ThumbnailBadge />
</Tooltip>
</div>
)}
<div
className={clx("absolute inset-0 cursor-grab touch-none outline-none", {
"cursor-grabbing": isDragging,
})}
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
/>
<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":
!isDragging && !checked,
"opacity-100": checked,
})}
>
<Checkbox
onClick={(e) => {
e.stopPropagation()
}}
checked={checked}
onCheckedChange={handleToggle}
/>
</div>
<img
src={media.url}
alt=""
className="size-full object-cover object-center"
/>
</div>
)
}
export const MediaGridItemOverlay = ({
media,
checked,
}: {
media: MediaView
checked: boolean
}) => {
return (
<div className="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-grabbing overflow-hidden rounded-lg outline-none">
{media.isThumbnail && (
<div className="absolute left-2 top-2">
<ThumbnailBadge />
</div>
)}
<div
className={clx("transition-fg absolute right-2 top-2 opacity-0", {
"opacity-100": checked,
})}
>
<Checkbox checked={checked} />
</div>
<img
src={media.url}
alt=""
className="size-full object-cover object-center"
/>
</div>
)
}
@@ -24,39 +24,39 @@ export const ProductMediaGallery = ({ product }: ProductMediaGalleryProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const prompt = usePrompt() const prompt = usePrompt()
const { mutateAsync, isLoading } = useUpdateProduct(product.id) const { mutateAsync, isPending } = useUpdateProduct(product.id)
const media = getMedia(product.images, product.thumbnail) const media = getMedia(product.images, product.thumbnail)
const next = useCallback(() => { const next = useCallback(() => {
if (isLoading) { if (isPending) {
return return
} }
setCurr((prev) => (prev + 1) % media.length) setCurr((prev) => (prev + 1) % media.length)
}, [media, isLoading]) }, [media, isPending])
const prev = useCallback(() => { const prev = useCallback(() => {
if (isLoading) { if (isPending) {
return return
} }
setCurr((prev) => (prev - 1 + media.length) % media.length) setCurr((prev) => (prev - 1 + media.length) % media.length)
}, [media, isLoading]) }, [media, isPending])
const goTo = useCallback( const goTo = useCallback(
(index: number) => { (index: number) => {
if (isLoading) { if (isPending) {
return return
} }
setCurr(index) setCurr(index)
}, },
[isLoading] [isPending]
) )
const handleDownloadCurrent = () => { const handleDownloadCurrent = () => {
if (isLoading) { if (isPending) {
return return
} }
@@ -87,9 +87,10 @@ export const ProductMediaGallery = ({ product }: ProductMediaGalleryProps) => {
return return
} }
const mediaToKeep = product.images const mediaToKeep =
.filter((i) => i.id !== current.id) product.images
.map((i) => ({ url: i.url })) ?.filter((i) => i.id !== current.id)
.map((i) => ({ url: i.url })) || []
if (curr === media.length - 1) { if (curr === media.length - 1) {
setCurr((prev) => prev - 1) setCurr((prev) => prev - 1)
@@ -195,7 +196,7 @@ const Canvas = ({ media, curr }: { media: Media[]; curr: number }) => {
return ( return (
<div className="bg-ui-bg-subtle relative size-full overflow-hidden"> <div className="bg-ui-bg-subtle relative size-full overflow-hidden">
<div className="flex size-full items-center justify-center p-6"> <div className="flex size-full items-center justify-center p-6">
<div className="relative h-full w-fit"> <div className="relative inline-block max-h-full max-w-full">
{media[curr].isThumbnail && ( {media[curr].isThumbnail && (
<div className="absolute left-2 top-2"> <div className="absolute left-2 top-2">
<Tooltip content={t("products.media.thumbnailTooltip")}> <Tooltip content={t("products.media.thumbnailTooltip")}>
@@ -206,7 +207,7 @@ const Canvas = ({ media, curr }: { media: Media[]; curr: number }) => {
<img <img
src={media[curr].url} src={media[curr].url}
alt="" alt=""
className="object-fit shadow-elevation-card-rest size-full rounded-xl object-contain" className="object-fit shadow-elevation-card-rest max-h-[calc(100vh-200px)] w-auto rounded-xl object-contain"
/> />
</div> </div>
</div> </div>
@@ -1,9 +1,11 @@
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/modals" import { RouteFocusModal } from "../../../components/modals"
import { useProduct } from "../../../hooks/api/products" import { useProduct } from "../../../hooks/api/products"
import { ProductMediaView } from "./components/product-media-view" import { ProductMediaView } from "./components/product-media-view"
export const ProductMedia = () => { export const ProductMedia = () => {
const { t } = useTranslation()
const { id } = useParams() const { id } = useParams()
const { product, isLoading, isError, error } = useProduct(id!) const { product, isLoading, isError, error } = useProduct(id!)
@@ -16,6 +18,12 @@ export const ProductMedia = () => {
return ( return (
<RouteFocusModal> <RouteFocusModal>
<RouteFocusModal.Title asChild>
<span className="sr-only">{t("products.media.label")}</span>
</RouteFocusModal.Title>
<RouteFocusModal.Description asChild>
<span className="sr-only">{t("products.media.editHint")}</span>
</RouteFocusModal.Description>
{ready && <ProductMediaView product={product} />} {ready && <ProductMediaView product={product} />}
</RouteFocusModal> </RouteFocusModal>
) )
+6 -1
View File
@@ -49,6 +49,11 @@ export interface SoftDeletableEntity extends BaseEntity {
deleted_at: Date | null deleted_at: Date | null
} }
/**
* Temporary type fixing to allow any level of orders until we get to properly clean all the types
*/
export type FindConfigOrder = { [Key: string]: "ASC" | "DESC" | string & {} | FindConfigOrder }
/** /**
* @interface * @interface
* *
@@ -80,7 +85,7 @@ export interface FindConfig<Entity> {
* An object used to specify how to sort the returned records. Its keys are the names of attributes of the entity, and a key's value can either be `ASC` * An object used to specify how to sort the returned records. Its keys are the names of attributes of the entity, and a key's value can either be `ASC`
* to sort retrieved records in an ascending order, or `DESC` to sort retrieved records in a descending order. * to sort retrieved records in an ascending order, or `DESC` to sort retrieved records in a descending order.
*/ */
order?: Record<string, "ASC" | "DESC" | (string & {})> order?: FindConfigOrder
/** /**
* A boolean indicating whether deleted records should also be retrieved as part of the result. This only works if the entity extends the * A boolean indicating whether deleted records should also be retrieved as part of the result. This only works if the entity extends the
@@ -290,6 +290,10 @@ export interface BaseProductImage {
* The image's URL. * The image's URL.
*/ */
url: string url: string
/**
* The rank of the product image.
*/
rank: number
/** /**
* The date the image was created. * The date the image was created.
*/ */
@@ -595,6 +595,10 @@ export interface ProductImageDTO {
* The URL of the product image. * The URL of the product image.
*/ */
url: string url: string
/**
* The rank of the product image.
*/
rank: number
/** /**
* Holds custom data in key-value pairs. * Holds custom data in key-value pairs.
*/ */
@@ -1,5 +1,6 @@
import { buildQuery } from "../build-query" import { FindConfig } from "@medusajs/types"
import { SoftDeletableFilterKey } from "../../dal/mikro-orm/mikro-orm-soft-deletable-filter" import { SoftDeletableFilterKey } from "../../dal/mikro-orm/mikro-orm-soft-deletable-filter"
import { buildQuery } from "../build-query"
describe("buildQuery", () => { describe("buildQuery", () => {
test("should return empty where and basic options when no filters or config provided", () => { test("should return empty where and basic options when no filters or config provided", () => {
@@ -46,7 +47,7 @@ describe("buildQuery", () => {
}) })
test("should apply config options", () => { test("should apply config options", () => {
const config = { const config: FindConfig<any> = {
relations: ["user", "order"], relations: ["user", "order"],
select: ["id", "name"], select: ["id", "name"],
take: 10, take: 10,
@@ -12,17 +12,18 @@ import {
ProductStatus, ProductStatus,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import { import {
Image,
Product, Product,
ProductCategory, ProductCategory,
ProductCollection, ProductCollection,
ProductType, ProductType,
} from "@models" } from "@models"
import { UpdateProductInput } from "@types"
import { import {
MockEventBusService, MockEventBusService,
moduleIntegrationTestRunner, moduleIntegrationTestRunner,
} from "@medusajs/test-utils" } from "@medusajs/test-utils"
import { UpdateProductInput } from "@types"
import { import {
buildProductAndRelationsData, buildProductAndRelationsData,
createCollections, createCollections,
@@ -1236,6 +1237,154 @@ moduleIntegrationTestRunner<IProductModuleService>({
expect(products).toEqual([]) expect(products).toEqual([])
}) })
}) })
describe("images", function () {
it("should create images with correct rank", async () => {
const images = [
{ url: "image-1" },
{ url: "image-2" },
{ url: "image-3" },
]
const [product] = await service.createProducts([
buildProductAndRelationsData({ images }),
])
expect(product.images).toHaveLength(3)
expect(product.images).toEqual([
expect.objectContaining({
url: "image-1",
rank: 0,
}),
expect.objectContaining({
url: "image-2",
rank: 1,
}),
expect.objectContaining({
url: "image-3",
rank: 2,
}),
])
})
it("should update images with correct rank", async () => {
const images = [
{ url: "image-1" },
{ url: "image-2" },
{ url: "image-3" },
]
const [product] = await service.createProducts([
buildProductAndRelationsData({ images }),
])
const reversedImages = [...product.images].reverse()
const updatedProduct = await service.updateProducts(product.id, {
images: reversedImages,
})
expect(updatedProduct.images).toEqual([
expect.objectContaining({
url: "image-3",
rank: 0,
}),
expect.objectContaining({
url: "image-2",
rank: 1,
}),
expect.objectContaining({
url: "image-1",
rank: 2,
}),
])
})
it("should retrieve images in the correct order consistently", async () => {
const images = Array.from({ length: 1000 }, (_, i) => ({
url: `image-${i + 1}`,
}))
const [product] = await service.createProducts([
buildProductAndRelationsData({ images }),
])
const retrievedProduct = await service.retrieveProduct(product.id, {
relations: ["images"],
})
const retrievedProductAgain = await service.retrieveProduct(product.id, {
relations: ["images"],
})
expect(retrievedProduct.images).toEqual(retrievedProductAgain.images)
expect(retrievedProduct.images).toEqual(
Array.from({ length: 1000 }, (_, i) =>
expect.objectContaining({
url: `image-${i + 1}`,
rank: i,
})
)
)
service.listAndCountProducts
// Explicitly verify sequential order
retrievedProduct.images.forEach((img, idx) => {
if (idx > 0) {
expect(img.rank).toBeGreaterThan(retrievedProduct.images[idx - 1].rank)
}
})
})
it("should retrieve images ordered by rank", async () => {
const [product] = await service.createProducts([
buildProductAndRelationsData({}),
])
const manager = MikroOrmWrapper.forkManager()
const images = [
manager.create(Image, {
product_id: product.id,
url: "image-one",
rank: 1,
}),
manager.create(Image, {
product_id: product.id,
url: "image-two",
rank: 0,
}),
manager.create(Image, {
product_id: product.id,
url: "image-three",
rank: 2,
}),
]
await manager.persistAndFlush(images)
const retrievedProduct = await service.retrieveProduct(product.id, {
relations: ["images"],
})
expect(retrievedProduct.images).toEqual([
expect.objectContaining({
url: "image-two",
rank: 0,
}),
expect.objectContaining({
url: "image-one",
rank: 1,
}),
expect.objectContaining({
url: "image-three",
rank: 2,
}),
])
})
})
}) })
}, },
}) })
@@ -1,9 +1,8 @@
import { Image, Product, ProductCategory, ProductCollection } from "@models" import { Product, ProductCategory, ProductCollection } from "@models"
import { import {
assignCategoriesToProduct, assignCategoriesToProduct,
buildProductOnlyData, buildProductOnlyData,
createCollections, createCollections,
createImages,
createProductAndTags, createProductAndTags,
createProductVariants, createProductVariants,
} from "../__fixtures__/product" } from "../__fixtures__/product"
@@ -15,13 +14,13 @@ import {
ProductStatus, ProductStatus,
kebabCase, kebabCase,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { SqlEntityManager } from "@mikro-orm/postgresql" import { SqlEntityManager } from "@mikro-orm/postgresql"
import { import {
ProductCategoryService, ProductCategoryService,
ProductModuleService, ProductModuleService,
ProductService, ProductService,
} from "@services" } from "@services"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { import {
categoriesData, categoriesData,
productsData, productsData,
@@ -215,19 +214,12 @@ moduleIntegrationTestRunner<Service>({
}) })
describe("create", function () { describe("create", function () {
let images: Image[] = []
beforeEach(async () => { beforeEach(async () => {
testManager = await MikroOrmWrapper.forkManager() testManager = await MikroOrmWrapper.forkManager()
images = await createImages(testManager, ["image-1"])
}) })
it("should create a product", async () => { it("should create a product", async () => {
const data = buildProductOnlyData({ const data = buildProductOnlyData()
images,
thumbnail: images[0].url,
})
const products = await service.create([data]) const products = await service.create([data])
@@ -241,25 +233,15 @@ moduleIntegrationTestRunner<Service>({
subtitle: data.subtitle, subtitle: data.subtitle,
is_giftcard: data.is_giftcard, is_giftcard: data.is_giftcard,
discountable: data.discountable, discountable: data.discountable,
thumbnail: images[0].url,
status: data.status, status: data.status,
images: expect.arrayContaining([
expect.objectContaining({
id: images[0].id,
url: images[0].url,
}),
]),
}) })
) )
}) })
}) })
describe("update", function () { describe("update", function () {
let images: Image[] = []
beforeEach(async () => { beforeEach(async () => {
testManager = await MikroOrmWrapper.forkManager() testManager = await MikroOrmWrapper.forkManager()
images = await createImages(testManager, ["image-1", "image-2"])
productOne = testManager.create(Product, { productOne = testManager.create(Product, {
id: "product-1", id: "product-1",
@@ -275,8 +257,6 @@ moduleIntegrationTestRunner<Service>({
{ {
id: productOne.id, id: productOne.id,
title: "update test 1", title: "update test 1",
images: images,
thumbnail: images[0].url,
}, },
] ]
@@ -284,24 +264,13 @@ moduleIntegrationTestRunner<Service>({
expect(products.length).toEqual(1) expect(products.length).toEqual(1)
let result = await service.retrieve(productOne.id, { let result = await service.retrieve(productOne.id)
relations: ["images", "thumbnail"],
})
let serialized = JSON.parse(JSON.stringify(result)) let serialized = JSON.parse(JSON.stringify(result))
expect(serialized).toEqual( expect(serialized).toEqual(
expect.objectContaining({ expect.objectContaining({
id: productOne.id, id: productOne.id,
title: "update test 1", title: "update test 1",
thumbnail: images[0].url,
images: [
expect.objectContaining({
url: images[0].url,
}),
expect.objectContaining({
url: images[1].url,
}),
],
}) })
) )
}) })
@@ -750,19 +719,12 @@ moduleIntegrationTestRunner<Service>({
}) })
describe("softDelete", function () { describe("softDelete", function () {
let images: Image[] = []
beforeEach(async () => { beforeEach(async () => {
testManager = await MikroOrmWrapper.forkManager() testManager = await MikroOrmWrapper.forkManager()
images = await createImages(testManager, ["image-1"])
}) })
it("should soft delete a product", async () => { it("should soft delete a product", async () => {
const data = buildProductOnlyData({ const data = buildProductOnlyData()
images,
thumbnail: images[0].url,
})
const products = await service.create([data]) const products = await service.create([data])
await service.softDelete(products.map((p) => p.id)) await service.softDelete(products.map((p) => p.id))
@@ -785,19 +747,12 @@ moduleIntegrationTestRunner<Service>({
}) })
describe("restore", function () { describe("restore", function () {
let images: Image[] = []
beforeEach(async () => { beforeEach(async () => {
testManager = await MikroOrmWrapper.forkManager() testManager = await MikroOrmWrapper.forkManager()
images = await createImages(testManager, ["image-1"])
}) })
it("should restore a soft deleted product", async () => { it("should restore a soft deleted product", async () => {
const data = buildProductOnlyData({ const data = buildProductOnlyData()
images,
thumbnail: images[0].url,
})
const products = await service.create([data]) const products = await service.create([data])
const product = products[0] const product = products[0]
@@ -268,93 +268,6 @@
"checks": [], "checks": [],
"foreignKeys": {} "foreignKeys": {}
}, },
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"url": {
"name": "url",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "image",
"schema": "public",
"indexes": [
{
"columnNames": [
"deleted_at"
],
"composite": false,
"keyName": "IDX_product_image_deleted_at",
"primary": false,
"unique": false
},
{
"keyName": "image_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{ {
"columns": { "columns": {
"id": { "id": {
@@ -1033,6 +946,126 @@
} }
} }
}, },
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"url": {
"name": "url",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
},
"rank": {
"name": "rank",
"type": "integer",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "0",
"mappedType": "integer"
},
"product_id": {
"name": "product_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
}
},
"name": "image",
"schema": "public",
"indexes": [
{
"columnNames": [
"deleted_at"
],
"composite": false,
"keyName": "IDX_product_image_deleted_at",
"primary": false,
"unique": false
},
{
"keyName": "image_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"image_product_id_foreign": {
"constraintName": "image_product_id_foreign",
"columnNames": [
"product_id"
],
"localTableName": "public.image",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.product",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
},
{ {
"columns": { "columns": {
"product_id": { "product_id": {
@@ -1098,71 +1131,6 @@
} }
} }
}, },
{
"columns": {
"product_id": {
"name": "product_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"image_id": {
"name": "image_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
}
},
"name": "product_images",
"schema": "public",
"indexes": [
{
"keyName": "product_images_pkey",
"columnNames": [
"product_id",
"image_id"
],
"composite": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"product_images_product_id_foreign": {
"constraintName": "product_images_product_id_foreign",
"columnNames": [
"product_id"
],
"localTableName": "public.product_images",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.product",
"deleteRule": "cascade",
"updateRule": "cascade"
},
"product_images_image_id_foreign": {
"constraintName": "product_images_image_id_foreign",
"columnNames": [
"image_id"
],
"localTableName": "public.product_images",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.image",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
},
{ {
"columns": { "columns": {
"product_id": { "product_id": {
@@ -0,0 +1,45 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20241122120331 extends Migration {
async up(): Promise<void> {
this.addSql('alter table if exists "image" add column if not exists "rank" integer not null default 0, add column if not exists "product_id" text not null;');
// Migrate existing relationships
this.addSql(`
update "image" i
set product_id = pi.product_id,
rank = (
select count(*)
from product_images pi2
where pi2.product_id = pi.product_id
and pi2.image_id <= pi.image_id
) - 1
from "product_images" pi
where pi.image_id = i.id;
`);
this.addSql('alter table if exists "image" add constraint "image_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;');
this.addSql('drop table if exists "product_images" cascade;');
}
async down(): Promise<void> {
this.addSql('create table if not exists "product_images" ("product_id" text not null, "image_id" text not null, constraint "product_images_pkey" primary key ("product_id", "image_id"));');
// Migrate relationships back to join table
this.addSql(`
insert into "product_images" (product_id, image_id)
select product_id, id
from "image"
where product_id is not null;
`);
this.addSql('alter table if exists "product_images" add constraint "product_images_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_images" add constraint "product_images_image_id_foreign" foreign key ("image_id") references "image" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "image" drop constraint if exists "image_product_id_foreign";');
this.addSql('alter table if exists "image" drop column if exists "rank";');
this.addSql('alter table if exists "image" drop column if exists "product_id";');
}
}
+3 -3
View File
@@ -1,9 +1,9 @@
export { default as Product } from "./product" export { default as Product } from "./product"
export { default as ProductCategory } from "./product-category" export { default as ProductCategory } from "./product-category"
export { default as ProductCollection } from "./product-collection" export { default as ProductCollection } from "./product-collection"
export { default as Image } from "./product-image"
export { default as ProductOption } from "./product-option"
export { default as ProductOptionValue } from "./product-option-value"
export { default as ProductTag } from "./product-tag" export { default as ProductTag } from "./product-tag"
export { default as ProductType } from "./product-type" export { default as ProductType } from "./product-type"
export { default as ProductVariant } from "./product-variant" export { default as ProductVariant } from "./product-variant"
export { default as ProductOption } from "./product-option"
export { default as ProductOptionValue } from "./product-option-value"
export { default as Image } from "./product-image"
@@ -1,13 +1,13 @@
import { import {
BeforeCreate, BeforeCreate,
Collection,
Entity, Entity,
Filter, Filter,
Index, Index,
ManyToMany, ManyToOne,
OnInit, OnInit,
PrimaryKey, PrimaryKey,
Property, Property,
Rel,
} from "@mikro-orm/core" } from "@mikro-orm/core"
import { import {
@@ -58,8 +58,21 @@ class ProductImage {
@Property({ columnType: "timestamptz", nullable: true }) @Property({ columnType: "timestamptz", nullable: true })
deleted_at?: Date deleted_at?: Date
@ManyToMany(() => Product, (product) => product.images) @Property({ columnType: "integer", default: 0 })
products = new Collection<Product>(this) rank: number
@ManyToOne(() => Product, {
columnType: "text",
onDelete: "cascade",
fieldName: "product_id",
mapToPk: true,
})
product_id: string
@ManyToOne(() => Product, {
persist: false,
})
product: Rel<Product>
@OnInit() @OnInit()
onInit() { onInit() {
@@ -1,5 +1,6 @@
import { import {
BeforeCreate, BeforeCreate,
Cascade,
Collection, Collection,
Entity, Entity,
Enum, Enum,
@@ -166,11 +167,8 @@ class Product {
}) })
tags = new Collection<ProductTag>(this) tags = new Collection<ProductTag>(this)
@ManyToMany(() => ProductImage, "products", { @OneToMany(() => ProductImage, (image) => image.product_id, {
owner: true, cascade: [Cascade.PERSIST, Cascade.REMOVE],
pivotTable: "product_images",
joinColumn: "product_id",
inverseJoinColumn: "image_id",
}) })
images = new Collection<ProductImage>(this) images = new Collection<ProductImage>(this)
@@ -128,6 +128,7 @@ type ProductOption {
type ProductImage { type ProductImage {
id: ID! id: ID!
url: String! url: String!
rank: Int!
metadata: JSON metadata: JSON
created_at: DateTime! created_at: DateTime!
updated_at: DateTime! updated_at: DateTime!
@@ -1,6 +1,7 @@
import { import {
Context, Context,
DAL, DAL,
FindConfig,
IEventBusModuleService, IEventBusModuleService,
InternalModuleDeclaration, InternalModuleDeclaration,
ModuleJoinerConfig, ModuleJoinerConfig,
@@ -8,10 +9,10 @@ import {
ProductTypes, ProductTypes,
} from "@medusajs/framework/types" } from "@medusajs/framework/types"
import { import {
Image as ProductImage,
Product, Product,
ProductCategory, ProductCategory,
ProductCollection, ProductCollection,
Image as ProductImage,
ProductOption, ProductOption,
ProductOptionValue, ProductOptionValue,
ProductTag, ProductTag,
@@ -58,6 +59,7 @@ type InjectedDependencies = {
productCategoryService: ProductCategoryService productCategoryService: ProductCategoryService
productCollectionService: ModulesSdkTypes.IMedusaInternalService<any> productCollectionService: ModulesSdkTypes.IMedusaInternalService<any>
productImageService: ModulesSdkTypes.IMedusaInternalService<any> productImageService: ModulesSdkTypes.IMedusaInternalService<any>
productImageProductService: ModulesSdkTypes.IMedusaInternalService<any>
productTypeService: ModulesSdkTypes.IMedusaInternalService<any> productTypeService: ModulesSdkTypes.IMedusaInternalService<any>
productOptionService: ModulesSdkTypes.IMedusaInternalService<any> productOptionService: ModulesSdkTypes.IMedusaInternalService<any>
productOptionValueService: ModulesSdkTypes.IMedusaInternalService<any> productOptionValueService: ModulesSdkTypes.IMedusaInternalService<any>
@@ -151,6 +153,74 @@ export default class ProductModuleService
return joinerConfig return joinerConfig
} }
@InjectManager()
// @ts-ignore
async retrieveProduct(
productId: string,
config?: FindConfig<ProductTypes.ProductDTO>,
@MedusaContext() sharedContext?: Context
): Promise<ProductTypes.ProductDTO> {
const product = await this.productService_.retrieve(
productId,
this.getProductFindConfig_(config),
sharedContext
)
return this.baseRepository_.serialize<ProductTypes.ProductDTO>(product)
}
@InjectManager()
// @ts-ignore
async listProducts(
filters?: ProductTypes.FilterableProductProps,
config?: FindConfig<ProductTypes.ProductDTO>,
sharedContext?: Context
): Promise<ProductTypes.ProductDTO[]> {
const products = await this.productService_.list(
filters,
this.getProductFindConfig_(config),
sharedContext
)
return this.baseRepository_.serialize<ProductTypes.ProductDTO[]>(products)
}
@InjectManager()
// @ts-ignore
async listAndCountProducts(
filters?: ProductTypes.FilterableProductProps,
config?: FindConfig<ProductTypes.ProductDTO>,
sharedContext?: Context
): Promise<[ProductTypes.ProductDTO[], number]> {
const [products, count] = await this.productService_.listAndCount(
filters,
this.getProductFindConfig_(config),
sharedContext
)
const serializedProducts = await this.baseRepository_.serialize<
ProductTypes.ProductDTO[]
>(products)
return [serializedProducts, count]
}
protected getProductFindConfig_(
config?: FindConfig<ProductTypes.ProductDTO>
): FindConfig<ProductTypes.ProductDTO> {
const hasImagesRelation = config?.relations?.includes("images")
return {
...config,
order: {
...config?.order,
...(hasImagesRelation ? {
images: {
rank: "ASC",
},
} : {}),
},
}
}
// @ts-ignore // @ts-ignore
createProductVariants( createProductVariants(
data: ProductTypes.CreateProductVariantDTO[], data: ProductTypes.CreateProductVariantDTO[],
@@ -1440,7 +1510,7 @@ export default class ProductModuleService
await this.productService_.upsertWithReplace( await this.productService_.upsertWithReplace(
normalizedInput, normalizedInput,
{ {
relations: ["images", "tags", "categories"], relations: ["tags", "categories"],
}, },
sharedContext sharedContext
) )
@@ -1480,6 +1550,27 @@ export default class ProductModuleService
) )
upsertedProduct.variants = productVariants upsertedProduct.variants = productVariants
} }
if (Array.isArray(product.images)) {
if (product.images.length) {
const { entities: productImages } =
await this.productImageService_.upsertWithReplace(
product.images.map((image, rank) => ({
...image,
product_id: upsertedProduct.id,
rank,
})),
{},
sharedContext
)
upsertedProduct.images = productImages
} else {
await this.productImageService_.delete(
{ product_id: upsertedProduct.id },
sharedContext
)
}
}
}) })
) )
@@ -1506,7 +1597,7 @@ export default class ProductModuleService
await this.productService_.upsertWithReplace( await this.productService_.upsertWithReplace(
normalizedInput, normalizedInput,
{ {
relations: ["images", "tags", "categories"], relations: ["tags", "categories"],
}, },
sharedContext sharedContext
) )
@@ -1585,6 +1676,27 @@ export default class ProductModuleService
sharedContext sharedContext
) )
} }
if (Array.isArray(product.images)) {
if (product.images.length) {
const { entities: productImages } =
await this.productImageService_.upsertWithReplace(
product.images.map((image, rank) => ({
...image,
product_id: upsertedProduct.id,
rank,
})),
{},
sharedContext
)
upsertedProduct.images = productImages
} else {
await this.productImageService_.delete(
{ product_id: upsertedProduct.id },
sharedContext
)
}
}
}) })
) )