feat: Add some of the missing pieces in the create product form (#6918)

The changes are still partial, there is more work to be done to have everything function properly.
Also, refactored the file upload from the product media form to a separate component
This commit is contained in:
Stevche Radevski authored and GitHub committed 2024-04-04 10:27:33 +00:00
1 parent 483bf98a49
commit 06b2f0a8dc
8 files changed
+539 -216

No files matched your search

@@ -55,6 +55,7 @@
}, },
"actions": { "actions": {
"save": "Save", "save": "Save",
"saveAsDraft": "Save as draft",
"create": "Create", "create": "Create",
"delete": "Delete", "delete": "Delete",
"remove": "Remove", "remove": "Remove",
@@ -151,7 +152,7 @@
"attributes": "Attributes", "attributes": "Attributes",
"editProduct": "Edit Product", "editProduct": "Edit Product",
"editAttributes": "Edit Attributes", "editAttributes": "Edit Attributes",
"organization": "Organization", "organization": "Organize",
"editOrganization": "Edit Organization", "editOrganization": "Edit Organization",
"options": "Options", "options": "Options",
"editOptions": "Edit Options", "editOptions": "Edit Options",
@@ -171,10 +172,7 @@
"deleteImageLabel": "Delete current image", "deleteImageLabel": "Delete current image",
"noMediaLabel": "The product has no associated media." "noMediaLabel": "The product has no associated media."
}, },
"titleHint": "Give your product a short and clear title.<0/>50-60 characters is the recommended length for search engines.",
"descriptionHint": "Give your product a short and clear description.<0/>120-160 characters is the recommended length for search engines.",
"discountableHint": "When unchecked discounts will not be applied to this product.", "discountableHint": "When unchecked discounts will not be applied to this product.",
"handleTooltip": "The handle is used to reference the product in your storefront. If not specified, the handle will be generated from the product title.",
"availableInSalesChannels": "Available in <0>{{x}}</0> of <1>{{y}}</1> sales channels", "availableInSalesChannels": "Available in <0>{{x}}</0> of <1>{{y}}</1> sales channels",
"noSalesChannels": "Not available in any sales channels", "noSalesChannels": "Not available in any sales channels",
"variantCount_one": "{{count}} variant", "variantCount_one": "{{count}} variant",
@@ -186,6 +184,61 @@
"proposed": "Proposed", "proposed": "Proposed",
"rejected": "Rejected" "rejected": "Rejected"
}, },
"fields": {
"title": {
"label": "Title",
"hint": "Give your product a short and clear title.<0/>50-60 characters is the recommended length for search engines."
},
"subtitle": {
"label": "Subtitle"
},
"handle": {
"label": "Handle",
"tooltip": "The handle is used to reference the product in your storefront. If not specified, the handle will be generated from the product title."
},
"description": {
"label": "Description",
"hint": "Give your product a short and clear description.<0/>120-160 characters is the recommended length for search engines."
},
"discountable": {
"label": "Discountable",
"hint": "When unchecked discounts will not be applied to this product"
},
"type": {
"label": "Type"
},
"collection": {
"label": "Collection"
},
"categories": {
"label": "Categories"
},
"tags": {
"label": "Tags"
},
"sales_channels": {
"label": "Sales channels",
"hint": "This product will only be available in the default sales channel if left untouched"
},
"countryOrigin": {
"label": "Country of origin"
},
"material": {
"label": "Material"
},
"width": {
"label": "Width"
},
"length": {
"label": "Length"
},
"height": {
"label": "Height"
},
"weight": {
"label": "Weight"
}
},
"variant": { "variant": {
"edit": { "edit": {
"header": "Edit Variant" "header": "Edit Variant"
@@ -0,0 +1,135 @@
import { ArrowDownTray } from "@medusajs/icons"
import { Text, clx } from "@medusajs/ui"
import { ChangeEvent, DragEvent, useRef, useState } from "react"
export interface FileType {
id: string
url: string
file: File
}
export interface FileUploadProps {
label: string
hint?: string
hasError?: boolean
formats: string[]
onUploaded: (files: FileType[]) => void
}
export const FileUpload = ({
label,
hint,
hasError,
formats,
onUploaded,
}: FileUploadProps) => {
const [isDragOver, setIsDragOver] = useState<boolean>(false)
const inputRef = useRef<HTMLInputElement>(null)
const dropZoneRef = useRef<HTMLButtonElement>(null)
const handleOpenFileSelector = () => {
inputRef.current?.click()
}
const handleDragEnter = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
const files = event.dataTransfer?.files
if (!files) {
return
}
setIsDragOver(true)
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
if (
!dropZoneRef.current ||
dropZoneRef.current.contains(event.relatedTarget as Node)
) {
return
}
setIsDragOver(false)
}
const handleUploaded = (files: FileList | null) => {
if (!files) {
return
}
const fileList = Array.from(files)
const fileObj = fileList.map((file) => {
const previewUrl = URL.createObjectURL(file)
return {
id: crypto.randomUUID(),
url: previewUrl,
file,
}
})
onUploaded(fileObj)
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
setIsDragOver(false)
handleUploaded(event.dataTransfer?.files)
}
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
handleUploaded(event.target.files)
}
return (
<div>
<button
ref={dropZoneRef}
type="button"
onClick={handleOpenFileSelector}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
className={clx(
"bg-ui-bg-component border-ui-border-strong transition-fg group flex w-full flex-col items-center gap-y-2 rounded-lg border border-dashed p-8",
"hover:border-ui-border-interactive focus:border-ui-border-interactive",
"focus:shadow-borders-focus outline-none focus:border-solid",
{
"!border-ui-border-error": hasError,
"!border-ui-border-interactive": isDragOver,
}
)}
>
<div className="text-ui-fg-subtle group-disabled:text-ui-fg-disabled flex items-center gap-x-2">
<ArrowDownTray />
<Text>{label}</Text>
</div>
{!!hint && (
<Text
size="small"
leading="compact"
className="text-ui-fg-muted group-disabled:text-ui-fg-disabled"
>
{hint}
</Text>
)}
</button>
<input
hidden
ref={inputRef}
onChange={handleFileChange}
type="file"
accept={formats.join(",")}
multiple
/>
</div>
)
}
@@ -0,0 +1 @@
export * from "./file-upload"
@@ -6,7 +6,7 @@ import {
import { v1Routes } from "./v1" import { v1Routes } from "./v1"
import { v2Routes } from "./v2" import { v2Routes } from "./v2"
const V2_ENABLED = import.meta.env.VITE_MEDUSA_V2 || false const V2_ENABLED = import.meta.env.VITE_MEDUSA_V2 === "true"
const router = createBrowserRouter(V2_ENABLED ? v2Routes : v1Routes) const router = createBrowserRouter(V2_ENABLED ? v2Routes : v1Routes)
@@ -1,10 +1,25 @@
import { Button, Checkbox, Heading, Input, Text, Textarea } from "@medusajs/ui" import {
Button,
Checkbox,
Heading,
Input,
Select,
Switch,
Text,
Textarea,
} from "@medusajs/ui"
import { Trans, useTranslation } from "react-i18next" import { Trans, useTranslation } from "react-i18next"
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels" import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"
import { SalesChannel } from "@medusajs/medusa" import { SalesChannel } from "@medusajs/medusa"
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table" import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
import { useAdminSalesChannels } from "medusa-react" import {
useAdminCollections,
useAdminProductCategories,
useAdminProductTags,
useAdminProductTypes,
useAdminSalesChannels,
} from "medusa-react"
import { Fragment, useMemo, useState } from "react" import { Fragment, useMemo, useState } from "react"
import { CountrySelect } from "../../../../../components/common/country-select" import { CountrySelect } from "../../../../../components/common/country-select"
import { Form } from "../../../../../components/common/form" import { Form } from "../../../../../components/common/form"
@@ -15,14 +30,38 @@ import { useSalesChannelTableFilters } from "../../../../../hooks/table/filters/
import { useSalesChannelTableQuery } from "../../../../../hooks/table/query/use-sales-channel-table-query" import { useSalesChannelTableQuery } from "../../../../../hooks/table/query/use-sales-channel-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../hooks/use-data-table"
import { CreateProductFormReturn } from "./create-product-form" import { CreateProductFormReturn } from "./create-product-form"
import { Combobox } from "../../../../../components/common/combobox"
import { FileUpload } from "../../../../../components/common/file-upload"
type CreateProductPropsProps = { type CreateProductPropsProps = {
form: CreateProductFormReturn form: CreateProductFormReturn
} }
const SUPPORTED_FORMATS = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/heic",
"image/svg+xml",
]
export const CreateProductDetails = ({ form }: CreateProductPropsProps) => { export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [open, onOpenChange] = useState(false) const [open, onOpenChange] = useState(false)
const { product_types, isLoading: isLoadingTypes } = useAdminProductTypes()
const { product_tags, isLoading: isLoadingTags } = useAdminProductTags()
const { collections, isLoading: isLoadingCollections } = useAdminCollections()
const { sales_channels, isLoading: isLoadingSalesChannels } =
useAdminSalesChannels()
const { product_categories, isLoading: isLoadingCategories } =
useAdminProductCategories()
// const { append } = useFieldArray({
// name: "images",
// control: form.control,
// // keyName: "field_id",
// })
return ( return (
<PanelGroup <PanelGroup
@@ -51,7 +90,9 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
render={({ field }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label>{t("fields.title")}</Form.Label> <Form.Label>
{t("products.fields.title.label")}
</Form.Label>
<Form.Control> <Form.Control>
<Input {...field} /> <Input {...field} />
</Form.Control> </Form.Control>
@@ -66,7 +107,7 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label optional> <Form.Label optional>
{t("fields.subtitle")} {t("products.fields.subtitle.label")}
</Form.Label> </Form.Label>
<Form.Control> <Form.Control>
<Input {...field} /> <Input {...field} />
@@ -76,17 +117,13 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
}} }}
/> />
</div> </div>
<Text <Form.Hint>
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
<Trans <Trans
i18nKey="products.titleHint" i18nKey="products.fields.title.hint"
t={t} t={t}
components={[<br key="break" />]} components={[<br key="break" />]}
/> />
</Text> </Form.Hint>
</div> </div>
<div className="grid grid-cols-2 gap-x-4"> <div className="grid grid-cols-2 gap-x-4">
<Form.Field <Form.Field
@@ -96,7 +133,7 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label <Form.Label
tooltip={t("products.handleTooltip")} tooltip={t("products.fields.handle.tooltip")}
optional optional
> >
{t("fields.handle")} {t("fields.handle")}
@@ -108,22 +145,6 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
) )
}} }}
/> />
<Form.Field
control={form.control}
name="material"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("fields.material")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
</div> </div>
<Form.Field <Form.Field
control={form.control} control={form.control}
@@ -132,14 +153,14 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label optional> <Form.Label optional>
{t("fields.description")} {t("products.fields.description.label")}
</Form.Label> </Form.Label>
<Form.Control> <Form.Control>
<Textarea {...field} /> <Textarea {...field} />
</Form.Control> </Form.Control>
<Form.Hint> <Form.Hint>
<Trans <Trans
i18nKey={"products.descriptionHint"} i18nKey={"products.fields.description.hint"}
components={[<br key="break" />]} components={[<br key="break" />]}
/> />
</Form.Hint> </Form.Hint>
@@ -150,16 +171,224 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
</div> </div>
<div id="organize" className="flex flex-col gap-y-8"> <div id="organize" className="flex flex-col gap-y-8">
<Heading level="h2">{t("products.organization")}</Heading> <Heading level="h2">{t("products.organization")}</Heading>
<Button <div className="grid grid-cols-1 gap-x-4">
<Form.Field
control={form.control}
name="discountable"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="flex items-center justify-between">
<Form.Label optional>
{t("products.fields.discountable.label")}
</Form.Label>
<Form.Control>
<Switch
{...field}
checked={!!value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
</Form.Item>
)
}}
/>
<Form.Hint>
<Trans i18nKey={"products.fields.discountable.hint"} />
</Form.Hint>
</div>
<div className="grid grid-cols-2 gap-x-4">
<Form.Field
control={form.control}
name="type_id"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.type.label")}
</Form.Label>
<Form.Control>
<Select disabled={isLoadingTypes} {...field}>
<Select.Trigger ref={field.ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{(product_types ?? []).map((type) => (
<Select.Item key={type.id} value={type.id}>
{type.value}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="collection_id"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.collection.label")}
</Form.Label>
<Form.Control>
<Select disabled={isLoadingCollections} {...field}>
<Select.Trigger ref={field.ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{(collections ?? []).map((collection) => (
<Select.Item
key={collection.id}
value={collection.id}
>
{collection.title}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-2 gap-x-4">
<Form.Field
control={form.control}
name="category_ids"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.categories.label")}
</Form.Label>
<Form.Control>
<Combobox
disabled={isLoadingCategories}
options={(product_categories ?? []).map(
(category) => ({
label: category.name,
value: category.id,
})
)}
{...field}
/>
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="tags"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.tags.label")}
</Form.Label>
<Form.Control>
<Combobox
disabled={isLoadingTags}
options={(product_tags ?? []).map((tag) => ({
label: tag.value,
value: tag.id,
}))}
{...field}
/>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
{/* TODO: Align to match designs */}
<div className="grid grid-cols-1 gap-x-4">
<Form.Field
control={form.control}
name="sales_channels"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.sales_channels.label")}
</Form.Label>
<Form.Hint>
<Trans
i18nKey={"products.fields.sales_channels.hint"}
/>
</Form.Hint>
<Form.Control>
<Combobox
disabled={isLoadingSalesChannels}
options={(sales_channels ?? []).map(
(salesChannel) => ({
label: salesChannel.name,
value: salesChannel.id,
})
)}
{...field}
/>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
{/* <Button
size="small" size="small"
variant="secondary" variant="secondary"
onClick={() => onOpenChange(!open)} onClick={() => onOpenChange(!open)}
> >
{t("actions.edit")} {t("actions.edit")}
</Button> </Button> */}
</div> </div>
<div id="variants" className="flex flex-col gap-y-8">
<Heading level="h2">{t("products.variants")}</Heading>
</div>
<div id="attributes" className="flex flex-col gap-y-8"> <div id="attributes" className="flex flex-col gap-y-8">
<Heading level="h2">{t("products.attributes")}</Heading> <Heading level="h2">{t("products.attributes")}</Heading>
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
<Form.Field
control={form.control}
name="origin_country"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.countryOrigin.label")}
</Form.Label>
<Form.Control>
<CountrySelect {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="material"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.material.label")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-8"> <div className="grid grid-cols-2 gap-x-4 gap-y-8">
<Form.Field <Form.Field
control={form.control} control={form.control}
@@ -167,7 +396,9 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
render={({ field }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label optional>{t("fields.width")}</Form.Label> <Form.Label optional>
{t("products.fields.width.label")}
</Form.Label>
<Form.Control> <Form.Control>
<Input {...field} type="number" min={0} /> <Input {...field} type="number" min={0} />
</Form.Control> </Form.Control>
@@ -181,7 +412,9 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
render={({ field }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label optional>{t("fields.length")}</Form.Label> <Form.Label optional>
{t("products.fields.length.label")}
</Form.Label>
<Form.Control> <Form.Control>
<Input {...field} type="number" min={0} /> <Input {...field} type="number" min={0} />
</Form.Control> </Form.Control>
@@ -195,7 +428,9 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
render={({ field }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label optional>{t("fields.height")}</Form.Label> <Form.Label optional>
{t("products.fields.height.label")}
</Form.Label>
<Form.Control> <Form.Control>
<Input {...field} type="number" min={0} /> <Input {...field} type="number" min={0} />
</Form.Control> </Form.Control>
@@ -209,7 +444,9 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
render={({ field }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label optional>{t("fields.weight")}</Form.Label> <Form.Label optional>
{t("products.fields.weight.label")}
</Form.Label>
<Form.Control> <Form.Control>
<Input {...field} type="number" min={0} /> <Input {...field} type="number" min={0} />
</Form.Control> </Form.Control>
@@ -217,18 +454,45 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
) )
}} }}
/> />
</div>
{/* TODO: Add missing attribute fields */}
</div>
<div id="media" className="flex flex-col gap-y-8">
<Heading level="h2">{t("products.media.label")}</Heading>
<div className="grid grid-cols-1 gap-x-4 gap-y-8">
<Form.Field <Form.Field
control={form.control} control={form.control}
name="origin_country" name="images"
render={({ field }) => { render={() => {
return ( return (
<Form.Item> <Form.Item>
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-1">
<Form.Label optional> <Form.Label optional>
{t("fields.countryOfOrigin")} {t("products.media.label")}
</Form.Label> </Form.Label>
<Form.Hint>
{t("products.media.editHint")}
</Form.Hint>
</div>
<Form.Control> <Form.Control>
<CountrySelect {...field} /> <FileUpload
label={t("products.media.uploadImagesLabel")}
hint={t("products.media.uploadImagesHint")}
hasError={!!form.formState.errors.images}
formats={SUPPORTED_FORMATS}
onUploaded={() => {
form.clearErrors("images")
// if (hasInvalidFiles(files)) {
// return
// }
// files.forEach((f) => append(f))
}}
/>
</Form.Control> </Form.Control>
<Form.ErrorMessage />
</div>
</Form.Item> </Form.Item>
) )
}} }}
@@ -15,15 +15,19 @@ const CreateProductSchema = zod.object({
title: zod.string(), title: zod.string(),
subtitle: zod.string().optional(), subtitle: zod.string().optional(),
handle: zod.string().optional(), handle: zod.string().optional(),
material: zod.string().optional(),
description: zod.string().optional(), description: zod.string().optional(),
discountable: zod.boolean(), discountable: zod.boolean(),
type_id: zod.string().optional(),
collection_id: zod.string().optional(),
category_ids: zod.array(zod.string()).optional(),
tags: zod.array(zod.string()).optional(),
sales_channels: zod.array(zod.string()).optional(), sales_channels: zod.array(zod.string()).optional(),
origin_country: zod.string().optional(),
material: zod.string().optional(),
width: zod.string().optional(), width: zod.string().optional(),
length: zod.string().optional(), length: zod.string().optional(),
height: zod.string().optional(), height: zod.string().optional(),
weight: zod.string().optional(), weight: zod.string().optional(),
origin_country: zod.string().optional(),
mid_code: zod.string().optional(), mid_code: zod.string().optional(),
hs_code: zod.string().optional(), hs_code: zod.string().optional(),
variants: zod.array( variants: zod.array(
@@ -31,6 +35,8 @@ const CreateProductSchema = zod.object({
variant_rank: zod.number(), variant_rank: zod.number(),
}) })
), ),
images: zod.array(zod.string()).optional(),
thumbnail: zod.string().optional(),
}) })
type Schema = zod.infer<typeof CreateProductSchema> type Schema = zod.infer<typeof CreateProductSchema>
@@ -45,18 +51,12 @@ export const CreateProductForm = () => {
title: "", title: "",
subtitle: "", subtitle: "",
handle: "", handle: "",
material: "",
description: "", description: "",
discountable: true, discountable: true,
height: "", tags: [],
length: "",
weight: "",
width: "",
origin_country: "",
mid_code: "",
hs_code: "",
sales_channels: [], sales_channels: [],
variants: [], variants: [],
images: [],
}, },
resolver: zodResolver(CreateProductSchema), resolver: zodResolver(CreateProductSchema),
}) })
@@ -66,13 +66,15 @@ export const CreateProductForm = () => {
const handleSubmit = form.handleSubmit(async (values) => { const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync( await mutateAsync(
{ {
title: values.title, ...values,
discountable: values.discountable,
is_giftcard: false, is_giftcard: false,
tags: values.tags?.map((tag) => ({ value: tag })),
sales_channels: values.sales_channels?.map((sc) => ({ id: sc })),
width: values.width ? parseFloat(values.width) : undefined, width: values.width ? parseFloat(values.width) : undefined,
length: values.length ? parseFloat(values.length) : undefined, length: values.length ? parseFloat(values.length) : undefined,
height: values.height ? parseFloat(values.height) : undefined, height: values.height ? parseFloat(values.height) : undefined,
weight: values.weight ? parseFloat(values.weight) : undefined, weight: values.weight ? parseFloat(values.weight) : undefined,
variants: values.variants.map((v) => ({ title: "", prices: [] })),
}, },
{ {
onSuccess: ({ product }) => { onSuccess: ({ product }) => {
@@ -93,7 +95,7 @@ export const CreateProductForm = () => {
</Button> </Button>
</RouteFocusModal.Close> </RouteFocusModal.Close>
<Button type="submit" size="small" isLoading={isLoading}> <Button type="submit" size="small" isLoading={isLoading}>
{t("actions.save")} {t("actions.saveAsDraft")}
</Button> </Button>
</div> </div>
</RouteFocusModal.Header> </RouteFocusModal.Header>
@@ -140,7 +140,7 @@ export const EditProductForm = ({ product }: EditProductFormProps) => {
className="text-ui-fg-subtle" className="text-ui-fg-subtle"
> >
<Trans <Trans
i18nKey="products.titleHint" i18nKey="products.fields.title.hint"
t={t} t={t}
components={[<br key="break" />]} components={[<br key="break" />]}
/> />
@@ -204,7 +204,7 @@ export const EditProductForm = ({ product }: EditProductFormProps) => {
<Form.ErrorMessage /> <Form.ErrorMessage />
<Form.Hint> <Form.Hint>
<Trans <Trans
i18nKey="products.descriptionHint" i18nKey="products.fields.description.hint"
t={t} t={t}
components={[<br key="break" />]} components={[<br key="break" />]}
/> />
@@ -1,22 +1,10 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { import { CheckMini, Spinner, ThumbnailBadge } from "@medusajs/icons"
ArrowDownTray,
CheckMini,
Spinner,
ThumbnailBadge,
} from "@medusajs/icons"
import { Image, Product } from "@medusajs/medusa" import { Image, Product } from "@medusajs/medusa"
import { Button, CommandBar, Text, Tooltip, clx } from "@medusajs/ui" import { Button, CommandBar, Tooltip, clx } from "@medusajs/ui"
import { AnimatePresence, motion } from "framer-motion" import { AnimatePresence, motion } from "framer-motion"
import { useAdminUpdateProduct, useMedusa } from "medusa-react" import { useAdminUpdateProduct, useMedusa } from "medusa-react"
import { import { Fragment, useCallback, useState } from "react"
ChangeEvent,
DragEvent,
Fragment,
useCallback,
useRef,
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 { z } from "zod" import { z } from "zod"
@@ -27,6 +15,10 @@ import {
RouteFocusModal, RouteFocusModal,
useRouteModal, useRouteModal,
} from "../../../../../components/route-modal" } from "../../../../../components/route-modal"
import {
FileType,
FileUpload,
} from "../../../../../components/common/file-upload"
type ProductMediaViewProps = { type ProductMediaViewProps = {
product: Product product: Product
@@ -65,11 +57,6 @@ const EditProductMediaSchema = z.object({
export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => { export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
const [selection, setSelection] = useState<Record<string, true>>({}) const [selection, setSelection] = useState<Record<string, true>>({})
const [isDragOver, setIsDragOver] = useState<boolean>(false)
const dropZoneRef = useRef<HTMLButtonElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const { t } = useTranslation() const { t } = useTranslation()
const { handleSuccess } = useRouteModal() const { handleSuccess } = useRouteModal()
@@ -137,20 +124,16 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
) )
}) })
const handleOpenFileSelector = () => { const hasInvalidFiles = (fileList: FileType[]) => {
inputRef.current?.click()
}
const hasInvalidFiles = (fileList: File[]) => {
const invalidFile = fileList.find( const invalidFile = fileList.find(
(f) => !SUPPORTED_FORMATS.includes(f.type) (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.name, name: invalidFile.file.name,
types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "), types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "),
}), }),
}) })
@@ -161,94 +144,6 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
return false return false
} }
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
if (files) {
const fileList = Array.from(files)
if (hasInvalidFiles(fileList)) {
return
}
form.clearErrors("media")
fileList.forEach((file) => {
const preview = URL.createObjectURL(file)
append({
id: crypto.randomUUID(),
url: preview,
isThumbnail: false,
file,
})
})
}
}
const handleDragEnter = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
const files = event.dataTransfer.files
if (files) {
const fileList = Array.from(files)
console.log(fileList.map((f) => f.type))
if (hasInvalidFiles(fileList)) {
console.log("invalid files")
setIsDragOver(false)
return
}
setIsDragOver(true)
}
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
if (
!dropZoneRef.current ||
dropZoneRef.current.contains(event.relatedTarget as Node)
) {
return
}
setIsDragOver(false)
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
setIsDragOver(false)
form.clearErrors("media")
const files = event.dataTransfer.files
if (files) {
const fileList = Array.from(files)
if (hasInvalidFiles(fileList)) {
return
}
fileList.forEach((file) => {
const previewUrl = URL.createObjectURL(file)
append({
id: crypto.randomUUID(),
url: previewUrl,
isThumbnail: false,
file,
})
})
}
}
const handleCheckedChange = useCallback( const handleCheckedChange = useCallback(
(id: string) => { (id: string) => {
return (val: boolean) => { return (val: boolean) => {
@@ -353,49 +248,22 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
<Form.Hint>{t("products.media.editHint")}</Form.Hint> <Form.Hint>{t("products.media.editHint")}</Form.Hint>
</div> </div>
<Form.Control> <Form.Control>
<div> <FileUpload
<button label={t("products.media.uploadImagesLabel")}
ref={dropZoneRef} hint={t("products.media.uploadImagesHint")}
type="button" hasError={!!form.formState.errors.media}
onClick={handleOpenFileSelector} formats={SUPPORTED_FORMATS}
onDrop={handleDrop} onUploaded={(files) => {
onDragOver={(e) => e.preventDefault()} form.clearErrors("media")
onDragEnter={handleDragEnter} if (hasInvalidFiles(files)) {
onDragLeave={handleDragLeave} return
className={clx(
"bg-ui-bg-component border-ui-border-strong transition-fg group flex w-full flex-col items-center gap-y-2 rounded-lg border border-dashed p-8",
"hover:border-ui-border-interactive focus:border-ui-border-interactive",
"focus:shadow-borders-focus outline-none focus:border-solid",
{
"!border-ui-border-error":
!!form.formState.errors.media,
"!border-ui-border-interactive": isDragOver,
} }
)}
> files.forEach((f) =>
<div className="text-ui-fg-subtle group-disabled:text-ui-fg-disabled flex items-center gap-x-2"> append({ ...f, isThumbnail: false })
<ArrowDownTray /> )
<Text> }}
{t("products.media.uploadImagesLabel")}
</Text>
</div>
<Text
size="small"
leading="compact"
className="text-ui-fg-muted group-disabled:text-ui-fg-disabled"
>
{t("products.media.uploadImagesHint")}
</Text>
</button>
<input
hidden
ref={inputRef}
onChange={handleFileChange}
type="file"
accept={SUPPORTED_FORMATS.join(",")}
multiple
/> />
</div>
</Form.Control> </Form.Control>
<Form.ErrorMessage /> <Form.ErrorMessage />
</div> </div>