From 97327670a5417067d65f97e8757b123a34f06a0c Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Fri, 31 May 2024 17:34:16 +0200 Subject: [PATCH] feat: Add support for uploading media when creating a product (#7567) --- .../components/media-grid-view/index.ts | 1 + .../media-grid-view/media-grid-view.tsx | 125 +++++++++ .../upload-media-form-item/index.ts | 1 + .../upload-media-form-item.tsx | 104 +++++++ .../product-create-details-media-section.tsx | 109 ++++---- .../product-create-details-form.tsx | 3 + .../product-create-form.tsx | 32 ++- .../products/product-create/constants.ts | 17 +- .../routes/products/product-create/types.ts | 4 +- .../routes/products/product-create/utils.ts | 11 +- .../edit-product-media-form.tsx | 262 +++--------------- .../product-media-view/product-media-view.tsx | 7 +- 12 files changed, 392 insertions(+), 284 deletions(-) create mode 100644 packages/admin-next/dashboard/src/routes/products/common/components/media-grid-view/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/products/common/components/media-grid-view/media-grid-view.tsx create mode 100644 packages/admin-next/dashboard/src/routes/products/common/components/upload-media-form-item/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx diff --git a/packages/admin-next/dashboard/src/routes/products/common/components/media-grid-view/index.ts b/packages/admin-next/dashboard/src/routes/products/common/components/media-grid-view/index.ts new file mode 100644 index 0000000000..4b124fbbb8 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/common/components/media-grid-view/index.ts @@ -0,0 +1 @@ +export * from "./media-grid-view" diff --git a/packages/admin-next/dashboard/src/routes/products/common/components/media-grid-view/media-grid-view.tsx b/packages/admin-next/dashboard/src/routes/products/common/components/media-grid-view/media-grid-view.tsx new file mode 100644 index 0000000000..122636cecb --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/common/components/media-grid-view/media-grid-view.tsx @@ -0,0 +1,125 @@ +import { CheckMini, Spinner, ThumbnailBadge } from "@medusajs/icons" +import { Tooltip, clx } from "@medusajs/ui" +import { AnimatePresence, motion } from "framer-motion" +import { useCallback, useState } from "react" +import { useTranslation } from "react-i18next" + +interface MediaView { + id?: string + field_id: string + url: string + isThumbnail: boolean +} + +interface MediaGridProps { + media: MediaView[] + selection: Record + onCheckedChange: (id: string) => (value: boolean) => void +} + +export const MediaGrid = ({ + media, + selection, + onCheckedChange, +}: MediaGridProps) => { + return ( +
+
+ {media.map((m) => { + return ( + + ) + })} +
+
+ ) +} + +interface MediaGridItemProps { + media: MediaView + checked: boolean + onCheckedChange: (value: boolean) => void +} + +const MediaGridItem = ({ + media, + checked, + onCheckedChange, +}: MediaGridItemProps) => { + const [isLoading, setIsLoading] = useState(true) + + const { t } = useTranslation() + + const handleToggle = useCallback(() => { + onCheckedChange(!checked) + }, [checked, onCheckedChange]) + + return ( + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/products/common/components/upload-media-form-item/index.ts b/packages/admin-next/dashboard/src/routes/products/common/components/upload-media-form-item/index.ts new file mode 100644 index 0000000000..c1d85fa8d9 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/common/components/upload-media-form-item/index.ts @@ -0,0 +1 @@ +export * from "./upload-media-form-item" diff --git a/packages/admin-next/dashboard/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx b/packages/admin-next/dashboard/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx new file mode 100644 index 0000000000..0d8f08d657 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx @@ -0,0 +1,104 @@ +import { useTranslation } from "react-i18next" +import { Form } from "../../../../../components/common/form" +import { + FileType, + FileUpload, +} from "../../../../../components/common/file-upload" +import { UseFormReturn } from "react-hook-form" +import { + EditProductMediaSchemaType, + ProductCreateSchemaType, +} from "../../../product-create/types" +import { MediaSchema } from "../../../product-create/constants" +import { z } from "zod" + +type Media = z.infer + +const SUPPORTED_FORMATS = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/heic", + "image/svg+xml", +] + +const SUPPORTED_FORMATS_FILE_EXTENSIONS = [ + ".jpeg", + ".png", + ".gif", + ".webp", + ".heic", + ".svg", +] + +export const UploadMediaFormItem = ({ + form, + append, +}: { + form: + | UseFormReturn + | UseFormReturn + append: (value: Media) => void +}) => { + const { t } = useTranslation() + + const hasInvalidFiles = (fileList: FileType[]) => { + const invalidFile = fileList.find( + (f) => !SUPPORTED_FORMATS.includes(f.file.type) + ) + + if (invalidFile) { + form.setError("media", { + type: "invalid_file", + message: t("products.media.invalidFileType", { + name: invalidFile.file.name, + types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "), + }), + }) + + return true + } + + return false + } + + return ( + ["control"] + } + name="media" + render={() => { + return ( + +
+
+ {t("products.media.label")} + {t("products.media.editHint")} +
+ + { + 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 })) + }} + /> + + +
+
+ ) + }} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-media-section/product-create-details-media-section.tsx b/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-media-section/product-create-details-media-section.tsx index 5e74e4a88e..f05f2876f1 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-media-section/product-create-details-media-section.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-media-section/product-create-details-media-section.tsx @@ -1,69 +1,80 @@ -import { Heading } from "@medusajs/ui" -import { UseFormReturn } from "react-hook-form" +import { CommandBar, Heading } from "@medusajs/ui" +import { UseFormReturn, useFieldArray } from "react-hook-form" import { useTranslation } from "react-i18next" - -import { FileUpload } from "../../../../../../../components/common/file-upload" -import { Form } from "../../../../../../../components/common/form" import { ProductCreateSchemaType } from "../../../../types" +import { MediaGrid } from "../../../../../common/components/media-grid-view" +import { useCallback, useState } from "react" +import { UploadMediaFormItem } from "../../../../../common/components/upload-media-form-item" type ProductCreateMediaSectionProps = { form: UseFormReturn } -const SUPPORTED_FORMATS = [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/heic", - "image/svg+xml", -] - export const ProductCreateMediaSection = ({ form, }: ProductCreateMediaSectionProps) => { const { t } = useTranslation() + const [selection, setSelection] = useState>({}) + const selectionCount = Object.keys(selection).length + + const { fields, append, remove } = useFieldArray({ + name: "media", + control: form.control, + keyName: "field_id", + }) + + const handleDelete = () => { + const ids = Object.keys(selection) + const indices = ids.map((id) => fields.findIndex((m) => m.id === id)) + + remove(indices) + setSelection({}) + } + + const handleCheckedChange = useCallback( + (id: string) => { + return (val: boolean) => { + if (!val) { + const { [id]: _, ...rest } = selection + setSelection(rest) + } else { + setSelection((prev) => ({ ...prev, [id]: true })) + } + } + }, + [selection] + ) return (
{t("products.media.label")}
- { - return ( - -
-
- - {t("products.media.label")} - - {t("products.media.editHint")} -
- - { - form.clearErrors("images") - // if (hasInvalidFiles(files)) { - // return - // } - - // files.forEach((f) => append(f)) - }} - /> - - -
-
- ) - }} - /> +
+ {fields?.length ? ( + + ) : null} + + + + + {t("general.countSelected", { + count: selectionCount, + })} + + + + + +
) } diff --git a/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-details-form/product-create-details-form.tsx b/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-details-form/product-create-details-form.tsx index 17f22e3684..6f7afe4483 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-details-form/product-create-details-form.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-details-form/product-create-details-form.tsx @@ -6,6 +6,7 @@ import { Divider } from "../../../../../components/common/divider" import { ProductCreateSchemaType } from "../../types" import { ProductCreateGeneralSection } from "./components/product-create-details-general-section" import { ProductCreateVariantsSection } from "./components/product-create-details-variant-section" +import { ProductCreateMediaSection } from "./components/product-create-details-media-section" type ProductAttributesProps = { form: UseFormReturn @@ -19,6 +20,8 @@ export const ProductCreateDetailsForm = ({ form }: ProductAttributesProps) => { + + ) diff --git a/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx b/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx index 85c84091cf..fcc563170d 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx @@ -19,6 +19,8 @@ import { ProductCreateOrganizeForm } from "../product-create-organize-form" import { ProductCreateInventoryKitForm } from "../product-create-inventory-kit-form" import { ProductCreateVariantsForm } from "../product-create-variants-form" import { isFetchError } from "../../../../../lib/is-fetch-error" +import { sdk } from "../../../../../lib/client" +import { HttpTypes } from "@medusajs/types" enum Tab { DETAILS = "details", @@ -80,13 +82,41 @@ export const ProductCreateForm = () => { const isDraftSubmission = submitter.dataset.name === SAVE_DRAFT_BUTTON - const payload = { ...values } + const media = values.media || [] + const payload = { ...values, media: undefined } + let uploadedMedia: (HttpTypes.AdminFile & { isThumbnail: boolean })[] = [] try { + if (media.length) { + const thumbnailReq = media.find((m) => m.isThumbnail) + const otherMediaReq = media.filter((m) => !m.isThumbnail) + + const fileReqs = [] + if (thumbnailReq) { + fileReqs.push( + sdk.admin.uploads + .create({ files: [thumbnailReq.file] }) + .then((r) => r.files.map((f) => ({ ...f, isThumbnail: true }))) + ) + } + if (otherMediaReq?.length) { + fileReqs.push( + sdk.admin.uploads + .create({ + files: otherMediaReq.map((m) => m.file), + }) + .then((r) => r.files.map((f) => ({ ...f, isThumbnail: false }))) + ) + } + + uploadedMedia = (await Promise.all(fileReqs)).flat() + } + const { product } = await mutateAsync( normalizeProductFormValues({ // TODO: workflow should handle inventory creation ...payload, + media: uploadedMedia, status: (isDraftSubmission ? "draft" : "published") as any, }) ) diff --git a/packages/admin-next/dashboard/src/routes/products/product-create/constants.ts b/packages/admin-next/dashboard/src/routes/products/product-create/constants.ts index 8556c72c85..0d54af2df6 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-create/constants.ts +++ b/packages/admin-next/dashboard/src/routes/products/product-create/constants.ts @@ -2,6 +2,13 @@ import { z } from "zod" import { decorateVariantsWithDefaultValues } from "./utils.ts" import { optionalInt } from "../../../lib/validation.ts" +export const MediaSchema = z.object({ + id: z.string().optional(), + url: z.string(), + isThumbnail: z.boolean(), + file: z.any().nullable(), // File +}) + export const ProductCreateSchema = z .object({ title: z.string().min(1), @@ -69,8 +76,7 @@ export const ProductCreateSchema = z }) ) .min(1), - images: z.array(z.string()).optional(), - thumbnail: z.string().optional(), + media: z.array(MediaSchema).optional(), }) .superRefine((data, ctx) => { if (data.variants.every((v) => !v.should_create)) { @@ -82,6 +88,10 @@ export const ProductCreateSchema = z } }) +export const EditProductMediaSchema = z.object({ + media: z.array(MediaSchema), +}) + export const PRODUCT_CREATE_FORM_DEFAULTS: Partial< z.infer > = { @@ -107,8 +117,7 @@ export const PRODUCT_CREATE_FORM_DEFAULTS: Partial< }, ]), enable_variants: false, - images: [], - thumbnail: "", + media: [], categories: [], collection_id: "", description: "", diff --git a/packages/admin-next/dashboard/src/routes/products/product-create/types.ts b/packages/admin-next/dashboard/src/routes/products/product-create/types.ts index 00429b9b93..6e86a97138 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-create/types.ts +++ b/packages/admin-next/dashboard/src/routes/products/product-create/types.ts @@ -1,4 +1,6 @@ import { z } from "zod" -import { ProductCreateSchema } from "./constants" +import { EditProductMediaSchema, ProductCreateSchema } from "./constants" export type ProductCreateSchemaType = z.infer + +export type EditProductMediaSchemaType = z.infer diff --git a/packages/admin-next/dashboard/src/routes/products/product-create/utils.ts b/packages/admin-next/dashboard/src/routes/products/product-create/utils.ts index cf3f8eeddc..ef995c3062 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-create/utils.ts +++ b/packages/admin-next/dashboard/src/routes/products/product-create/utils.ts @@ -6,6 +6,11 @@ import { castNumber } from "../../../lib/cast-number.ts" export const normalizeProductFormValues = ( values: ProductCreateSchemaType & { status: CreateProductDTO["status"] } ) => { + const thumbnail = values.media?.find((media) => media.isThumbnail)?.url + const images = values.media + ?.filter((media) => !media.isThumbnail) + .map((media) => ({ url: media.url })) + return { status: values.status, is_giftcard: false, @@ -15,9 +20,7 @@ export const normalizeProductFormValues = ( sales_channels: values?.sales_channels?.length ? values.sales_channels?.map((sc) => ({ id: sc.id })) : undefined, - images: values.images?.length - ? values.images.map((url) => ({ url })) - : undefined, + images, collection_id: values.collection_id || undefined, categories: values.categories.map((id) => ({ id })), type_id: values.type_id || undefined, @@ -26,7 +29,7 @@ export const normalizeProductFormValues = ( material: values.material || undefined, mid_code: values.mid_code || undefined, hs_code: values.hs_code || undefined, - thumbnail: values.thumbnail || undefined, + thumbnail, title: values.title, subtitle: values.subtitle || undefined, description: values.description || undefined, diff --git a/packages/admin-next/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx b/packages/admin-next/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx index 55f05f9a0c..22dcefc848 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx @@ -1,67 +1,38 @@ import { zodResolver } from "@hookform/resolvers/zod" -import { CheckMini, Spinner, ThumbnailBadge } from "@medusajs/icons" -import { Image, Product } from "@medusajs/medusa" -import { Button, CommandBar, Tooltip, clx, toast } from "@medusajs/ui" -import { AnimatePresence, motion } from "framer-motion" +import { Button, CommandBar } from "@medusajs/ui" import { Fragment, useCallback, useState } from "react" import { useFieldArray, useForm } from "react-hook-form" import { useTranslation } from "react-i18next" import { z } from "zod" import { Link } from "react-router-dom" -import { - FileType, - FileUpload, -} from "../../../../../components/common/file-upload" -import { Form } from "../../../../../components/common/form" import { RouteFocusModal, useRouteModal, } from "../../../../../components/route-modal" import { useUpdateProduct } from "../../../../../hooks/api/products" import { sdk } from "../../../../../lib/client" +import { + EditProductMediaSchema, + MediaSchema, +} from "../../../product-create/constants" +import { HttpTypes } from "@medusajs/types" +import { MediaGrid } from "../../../common/components/media-grid-view" +import { UploadMediaFormItem } from "../../../common/components/upload-media-form-item" +import { EditProductMediaSchemaType } from "../../../product-create/types" type ProductMediaViewProps = { - product: Product + product: HttpTypes.AdminProduct } -const MediaSchema = z.object({ - id: z.string(), - url: z.string(), - isThumbnail: z.boolean(), - file: z.any().nullable(), // File -}) - -const SUPPORTED_FORMATS = [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/heic", - "image/svg+xml", -] - -const SUPPORTED_FORMATS_FILE_EXTENSIONS = [ - ".jpeg", - ".png", - ".gif", - ".webp", - ".heic", - ".svg", -] - type Media = z.infer -const EditProductMediaSchema = z.object({ - media: z.array(MediaSchema), -}) - export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => { const [selection, setSelection] = useState>({}) const { t } = useTranslation() const { handleSuccess } = useRouteModal() - const form = useForm>({ + const form = useForm({ defaultValues: { media: getDefaultValues(product.images, product.thumbnail), }, @@ -74,47 +45,40 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => { keyName: "field_id", }) - const { mutateAsync, isPending } = useUpdateProduct(product.id) + const { mutateAsync, isPending } = useUpdateProduct(product.id!) const handleSubmit = form.handleSubmit(async ({ media }) => { - const urls = media.map((m) => m.url) - const filesToUpload = media - .map((m, index) => ({ file: m.file, index })) - .filter((m) => m.file) + .map((m, i) => ({ file: m.file, index: i })) + .filter((m) => !!m.file) + + let uploaded: HttpTypes.AdminFile[] = [] if (filesToUpload.length) { - const files = filesToUpload.map((m) => m.file) as File[] - - const uploads = await sdk.admin.uploads - .create({ files }) - .then((res) => { - return res.files - }) + const { files: uploads } = await sdk.admin.uploads + .create({ files: filesToUpload.map((m) => m.file) }) .catch(() => { form.setError("media", { type: "invalid_file", message: t("products.media.failedToUpload"), }) + return { files: [] } }) - - if (!uploads) { - return - } - - // Insert the URLs of the uploaded files back into the urls array - uploads.forEach((upload, i) => { - const originalIndex = filesToUpload[i].index - urls[originalIndex] = upload.url - }) + uploaded = uploads } - const thumbnailIndex = media.findIndex((m) => m.isThumbnail) - const thumbnail = thumbnailIndex > -1 ? urls[thumbnailIndex] : null + const withUpdatedUrls = media.map((entry, i) => { + const toUploadIndex = filesToUpload.findIndex((m) => m.index === i) + if (toUploadIndex > -1) { + return { ...entry, url: uploaded[toUploadIndex]?.url } + } + return entry + }) + const thumbnail = withUpdatedUrls.find((m) => m.isThumbnail)?.url await mutateAsync( { - images: urls.map((url) => ({ 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 || "", }, @@ -126,26 +90,6 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => { ) }) - const hasInvalidFiles = (fileList: FileType[]) => { - const invalidFile = fileList.find( - (f) => !SUPPORTED_FORMATS.includes(f.file.type) - ) - - if (invalidFile) { - form.setError("media", { - type: "invalid_file", - message: t("products.media.invalidFileType", { - name: invalidFile.file.name, - types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "), - }), - }) - - return true - } - - return false - } - const handleCheckedChange = useCallback( (id: string) => { return (val: boolean) => { @@ -221,58 +165,13 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
-
-
- {fields.map((m) => { - return ( - - ) - })} -
-
+
- { - return ( - -
-
- - {t("products.media.label")} - - {t("products.media.editHint")} -
- - { - form.clearErrors("media") - if (hasInvalidFiles(files)) { - return - } - - files.forEach((f) => - append({ ...f, isThumbnail: false }) - ) - }} - /> - - -
-
- ) - }} - /> +
@@ -306,93 +205,14 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => { ) } -const GridItem = ({ - media, - checked, - onCheckedChange, -}: { - media: Media - checked: boolean - onCheckedChange: (value: boolean) => void -}) => { - const [isLoading, setIsLoading] = useState(true) - - const { t } = useTranslation() - - const handleToggle = useCallback(() => { - onCheckedChange(!checked) - }, [checked, onCheckedChange]) - - return ( - - ) -} - -const getDefaultValues = (images: Image[] | null, thumbnail: string | null) => { +const getDefaultValues = ( + images: HttpTypes.AdminProductImage[] | undefined, + thumbnail: string | undefined +) => { const media: Media[] = images?.map((image) => ({ - id: image.id, - url: image.url, + id: image.id!, + url: image.url!, isThumbnail: image.url === thumbnail, file: null, })) || [] diff --git a/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/product-media-view.tsx b/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/product-media-view.tsx index 98d2568365..d21b71d810 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/product-media-view.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/product-media-view.tsx @@ -1,12 +1,11 @@ -import { Product } from "@medusajs/medusa" - import { useSearchParams } from "react-router-dom" import { EditProductMediaForm } from "../edit-product-media-form" import { ProductMediaGallery } from "../product-media-gallery" import { ProductMediaViewContext } from "./product-media-view-context" +import { HttpTypes } from "@medusajs/types" type ProductMediaViewProps = { - product: Product + product: HttpTypes.AdminProduct } enum View { @@ -45,7 +44,7 @@ export const ProductMediaView = ({ product }: ProductMediaViewProps) => { ) } -const renderView = (view: View, product: Product) => { +const renderView = (view: View, product: HttpTypes.AdminProduct) => { switch (view) { case View.GALLERY: return