diff --git a/.changeset/flat-mugs-try.md b/.changeset/flat-mugs-try.md new file mode 100644 index 0000000000000..3d51fdafefdff --- /dev/null +++ b/.changeset/flat-mugs-try.md @@ -0,0 +1,5 @@ +--- +"@medusajs/dashboard": patch +--- + +feat(dashboard): Allow re-ordering product images diff --git a/packages/admin/dashboard/src/components/modals/route-modal-form/route-modal-form.tsx b/packages/admin/dashboard/src/components/modals/route-modal-form/route-modal-form.tsx index 2135f6c696e60..69ececbcdb482 100644 --- a/packages/admin/dashboard/src/components/modals/route-modal-form/route-modal-form.tsx +++ b/packages/admin/dashboard/src/components/modals/route-modal-form/route-modal-form.tsx @@ -7,13 +7,13 @@ import { Form } from "../../common/form" type RouteModalFormProps = PropsWithChildren<{ form: UseFormReturn - blockSearch?: boolean + blockSearchParams?: boolean onClose?: (isSubmitSuccessful: boolean) => void }> export const RouteModalForm = ({ form, - blockSearch = false, + blockSearchParams: blockSearch = false, children, onClose, }: RouteModalFormProps) => { diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index 582afa68b38a1..aa91a999b2988 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -1578,6 +1578,12 @@ "create": { "type": "object", "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, "header": { "type": "string" }, @@ -1742,6 +1748,8 @@ } }, "required": [ + "title", + "description", "header", "tabs", "errors", @@ -1990,6 +1998,9 @@ "action" ], "additionalProperties": false + }, + "successToast": { + "type": "string" } }, "required": [ @@ -2008,7 +2019,8 @@ "galleryLabel", "downloadImageLabel", "deleteImageLabel", - "emptyState" + "emptyState", + "successToast" ], "additionalProperties": false }, diff --git a/packages/admin/dashboard/src/i18n/translations/de.json b/packages/admin/dashboard/src/i18n/translations/de.json index f433dfc43537d..09b6f9c8e78bf 100644 --- a/packages/admin/dashboard/src/i18n/translations/de.json +++ b/packages/admin/dashboard/src/i18n/translations/de.json @@ -386,6 +386,8 @@ "successToast": "Produkz {{title}} angepasst." }, "create": { + "title": "Produkt erstellen", + "description": "Erstellen Sie ein neues Produkt.", "header": "Allgemein", "tabs": { "details": "Details", @@ -490,7 +492,8 @@ "header": "Noch keine Medien", "description": "Fügen Sie dem Produkt Medien hinzu, um es in Ihrem Schaufenster zu präsentieren.", "action": "Medien hinzufügen" - } + }, + "successToast": "Medien wurden erfolgreich aktualisiert." }, "discountableHint": "Wenn diese Option deaktiviert ist, werden auf dieses Produkt keine Rabatte gewährt.", "noSalesChannels": "In keinem Vertriebskanal verfügbar", diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index ae8604bb43718..60af19619567d 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -386,6 +386,8 @@ "successToast": "Product {{title}} was successfully updated." }, "create": { + "title": "Create Product", + "description": "Create a new product.", "header": "General", "tabs": { "details": "Details", @@ -490,7 +492,8 @@ "header": "No media yet", "description": "Add media to the product to showcase it in your storefront.", "action": "Add media" - } + }, + "successToast": "Media was successfully updated." }, "discountableHint": "When unchecked, discounts will not be applied to this product.", "noSalesChannels": "Not available in any sales channels", diff --git a/packages/admin/dashboard/src/i18n/translations/pl.json b/packages/admin/dashboard/src/i18n/translations/pl.json index 3f807b3f58c42..69e4adcab258e 100644 --- a/packages/admin/dashboard/src/i18n/translations/pl.json +++ b/packages/admin/dashboard/src/i18n/translations/pl.json @@ -386,6 +386,8 @@ "successToast": "Produkt {{title}} został pomyślnie zaktualizowany." }, "create": { + "title": "Utwórz produkt", + "description": "Utwórz nowy produkt.", "header": "Ogólne", "tabs": { "details": "Szczegóły", @@ -490,7 +492,8 @@ "header": "Brak mediów", "description": "Dodaj media do produktu, aby zaprezentować go w swoim sklepie.", "action": "Dodaj media" - } + }, + "successToast": "Media zostały pomyślnie zaktualizowane." }, "discountableHint": "Jeśli odznaczone, rabaty nie będą stosowane do tego produktu.", "noSalesChannels": "Niedostępny w żadnych kanałach sprzedaży", @@ -2752,4 +2755,4 @@ "seconds_one": "Drugi", "seconds_other": "Towary drugiej jakości" } -} \ No newline at end of file +} diff --git a/packages/admin/dashboard/src/i18n/translations/tr.json b/packages/admin/dashboard/src/i18n/translations/tr.json index 9812581eb797a..d52e3dce14a0e 100644 --- a/packages/admin/dashboard/src/i18n/translations/tr.json +++ b/packages/admin/dashboard/src/i18n/translations/tr.json @@ -386,6 +386,8 @@ "successToast": "Ürün {{title}} başarıyla güncellendi." }, "create": { + "title": "Ürün Oluştur", + "description": "Yeni bir ürün oluşturun.", "header": "Genel", "tabs": { "details": "Detaylar", @@ -490,7 +492,8 @@ "header": "Henüz medya yok", "description": "Ürünü mağazanızda sergilemek için medya ekleyin.", "action": "Medya ekle" - } + }, + "successToast": "Medya başarıyla güncellendi." }, "discountableHint": "İşaretlenmediğinde, bu ürüne indirim uygulanmayacaktır.", "noSalesChannels": "Hiçbir satış kanalında mevcut değil", diff --git a/packages/admin/dashboard/src/routes/products/common/components/media-grid-view/index.ts b/packages/admin/dashboard/src/routes/products/common/components/media-grid-view/index.ts deleted file mode 100644 index 4b124fbbb8a58..0000000000000 --- a/packages/admin/dashboard/src/routes/products/common/components/media-grid-view/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./media-grid-view" diff --git a/packages/admin/dashboard/src/routes/products/common/components/media-grid-view/media-grid-view.tsx b/packages/admin/dashboard/src/routes/products/common/components/media-grid-view/media-grid-view.tsx deleted file mode 100644 index 122636cecbbc2..0000000000000 --- a/packages/admin/dashboard/src/routes/products/common/components/media-grid-view/media-grid-view.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { CheckMini, Spinner, ThumbnailBadge } from "@medusajs/icons" -import { Tooltip, clx } from "@medusajs/ui" -import { AnimatePresence, motion } from "framer-motion" -import { useCallback, useState } from "react" -import { useTranslation } from "react-i18next" - -interface MediaView { - id?: string - field_id: string - url: string - isThumbnail: boolean -} - -interface MediaGridProps { - media: MediaView[] - selection: Record - onCheckedChange: (id: string) => (value: boolean) => void -} - -export const MediaGrid = ({ - media, - selection, - onCheckedChange, -}: MediaGridProps) => { - return ( -
-
- {media.map((m) => { - return ( - - ) - })} -
-
- ) -} - -interface MediaGridItemProps { - media: MediaView - checked: boolean - onCheckedChange: (value: boolean) => void -} - -const MediaGridItem = ({ - media, - checked, - onCheckedChange, -}: MediaGridItemProps) => { - const [isLoading, setIsLoading] = useState(true) - - const { t } = useTranslation() - - const handleToggle = useCallback(() => { - onCheckedChange(!checked) - }, [checked, onCheckedChange]) - - return ( - - ) -} diff --git a/packages/admin/dashboard/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx b/packages/admin/dashboard/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx index 883ef812e2e58..2d23cb8f74471 100644 --- a/packages/admin/dashboard/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx +++ b/packages/admin/dashboard/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx @@ -1,3 +1,4 @@ +import { useCallback } from "react" import { UseFormReturn } from "react-hook-form" import { useTranslation } from "react-i18next" import { z } from "zod" @@ -45,25 +46,40 @@ export const UploadMediaFormItem = ({ }) => { const { t } = useTranslation() - const hasInvalidFiles = (fileList: FileType[]) => { - const invalidFile = fileList.find( - (f) => !SUPPORTED_FORMATS.includes(f.file.type) - ) + const hasInvalidFiles = useCallback( + (fileList: FileType[]) => { + const invalidFile = fileList.find( + (f) => !SUPPORTED_FORMATS.includes(f.file.type) + ) - if (invalidFile) { - form.setError("media", { - type: "invalid_file", - message: t("products.media.invalidFileType", { - name: invalidFile.file.name, - types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "), - }), - }) + if (invalidFile) { + form.setError("media", { + type: "invalid_file", + message: t("products.media.invalidFileType", { + name: invalidFile.file.name, + types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "), + }), + }) - return true - } + return true + } + + return false + }, + [form, t] + ) - return false - } + const onUploaded = useCallback( + (files: FileType[]) => { + form.clearErrors("media") + if (hasInvalidFiles(files)) { + return + } + + files.forEach((f) => append({ ...f, isThumbnail: false })) + }, + [form, append, hasInvalidFiles] + ) return ( { - form.clearErrors("media") - if (hasInvalidFiles(files)) { - return - } - - // TODO: For now all files that get uploaded are not thumbnails, revisit this logic - files.forEach((f) => append({ ...f, isThumbnail: false })) - }} + onUploaded={onUploaded} /> diff --git a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-media-section/product-create-details-media-section.tsx b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-media-section/product-create-details-media-section.tsx index 7272d893bdad2..6cb5246586f3d 100644 --- a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-media-section/product-create-details-media-section.tsx +++ b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-media-section/product-create-details-media-section.tsx @@ -1,7 +1,33 @@ -import { StackPerspective, ThumbnailBadge, Trash, XMark } from "@medusajs/icons" +import { + defaultDropAnimationSideEffects, + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + DropAnimation, + KeyboardSensor, + PointerSensor, + UniqueIdentifier, + useSensor, + useSensors, +} from "@dnd-kit/core" +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { + DotsSix, + StackPerspective, + ThumbnailBadge, + Trash, + XMark, +} from "@medusajs/icons" import { IconButton, Text } from "@medusajs/ui" -import { useEffect, useState } from "react" -import { UseFormReturn, useFieldArray } from "react-hook-form" +import { useState } from "react" +import { useFieldArray, UseFormReturn } from "react-hook-form" import { useTranslation } from "react-i18next" import { ActionMenu } from "../../../../../../../components/common/action-menu" import { UploadMediaFormItem } from "../../../../../common/components/upload-media-form-item" @@ -11,6 +37,16 @@ type ProductCreateMediaSectionProps = { form: UseFormReturn } +const dropAnimationConfig: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: "0.4", + }, + }, + }), +} + export const ProductCreateMediaSection = ({ form, }: ProductCreateMediaSectionProps) => { @@ -20,6 +56,38 @@ export const ProductCreateMediaSection = ({ keyName: "field_id", }) + const [activeId, setActiveId] = useState(null) + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id) + } + + const handleDragEnd = (event: DragEndEvent) => { + setActiveId(null) + const { active, over } = event + + if (active.id !== over?.id) { + const oldIndex = fields.findIndex((item) => item.field_id === active.id) + const newIndex = fields.findIndex((item) => item.field_id === over?.id) + + form.setValue("media", arrayMove(fields, oldIndex, newIndex), { + shouldDirty: true, + shouldTouch: true, + }) + } + } + + const handleDragCancel = () => { + setActiveId(null) + } + const getOnDelete = (index: number) => { return () => { remove(index) @@ -52,20 +120,36 @@ export const ProductCreateMediaSection = ({ return (
-
    - {fields.map((field, index) => { - const { onDelete, onMakeThumbnail } = getItemHandlers(index) - - return ( - + + {activeId ? ( + m.field_id === activeId)!} /> - ) - })} -
+ ) : null} + +
    + field.field_id)}> + {fields.map((field, index) => { + const { onDelete, onMakeThumbnail } = getItemHandlers(index) + + return ( + + ) + })} + +
