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