fix(dashboard): Fix spacing, media, and missing tip in product create form (#8338)

Resolves CC-146, CC-109
This commit is contained in:
Kasper Fabricius Kristensen
2024-07-31 07:06:42 +00:00
committed by GitHub
parent 9de1d8c9c3
commit 4fda46d9b1
7 changed files with 299 additions and 176 deletions
@@ -334,7 +334,8 @@
"productVariants": { "productVariants": {
"label": "Product variants", "label": "Product variants",
"hint": "This ranking will affect the variants' order in your storefront.", "hint": "This ranking will affect the variants' order in your storefront.",
"alert": "Add options to create variants." "alert": "Add options to create variants.",
"tip": "Variants left unchecked won't be created. You can always create and edit variants afterwards but this list fits the variations in your product options."
}, },
"productOptions": { "productOptions": {
"label": "Product options", "label": "Product options",
@@ -388,6 +389,7 @@
"media": { "media": {
"label": "Media", "label": "Media",
"editHint": "Add media to the product to showcase it in your storefront.", "editHint": "Add media to the product to showcase it in your storefront.",
"makeThumbnail": "Make thumbnail",
"uploadImagesLabel": "Upload images", "uploadImagesLabel": "Upload images",
"uploadImagesHint": "Drag and drop images here or click to upload.", "uploadImagesHint": "Drag and drop images here or click to upload.",
"invalidFileType": "'{{name}}' is not a supported file type. Supported file types are: {{types}}.", "invalidFileType": "'{{name}}' is not a supported file type. Supported file types are: {{types}}.",
@@ -1,16 +1,16 @@
import { UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Form } from "../../../../../components/common/form" import { z } from "zod"
import { import {
FileType, FileType,
FileUpload, FileUpload,
} from "../../../../../components/common/file-upload" } from "../../../../../components/common/file-upload"
import { UseFormReturn } from "react-hook-form" import { Form } from "../../../../../components/common/form"
import { MediaSchema } from "../../../product-create/constants"
import { import {
EditProductMediaSchemaType, EditProductMediaSchemaType,
ProductCreateSchemaType, ProductCreateSchemaType,
} from "../../../product-create/types" } from "../../../product-create/types"
import { MediaSchema } from "../../../product-create/constants"
import { z } from "zod"
type Media = z.infer<typeof MediaSchema> type Media = z.infer<typeof MediaSchema>
@@ -35,11 +35,13 @@ const SUPPORTED_FORMATS_FILE_EXTENSIONS = [
export const UploadMediaFormItem = ({ export const UploadMediaFormItem = ({
form, form,
append, append,
showHint = true,
}: { }: {
form: form:
| UseFormReturn<ProductCreateSchemaType> | UseFormReturn<ProductCreateSchemaType>
| UseFormReturn<EditProductMediaSchemaType> | UseFormReturn<EditProductMediaSchemaType>
append: (value: Media) => void append: (value: Media) => void
showHint?: boolean
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -72,10 +74,12 @@ export const UploadMediaFormItem = ({
render={() => { render={() => {
return ( return (
<Form.Item> <Form.Item>
<div className="flex flex-col gap-y-4"> <div className="flex flex-col gap-y-2">
<div className="flex flex-col gap-y-1"> <div className="flex flex-col gap-y-1">
<Form.Label optional>{t("products.media.label")}</Form.Label> <Form.Label optional>{t("products.media.label")}</Form.Label>
<Form.Hint>{t("products.media.editHint")}</Form.Hint> {showHint && (
<Form.Hint>{t("products.media.editHint")}</Form.Hint>
)}
</div> </div>
<Form.Control> <Form.Control>
<FileUpload <FileUpload
@@ -16,7 +16,7 @@ export const ProductCreateGeneralSection = ({
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div id="general" className="flex flex-col gap-y-8"> <div id="general" className="flex flex-col gap-y-6">
<div className="flex flex-col gap-y-2"> <div className="flex flex-col gap-y-2">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Form.Field <Form.Field
@@ -1,10 +1,11 @@
import { CommandBar, Heading } from "@medusajs/ui" import { 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 { UseFormReturn, useFieldArray } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ProductCreateSchemaType } from "../../../../types" import { ActionMenu } from "../../../../../../../components/common/action-menu"
import { MediaGrid } from "../../../../../common/components/media-grid-view"
import { useCallback, useState } from "react"
import { UploadMediaFormItem } from "../../../../../common/components/upload-media-form-item" import { UploadMediaFormItem } from "../../../../../common/components/upload-media-form-item"
import { ProductCreateSchemaType } from "../../../../types"
type ProductCreateMediaSectionProps = { type ProductCreateMediaSectionProps = {
form: UseFormReturn<ProductCreateSchemaType> form: UseFormReturn<ProductCreateSchemaType>
@@ -13,68 +14,172 @@ type ProductCreateMediaSectionProps = {
export const ProductCreateMediaSection = ({ export const ProductCreateMediaSection = ({
form, form,
}: ProductCreateMediaSectionProps) => { }: ProductCreateMediaSectionProps) => {
const { t } = useTranslation()
const [selection, setSelection] = useState<Record<string, true>>({})
const selectionCount = Object.keys(selection).length
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
name: "media", name: "media",
control: form.control, control: form.control,
keyName: "field_id", keyName: "field_id",
}) })
const handleDelete = () => { const getOnDelete = (index: number) => {
const ids = Object.keys(selection) return () => {
const indices = ids.map((id) => fields.findIndex((m) => m.id === id)) remove(index)
}
remove(indices)
setSelection({})
} }
const handleCheckedChange = useCallback( const getMakeThumbnail = (index: number) => {
(id: string) => { return () => {
return (val: boolean) => { const newFields = fields.map((field, i) => {
if (!val) { return {
const { [id]: _, ...rest } = selection ...field,
setSelection(rest) isThumbnail: i === index,
} else {
setSelection((prev) => ({ ...prev, [id]: true }))
} }
} })
},
[selection] form.setValue("media", newFields, {
) shouldDirty: true,
shouldTouch: true,
})
}
}
const getItemHandlers = (index: number) => {
return {
onDelete: getOnDelete(index),
onMakeThumbnail: getMakeThumbnail(index),
}
}
return ( return (
<div id="media" className="flex flex-col gap-y-8"> <div id="media" className="flex flex-col gap-y-2">
<Heading level="h2">{t("products.media.label")}</Heading> <UploadMediaFormItem form={form} append={append} showHint={false} />
<div className="grid grid-cols-1 gap-x-4 gap-y-8"> <ul className="flex flex-col gap-y-2">
<UploadMediaFormItem form={form} append={append} /> {fields.map((field, index) => {
</div> const { onDelete, onMakeThumbnail } = getItemHandlers(index)
{fields?.length ? (
<MediaGrid
media={fields}
selection={selection}
onCheckedChange={handleCheckedChange}
/>
) : null}
<CommandBar open={!!selectionCount}> return (
<CommandBar.Bar> <MediaItem
<CommandBar.Value> key={field.id}
{t("general.countSelected", { field={field}
count: selectionCount, onDelete={onDelete}
})} onMakeThumbnail={onMakeThumbnail}
</CommandBar.Value> />
<CommandBar.Seperator /> )
})}
<CommandBar.Command </ul>
action={handleDelete}
label={t("actions.delete")}
shortcut="d"
/>
</CommandBar.Bar>
</CommandBar>
</div> </div>
) )
} }
type MediaField = {
isThumbnail: boolean
url: string
id?: string | undefined
file?: File
field_id: string
}
type MediaItemProps = {
field: MediaField
onDelete: () => void
onMakeThumbnail: () => void
}
const MediaItem = ({ field, onDelete, onMakeThumbnail }: MediaItemProps) => {
const { t } = useTranslation()
if (!field.file) {
return 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-3">
<div className="bg-ui-bg-base h-10 w-[30px] overflow-hidden rounded-md">
<ThumbnailPreview file={field.file} />
</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)}
</Text>
</div>
</div>
</div>
<div className="flex items-center gap-x-1">
<ActionMenu
groups={[
{
actions: [
{
label: t("products.media.makeThumbnail"),
icon: <StackPerspective />,
onClick: onMakeThumbnail,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("actions.delete"),
onClick: onDelete,
},
],
},
]}
/>
<IconButton
type="button"
size="small"
variant="transparent"
onClick={onDelete}
>
<XMark />
</IconButton>
</div>
</li>
)
}
const ThumbnailPreview = ({ file }: { file?: File | null }) => {
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null)
useEffect(() => {
if (file) {
const objectUrl = URL.createObjectURL(file)
setThumbnailUrl(objectUrl)
return () => URL.revokeObjectURL(objectUrl)
}
}, [file])
if (!thumbnailUrl) {
return null
}
return (
<img
src={thumbnailUrl}
alt=""
className="size-full object-cover object-center"
/>
)
}
function formatFileSize(bytes: number, decimalPlaces: number = 2): string {
if (bytes === 0) {
return "0 Bytes"
}
const k = 1024
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return (
parseFloat((bytes / Math.pow(k, i)).toFixed(decimalPlaces)) + " " + sizes[i]
)
}
@@ -8,7 +8,6 @@ import {
IconButton, IconButton,
Input, Input,
Label, Label,
Switch,
Text, Text,
clx, clx,
} from "@medusajs/ui" } from "@medusajs/ui"
@@ -22,6 +21,7 @@ import {
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Form } from "../../../../../../../components/common/form" import { Form } from "../../../../../../../components/common/form"
import { InlineTip } from "../../../../../../../components/common/inline-tip"
import { SortableList } from "../../../../../../../components/common/sortable-list" import { SortableList } from "../../../../../../../components/common/sortable-list"
import { SwitchBox } from "../../../../../../../components/common/switch-box" import { SwitchBox } from "../../../../../../../components/common/switch-box"
import { ChipInput } from "../../../../../../../components/inputs/chip-input" import { ChipInput } from "../../../../../../../components/inputs/chip-input"
@@ -285,26 +285,28 @@ export const ProductCreateVariantsSection = ({
return ( return (
<div id="variants" className="flex flex-col gap-y-8"> <div id="variants" className="flex flex-col gap-y-8">
<Heading level="h2">{t("products.create.variants.header")}</Heading> <div className="flex flex-col gap-y-6">
<SwitchBox <Heading level="h2">{t("products.create.variants.header")}</Heading>
control={form.control} <SwitchBox
name="enable_variants" control={form.control}
label={t("products.create.variants.subHeadingTitle")} name="enable_variants"
description={t("products.create.variants.subHeadingDescription")} label={t("products.create.variants.subHeadingTitle")}
onCheckedChange={(checked) => { description={t("products.create.variants.subHeadingDescription")}
if (checked) { onCheckedChange={(checked) => {
form.setValue("options", [ if (checked) {
{ form.setValue("options", [
title: "", {
values: [], title: "",
}, values: [],
]) },
form.setValue("variants", []) ])
} else { form.setValue("variants", [])
createDefaultOptionAndVariant() } else {
} createDefaultOptionAndVariant()
}} }
/> }}
/>
</div>
{watchedAreVariantsEnabled && ( {watchedAreVariantsEnabled && (
<> <>
<div className="flex flex-col gap-y-6"> <div className="flex flex-col gap-y-6">
@@ -314,7 +316,7 @@ export const ProductCreateVariantsSection = ({
render={() => { render={() => {
return ( return (
<Form.Item> <Form.Item>
<div className="flex flex-col gap-y-4"> <div className="flex flex-col gap-y-6">
<div className="flex items-start justify-between gap-x-4"> <div className="flex items-start justify-between gap-x-4">
<div className="flex flex-col"> <div className="flex flex-col">
<Form.Label> <Form.Label>
@@ -426,94 +428,103 @@ export const ProductCreateVariantsSection = ({
}} }}
/> />
</div> </div>
<div className="grid grid-cols-1 gap-x-4 gap-y-4"> <div className="grid grid-cols-1 gap-x-4 gap-y-8">
<div className="flex flex-col gap-y-1"> <div className="flex flex-col gap-y-6">
<Label weight="plus"> <div className="flex flex-col">
{t("products.create.variants.productVariants.label")} <Label weight="plus">
</Label> {t("products.create.variants.productVariants.label")}
<Hint>{t("products.create.variants.productVariants.hint")}</Hint> </Label>
</div> <Hint>
{!showInvalidOptionsMessage && showInvalidVariantsMessage && ( {t("products.create.variants.productVariants.hint")}
<Alert dismissible variant="error"> </Hint>
{t("products.create.errors.variants")}
</Alert>
)}
{variants.fields.length > 0 ? (
<div className="overflow-hidden rounded-xl border">
<div
className="bg-ui-bg-component text-ui-fg-subtle grid items-center gap-3 border-b px-6 py-3.5"
style={{
gridTemplateColumns: `20px 28px repeat(${watchedOptions.length}, 1fr)`,
}}
>
<div>
<Checkbox
checked={getCheckboxState(watchedVariants)}
onCheckedChange={onCheckboxChange}
/>
</div>
<div />
{watchedOptions.map((option, index) => (
<div key={index}>
<Text size="small" leading="compact" weight="plus">
{option.title}
</Text>
</div>
))}
</div>
<SortableList
items={variants.fields}
onChange={handleRankChange}
renderItem={(item, index) => {
return (
<SortableList.Item
id={item.id}
className={clx("bg-ui-bg-base border-b", {
"border-b-0": index === variants.fields.length - 1,
})}
>
<div
className="text-ui-fg-subtle grid w-full items-center gap-3 px-6 py-3.5"
style={{
gridTemplateColumns: `20px 28px repeat(${watchedOptions.length}, 1fr)`,
}}
>
<Form.Field
control={form.control}
name={`variants.${index}.should_create` as const}
render={({
field: { value, onChange, ...field },
}) => {
return (
<Form.Item>
<Form.Control>
<Checkbox
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</Form.Item>
)
}}
/>
<SortableList.DragHandle />
{Object.values(item.options).map((value, index) => (
<Text key={index} size="small" leading="compact">
{value}
</Text>
))}
</div>
</SortableList.Item>
)
}}
/>
</div> </div>
) : ( {!showInvalidOptionsMessage && showInvalidVariantsMessage && (
<Alert> <Alert dismissible variant="error">
{t("products.create.variants.productVariants.alert")} {t("products.create.errors.variants")}
</Alert> </Alert>
)} )}
{variants.fields.length > 0 ? (
<div className="overflow-hidden rounded-xl border">
<div
className="bg-ui-bg-component text-ui-fg-subtle grid items-center gap-3 border-b px-6 py-2.5"
style={{
gridTemplateColumns: `20px 28px repeat(${watchedOptions.length}, 1fr)`,
}}
>
<div>
<Checkbox
checked={getCheckboxState(watchedVariants)}
onCheckedChange={onCheckboxChange}
/>
</div>
<div />
{watchedOptions.map((option, index) => (
<div key={index}>
<Text size="small" leading="compact" weight="plus">
{option.title}
</Text>
</div>
))}
</div>
<SortableList
items={variants.fields}
onChange={handleRankChange}
renderItem={(item, index) => {
return (
<SortableList.Item
id={item.id}
className={clx("bg-ui-bg-base border-b", {
"border-b-0": index === variants.fields.length - 1,
})}
>
<div
className="text-ui-fg-subtle grid w-full items-center gap-3 px-6 py-2.5"
style={{
gridTemplateColumns: `20px 28px repeat(${watchedOptions.length}, 1fr)`,
}}
>
<Form.Field
control={form.control}
name={`variants.${index}.should_create` as const}
render={({
field: { value, onChange, ...field },
}) => {
return (
<Form.Item>
<Form.Control>
<Checkbox
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</Form.Item>
)
}}
/>
<SortableList.DragHandle />
{Object.values(item.options).map((value, index) => (
<Text key={index} size="small" leading="compact">
{value}
</Text>
))}
</div>
</SortableList.Item>
)
}}
/>
</div>
) : (
<Alert>
{t("products.create.variants.productVariants.alert")}
</Alert>
)}
{variants.fields.length > 0 && (
<InlineTip variant="tip">
{t("products.create.variants.productVariants.tip")}
</InlineTip>
)}
</div>
</div> </div>
</> </>
)} )}
@@ -5,8 +5,8 @@ import { useTranslation } from "react-i18next"
import { Divider } from "../../../../../components/common/divider" import { Divider } from "../../../../../components/common/divider"
import { ProductCreateSchemaType } from "../../types" import { ProductCreateSchemaType } from "../../types"
import { ProductCreateGeneralSection } from "./components/product-create-details-general-section" 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" import { ProductCreateMediaSection } from "./components/product-create-details-media-section"
import { ProductCreateVariantsSection } from "./components/product-create-details-variant-section"
type ProductAttributesProps = { type ProductAttributesProps = {
form: UseFormReturn<ProductCreateSchemaType> form: UseFormReturn<ProductCreateSchemaType>
@@ -17,11 +17,12 @@ export const ProductCreateDetailsForm = ({ form }: ProductAttributesProps) => {
<div className="flex flex-col items-center p-16"> <div className="flex flex-col items-center p-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-8"> <div className="flex w-full max-w-[720px] flex-col gap-y-8">
<Header /> <Header />
<ProductCreateGeneralSection form={form} /> <div className="flex flex-col gap-y-6">
<ProductCreateGeneralSection form={form} />
<ProductCreateMediaSection form={form} />
</div>
<Divider /> <Divider />
<ProductCreateVariantsSection form={form} /> <ProductCreateVariantsSection form={form} />
<Divider />
<ProductCreateMediaSection form={form} />
</div> </div>
</div> </div>
) )
@@ -187,7 +187,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
<Fragment> <Fragment>
<CommandBar.Command <CommandBar.Command
action={handlePromoteToThumbnail} action={handlePromoteToThumbnail}
label={"Make thumbnail"} label={t("products.media.makeThumbnail")}
shortcut="t" shortcut="t"
/> />
<CommandBar.Seperator /> <CommandBar.Seperator />