+
) } @@ -87,25 +171,62 @@ type MediaItemProps = { const MediaItem = ({ field, onDelete, onMakeThumbnail }: MediaItemProps) => { const { t } = useTranslation() + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: field.field_id }) + + const style = { + opacity: isDragging ? 0.4 : undefined, + transform: CSS.Translate.toString(transform), + transition, + } + if (!field.file) { return null } return ( -
  • -
    -
    - -
    -
    - - {field.file.name} - -
    - {field.isThumbnail && } - - {formatFileSize(field.file.size)} +
  • +
    + + + +
    +
    + +
    +
    + + {field.file.name} +
    + {field.isThumbnail && } + + {formatFileSize(field.file.size)} + +
    @@ -145,28 +266,60 @@ const MediaItem = ({ field, onDelete, onMakeThumbnail }: MediaItemProps) => { ) } -const ThumbnailPreview = ({ file }: { file?: File | null }) => { - const [thumbnailUrl, setThumbnailUrl] = useState(null) - - useEffect(() => { - if (file) { - const objectUrl = URL.createObjectURL(file) - setThumbnailUrl(objectUrl) - - return () => URL.revokeObjectURL(objectUrl) - } - }, [file]) +const MediaGridItemOverlay = ({ field }: { field: MediaField }) => { + return ( +
  • +
    + + + +
    +
    + +
    +
    + + {field.file?.name} + +
    + {field.isThumbnail && } + + {formatFileSize(field.file?.size ?? 0)} + +
    +
    +
    +
    +
    + + {}} + > + + +
    +
  • + ) +} - if (!thumbnailUrl) { +const ThumbnailPreview = ({ url }: { url?: string | null }) => { + if (!url) { return null } return ( - + ) } diff --git a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx index 566ea2fa22673..f8bdcdc76c890 100644 --- a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx +++ b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx @@ -14,7 +14,6 @@ import { } from "../../../../../extensions" import { useCreateProduct } from "../../../../../hooks/api/products" import { sdk } from "../../../../../lib/client" -import { isFetchError } from "../../../../../lib/is-fetch-error" import { PRODUCT_CREATE_FORM_DEFAULTS, ProductCreateSchema, @@ -80,13 +79,10 @@ export const ProductCreateForm = ({ return {} } - return regions.reduce( - (acc, reg) => { - acc[reg.id] = reg.currency_code - return acc - }, - {} as Record - ) + return regions.reduce((acc, reg) => { + acc[reg.id] = reg.currency_code + return acc + }, {} as Record) }, [regions]) /** @@ -140,32 +136,34 @@ export const ProductCreateForm = ({ uploadedMedia = (await Promise.all(fileReqs)).flat() } - - const { product } = await mutateAsync( - normalizeProductFormValues({ - ...payload, - media: uploadedMedia, - status: (isDraftSubmission ? "draft" : "published") as any, - regionsCurrencyMap, - }) - ) - - toast.success( - t("products.create.successToast", { - title: product.title, - }) - ) - - handleSuccess(`../${product.id}`) } catch (error) { - if (isFetchError(error) && error.status === 400) { + if (error instanceof Error) { toast.error(error.message) - } else { - toast.error(t("general.error"), { - description: error.message, - }) } } + + await mutateAsync( + normalizeProductFormValues({ + ...payload, + media: uploadedMedia, + status: (isDraftSubmission ? "draft" : "published") as any, + regionsCurrencyMap, + }), + { + onSuccess: (data) => { + toast.success( + t("products.create.successToast", { + title: data.product.title, + }) + ) + + handleSuccess(`../${data.product.id}`) + }, + onError: (error) => { + toast.error(error.message) + }, + } + ) }) const onNext = async (currentTab: Tab) => { @@ -210,143 +208,141 @@ export const ProductCreateForm = ({ } setTabState({ ...currentState }) - }, [tab]) + }, [tab, tabState]) return ( - - - { - // We want to continue to the next tab on enter instead of saving as draft immediately - if (e.key === "Enter") { - e.preventDefault() - - if (e.metaKey || e.ctrlKey) { - if (tab !== Tab.VARIANTS) { - e.preventDefault() - e.stopPropagation() - onNext(tab) - - return - } - - handleSubmit() - } - } - }} - onSubmit={handleSubmit} - className="flex h-full flex-col" - > - { - const valid = await form.trigger() + + { + // We want to continue to the next tab on enter instead of saving as draft immediately + if (e.key === "Enter") { + e.preventDefault() + + if (e.metaKey || e.ctrlKey) { + if (tab !== Tab.VARIANTS) { + e.preventDefault() + e.stopPropagation() + onNext(tab) - if (!valid) { return } - setTab(tab as Tab) - }} - className="flex h-full flex-col overflow-hidden" - > - -
    - - - {t("products.create.tabs.details")} - - - {t("products.create.tabs.organize")} - + handleSubmit() + } + } + }} + onSubmit={handleSubmit} + className="flex h-full flex-col" + > + { + const valid = await form.trigger() + + if (!valid) { + return + } + + setTab(tab as Tab) + }} + className="flex h-full flex-col overflow-hidden" + > + +
    + + + {t("products.create.tabs.details")} + + + {t("products.create.tabs.organize")} + + + {t("products.create.tabs.variants")} + + {showInventoryTab && ( - {t("products.create.tabs.variants")} + {t("products.create.tabs.inventory")} - {showInventoryTab && ( - - {t("products.create.tabs.inventory")} - - )} - -
    -
    - - - - - - - + )} +
    +
    +
    + + + + + + + + + + + {showInventoryTab && ( - + - {showInventoryTab && ( - - - - )} - -
    - -
    - - - - - -
    -
    -
    -
    -
    + + + + + + + ) } diff --git a/packages/admin/dashboard/src/routes/products/product-create/product-create.tsx b/packages/admin/dashboard/src/routes/products/product-create/product-create.tsx index c9c872cfa9740..fd65e86e13480 100644 --- a/packages/admin/dashboard/src/routes/products/product-create/product-create.tsx +++ b/packages/admin/dashboard/src/routes/products/product-create/product-create.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next" import { RouteFocusModal } from "../../../components/modals" import { useRegions } from "../../../hooks/api" import { usePricePreferences } from "../../../hooks/api/price-preferences" @@ -6,13 +7,15 @@ import { useStore } from "../../../hooks/api/store" import { ProductCreateForm } from "./components/product-create-form/product-create-form" export const ProductCreate = () => { + const { t } = useTranslation() + const { store, isPending: isStorePending, isError: isStoreError, error: storeError, } = useStore({ - fields: "default_sales_channel", + fields: "+default_sales_channel", }) const { @@ -68,6 +71,12 @@ export const ProductCreate = () => { return ( + + {t("products.create.title")} + + + {t("products.create.description")} + {ready && ( } -) => { +): HttpTypes.AdminCreateProduct => { const thumbnail = values.media?.find((media) => media.isThumbnail)?.url const images = values.media ?.filter((media) => !media.isThumbnail) @@ -51,7 +51,7 @@ export const normalizeProductFormValues = ( export const normalizeVariants = ( variants: ProductCreateSchemaType["variants"], regionsCurrencyMap: Record -) => { +): HttpTypes.AdminCreateProductVariant[] => { return variants.map((variant) => ({ title: variant.custom_title || Object.values(variant.options || {}).join(" / "), @@ -61,7 +61,9 @@ export const normalizeVariants = ( allow_backorder: !!variant.allow_backorder, inventory_items: variant .inventory!.map((i) => { - const quantity = castNumber(i.required_quantity) + const quantity = i.required_quantity + ? castNumber(i.required_quantity) + : null if (!i.inventory_item_id || !quantity) { return false @@ -72,7 +74,12 @@ export const normalizeVariants = ( required_quantity: quantity, } }) - .filter(Boolean), + .filter( + ( + item + ): item is { required_quantity: number; inventory_item_id: string } => + item !== false + ), prices: Object.entries(variant.prices || {}) .map(([key, value]: any) => { if (value === "" || value === undefined) { diff --git a/packages/admin/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx b/packages/admin/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx index 224147a171819..11ab0f748746a 100644 --- a/packages/admin/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx +++ b/packages/admin/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx @@ -1,12 +1,34 @@ +import { + defaultDropAnimationSideEffects, + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + DropAnimation, + KeyboardSensor, + PointerSensor, + UniqueIdentifier, + useSensor, + useSensors, +} from "@dnd-kit/core" +import { + arrayMove, + rectSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" import { zodResolver } from "@hookform/resolvers/zod" -import { Button, CommandBar } from "@medusajs/ui" +import { ThumbnailBadge } from "@medusajs/icons" +import { HttpTypes } from "@medusajs/types" +import { Button, Checkbox, clx, CommandBar, toast, Tooltip } from "@medusajs/ui" import { Fragment, useCallback, useState } from "react" import { useFieldArray, useForm } from "react-hook-form" import { useTranslation } from "react-i18next" +import { Link } from "react-router-dom" import { z } from "zod" -import { HttpTypes } from "@medusajs/types" -import { Link } from "react-router-dom" import { RouteFocusModal, useRouteModal, @@ -14,7 +36,6 @@ import { import { KeyboundForm } from "../../../../../components/utilities/keybound-form" import { useUpdateProduct } from "../../../../../hooks/api/products" import { sdk } from "../../../../../lib/client" -import { MediaGrid } from "../../../common/components/media-grid-view" import { UploadMediaFormItem } from "../../../common/components/upload-media-form-item" import { EditProductMediaSchema, @@ -46,6 +67,38 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => { keyName: "field_id", }) + const [activeId, setActiveId] = useState(null) + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id) + } + + const handleDragEnd = (event: DragEndEvent) => { + setActiveId(null) + const { active, over } = event + + if (active.id !== over?.id) { + const oldIndex = fields.findIndex((item) => item.field_id === active.id) + const newIndex = fields.findIndex((item) => item.field_id === over?.id) + + form.setValue("media", arrayMove(fields, oldIndex, newIndex), { + shouldDirty: true, + shouldTouch: true, + }) + } + } + + const handleDragCancel = () => { + setActiveId(null) + } + const { mutateAsync, isPending } = useUpdateProduct(product.id!) const handleSubmit = form.handleSubmit(async ({ media }) => { @@ -80,13 +133,16 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => { await mutateAsync( { images: withUpdatedUrls.map((file) => ({ url: file.url })), - // Set thumbnail to empty string if no thumbnail is selected, as the API does not accept null - thumbnail: thumbnail || "", + thumbnail: thumbnail, }, { onSuccess: () => { + toast.success(t("products.media.successToast")) handleSuccess() }, + onError: (error) => { + toast.error(error.message) + }, } ) }) @@ -142,7 +198,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => { const selectionCount = Object.keys(selection).length return ( - + {
    - + +
    +
    + m.field_id)} + strategy={rectSortingStrategy} + > + {fields.map((m) => { + return ( + + ) + })} + + + {activeId ? ( + m.field_id === activeId)!} + checked={ + !!selection[ + fields.find((m) => m.field_id === activeId)!.id! + ] + } + /> + ) : null} + +
    +
    +
    @@ -211,8 +300,8 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => { } const getDefaultValues = ( - images: HttpTypes.AdminProductImage[] | undefined, - thumbnail: string | undefined + images: HttpTypes.AdminProductImage[] | null | undefined, + thumbnail: string | null | undefined ) => { const media: Media[] = images?.map((image) => ({ @@ -235,3 +324,133 @@ const getDefaultValues = ( return media } + +interface MediaView { + id?: string + field_id: string + url: string + isThumbnail: boolean +} + +const dropAnimationConfig: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: "0.4", + }, + }, + }), +} + +interface MediaGridItemProps { + media: MediaView + checked: boolean + onCheckedChange: (value: boolean) => void +} + +const MediaGridItem = ({ + media, + checked, + onCheckedChange, +}: MediaGridItemProps) => { + const { t } = useTranslation() + + const handleToggle = useCallback( + (value: boolean) => { + onCheckedChange(value) + }, + [onCheckedChange] + ) + + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: media.field_id }) + + const style = { + opacity: isDragging ? 0.4 : undefined, + transform: CSS.Transform.toString(transform), + transition, + } + + return ( +
    + {media.isThumbnail && ( +
    + + + +
    + )} +
    +
    + { + e.stopPropagation() + }} + checked={checked} + onCheckedChange={handleToggle} + /> +
    + +
    + ) +} + +export const MediaGridItemOverlay = ({ + media, + checked, +}: { + media: MediaView + checked: boolean +}) => { + return ( +
    + {media.isThumbnail && ( +
    + +
    + )} +
    + +
    + +
    + ) +} diff --git a/packages/admin/dashboard/src/routes/products/product-media/components/product-media-gallery/product-media-gallery.tsx b/packages/admin/dashboard/src/routes/products/product-media/components/product-media-gallery/product-media-gallery.tsx index 3bdb10d84620b..1f410642e09ae 100644 --- a/packages/admin/dashboard/src/routes/products/product-media/components/product-media-gallery/product-media-gallery.tsx +++ b/packages/admin/dashboard/src/routes/products/product-media/components/product-media-gallery/product-media-gallery.tsx @@ -24,39 +24,39 @@ export const ProductMediaGallery = ({ product }: ProductMediaGalleryProps) => { const { t } = useTranslation() const prompt = usePrompt() - const { mutateAsync, isLoading } = useUpdateProduct(product.id) + const { mutateAsync, isPending } = useUpdateProduct(product.id) const media = getMedia(product.images, product.thumbnail) const next = useCallback(() => { - if (isLoading) { + if (isPending) { return } setCurr((prev) => (prev + 1) % media.length) - }, [media, isLoading]) + }, [media, isPending]) const prev = useCallback(() => { - if (isLoading) { + if (isPending) { return } setCurr((prev) => (prev - 1 + media.length) % media.length) - }, [media, isLoading]) + }, [media, isPending]) const goTo = useCallback( (index: number) => { - if (isLoading) { + if (isPending) { return } setCurr(index) }, - [isLoading] + [isPending] ) const handleDownloadCurrent = () => { - if (isLoading) { + if (isPending) { return } @@ -87,9 +87,10 @@ export const ProductMediaGallery = ({ product }: ProductMediaGalleryProps) => { return } - const mediaToKeep = product.images - .filter((i) => i.id !== current.id) - .map((i) => ({ url: i.url })) + const mediaToKeep = + product.images + ?.filter((i) => i.id !== current.id) + .map((i) => ({ url: i.url })) || [] if (curr === media.length - 1) { setCurr((prev) => prev - 1) @@ -195,7 +196,7 @@ const Canvas = ({ media, curr }: { media: Media[]; curr: number }) => { return (
    -
    +
    {media[curr].isThumbnail && (
    @@ -206,7 +207,7 @@ const Canvas = ({ media, curr }: { media: Media[]; curr: number }) => {
    diff --git a/packages/admin/dashboard/src/routes/products/product-media/product-media.tsx b/packages/admin/dashboard/src/routes/products/product-media/product-media.tsx index 03a2334cb581f..d5f05866e2ff0 100644 --- a/packages/admin/dashboard/src/routes/products/product-media/product-media.tsx +++ b/packages/admin/dashboard/src/routes/products/product-media/product-media.tsx @@ -1,9 +1,11 @@ +import { useTranslation } from "react-i18next" import { useParams } from "react-router-dom" import { RouteFocusModal } from "../../../components/modals" import { useProduct } from "../../../hooks/api/products" import { ProductMediaView } from "./components/product-media-view" export const ProductMedia = () => { + const { t } = useTranslation() const { id } = useParams() const { product, isLoading, isError, error } = useProduct(id!) @@ -16,6 +18,12 @@ export const ProductMedia = () => { return ( + + {t("products.media.label")} + + + {t("products.media.editHint")} + {ready && } )