feat(dashboard): Product create from - details (#7121)

**What**
- First part of the product creation form.
- New components:
  - ChipInput - Allows users to input chips into a input field. Chips are created by hitting the `,` or `Enter / Return` keys. Deleting a chip is done by hitting `Backspace` when the cursor is next to chip, or clicking the `X` button in the chip. Used for inputting option values.
  - SortableList - A sortable drag-n-drop list that allows the user to re-arrange the order of items. Used for re-arranging the ranking of variants.
  - ChipGroup - New re-usable component that is used to render a group of values as Chips. This should be used for SplitView form items.
  - CategoryCombobox - (WIP) Nested Combobox component for selecting multiple categories a product should be associated with.
- New hooks:
  - useComboboxData - Hook for easily managing the state of comboboxes.
  - useDebouncedSearch - Hook for managing debounced search queries.
This commit is contained in:
Kasper Fabricius Kristensen
2024-05-03 10:37:36 +00:00
committed by GitHub
parent e42308557e
commit fdee748eed
84 changed files with 2837 additions and 1588 deletions
@@ -0,0 +1,111 @@
import { XMarkMini } from "@medusajs/icons"
import { Button, clx } from "@medusajs/ui"
import { Children, PropsWithChildren, createContext, useContext } from "react"
import { useTranslation } from "react-i18next"
type ChipGroupVariant = "base" | "component"
type ChipGroupProps = PropsWithChildren<{
onClearAll?: () => void
onRemove?: (index: number) => void
variant?: ChipGroupVariant
className?: string
}>
type GroupContextValue = {
onRemove?: (index: number) => void
variant: ChipGroupVariant
}
const GroupContext = createContext<GroupContextValue | null>(null)
const useGroupContext = () => {
const context = useContext(GroupContext)
if (!context) {
throw new Error("useGroupContext must be used within a ChipGroup component")
}
return context
}
const Group = ({
onClearAll,
onRemove,
variant = "component",
className,
children,
}: ChipGroupProps) => {
const { t } = useTranslation()
const showClearAll = !!onClearAll && Children.count(children) > 0
return (
<GroupContext.Provider value={{ onRemove, variant }}>
<ul
role="application"
className={clx("flex flex-wrap items-center gap-2", className)}
>
{children}
{showClearAll && (
<li>
<Button
size="small"
variant="transparent"
type="button"
onClick={onClearAll}
className="text-ui-fg-muted active:text-ui-fg-subtle"
>
{t("actions.clearAll")}
</Button>
</li>
)}
</ul>
</GroupContext.Provider>
)
}
type ChipProps = PropsWithChildren<{
index: number
className?: string
}>
const Chip = ({ index, className, children }: ChipProps) => {
const { onRemove, variant } = useGroupContext()
return (
<li
className={clx(
"bg-ui-bg-component shadow-borders-base flex items-center divide-x overflow-hidden rounded-md",
{
"bg-ui-bg-component": variant === "component",
"bg-ui-bg-base-": variant === "base",
},
className
)}
>
<span className="txt-compact-small-plus flex items-center justify-center px-2 py-1">
{children}
</span>
{!!onRemove && (
<button
onClick={() => onRemove(index)}
type="button"
className={clx(
"text-ui-fg-muted active:text-ui-fg-subtle transition-fg flex items-center justify-center p-1",
{
"hover:bg-ui-bg-component-hover active:bg-ui-bg-component-pressed":
variant === "component",
"hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed":
variant === "base",
}
)}
>
<XMarkMini />
</button>
)}
</li>
)
}
export const ChipGroup = Object.assign(Group, { Chip })
@@ -0,0 +1 @@
export * from "./chip-group"
@@ -1 +0,0 @@
export * from "./keypair"
@@ -1,149 +0,0 @@
import { Plus, Trash } from "@medusajs/icons"
import { Button, Input, Table } from "@medusajs/ui"
import { useState } from "react"
interface KeyPair {
key: string
value: string
}
export interface KeypairProps {
labels: {
add: string
key?: string
value?: string
}
value: KeyPair[]
onChange: (value: KeyPair[]) => void
disabled?: boolean
}
export const Keypair = ({ labels, onChange, value }: KeypairProps) => {
const addKeyPair = () => {
onChange([...value, { key: ``, value: `` }])
}
const deleteKeyPair = (index: number) => {
return () => {
onChange(value.filter((_, i) => i !== index))
}
}
const onKeyChange = (index: number) => {
return (key: string) => {
const newArr = value.map((pair, i) => {
if (i === index) {
return { key, value: pair.value }
}
return pair
})
onChange(newArr)
}
}
const onValueChange = (index: number) => {
return (val: string) => {
const newArr = value.map((pair, i) => {
if (i === index) {
return { key: pair.key, value: val }
}
return pair
})
onChange(newArr)
}
}
return (
<div>
<Table className="w-full">
<Table.Header className="border-t-0">
<Table.Row>
<Table.HeaderCell>{labels.key}</Table.HeaderCell>
<Table.HeaderCell>{labels.value}</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{value.map((pair, index) => {
return (
<Field
labels={labels}
field={pair}
updateKey={onKeyChange(index)}
updateValue={onValueChange(index)}
onDelete={deleteKeyPair(index)}
/>
)
})}
</Table.Body>
</Table>
<Button
variant="secondary"
size="small"
type="button"
className="w-full mt-4"
onClick={addKeyPair}
>
<Plus />
{labels.add}
</Button>
</div>
)
}
type FieldProps = {
field: KeyPair
labels: {
key?: string
value?: string
}
updateKey: (key: string) => void
updateValue: (value: string) => void
onDelete: () => void
}
const Field: React.FC<FieldProps> = ({
field,
updateKey,
updateValue,
onDelete,
}) => {
const [key, setKey] = useState(field.key)
const [value, setValue] = useState(field.value)
return (
<Table.Row>
<Table.Cell className="!p-0 h-0">
<Input
className="rounded-none bg-transparent"
onBlur={() => updateKey(key)}
value={key}
onChange={(e) => {
setKey(e.currentTarget.value)
}}
/>
</Table.Cell>
<Table.Cell className="!p-0 h-0">
<Input
className="rounded-none bg-transparent"
onBlur={() => updateValue(value)}
value={value}
onChange={(e) => {
setValue(e.currentTarget.value)
}}
/>
</Table.Cell>
<Table.Cell className="!p-0 h-0 border-r">
<Button
variant="transparent"
size="small"
type="button"
onClick={onDelete}
>
<Trash />
</Button>
</Table.Cell>
</Table.Row>
)
}
@@ -1 +0,0 @@
export * from "./list"
@@ -1,54 +0,0 @@
import { Checkbox, Text } from "@medusajs/ui"
export interface ListProps<T> {
options: { title: string; value: T }[]
value?: T[]
onChange?: (value: T[]) => void
compare?: (a: T, b: T) => boolean
disabled?: boolean
}
export const List = <T extends any>({
options,
onChange,
value,
compare,
disabled,
}: ListProps<T>) => {
if (options.length === 0) {
return null
}
return (
<div className="flex-row justify-center border divide-y rounded-lg">
{options.map((option) => {
return (
<div className="flex p-4 gap-x-4">
{onChange && value !== undefined && (
<Checkbox
disabled={disabled}
checked={value.some(
(v) => compare?.(v, option.value) ?? v === option.value
)}
onCheckedChange={(checked) => {
if (checked) {
onChange([...value, option.value])
} else {
onChange(
value.filter(
(v) =>
!(compare?.(v, option.value) ?? v === option.value)
)
)
}
}}
/>
)}
<Text key={option.title}>{option.title}</Text>
</div>
)
})}
</div>
)
}
@@ -1 +0,0 @@
export * from "./product-table-cells"
@@ -1,130 +0,0 @@
import { SalesChannel } from "@medusajs/medusa"
import {
ProductCollectionDTO,
ProductDTO,
ProductVariantDTO,
} from "@medusajs/types"
import { StatusBadge, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Thumbnail } from "../thumbnail"
export const ProductVariantCell = ({
variants,
}: {
variants: ProductVariantDTO[] | null
}) => {
const { t } = useTranslation()
if (!variants || !variants.length) {
return (
<Text size="small" className="text-ui-fg-subtle">
-
</Text>
)
}
return (
<Text size="small" className="text-ui-fg-base">
{t("products.variantCount", {
count: variants.length,
})}
</Text>
)
}
export const ProductStatusCell = ({
status,
}: {
status: ProductDTO["status"]
}) => {
const { t } = useTranslation()
const color = {
draft: "grey",
published: "green",
rejected: "red",
proposed: "blue",
}[status] as "grey" | "green" | "red" | "blue"
return (
<StatusBadge color={color}>
{t(`products.productStatus.${status}`)}
</StatusBadge>
)
}
export const ProductAvailabilityCell = ({
salesChannels,
}: {
salesChannels: SalesChannel[] | null
}) => {
const { t } = useTranslation()
if (!salesChannels || salesChannels.length === 0) {
return (
<Text size="small" className="text-ui-fg-subtle">
-
</Text>
)
}
if (salesChannels.length < 3) {
return (
<Text size="small" className="text-ui-fg-base">
{salesChannels.map((sc) => sc.name).join(", ")}
</Text>
)
}
return (
<div className="flex items-center gap-x-2">
<Text size="small" className="text-ui-fg-base">
<span>
{salesChannels
.slice(0, 2)
.map((sc) => sc.name)
.join(", ")}
</span>{" "}
<span>
{t("general.plusCountMore", {
count: salesChannels.length - 2,
})}
</span>
</Text>
</div>
)
}
export const ProductTitleCell = ({ product }: { product: ProductDTO }) => {
const thumbnail = product.thumbnail
const title = product.title
return (
<div className="flex items-center gap-x-3">
<Thumbnail src={thumbnail} alt={`Thumbnail image of ${title}`} />
<Text size="small" className="text-ui-fg-base">
{title}
</Text>
</div>
)
}
export const ProductCollectionCell = ({
collection,
}: {
collection: ProductCollectionDTO | null
}) => {
if (!collection) {
return (
<Text size="small" className="text-ui-fg-subtle">
-
</Text>
)
}
return (
<Text size="small" className="text-ui-fg-base">
{collection.title}
</Text>
)
}
@@ -0,0 +1 @@
export * from "./sortable-list"
@@ -0,0 +1,228 @@
import {
Active,
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
DraggableSyntheticListeners,
KeyboardSensor,
PointerSensor,
defaultDropAnimationSideEffects,
useSensor,
useSensors,
type DropAnimation,
type UniqueIdentifier,
} from "@dnd-kit/core"
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { DotsSix } from "@medusajs/icons"
import { IconButton, clx } from "@medusajs/ui"
import {
CSSProperties,
Fragment,
PropsWithChildren,
ReactNode,
createContext,
useContext,
useMemo,
useState,
} from "react"
type SortableBaseItem = {
id: UniqueIdentifier
}
interface SortableListProps<TItem extends SortableBaseItem> {
items: TItem[]
onChange: (items: TItem[]) => void
renderItem: (item: TItem, index: number) => ReactNode
}
const List = <TItem extends SortableBaseItem>({
items,
onChange,
renderItem,
}: SortableListProps<TItem>) => {
const [active, setActive] = useState<Active | null>(null)
const [activeItem, activeIndex] = useMemo(() => {
if (active === null) {
return [null, null]
}
const index = items.findIndex(({ id }) => id === active.id)
return [items[index], index]
}, [active, items])
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleDragStart = ({ active }: DragStartEvent) => {
setActive(active)
}
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (over && active.id !== over.id) {
const activeIndex = items.findIndex(({ id }) => id === active.id)
const overIndex = items.findIndex(({ id }) => id === over.id)
onChange(arrayMove(items, activeIndex, overIndex))
}
setActive(null)
}
const handleDragCancel = () => {
setActive(null)
}
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<Overlay>
{activeItem && activeIndex !== null
? renderItem(activeItem, activeIndex)
: null}
</Overlay>
<SortableContext items={items}>
<ul
role="application"
className="flex list-inside list-none list-image-none flex-col p-0"
>
{items.map((item, index) => (
<Fragment key={item.id}>{renderItem(item, index)}</Fragment>
))}
</ul>
</SortableContext>
</DndContext>
)
}
const dropAnimationConfig: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.4",
},
},
}),
}
type SortableOverlayProps = PropsWithChildren
const Overlay = ({ children }: SortableOverlayProps) => {
return (
<DragOverlay
className="shadow-elevation-card-hover overflow-hidden rounded-md [&>li]:border-b-0"
dropAnimation={dropAnimationConfig}
>
{children}
</DragOverlay>
)
}
type SortableItemProps<TItem extends SortableBaseItem> = PropsWithChildren<{
id: TItem["id"]
className?: string
}>
type SortableItemContextValue = {
attributes: Record<string, any>
listeners: DraggableSyntheticListeners
ref: (node: HTMLElement | null) => void
isDragging: boolean
}
const SortableItemContext = createContext<SortableItemContextValue | null>(null)
const useSortableItemContext = () => {
const context = useContext(SortableItemContext)
if (!context) {
throw new Error(
"useSortableItemContext must be used within a SortableItemContext"
)
}
return context
}
const Item = <TItem extends SortableBaseItem>({
id,
className,
children,
}: SortableItemProps<TItem>) => {
const {
attributes,
isDragging,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
} = useSortable({ id })
const context = useMemo(
() => ({
attributes,
listeners,
ref: setActivatorNodeRef,
isDragging,
}),
[attributes, listeners, setActivatorNodeRef, isDragging]
)
const style: CSSProperties = {
opacity: isDragging ? 0.4 : undefined,
transform: CSS.Translate.toString(transform),
transition,
}
return (
<SortableItemContext.Provider value={context}>
<li
className={clx("transition-fg flex flex-1 list-none", className)}
ref={setNodeRef}
style={style}
>
{children}
</li>
</SortableItemContext.Provider>
)
}
const DragHandle = () => {
const { attributes, listeners, ref } = useSortableItemContext()
return (
<IconButton
variant="transparent"
size="small"
{...attributes}
{...listeners}
ref={ref}
className="cursor-grab touch-none active:cursor-grabbing"
>
<DotsSix className="text-ui-fg-muted" />
</IconButton>
)
}
export const SortableList = Object.assign(List, {
Item,
DragHandle,
})
@@ -5,8 +5,8 @@ import { z } from "zod"
import { Control } from "react-hook-form"
import { AddressSchema } from "../../../lib/schemas"
import { CountrySelect } from "../../common/country-select"
import { Form } from "../../common/form"
import { CountrySelect } from "../../inputs/country-select"
type AddressFieldValues = z.infer<typeof AddressSchema>
@@ -16,9 +16,9 @@ import {
getOrderPaymentStatus,
} from "../../../lib/order-helpers"
import { TransferOwnershipSchema } from "../../../lib/schemas"
import { Combobox } from "../../common/combobox"
import { Form } from "../../common/form"
import { Skeleton } from "../../common/skeleton"
import { Combobox } from "../../inputs/combobox"
type TransferOwnerShipFieldValues = z.infer<typeof TransferOwnershipSchema>
@@ -0,0 +1,187 @@
import { XMarkMini } from "@medusajs/icons"
import { Badge, clx } from "@medusajs/ui"
import { AnimatePresence, motion } from "framer-motion"
import {
FocusEvent,
KeyboardEvent,
forwardRef,
useImperativeHandle,
useRef,
useState,
} from "react"
type ChipInputProps = {
value?: string[]
onChange?: (value: string[]) => void
onBlur?: () => void
name?: string
disabled?: boolean
allowDuplicates?: boolean
showRemove?: boolean
variant?: "base" | "contrast"
className?: string
}
export const ChipInput = forwardRef<HTMLInputElement, ChipInputProps>(
(
{
value,
onChange,
onBlur,
disabled,
name,
showRemove = true,
variant = "base",
allowDuplicates = false,
className,
},
ref
) => {
const innerRef = useRef<HTMLInputElement>(null)
const isControlledRef = useRef(typeof value !== "undefined")
const isControlled = isControlledRef.current
const [uncontrolledValue, setUncontrolledValue] = useState<string[]>([])
useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(
ref,
() => innerRef.current
)
const [duplicateIndex, setDuplicateIndex] = useState<number | null>(null)
const chips = isControlled ? (value as string[]) : uncontrolledValue
const handleAddChip = (chip: string) => {
const cleanValue = chip.trim()
if (!cleanValue) {
return
}
if (!allowDuplicates && chips.includes(cleanValue)) {
setDuplicateIndex(chips.indexOf(cleanValue))
setTimeout(() => {
setDuplicateIndex(null)
}, 300)
return
}
onChange?.([...chips, cleanValue])
if (!isControlled) {
setUncontrolledValue([...chips, cleanValue])
}
}
const handleRemoveChip = (chip: string) => {
onChange?.(chips.filter((v) => v !== chip))
if (!isControlled) {
setUncontrolledValue(chips.filter((v) => v !== chip))
}
}
const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
onBlur?.()
if (e.target.value) {
handleAddChip(e.target.value)
e.target.value = ""
}
}
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault()
if (!innerRef.current?.value) {
return
}
handleAddChip(innerRef.current?.value ?? "")
innerRef.current.value = ""
innerRef.current?.focus()
}
if (e.key === "Backspace" && innerRef.current?.value === "") {
handleRemoveChip(chips[chips.length - 1])
}
}
// create a shake animation using framer motion
const shake = {
x: [0, -2, 2, -2, 2, 0],
transition: { duration: 0.3 },
}
return (
<div
className={clx(
"shadow-borders-base flex min-h-8 flex-wrap items-center gap-1 rounded-md px-2 py-1.5",
"transition-fg focus-within:shadow-borders-interactive-with-active",
"has-[input:disabled]:bg-ui-bg-disabled has-[input:disabled]:text-ui-fg-disabled has-[input:disabled]:cursor-not-allowed",
{
"bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover":
variant === "contrast",
"bg-ui-bg-field hover:bg-ui-bg-field-hover": variant === "base",
},
className
)}
tabIndex={-1}
onClick={() => innerRef.current?.focus()}
>
{chips.map((v, index) => {
return (
<AnimatePresence key={`${v}-${index}`}>
<Badge
size="2xsmall"
className={clx("gap-x-0.5 pl-1.5 pr-1.5", {
"transition-fg pr-1": showRemove,
"shadow-borders-focus": index === duplicateIndex,
})}
asChild
>
<motion.div
animate={index === duplicateIndex ? shake : undefined}
>
{v}
{showRemove && (
<button
tabIndex={-1}
type="button"
onClick={() => handleRemoveChip(v)}
className={clx(
"text-ui-fg-subtle transition-fg outline-none"
)}
>
<XMarkMini />
</button>
)}
</motion.div>
</Badge>
</AnimatePresence>
)
})}
<input
className={clx(
"caret-ui-fg-base text-ui-fg-base txt-compact-small flex-1 appearance-none bg-transparent",
"disabled:text-ui-fg-disabled disabled:cursor-not-allowed",
"focus:outline-none",
"placeholder:text-ui-fg-muted"
)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
disabled={disabled}
name={name}
ref={innerRef}
/>
</div>
)
}
)
ChipInput.displayName = "ChipInput"
@@ -0,0 +1 @@
export * from "./chip-input"
@@ -30,7 +30,7 @@ import {
} from "react"
import { useTranslation } from "react-i18next"
import { genericForwardRef } from "../generic-forward-ref"
import { genericForwardRef } from "../../common/generic-forward-ref"
type ComboboxOption = {
value: string
@@ -183,6 +183,8 @@ export const DataTableRoot = <TData,>({
const to = navigateTo ? navigateTo(row) : undefined
const isRowDisabled = hasSelect && !row.getCanSelect()
const isOdd = row.depth % 2 !== 0
return (
<Table.Row
key={row.id}
@@ -190,6 +192,7 @@ export const DataTableRoot = <TData,>({
className={clx(
"transition-fg group/row [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
{
"bg-ui-bg-subtle hover:bg-ui-bg-subtle-hover": isOdd,
"cursor-pointer": !!to,
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
@@ -228,6 +231,8 @@ export const DataTableRoot = <TData,>({
className={clx({
"bg-ui-bg-base group-data-[selected=true]/row:bg-ui-bg-highlight group-data-[selected=true]/row:group-hover/row:bg-ui-bg-highlight-hover group-hover/row:bg-ui-bg-base-hover transition-fg sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
isStickyCell,
"bg-ui-bg-subtle group-hover/row:bg-ui-bg-subtle-hover":
isOdd && isStickyCell,
"left-[68px]":
isStickyCell && hasSelect && !isSelectCell,
"after:bg-ui-border-base":