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 12:37:36 +02:00
committed by GitHub
parent e42308557e
commit fdee748eed
84 changed files with 2837 additions and 1588 deletions
+6 -4
View File
@@ -18,6 +18,8 @@
],
"dependencies": {
"@ariakit/react": "^0.4.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@hookform/resolvers": "3.3.2",
"@medusajs/icons": "workspace:^",
"@medusajs/ui": "workspace:^",
@@ -36,10 +38,10 @@
"match-sorter": "^6.3.4",
"medusa-react": "workspace:^",
"qs": "^6.12.0",
"react": "18.2.0",
"react": "^18.2.0",
"react-country-flag": "^3.1.0",
"react-currency-input-field": "^3.6.11",
"react-dom": "18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "7.49.1",
"react-i18next": "13.5.0",
"react-jwt": "^1.2.0",
@@ -53,8 +55,8 @@
"@medusajs/ui-preset": "workspace:^",
"@medusajs/vite-plugin-extension": "workspace:^",
"@types/node": "^20.11.15",
"@types/react": "18.2.43",
"@types/react-dom": "18.2.17",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@vitejs/plugin-react": "4.2.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
@@ -41,14 +41,13 @@
"typeToConfirm": "Please type {val} to confirm:",
"noResultsTitle": "No results",
"noResultsMessage": "Try changing the filters or search query",
"noSearchResults": "No search results",
"noSearchResultsFor": "No search results for <0>'{{query}}'</0>",
"noRecordsTitle": "No records",
"noRecordsMessage": "There are no records to show",
"unsavedChangesTitle": "Are you sure you want to leave this page?",
"unsavedChangesDescription": "You have unsaved changes that will be lost if you leave this page.",
"includesTaxTooltip": "Enter the total amount including tax. The net amount excluding tax will be automatically calculated and saved.",
"timeline": "Timeline",
"success": "Success",
"error": "Error"
"includesTaxTooltip": "Enter the total amount including tax. The net amount excluding tax will be automatically calculated and saved."
},
"validation": {
"mustBeInt": "The value must be a whole number.",
@@ -162,8 +161,27 @@
},
"products": {
"domain": "Products",
"createProductTitle": "Create Product",
"createProductHint": "Create a new product to sell in your store.",
"create": {
"header": "Create Product",
"hint": "Create a new product to sell in your store.",
"tabs": {
"details": "Details",
"variants": "Variants"
},
"variants": {
"header": "Variants",
"productVariants": {
"label": "Product variants",
"hint": "Variants left unchecked won't be created. This ranking will affect how the variants are ranked in your frontend.",
"alert": "Add options to create variants."
},
"productOptions": {
"label": "Product options",
"hint": "Define the options for the product, e.g. color, size, etc."
}
},
"successToast": "Product {{title}} was successfully created."
},
"deleteWarning": "You are about to delete the product {{title}}. This action cannot be undone.",
"variants": "Variants",
"attributes": "Attributes",
@@ -296,10 +314,12 @@
"options": {
"header": "Options",
"edit": {
"header": "Edit Option"
"header": "Edit Option",
"successToast": "Option {{title}} was successfully updated."
},
"create": {
"header": "Create Option"
"header": "Create Option",
"successToast": "Option {{title}} was successfully created."
}
},
"toasts": {
@@ -349,11 +369,11 @@
"associatedVariants": "Associated variants",
"manageLocations": "Manage locations",
"deleteWarning": "You are about to delete an inventory item. This action cannot be undone.",
"reservation": {
"reservation": {
"header": "Reservation of {{itemName}}",
"editItemDetails": "Edit item details",
"orderID": "Order ID",
"description": "Description",
"description": "Description",
"location": "Location",
"inStockAtLocation": "In stock at this location",
"availableAtLocation": "Available at this location",
@@ -1579,7 +1599,8 @@
"unitPrice": "Unit price",
"startDate": "Start date",
"endDate": "End date",
"draft": "Draft"
"draft": "Draft",
"values": "Values"
},
"metadata": {
"warnings": {
@@ -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":
@@ -0,0 +1,114 @@
import { TriangleRightMini } from "@medusajs/icons"
import { AdminProductCategoryResponse } from "@medusajs/types"
import { IconButton, Text, clx } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { StatusCell } from "../../../components/table/table-cells/common/status-cell"
import {
TextCell,
TextHeader,
} from "../../../components/table/table-cells/common/text-cell"
import {
getCategoryPath,
getIsActiveProps,
getIsInternalProps,
} from "../../../v2-routes/categories/common/utils"
const columnHelper =
createColumnHelper<AdminProductCategoryResponse["product_category"]>()
export const useCategoryTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("name", {
header: () => <TextHeader text={t("fields.name")} />,
cell: ({ getValue, row }) => {
const expandHandler = row.getToggleExpandedHandler()
if (row.original.parent_category !== undefined) {
const path = getCategoryPath(row.original)
return (
<div className="flex size-full items-center gap-1 overflow-hidden">
{path.map((chip, index) => (
<div
key={chip.id}
className={clx("overflow-hidden", {
"text-ui-fg-muted flex items-center gap-x-1":
index !== path.length - 1,
})}
>
<Text size="small" leading="compact" className="truncate">
{chip.name}
</Text>
{index !== path.length - 1 && (
<Text size="small" leading="compact">
/
</Text>
)}
</div>
))}
</div>
)
}
return (
<div className="flex size-full items-center gap-x-3 overflow-hidden">
<div className="flex size-7 items-center justify-center">
{row.getCanExpand() ? (
<IconButton
type="button"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
expandHandler()
}}
size="small"
variant="transparent"
className="text-ui-fg-subtle"
>
<TriangleRightMini
className={clx({
"rotate-90 transition-transform will-change-transform":
row.getIsExpanded(),
})}
/>
</IconButton>
) : null}
</div>
<span className="truncate">{getValue()}</span>
</div>
)
},
}),
columnHelper.accessor("handle", {
header: () => <TextHeader text={t("fields.handle")} />,
cell: ({ getValue }) => {
return <TextCell text={`/${getValue()}`} />
},
}),
columnHelper.accessor("is_active", {
header: () => <TextHeader text={t("fields.status")} />,
cell: ({ getValue }) => {
const { color, label } = getIsActiveProps(getValue(), t)
return <StatusCell color={color}>{label}</StatusCell>
},
}),
columnHelper.accessor("is_internal", {
header: () => <TextHeader text={t("categories.fields.visibility")} />,
cell: ({ getValue }) => {
const { color, label } = getIsInternalProps(getValue(), t)
return <StatusCell color={color}>{label}</StatusCell>
},
}),
],
[t]
)
}
@@ -30,7 +30,6 @@ export const useProductTableFilters = (
{
limit: 1000,
fields: "id,name",
expand: "",
},
{
enabled: !isSalesChannelExcluded,
@@ -1,71 +1,91 @@
import { QueryKey, useInfiniteQuery } from "@tanstack/react-query"
import debounce from "lodash/debounce"
import { useCallback, useEffect, useState } from "react"
import {
QueryKey,
keepPreviousData,
useInfiniteQuery,
useQuery,
} from "@tanstack/react-query"
import { useDebouncedSearch } from "./use-debounced-search"
type Params = {
q: string
limit: number
type ComboboxExternalData = {
offset: number
}
type Page = {
limit: number
count: number
offset: number
limit: number
}
type UseComboboxDataProps<TParams extends Params, TRes extends Page> = {
fetcher: (params: TParams) => Promise<TRes>
params?: Omit<TParams, "q" | "limit" | "offset">
queryKey: QueryKey
type ComboboxQueryParams = {
q?: string
offset?: number
limit?: number
}
/**
* Hook for fetching infinite data for a combobox.
*/
export const useComboboxData = <TParams extends Params, TRes extends Page>({
fetcher,
params,
export const useComboboxData = <
TResponse extends ComboboxExternalData,
TParams extends ComboboxQueryParams
>({
queryKey,
}: UseComboboxDataProps<TParams, TRes>) => {
const [query, setQuery] = useState("")
const [debouncedQuery, setDebouncedQuery] = useState("")
queryFn,
getOptions,
defaultValue,
defaultValueKey,
pageSize = 10,
}: {
queryKey: QueryKey
queryFn: (params: TParams) => Promise<TResponse>
getOptions: (data: TResponse) => { label: string; value: string }[]
defaultValueKey?: keyof TParams
defaultValue?: string | string[]
pageSize?: number
}) => {
const { searchValue, onSearchValueChange, query } = useDebouncedSearch()
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdate = useCallback(
debounce((query) => setDebouncedQuery(query), 300),
[]
)
useEffect(() => {
debouncedUpdate(query)
return () => debouncedUpdate.cancel()
}, [query, debouncedUpdate])
const data = useInfiniteQuery(
[...queryKey, debouncedQuery],
async ({ pageParam = 0 }) => {
const res = await fetcher({
q: debouncedQuery,
limit: 10,
offset: pageParam,
...params,
const queryIntialDataBy = defaultValueKey || "id"
const { data: initialData } = useQuery({
queryKey: queryKey,
queryFn: async () => {
return queryFn({
[queryIntialDataBy]: defaultValue,
limit: Array.isArray(defaultValue) ? defaultValue.length : 1,
} as TParams)
return res
},
{
getNextPageParam: (lastPage) => {
const morePages = lastPage.count > lastPage.offset + lastPage.limit
return morePages ? lastPage.offset + lastPage.limit : undefined
},
keepPreviousData: true,
}
)
enabled: !!defaultValue,
})
const { data, ...rest } = useInfiniteQuery({
queryKey: [...queryKey, query],
queryFn: async ({ pageParam = 0 }) => {
return queryFn({
q: query,
limit: pageSize,
offset: pageParam,
} as TParams)
},
initialPageParam: 0,
getNextPageParam: (lastPage) => {
const moreItemsExist = lastPage.count > lastPage.offset + lastPage.limit
return moreItemsExist ? lastPage.offset + lastPage.limit : undefined
},
placeholderData: keepPreviousData,
})
const options = data?.pages.flatMap((page) => getOptions(page)) ?? []
const defaultOptions = initialData ? getOptions(initialData) : []
/**
* If there are no options and the query is empty, then the combobox should be disabled,
* as there is no data to search for.
*/
const disabled = !rest.isPending && !options.length && !searchValue
// // make sure that the default value is included in the option, if its not in options already
if (defaultValue && !options.find((o) => o.value === defaultValue)) {
options.unshift(defaultOptions[0])
}
return {
...data,
query,
setQuery,
options,
searchValue,
onSearchValueChange,
disabled,
...rest,
}
}
@@ -0,0 +1,29 @@
import debounce from "lodash/debounce"
import { useCallback, useEffect, useState } from "react"
/**
* Hook for debouncing search input
* @returns searchValue, onSearchValueChange, query
*/
export const useDebouncedSearch = () => {
const [searchValue, onSearchValueChange] = useState("")
const [debouncedQuery, setDebouncedQuery] = useState("")
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdate = useCallback(
debounce((query: string) => setDebouncedQuery(query), 300),
[]
)
useEffect(() => {
debouncedUpdate(searchValue)
return () => debouncedUpdate.cancel()
}, [searchValue, debouncedUpdate])
return {
searchValue,
onSearchValueChange,
query: debouncedQuery || undefined,
}
}
@@ -51,6 +51,7 @@ async function makeRequest<
if (!response.ok) {
const errorData = await response.json()
// Temp: Add a better error type
throw new Error(`API error ${response.status}: ${errorData.message}`)
}
@@ -15,9 +15,9 @@ import { useEffect, useMemo } from "react"
import { Trans, useTranslation } from "react-i18next"
import { useWatch } from "react-hook-form"
import { Combobox } from "../../../../../components/common/combobox"
import { Form } from "../../../../../components/common/form"
import { PercentageInput } from "../../../../../components/common/percentage-input"
import { Combobox } from "../../../../../components/inputs/combobox"
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
import { getCurrencySymbol } from "../../../../../lib/currencies"
import { CreateDiscountFormReturn } from "./create-discount-form"
import { DiscountRuleType } from "./types"
@@ -13,9 +13,9 @@ import { useForm } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next"
import * as zod from "zod"
import { Combobox } from "../../../../../components/common/combobox"
import { Form } from "../../../../../components/common/form"
import { PercentageInput } from "../../../../../components/common/percentage-input"
import { Combobox } from "../../../../../components/inputs/combobox"
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
import {
RouteDrawer,
useRouteModal,
@@ -7,8 +7,8 @@ import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { json } from "react-router-dom"
import { Combobox } from "../../../../../../components/common/combobox"
import { Form } from "../../../../../../components/common/form"
import { Combobox } from "../../../../../../components/inputs/combobox"
import { useCreateDraftOrder } from "../hooks"
export const CreateDraftOrderCustomerDetails = () => {
@@ -7,9 +7,9 @@ import { useTranslation } from "react-i18next"
import { ShippingOption } from "@medusajs/medusa"
import { CurrencyInput, Heading, Input } from "@medusajs/ui"
import { json } from "react-router-dom"
import { Combobox } from "../../../../../../components/common/combobox"
import { ConditionalTooltip } from "../../../../../../components/common/conditional-tooltip"
import { Form } from "../../../../../../components/common/form"
import { Combobox } from "../../../../../../components/inputs/combobox"
import { getLocaleAmount } from "../../../../../../lib/money-amount-helpers"
import { useCreateDraftOrder } from "../hooks"
@@ -1,18 +1,18 @@
import { PricedVariant } from "@medusajs/client-types"
import { useTranslation } from "react-i18next"
import { OnChangeFn, RowSelectionState } from "@tanstack/react-table"
import { useState } from "react"
import { useAdminVariants } from "medusa-react"
import { Button } from "@medusajs/ui"
import { Order } from "@medusajs/medusa"
import { Button } from "@medusajs/ui"
import { OnChangeFn, RowSelectionState } from "@tanstack/react-table"
import { useAdminVariants } from "medusa-react"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { SplitView } from "../../../../../components/layout/split-view"
import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table.tsx"
import { SplitView } from "../../../../../components/layout/split-view"
import { useVariantTableQuery } from "./use-variant-table-query"
import { useVariantTableColumns } from "./use-variant-table-columns"
import { useVariantTableFilters } from "./use-variant-table-filters"
import { useVariantTableQuery } from "./use-variant-table-query"
const PAGE_SIZE = 50
@@ -1,25 +1,17 @@
import { PencilSquare, TriangleRightMini } from "@medusajs/icons"
import { PencilSquare } from "@medusajs/icons"
import { AdminProductCategoryResponse } from "@medusajs/types"
import { Container, Heading, IconButton, Text, clx } from "@medusajs/ui"
import { Container, Heading } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table"
import { StatusCell } from "../../../../../components/table/table-cells/common/status-cell"
import {
TextCell,
TextHeader,
} from "../../../../../components/table/table-cells/common/text-cell"
import { useCategories } from "../../../../../hooks/api/categories"
import { useCategoryTableColumns } from "../../../../../hooks/table/columns/use-category-table-columns"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useCategoryTableQuery } from "../../../common/hooks/use-category-table-query"
import {
getCategoryPath,
getIsActiveProps,
getIsInternalProps,
} from "../../../common/utils"
const PAGE_SIZE = 20
@@ -51,7 +43,7 @@ export const CategoryListTable = () => {
}
)
const columns = useCategoryTableColumns()
const columns = useColumns()
const { table } = useDataTable({
data: product_categories || [],
@@ -114,83 +106,12 @@ const CategoryRowActions = ({
const columnHelper =
createColumnHelper<AdminProductCategoryResponse["product_category"]>()
const useCategoryTableColumns = () => {
const { t } = useTranslation()
const useColumns = () => {
const base = useCategoryTableColumns()
return useMemo(
() => [
columnHelper.accessor("name", {
header: () => <TextHeader text={t("fields.name")} />,
cell: ({ getValue, row }) => {
const expandHandler = row.getToggleExpandedHandler()
console.log(row.original)
if (row.original.parent_category !== undefined) {
const path = getCategoryPath(row.original)
return (
<div className="flex size-full items-center">
{path.map((chip) => (
<div key={chip.id}>
<Text>{chip.name}</Text>
</div>
))}
</div>
)
}
return (
<div className="flex size-full items-center gap-x-3 overflow-hidden">
<div className="flex size-7 items-center justify-center">
{row.getCanExpand() ? (
<IconButton
type="button"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
expandHandler()
}}
size="small"
variant="transparent"
>
<TriangleRightMini
className={clx({
"rotate-90 transition-transform will-change-transform":
row.getIsExpanded(),
})}
/>
</IconButton>
) : null}
</div>
<span className="truncate">{getValue()}</span>
</div>
)
},
}),
columnHelper.accessor("handle", {
header: () => <TextHeader text={t("fields.handle")} />,
cell: ({ getValue }) => {
return <TextCell text={`/${getValue()}`} />
},
}),
columnHelper.accessor("is_active", {
header: () => <TextHeader text={t("fields.status")} />,
cell: ({ getValue }) => {
const { color, label } = getIsActiveProps(getValue(), t)
return <StatusCell color={color}>{label}</StatusCell>
},
}),
columnHelper.accessor("is_internal", {
header: () => <TextHeader text={t("categories.fields.visibility")} />,
cell: ({ getValue }) => {
const { color, label } = getIsInternalProps(getValue(), t)
return <StatusCell color={color}>{label}</StatusCell>
},
}),
...base,
columnHelper.display({
id: "actions",
cell: ({ row }) => {
@@ -198,6 +119,6 @@ const useCategoryTableColumns = () => {
},
}),
],
[t]
[base]
)
}
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
import { HandleInput } from "../../../../../components/common/handle-input"
import { HandleInput } from "../../../../../components/inputs/handle-input"
import {
RouteFocusModal,
useRouteModal,
@@ -6,14 +6,14 @@ import {
useRouteModal,
} from "../../../../../../components/route-modal"
import { CountrySelect } from "../../../../../../components/common/country-select"
import { Form } from "../../../../../../components/common/form"
import { zodResolver } from "@hookform/resolvers/zod"
import { InventoryNext } from "@medusajs/types"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useUpdateInventoryItem } from "../../../../../../hooks/api/inventory"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { Form } from "../../../../../../components/common/form"
import { CountrySelect } from "../../../../../../components/inputs/country-select"
import { useUpdateInventoryItem } from "../../../../../../hooks/api/inventory"
type EditInventoryItemAttributeFormProps = {
item: InventoryNext.InventoryItemDTO
@@ -0,0 +1,380 @@
import {
ArrowUturnLeft,
CheckMini,
TriangleRightMini,
TrianglesMini,
XMarkMini,
} from "@medusajs/icons"
import { AdminProductCategoryResponse } from "@medusajs/types"
import { Text, clx } from "@medusajs/ui"
import * as Popover from "@radix-ui/react-popover"
import {
ComponentPropsWithoutRef,
Fragment,
MouseEvent,
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { Trans, useTranslation } from "react-i18next"
import { Divider } from "../../../../../components/common/divider"
import { TextSkeleton } from "../../../../../components/common/skeleton"
import { useCategories } from "../../../../../hooks/api/categories"
import { useDebouncedSearch } from "../../../../../hooks/use-debounced-search"
interface CategoryComboboxProps
extends Omit<
ComponentPropsWithoutRef<"input">,
"value" | "defaultValue" | "onChange"
> {
value: string[]
onChange: (value: string[]) => void
}
type Level = {
id: string
label: string
}
export const CategoryCombobox = forwardRef<
HTMLInputElement,
CategoryComboboxProps
>(({ value, onChange, className, ...props }, ref) => {
const innerRef = useRef<HTMLInputElement>(null)
useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(
ref,
() => innerRef.current,
[]
)
const [open, setOpen] = useState(false)
const { i18n, t } = useTranslation()
const [level, setLevel] = useState<Level[]>([])
const { searchValue, onSearchValueChange, query } = useDebouncedSearch()
const { product_categories, isPending, isError, error } = useCategories(
{
q: query,
parent_category_id: !searchValue ? getParentId(level) : undefined,
include_descendants_tree: !searchValue ? true : false,
},
{
enabled: open,
}
)
const [showLoading, setShowLoading] = useState(false)
/**
* We add a small artificial delay to the end of the loading state,
* this is done to prevent the popover from flickering too much when
* navigating between levels or searching.
*/
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout> | undefined
if (isPending) {
setShowLoading(true)
} else {
timeoutId = setTimeout(() => {
setShowLoading(false)
}, 150)
}
return () => {
clearTimeout(timeoutId)
}
}, [isPending])
useEffect(() => {
if (searchValue) {
setLevel([])
}
}, [searchValue])
function handleLevelUp(e: MouseEvent<HTMLButtonElement>) {
e.preventDefault()
e.stopPropagation()
setLevel(level.slice(0, level.length - 1))
innerRef.current?.focus()
}
function handleLevelDown(option: ProductCategoryOption) {
return (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
setLevel([...level, { id: option.value, label: option.label }])
innerRef.current?.focus()
}
}
function handleSelect(option: ProductCategoryOption) {
return (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
if (isSelected(value, option.value)) {
onChange(value.filter((v) => v !== option.value))
} else {
onChange([...value, option.value])
}
innerRef.current?.focus()
}
}
function handleOpenChange(open: boolean) {
if (!open) {
onSearchValueChange("")
setLevel([])
}
if (open) {
requestAnimationFrame(() => {
innerRef.current?.focus()
})
}
setOpen(open)
}
const options = getOptions(product_categories || [])
const showTag = value.length > 0 && !open
if (isError) {
throw error
}
return (
<Popover.Root open={open} onOpenChange={handleOpenChange}>
<Popover.Trigger asChild>
<div
className={clx(
"relative flex cursor-pointer items-center gap-x-2 overflow-hidden",
"h-8 w-full rounded-md px-2 py-0.5",
"bg-ui-bg-field transition-fg shadow-borders-base",
"hover:bg-ui-bg-field-hover",
"has-[input:focus]:shadow-borders-interactive-with-active",
"has-[:invalid]:shadow-borders-error has-[[aria-invalid=true]]:shadow-borders-error",
"has-[:disabled]:bg-ui-bg-disabled has-[:disabled]:text-ui-fg-disabled has-[:disabled]:cursor-not-allowed",
{
// Fake the focus state as long as the popover is open,
// this prevents the styling from flickering when navigating
// between levels.
"shadow-borders-interactive-with-active": open,
"pl-0.5": showTag,
},
className
)}
>
{open ? (
<input
ref={innerRef}
value={searchValue}
onChange={(e) => onSearchValueChange(e.target.value)}
className={clx(
"txt-compact-small w-full appearance-none bg-transparent outline-none",
"placeholder:text-ui-fg-muted"
)}
{...props}
/>
) : showTag ? (
<div className="flex w-full items-center gap-x-2">
<div className="flex w-fit items-center gap-x-1">
<div className="bg-ui-bg-base txt-compact-small-plus text-ui-fg-subtle focus-within:border-ui-fg-interactive relative flex h-[28px] items-center rounded-[4px] border py-[3px] pl-1.5 pr-1">
<span>{value.length}</span>
<button
type="button"
className="size-fit outline-none"
onClick={(e) => {
e.preventDefault()
onChange([])
}}
>
<XMarkMini className="text-ui-fg-muted" />
</button>
</div>
</div>
<Text size="small" leading="compact">
{t("general.selected")}
</Text>
</div>
) : (
<div className="w-full"></div>
)}
<div className="flex size-5 items-center justify-center">
<TrianglesMini className="text-ui-fg-muted" />
</div>
</div>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
sideOffset={8}
role="listbox"
className={clx(
"shadow-elevation-flyout bg-ui-bg-base -left-2 z-50 w-[var(--radix-popper-anchor-width)] rounded-[8px]",
"max-h-[200px] overflow-y-auto",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
)}
onOpenAutoFocus={(e) => {
e.preventDefault()
}}
>
{!searchValue && level.length > 0 && (
<Fragment>
<div className="p-1">
<button
className={clx(
"transition-fg grid w-full appearance-none grid-cols-[20px_1fr] items-center justify-center gap-2 rounded-md px-2 py-1.5 text-left outline-none",
"hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed"
)}
type="button"
onClick={handleLevelUp}
>
<ArrowUturnLeft className="text-ui-fg-muted" />
<Text size="small" leading="compact">
{getParentLabel(level)}
</Text>
</button>
</div>
<Divider />
</Fragment>
)}
<div className="p-1">
{options.length > 0 &&
!showLoading &&
options.map((option) => (
<div
key={option.value}
className={clx(
"transition-fg bg-ui-bg-base grid cursor-pointer grid-cols-1 items-center gap-2 overflow-hidden",
{
"grid-cols-[1fr_32px]":
option.has_children && !searchValue,
}
)}
>
<button
type="button"
role="option"
className={clx(
"grid h-full w-full appearance-none grid-cols-[20px_1fr] items-center gap-2 overflow-hidden rounded-md px-2 py-1.5 text-left",
"hover:bg-ui-bg-base-hover"
)}
onClick={handleSelect(option)}
>
<div className="flex size-5 items-center justify-center">
{isSelected(value, option.value) && <CheckMini />}
</div>
<Text
as="span"
size="small"
leading="compact"
className="w-full truncate"
>
{option.label}
</Text>
</button>
{option.has_children && !searchValue && (
<button
className={clx(
"text-ui-fg-muted flex size-8 appearance-none items-center justify-center rounded-md outline-none",
"hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed"
)}
type="button"
onClick={handleLevelDown(option)}
>
<TriangleRightMini />
</button>
)}
</div>
))}
{showLoading &&
Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className="grid grid-cols-[20px_1fr_20px] gap-2 px-2 py-1.5"
>
<div />
<TextSkeleton size="small" leading="compact" />
<div />
</div>
))}
{options.length === 0 && !showLoading && (
<div className="px-2 py-1.5">
<Text size="small" leading="compact">
{query ? (
<Trans
i18n={i18n}
i18nKey={"general.noSearchResultsFor"}
tOptions={{
query: query,
}}
components={[
<span className="font-medium" key="query" />,
]}
/>
) : (
t("general.noRecordsFound")
)}
</Text>
</div>
)}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
)
})
CategoryCombobox.displayName = "CategoryCombobox"
type ProductCategoryOption = {
value: string
label: string
has_children: boolean
}
function getParentId(level: Level[]): string {
if (!level.length) {
return "null"
}
return level[level.length - 1].id
}
function getParentLabel(level: Level[]): string | null {
if (!level.length) {
return null
}
return level[level.length - 1].label
}
function getOptions(
categories: AdminProductCategoryResponse["product_category"][]
): ProductCategoryOption[] {
return categories.map((cat) => {
return {
value: cat.id,
label: cat.name,
has_children: cat.category_children?.length > 0,
}
})
}
function isSelected(values: string[], value: string): boolean {
return values.includes(value)
}
@@ -0,0 +1 @@
export * from "./category-combobox"
@@ -1,18 +1,18 @@
import { CurrencyDTO, ProductVariantDTO } from "@medusajs/types"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { UseFormReturn, useWatch } from "react-hook-form"
import { CreateProductSchemaType } from "../product-create/schema"
import { useTranslation } from "react-i18next"
import { DataGrid } from "../../../components/grid/data-grid"
import { CurrencyCell } from "../../../components/grid/grid-cells/common/currency-cell"
import { ReadonlyCell } from "../../../components/grid/grid-cells/common/readonly-cell"
import { DataGridMeta } from "../../../components/grid/types"
import { useCurrencies } from "../../../hooks/api/currencies"
import { useStore } from "../../../hooks/api/store"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { CurrencyDTO, ProductVariantDTO } from "@medusajs/types"
import { useTranslation } from "react-i18next"
import { useMemo } from "react"
import { ReadonlyCell } from "../../../components/grid/grid-cells/common/readonly-cell"
import { CurrencyCell } from "../../../components/grid/grid-cells/common/currency-cell"
import { DataGridMeta } from "../../../components/grid/types"
import { ProductCreateSchemaType } from "../product-create/schema"
type VariantPricingFormProps = {
form: UseFormReturn<CreateProductSchemaType>
form: UseFormReturn<ProductCreateSchemaType>
}
export const VariantPricingForm = ({ form }: VariantPricingFormProps) => {
@@ -4,8 +4,8 @@ import { Button, Input } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { CountrySelect } from "../../../../../components/common/country-select"
import { Form } from "../../../../../components/common/form"
import { CountrySelect } from "../../../../../components/inputs/country-select"
import {
RouteDrawer,
useRouteModal,
@@ -1,10 +1,12 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Product } from "@medusajs/medusa"
import { Button, Input } from "@medusajs/ui"
import { Button, Input, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { Form } from "../../../../../components/common/form"
import { ChipInput } from "../../../../../components/inputs/chip-input"
import {
RouteDrawer,
useRouteModal,
@@ -34,13 +36,25 @@ export const CreateProductOptionForm = ({
resolver: zodResolver(CreateProductOptionSchema),
})
const { mutateAsync, isLoading } = useCreateProductOption(product.id)
const { mutateAsync, isPending } = useCreateProductOption(product.id)
const handleSubmit = form.handleSubmit(async (values) => {
mutateAsync(values, {
onSuccess: () => {
onSuccess: ({ option }) => {
toast.success(t("general.success"), {
description: t("products.options.create.successToast", {
title: option.title,
}),
dismissLabel: t("general.close"),
})
handleSuccess()
},
onError: async (err) => {
toast.error(t("general.error"), {
description: err.message,
dismissLabel: t("general.close"),
})
},
})
})
@@ -71,21 +85,14 @@ export const CreateProductOptionForm = ({
<Form.Field
control={form.control}
name="values"
render={({ field: { value, onChange, ...field } }) => {
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("products.fields.options.variations")}
</Form.Label>
<Form.Control>
<Input
{...field}
value={(value ?? []).join(",")}
onChange={(e) => {
const val = e.target.value
onChange(val.split(",").map((v) => v.trim()))
}}
/>
<ChipInput {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
@@ -100,7 +107,7 @@ export const CreateProductOptionForm = ({
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button type="submit" size="small" isLoading={isLoading}>
<Button type="submit" size="small" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
@@ -1,22 +1,22 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Product, ProductVariant } from "@medusajs/medusa"
import { Product } from "@medusajs/medusa"
import { Button, Heading, Input, Switch } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { Fragment } from "react"
import { Combobox } from "../../../../../components/common/combobox"
import { CountrySelect } from "../../../../../components/common/country-select"
import { Divider } from "../../../../../components/common/divider"
import { Form } from "../../../../../components/common/form"
import { Combobox } from "../../../../../components/inputs/combobox"
import { CountrySelect } from "../../../../../components/inputs/country-select"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { useCreateProductVariant } from "../../../../../hooks/api/products"
import { castNumber } from "../../../../../lib/cast-number"
import { optionalInt } from "../../../../../lib/validation"
import { useCreateProductVariant } from "../../../../../hooks/api/products"
type CreateProductVariantFormProps = {
product: Product
@@ -1,801 +0,0 @@
import {
Button,
Checkbox,
Heading,
Input,
Select,
Switch,
Text,
Textarea,
} from "@medusajs/ui"
import { Trans, useTranslation } from "react-i18next"
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"
import { SalesChannel } from "@medusajs/medusa"
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
import { Fragment, useMemo, useState } from "react"
import { CountrySelect } from "../../../../components/common/country-select"
import { Form } from "../../../../components/common/form"
import { HandleInput } from "../../../../components/common/handle-input"
import { DataTable } from "../../../../components/table/data-table"
import { useSalesChannelTableColumns } from "../../../../hooks/table/columns/use-sales-channel-table-columns"
import { useSalesChannelTableFilters } from "../../../../hooks/table/filters/use-sales-channel-table-filters"
import { useSalesChannelTableQuery } from "../../../../hooks/table/query/use-sales-channel-table-query"
import { useDataTable } from "../../../../hooks/use-data-table"
import { Combobox } from "../../../../components/common/combobox"
import { FileUpload } from "../../../../components/common/file-upload"
import { List } from "../../../../components/common/list"
import { useProductTypes } from "../../../../hooks/api/product-types"
import { useCollections } from "../../../../hooks/api/collections"
import { useSalesChannels } from "../../../../hooks/api/sales-channels"
import { useCategories } from "../../../../hooks/api/categories"
import { useTags } from "../../../../hooks/api/tags"
import { Keypair } from "../../../../components/common/keypair"
import { UseFormReturn } from "react-hook-form"
import { CreateProductSchemaType } from "../schema"
type ProductAttributesProps = {
form: UseFormReturn<CreateProductSchemaType>
}
const SUPPORTED_FORMATS = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/heic",
"image/svg+xml",
]
const permutations = (
data: { title: string; values: string[] }[]
): { [key: string]: string }[] => {
if (data.length === 0) {
return []
}
if (data.length === 1) {
return data[0].values.map((value) => ({ [data[0].title]: value }))
}
const toProcess = data[0]
const rest = data.slice(1)
return toProcess.values.flatMap((value) => {
return permutations(rest).map((permutation) => {
return {
[toProcess.title]: value,
...permutation,
}
})
})
}
const generateNameFromPermutation = (permutation: {
[key: string]: string
}) => {
return Object.values(permutation).join(" / ")
}
export const ProductAttributesForm = ({ form }: ProductAttributesProps) => {
const { t } = useTranslation()
const [open, onOpenChange] = useState(false)
const { product_types, isLoading: isLoadingTypes } = useProductTypes()
const { product_tags, isLoading: isLoadingTags } = useTags()
const { collections, isLoading: isLoadingCollections } = useCollections()
const { sales_channels, isLoading: isLoadingSalesChannels } =
useSalesChannels()
const { product_categories, isLoading: isLoadingCategories } = useCategories()
const options = form.watch("options")
const optionPermutations = permutations(options ?? [])
// const { append } = useFieldArray({
// name: "images",
// control: form.control,
// // keyName: "field_id",
// })
return (
<PanelGroup
direction="horizontal"
className="flex h-full justify-center overflow-hidden"
>
<Panel
className="flex h-full w-full flex-col items-center"
minSize={open ? 50 : 100}
>
<div className="flex size-full flex-col items-center overflow-auto p-16">
<div className="flex w-full max-w-[736px] flex-col justify-center px-2 pb-2">
<div className="flex flex-col gap-y-1">
<Heading>{t("products.createProductTitle")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("products.createProductHint")}
</Text>
</div>
<div className="flex flex-col gap-y-8 divide-y [&>div]:pt-8">
<div id="general" className="flex flex-col gap-y-8">
<div className="flex flex-col gap-y-2">
<div className="grid grid-cols-2 gap-x-4">
<Form.Field
control={form.control}
name="title"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("products.fields.title.label")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="subtitle"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.subtitle.label")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
</div>
<Form.Hint>
<Trans
i18nKey="products.fields.title.hint"
t={t}
components={[<br key="break" />]}
/>
</Form.Hint>
</div>
<div className="grid grid-cols-2 gap-x-4">
<Form.Field
control={form.control}
name="handle"
render={({ field }) => {
return (
<Form.Item>
<Form.Label
tooltip={t("products.fields.handle.tooltip")}
optional
>
{t("fields.handle")}
</Form.Label>
<Form.Control>
<HandleInput {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
</div>
<Form.Field
control={form.control}
name="description"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.description.label")}
</Form.Label>
<Form.Control>
<Textarea {...field} />
</Form.Control>
<Form.Hint>
<Trans
i18nKey={"products.fields.description.hint"}
components={[<br key="break" />]}
/>
</Form.Hint>
</Form.Item>
)
}}
/>
</div>
<div id="organize" className="flex flex-col gap-y-8">
<Heading level="h2">{t("products.organization")}</Heading>
<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: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.type.label")}
</Form.Label>
<Form.Control>
<Select
disabled={isLoadingTypes}
{...field}
onValueChange={onChange}
>
<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: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.collection.label")}
</Form.Label>
<Form.Control>
<Select
disabled={isLoadingCollections}
{...field}
onValueChange={onChange}
>
<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"
variant="secondary"
onClick={() => onOpenChange(!open)}
>
{t("actions.edit")}
</Button> */}
</div>
<div id="variants" className="flex flex-col gap-y-8">
<Heading level="h2">{t("products.variants")}</Heading>
<div className="grid grid-cols-1 gap-x-4 gap-y-8">
<Form.Field
control={form.control}
name="options"
render={({ field: { onChange, value } }) => {
const normalizedValue = (value ?? []).map((v) => {
return {
key: v.title,
value: v.values.join(","),
}
})
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.options.label")}
</Form.Label>
<Form.Hint>
{t("products.fields.options.hint")}
</Form.Hint>
<Form.Control>
<Keypair
labels={{
add: t("products.fields.options.add"),
key: t("products.fields.options.optionTitle"),
value: t("products.fields.options.variations"),
}}
value={normalizedValue}
onChange={(newVal) =>
onChange(
newVal.map((v) => {
return {
title: v.key,
values: v.value
.split(",")
.map((v) => v.trim()),
}
})
)
}
/>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-1 gap-x-4 gap-y-8">
<Form.Field
control={form.control}
name="variants"
render={({ field: { value, onChange, ...field } }) => {
const selectedOptions = (value ?? []).map(
(v) => v.options
)
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.variants.label")}
</Form.Label>
<Form.Hint>
{t("products.fields.variants.hint")}
</Form.Hint>
<Form.Control>
<List
{...field}
value={selectedOptions}
onChange={(v) => {
onChange(
v.map((options, i) => {
return {
title:
generateNameFromPermutation(options),
variant_rank: i,
options,
}
})
)
}}
compare={(a, b) => {
return (
generateNameFromPermutation(a) ===
generateNameFromPermutation(b)
)
}}
options={optionPermutations.map((opt) => {
return {
title: generateNameFromPermutation(opt),
value: opt,
}
})}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
<div id="attributes" className="flex flex-col gap-y-8">
<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">
<Form.Field
control={form.control}
name="width"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.width.label")}
</Form.Label>
<Form.Control>
<Input {...field} type="number" min={0} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="length"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.length.label")}
</Form.Label>
<Form.Control>
<Input {...field} type="number" min={0} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="height"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.height.label")}
</Form.Label>
<Form.Control>
<Input {...field} type="number" min={0} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="weight"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.weight.label")}
</Form.Label>
<Form.Control>
<Input {...field} type="number" min={0} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="mid_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.mid_code.label")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="hs_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.hs_code.label")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
</div>
</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
control={form.control}
name="images"
render={() => {
return (
<Form.Item>
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-1">
<Form.Label optional>
{t("products.media.label")}
</Form.Label>
<Form.Hint>
{t("products.media.editHint")}
</Form.Hint>
</div>
<Form.Control>
<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.ErrorMessage />
</div>
</Form.Item>
)
}}
/>
</div>
</div>
</div>
</div>
</div>
</Panel>
{open && (
<Fragment>
<PanelResizeHandle className="bg-ui-bg-subtle group flex items-center justify-center border-x px-[4.5px] outline-none">
{/* Is this meant to be resizable? And if so we need some kind of focus state for the handle cc: @ludvig18 */}
<div className="bg-ui-fg-disabled group-focus-visible:bg-ui-fg-muted transition-fg h-6 w-[3px] rounded-full" />
</PanelResizeHandle>
<Panel defaultSize={50} maxSize={50} minSize={40}>
<AddSalesChannelsDrawer onCancel={() => onOpenChange(false)} />
</Panel>
</Fragment>
)}
</PanelGroup>
)
}
const PAGE_SIZE = 20
const AddSalesChannelsDrawer = ({ onCancel }: { onCancel: () => void }) => {
const { t } = useTranslation()
const [selection, setSelection] = useState<RowSelectionState>({})
const { searchParams, raw } = useSalesChannelTableQuery({
pageSize: PAGE_SIZE,
})
const { sales_channels, count, isLoading, isError, error } =
useAdminSalesChannels({
...searchParams,
})
const filters = useSalesChannelTableFilters()
const columns = useColumns()
const { table } = useDataTable({
data: sales_channels ?? [],
columns,
count: sales_channels?.length ?? 0,
enablePagination: true,
enableRowSelection: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
rowSelection: {
state: selection,
updater: setSelection,
},
})
if (isError) {
throw error
}
return (
<div className="flex h-full flex-1 flex-col overflow-hidden">
<div className="flex-1">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
filters={filters}
isLoading={isLoading}
layout="fill"
orderBy={["name", "created_at", "updated_at"]}
queryObject={raw}
search
pagination
count={count}
/>
</div>
<div className="flex items-center justify-end gap-x-2 border-t px-6 py-4">
<Button
size="small"
variant="secondary"
onClick={onCancel}
type="button"
>
{t("actions.cancel")}
</Button>
<Button size="small" onClick={() => {}} type="button">
{t("actions.save")}
</Button>
</div>
</div>
)
}
const columnHelper = createColumnHelper<SalesChannel>()
const useColumns = () => {
const base = useSalesChannelTableColumns()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
...base,
],
[base]
)
}
@@ -0,0 +1,155 @@
import { Heading, Input } from "@medusajs/ui"
import { UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../../../../components/common/form"
import { CountrySelect } from "../../../../../../../components/inputs/country-select"
import { ProductCreateSchemaType } from "../../../../types"
type ProductCreateAttributeSectionProps = {
form: UseFormReturn<ProductCreateSchemaType>
}
export const ProductCreateAttributeSection = ({
form,
}: ProductCreateAttributeSectionProps) => {
const { t } = useTranslation()
return (
<div id="attributes" className="flex flex-col gap-y-8">
<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">
<Form.Field
control={form.control}
name="width"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.width.label")}
</Form.Label>
<Form.Control>
<Input {...field} type="number" min={0} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="length"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.length.label")}
</Form.Label>
<Form.Control>
<Input {...field} type="number" min={0} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="height"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.height.label")}
</Form.Label>
<Form.Control>
<Input {...field} type="number" min={0} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="weight"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.weight.label")}
</Form.Label>
<Form.Control>
<Input {...field} type="number" min={0} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="mid_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.mid_code.label")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="hs_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.hs_code.label")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
</div>
</div>
)
}
@@ -0,0 +1,2 @@
export * from "./product-create-details-context"
export * from "./use-product-create-details-context"
@@ -0,0 +1,9 @@
import { createContext } from "react"
type ProductCreateDetailsContextValue = {
open: boolean
onOpenChange: (open: boolean) => void
}
export const ProductCreateDetailsContext =
createContext<ProductCreateDetailsContextValue | null>(null)
@@ -0,0 +1,14 @@
import { useContext } from "react"
import { ProductCreateDetailsContext } from "./product-create-details-context"
export const useProductCreateDetailsContext = () => {
const context = useContext(ProductCreateDetailsContext)
if (!context) {
throw new Error(
"useProductCreateDetailsContext must be used within a ProductCreateDetailsContextProvider"
)
}
return context
}
@@ -0,0 +1,106 @@
import { Input, Textarea } from "@medusajs/ui"
import { UseFormReturn } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next"
import { Form } from "../../../../../../../components/common/form"
import { HandleInput } from "../../../../../../../components/inputs/handle-input"
import { ProductCreateSchemaType } from "../../../../types"
type ProductCreateGeneralSectionProps = {
form: UseFormReturn<ProductCreateSchemaType>
}
export const ProductCreateGeneralSection = ({
form,
}: ProductCreateGeneralSectionProps) => {
const { t } = useTranslation()
return (
<div id="general" className="flex flex-col gap-y-8">
<div className="flex flex-col gap-y-2">
<div className="grid grid-cols-2 gap-x-4">
<Form.Field
control={form.control}
name="title"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("products.fields.title.label")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="subtitle"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.subtitle.label")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
</div>
<Form.Hint>
<Trans
i18nKey="products.fields.title.hint"
t={t}
components={[<br key="break" />]}
/>
</Form.Hint>
</div>
<div className="grid grid-cols-2 gap-x-4">
<Form.Field
control={form.control}
name="handle"
render={({ field }) => {
return (
<Form.Item>
<Form.Label
tooltip={t("products.fields.handle.tooltip")}
optional
>
{t("fields.handle")}
</Form.Label>
<Form.Control>
<HandleInput {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
</div>
<Form.Field
control={form.control}
name="description"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.description.label")}
</Form.Label>
<Form.Control>
<Textarea {...field} />
</Form.Control>
<Form.Hint>
<Trans
i18nKey={"products.fields.description.hint"}
components={[<br key="break" />]}
/>
</Form.Hint>
</Form.Item>
)
}}
/>
</div>
)
}
@@ -0,0 +1,69 @@
import { Heading } from "@medusajs/ui"
import { UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { FileUpload } from "../../../../../../../components/common/file-upload"
import { Form } from "../../../../../../../components/common/form"
import { ProductCreateSchemaType } from "../../../../types"
type ProductCreateMediaSectionProps = {
form: UseFormReturn<ProductCreateSchemaType>
}
const SUPPORTED_FORMATS = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/heic",
"image/svg+xml",
]
export const ProductCreateMediaSection = ({
form,
}: ProductCreateMediaSectionProps) => {
const { t } = useTranslation()
return (
<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
control={form.control}
name="images"
render={() => {
return (
<Form.Item>
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-1">
<Form.Label optional>
{t("products.media.label")}
</Form.Label>
<Form.Hint>{t("products.media.editHint")}</Form.Hint>
</div>
<Form.Control>
<FileUpload
label={t("products.media.uploadImagesLabel")}
hint={t("products.media.uploadImagesHint")}
hasError={!!form.formState.errors.images}
formats={SUPPORTED_FORMATS}
onUploaded={(files) => {
form.clearErrors("images")
// if (hasInvalidFiles(files)) {
// return
// }
// files.forEach((f) => append(f))
}}
/>
</Form.Control>
<Form.ErrorMessage />
</div>
</Form.Item>
)
}}
/>
</div>
</div>
)
}
@@ -0,0 +1,193 @@
import { Button, Heading, Switch } from "@medusajs/ui"
import { UseFormReturn, useFieldArray } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next"
import { ChipGroup } from "../../../../../../../components/common/chip-group"
import { Form } from "../../../../../../../components/common/form"
import { Combobox } from "../../../../../../../components/inputs/combobox"
import { useComboboxData } from "../../../../../../../hooks/use-combobox-data"
import { client } from "../../../../../../../lib/client"
import { CategoryCombobox } from "../../../../../common/components/category-combobox"
import { ProductCreateSchemaType } from "../../../../types"
import { useProductCreateDetailsContext } from "../product-create-details-context"
type ProductCreateOrganizationSectionProps = {
form: UseFormReturn<ProductCreateSchemaType>
}
export const ProductCreateOrganizationSection = ({
form,
}: ProductCreateOrganizationSectionProps) => {
const { t } = useTranslation()
const { onOpenChange } = useProductCreateDetailsContext()
const collections = useComboboxData({
queryKey: ["product_collections"],
queryFn: client.collections.list,
getOptions: (data) =>
data.collections.map((collection) => ({
label: collection.title,
value: collection.id,
})),
})
const types = useComboboxData({
queryKey: ["product_types"],
queryFn: client.productTypes.list,
getOptions: (data) =>
data.product_types.map((type) => ({
label: type.value,
value: type.id,
})),
})
const { fields, remove, replace } = useFieldArray({
control: form.control,
name: "sales_channels",
keyName: "key",
})
const handleClearAllSalesChannels = () => {
replace([])
}
return (
<div id="organize" className="flex flex-col gap-y-8">
<Heading level="h2">{t("products.organization")}</Heading>
<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>
<Combobox
{...field}
options={types.options}
searchValue={types.searchValue}
onSearchValueChange={types.onSearchValueChange}
/>
</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>
<Combobox
{...field}
options={collections.options}
searchValue={collections.searchValue}
onSearchValueChange={collections.onSearchValueChange}
/>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-2 gap-x-4">
<Form.Field
control={form.control}
name="categories"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.categories.label")}
</Form.Label>
<Form.Control>
<CategoryCombobox {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-1 gap-y-4">
<Form.Field
control={form.control}
name="sales_channels"
render={() => {
return (
<Form.Item>
<div className="flex items-start justify-between gap-x-4">
<div>
<Form.Label optional>
{t("products.fields.sales_channels.label")}
</Form.Label>
<Form.Hint>
<Trans i18nKey={"products.fields.sales_channels.hint"} />
</Form.Hint>
</div>
<Button
size="small"
variant="secondary"
type="button"
onClick={() => onOpenChange(true)}
>
{t("actions.add")}
</Button>
</div>
<Form.Control className="mt-0">
{fields.length > 0 && (
<ChipGroup
onClearAll={handleClearAllSalesChannels}
onRemove={remove}
className="py-4"
>
{fields.map((field, index) => (
<ChipGroup.Chip key={field.key} index={index}>
{field.name}
</ChipGroup.Chip>
))}
</ChipGroup>
)}
</Form.Control>
</Form.Item>
)
}}
/>
</div>
</div>
)
}
@@ -0,0 +1,428 @@
import { XMarkMini } from "@medusajs/icons"
import {
Alert,
Button,
Checkbox,
Heading,
Hint,
IconButton,
Input,
Label,
Text,
clx,
} from "@medusajs/ui"
import {
Controller,
FieldArrayWithId,
UseFormReturn,
useFieldArray,
useWatch,
} from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../../../../components/common/form"
import { SortableList } from "../../../../../../../components/common/sortable-list"
import { ChipInput } from "../../../../../../../components/inputs/chip-input"
import { ProductCreateSchemaType } from "../../../../types"
type ProductCreateVariantsSectionProps = {
form: UseFormReturn<ProductCreateSchemaType>
}
const getPermutations = (
data: { title: string; values: string[] }[]
): { [key: string]: string }[] => {
if (data.length === 0) {
return []
}
if (data.length === 1) {
return data[0].values.map((value) => ({ [data[0].title]: value }))
}
const toProcess = data[0]
const rest = data.slice(1)
return toProcess.values.flatMap((value) => {
return getPermutations(rest).map((permutation) => {
return {
[toProcess.title]: value,
...permutation,
}
})
})
}
const getVariantName = (options: Record<string, string>) => {
return Object.values(options).join(" / ")
}
export const ProductCreateVariantsSection = ({
form,
}: ProductCreateVariantsSectionProps) => {
const { t } = useTranslation()
const options = useFieldArray({
control: form.control,
name: "options",
})
const variants = useFieldArray({
control: form.control,
name: "variants",
})
const watchedOptions = useWatch({
control: form.control,
name: "options",
defaultValue: [],
})
const watchedVariants = useWatch({
control: form.control,
name: "variants",
defaultValue: [],
})
const handleOptionValueUpdate = (index: number, value: string[]) => {
const newOptions = [...watchedOptions]
newOptions[index].values = value
const permutations = getPermutations(newOptions)
const oldVariants = [...watchedVariants]
const findMatchingPermutation = (options: Record<string, string>) => {
return permutations.find((permutation) =>
Object.keys(options).every((key) => options[key] === permutation[key])
)
}
const newVariants = oldVariants.reduce((variants, variant) => {
const match = findMatchingPermutation(variant.options)
if (match) {
variants.push({
...variant,
title: getVariantName(match),
options: match,
})
}
return variants
}, [] as typeof oldVariants)
const usedPermutations = new Set(
newVariants.map((variant) => variant.options)
)
const unusedPermutations = permutations.filter(
(permutation) => !usedPermutations.has(permutation)
)
unusedPermutations.forEach((permutation) => {
newVariants.push({
title: getVariantName(permutation),
options: permutation,
should_create: false,
variant_rank: newVariants.length,
})
})
form.setValue("variants", newVariants)
}
const handleRemoveOption = (index: number) => {
options.remove(index)
const newOptions = [...watchedOptions]
newOptions.splice(index, 1)
const permutations = getPermutations(newOptions)
const oldVariants = [...watchedVariants]
const findMatchingPermutation = (options: Record<string, string>) => {
return permutations.find((permutation) =>
Object.keys(options).every((key) => options[key] === permutation[key])
)
}
const newVariants = oldVariants.reduce((variants, variant) => {
const match = findMatchingPermutation(variant.options)
if (match) {
variants.push({
...variant,
title: getVariantName(match),
options: match,
})
}
return variants
}, [] as typeof oldVariants)
const usedPermutations = new Set(
newVariants.map((variant) => variant.options)
)
const unusedPermutations = permutations.filter(
(permutation) => !usedPermutations.has(permutation)
)
unusedPermutations.forEach((permutation) => {
newVariants.push({
title: getVariantName(permutation),
options: permutation,
should_create: false,
variant_rank: newVariants.length,
})
})
form.setValue("variants", newVariants)
}
const handleRankChange = (
items: FieldArrayWithId<ProductCreateSchemaType, "variants">[]
) => {
// Items in the SortableList are momorized, so we need to find the current
// value to preserve any changes that have been made to `should_create`.
const update = items.map((item, index) => {
const variant = watchedVariants.find((v) => v.title === item.title)
return {
id: item.id,
...(variant || item),
variant_rank: index,
}
})
variants.replace(update)
}
const getCheckboxState = (variants: ProductCreateSchemaType["variants"]) => {
if (variants.every((variant) => variant.should_create)) {
return true
}
if (variants.some((variant) => variant.should_create)) {
return "indeterminate"
}
return false
}
const onCheckboxChange = (value: boolean | "indeterminate") => {
switch (value) {
case true: {
const update = watchedVariants.map((variant) => {
return {
...variant,
should_create: true,
}
})
form.setValue("variants", update)
break
}
case false: {
const update = watchedVariants.map((variant) => {
return {
...variant,
should_create: false,
}
})
form.setValue("variants", update)
break
}
case "indeterminate":
break
}
}
return (
<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">
<Form.Field
control={form.control}
name="options"
render={() => {
return (
<Form.Item>
<div className="flex flex-col gap-y-4">
<div className="flex items-start justify-between gap-x-4">
<div className="flex flex-col">
<Form.Label>
{t("products.create.variants.productOptions.label")}
</Form.Label>
<Form.Hint>
{t("products.create.variants.productOptions.hint")}
</Form.Hint>
</div>
<Button
size="small"
variant="secondary"
type="button"
onClick={() => {
options.append({
title: "",
values: [],
})
}}
>
{t("actions.add")}
</Button>
</div>
<ul className="flex flex-col gap-y-4">
{options.fields.map((option, index) => {
return (
<li
key={option.id}
className="bg-ui-bg-component shadow-elevation-card-rest grid grid-cols-[1fr_28px] items-center gap-1.5 rounded-xl p-1.5"
>
<div className="grid grid-cols-[min-content,1fr] items-center gap-1.5">
<div className="flex items-center px-2 py-1.5">
<Label
size="xsmall"
weight="plus"
className="text-ui-fg-subtle"
htmlFor={`options.${index}.title`}
>
{t("fields.title")}
</Label>
</div>
<Input
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover"
{...form.register(
`options.${index}.title` as const
)}
/>
<div className="flex items-center px-2 py-1.5">
<Label
size="xsmall"
weight="plus"
className="text-ui-fg-subtle"
htmlFor={`options.${index}.values`}
>
{t("fields.values")}
</Label>
</div>
<Controller
control={form.control}
name={`options.${index}.values` as const}
render={({ field: { onChange, ...field } }) => {
const handleValueChange = (value: string[]) => {
handleOptionValueUpdate(index, value)
onChange(value)
}
return (
<ChipInput
{...field}
variant="contrast"
onChange={handleValueChange}
/>
)
}}
/>
</div>
<IconButton
type="button"
size="small"
variant="transparent"
className="text-ui-fg-muted"
onClick={() => handleRemoveOption(index)}
>
<XMarkMini />
</IconButton>
</li>
)
})}
</ul>
</div>
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-1 gap-x-4 gap-y-4">
<div className="flex flex-col gap-y-1">
<Label weight="plus">
{t("products.create.variants.productVariants.label")}
</Label>
<Hint>{t("products.create.variants.productVariants.hint")}</Hint>
</div>
{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>
) : (
<Alert>{t("products.create.variants.productVariants.alert")}</Alert>
)}
</div>
</div>
)
}
@@ -0,0 +1,198 @@
import { AdminSalesChannelResponse } from "@medusajs/types"
import { Button, Checkbox } from "@medusajs/ui"
import {
OnChangeFn,
RowSelectionState,
createColumnHelper,
} from "@tanstack/react-table"
import { useEffect, useMemo, useState } from "react"
import { UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { SplitView } from "../../../../../../../components/layout/split-view"
import { DataTable } from "../../../../../../../components/table/data-table"
import { useSalesChannels } from "../../../../../../../hooks/api/sales-channels"
import { useSalesChannelTableColumns } from "../../../../../../../hooks/table/columns/use-sales-channel-table-columns"
import { useSalesChannelTableFilters } from "../../../../../../../hooks/table/filters/use-sales-channel-table-filters"
import { useSalesChannelTableQuery } from "../../../../../../../hooks/table/query/use-sales-channel-table-query"
import { useDataTable } from "../../../../../../../hooks/use-data-table"
import { ProductCreateSchemaType } from "../../../../types"
import { useProductCreateDetailsContext } from "../product-create-details-context"
type ProductCreateSalesChannelDrawerProps = {
form: UseFormReturn<ProductCreateSchemaType>
}
const PAGE_SIZE = 50
const PREFIX = "sc"
export const ProductCreateSalesChannelDrawer = ({
form,
}: ProductCreateSalesChannelDrawerProps) => {
const { t } = useTranslation()
const { open, onOpenChange } = useProductCreateDetailsContext()
const { getValues, setValue } = form
const [selection, setSelection] = useState<RowSelectionState>({})
const [state, setState] = useState<{ id: string; name: string }[]>([])
const { searchParams, raw } = useSalesChannelTableQuery({
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
const { sales_channels, count, isLoading, isError, error } = useSalesChannels(
{
...searchParams,
}
)
useEffect(() => {
if (!open) {
return
}
const salesChannels = getValues("sales_channels")
if (salesChannels) {
setState(
salesChannels.map((channel) => ({
id: channel.id,
name: channel.name,
}))
)
setSelection(
salesChannels.reduce(
(acc, channel) => ({
...acc,
[channel.id]: true,
}),
{}
)
)
}
}, [open, getValues])
const updater: OnChangeFn<RowSelectionState> = (fn) => {
const value = typeof fn === "function" ? fn(selection) : fn
const ids = Object.keys(value)
const addedIdsSet = new Set(ids.filter((id) => value[id] && !selection[id]))
let addedSalesChannels: { id: string; name: string }[] = []
if (addedIdsSet.size > 0) {
addedSalesChannels =
sales_channels?.filter((channel) => addedIdsSet.has(channel.id)) ?? []
}
setState((prev) => {
const filteredPrev = prev.filter((channel) => value[channel.id])
return Array.from(new Set([...filteredPrev, ...addedSalesChannels]))
})
setSelection(value)
}
const handleAdd = () => {
setValue("sales_channels", state, {
shouldDirty: true,
shouldTouch: true,
})
onOpenChange(false)
}
const filters = useSalesChannelTableFilters()
const columns = useColumns()
const { table } = useDataTable({
data: sales_channels ?? [],
columns,
count: sales_channels?.length ?? 0,
enablePagination: true,
enableRowSelection: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
rowSelection: {
state: selection,
updater,
},
prefix: PREFIX,
})
if (isError) {
throw error
}
return (
<div className="flex h-full flex-1 flex-col overflow-hidden">
<div className="flex-1">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
filters={filters}
isLoading={isLoading}
layout="fill"
orderBy={["name", "created_at", "updated_at"]}
queryObject={raw}
search
pagination
count={count}
prefix={PREFIX}
/>
</div>
<div className="flex items-center justify-end gap-x-2 border-t px-6 py-4">
<SplitView.Close asChild>
<Button size="small" variant="secondary" type="button">
{t("actions.cancel")}
</Button>
</SplitView.Close>
<Button size="small" onClick={handleAdd} type="button">
{t("actions.add")}
</Button>
</div>
</div>
)
}
const columnHelper =
createColumnHelper<AdminSalesChannelResponse["sales_channel"]>()
const useColumns = () => {
const base = useSalesChannelTableColumns()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
...base,
],
[base]
)
}
@@ -0,0 +1 @@
export * from "./product-create-details-form"
@@ -0,0 +1,61 @@
import { Heading, Text } from "@medusajs/ui"
import { useState } from "react"
import { UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Divider } from "../../../../../components/common/divider"
import { SplitView } from "../../../../../components/layout/split-view"
import { ProductCreateSchemaType } from "../../types"
import { ProductCreateAttributeSection } from "./components/product-create-details-attribute-section"
import { ProductCreateDetailsContext } from "./components/product-create-details-context"
import { ProductCreateGeneralSection } from "./components/product-create-details-general-section"
import { ProductCreateOrganizationSection } from "./components/product-create-details-organize-section"
import { ProductCreateVariantsSection } from "./components/product-create-details-variant-section"
import { ProductCreateSalesChannelDrawer } from "./components/product-create-sales-channel-drawer"
type ProductAttributesProps = {
form: UseFormReturn<ProductCreateSchemaType>
}
export const ProductCreateDetailsForm = ({ form }: ProductAttributesProps) => {
const [open, setOpen] = useState(false)
return (
<ProductCreateDetailsContext.Provider
value={{ open, onOpenChange: setOpen }}
>
<SplitView open={open} onOpenChange={setOpen}>
<SplitView.Content>
<div className="flex flex-col items-center p-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<Header />
<ProductCreateGeneralSection form={form} />
<Divider />
<ProductCreateVariantsSection form={form} />
<Divider />
<ProductCreateOrganizationSection form={form} />
<Divider />
<ProductCreateAttributeSection form={form} />
</div>
</div>
</SplitView.Content>
<SplitView.Drawer>
<ProductCreateSalesChannelDrawer form={form} />
</SplitView.Drawer>
</SplitView>
</ProductCreateDetailsContext.Provider>
)
}
const Header = () => {
const { t } = useTranslation()
return (
<div className="flex flex-col">
<Heading>{t("products.create.header")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("products.create.hint")}
</Text>
</div>
)
}
@@ -0,0 +1 @@
export * from "./product-create-form"
@@ -1,53 +1,71 @@
import { Button, ProgressStatus, ProgressTabs } from "@medusajs/ui"
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, ProgressStatus, ProgressTabs, toast } from "@medusajs/ui"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../components/route-modal"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
} from "../../../../../components/route-modal"
import { useCreateProduct } from "../../../../../hooks/api/products"
import { VariantPricingForm } from "../../../common/variant-pricing-form"
import {
CreateProductSchema,
CreateProductSchemaType,
defaults,
normalize,
} from "../schema"
import { useCreateProduct } from "../../../../hooks/api/products"
import { ProductAttributesForm } from "./product-attributes-form"
import { VariantPricingForm } from "../../common/variant-pricing-form"
PRODUCT_CREATE_FORM_DEFAULTS,
ProductCreateSchema,
} from "../../constants"
import { ProductCreateSchemaType } from "../../types"
import { normalizeProductFormValues } from "../../utils"
import { ProductCreateDetailsForm } from "../product-create-details-form"
enum Tab {
PRODUCT = "product",
PRICE = "price",
}
type TabState = Record<Tab, ProgressStatus>
export const CreateProductPage = () => {
const { t } = useTranslation()
const SAVE_DRAFT_BUTTON = "save-draft-button"
export const ProductCreateForm = () => {
const [tab, setTab] = useState<Tab>(Tab.PRODUCT)
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<CreateProductSchemaType>({
defaultValues: defaults,
resolver: zodResolver(CreateProductSchema),
const form = useForm<ProductCreateSchemaType>({
defaultValues: PRODUCT_CREATE_FORM_DEFAULTS,
resolver: zodResolver(ProductCreateSchema),
})
const { mutateAsync, isLoading } = useCreateProduct()
const { mutateAsync, isPending } = useCreateProduct()
const handleSubmit = form.handleSubmit(
async (values, e) => {
if (!(e?.nativeEvent instanceof SubmitEvent)) return
if (!(e?.nativeEvent instanceof SubmitEvent)) {
return
}
const submitter = e?.nativeEvent?.submitter as HTMLButtonElement
if (!(submitter instanceof HTMLButtonElement)) return
const isDraftSubmission = submitter.dataset.name === "save-draft-button"
if (!(submitter instanceof HTMLButtonElement)) {
return
}
const isDraftSubmission = submitter.dataset.name === SAVE_DRAFT_BUTTON
await mutateAsync(
normalize({
normalizeProductFormValues({
...values,
status: (isDraftSubmission ? "draft" : "published") as any,
}),
{
onSuccess: ({ product }) => {
toast.success(t("general.success"), {
dismissLabel: t("actions.close"),
description: t("products.create.successToast", {
title: product.title,
}),
})
handleSuccess(`../${product.id}`)
},
}
@@ -79,13 +97,13 @@ export const CreateProductPage = () => {
status={tabState.product}
value={Tab.PRODUCT}
>
Products
{t("products.create.tabs.details")}
</ProgressTabs.Trigger>
<ProgressTabs.Trigger
status={tabState.price}
value={Tab.PRICE}
>
Prices
{t("products.create.tabs.variants")}
</ProgressTabs.Trigger>
</ProgressTabs.List>
</div>
@@ -96,32 +114,28 @@ export const CreateProductPage = () => {
</Button>
</RouteFocusModal.Close>
<Button
className="whitespace-nowrap"
data-name="save-draft-button"
variant="primary"
data-name={SAVE_DRAFT_BUTTON}
size="small"
key="submit-button"
type="submit"
isLoading={isLoading}
isLoading={isPending}
className="whitespace-nowrap"
>
{t("actions.saveAsDraft")}
</Button>
<PrimaryButton
tab={tab}
next={() => setTab(Tab.PRICE)}
isLoading={isLoading}
isLoading={isPending}
/>
</div>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="size-full overflow-hidden">
<ProgressTabs.Content
className="size-full overflow-y-auto"
className="size-full overflow-hidden"
value={Tab.PRODUCT}
>
<div className="flex h-full w-full">
<ProductAttributesForm form={form} />
</div>
<ProductCreateDetailsForm form={form} />
</ProgressTabs.Content>
<ProgressTabs.Content
className="size-full overflow-y-auto"
@@ -0,0 +1,78 @@
import { z } from "zod"
export const ProductCreateSchema = z.object({
title: z.string(),
subtitle: z.string().optional(),
handle: z.string().optional(),
description: z.string().optional(),
discountable: z.boolean(),
type_id: z.string().optional(),
collection_id: z.string().optional(),
categories: z.array(z.string()),
tags: z.array(z.string()).optional(),
sales_channels: z
.array(
z.object({
id: z.string(),
name: z.string(),
})
)
.optional(),
origin_country: z.string().optional(),
material: z.string().optional(),
width: z.string().optional(),
length: z.string().optional(),
height: z.string().optional(),
weight: z.string().optional(),
mid_code: z.string().optional(),
hs_code: z.string().optional(),
options: z.array(
z.object({
title: z.string(),
values: z.array(z.string()),
})
),
variants: z.array(
z.object({
should_create: z.boolean(),
title: z.string(),
options: z.record(z.string(), z.string()),
variant_rank: z.number(),
prices: z.record(z.string(), z.string()).optional(),
})
),
images: z.array(z.string()).optional(),
thumbnail: z.string().optional(),
})
export const PRODUCT_CREATE_FORM_DEFAULTS: Partial<
z.infer<typeof ProductCreateSchema>
> = {
discountable: true,
tags: [],
sales_channels: [],
options: [
{
title: "",
values: [],
},
],
variants: [],
images: [],
thumbnail: "",
categories: [],
collection_id: "",
description: "",
handle: "",
height: "",
hs_code: "",
length: "",
material: "",
mid_code: "",
origin_country: "",
subtitle: "",
title: "",
type_id: "",
weight: "",
width: "",
}
@@ -1,10 +1,10 @@
import { RouteFocusModal } from "../../../components/route-modal"
import { CreateProductPage } from "./components/create-product"
import { ProductCreateForm } from "./components/product-create-form/product-create-form"
export const ProductCreate = () => {
return (
<RouteFocusModal>
<CreateProductPage />
<ProductCreateForm />
</RouteFocusModal>
)
}
@@ -1,82 +0,0 @@
import { CreateProductDTO, CreateProductVariantDTO } from "@medusajs/types"
import * as zod from "zod"
export const CreateProductSchema = zod.object({
title: zod.string(),
subtitle: zod.string().optional(),
handle: zod.string().optional(),
description: zod.string().optional(),
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(),
origin_country: zod.string().optional(),
material: zod.string().optional(),
width: zod.string().optional(),
length: zod.string().optional(),
height: zod.string().optional(),
weight: zod.string().optional(),
mid_code: zod.string().optional(),
hs_code: zod.string().optional(),
options: zod.array(
zod.object({
title: zod.string(),
values: zod.array(zod.string()),
})
),
variants: zod.array(
zod.object({
title: zod.string(),
options: zod.record(zod.string(), zod.string()),
variant_rank: zod.number(),
prices: zod.record(zod.string(), zod.string()).optional(),
})
),
images: zod.array(zod.string()).optional(),
thumbnail: zod.string().optional(),
})
export const defaults = {
discountable: true,
tags: [],
sales_channels: [],
options: [],
variants: [],
images: [],
}
export const normalize = (
values: CreateProductSchemaType & { status: CreateProductDTO["status"] }
) => {
const reqData = {
...values,
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,
length: values.length ? parseFloat(values.length) : undefined,
height: values.height ? parseFloat(values.height) : undefined,
weight: values.weight ? parseFloat(values.weight) : undefined,
variants: normalizeVariants(values.variants as any),
} as any
return reqData
}
export const normalizeVariants = (
variants: (Partial<CreateProductVariantDTO> & {
prices?: Record<string, string>
})[]
) => {
return variants.map((variant) => ({
...variant,
prices: Object.entries(variant.prices || {}).map(([key, value]: any) => ({
currency_code: key,
amount: value ? parseFloat(value) : 0,
})),
}))
}
export type CreateProductSchemaType = zod.infer<typeof CreateProductSchema>
@@ -0,0 +1,4 @@
import { z } from "zod"
import { ProductCreateSchema } from "./constants"
export type ProductCreateSchemaType = z.infer<typeof ProductCreateSchema>
@@ -0,0 +1,51 @@
import { CreateProductDTO } from "@medusajs/types"
import { ProductCreateSchemaType } from "./types"
export const normalizeProductFormValues = (
values: ProductCreateSchemaType & { status: CreateProductDTO["status"] }
) => {
const reqData = {
...values,
is_giftcard: false,
tags: values?.tags?.length
? values.tags?.map((tag) => ({ value: tag }))
: undefined,
sales_channels: values?.sales_channels?.length
? values.sales_channels?.map((sc) => ({ id: sc.id }))
: undefined,
images: values.images?.length ? values.images : undefined,
collection_id: values.collection_id || undefined,
categories: values.categories.map((id) => ({ id })),
type_id: values.type_id || undefined,
handle: values.handle || undefined,
origin_country: values.origin_country || undefined,
material: values.material || undefined,
mid_code: values.mid_code || undefined,
hs_code: values.hs_code || undefined,
thumbnail: values.thumbnail || undefined,
subtitle: values.subtitle || undefined,
description: values.description || undefined,
width: values.width ? parseFloat(values.width) : undefined,
length: values.length ? parseFloat(values.length) : undefined,
height: values.height ? parseFloat(values.height) : undefined,
weight: values.weight ? parseFloat(values.weight) : undefined,
variants: normalizeVariants(values.variants),
}
return reqData
}
export const normalizeVariants = (
variants: ProductCreateSchemaType["variants"]
) => {
return variants
.filter((variant) => variant.should_create)
.map((variant) => ({
title: Object.values(variant.options || {}).join(" / "),
options: variant.options,
prices: Object.entries(variant.prices || {}).map(([key, value]: any) => ({
currency_code: key,
amount: value ? parseFloat(value) : 0,
})),
}))
}
@@ -6,17 +6,17 @@ import { useTranslation } from "react-i18next"
import { z } from "zod"
import { Fragment } from "react"
import { Combobox } from "../../../../../components/common/combobox"
import { CountrySelect } from "../../../../../components/common/country-select"
import { Divider } from "../../../../../components/common/divider"
import { Form } from "../../../../../components/common/form"
import { Combobox } from "../../../../../components/inputs/combobox"
import { CountrySelect } from "../../../../../components/inputs/country-select"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { useUpdateProductVariant } from "../../../../../hooks/api/products"
import { castNumber } from "../../../../../lib/cast-number"
import { optionalInt } from "../../../../../lib/validation"
import { useUpdateProductVariant } from "../../../../../hooks/api/products"
type ProductEditVariantFormProps = {
product: Product
@@ -1,21 +1,20 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Product } from "@medusajs/medusa"
import { Button, Input, Select } from "@medusajs/ui"
import { Button, Select } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { CountrySelect } from "../../../../../components/common/country-select"
import { Form } from "../../../../../components/common/form"
import { Combobox } from "../../../../../components/inputs/combobox"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { useUpdateProduct } from "../../../../../hooks/api/products"
import { Combobox } from "../../../../../components/common/combobox"
import { useProductTypes } from "../../../../../hooks/api/product-types"
import { useTags } from "../../../../../hooks/api/tags"
import { useCollections } from "../../../../../hooks/api/collections"
import { useCategories } from "../../../../../hooks/api/categories"
import { useCollections } from "../../../../../hooks/api/collections"
import { useProductTypes } from "../../../../../hooks/api/product-types"
import { useUpdateProduct } from "../../../../../hooks/api/products"
import { useTags } from "../../../../../hooks/api/tags"
type ProductOrganizationFormProps = {
product: Product
@@ -58,7 +57,7 @@ export const ProductOrganizationForm = ({
category_ids: data.category_ids || undefined,
tags:
data.tags?.map((t) => {
id: t
t
}) || undefined,
},
{
@@ -1,13 +1,13 @@
import { useUpdateProductVariant } from "../../../hooks/api/products"
import { RouteFocusModal, useRouteModal } from "../../../components/route-modal"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { ExtendedProductDTO } from "../../../types/api-responses"
import { VariantPricingForm } from "../common/variant-pricing-form"
import { normalizeVariants } from "../product-create/schema"
import { Button } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { RouteFocusModal, useRouteModal } from "../../../components/route-modal"
import { useUpdateProductVariant } from "../../../hooks/api/products"
import { ExtendedProductDTO } from "../../../types/api-responses"
import { VariantPricingForm } from "../common/variant-pricing-form"
import { normalizeVariants } from "../product-create/utils"
export const UpdateVariantPricesSchema = zod.object({
variants: zod.array(
@@ -38,7 +38,7 @@ export const PricingEdit = ({ product }: { product: ExtendedProductDTO }) => {
})
// TODO: Add batch update method here
const { mutateAsync, isLoading } = useUpdateProductVariant(product.id, "")
const { mutateAsync, isPending } = useUpdateProductVariant(product.id, "")
const handleSubmit = form.handleSubmit(
async (values) => {
@@ -69,7 +69,7 @@ export const PricingEdit = ({ product }: { product: ExtendedProductDTO }) => {
type="submit"
variant="primary"
size="small"
isLoading={isLoading}
isLoading={isPending}
>
{t("actions.save")}
</Button>
@@ -7,8 +7,8 @@ import { Fragment, useState } from "react"
import { useFieldArray, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Combobox } from "../../../../../../components/common/combobox"
import { Form } from "../../../../../../components/common/form"
import { Combobox } from "../../../../../../components/inputs/combobox"
import { RouteDrawer } from "../../../../../../components/route-modal"
import { usePromotionRuleValues } from "../../../../../../hooks/api/promotions"
import { RuleTypeValues } from "../../edit-rules"
@@ -162,7 +162,7 @@ export const RulesFormField = ({
return (
<Fragment key={`${fieldRule.id}.${index}`}>
<div className="flex flex-row gap-2 bg-ui-bg-subtle py-2 px-2 rounded-xl border border-ui-border-base">
<div className="bg-ui-bg-subtle border-ui-border-base flex flex-row gap-2 rounded-xl border px-2 py-2">
<div className="grow">
<Form.Field
key={`${identifier}.${scope}.${attributeFields.name}`}
@@ -299,7 +299,7 @@ export const RulesFormField = ({
{index < fields.length - 1 && (
<div className="relative px-6 py-3">
<div className="absolute top-0 bottom-0 left-[40px] z-[-1] border-ui-border-strong w-px bg-[linear-gradient(var(--border-strong)_33%,rgba(255,255,255,0)_0%)] bg-[length:1px_3px] bg-repeat-y"></div>
<div className="border-ui-border-strong absolute bottom-0 left-[40px] top-0 z-[-1] w-px bg-[linear-gradient(var(--border-strong)_33%,rgba(255,255,255,0)_0%)] bg-[length:1px_3px] bg-repeat-y"></div>
<Badge size="2xsmall" className=" text-xs">
{t("promotions.form.and")}
@@ -330,7 +330,7 @@ export const RulesFormField = ({
<Button
type="button"
variant="transparent"
className="inline-block text-ui-fg-muted hover:text-ui-fg-subtle ml-2"
className="text-ui-fg-muted hover:text-ui-fg-subtle ml-2 inline-block"
onClick={() => {
const indicesToRemove = fields
.map((field, index) => (field.required ? null : index))
@@ -21,7 +21,7 @@ import {
RuleOperatorOptionsResponse,
} from "@medusajs/types"
import { Form } from "../../../../../components/common/form"
import { PercentageInput } from "../../../../../components/common/percentage-input"
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
import {
RouteFocusModal,
useRouteModal,
@@ -358,7 +358,7 @@ export const CreatePromotionForm = ({
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="w-[800px] mx-auto my-20">
<RouteFocusModal.Body className="mx-auto my-20 w-[800px]">
<ProgressTabs.Content value={Tab.TYPE}>
<Form.Field
control={form.control}
@@ -382,7 +382,7 @@ export const CreatePromotionForm = ({
label={template.title}
description={template.description}
className={clx("", {
"border-2 border-ui-border-interactive":
"border-ui-border-interactive border-2":
template.id === field.value,
})}
/>
@@ -399,7 +399,7 @@ export const CreatePromotionForm = ({
<ProgressTabs.Content
value={Tab.PROMOTION}
className="flex flex-col gap-10 flex-1"
className="flex flex-1 flex-col gap-10"
>
{form.formState.errors.root && (
<Alert
@@ -432,7 +432,7 @@ export const CreatePromotionForm = ({
"promotions.form.method.code.description"
)}
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"border-ui-border-interactive border-2":
"false" === field.value,
})}
/>
@@ -443,7 +443,7 @@ export const CreatePromotionForm = ({
"promotions.form.method.automatic.description"
)}
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"border-ui-border-interactive border-2":
"true" === field.value,
})}
/>
@@ -509,7 +509,7 @@ export const CreatePromotionForm = ({
"promotions.form.value_type.fixed.description"
)}
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"border-ui-border-interactive border-2":
"fixed" === field.value,
})}
/>
@@ -523,7 +523,7 @@ export const CreatePromotionForm = ({
"promotions.form.value_type.percentage.description"
)}
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"border-ui-border-interactive border-2":
"percentage" === field.value,
})}
/>
@@ -604,7 +604,7 @@ export const CreatePromotionForm = ({
"promotions.form.type.standard.description"
)}
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"border-ui-border-interactive border-2":
"standard" === field.value,
})}
/>
@@ -616,7 +616,7 @@ export const CreatePromotionForm = ({
"promotions.form.type.buyget.description"
)}
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"border-ui-border-interactive border-2":
"buyget" === field.value,
})}
/>
@@ -652,7 +652,7 @@ export const CreatePromotionForm = ({
"promotions.form.allocation.each.description"
)}
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"border-ui-border-interactive border-2":
"each" === field.value,
})}
/>
@@ -666,7 +666,7 @@ export const CreatePromotionForm = ({
"promotions.form.allocation.across.description"
)}
className={clx("basis-1/2", {
"border-2 border-ui-border-interactive":
"border-ui-border-interactive border-2":
"across" === field.value,
})}
/>
@@ -13,10 +13,10 @@ import { Trans, useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
import { PercentageInput } from "../../../../../components/common/percentage-input"
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
import {
RouteDrawer,
useRouteModal,
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { useUpdatePromotion } from "../../../../../hooks/api/promotions"
import { getCurrencySymbol } from "../../../../../lib/currencies"
@@ -19,8 +19,8 @@ import * as zod from "zod"
import { PaymentProviderDTO, RegionCountryDTO } from "@medusajs/types"
import { Combobox } from "../../../../../components/common/combobox"
import { Form } from "../../../../../components/common/form"
import { Combobox } from "../../../../../components/inputs/combobox"
import { SplitView } from "../../../../../components/layout/split-view"
import {
RouteFocusModal,
@@ -1,18 +1,18 @@
import { PaymentProviderDTO, RegionDTO } from "@medusajs/types"
import { Button, Input, Select, Text, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { PaymentProviderDTO, RegionDTO } from "@medusajs/types"
import { Combobox } from "../../../../../components/common/combobox"
import { Form } from "../../../../../components/common/form"
import { Combobox } from "../../../../../components/inputs/combobox/index.ts"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { formatProvider } from "../../../../../lib/format-provider"
import { CurrencyInfo } from "../../../../../lib/currencies"
import { useUpdateRegion } from "../../../../../hooks/api/regions.tsx"
import { CurrencyInfo } from "../../../../../lib/currencies"
import { formatProvider } from "../../../../../lib/format-provider"
type EditRegionFormProps = {
region: RegionDTO
@@ -6,17 +6,17 @@ import {
useRouteModal,
} from "../../../../../../components/route-modal"
import { Combobox } from "../../../../../../components/common/combobox"
import { Form } from "../../../../../../components/common/form"
import { InventoryItemRes } from "../../../../../../types/api-responses"
import { zodResolver } from "@hookform/resolvers/zod"
import { InventoryNext } from "@medusajs/types"
import React from "react"
import { useCreateReservationItem } from "../../../../../../hooks/api/reservations"
import { useForm } from "react-hook-form"
import { useInventoryItems } from "../../../../../../hooks/api/inventory"
import { useStockLocations } from "../../../../../../hooks/api/stock-locations"
import { useTranslation } from "react-i18next"
import { zodResolver } from "@hookform/resolvers/zod"
import { Form } from "../../../../../../components/common/form"
import { Combobox } from "../../../../../../components/inputs/combobox"
import { useInventoryItems } from "../../../../../../hooks/api/inventory"
import { useCreateReservationItem } from "../../../../../../hooks/api/reservations"
import { useStockLocations } from "../../../../../../hooks/api/stock-locations"
import { InventoryItemRes } from "../../../../../../types/api-responses"
export const CreateReservationSchema = zod.object({
inventory_item_id: zod.string().min(1),
@@ -3,8 +3,8 @@ import { Button, Heading, Input, Text, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { CountrySelect } from "../../../../../components/common/country-select"
import { Form } from "../../../../../components/common/form"
import { CountrySelect } from "../../../../../components/inputs/country-select"
import {
RouteFocusModal,
useRouteModal,
@@ -4,8 +4,8 @@ import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { CountrySelect } from "../../../../../components/common/country-select"
import { Form } from "../../../../../components/common/form"
import { CountrySelect } from "../../../../../components/inputs/country-select"
import {
RouteDrawer,
useRouteModal,
@@ -6,10 +6,10 @@ import * as zod from "zod"
import { TaxRegionResponse } from "@medusajs/types"
import { Form } from "../../../../../components/common/form"
import { PercentageInput } from "../../../../../components/common/percentage-input"
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
import {
RouteFocusModal,
useRouteModal,
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { useCreateTaxRegion } from "../../../../../hooks/api/tax-regions"
import { countries } from "../../../../../lib/countries"
@@ -1,13 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod"
import {
Button,
clx,
DropdownMenu,
Heading,
Input,
Select,
Switch,
Text,
Button,
clx,
DropdownMenu,
Heading,
Input,
Select,
Switch,
Text,
} from "@medusajs/ui"
import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
@@ -17,19 +17,19 @@ import { TaxRegionResponse } from "@medusajs/types"
import { useState } from "react"
import { useSearchParams } from "react-router-dom"
import { Form } from "../../../../../components/common/form"
import { PercentageInput } from "../../../../../components/common/percentage-input"
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
import { SplitView } from "../../../../../components/layout/split-view"
import {
RouteFocusModal,
useRouteModal,
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { useCreateTaxRate } from "../../../../../hooks/api/tax-rates"
import { useTaxRegions } from "../../../../../hooks/api/tax-regions"
import { ConditionsDrawer } from "../../../common/components/conditions-drawer"
import { ConditionEntities } from "../../../common/constants"
import {
ConditionEntitiesValues,
ConditionsOption,
ConditionEntitiesValues,
ConditionsOption,
} from "../../../common/types"
import { Condition } from "../condition"
@@ -1,12 +1,12 @@
import { zodResolver } from "@hookform/resolvers/zod"
import {
Button,
clx,
DropdownMenu,
Heading,
Input,
Switch,
Text,
Button,
clx,
DropdownMenu,
Heading,
Input,
Switch,
Text,
} from "@medusajs/ui"
import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
@@ -16,11 +16,11 @@ import { TaxRateResponse, TaxRegionResponse } from "@medusajs/types"
import { useState } from "react"
import { useSearchParams } from "react-router-dom"
import { Form } from "../../../../../components/common/form"
import { PercentageInput } from "../../../../../components/common/percentage-input"
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
import { SplitView } from "../../../../../components/layout/split-view"
import {
RouteFocusModal,
useRouteModal,
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { useUpdateTaxRate } from "../../../../../hooks/api/tax-rates"
import { ConditionsDrawer } from "../../../common/components/conditions-drawer"
+95 -45
View File
@@ -4340,6 +4340,55 @@ __metadata:
languageName: node
linkType: hard
"@dnd-kit/accessibility@npm:^3.1.0":
version: 3.1.0
resolution: "@dnd-kit/accessibility@npm:3.1.0"
dependencies:
tslib: ^2.0.0
peerDependencies:
react: ">=16.8.0"
checksum: 4f9d24e801d66d4fbb551ec389ed90424dd4c5bbdf527000a618e9abb9833cbd84d9a79e362f470ccbccfbd6d00217a9212c92f3cef66e01c951c7f79625b9d7
languageName: node
linkType: hard
"@dnd-kit/core@npm:^6.1.0":
version: 6.1.0
resolution: "@dnd-kit/core@npm:6.1.0"
dependencies:
"@dnd-kit/accessibility": ^3.1.0
"@dnd-kit/utilities": ^3.2.2
tslib: ^2.0.0
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: c793eb97cb59285ca8937ebcdfcd27cff09d750ae06722e36ca5ed07925e41abc36a38cff98f9f6056f7a07810878d76909826142a2968330e7e22060e6be584
languageName: node
linkType: hard
"@dnd-kit/sortable@npm:^8.0.0":
version: 8.0.0
resolution: "@dnd-kit/sortable@npm:8.0.0"
dependencies:
"@dnd-kit/utilities": ^3.2.2
tslib: ^2.0.0
peerDependencies:
"@dnd-kit/core": ^6.1.0
react: ">=16.8.0"
checksum: a6066c652b892c6a11320c7d8f5c18fdf723e721e8eea37f4ab657dee1ac5e7ca710ac32ce0712a57fe968bc07c13bcea5d5599d90dfdd95619e162befd4d2fb
languageName: node
linkType: hard
"@dnd-kit/utilities@npm:^3.2.2":
version: 3.2.2
resolution: "@dnd-kit/utilities@npm:3.2.2"
dependencies:
tslib: ^2.0.0
peerDependencies:
react: ">=16.8.0"
checksum: 9aa90526f3e3fd567b5acc1b625a63177b9e8d00e7e50b2bd0e08fa2bf4dba7e19529777e001fdb8f89a7ce69f30b190c8364d390212634e0afdfa8c395e85a0
languageName: node
linkType: hard
"@emotion/is-prop-valid@npm:1.2.1":
version: 1.2.1
resolution: "@emotion/is-prop-valid@npm:1.2.1"
@@ -6828,6 +6877,8 @@ __metadata:
resolution: "@medusajs/dashboard@workspace:packages/admin-next/dashboard"
dependencies:
"@ariakit/react": ^0.4.1
"@dnd-kit/core": ^6.1.0
"@dnd-kit/sortable": ^8.0.0
"@hookform/resolvers": 3.3.2
"@medusajs/icons": "workspace:^"
"@medusajs/medusa": "workspace:^"
@@ -6841,8 +6892,8 @@ __metadata:
"@tanstack/react-table": 8.10.7
"@tanstack/react-virtual": ^3.0.4
"@types/node": ^20.11.15
"@types/react": 18.2.43
"@types/react-dom": 18.2.17
"@types/react": ^18.2.79
"@types/react-dom": ^18.2.25
"@uiw/react-json-view": ^2.0.0-alpha.17
"@vitejs/plugin-react": 4.2.1
autoprefixer: ^10.4.17
@@ -6857,10 +6908,10 @@ __metadata:
postcss: ^8.4.33
prettier: ^3.1.1
qs: ^6.12.0
react: 18.2.0
react: ^18.2.0
react-country-flag: ^3.1.0
react-currency-input-field: ^3.6.11
react-dom: 18.2.0
react-dom: ^18.2.0
react-hook-form: 7.49.1
react-i18next: 13.5.0
react-jwt: ^1.2.0
@@ -14791,15 +14842,6 @@ __metadata:
languageName: node
linkType: hard
"@types/react-dom@npm:18.2.17":
version: 18.2.17
resolution: "@types/react-dom@npm:18.2.17"
dependencies:
"@types/react": "*"
checksum: 33b53078ed7e9e0cfc4dc691e938f7db1cc06353bc345947b41b581c3efe2b980c9e4eb6460dbf5ddc521dd91959194c970221a2bd4bfad9d23ebce338e12938
languageName: node
linkType: hard
"@types/react-dom@npm:^17.0.18":
version: 17.0.20
resolution: "@types/react-dom@npm:17.0.20"
@@ -14827,6 +14869,15 @@ __metadata:
languageName: node
linkType: hard
"@types/react-dom@npm:^18.2.25":
version: 18.3.0
resolution: "@types/react-dom@npm:18.3.0"
dependencies:
"@types/react": "*"
checksum: 6c90d2ed72c5a0e440d2c75d99287e4b5df3e7b011838cdc03ae5cd518ab52164d86990e73246b9d812eaf02ec351d74e3b4f5bd325bf341e13bf980392fd53b
languageName: node
linkType: hard
"@types/react@npm:*":
version: 18.2.14
resolution: "@types/react@npm:18.2.14"
@@ -14838,17 +14889,6 @@ __metadata:
languageName: node
linkType: hard
"@types/react@npm:18.2.43":
version: 18.2.43
resolution: "@types/react@npm:18.2.43"
dependencies:
"@types/prop-types": "*"
"@types/scheduler": "*"
csstype: ^3.0.2
checksum: 10477a50fbd3c0cc5b8a2ade679f442717f68fb27c8460b2aa1d3256cd18c48f742bbe5b9ee37a8c4c5f832ffa37b3a23c09fd96dd880a8e3182d8929c05e803
languageName: node
linkType: hard
"@types/react@npm:>=16, @types/react@npm:^18.2.0, @types/react@npm:^18.2.14":
version: 18.2.31
resolution: "@types/react@npm:18.2.31"
@@ -14871,6 +14911,16 @@ __metadata:
languageName: node
linkType: hard
"@types/react@npm:^18.2.79":
version: 18.2.79
resolution: "@types/react@npm:18.2.79"
dependencies:
"@types/prop-types": "*"
csstype: ^3.0.2
checksum: c8a8a005d8830a48cc1ef93c3510c4935a2a03e5557dbecaa8f1038450cbfcb18eb206fa7fba7077d54b8da21faeb25577e897a333392770a7797f625b62c78a
languageName: node
linkType: hard
"@types/resolve@npm:1.17.1":
version: 1.17.1
resolution: "@types/resolve@npm:1.17.1"
@@ -35579,18 +35629,6 @@ __metadata:
languageName: node
linkType: hard
"react-dom@npm:18.2.0, react-dom@npm:^18.2.0":
version: 18.2.0
resolution: "react-dom@npm:18.2.0"
dependencies:
loose-envify: ^1.1.0
scheduler: ^0.23.0
peerDependencies:
react: ^18.2.0
checksum: 66dfc5f93e13d0674e78ef41f92ed21dfb80f9c4ac4ac25a4b51046d41d4d2186abc915b897f69d3d0ebbffe6184e7c5876f2af26bfa956f179225d921be713a
languageName: node
linkType: hard
"react-dom@npm:^17.0.1, react-dom@npm:^17.0.2":
version: 17.0.2
resolution: "react-dom@npm:17.0.2"
@@ -35604,6 +35642,18 @@ __metadata:
languageName: node
linkType: hard
"react-dom@npm:^18.2.0":
version: 18.2.0
resolution: "react-dom@npm:18.2.0"
dependencies:
loose-envify: ^1.1.0
scheduler: ^0.23.0
peerDependencies:
react: ^18.2.0
checksum: 66dfc5f93e13d0674e78ef41f92ed21dfb80f9c4ac4ac25a4b51046d41d4d2186abc915b897f69d3d0ebbffe6184e7c5876f2af26bfa956f179225d921be713a
languageName: node
linkType: hard
"react-element-to-jsx-string@npm:^14.3.4":
version: 14.3.4
resolution: "react-element-to-jsx-string@npm:14.3.4"
@@ -35900,15 +35950,6 @@ __metadata:
languageName: node
linkType: hard
"react@npm:18.2.0, react@npm:^18.2.0":
version: 18.2.0
resolution: "react@npm:18.2.0"
dependencies:
loose-envify: ^1.1.0
checksum: b562d9b569b0cb315e44b48099f7712283d93df36b19a39a67c254c6686479d3980b7f013dc931f4a5a3ae7645eae6386b4aa5eea933baa54ecd0f9acb0902b8
languageName: node
linkType: hard
"react@npm:^17.0.1, react@npm:^17.0.2":
version: 17.0.2
resolution: "react@npm:17.0.2"
@@ -35919,6 +35960,15 @@ __metadata:
languageName: node
linkType: hard
"react@npm:^18.2.0":
version: 18.2.0
resolution: "react@npm:18.2.0"
dependencies:
loose-envify: ^1.1.0
checksum: b562d9b569b0cb315e44b48099f7712283d93df36b19a39a67c254c6686479d3980b7f013dc931f4a5a3ae7645eae6386b4aa5eea933baa54ecd0f9acb0902b8
languageName: node
linkType: hard
"read-cache@npm:^1.0.0":
version: 1.0.0
resolution: "read-cache@npm:1.0.0"