diff --git a/.changeset/flat-mugs-try.md b/.changeset/flat-mugs-try.md new file mode 100644 index 0000000000000..5b9eba5562d92 --- /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/.changeset/spotty-eagles-tie.md b/.changeset/spotty-eagles-tie.md new file mode 100644 index 0000000000000..22c4001a978b8 --- /dev/null +++ b/.changeset/spotty-eagles-tie.md @@ -0,0 +1,5 @@ +--- +"@medusajs/admin-vite-plugin": patch +--- + +fix(admin-vite-plugin): Move @babel/types to dependencies from devDependencies to make it compatible with Yarn PnP diff --git a/integration-tests/http/__tests__/order/admin/transfer-flow.spec.ts b/integration-tests/http/__tests__/order/admin/transfer-flow.spec.ts index 80455cc919d69..a06a4824e0c50 100644 --- a/integration-tests/http/__tests__/order/admin/transfer-flow.spec.ts +++ b/integration-tests/http/__tests__/order/admin/transfer-flow.spec.ts @@ -141,6 +141,54 @@ medusaIntegrationTestRunner({ expect(finalOrderResult.customer_id).toEqual(customer.id) }) + it("should cancel an order transfer request from admin successfully", async () => { + await api.post( + `/admin/orders/${order.id}/transfer`, + { + customer_id: customer.id, + }, + adminHeaders + ) + + await api.get( + `/admin/orders/${order.id}?fields=+customer_id,+email`, + adminHeaders + ) + + let orderPreviewResult = ( + await api.get(`/admin/orders/${order.id}/preview`, adminHeaders) + ).data.order + + expect(orderPreviewResult).toEqual( + expect.objectContaining({ + customer_id: customer.id, + order_change: expect.objectContaining({ + change_type: "transfer", + status: "requested", + requested_by: user.id, + }), + }) + ) + + await api.post( + `/admin/orders/${order.id}/transfer/cancel`, + {}, + adminHeaders + ) + + orderPreviewResult = ( + await api.get(`/admin/orders/${order.id}/preview`, adminHeaders) + ).data.order + + expect(orderPreviewResult.order_change).not.toBeDefined() + + const orderChangesResult = ( + await api.get(`/admin/orders/${order.id}/changes`, adminHeaders) + ).data.order_changes + + expect(orderChangesResult.length).toEqual(0) + }) + it("should fail to request order transfer to a guest customer", async () => { const customer = ( await api.post( @@ -238,13 +286,9 @@ medusaIntegrationTestRunner({ let storeHeaders let signInToken - let orderModule - beforeEach(async () => { const container = getContainer() - orderModule = await container.resolve(Modules.ORDER) - const publishableKey = await generatePublishableKey(container) storeHeaders = generateStoreHeaders({ publishableKey }) @@ -301,10 +345,12 @@ medusaIntegrationTestRunner({ expect(storeOrder.email).toEqual("tony@stark-industries.com") expect(storeOrder.customer_id).not.toEqual(customer.id) - const orderChanges = await orderModule.listOrderChanges( - { order_id: order.id }, - { relations: ["actions"] } - ) + const orderChanges = ( + await api.get( + `/admin/orders/${order.id}/changes?fields=*actions`, + adminHeaders + ) + ).data.order_changes expect(orderChanges.length).toEqual(1) expect(orderChanges[0]).toEqual( @@ -344,6 +390,280 @@ medusaIntegrationTestRunner({ // 4. Customer account is now associated with the order (email on the order is still as original, guest email) expect(finalOrder.customer_id).toEqual(customer.id) }) + + it("should cancel a customer transfer request as an admin", async () => { + await api.post( + `/store/orders/${order.id}/transfer/request`, + {}, + { + headers: { + authorization: `Bearer ${signInToken}`, + ...storeHeaders.headers, + }, + } + ) + + let orderChanges = ( + await api.get( + `/admin/orders/${order.id}/changes?fields=*actions`, + adminHeaders + ) + ).data.order_changes + + expect(orderChanges.length).toEqual(1) + expect(orderChanges[0]).toEqual( + expect.objectContaining({ + change_type: "transfer", + status: "requested", + requested_by: customer.id, + created_by: customer.id, + confirmed_by: null, + confirmed_at: null, + declined_by: null, + actions: expect.arrayContaining([ + expect.objectContaining({ + version: 2, + action: "TRANSFER_CUSTOMER", + reference: "customer", + reference_id: customer.id, + details: expect.objectContaining({ + token: expect.any(String), + original_email: "tony@stark-industries.com", + }), + }), + ]), + }) + ) + + // Admin cancels the transfer request + await api.post( + `/admin/orders/${order.id}/transfer/cancel`, + {}, + adminHeaders + ) + + orderChanges = ( + await api.get(`/admin/orders/${order.id}/changes`, adminHeaders) + ).data.order_changes + + expect(orderChanges.length).toEqual(0) + }) + + it("customer should be able to cancel their own transfer request", async () => { + await api.post( + `/store/orders/${order.id}/transfer/request`, + {}, + { + headers: { + authorization: `Bearer ${signInToken}`, + ...storeHeaders.headers, + }, + } + ) + + let orderChanges = ( + await api.get( + `/admin/orders/${order.id}/changes?fields=*actions`, + adminHeaders + ) + ).data.order_changes + + expect(orderChanges.length).toEqual(1) + expect(orderChanges[0]).toEqual( + expect.objectContaining({ + change_type: "transfer", + status: "requested", + requested_by: customer.id, + created_by: customer.id, + confirmed_by: null, + confirmed_at: null, + declined_by: null, + actions: expect.arrayContaining([ + expect.objectContaining({ + version: 2, + action: "TRANSFER_CUSTOMER", + reference: "customer", + reference_id: customer.id, + details: expect.objectContaining({ + token: expect.any(String), + original_email: "tony@stark-industries.com", + }), + }), + ]), + }) + ) + + await api.post( + `/store/orders/${order.id}/transfer/cancel`, + {}, + { + headers: { + authorization: `Bearer ${signInToken}`, + ...storeHeaders.headers, + }, + } + ) + + orderChanges = ( + await api.get( + `/admin/orders/${order.id}/changes?fields=*actions`, + adminHeaders + ) + ).data.order_changes + + expect(orderChanges.length).toEqual(0) + }) + + it("original customer should be able to decline a transfer request", async () => { + await api.post( + `/store/orders/${order.id}/transfer/request`, + {}, + { + headers: { + authorization: `Bearer ${signInToken}`, + ...storeHeaders.headers, + }, + } + ) + + let orderChanges = ( + await api.get( + `/admin/orders/${order.id}/changes?fields=*actions`, + adminHeaders + ) + ).data.order_changes + + expect(orderChanges.length).toEqual(1) + expect(orderChanges[0]).toEqual( + expect.objectContaining({ + change_type: "transfer", + status: "requested", + requested_by: customer.id, + created_by: customer.id, + confirmed_by: null, + confirmed_at: null, + declined_by: null, + actions: expect.arrayContaining([ + expect.objectContaining({ + version: 2, + action: "TRANSFER_CUSTOMER", + reference: "customer", + reference_id: customer.id, + details: expect.objectContaining({ + token: expect.any(String), + original_email: "tony@stark-industries.com", + }), + }), + ]), + }) + ) + + await api.post( + `/store/orders/${order.id}/transfer/decline`, + { token: orderChanges[0].actions[0].details.token }, + { + headers: { + ...storeHeaders.headers, + }, + } + ) + + orderChanges = ( + await api.get( + `/admin/orders/${order.id}/changes?fields=+declined_at`, + adminHeaders + ) + ).data.order_changes + + expect(orderChanges.length).toEqual(1) + expect(orderChanges[0]).toEqual( + expect.objectContaining({ + change_type: "transfer", + status: "declined", + requested_by: customer.id, + created_by: customer.id, + declined_at: expect.any(String), + }) + ) + }) + + it("shound not decline a transfer request without proper token", async () => { + await api.post( + `/store/orders/${order.id}/transfer/request`, + {}, + { + headers: { + authorization: `Bearer ${signInToken}`, + ...storeHeaders.headers, + }, + } + ) + + let orderChanges = ( + await api.get( + `/admin/orders/${order.id}/changes?fields=*actions`, + adminHeaders + ) + ).data.order_changes + + expect(orderChanges.length).toEqual(1) + expect(orderChanges[0]).toEqual( + expect.objectContaining({ + change_type: "transfer", + status: "requested", + requested_by: customer.id, + created_by: customer.id, + confirmed_by: null, + confirmed_at: null, + declined_by: null, + actions: expect.arrayContaining([ + expect.objectContaining({ + version: 2, + action: "TRANSFER_CUSTOMER", + reference: "customer", + reference_id: customer.id, + details: expect.objectContaining({ + token: expect.any(String), + original_email: "tony@stark-industries.com", + }), + }), + ]), + }) + ) + + const error = await api + .post( + `/store/orders/${order.id}/transfer/decline`, + { token: "fake-token" }, + { + headers: { + ...storeHeaders.headers, + }, + } + ) + .catch((e) => e) + + expect(error.response.status).toBe(400) + expect(error.response.data).toEqual( + expect.objectContaining({ + type: "not_allowed", + message: "Invalid token.", + }) + ) + + orderChanges = ( + await api.get(`/admin/orders/${order.id}/changes`, adminHeaders) + ).data.order_changes + + expect(orderChanges.length).toEqual(1) + expect(orderChanges[0]).toEqual( + expect.objectContaining({ + change_type: "transfer", + status: "requested", + declined_at: null, + }) + ) + }) }) }, }) diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index bc6d52e593819..e7e90014a4c88 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 e5618d5f4ec45..4cbd99731993d 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/admin-vite-plugin/package.json b/packages/admin/admin-vite-plugin/package.json index fae6bcb209edc..513bc71936c37 100644 --- a/packages/admin/admin-vite-plugin/package.json +++ b/packages/admin/admin-vite-plugin/package.json @@ -27,7 +27,6 @@ "test:watch": "vitest" }, "devDependencies": { - "@babel/types": "7.25.6", "@types/node": "^20.10.4", "tsup": "8.0.1", "typescript": "5.3.3", @@ -40,6 +39,7 @@ "dependencies": { "@babel/parser": "7.25.6", "@babel/traverse": "7.25.6", + "@babel/types": "7.25.6", "@medusajs/admin-shared": "2.0.4", "chokidar": "3.5.3", "fdir": "6.1.1", 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 2135f6c696e60..69ececbcdb482 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 ee0ac2f1c2638..925f1d283051a 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 f433dfc43537d..09b6f9c8e78bf 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 4725c1885b81d..1bd01a0842e9d 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 3f807b3f58c42..69e4adcab258e 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 9812581eb797a..d52e3dce14a0e 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 4b124fbbb8a58..0000000000000 --- 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 122636cecbbc2..0000000000000 --- 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 883ef812e2e58..2d23cb8f74471 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 + }, + [form, t] + ) - return false - } + 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 7272d893bdad2..6cb5246586f3d 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) - - useEffect(() => { - if (file) { - const objectUrl = URL.createObjectURL(file) - setThumbnailUrl(objectUrl) - - return () => URL.revokeObjectURL(objectUrl) - } - }, [file]) +const MediaGridItemOverlay = ({ field }: { field: MediaField }) => { + return ( +
  • +
    + + + +
    +
    + +
    +
    + + {field.file?.name} + +
    + {field.isThumbnail && } + + {formatFileSize(field.file?.size ?? 0)} + +
    +
    +
    +
    +
    + + {}} + > + + +
    +
  • + ) +} - 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 566ea2fa22673..f8bdcdc76c890 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() - - 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() + + { + // 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 (!valid) { return } - setTab(tab as Tab) - }} - className="flex h-full flex-col overflow-hidden" - > - -
    - - - {t("products.create.tabs.details")} - - - {t("products.create.tabs.organize")} - + 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.variants")} + {t("products.create.tabs.inventory")} - {showInventoryTab && ( - - {t("products.create.tabs.inventory")} - - )} - -
    -
    - - - - - - - + )} +
    +
    +
    + + + + + + + + + + + {showInventoryTab && ( - + - {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 c9c872cfa9740..fd65e86e13480 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-detail/components/product-organization-section/product-organization-section.tsx b/packages/admin/dashboard/src/routes/products/product-detail/components/product-organization-section/product-organization-section.tsx index 9091f081166df..8387a66dbea85 100644 --- a/packages/admin/dashboard/src/routes/products/product-detail/components/product-organization-section/product-organization-section.tsx +++ b/packages/admin/dashboard/src/routes/products/product-detail/components/product-organization-section/product-organization-section.tsx @@ -65,9 +65,9 @@ export const ProductOrganizationSection = ({ title={t("fields.collection")} value={ product.collection ? ( - + - {product.collection.title} + {product.collection.title} ) : 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 224147a171819..11ab0f748746a 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 3bdb10d84620b..1f410642e09ae 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 03a2334cb581f..d5f05866e2ff0 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/core-flows/src/order/workflows/index.ts b/packages/core/core-flows/src/order/workflows/index.ts index e7950a5d04f87..921c92687e41b 100644 --- a/packages/core/core-flows/src/order/workflows/index.ts +++ b/packages/core/core-flows/src/order/workflows/index.ts @@ -80,3 +80,5 @@ export * from "./update-order-changes" export * from "./update-tax-lines" export * from "./transfer/request-order-transfer" export * from "./transfer/accept-order-transfer" +export * from "./transfer/cancel-order-transfer" +export * from "./transfer/decline-order-transfer" diff --git a/packages/core/core-flows/src/order/workflows/order-edit/cancel-begin-order-edit.ts b/packages/core/core-flows/src/order/workflows/order-edit/cancel-begin-order-edit.ts index 55eaa88702f7e..85805e26e0125 100644 --- a/packages/core/core-flows/src/order/workflows/order-edit/cancel-begin-order-edit.ts +++ b/packages/core/core-flows/src/order/workflows/order-edit/cancel-begin-order-edit.ts @@ -41,7 +41,9 @@ export const cancelBeginOrderEditWorkflowId = "cancel-begin-order-edit" */ export const cancelBeginOrderEditWorkflow = createWorkflow( cancelBeginOrderEditWorkflowId, - function (input: CancelBeginOrderEditWorkflowInput): WorkflowData { + function ( + input: WorkflowData + ): WorkflowData { const order: OrderDTO = useRemoteQueryStep({ entry_point: "orders", fields: ["id", "version", "canceled_at"], diff --git a/packages/core/core-flows/src/order/workflows/transfer/accept-order-transfer.ts b/packages/core/core-flows/src/order/workflows/transfer/accept-order-transfer.ts index ccbe4d78cd7d2..a48e4a22e4757 100644 --- a/packages/core/core-flows/src/order/workflows/transfer/accept-order-transfer.ts +++ b/packages/core/core-flows/src/order/workflows/transfer/accept-order-transfer.ts @@ -8,17 +8,18 @@ import { WorkflowResponse, createStep, createWorkflow, + transform, } from "@medusajs/framework/workflows-sdk" import { OrderPreviewDTO } from "@medusajs/types" - -import { useRemoteQueryStep } from "../../../common" -import { throwIfOrderIsCancelled } from "../../utils/order-validation" -import { previewOrderChangeStep } from "../../steps" import { ChangeActionType, MedusaError, OrderChangeStatus, } from "@medusajs/utils" + +import { useQueryGraphStep } from "../../../common" +import { throwIfOrderIsCancelled } from "../../utils/order-validation" +import { previewOrderChangeStep } from "../../steps" import { confirmOrderChanges } from "../../steps/confirm-order-changes" /** @@ -62,16 +63,20 @@ export const acceptOrderTransferWorkflow = createWorkflow( function ( input: WorkflowData ): WorkflowResponse { - const order: OrderDTO = useRemoteQueryStep({ - entry_point: "orders", + const orderQuery = useQueryGraphStep({ + entity: "order", fields: ["id", "email", "status", "customer_id"], - variables: { id: input.order_id }, - list: false, - throw_if_key_not_found: true, - }) + filters: { id: input.order_id }, + options: { throwIfKeyNotFound: true }, + }).config({ name: "order-query" }) + + const order = transform( + { orderQuery }, + ({ orderQuery }) => orderQuery.data[0] + ) - const orderChange: OrderChangeDTO = useRemoteQueryStep({ - entry_point: "order_change", + const orderChangeQuery = useQueryGraphStep({ + entity: "order_change", fields: [ "id", "status", @@ -84,15 +89,18 @@ export const acceptOrderTransferWorkflow = createWorkflow( "actions.reference_id", "actions.internal_note", ], - variables: { - filters: { - order_id: input.order_id, - status: [OrderChangeStatus.REQUESTED], - }, + filters: { + order_id: input.order_id, + status: [OrderChangeStatus.REQUESTED], }, - list: false, + options: { throwIfKeyNotFound: true }, }).config({ name: "order-change-query" }) + const orderChange = transform( + { orderChangeQuery }, + ({ orderChangeQuery }) => orderChangeQuery.data[0] + ) + acceptOrderTransferValidationStep({ order, orderChange, diff --git a/packages/core/core-flows/src/order/workflows/transfer/cancel-order-transfer.ts b/packages/core/core-flows/src/order/workflows/transfer/cancel-order-transfer.ts new file mode 100644 index 0000000000000..4044e0a866a19 --- /dev/null +++ b/packages/core/core-flows/src/order/workflows/transfer/cancel-order-transfer.ts @@ -0,0 +1,101 @@ +import { + OrderChangeDTO, + OrderDTO, + OrderWorkflow, +} from "@medusajs/framework/types" +import { + ChangeActionType, + MedusaError, + OrderChangeStatus, +} from "@medusajs/framework/utils" +import { + WorkflowData, + createStep, + createWorkflow, + transform, +} from "@medusajs/framework/workflows-sdk" +import { useQueryGraphStep } from "../../../common" +import { deleteOrderChangesStep } from "../../steps" +import { + throwIfIsCancelled, + throwIfOrderChangeIsNotActive, +} from "../../utils/order-validation" + +/** + * This step validates that a requested order transfer can be canceled. + */ +export const cancelTransferOrderRequestValidationStep = createStep( + "validate-cancel-transfer-order-request", + async function ({ + order, + orderChange, + input, + }: { + order: OrderDTO + orderChange: OrderChangeDTO + input: OrderWorkflow.CancelTransferOrderRequestWorkflowInput + }) { + throwIfIsCancelled(order, "Order") + throwIfOrderChangeIsNotActive({ orderChange }) + + if (input.actor_type === "user") { + return + } + + const action = orderChange.actions?.find( + (a) => a.action === ChangeActionType.TRANSFER_CUSTOMER + ) + + if (action?.reference_id !== input.logged_in_user_id) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "This customer is not allowed to cancel the transfer." + ) + } + } +) + +export const cancelTransferOrderRequestWorkflowId = + "cancel-transfer-order-request" +/** + * This workflow cancels a requested order transfer. + * + * Customer that requested the transfer or a store admin are allowed to cancel the order transfer request. + */ +export const cancelOrderTransferRequestWorkflow = createWorkflow( + cancelTransferOrderRequestWorkflowId, + function ( + input: WorkflowData + ): WorkflowData { + const orderQuery = useQueryGraphStep({ + entity: "order", + fields: ["id", "version", "canceled_at"], + filters: { id: input.order_id }, + options: { throwIfKeyNotFound: true }, + }).config({ name: "order-query" }) + + const order = transform( + { orderQuery }, + ({ orderQuery }) => orderQuery.data[0] + ) + + const orderChangeQuery = useQueryGraphStep({ + entity: "order_change", + fields: ["id", "status", "version", "actions.*"], + filters: { + order_id: input.order_id, + status: [OrderChangeStatus.PENDING, OrderChangeStatus.REQUESTED], + }, + options: { throwIfKeyNotFound: true }, + }).config({ name: "order-change-query" }) + + const orderChange = transform( + { orderChangeQuery }, + ({ orderChangeQuery }) => orderChangeQuery.data[0] + ) + + cancelTransferOrderRequestValidationStep({ order, orderChange, input }) + + deleteOrderChangesStep({ ids: [orderChange.id] }) + } +) diff --git a/packages/core/core-flows/src/order/workflows/transfer/decline-order-transfer.ts b/packages/core/core-flows/src/order/workflows/transfer/decline-order-transfer.ts new file mode 100644 index 0000000000000..bc48192d05faf --- /dev/null +++ b/packages/core/core-flows/src/order/workflows/transfer/decline-order-transfer.ts @@ -0,0 +1,95 @@ +import { + OrderChangeDTO, + OrderDTO, + OrderWorkflow, +} from "@medusajs/framework/types" +import { + ChangeActionType, + MedusaError, + OrderChangeStatus, +} from "@medusajs/framework/utils" +import { + WorkflowData, + createStep, + createWorkflow, + transform, +} from "@medusajs/framework/workflows-sdk" + +import { useQueryGraphStep } from "../../../common" +import { declineOrderChangeStep } from "../../steps" +import { + throwIfIsCancelled, + throwIfOrderChangeIsNotActive, +} from "../../utils/order-validation" + +/** + * This step validates that a requested order transfer can be declineed. + */ +export const declineTransferOrderRequestValidationStep = createStep( + "validate-decline-transfer-order-request", + async function ({ + order, + orderChange, + input, + }: { + order: OrderDTO + orderChange: OrderChangeDTO + input: OrderWorkflow.DeclineTransferOrderRequestWorkflowInput + }) { + throwIfIsCancelled(order, "Order") + throwIfOrderChangeIsNotActive({ orderChange }) + + const token = orderChange.actions?.find( + (a) => a.action === ChangeActionType.TRANSFER_CUSTOMER + )?.details!.token + + if (!input.token?.length || token !== input.token) { + throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Invalid token.") + } + } +) + +export const declineTransferOrderRequestWorkflowId = + "decline-transfer-order-request" +/** + * This workflow declines a requested order transfer. + * + * Only the original customer (who has the token) is allowed to decline the transfer. + */ +export const declineOrderTransferRequestWorkflow = createWorkflow( + declineTransferOrderRequestWorkflowId, + function ( + input: WorkflowData + ): WorkflowData { + const orderQuery = useQueryGraphStep({ + entity: "order", + fields: ["id", "version", "declineed_at"], + filters: { id: input.order_id }, + options: { throwIfKeyNotFound: true }, + }).config({ name: "order-query" }) + + const order = transform( + { orderQuery }, + ({ orderQuery }) => orderQuery.data[0] + ) + + const orderChangeQuery = useQueryGraphStep({ + entity: "order_change", + fields: ["id", "status", "version", "actions.*"], + filters: { + order_id: input.order_id, + status: [OrderChangeStatus.PENDING, OrderChangeStatus.REQUESTED], + }, + options: { throwIfKeyNotFound: true }, + }).config({ name: "order-change-query" }) + + const orderChange = transform( + { orderChangeQuery }, + ({ orderChangeQuery }) => orderChangeQuery.data[0] + ) + + declineTransferOrderRequestValidationStep({ order, orderChange, input }) + + declineOrderChangeStep({ id: orderChange.id }) + } +) diff --git a/packages/core/core-flows/src/order/workflows/transfer/request-order-transfer.ts b/packages/core/core-flows/src/order/workflows/transfer/request-order-transfer.ts index dabda451f843c..55b55998f7eb3 100644 --- a/packages/core/core-flows/src/order/workflows/transfer/request-order-transfer.ts +++ b/packages/core/core-flows/src/order/workflows/transfer/request-order-transfer.ts @@ -131,7 +131,7 @@ export const requestOrderTransferWorkflow = createWorkflow( emitEventStep({ eventName: OrderWorkflowEvents.TRANSFER_REQUESTED, - data: { id: input.order_id }, + data: { id: input.order_id, order_change_id: change.id }, }) return new WorkflowResponse(previewOrderChangeStep(input.order_id)) diff --git a/packages/core/js-sdk/src/store/index.ts b/packages/core/js-sdk/src/store/index.ts index 78e0f9c79fb54..d475427e905b2 100644 --- a/packages/core/js-sdk/src/store/index.ts +++ b/packages/core/js-sdk/src/store/index.ts @@ -1077,6 +1077,162 @@ export class Store { } ) }, + + /** + * This method requests a order transfer from a guest account to the current, logged in customer account. + * + * Customer requesting the transfer must be logged in. + * + * @param body - The transfer's details. + * @param query - Configure the fields to retrieve in the order. + * @param headers - Headers to pass in the request. + * @returns The order details. + * + * @example + * sdk.store.order.requestTransfer( + * "order_123", + * { + * description: "I want to transfer this order to my friend." + * }, + * {}, + * { + * Authorization: `Bearer ${token}` + * } + * ) + * .then(({ order }) => { + * console.log(order) + * }) + */ + requestTransfer: async ( + id: string, + body: HttpTypes.StoreRequestOrderTransfer, + query?: SelectParams, + headers?: ClientHeaders + ) => { + return this.client.fetch( + `/store/orders/${id}/transfer/request`, + { + method: "POST", + headers, + body, + query, + } + ) + }, + /** + * This method cancels an order transfer request. + * + * Customer requesting the transfer must be logged in. + * + * @param id - The order's ID. + * @param query - Configure the fields to retrieve in the order. + * @param headers - Headers to pass in the request. + * @returns The order details. + * + * @example + * sdk.store.order.cancelTransfer( + * "order_123", + * {}, + * { + * Authorization: `Bearer ${token}` + * } + * ) + * .then(({ order }) => { + * console.log(order) + * }) + */ + cancelTransfer: async ( + id: string, + query?: SelectParams, + headers?: ClientHeaders + ) => { + return this.client.fetch( + `/store/orders/${id}/transfer/cancel`, + { + method: "POST", + headers, + query, + } + ) + }, + /** + * The method called for the original owner to accept the order transfer to a new owner. + * + * @param id - The order's ID. + * @param body - The payload containing the transfer token. + * @param query - Configure the fields to retrieve in the order. + * @param headers - Headers to pass in the request. + * @returns The order details. + * + * @example + * sdk.store.order.acceptTransfer( + * "order_123", + * { + * token: "transfer_token" + * }, + * { + * Authorization: `Bearer ${token}` + * } + * ) + * .then(({ order }) => { + * console.log(order) + * }) + */ + acceptTransfer: async ( + id: string, + body: HttpTypes.StoreAcceptOrderTransfer, + query?: SelectParams, + headers?: ClientHeaders + ) => { + return this.client.fetch( + `/store/orders/${id}/transfer/accept`, + { + method: "POST", + headers, + body, + query, + } + ) + }, + /** + * The method called for the original owner to decline the order transfer to a new owner. + * + * @param id - The order's ID. + * @param body - The payload containing the transfer token. + * @param query - Configure the fields to retrieve in the order. + * @param headers - Headers to pass in the request. + * @returns The order details. + * + * @example + * sdk.store.order.declineTransfer( + * "order_123", + * { + * token: "transfer_token" + * }, + * { + * Authorization: `Bearer ${token}` + * } + * ) + * .then(({ order }) => { + * console.log(order) + * }) + */ + declineTransfer: async ( + id: string, + body: HttpTypes.StoreDeclineOrderTransfer, + query?: SelectParams, + headers?: ClientHeaders + ) => { + return this.client.fetch( + `/store/orders/${id}/transfer/decline`, + { + method: "POST", + headers, + body, + query, + } + ) + }, } public customer = { diff --git a/packages/core/types/src/common/common.ts b/packages/core/types/src/common/common.ts index af394480ea8bb..4aaebbf73b629 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/order/store/index.ts b/packages/core/types/src/http/order/store/index.ts index 020c34f02c710..e236f0b40fef3 100644 --- a/packages/core/types/src/http/order/store/index.ts +++ b/packages/core/types/src/http/order/store/index.ts @@ -1,3 +1,4 @@ export * from "./entities" export * from "./queries" export * from "./responses" +export * from "./payloads" diff --git a/packages/core/types/src/http/order/store/payloads.ts b/packages/core/types/src/http/order/store/payloads.ts new file mode 100644 index 0000000000000..e609f83a236b1 --- /dev/null +++ b/packages/core/types/src/http/order/store/payloads.ts @@ -0,0 +1,14 @@ +export interface StoreRequestOrderTransfer { + /** + * The description of the transfer request. + */ + description?: string +} + +export interface StoreAcceptOrderTransfer { + token: string +} + +export interface StoreDeclineOrderTransfer { + token: string +} diff --git a/packages/core/types/src/http/product/common.ts b/packages/core/types/src/http/product/common.ts index 9bc0106074e08..9a17450904a44 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 470fa059e8428..e3f7fdc4d9675 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/types/src/workflow/order/cancel-transfer.ts b/packages/core/types/src/workflow/order/cancel-transfer.ts new file mode 100644 index 0000000000000..9d0bcba83c84b --- /dev/null +++ b/packages/core/types/src/workflow/order/cancel-transfer.ts @@ -0,0 +1,5 @@ +export type CancelTransferOrderRequestWorkflowInput = { + order_id: string + logged_in_user_id: string + actor_type: "customer" | "user" +} diff --git a/packages/core/types/src/workflow/order/decline-transfer.ts b/packages/core/types/src/workflow/order/decline-transfer.ts new file mode 100644 index 0000000000000..d727eb891e1ab --- /dev/null +++ b/packages/core/types/src/workflow/order/decline-transfer.ts @@ -0,0 +1,4 @@ +export type DeclineTransferOrderRequestWorkflowInput = { + order_id: string + token: string +} diff --git a/packages/core/types/src/workflow/order/index.ts b/packages/core/types/src/workflow/order/index.ts index 46f8034777ab3..014f9f141128e 100644 --- a/packages/core/types/src/workflow/order/index.ts +++ b/packages/core/types/src/workflow/order/index.ts @@ -17,3 +17,5 @@ export * from "./shipping-method" export * from "./update-return" export * from "./request-transfer" export * from "./accept-transfer" +export * from "./cancel-transfer" +export * from "./decline-transfer" 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 dbd0da8bcf26e..6c9623faaf8bb 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/medusa/src/api/admin/orders/[id]/transfer/cancel/route.ts b/packages/medusa/src/api/admin/orders/[id]/transfer/cancel/route.ts new file mode 100644 index 0000000000000..0004a2b96b47e --- /dev/null +++ b/packages/medusa/src/api/admin/orders/[id]/transfer/cancel/route.ts @@ -0,0 +1,34 @@ +import { cancelOrderTransferRequestWorkflow } from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { AdminOrder, HttpTypes } from "@medusajs/framework/types" +import { AdminCancelOrderTransferRequestType } from "../../../validators" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const orderId = req.params.id + const userId = req.auth_context.actor_id + + await cancelOrderTransferRequestWorkflow(req.scope).run({ + input: { + order_id: orderId, + logged_in_user_id: userId, + actor_type: req.auth_context.actor_type as "user", + }, + }) + + const result = await query.graph({ + entity: "order", + filters: { id: orderId }, + fields: req.remoteQueryConfig.fields, + }) + + res.status(200).json({ order: result.data[0] as AdminOrder }) +} diff --git a/packages/medusa/src/api/admin/orders/middlewares.ts b/packages/medusa/src/api/admin/orders/middlewares.ts index ece0980b55b6b..ccb4d76287cae 100644 --- a/packages/medusa/src/api/admin/orders/middlewares.ts +++ b/packages/medusa/src/api/admin/orders/middlewares.ts @@ -5,13 +5,14 @@ import { import { MiddlewareRoute } from "@medusajs/framework/http" import * as QueryConfig from "./query-config" import { + AdminCancelOrderTransferRequest, AdminCompleteOrder, AdminGetOrdersOrderItemsParams, AdminGetOrdersOrderParams, AdminGetOrdersParams, AdminMarkOrderFulfillmentDelivered, AdminOrderCancelFulfillment, - AdminOrderChanges, + AdminOrderChangesParams, AdminOrderCreateFulfillment, AdminOrderCreateShipment, AdminTransferOrder, @@ -53,7 +54,7 @@ export const adminOrderRoutesMiddlewares: MiddlewareRoute[] = [ matcher: "/admin/orders/:id/changes", middlewares: [ validateAndTransformQuery( - AdminOrderChanges, + AdminOrderChangesParams, QueryConfig.retrieveOrderChangesTransformQueryConfig ), ], @@ -156,4 +157,15 @@ export const adminOrderRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/orders/:id/transfer/cancel", + middlewares: [ + validateAndTransformBody(AdminCancelOrderTransferRequest), + validateAndTransformQuery( + AdminGetOrdersOrderParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, ] diff --git a/packages/medusa/src/api/admin/orders/validators.ts b/packages/medusa/src/api/admin/orders/validators.ts index 04fbd26758969..8b6e11b1830b7 100644 --- a/packages/medusa/src/api/admin/orders/validators.ts +++ b/packages/medusa/src/api/admin/orders/validators.ts @@ -106,19 +106,23 @@ export const AdminOrderCancelFulfillment = WithAdditionalData( OrderCancelFulfillment ) -export const AdminOrderChanges = z.object({ - id: z.union([z.string(), z.array(z.string())]).optional(), - status: z.union([z.string(), z.array(z.string())]).optional(), - change_type: z.union([z.string(), z.array(z.string())]).optional(), - created_at: createOperatorMap().optional(), - updated_at: createOperatorMap().optional(), - deleted_at: createOperatorMap().optional(), -}) -export type AdminOrderChangesType = z.infer +export const AdminOrderChangesParams = createSelectParams().merge( + z.object({ + id: z.union([z.string(), z.array(z.string())]).optional(), + status: z.union([z.string(), z.array(z.string())]).optional(), + change_type: z.union([z.string(), z.array(z.string())]).optional(), + created_at: createOperatorMap().optional(), + updated_at: createOperatorMap().optional(), + deleted_at: createOperatorMap().optional(), + }) +) + +export type AdminOrderChangesType = z.infer export type AdminMarkOrderFulfillmentDeliveredType = z.infer< typeof AdminMarkOrderFulfillmentDelivered > + export const AdminMarkOrderFulfillmentDelivered = z.object({}) export type AdminTransferOrderType = z.infer @@ -127,3 +131,8 @@ export const AdminTransferOrder = z.object({ description: z.string().optional(), internal_note: z.string().optional(), }) + +export type AdminCancelOrderTransferRequestType = z.infer< + typeof AdminCancelOrderTransferRequest +> +export const AdminCancelOrderTransferRequest = z.object({}) diff --git a/packages/medusa/src/api/store/orders/[id]/transfer/cancel/route.ts b/packages/medusa/src/api/store/orders/[id]/transfer/cancel/route.ts new file mode 100644 index 0000000000000..4c24b54c52ad1 --- /dev/null +++ b/packages/medusa/src/api/store/orders/[id]/transfer/cancel/route.ts @@ -0,0 +1,35 @@ +import { + cancelOrderTransferRequestWorkflow, + getOrderDetailWorkflow, +} from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { HttpTypes } from "@medusajs/framework/types" +import { StoreCancelOrderTransferRequestType } from "../../../validators" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const orderId = req.params.id + const customerId = req.auth_context.actor_id + + await cancelOrderTransferRequestWorkflow(req.scope).run({ + input: { + order_id: orderId, + logged_in_user_id: customerId, + actor_type: req.auth_context.actor_type as "customer", + }, + }) + + const { result } = await getOrderDetailWorkflow(req.scope).run({ + input: { + fields: req.remoteQueryConfig.fields, + order_id: orderId, + }, + }) + + res.status(200).json({ order: result as HttpTypes.StoreOrder }) +} diff --git a/packages/medusa/src/api/store/orders/[id]/transfer/decline/route.ts b/packages/medusa/src/api/store/orders/[id]/transfer/decline/route.ts new file mode 100644 index 0000000000000..f82f00d9fe0ff --- /dev/null +++ b/packages/medusa/src/api/store/orders/[id]/transfer/decline/route.ts @@ -0,0 +1,29 @@ +import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework" +import { HttpTypes } from "@medusajs/framework/types" +import { + declineOrderTransferRequestWorkflow, + getOrderDetailWorkflow, +} from "@medusajs/core-flows" + +import { StoreDeclineOrderTransferRequestType } from "../../../validators" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + await declineOrderTransferRequestWorkflow(req.scope).run({ + input: { + order_id: req.params.id, + token: req.validatedBody.token, + }, + }) + + const { result } = await getOrderDetailWorkflow(req.scope).run({ + input: { + fields: req.remoteQueryConfig.fields, + order_id: req.params.id, + }, + }) + + res.status(200).json({ order: result as HttpTypes.StoreOrder }) +} diff --git a/packages/medusa/src/api/store/orders/middlewares.ts b/packages/medusa/src/api/store/orders/middlewares.ts index beeeaeddb5452..236d483e31fca 100644 --- a/packages/medusa/src/api/store/orders/middlewares.ts +++ b/packages/medusa/src/api/store/orders/middlewares.ts @@ -10,6 +10,8 @@ import { StoreGetOrderParams, StoreGetOrdersParams, StoreRequestOrderTransfer, + StoreCancelOrderTransferRequest, + StoreDeclineOrderTransferRequest, } from "./validators" export const storeOrderRoutesMiddlewares: MiddlewareRoute[] = [ @@ -46,6 +48,18 @@ export const storeOrderRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/store/orders/:id/transfer/cancel", + middlewares: [ + authenticate("customer", ["session", "bearer"]), + validateAndTransformBody(StoreCancelOrderTransferRequest), + validateAndTransformQuery( + StoreGetOrderParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, { method: ["POST"], matcher: "/store/orders/:id/transfer/accept", @@ -57,4 +71,15 @@ export const storeOrderRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/store/orders/:id/transfer/decline", + middlewares: [ + validateAndTransformBody(StoreDeclineOrderTransferRequest), + validateAndTransformQuery( + StoreGetOrderParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, ] diff --git a/packages/medusa/src/api/store/orders/validators.ts b/packages/medusa/src/api/store/orders/validators.ts index a4c297c624e74..91ee447940813 100644 --- a/packages/medusa/src/api/store/orders/validators.ts +++ b/packages/medusa/src/api/store/orders/validators.ts @@ -19,13 +19,12 @@ export const StoreGetOrdersParams = createFindParams({ export type StoreGetOrdersParamsType = z.infer -export const StoreAcceptOrderTransfer = z.object({ - token: z.string().min(1), -}) - export type StoreAcceptOrderTransferType = z.infer< typeof StoreAcceptOrderTransfer > +export const StoreAcceptOrderTransfer = z.object({ + token: z.string().min(1), +}) export type StoreRequestOrderTransferType = z.infer< typeof StoreRequestOrderTransfer @@ -33,3 +32,15 @@ export type StoreRequestOrderTransferType = z.infer< export const StoreRequestOrderTransfer = z.object({ description: z.string().optional(), }) + +export type StoreCancelOrderTransferRequestType = z.infer< + typeof StoreCancelOrderTransferRequest +> +export const StoreCancelOrderTransferRequest = z.object({}) + +export type StoreDeclineOrderTransferRequestType = z.infer< + typeof StoreDeclineOrderTransferRequest +> +export const StoreDeclineOrderTransferRequest = z.object({ + token: z.string().min(1), +}) 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 fbfecddd4c799..3fd059e871b9c 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 eb2b276148959..84e0da968a365 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 93d23dfb7769f..ce2f18059e3db 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": { @@ -1035,8 +948,8 @@ }, { "columns": { - "product_id": { - "name": "product_id", + "id": { + "name": "id", "type": "text", "unsigned": false, "autoincrement": false, @@ -1044,8 +957,68 @@ "nullable": false, "mappedType": "text" }, - "product_tag_id": { - "name": "product_tag_id", + "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, @@ -1054,47 +1027,42 @@ "mappedType": "text" } }, - "name": "product_tags", + "name": "image", "schema": "public", "indexes": [ { - "keyName": "product_tags_pkey", "columnNames": [ - "product_id", - "product_tag_id" + "deleted_at" ], - "composite": true, + "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": { - "product_tags_product_id_foreign": { - "constraintName": "product_tags_product_id_foreign", + "image_product_id_foreign": { + "constraintName": "image_product_id_foreign", "columnNames": [ "product_id" ], - "localTableName": "public.product_tags", + "localTableName": "public.image", "referencedColumnNames": [ "id" ], "referencedTableName": "public.product", "deleteRule": "cascade", "updateRule": "cascade" - }, - "product_tags_product_tag_id_foreign": { - "constraintName": "product_tags_product_tag_id_foreign", - "columnNames": [ - "product_tag_id" - ], - "localTableName": "public.product_tags", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.product_tag", - "deleteRule": "cascade", - "updateRule": "cascade" } } }, @@ -1109,8 +1077,8 @@ "nullable": false, "mappedType": "text" }, - "image_id": { - "name": "image_id", + "product_tag_id": { + "name": "product_tag_id", "type": "text", "unsigned": false, "autoincrement": false, @@ -1119,14 +1087,14 @@ "mappedType": "text" } }, - "name": "product_images", + "name": "product_tags", "schema": "public", "indexes": [ { - "keyName": "product_images_pkey", + "keyName": "product_tags_pkey", "columnNames": [ "product_id", - "image_id" + "product_tag_id" ], "composite": true, "primary": true, @@ -1135,12 +1103,12 @@ ], "checks": [], "foreignKeys": { - "product_images_product_id_foreign": { - "constraintName": "product_images_product_id_foreign", + "product_tags_product_id_foreign": { + "constraintName": "product_tags_product_id_foreign", "columnNames": [ "product_id" ], - "localTableName": "public.product_images", + "localTableName": "public.product_tags", "referencedColumnNames": [ "id" ], @@ -1148,16 +1116,16 @@ "deleteRule": "cascade", "updateRule": "cascade" }, - "product_images_image_id_foreign": { - "constraintName": "product_images_image_id_foreign", + "product_tags_product_tag_id_foreign": { + "constraintName": "product_tags_product_tag_id_foreign", "columnNames": [ - "image_id" + "product_tag_id" ], - "localTableName": "public.product_images", + "localTableName": "public.product_tags", "referencedColumnNames": [ "id" ], - "referencedTableName": "public.image", + "referencedTableName": "public.product_tag", "deleteRule": "cascade", "updateRule": "cascade" } diff --git a/packages/modules/product/src/migrations/Migration20241122120331.ts b/packages/modules/product/src/migrations/Migration20241122120331.ts new file mode 100644 index 0000000000000..efcb5443f0ac2 --- /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 f3f6a304fabe7..f7acfaa74e145 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 5c0baf2445b85..c1bd26768a0cc 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 c2f89a39751af..e2caae6bc027b 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 dd48bd51904ff..052ff6ad54c47 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 628887674ffd7..777fb9fc091ef 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 + ) + } + } }) ) diff --git a/www/packages/docs-ui/src/components/Heading/H2/index.tsx b/www/packages/docs-ui/src/components/Heading/H2/index.tsx index f4c856072614f..92b28a8aafbad 100644 --- a/www/packages/docs-ui/src/components/Heading/H2/index.tsx +++ b/www/packages/docs-ui/src/components/Heading/H2/index.tsx @@ -1,10 +1,9 @@ "use client" import clsx from "clsx" -import React, { useMemo } from "react" +import React from "react" import { CopyButton, Link } from "@/components" -import { useIsBrowser } from "../../../providers" -import { usePathname } from "next/navigation" +import { useHeadingUrl } from "../../.." type H2Props = React.HTMLAttributes & { id?: string @@ -12,16 +11,9 @@ type H2Props = React.HTMLAttributes & { } export const H2 = ({ className, children, passRef, ...props }: H2Props) => { - const { isBrowser } = useIsBrowser() - const pathname = usePathname() - const copyText = useMemo(() => { - const hash = `#${props.id}` - if (!isBrowser) { - return hash - } - - return `${window.location.origin}${pathname}${hash}` - }, [props.id, isBrowser, pathname]) + const copyText = useHeadingUrl({ + id: props.id || "", + }) return (

    & { id?: string } export const H3 = ({ className, children, ...props }: H3Props) => { - const { isBrowser } = useIsBrowser() - const copyText = useMemo(() => { - const url = `#${props.id}` - if (!isBrowser) { - return url - } - - const hashIndex = window.location.href.indexOf("#") - return ( - window.location.href.substring( - 0, - hashIndex !== -1 ? hashIndex : window.location.href.length - ) + url - ) - }, [props.id, isBrowser]) + const copyText = useHeadingUrl({ id: props.id || "" }) return (

    { + const { isBrowser } = useIsBrowser() + const { + config: { basePath }, + } = useSiteConfig() + const pathname = usePathname() + const headingUrl = useMemo(() => { + const hash = `#${id}` + if (!isBrowser) { + return hash + } + + const url = `${window.location.origin}${basePath}${pathname}`.replace( + /\/$/, + "" + ) + + return `${url}${hash}` + }, [id, isBrowser, pathname]) + + return headingUrl +}