From 1659c9be5d3ace1bed9f3a6e7206fe54e60645c0 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Mon, 25 Nov 2024 09:03:10 +0100 Subject: [PATCH] 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> --- .changeset/flat-mugs-try.md | 7 + .../__tests__/product/admin/product.spec.ts | 36 ++ .../__tests__/product/store/product.spec.ts | 43 +++ .../route-modal-form/route-modal-form.tsx | 4 +- .../src/i18n/translations/$schema.json | 14 +- .../dashboard/src/i18n/translations/de.json | 5 +- .../dashboard/src/i18n/translations/en.json | 5 +- .../dashboard/src/i18n/translations/pl.json | 7 +- .../dashboard/src/i18n/translations/tr.json | 5 +- .../components/media-grid-view/index.ts | 1 - .../media-grid-view/media-grid-view.tsx | 125 ------- .../upload-media-form-item.tsx | 58 ++-- .../product-create-details-media-section.tsx | 245 +++++++++++--- .../product-create-form.tsx | 312 +++++++++--------- .../product-create/product-create.tsx | 11 +- .../routes/products/product-create/utils.ts | 15 +- .../edit-product-media-form.tsx | 247 +++++++++++++- .../product-media-gallery.tsx | 27 +- .../products/product-media/product-media.tsx | 8 + packages/core/types/src/common/common.ts | 7 +- .../core/types/src/http/product/common.ts | 4 + packages/core/types/src/product/common.ts | 4 + .../modules-sdk/__tests__/build-query.spec.ts | 5 +- .../product-module-service/products.spec.ts | 151 ++++++++- .../integration-tests/__tests__/product.ts | 57 +--- .../migrations/.snapshot-medusa-product.json | 272 +++++++-------- .../src/migrations/Migration20241122120331.ts | 45 +++ packages/modules/product/src/models/index.ts | 6 +- .../product/src/models/product-image.ts | 21 +- .../modules/product/src/models/product.ts | 8 +- packages/modules/product/src/schema/index.ts | 1 + .../src/services/product-module-service.ts | 118 ++++++- 32 files changed, 1257 insertions(+), 617 deletions(-) create mode 100644 .changeset/flat-mugs-try.md delete mode 100644 packages/admin/dashboard/src/routes/products/common/components/media-grid-view/index.ts delete mode 100644 packages/admin/dashboard/src/routes/products/common/components/media-grid-view/media-grid-view.tsx create mode 100644 packages/modules/product/src/migrations/Migration20241122120331.ts diff --git a/.changeset/flat-mugs-try.md b/.changeset/flat-mugs-try.md new file mode 100644 index 0000000000..5b9eba5562 --- /dev/null +++ b/.changeset/flat-mugs-try.md @@ -0,0 +1,7 @@ +--- +"@medusajs/dashboard": patch +"@medusajs/product": patch +"@medusajs/types": patch +--- + +feat(dashboard): Allow re-ordering product images diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index bc6d52e593..e7e90014a4 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -74,6 +74,13 @@ medusaIntegrationTestRunner({ // BREAKING: Type input changed from {type: {value: string}} to {type_id: string} type_id: baseType.id, tags: [{ id: baseTag1.id }, { id: baseTag2.id }], + images: [{ + url: "image-one", + }, + { + url: "image-two", + }, + ], }), adminHeaders ) @@ -116,6 +123,24 @@ medusaIntegrationTestRunner({ describe("/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 () => { const res = await api .get("/admin/products", adminHeaders) @@ -965,6 +990,17 @@ medusaIntegrationTestRunner({ 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 () => { const res = await api .get( diff --git a/integration-tests/http/__tests__/product/store/product.spec.ts b/integration-tests/http/__tests__/product/store/product.spec.ts index e5618d5f4e..4cbd997319 100644 --- a/integration-tests/http/__tests__/product/store/product.spec.ts +++ b/integration-tests/http/__tests__/product/store/product.spec.ts @@ -526,6 +526,14 @@ medusaIntegrationTestRunner({ prices: [{ amount: 3000, currency_code: "usd" }], }, ], + images: [ + { + url: "image-one", + }, + { + url: "image-two", + }, + ], }) ;[product2, [variant2]] = await createProducts({ 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 () => { let response = await api.get( `/admin/products?fields=-variants`, @@ -1406,6 +1430,14 @@ medusaIntegrationTestRunner({ ], }, ], + images: [ + { + url: "image-one", + }, + { + url: "image-two", + } + ], }) 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 // 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 diff --git a/packages/admin/dashboard/src/components/modals/route-modal-form/route-modal-form.tsx b/packages/admin/dashboard/src/components/modals/route-modal-form/route-modal-form.tsx index 2135f6c696..69ececbcdb 100644 --- a/packages/admin/dashboard/src/components/modals/route-modal-form/route-modal-form.tsx +++ b/packages/admin/dashboard/src/components/modals/route-modal-form/route-modal-form.tsx @@ -7,13 +7,13 @@ import { Form } from "../../common/form" type RouteModalFormProps = PropsWithChildren<{ form: UseFormReturn - blockSearch?: boolean + blockSearchParams?: boolean onClose?: (isSubmitSuccessful: boolean) => void }> export const RouteModalForm = ({ form, - blockSearch = false, + blockSearchParams: blockSearch = false, children, onClose, }: RouteModalFormProps) => { diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index ee0ac2f1c2..925f1d2830 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -1578,6 +1578,12 @@ "create": { "type": "object", "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, "header": { "type": "string" }, @@ -1742,6 +1748,8 @@ } }, "required": [ + "title", + "description", "header", "tabs", "errors", @@ -1990,6 +1998,9 @@ "action" ], "additionalProperties": false + }, + "successToast": { + "type": "string" } }, "required": [ @@ -2008,7 +2019,8 @@ "galleryLabel", "downloadImageLabel", "deleteImageLabel", - "emptyState" + "emptyState", + "successToast" ], "additionalProperties": false }, diff --git a/packages/admin/dashboard/src/i18n/translations/de.json b/packages/admin/dashboard/src/i18n/translations/de.json index f433dfc435..09b6f9c8e7 100644 --- a/packages/admin/dashboard/src/i18n/translations/de.json +++ b/packages/admin/dashboard/src/i18n/translations/de.json @@ -386,6 +386,8 @@ "successToast": "Produkz {{title}} angepasst." }, "create": { + "title": "Produkt erstellen", + "description": "Erstellen Sie ein neues Produkt.", "header": "Allgemein", "tabs": { "details": "Details", @@ -490,7 +492,8 @@ "header": "Noch keine Medien", "description": "Fügen Sie dem Produkt Medien hinzu, um es in Ihrem Schaufenster zu präsentieren.", "action": "Medien hinzufügen" - } + }, + "successToast": "Medien wurden erfolgreich aktualisiert." }, "discountableHint": "Wenn diese Option deaktiviert ist, werden auf dieses Produkt keine Rabatte gewährt.", "noSalesChannels": "In keinem Vertriebskanal verfügbar", diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 4725c1885b..1bd01a0842 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -386,6 +386,8 @@ "successToast": "Product {{title}} was successfully updated." }, "create": { + "title": "Create Product", + "description": "Create a new product.", "header": "General", "tabs": { "details": "Details", @@ -490,7 +492,8 @@ "header": "No media yet", "description": "Add media to the product to showcase it in your storefront.", "action": "Add media" - } + }, + "successToast": "Media was successfully updated." }, "discountableHint": "When unchecked, discounts will not be applied to this product.", "noSalesChannels": "Not available in any sales channels", diff --git a/packages/admin/dashboard/src/i18n/translations/pl.json b/packages/admin/dashboard/src/i18n/translations/pl.json index 3f807b3f58..69e4adcab2 100644 --- a/packages/admin/dashboard/src/i18n/translations/pl.json +++ b/packages/admin/dashboard/src/i18n/translations/pl.json @@ -386,6 +386,8 @@ "successToast": "Produkt {{title}} został pomyślnie zaktualizowany." }, "create": { + "title": "Utwórz produkt", + "description": "Utwórz nowy produkt.", "header": "Ogólne", "tabs": { "details": "Szczegóły", @@ -490,7 +492,8 @@ "header": "Brak mediów", "description": "Dodaj media do produktu, aby zaprezentować go w swoim sklepie.", "action": "Dodaj media" - } + }, + "successToast": "Media zostały pomyślnie zaktualizowane." }, "discountableHint": "Jeśli odznaczone, rabaty nie będą stosowane do tego produktu.", "noSalesChannels": "Niedostępny w żadnych kanałach sprzedaży", @@ -2752,4 +2755,4 @@ "seconds_one": "Drugi", "seconds_other": "Towary drugiej jakości" } -} \ No newline at end of file +} diff --git a/packages/admin/dashboard/src/i18n/translations/tr.json b/packages/admin/dashboard/src/i18n/translations/tr.json index 9812581eb7..d52e3dce14 100644 --- a/packages/admin/dashboard/src/i18n/translations/tr.json +++ b/packages/admin/dashboard/src/i18n/translations/tr.json @@ -386,6 +386,8 @@ "successToast": "Ürün {{title}} başarıyla güncellendi." }, "create": { + "title": "Ürün Oluştur", + "description": "Yeni bir ürün oluşturun.", "header": "Genel", "tabs": { "details": "Detaylar", @@ -490,7 +492,8 @@ "header": "Henüz medya yok", "description": "Ürünü mağazanızda sergilemek için medya ekleyin.", "action": "Medya ekle" - } + }, + "successToast": "Medya başarıyla güncellendi." }, "discountableHint": "İşaretlenmediğinde, bu ürüne indirim uygulanmayacaktır.", "noSalesChannels": "Hiçbir satış kanalında mevcut değil", diff --git a/packages/admin/dashboard/src/routes/products/common/components/media-grid-view/index.ts b/packages/admin/dashboard/src/routes/products/common/components/media-grid-view/index.ts deleted file mode 100644 index 4b124fbbb8..0000000000 --- a/packages/admin/dashboard/src/routes/products/common/components/media-grid-view/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./media-grid-view" diff --git a/packages/admin/dashboard/src/routes/products/common/components/media-grid-view/media-grid-view.tsx b/packages/admin/dashboard/src/routes/products/common/components/media-grid-view/media-grid-view.tsx deleted file mode 100644 index 122636cecb..0000000000 --- a/packages/admin/dashboard/src/routes/products/common/components/media-grid-view/media-grid-view.tsx +++ /dev/null @@ -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 - onCheckedChange: (id: string) => (value: boolean) => void -} - -export const MediaGrid = ({ - media, - selection, - onCheckedChange, -}: MediaGridProps) => { - return ( -
-
- {media.map((m) => { - return ( - - ) - })} -
-
- ) -} - -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 ( - - ) -} diff --git a/packages/admin/dashboard/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx b/packages/admin/dashboard/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx index 883ef812e2..2d23cb8f74 100644 --- a/packages/admin/dashboard/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx +++ b/packages/admin/dashboard/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx @@ -1,3 +1,4 @@ +import { useCallback } from "react" import { UseFormReturn } from "react-hook-form" import { useTranslation } from "react-i18next" import { z } from "zod" @@ -45,25 +46,40 @@ export const UploadMediaFormItem = ({ }) => { const { t } = useTranslation() - const hasInvalidFiles = (fileList: FileType[]) => { - const invalidFile = fileList.find( - (f) => !SUPPORTED_FORMATS.includes(f.file.type) - ) + const hasInvalidFiles = useCallback( + (fileList: FileType[]) => { + const invalidFile = fileList.find( + (f) => !SUPPORTED_FORMATS.includes(f.file.type) + ) - if (invalidFile) { - form.setError("media", { - type: "invalid_file", - message: t("products.media.invalidFileType", { - name: invalidFile.file.name, - types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "), - }), - }) + if (invalidFile) { + form.setError("media", { + type: "invalid_file", + message: t("products.media.invalidFileType", { + name: invalidFile.file.name, + types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "), + }), + }) - return true - } + return true + } - 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 ( { - 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 })) - }} + onUploaded={onUploaded} /> diff --git a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-media-section/product-create-details-media-section.tsx b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-media-section/product-create-details-media-section.tsx index 7272d893bd..6cb5246586 100644 --- a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-media-section/product-create-details-media-section.tsx +++ b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-media-section/product-create-details-media-section.tsx @@ -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 { useEffect, useState } from "react" -import { UseFormReturn, useFieldArray } from "react-hook-form" +import { useState } from "react" +import { useFieldArray, UseFormReturn } from "react-hook-form" import { useTranslation } from "react-i18next" import { ActionMenu } from "../../../../../../../components/common/action-menu" import { UploadMediaFormItem } from "../../../../../common/components/upload-media-form-item" @@ -11,6 +37,16 @@ type ProductCreateMediaSectionProps = { form: UseFormReturn } +const dropAnimationConfig: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: "0.4", + }, + }, + }), +} + export const ProductCreateMediaSection = ({ form, }: ProductCreateMediaSectionProps) => { @@ -20,6 +56,38 @@ export const ProductCreateMediaSection = ({ keyName: "field_id", }) + const [activeId, setActiveId] = useState(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) => { return () => { remove(index) @@ -52,20 +120,36 @@ export const ProductCreateMediaSection = ({ return (
-
    - {fields.map((field, index) => { - const { onDelete, onMakeThumbnail } = getItemHandlers(index) - - return ( - + + {activeId ? ( + m.field_id === activeId)!} /> - ) - })} -
+ ) : null} + +
    + field.field_id)}> + {fields.map((field, index) => { + const { onDelete, onMakeThumbnail } = getItemHandlers(index) + + return ( + + ) + })} + +
+
) } @@ -87,25 +171,62 @@ type MediaItemProps = { const MediaItem = ({ field, onDelete, onMakeThumbnail }: MediaItemProps) => { 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) { return null } return ( -
  • -
    -
    - -
    -
    - - {field.file.name} - -
    - {field.isThumbnail && } - - {formatFileSize(field.file.size)} +
  • +
    + + + +
    +
    + +
    +
    + + {field.file.name} +
    + {field.isThumbnail && } + + {formatFileSize(field.file.size)} + +
    @@ -145,28 +266,60 @@ const MediaItem = ({ field, onDelete, onMakeThumbnail }: MediaItemProps) => { ) } -const ThumbnailPreview = ({ file }: { file?: File | null }) => { - const [thumbnailUrl, setThumbnailUrl] = useState(null) +const MediaGridItemOverlay = ({ field }: { field: MediaField }) => { + return ( +
  • +
    + + + +
    +
    + +
    +
    + + {field.file?.name} + +
    + {field.isThumbnail && } + + {formatFileSize(field.file?.size ?? 0)} + +
    +
    +
    +
    +
    + + {}} + > + + +
    +
  • + ) +} - useEffect(() => { - if (file) { - const objectUrl = URL.createObjectURL(file) - setThumbnailUrl(objectUrl) - - return () => URL.revokeObjectURL(objectUrl) - } - }, [file]) - - if (!thumbnailUrl) { +const ThumbnailPreview = ({ url }: { url?: string | null }) => { + if (!url) { return null } return ( - + ) } diff --git a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx index 566ea2fa22..f8bdcdc76c 100644 --- a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx +++ b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx @@ -14,7 +14,6 @@ import { } from "../../../../../extensions" import { useCreateProduct } from "../../../../../hooks/api/products" import { sdk } from "../../../../../lib/client" -import { isFetchError } from "../../../../../lib/is-fetch-error" import { PRODUCT_CREATE_FORM_DEFAULTS, ProductCreateSchema, @@ -80,13 +79,10 @@ export const ProductCreateForm = ({ return {} } - return regions.reduce( - (acc, reg) => { - acc[reg.id] = reg.currency_code - return acc - }, - {} as Record - ) + return regions.reduce((acc, reg) => { + acc[reg.id] = reg.currency_code + return acc + }, {} as Record) }, [regions]) /** @@ -140,32 +136,34 @@ export const ProductCreateForm = ({ uploadedMedia = (await Promise.all(fileReqs)).flat() } - - const { product } = await mutateAsync( - normalizeProductFormValues({ - ...payload, - media: uploadedMedia, - status: (isDraftSubmission ? "draft" : "published") as any, - regionsCurrencyMap, - }) - ) - - toast.success( - t("products.create.successToast", { - title: product.title, - }) - ) - - handleSuccess(`../${product.id}`) } catch (error) { - if (isFetchError(error) && error.status === 400) { + if (error instanceof Error) { toast.error(error.message) - } else { - toast.error(t("general.error"), { - description: error.message, - }) } } + + await mutateAsync( + normalizeProductFormValues({ + ...payload, + media: uploadedMedia, + status: (isDraftSubmission ? "draft" : "published") as any, + regionsCurrencyMap, + }), + { + onSuccess: (data) => { + toast.success( + t("products.create.successToast", { + title: data.product.title, + }) + ) + + handleSuccess(`../${data.product.id}`) + }, + onError: (error) => { + toast.error(error.message) + }, + } + ) }) const onNext = async (currentTab: Tab) => { @@ -210,143 +208,141 @@ export const ProductCreateForm = ({ } setTabState({ ...currentState }) - }, [tab]) + }, [tab, tabState]) return ( - - - { - // We want to continue to the next tab on enter instead of saving as draft immediately - if (e.key === "Enter") { - e.preventDefault() + + { + // We want to continue to the next tab on enter instead of saving as draft immediately + if (e.key === "Enter") { + e.preventDefault() - if (e.metaKey || e.ctrlKey) { - if (tab !== Tab.VARIANTS) { - e.preventDefault() - e.stopPropagation() - onNext(tab) + if (e.metaKey || e.ctrlKey) { + if (tab !== Tab.VARIANTS) { + e.preventDefault() + e.stopPropagation() + onNext(tab) - return - } - - handleSubmit() - } - } - }} - onSubmit={handleSubmit} - className="flex h-full flex-col" - > - { - const valid = await form.trigger() - - if (!valid) { return } - setTab(tab as Tab) - }} - className="flex h-full flex-col overflow-hidden" - > - -
    - - - {t("products.create.tabs.details")} - - - {t("products.create.tabs.organize")} - - - {t("products.create.tabs.variants")} - - {showInventoryTab && ( - - {t("products.create.tabs.inventory")} - - )} - -
    -
    - - - - - - - - - - - {showInventoryTab && ( - + { + const valid = await form.trigger() + + if (!valid) { + return + } + + setTab(tab as Tab) + }} + className="flex h-full flex-col overflow-hidden" + > + +
    + + - - - )} - - - -
    - - - - - + {t("products.create.tabs.details")} + + + {t("products.create.tabs.organize")} + + + {t("products.create.tabs.variants")} + + {showInventoryTab && ( + + {t("products.create.tabs.inventory")} + + )} +
    -
    - - - + + + + + + + + + + + + {showInventoryTab && ( + + + + )} + + + +
    + + + + + +
    +
    + + ) } diff --git a/packages/admin/dashboard/src/routes/products/product-create/product-create.tsx b/packages/admin/dashboard/src/routes/products/product-create/product-create.tsx index c9c872cfa9..fd65e86e13 100644 --- a/packages/admin/dashboard/src/routes/products/product-create/product-create.tsx +++ b/packages/admin/dashboard/src/routes/products/product-create/product-create.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next" import { RouteFocusModal } from "../../../components/modals" import { useRegions } from "../../../hooks/api" 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" export const ProductCreate = () => { + const { t } = useTranslation() + const { store, isPending: isStorePending, isError: isStoreError, error: storeError, } = useStore({ - fields: "default_sales_channel", + fields: "+default_sales_channel", }) const { @@ -68,6 +71,12 @@ export const ProductCreate = () => { return ( + + {t("products.create.title")} + + + {t("products.create.description")} + {ready && ( } -) => { +): HttpTypes.AdminCreateProduct => { const thumbnail = values.media?.find((media) => media.isThumbnail)?.url const images = values.media ?.filter((media) => !media.isThumbnail) @@ -51,7 +51,7 @@ export const normalizeProductFormValues = ( export const normalizeVariants = ( variants: ProductCreateSchemaType["variants"], regionsCurrencyMap: Record -) => { +): HttpTypes.AdminCreateProductVariant[] => { return variants.map((variant) => ({ title: variant.title || Object.values(variant.options || {}).join(" / "), options: variant.options, @@ -60,7 +60,9 @@ export const normalizeVariants = ( allow_backorder: !!variant.allow_backorder, inventory_items: variant .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) { return false @@ -71,7 +73,12 @@ export const normalizeVariants = ( required_quantity: quantity, } }) - .filter(Boolean), + .filter( + ( + item + ): item is { required_quantity: number; inventory_item_id: string } => + item !== false + ), prices: Object.entries(variant.prices || {}) .map(([key, value]: any) => { if (value === "" || value === undefined) { diff --git a/packages/admin/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx b/packages/admin/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx index 224147a171..11ab0f7487 100644 --- a/packages/admin/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx +++ b/packages/admin/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx @@ -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 { 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 { useFieldArray, useForm } from "react-hook-form" import { useTranslation } from "react-i18next" +import { Link } from "react-router-dom" import { z } from "zod" -import { HttpTypes } from "@medusajs/types" -import { Link } from "react-router-dom" import { RouteFocusModal, useRouteModal, @@ -14,7 +36,6 @@ import { import { KeyboundForm } from "../../../../../components/utilities/keybound-form" import { useUpdateProduct } from "../../../../../hooks/api/products" import { sdk } from "../../../../../lib/client" -import { MediaGrid } from "../../../common/components/media-grid-view" import { UploadMediaFormItem } from "../../../common/components/upload-media-form-item" import { EditProductMediaSchema, @@ -46,6 +67,38 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => { keyName: "field_id", }) + const [activeId, setActiveId] = useState(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 handleSubmit = form.handleSubmit(async ({ media }) => { @@ -80,13 +133,16 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => { await mutateAsync( { 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: () => { + toast.success(t("products.media.successToast")) handleSuccess() }, + onError: (error) => { + toast.error(error.message) + }, } ) }) @@ -142,7 +198,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => { const selectionCount = Object.keys(selection).length return ( - + {
    - + +
    +
    + m.field_id)} + strategy={rectSortingStrategy} + > + {fields.map((m) => { + return ( + + ) + })} + + + {activeId ? ( + m.field_id === activeId)!} + checked={ + !!selection[ + fields.find((m) => m.field_id === activeId)!.id! + ] + } + /> + ) : null} + +
    +
    +
    @@ -211,8 +300,8 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => { } const getDefaultValues = ( - images: HttpTypes.AdminProductImage[] | undefined, - thumbnail: string | undefined + images: HttpTypes.AdminProductImage[] | null | undefined, + thumbnail: string | null | undefined ) => { const media: Media[] = images?.map((image) => ({ @@ -235,3 +324,133 @@ const getDefaultValues = ( 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 ( +
    + {media.isThumbnail && ( +
    + + + +
    + )} +
    +
    + { + e.stopPropagation() + }} + checked={checked} + onCheckedChange={handleToggle} + /> +
    + +
    + ) +} + +export const MediaGridItemOverlay = ({ + media, + checked, +}: { + media: MediaView + checked: boolean +}) => { + return ( +
    + {media.isThumbnail && ( +
    + +
    + )} +
    + +
    + +
    + ) +} diff --git a/packages/admin/dashboard/src/routes/products/product-media/components/product-media-gallery/product-media-gallery.tsx b/packages/admin/dashboard/src/routes/products/product-media/components/product-media-gallery/product-media-gallery.tsx index 3bdb10d846..1f410642e0 100644 --- a/packages/admin/dashboard/src/routes/products/product-media/components/product-media-gallery/product-media-gallery.tsx +++ b/packages/admin/dashboard/src/routes/products/product-media/components/product-media-gallery/product-media-gallery.tsx @@ -24,39 +24,39 @@ export const ProductMediaGallery = ({ product }: ProductMediaGalleryProps) => { const { t } = useTranslation() const prompt = usePrompt() - const { mutateAsync, isLoading } = useUpdateProduct(product.id) + const { mutateAsync, isPending } = useUpdateProduct(product.id) const media = getMedia(product.images, product.thumbnail) const next = useCallback(() => { - if (isLoading) { + if (isPending) { return } setCurr((prev) => (prev + 1) % media.length) - }, [media, isLoading]) + }, [media, isPending]) const prev = useCallback(() => { - if (isLoading) { + if (isPending) { return } setCurr((prev) => (prev - 1 + media.length) % media.length) - }, [media, isLoading]) + }, [media, isPending]) const goTo = useCallback( (index: number) => { - if (isLoading) { + if (isPending) { return } setCurr(index) }, - [isLoading] + [isPending] ) const handleDownloadCurrent = () => { - if (isLoading) { + if (isPending) { return } @@ -87,9 +87,10 @@ export const ProductMediaGallery = ({ product }: ProductMediaGalleryProps) => { return } - const mediaToKeep = product.images - .filter((i) => i.id !== current.id) - .map((i) => ({ url: i.url })) + const mediaToKeep = + product.images + ?.filter((i) => i.id !== current.id) + .map((i) => ({ url: i.url })) || [] if (curr === media.length - 1) { setCurr((prev) => prev - 1) @@ -195,7 +196,7 @@ const Canvas = ({ media, curr }: { media: Media[]; curr: number }) => { return (
    -
    +
    {media[curr].isThumbnail && (
    @@ -206,7 +207,7 @@ const Canvas = ({ media, curr }: { media: Media[]; curr: number }) => {
    diff --git a/packages/admin/dashboard/src/routes/products/product-media/product-media.tsx b/packages/admin/dashboard/src/routes/products/product-media/product-media.tsx index 03a2334cb5..d5f05866e2 100644 --- a/packages/admin/dashboard/src/routes/products/product-media/product-media.tsx +++ b/packages/admin/dashboard/src/routes/products/product-media/product-media.tsx @@ -1,9 +1,11 @@ +import { useTranslation } from "react-i18next" import { useParams } from "react-router-dom" import { RouteFocusModal } from "../../../components/modals" import { useProduct } from "../../../hooks/api/products" import { ProductMediaView } from "./components/product-media-view" export const ProductMedia = () => { + const { t } = useTranslation() const { id } = useParams() const { product, isLoading, isError, error } = useProduct(id!) @@ -16,6 +18,12 @@ export const ProductMedia = () => { return ( + + {t("products.media.label")} + + + {t("products.media.editHint")} + {ready && } ) diff --git a/packages/core/types/src/common/common.ts b/packages/core/types/src/common/common.ts index af394480ea..4aaebbf73b 100644 --- a/packages/core/types/src/common/common.ts +++ b/packages/core/types/src/common/common.ts @@ -49,6 +49,11 @@ export interface SoftDeletableEntity extends BaseEntity { 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 * @@ -80,7 +85,7 @@ export interface FindConfig { * 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. */ - order?: Record + 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 diff --git a/packages/core/types/src/http/product/common.ts b/packages/core/types/src/http/product/common.ts index 9bc0106074..9a17450904 100644 --- a/packages/core/types/src/http/product/common.ts +++ b/packages/core/types/src/http/product/common.ts @@ -290,6 +290,10 @@ export interface BaseProductImage { * The image's URL. */ url: string + /** + * The rank of the product image. + */ + rank: number /** * The date the image was created. */ diff --git a/packages/core/types/src/product/common.ts b/packages/core/types/src/product/common.ts index 470fa059e8..e3f7fdc4d9 100644 --- a/packages/core/types/src/product/common.ts +++ b/packages/core/types/src/product/common.ts @@ -595,6 +595,10 @@ export interface ProductImageDTO { * The URL of the product image. */ url: string + /** + * The rank of the product image. + */ + rank: number /** * Holds custom data in key-value pairs. */ diff --git a/packages/core/utils/src/modules-sdk/__tests__/build-query.spec.ts b/packages/core/utils/src/modules-sdk/__tests__/build-query.spec.ts index dbd0da8bcf..6c9623faaf 100644 --- a/packages/core/utils/src/modules-sdk/__tests__/build-query.spec.ts +++ b/packages/core/utils/src/modules-sdk/__tests__/build-query.spec.ts @@ -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 { buildQuery } from "../build-query" describe("buildQuery", () => { 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", () => { - const config = { + const config: FindConfig = { relations: ["user", "order"], select: ["id", "name"], take: 10, diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts index fbfecddd4c..3fd059e871 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts @@ -12,17 +12,18 @@ import { ProductStatus, } from "@medusajs/framework/utils" import { + Image, Product, ProductCategory, ProductCollection, ProductType, } from "@models" -import { UpdateProductInput } from "@types" import { MockEventBusService, moduleIntegrationTestRunner, } from "@medusajs/test-utils" +import { UpdateProductInput } from "@types" import { buildProductAndRelationsData, createCollections, @@ -1236,6 +1237,154 @@ moduleIntegrationTestRunner({ 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, + }), + ]) + }) + }) }) }, }) diff --git a/packages/modules/product/integration-tests/__tests__/product.ts b/packages/modules/product/integration-tests/__tests__/product.ts index eb2b276148..84e0da968a 100644 --- a/packages/modules/product/integration-tests/__tests__/product.ts +++ b/packages/modules/product/integration-tests/__tests__/product.ts @@ -1,9 +1,8 @@ -import { Image, Product, ProductCategory, ProductCollection } from "@models" +import { Product, ProductCategory, ProductCollection } from "@models" import { assignCategoriesToProduct, buildProductOnlyData, createCollections, - createImages, createProductAndTags, createProductVariants, } from "../__fixtures__/product" @@ -15,13 +14,13 @@ import { ProductStatus, kebabCase, } from "@medusajs/framework/utils" +import { moduleIntegrationTestRunner } from "@medusajs/test-utils" import { SqlEntityManager } from "@mikro-orm/postgresql" import { ProductCategoryService, ProductModuleService, ProductService, } from "@services" -import { moduleIntegrationTestRunner } from "@medusajs/test-utils" import { categoriesData, productsData, @@ -215,19 +214,12 @@ moduleIntegrationTestRunner({ }) describe("create", function () { - let images: Image[] = [] - beforeEach(async () => { testManager = await MikroOrmWrapper.forkManager() - - images = await createImages(testManager, ["image-1"]) }) it("should create a product", async () => { - const data = buildProductOnlyData({ - images, - thumbnail: images[0].url, - }) + const data = buildProductOnlyData() const products = await service.create([data]) @@ -241,25 +233,15 @@ moduleIntegrationTestRunner({ subtitle: data.subtitle, is_giftcard: data.is_giftcard, discountable: data.discountable, - thumbnail: images[0].url, status: data.status, - images: expect.arrayContaining([ - expect.objectContaining({ - id: images[0].id, - url: images[0].url, - }), - ]), }) ) }) }) describe("update", function () { - let images: Image[] = [] - beforeEach(async () => { testManager = await MikroOrmWrapper.forkManager() - images = await createImages(testManager, ["image-1", "image-2"]) productOne = testManager.create(Product, { id: "product-1", @@ -275,8 +257,6 @@ moduleIntegrationTestRunner({ { id: productOne.id, title: "update test 1", - images: images, - thumbnail: images[0].url, }, ] @@ -284,24 +264,13 @@ moduleIntegrationTestRunner({ expect(products.length).toEqual(1) - let result = await service.retrieve(productOne.id, { - relations: ["images", "thumbnail"], - }) + let result = await service.retrieve(productOne.id) let serialized = JSON.parse(JSON.stringify(result)) expect(serialized).toEqual( expect.objectContaining({ id: productOne.id, 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({ }) describe("softDelete", function () { - let images: Image[] = [] - beforeEach(async () => { testManager = await MikroOrmWrapper.forkManager() - - images = await createImages(testManager, ["image-1"]) }) it("should soft delete a product", async () => { - const data = buildProductOnlyData({ - images, - thumbnail: images[0].url, - }) + const data = buildProductOnlyData() const products = await service.create([data]) await service.softDelete(products.map((p) => p.id)) @@ -785,19 +747,12 @@ moduleIntegrationTestRunner({ }) describe("restore", function () { - let images: Image[] = [] - beforeEach(async () => { testManager = await MikroOrmWrapper.forkManager() - - images = await createImages(testManager, ["image-1"]) }) it("should restore a soft deleted product", async () => { - const data = buildProductOnlyData({ - images, - thumbnail: images[0].url, - }) + const data = buildProductOnlyData() const products = await service.create([data]) const product = products[0] diff --git a/packages/modules/product/src/migrations/.snapshot-medusa-product.json b/packages/modules/product/src/migrations/.snapshot-medusa-product.json index 93d23dfb77..ce2f18059e 100644 --- a/packages/modules/product/src/migrations/.snapshot-medusa-product.json +++ b/packages/modules/product/src/migrations/.snapshot-medusa-product.json @@ -268,93 +268,6 @@ "checks": [], "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": { "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": { "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": { "product_id": { diff --git a/packages/modules/product/src/migrations/Migration20241122120331.ts b/packages/modules/product/src/migrations/Migration20241122120331.ts new file mode 100644 index 0000000000..efcb5443f0 --- /dev/null +++ b/packages/modules/product/src/migrations/Migration20241122120331.ts @@ -0,0 +1,45 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20241122120331 extends Migration { + + async up(): Promise { + 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 { + 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";'); + } + +} diff --git a/packages/modules/product/src/models/index.ts b/packages/modules/product/src/models/index.ts index f3f6a304fa..f7acfaa74e 100644 --- a/packages/modules/product/src/models/index.ts +++ b/packages/modules/product/src/models/index.ts @@ -1,9 +1,9 @@ export { default as Product } from "./product" export { default as ProductCategory } from "./product-category" 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 ProductType } from "./product-type" 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" diff --git a/packages/modules/product/src/models/product-image.ts b/packages/modules/product/src/models/product-image.ts index 5c0baf2445..c1bd26768a 100644 --- a/packages/modules/product/src/models/product-image.ts +++ b/packages/modules/product/src/models/product-image.ts @@ -1,13 +1,13 @@ import { BeforeCreate, - Collection, Entity, Filter, Index, - ManyToMany, + ManyToOne, OnInit, PrimaryKey, Property, + Rel, } from "@mikro-orm/core" import { @@ -58,8 +58,21 @@ class ProductImage { @Property({ columnType: "timestamptz", nullable: true }) deleted_at?: Date - @ManyToMany(() => Product, (product) => product.images) - products = new Collection(this) + @Property({ columnType: "integer", default: 0 }) + rank: number + + @ManyToOne(() => Product, { + columnType: "text", + onDelete: "cascade", + fieldName: "product_id", + mapToPk: true, + }) + product_id: string + + @ManyToOne(() => Product, { + persist: false, + }) + product: Rel @OnInit() onInit() { diff --git a/packages/modules/product/src/models/product.ts b/packages/modules/product/src/models/product.ts index c2f89a3975..e2caae6bc0 100644 --- a/packages/modules/product/src/models/product.ts +++ b/packages/modules/product/src/models/product.ts @@ -1,5 +1,6 @@ import { BeforeCreate, + Cascade, Collection, Entity, Enum, @@ -166,11 +167,8 @@ class Product { }) tags = new Collection(this) - @ManyToMany(() => ProductImage, "products", { - owner: true, - pivotTable: "product_images", - joinColumn: "product_id", - inverseJoinColumn: "image_id", + @OneToMany(() => ProductImage, (image) => image.product_id, { + cascade: [Cascade.PERSIST, Cascade.REMOVE], }) images = new Collection(this) diff --git a/packages/modules/product/src/schema/index.ts b/packages/modules/product/src/schema/index.ts index dd48bd5190..052ff6ad54 100644 --- a/packages/modules/product/src/schema/index.ts +++ b/packages/modules/product/src/schema/index.ts @@ -128,6 +128,7 @@ type ProductOption { type ProductImage { id: ID! url: String! + rank: Int! metadata: JSON created_at: DateTime! updated_at: DateTime! diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index 628887674f..777fb9fc09 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -1,6 +1,7 @@ import { Context, DAL, + FindConfig, IEventBusModuleService, InternalModuleDeclaration, ModuleJoinerConfig, @@ -8,10 +9,10 @@ import { ProductTypes, } from "@medusajs/framework/types" import { - Image as ProductImage, Product, ProductCategory, ProductCollection, + Image as ProductImage, ProductOption, ProductOptionValue, ProductTag, @@ -58,6 +59,7 @@ type InjectedDependencies = { productCategoryService: ProductCategoryService productCollectionService: ModulesSdkTypes.IMedusaInternalService productImageService: ModulesSdkTypes.IMedusaInternalService + productImageProductService: ModulesSdkTypes.IMedusaInternalService productTypeService: ModulesSdkTypes.IMedusaInternalService productOptionService: ModulesSdkTypes.IMedusaInternalService productOptionValueService: ModulesSdkTypes.IMedusaInternalService @@ -151,6 +153,74 @@ export default class ProductModuleService return joinerConfig } + @InjectManager() + // @ts-ignore + async retrieveProduct( + productId: string, + config?: FindConfig, + @MedusaContext() sharedContext?: Context + ): Promise { + const product = await this.productService_.retrieve( + productId, + this.getProductFindConfig_(config), + sharedContext + ) + + return this.baseRepository_.serialize(product) + } + + @InjectManager() + // @ts-ignore + async listProducts( + filters?: ProductTypes.FilterableProductProps, + config?: FindConfig, + sharedContext?: Context + ): Promise { + const products = await this.productService_.list( + filters, + this.getProductFindConfig_(config), + sharedContext + ) + + return this.baseRepository_.serialize(products) + } + + @InjectManager() + // @ts-ignore + async listAndCountProducts( + filters?: ProductTypes.FilterableProductProps, + config?: FindConfig, + 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 + ): FindConfig { + const hasImagesRelation = config?.relations?.includes("images") + + return { + ...config, + order: { + ...config?.order, + ...(hasImagesRelation ? { + images: { + rank: "ASC", + }, + } : {}), + }, + } + } + // @ts-ignore createProductVariants( data: ProductTypes.CreateProductVariantDTO[], @@ -1440,7 +1510,7 @@ export default class ProductModuleService await this.productService_.upsertWithReplace( normalizedInput, { - relations: ["images", "tags", "categories"], + relations: ["tags", "categories"], }, sharedContext ) @@ -1480,6 +1550,27 @@ export default class ProductModuleService ) 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( normalizedInput, { - relations: ["images", "tags", "categories"], + relations: ["tags", "categories"], }, sharedContext ) @@ -1585,6 +1676,27 @@ export default class ProductModuleService 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 + ) + } + } }) )