feat(dashboard): 3.0 product collection (#6120)

This commit is contained in:
Kasper Fabricius Kristensen
2024-01-18 15:01:34 +01:00
committed by GitHub
parent b132ff7669
commit e49b6944e3
70 changed files with 2787 additions and 752 deletions

View File

@@ -3,7 +3,7 @@ import { DropdownMenu, IconButton } from "@medusajs/ui"
import { ReactNode } from "react"
import { Link } from "react-router-dom"
type TableRowAction = {
type Action = {
icon: ReactNode
label: string
} & (
@@ -17,15 +17,15 @@ type TableRowAction = {
}
)
type TableRowActionGroup = {
actions: TableRowAction[]
type ActionGroup = {
actions: Action[]
}
type TableRowActionsProps = {
groups: TableRowActionGroup[]
type ActionMenuProps = {
groups: ActionGroup[]
}
export const TableRowActions = ({ groups }: TableRowActionsProps) => {
export const ActionMenu = ({ groups }: ActionMenuProps) => {
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
@@ -42,7 +42,7 @@ export const TableRowActions = ({ groups }: TableRowActionsProps) => {
const isLast = index === groups.length - 1
return (
<div className="flex flex-col gap-y-1">
<DropdownMenu.Group key={index}>
{group.actions.map((action, index) => {
if (action.onClick) {
return (
@@ -61,21 +61,18 @@ export const TableRowActions = ({ groups }: TableRowActionsProps) => {
}
return (
<Link to={action.to} key={index}>
<DropdownMenu.Item
onClick={(e) => {
e.stopPropagation()
}}
className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2"
>
{action.icon}
<span>{action.label}</span>
</DropdownMenu.Item>
</Link>
<div key={index}>
<Link to={action.to} onClick={(e) => e.stopPropagation()}>
<DropdownMenu.Item className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2">
{action.icon}
<span>{action.label}</span>
</DropdownMenu.Item>
</Link>
</div>
)
})}
{!isLast && <DropdownMenu.Separator />}
</div>
</DropdownMenu.Group>
)
})}
</DropdownMenu.Content>

View File

@@ -0,0 +1 @@
export * from "./action-menu"

View File

@@ -1,5 +1,23 @@
import { Combobox as Primitive } from "@headlessui/react"
import { EllipseMiniSolid, TrianglesMini } from "@medusajs/icons"
import { Product } from "@medusajs/medusa"
import { clx } from "@medusajs/ui"
import * as Popover from "@radix-ui/react-popover"
import { useAdminProducts } from "medusa-react"
import {
ComponentPropsWithoutRef,
ElementRef,
ReactNode,
createContext,
forwardRef,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react"
import { useTranslation } from "react-i18next"
type ComboboxOption = {
value: string
@@ -13,25 +31,363 @@ type ComboboxProps = {
}
export const Combobox = ({ size = "base" }: ComboboxProps) => {
const [product, setProduct] = useState<Product | null>(null)
const [query, setQuery] = useState("")
const { products, count, isLoading } = useAdminProducts(
{
q: query,
},
{
keepPreviousData: true,
}
)
return (
<Popover.Root>
<Primitive by="id" value={product} onChange={setProduct}>
<div className="relative">
<div className="relative w-full">
<Primitive.Input
onChange={(e) => setQuery(e.target.value)}
displayValue={(value: Product) => value?.title}
className={clx(
"bg-ui-bg-field shadow-buttons-neutral transition-fg flex w-full select-none items-center justify-between rounded-md outline-none",
"placeholder:text-ui-fg-muted text-ui-fg-base",
"hover:bg-ui-bg-field-hover",
"focus-visible:shadow-borders-interactive-with-active data-[state=open]:!shadow-borders-interactive-with-active",
"aria-[invalid=true]:border-ui-border-error aria-[invalid=true]:shadow-borders-error",
"invalid:border-ui-border-error invalid:shadow-borders-error",
"disabled:!bg-ui-bg-disabled disabled:!text-ui-fg-disabled",
{
"h-8 px-2 py-1.5 txt-compact-small": size === "base",
"h-7 px-2 py-1 txt-compact-small": size === "small",
}
)}
/>
<Primitive.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<TrianglesMini className="text-ui-fg-muted" aria-hidden="true" />
</Primitive.Button>
</div>
<Primitive.Options className="absolute mt-2 max-h-[200px] w-full overflow-auto z-10 bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout rounded-lg p-1">
{products?.map((p) => (
<Primitive.Option
key={p.id}
value={p}
className={clx(
"bg-ui-bg-base grid cursor-pointer grid-cols-[20px_1fr] gap-x-2 items-center rounded-md px-3 py-2 outline-none transition-colors",
"ui-active:bg-ui-bg-base-hover",
{
"txt-compact-medium data-[state=checked]:txt-compact-medium-plus":
size === "base",
"txt-compact-small data-[state=checked]:txt-compact-medium-plus":
size === "small",
}
)}
>
<div
className="w-5 h-5 flex items-center justify-center"
aria-hidden="true"
>
<EllipseMiniSolid className="ui-selected:block hidden" />
</div>
<span className="block truncate ui-selected:font-medium">
{p.title}
</span>
</Primitive.Option>
))}
</Primitive.Options>
</div>
</Primitive>
)
}
type ComboboxContextValue = {
size: "base" | "small"
open: boolean
setOpen: (open: boolean) => void
}
const ComboboxContext = createContext<ComboboxContextValue | null>(null)
const useComboboxContext = () => {
const context = useContext(ComboboxContext)
if (!context) {
throw new Error(
"Combobox compound components cannot be rendered outside the Combobox component"
)
}
return context
}
const Root = forwardRef<
ElementRef<typeof Primitive>,
Omit<ComponentPropsWithoutRef<typeof Primitive>, "children"> & {
className?: string
size?: "base" | "small"
children: ReactNode
}
>(({ children, className, size = "base", ...props }, ref) => {
const [open, setOpen] = useState(false)
const value = useMemo(() => ({ size, open, setOpen }), [size, open])
return (
<ComboboxContext.Provider value={value}>
<Primitive {...props} ref={ref}>
<Popover.Root open={open} onOpenChange={setOpen}>
{children}
</Popover.Root>
</Primitive>
</ComboboxContext.Provider>
)
})
Root.displayName = "Combobox"
const Trigger = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<"div">>(
({ className, children, ...props }, ref) => {
const { size } = useComboboxContext()
return (
<Popover.Trigger asChild>
<button
<div
className={clx(
"bg-ui-bg-field shadow-buttons-neutral transition-fg flex w-full select-none items-center justify-between rounded-md outline-none",
"data-[placeholder]:text-ui-fg-muted text-ui-fg-base",
"relative bg-ui-bg-field shadow-buttons-neutral transition-fg w-full select-none items-center justify-between rounded-md outline-none",
"hover:bg-ui-bg-field-hover",
"focus:shadow-borders-interactive-with-active data-[state=open]:!shadow-borders-interactive-with-active",
"focus-visible:shadow-borders-interactive-with-active data-[state=open]:!shadow-borders-interactive-with-active",
"aria-[invalid=true]:border-ui-border-error aria-[invalid=true]:shadow-borders-error",
"invalid::border-ui-border-error invalid:shadow-borders-error",
"invalid:border-ui-border-error invalid:shadow-borders-error",
"disabled:!bg-ui-bg-disabled disabled:!text-ui-fg-disabled",
{
"h-8 px-2 py-1.5 txt-compact-small": size === "base",
"h-7 px-2 py-1 txt-compact-small": size === "small",
}
},
className
)}
></button>
{...props}
ref={ref}
>
{children}
<Primitive.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<TrianglesMini className="text-ui-fg-muted" aria-hidden="true" />
</Primitive.Button>
</div>
</Popover.Trigger>
</Popover.Root>
)
}
)
Trigger.displayName = "Combobox.Trigger"
const Value = forwardRef<
ElementRef<typeof Primitive.Input>,
ComponentPropsWithoutRef<typeof Primitive.Input>
>(({ className, ...props }, ref) => {
const { size } = useComboboxContext()
return (
<Primitive.Input
{...props}
ref={ref}
className={clx(
"placeholder:text-ui-fg-muted text-ui-fg-base outline-none bg-transparent w-full",
"disabled:!text-ui-fg-disabled",
{
" txt-compact-small": size === "base",
"txt-compact-small": size === "small",
},
className
)}
/>
)
})
Value.displayName = "Combobox.Value"
const Item = forwardRef<
ElementRef<typeof Primitive.Option>,
Omit<ComponentPropsWithoutRef<typeof Primitive.Option>, "children"> & {
children?: ReactNode
}
>(({ children, className, ...props }, ref) => {
const { size } = useComboboxContext()
return (
<Primitive.Option
{...props}
ref={ref}
className={clx(
"bg-ui-bg-base grid cursor-pointer grid-cols-[20px_1fr] gap-x-2 items-center rounded-md px-2 py-1.5 outline-none transition-colors",
"ui-active:bg-ui-bg-base-hover",
{
"txt-compact-medium data-[state=checked]:txt-compact-medium-plus":
size === "base",
"txt-compact-small data-[state=checked]:txt-compact-medium-plus":
size === "small",
},
className
)}
>
<div
className="w-5 h-5 flex items-center justify-center"
aria-hidden="true"
>
<EllipseMiniSolid className="ui-selected:block hidden" />
</div>
{children}
</Primitive.Option>
)
})
Item.displayName = "Combobox.Item"
const NoResults = forwardRef<
ElementRef<"span">,
ComponentPropsWithoutRef<"span">
>(({ children, className, ...props }, ref) => {
const { size } = useComboboxContext()
const { t } = useTranslation()
return (
<span
{...props}
ref={ref}
className={clx(
"bg-ui-bg-base items-center flex w-full justify-center rounded-md px-2 py-1.5 outline-none transition-colors",
"ui-active:bg-ui-bg-base-hover",
{
"txt-compact-medium data-[state=checked]:txt-compact-medium-plus":
size === "base",
"txt-compact-small data-[state=checked]:txt-compact-medium-plus":
size === "small",
},
className
)}
>
{children ?? t("general.noResultsTitle")}
</span>
)
})
Item.displayName = "Combobox.NoResults"
const Content = forwardRef<
ElementRef<typeof Popover.Content>,
ComponentPropsWithoutRef<typeof Popover.Content>
>(
(
{
children,
className,
side = "bottom",
sideOffset = 8,
collisionPadding = 24,
...props
},
ref
) => {
return (
<Popover.Portal>
<Popover.Content
ref={ref}
className={clx(
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout relative max-h-[120px] h-full min-w-[var(--radix-popper-anchor-width)] overflow-hidden rounded-lg flex flex-col divide-y",
"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",
className
)}
side={side}
sideOffset={sideOffset}
collisionPadding={collisionPadding}
{...props}
>
<Primitive.Options
static={true}
className={clx("p-1 flex-1 overflow-auto")}
>
{children}
</Primitive.Options>
</Popover.Content>
</Popover.Portal>
)
}
)
Content.displayName = "Combobox.Content"
const Pagination = forwardRef<
ElementRef<"div">,
{
isLoading?: boolean
hasNext?: boolean
onPaginate: () => void
className?: string
}
>(({ isLoading, hasNext, onPaginate, className, ...props }, ref) => {
const observerRef = useRef<IntersectionObserver | null>(null)
const innerRef = useRef<HTMLDivElement>(null)
// Merge innerRef and ref
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(ref, () => {
return innerRef.current
})
useEffect(() => {
if (innerRef.current) {
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
onPaginate()
}
})
observerRef.current.observe(innerRef.current)
}
return () => {
if (observerRef.current && innerRef.current) {
observerRef.current.unobserve(innerRef.current)
}
}
}, [isLoading, hasNext, onPaginate, ref])
return <div ref={innerRef} className="bg-transparent w-px h-px" {...props} />
})
Pagination.displayName = "Combobox.Pagination"
const Combo = Object.assign(Root, {
Trigger,
Value,
Item,
NoResults,
Pagination,
Content,
})
export const TestCombobox = () => {
const [product, setProduct] = useState<Product[]>([])
const [query, setQuery] = useState("")
const { products, count, isLoading } = useAdminProducts(
{
q: query,
},
{
keepPreviousData: true,
}
)
return (
<Combo value={product} onChange={setProduct}>
<Combo.Trigger>
<Combo.Value
onChange={(e) => setQuery(e.target.value)}
displayValue={(value: Product[]) => `${value?.length}`}
/>
</Combo.Trigger>
<Combo.Content>
{!products?.length && <Combo.NoResults />}
<Combo.Pagination isLoading onPaginate={() => console.log("Heyo!")} />
{products?.map((p) => (
<Combo.Item key={p.id} value={p}>
{p.title}
</Combo.Item>
))}
</Combo.Content>
</Combo>
)
}

View File

@@ -1,12 +1,14 @@
import { InformationCircleSolid } from "@medusajs/icons"
import {
Hint as HintComponent,
Label as LabelComponent,
Text,
Tooltip,
clx,
} from "@medusajs/ui"
import * as LabelPrimitives from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import { createContext, forwardRef, useContext, useId } from "react"
import { ReactNode, createContext, forwardRef, useContext, useId } from "react"
import {
Controller,
ControllerProps,
@@ -22,7 +24,7 @@ const Provider = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
@@ -33,7 +35,7 @@ const FormFieldContext = createContext<FormFieldContextValue>(
const Field = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
@@ -97,8 +99,9 @@ const Label = forwardRef<
React.ElementRef<typeof LabelPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitives.Root> & {
optional?: boolean
tooltip?: ReactNode
}
>(({ className, optional = false, ...props }, ref) => {
>(({ className, optional = false, tooltip, ...props }, ref) => {
const { formItemId } = useFormField()
const { t } = useTranslation()
@@ -112,6 +115,11 @@ const Label = forwardRef<
weight="plus"
{...props}
/>
{tooltip && (
<Tooltip content={tooltip}>
<InformationCircleSolid className="text-ui-fg-muted" />
</Tooltip>
)}
{optional && (
<Text size="small" leading="compact" className="text-ui-fg-muted">
({t("fields.optional")})

View File

@@ -1 +0,0 @@
export * from "./table-row-actions"