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

@@ -0,0 +1,5 @@
---
"@medusajs/ui": patch
---
fix(ui): Fix broken responsive style of Drawer between `sm` and `md`.

View File

@@ -16,6 +16,8 @@
"package.json"
],
"dependencies": {
"@headlessui/react": "^1.7.18",
"@headlessui/tailwindcss": "^0.2.0",
"@hookform/resolvers": "3.3.2",
"@medusajs/icons": "workspace:^",
"@medusajs/ui": "workspace:^",

View File

@@ -56,7 +56,16 @@
}
},
"collections": {
"domain": "Collections"
"domain": "Collections",
"createCollection": "Create Collection",
"createCollectionHint": "Create a new collection to organize your products.",
"editCollection": "Edit Collection",
"handleTooltip": "The handle is used to reference the collection in your storefront. If not specified, the handle will be generated from the collection title.",
"deleteWarning_one": "You are about to delete {{count}} collection. This action cannot be undone.",
"deleteWarning_other": "You are about to delete {{count}} collections. This action cannot be undone.",
"removeSingleProductWarning": "You are about to remove the product {{title}} from the collection. This action cannot be undone.",
"removeProductsWarning_one": "You are about to remove {{count}} product from the collection. This action cannot be undone.",
"removeProductsWarning_other": "You are about to remove {{count}} products from the collection. This action cannot be undone."
},
"categories": {
"domain": "Categories"
@@ -78,7 +87,8 @@
"changePasswordPromptDescription": "You are about to change the password for {{email}}. Make sure that you have communicated the new password to the customer before proceeding.",
"guest": "Guest",
"registered": "Registered",
"firstSeen": "First seen"
"firstSeen": "First seen",
"viewOrder": "View order"
},
"customerGroups": {
"domain": "Customer Groups",
@@ -138,10 +148,13 @@
"regions": {
"domain": "Regions",
"createRegion": "Create Region",
"createRegionHint": "Manage tax rates and providers for a set of countries.",
"editRegion": "Edit Region",
"deleteRegionWarning": "You are about to delete the region {{name}}. This action cannot be undone.",
"taxInclusiveHint": "When enabled all prices in the region will be tax inclusive.",
"providersHint": "The providers that are available in the region."
"providersHint": "The providers that are available in the region.",
"shippingOptions": "Shipping Options",
"returnShippingOptions": "Return Shipping Options"
},
"locations": {
"domain": "Locations",
@@ -150,7 +163,9 @@
"addSalesChannels": "Add sales channels",
"detailsHint": "Specify the details of the location.",
"noLocationsFound": "No locations found",
"deleteLocationWarning": "You are about to delete the location {{name}}. This action cannot be undone."
"deleteLocationWarning": "You are about to delete the location {{name}}. This action cannot be undone.",
"removeSalesChannelsWarning_one": "You are about to remove {{count}} sales channel from the location.",
"removeSalesChannelsWarning_other": "You are about to remove {{count}} sales channels from the location."
},
"salesChannels": {
"domain": "Sales Channels",
@@ -215,6 +230,7 @@
"phone": "Phone",
"metadata": "Metadata",
"selectCountry": "Select country",
"products": "Products",
"variants": "Variants",
"orders": "Orders",
"account": "Account",

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"

View File

@@ -122,7 +122,7 @@ export const NavItem = ({
"text-ui-fg-subtle hover:text-ui-fg-base transition-fg hover:bg-ui-bg-subtle-hover flex h-8 flex-1 items-center gap-x-2 rounded-md px-2 py-2.5 outline-none first-of-type:mt-1 last-of-type:mb-2 md:py-1.5",
{
"bg-ui-bg-base text-ui-fg-base hover:bg-ui-bg-base shadow-elevation-card-rest":
location.pathname === item.to,
location.pathname.startsWith(item.to),
}
)}
>

View File

@@ -0,0 +1,24 @@
import { useRef, useState } from "react"
export const useHandleTableScroll = () => {
const tableContainerRef = useRef<HTMLDivElement>(null)
// Listen for if the table container that has overflow-y: auto is scrolled, and if true set some state
const [isScrolled, setIsScrolled] = useState(false)
const handleScroll = () => {
if (tableContainerRef.current) {
setIsScrolled(
tableContainerRef.current.scrollTop > 0 &&
tableContainerRef.current.scrollTop <
tableContainerRef.current.scrollHeight
)
}
}
return {
tableContainerRef,
isScrolled,
handleScroll,
}
}

View File

@@ -1,4 +1,5 @@
import type {
AdminCollectionsRes,
AdminCustomerGroupsRes,
AdminCustomersRes,
AdminProductsRes,
@@ -136,12 +137,37 @@ const router = createBrowserRouter([
},
children: [
{
index: true,
lazy: () => import("../../routes/collections/list"),
path: "",
lazy: () => import("../../routes/collections/collection-list"),
children: [
{
path: "create",
lazy: () =>
import("../../routes/collections/collection-create"),
},
],
},
{
path: ":id",
lazy: () => import("../../routes/collections/details"),
handle: {
crumb: (data: AdminCollectionsRes) => data.collection.title,
},
lazy: () =>
import("../../routes/collections/collection-detail"),
children: [
{
path: "edit",
lazy: () =>
import("../../routes/collections/collection-edit"),
},
{
path: "add-products",
lazy: () =>
import(
"../../routes/collections/collection-add-products"
),
},
],
},
],
},

View File

@@ -62,7 +62,7 @@ export const CreatePublishableApiKeyForm = ({
</FocusModal.Header>
<FocusModal.Body className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col items-center overflow-y-auto">
<div className="flex w-full max-w-[720px] flex-col gap-y-10 px-2 pb-6 pt-[72px]">
<div className="flex w-full max-w-[720px] flex-col gap-y-8 px-2 py-16">
<div>
<Heading>
{t("apiKeyManagement.createPublishableApiKey")}

View File

@@ -1,15 +1,6 @@
import { EllipsisHorizontal, Trash, XCircle } from "@medusajs/icons"
import { PencilSquare, Trash, XCircle } from "@medusajs/icons"
import { PublishableApiKey } from "@medusajs/medusa"
import {
Button,
Container,
DropdownMenu,
Heading,
IconButton,
Table,
clx,
usePrompt,
} from "@medusajs/ui"
import { Button, Container, Heading, Table, clx, usePrompt } from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
@@ -26,6 +17,7 @@ import {
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { NoRecords } from "../../../../../components/common/empty-table-content"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
@@ -212,27 +204,33 @@ const KeyActions = ({ apiKey }: { apiKey: PublishableApiKey }) => {
}
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton size="small" variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onClick={handleRevoke}>
<div className="flex items-center gap-x-2 [&_svg]:text-ui-fg-subtle">
<XCircle />
<span onClick={handleRevoke}>{t("apiKeyManagement.revoke")}</span>
</div>
</DropdownMenu.Item>
<DropdownMenu.Item onClick={handleDelete}>
<div className="flex items-center gap-x-2 [&_svg]:text-ui-fg-subtle">
<Trash />
<span>{t("general.delete")}</span>
</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/settings/api-key-management/${apiKey.id}`,
},
],
},
{
actions: [
{
icon: <XCircle />,
label: t("apiKeyManagement.revoke"),
onClick: handleRevoke,
},
{
icon: <Trash />,
label: t("general.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}

View File

@@ -0,0 +1,34 @@
import { FocusModal } from "@medusajs/ui"
import { useAdminCollection } from "medusa-react"
import { useParams } from "react-router-dom"
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
import { AddProductsToCollectionForm } from "./components/add-products-to-collection-form"
export const CollectionAddProducts = () => {
const { id } = useParams()
const { collection, isLoading, isError, error } = useAdminCollection(id!)
const [open, onOpenChange, subscribe] = useRouteModalState()
const handleSuccessfulSubmit = () => {
onOpenChange(false, true)
}
if (isError) {
throw error
}
return (
<FocusModal open={open} onOpenChange={onOpenChange}>
<FocusModal.Content>
{!isLoading && collection && (
<AddProductsToCollectionForm
collection={collection}
onSuccessfulSubmit={handleSuccessfulSubmit}
subscribe={subscribe}
/>
)}
</FocusModal.Content>
</FocusModal>
)
}

View File

@@ -0,0 +1,400 @@
import { zodResolver } from "@hookform/resolvers/zod"
import type { Product, ProductCollection } from "@medusajs/medusa"
import {
Button,
Checkbox,
FocusModal,
Hint,
Table,
Tooltip,
clx,
} from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import {
adminProductKeys,
useAdminAddProductsToCollection,
useAdminProducts,
} from "medusa-react"
import { Fragment, useEffect, useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import {
NoRecords,
NoResults,
} from "../../../../../components/common/empty-table-content"
import { Form } from "../../../../../components/common/form"
import {
ProductAvailabilityCell,
ProductCollectionCell,
ProductStatusCell,
ProductTitleCell,
ProductVariantCell,
} from "../../../../../components/common/product-table-cells"
import { OrderBy } from "../../../../../components/filtering/order-by"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useHandleTableScroll } from "../../../../../hooks/use-handle-table-scroll"
import { useQueryParams } from "../../../../../hooks/use-query-params"
import { queryClient } from "../../../../../lib/medusa"
type AddProductsToCollectionFormProps = {
collection: ProductCollection
subscribe: (state: boolean) => void
onSuccessfulSubmit: () => void
}
const AddProductsToSalesChannelSchema = zod.object({
product_ids: zod.array(zod.string()).min(1),
})
const PAGE_SIZE = 50
export const AddProductsToCollectionForm = ({
collection,
subscribe,
onSuccessfulSubmit,
}: AddProductsToCollectionFormProps) => {
const { t } = useTranslation()
const form = useForm<zod.infer<typeof AddProductsToSalesChannelSchema>>({
defaultValues: {
product_ids: [],
},
resolver: zodResolver(AddProductsToSalesChannelSchema),
})
const {
formState: { isDirty },
} = form
useEffect(() => {
subscribe(isDirty)
}, [isDirty])
const { mutateAsync, isLoading: isMutating } =
useAdminAddProductsToCollection(collection.id)
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
useEffect(() => {
form.setValue(
"product_ids",
Object.keys(rowSelection).filter((k) => rowSelection[k])
)
}, [rowSelection])
const params = useQueryParams(["q", "order"])
const { products, count, isLoading, isError, error } = useAdminProducts(
{
expand: "variants,sales_channels",
...params,
},
{
keepPreviousData: true,
}
)
const columns = useColumns()
const table = useReactTable({
data: (products ?? []) as Product[],
columns,
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
state: {
pagination,
rowSelection,
},
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
getRowId: (row) => row.id,
enableRowSelection(row) {
return row.original.collection_id !== collection.id
},
meta: {
collectionId: collection.id,
},
})
const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(
{
product_ids: values.product_ids.map((p) => p),
},
{
onSuccess: () => {
/**
* Invalidate the products list query to refetch products and
* determine if they are added to the collection or not.
*/
queryClient.invalidateQueries(adminProductKeys.lists())
onSuccessfulSubmit()
},
}
)
})
const { handleScroll, isScrolled, tableContainerRef } = useHandleTableScroll()
const noRecords =
!isLoading &&
products?.length === 0 &&
!Object.values(params).filter((v) => v).length
if (isError) {
throw error
}
return (
<Form {...form}>
<form
onSubmit={handleSubmit}
className="flex h-full flex-col overflow-hidden"
>
<FocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
{form.formState.errors.product_ids && (
<Hint variant="error">
{form.formState.errors.product_ids.message}
</Hint>
)}
<FocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
</Button>
</FocusModal.Close>
<Button size="small" type="submit" isLoading={isMutating}>
{t("general.save")}
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex h-full w-full flex-col items-center overflow-y-auto divide-y">
{!noRecords && (
<div className="flex items-center justify-between w-full px-6 py-4">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
<OrderBy keys={["title"]} />
</div>
</div>
)}
{!noRecords ? (
<Fragment>
<div
ref={tableContainerRef}
className="w-full flex-1 overflow-y-auto"
onScroll={handleScroll}
>
{!isLoading && !products?.length ? (
<div className="flex-1 flex items-center justify-center h-full">
<NoResults />
</div>
) : (
<Table>
<Table.Header
className={clx(
"bg-ui-bg-base transition-fg sticky inset-x-0 top-0 z-10 border-t-0",
{
"shadow-elevation-card-hover": isScrolled,
}
)}
>
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th]:w-1/5"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
},
{
"bg-ui-bg-disabled hover:bg-ui-bg-disabled":
row.original.collection_id === collection.id,
}
)}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
)}
</div>
<div className="w-full border-t">
<LocalizedTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
</div>
</Fragment>
) : (
<div className="flex-1 flex items-center justify-center">
<NoRecords />
{/* TODO: fix this, and add NoRecords as well */}
</div>
)}
</FocusModal.Body>
</form>
</Form>
)
}
const columnHelper = createColumnHelper<Product>()
const useColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row, table }) => {
const { collectionId } = table.options.meta as {
collectionId: string
}
const isAdded = row.original.collection_id === collectionId
const isSelected = row.getIsSelected() || isAdded
const Component = (
<Checkbox
checked={isSelected}
disabled={isAdded}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
if (isAdded) {
return (
<Tooltip
content={t("salesChannels.productAlreadyAdded")}
side="right"
>
{Component}
</Tooltip>
)
}
return Component
},
}),
columnHelper.accessor("title", {
header: t("fields.title"),
cell: ({ row }) => {
const product = row.original
return <ProductTitleCell product={product} />
},
}),
columnHelper.accessor("collection", {
header: t("fields.collection"),
cell: ({ getValue }) => {
const collection = getValue()
return <ProductCollectionCell collection={collection} />
},
}),
columnHelper.accessor("sales_channels", {
header: t("fields.availability"),
cell: ({ getValue }) => {
const salesChannels = getValue()
return <ProductAvailabilityCell salesChannels={salesChannels} />
},
}),
columnHelper.accessor("variants", {
header: t("fields.inventory"),
cell: (cell) => {
const variants = cell.getValue()
return <ProductVariantCell variants={variants} />
},
}),
columnHelper.accessor("status", {
header: t("fields.status"),
cell: ({ getValue }) => {
const status = getValue()
return <ProductStatusCell status={status} />
},
}),
],
[t]
)
}

View File

@@ -0,0 +1 @@
export * from "./add-products-to-collection-form"

View File

@@ -0,0 +1 @@
export { CollectionAddProducts as Component } from "./collection-add-products"

View File

@@ -0,0 +1,15 @@
import { FocusModal } from "@medusajs/ui"
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
import { CreateCollectionForm } from "./components/create-collection-form"
export const CollectionCreate = () => {
const [open, onOpenChange, subscribe] = useRouteModalState()
return (
<FocusModal open={open} onOpenChange={onOpenChange}>
<FocusModal.Content>
<CreateCollectionForm subscribe={subscribe} />
</FocusModal.Content>
</FocusModal>
)
}

View File

@@ -0,0 +1,138 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, FocusModal, Heading, Input, Text } from "@medusajs/ui"
import { useAdminCreateCollection } from "medusa-react"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
type CreateCollectionFormProps = {
subscribe: (state: boolean) => void
}
const CreateCollectionSchema = zod.object({
title: zod.string().min(1),
handle: zod.string().optional(),
})
export const CreateCollectionForm = ({
subscribe,
}: CreateCollectionFormProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
const form = useForm<zod.infer<typeof CreateCollectionSchema>>({
defaultValues: {
title: "",
handle: "",
},
resolver: zodResolver(CreateCollectionSchema),
})
const {
formState: { isDirty },
} = form
useEffect(() => {
subscribe(isDirty)
}, [isDirty])
const { mutateAsync, isLoading } = useAdminCreateCollection()
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(data, {
onSuccess: ({ collection }) => {
navigate(`/collections/${collection.id}`)
},
})
})
return (
<Form {...form}>
<form onSubmit={handleSubmit}>
<FocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<FocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
</Button>
</FocusModal.Close>
<Button
size="small"
variant="primary"
type="submit"
isLoading={isLoading}
>
{t("general.create")}
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex flex-col items-center py-16">
<div className="w-full max-w-[720px] flex flex-col gap-y-8">
<div>
<Heading>{t("collections.createCollection")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("collections.createCollectionHint")}
</Text>
</div>
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="title"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.title")}</Form.Label>
<Form.Control>
<Input autoComplete="off" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="handle"
render={({ field }) => {
return (
<Form.Item>
<Form.Label
optional
tooltip={t("collections.handleTooltip")}
>
{t("fields.handle")}
</Form.Label>
<Form.Control>
<div className="relative">
<div className="absolute left-0 inset-y-0 w-8 border-r z-10 flex items-center justify-center">
<Text
className="text-ui-fg-muted"
size="small"
leading="compact"
weight="plus"
>
/
</Text>
</div>
<Input
autoComplete="off"
{...field}
className="pl-10"
/>
</div>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</FocusModal.Body>
</form>
</Form>
)
}

View File

@@ -0,0 +1 @@
export * from "./create-collection-form"

View File

@@ -0,0 +1 @@
export { CollectionCreate as Component } from "./collection-create"

View File

@@ -0,0 +1,38 @@
import { useAdminCollection } from "medusa-react"
import { Outlet, json, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { CollectionGeneralSection } from "./components/collection-general-section"
import { CollectionProductSection } from "./components/collection-product-section"
import { collectionLoader } from "./loader"
export const CollectionDetail = () => {
const initialData = useLoaderData() as Awaited<
ReturnType<typeof collectionLoader>
>
const { id } = useParams()
const { collection, isLoading, isError, error } = useAdminCollection(id!, {
initialData,
})
if (isLoading) {
return <div>Loading...</div>
}
if (isError || !collection) {
if (error) {
throw error
}
throw json("An unknown error occurred", 500)
}
return (
<div className="flex flex-col gap-y-2">
<CollectionGeneralSection collection={collection} />
<CollectionProductSection collection={collection} />
<JsonViewSection data={collection} />
<Outlet />
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import type { ProductCollection } from "@medusajs/medusa"
import { Container, Heading, Text, usePrompt } from "@medusajs/ui"
import { useAdminDeleteCollection } from "medusa-react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
type CollectionGeneralSectionProps = {
collection: ProductCollection
}
export const CollectionGeneralSection = ({
collection,
}: CollectionGeneralSectionProps) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteCollection(collection.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("collections.deleteWarning", {
count: 1,
}),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<Container className="p-0 divide-y">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{collection.title}</Heading>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/collections/${collection.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("general.delete"),
onClick: handleDelete,
},
],
},
]}
/>
</div>
<div className="grid grid-cols-2 px-6 py-4 items-center">
<Text size="small" leading="compact" weight="plus">
{t("fields.handle")}
</Text>
<Text size="small">/{collection.handle}</Text>
</div>
<div className="grid grid-cols-2 px-6 py-4 items-center">
<Text size="small" leading="compact" weight="plus">
{t("fields.products")}
</Text>
<Text size="small">{collection.products?.length || "-"}</Text>
</div>
</Container>
)
}

View File

@@ -0,0 +1 @@
export * from "./collection-general-section"

View File

@@ -0,0 +1,402 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import type { Product, ProductCollection } from "@medusajs/medusa"
import {
Button,
Checkbox,
CommandBar,
Container,
Heading,
Table,
clx,
usePrompt,
} from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import {
adminProductKeys,
useAdminProducts,
useAdminRemoveProductsFromCollection,
} from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import {
NoRecords,
NoResults,
} from "../../../../../components/common/empty-table-content"
import {
ProductAvailabilityCell,
ProductCollectionCell,
ProductStatusCell,
ProductTitleCell,
ProductVariantCell,
} from "../../../../../components/common/product-table-cells"
import { OrderBy } from "../../../../../components/filtering/order-by"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useQueryParams } from "../../../../../hooks/use-query-params"
import { queryClient } from "../../../../../lib/medusa"
type CollectionProductSectionProps = {
collection: ProductCollection
}
const PAGE_SIZE = 10
export const CollectionProductSection = ({
collection,
}: CollectionProductSectionProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const params = useQueryParams(["q", "order"])
const { products, count, isLoading, isError, error } = useAdminProducts(
{
limit: PAGE_SIZE,
offset: pageIndex * PAGE_SIZE,
collection_id: [collection.id],
...params,
},
{
keepPreviousData: true,
}
)
const columns = useColumns()
const table = useReactTable({
data: (products ?? []) as Product[],
columns,
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
state: {
pagination,
rowSelection,
},
getRowId: (row) => row.id,
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
meta: {
collectionId: collection.id,
},
})
const prompt = usePrompt()
const { mutateAsync } = useAdminRemoveProductsFromCollection(collection.id)
const handleRemove = async () => {
const ids = Object.keys(rowSelection)
const res = await prompt({
title: t("general.areYouSure"),
description: t("collections.removeProductsWarning", {
count: ids.length,
}),
confirmText: t("general.confirm"),
cancelText: t("general.cancel"),
})
if (!res) {
return
}
await mutateAsync(
{
product_ids: ids,
},
{
onSuccess: () => {
queryClient.invalidateQueries(adminProductKeys.lists())
setRowSelection({})
},
}
)
}
const noRecords =
!isLoading &&
products?.length === 0 &&
!Object.values(params).filter((v) => v).length
if (isError) {
throw error
}
return (
<Container className="p-0 divide-y">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("products.domain")}</Heading>
<Link to={`/collections/${collection.id}/add-products`}>
<Button size="small" variant="secondary">
{t("general.add")}
</Button>
</Link>
</div>
{!noRecords && (
<div className="flex items-center justify-between px-6 py-4">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
<OrderBy keys={["title", "status", "created_at", "updated_at"]} />
</div>
</div>
)}
{noRecords ? (
<NoRecords />
) : (
<div>
{!isLoading && !products?.length ? (
<div className="border-b">
<NoResults />
</div>
) : (
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th]:w-1/5"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap [&_td:first-of-type]:w-[1%] [&_td:first-of-type]:whitespace-nowrap",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
onClick={() => navigate(`/products/${row.original.id}`)}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
)}
<LocalizedTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
<CommandBar open={!!Object.keys(rowSelection).length}>
<CommandBar.Bar>
<CommandBar.Value>
{t("general.countSelected", {
count: Object.keys(rowSelection).length,
})}
</CommandBar.Value>
<CommandBar.Seperator />
<CommandBar.Command
action={handleRemove}
shortcut="r"
label={t("general.remove")}
/>
</CommandBar.Bar>
</CommandBar>
</div>
)}
</Container>
)
}
const ProductActions = ({
product,
collectionId,
}: {
product: Product
collectionId: string
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminRemoveProductsFromCollection(collectionId)
const handleRemove = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("collections.removeSingleProductWarning", {
title: product.title,
}),
confirmText: t("general.confirm"),
cancelText: t("general.cancel"),
})
if (!res) {
return
}
await mutateAsync({
product_ids: [product.id],
})
}
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/products/${product.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("general.remove"),
onClick: handleRemove,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<Product>()
const useColumns = () => {
const { t } = useTranslation()
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()
}}
/>
)
},
}),
columnHelper.accessor("title", {
header: t("fields.title"),
cell: ({ row }) => {
return <ProductTitleCell product={row.original} />
},
}),
columnHelper.accessor("collection", {
header: t("fields.collection"),
cell: (cell) => {
const collection = cell.getValue()
return <ProductCollectionCell collection={collection} />
},
}),
columnHelper.accessor("sales_channels", {
header: t("fields.availability"),
cell: (cell) => {
const salesChannels = cell.getValue()
return <ProductAvailabilityCell salesChannels={salesChannels ?? []} />
},
}),
columnHelper.accessor("variants", {
header: t("fields.variants"),
cell: (cell) => {
const variants = cell.getValue()
return <ProductVariantCell variants={variants} />
},
}),
columnHelper.accessor("status", {
header: t("fields.status"),
cell: (cell) => {
const value = cell.getValue()
return <ProductStatusCell status={value} />
},
}),
columnHelper.display({
id: "actions",
cell: ({ row, table }) => {
const { collectionId } = table.options.meta as {
collectionId: string
}
return (
<ProductActions
product={row.original}
collectionId={collectionId}
/>
)
},
}),
],
[t]
)
}

View File

@@ -0,0 +1 @@
export * from "./collection-product-section"

View File

@@ -0,0 +1,2 @@
export { CollectionDetail as Component } from "./collection-detail"
export { collectionLoader as loader } from "./loader"

View File

@@ -0,0 +1,21 @@
import { AdminCollectionsRes } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { adminProductKeys } from "medusa-react"
import { LoaderFunctionArgs } from "react-router-dom"
import { medusa, queryClient } from "../../../lib/medusa"
const collectionDetailQuery = (id: string) => ({
queryKey: adminProductKeys.detail(id),
queryFn: async () => medusa.admin.collections.retrieve(id),
})
export const collectionLoader = async ({ params }: LoaderFunctionArgs) => {
const id = params.id
const query = collectionDetailQuery(id!)
return (
queryClient.getQueryData<Response<AdminCollectionsRes>>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}

View File

@@ -0,0 +1,39 @@
import { Drawer, Heading } from "@medusajs/ui"
import { useAdminCollection } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
import { EditCollectionForm } from "./components/edit-collection-form"
export const CollectionEdit = () => {
const { id } = useParams()
const { t } = useTranslation()
const { collection, isLoading, isError, error } = useAdminCollection(id!)
const [open, onOpenChange, subscribe] = useRouteModalState()
const handleSuccessfulSubmit = () => {
onOpenChange(false, true)
}
if (isError) {
throw error
}
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<Drawer.Content>
<Drawer.Header>
<Heading>{t("collections.editCollection")}</Heading>
</Drawer.Header>
{!isLoading && collection && (
<EditCollectionForm
collection={collection}
onSuccessfulSubmit={handleSuccessfulSubmit}
subscribe={subscribe}
/>
)}
</Drawer.Content>
</Drawer>
)
}

View File

@@ -0,0 +1,121 @@
import { zodResolver } from "@hookform/resolvers/zod"
import type { ProductCollection } from "@medusajs/medusa"
import { Button, Drawer, Input, Text } from "@medusajs/ui"
import { useAdminUpdateCollection } from "medusa-react"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
type EditCollectionFormProps = {
collection: ProductCollection
subscribe: (state: boolean) => void
onSuccessfulSubmit: () => void
}
const EditCollectionSchema = zod.object({
title: zod.string().min(1),
handle: zod.string().min(1),
})
export const EditCollectionForm = ({
collection,
onSuccessfulSubmit,
subscribe,
}: EditCollectionFormProps) => {
const { t } = useTranslation()
const form = useForm<zod.infer<typeof EditCollectionSchema>>({
defaultValues: {
title: collection.title,
handle: collection.handle,
},
resolver: zodResolver(EditCollectionSchema),
})
const {
formState: { isDirty },
} = form
useEffect(() => {
subscribe(isDirty)
}, [isDirty])
const { mutateAsync, isLoading } = useAdminUpdateCollection(collection.id)
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(data, {
onSuccess: () => {
onSuccessfulSubmit()
},
})
})
return (
<Form {...form}>
<form onSubmit={handleSubmit} className="flex flex-1 flex-col">
<Drawer.Body>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="title"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.title")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="handle"
render={({ field }) => {
return (
<Form.Item>
<Form.Label tooltip={t("collections.handleTooltip")}>
{t("fields.handle")}
</Form.Label>
<Form.Control>
<div className="relative">
<div className="absolute left-0 inset-y-0 w-8 border-r z-10 flex items-center justify-center">
<Text
className="text-ui-fg-muted"
size="small"
leading="compact"
weight="plus"
>
/
</Text>
</div>
<Input {...field} className="pl-10" />
</div>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</Drawer.Body>
<Drawer.Footer>
<div className="flex items-center gap-x-2">
<Drawer.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
</Button>
</Drawer.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("general.save")}
</Button>
</div>
</Drawer.Footer>
</form>
</Form>
)
}

View File

@@ -0,0 +1 @@
export * from "./edit-collection-form"

View File

@@ -0,0 +1 @@
export { CollectionEdit as Component } from "./collection-add-edit"

View File

@@ -0,0 +1,11 @@
import { Outlet } from "react-router-dom"
import { CollectionListTable } from "./components/collection-list-table"
export const CollectionList = () => {
return (
<div className="flex flex-col gap-y-2">
<CollectionListTable />
<Outlet />
</div>
)
}

View File

@@ -0,0 +1,239 @@
import { ProductCollection } from "@medusajs/medusa"
import { Button, Container, Heading, Table, clx, usePrompt } from "@medusajs/ui"
import {
PaginationState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { useAdminCollections, useAdminDeleteCollection } from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { PencilSquare, Trash } from "@medusajs/icons"
import { ActionMenu } from "../../../../../components/common/action-menu"
import {
NoRecords,
NoResults,
} from "../../../../../components/common/empty-table-content"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useQueryParams } from "../../../../../hooks/use-query-params"
const PAGE_SIZE = 50
export const CollectionListTable = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const params = useQueryParams(["q"])
const { collections, count, isError, error, isLoading } = useAdminCollections(
{
limit: PAGE_SIZE,
offset: pageIndex * PAGE_SIZE,
...params,
},
{
keepPreviousData: true,
}
)
const columns = useColumns()
const table = useReactTable({
data: collections ?? [],
columns,
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
state: {
pagination,
},
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
})
const noRecords =
!isLoading &&
(!collections || collections.length === 0) &&
!Object.values(params).filter(Boolean).length
if (isError) {
throw error
}
return (
<Container className="p-0 divide-y">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("collections.domain")}</Heading>
<Link to="/collections/create">
<Button size="small" variant="secondary">
{t("general.create")}
</Button>
</Link>
</div>
{!noRecords && (
<div className="px-6 py-4 flex items-center justify-between">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
</div>
</div>
)}
{noRecords ? (
<NoRecords />
) : (
<div>
{!isLoading && !collections?.length ? (
<div className="border-b">
<NoResults />
</div>
) : (
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className=" [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th]:w-1/3"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
onClick={() => navigate(`/collections/${row.original.id}`)}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
)}
<LocalizedTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
</div>
)}
</Container>
)
}
const CollectionActions = ({
collection,
}: {
collection: ProductCollection
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteCollection(collection.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("collections.deleteWarning", {
count: 1,
}),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/collections/${collection.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("general.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<ProductCollection>()
const useColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("title", {
header: t("fields.title"),
cell: ({ getValue }) => getValue(),
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => <CollectionActions collection={row.original} />,
}),
],
[t]
)
}

View File

@@ -0,0 +1 @@
export * from "./collection-list-table"

View File

@@ -0,0 +1 @@
export { CollectionList as Component } from "./collection-list"

View File

@@ -1,11 +0,0 @@
import { Container, Heading } from "@medusajs/ui";
export const CollectionDetails = () => {
return (
<div>
<Container>
<Heading>Collection</Heading>
</Container>
</div>
);
};

View File

@@ -1 +0,0 @@
export { CollectionDetails as Component } from "./details";

View File

@@ -1 +0,0 @@
export { CollectionsList as Component } from "./list";

View File

@@ -1,11 +0,0 @@
import { Container, Heading } from "@medusajs/ui";
export const CollectionsList = () => {
return (
<div>
<Container>
<Heading>Collections</Heading>
</Container>
</div>
);
};

View File

@@ -26,11 +26,11 @@ import {
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import {
NoRecords,
NoResults,
} from "../../../../../components/common/empty-table-content"
import { TableRowActions } from "../../../../../components/common/table-row-actions"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useQueryParams } from "../../../../../hooks/use-query-params"
@@ -279,7 +279,7 @@ const CustomerActions = ({
}
return (
<TableRowActions
<ActionMenu
groups={[
{
actions: [

View File

@@ -1,9 +1,10 @@
import { EllipsisHorizontal, PencilSquare, Trash } from "@medusajs/icons"
import { PencilSquare, Trash } from "@medusajs/icons"
import type { CustomerGroup } from "@medusajs/medusa"
import { Container, DropdownMenu, Heading, IconButton } from "@medusajs/ui"
import { Container, Heading } from "@medusajs/ui"
import { useAdminDeleteCustomerGroup } from "medusa-react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
type CustomerGroupGeneralSectionProps = {
group: CustomerGroup
@@ -28,29 +29,28 @@ export const CustomerGroupGeneralSection = ({
return (
<Container className="px-6 py-4 flex items-center justify-between">
<Heading>{group.name}</Heading>
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton size="small" variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<Link to={`/customer-groups/${group.id}/edit`}>
<DropdownMenu.Item className="flex items-center gap-x-2">
<PencilSquare className="text-ui-fg-subtle" />
<span>{t("general.edit")}</span>
</DropdownMenu.Item>
</Link>
<DropdownMenu.Separator />
<DropdownMenu.Item
className="flex items-center gap-x-2"
onClick={handleDelete}
>
<Trash className="text-ui-fg-subtle" />
<span>{t("general.delete")}</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/customer-groups/${group.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("general.delete"),
onClick: handleDelete,
},
],
},
]}
/>
</Container>
)
}

View File

@@ -16,11 +16,11 @@ import {
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import {
NoRecords,
NoResults,
} from "../../../../../components/common/empty-table-content"
import { TableRowActions } from "../../../../../components/common/table-row-actions"
import { OrderBy } from "../../../../../components/filtering/order-by"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
@@ -205,7 +205,7 @@ const CustomerGroupActions = ({ group }: { group: CustomerGroup }) => {
}
return (
<TableRowActions
<ActionMenu
groups={[
{
actions: [

View File

@@ -93,8 +93,8 @@ export const CreateCustomerForm = ({ subscribe }: CreateCustomerFormProps) => {
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex flex-col items-center pt-[72px]">
<div className="w-full max-w-[720px] flex flex-col gap-y-10">
<FocusModal.Body className="flex flex-col items-center py-16">
<div className="w-full max-w-[720px] flex flex-col gap-y-8">
<div>
<Heading>{t("customers.createCustomer")}</Heading>
<Text size="small" className="text-ui-fg-subtle">

View File

@@ -1,14 +1,6 @@
import { EllipsisHorizontal, ReceiptPercent } from "@medusajs/icons"
import { ReceiptPercent } from "@medusajs/icons"
import { Customer, Order } from "@medusajs/medusa"
import {
Button,
Container,
DropdownMenu,
Heading,
IconButton,
Table,
clx,
} from "@medusajs/ui"
import { Button, Container, Heading, Table, clx } from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
@@ -20,7 +12,8 @@ import {
import { useAdminOrders } from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { NoRecords } from "../../../../../components/common/empty-table-content"
import {
OrderDateCell,
@@ -184,22 +177,22 @@ export const CustomerOrderSection = ({
}
const OrderActions = ({ order }: { order: Order }) => {
const { t } = useTranslation()
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton size="small" variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<Link to={`/orders/${order.id}`}>
<DropdownMenu.Item className="flex items-center gap-x-2">
<ReceiptPercent />
<span>Go to order</span>
</DropdownMenu.Item>
</Link>
</DropdownMenu.Content>
</DropdownMenu>
<ActionMenu
groups={[
{
actions: [
{
icon: <ReceiptPercent />,
label: t("customers.viewOrder"),
to: `/orders/${order.id}/edit`,
},
],
},
]}
/>
)
}

View File

@@ -1,18 +1,15 @@
import { EllipsisHorizontal, PencilSquare } from "@medusajs/icons"
import { PencilSquare } from "@medusajs/icons"
import { Customer } from "@medusajs/medusa"
import {
Button,
Container,
DropdownMenu,
Heading,
IconButton,
StatusBadge,
Table,
clx,
} from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
createColumnHelper,
flexRender,
getCoreRowModel,
@@ -22,6 +19,8 @@ import { useAdminCustomers } from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { NoRecords } from "../../../../../components/common/empty-table-content"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useQueryParams } from "../../../../../hooks/use-query-params"
@@ -45,13 +44,11 @@ export const CustomerListTable = () => {
[pageIndex, pageSize]
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const { q } = useQueryParams(["q"])
const params = useQueryParams(["q"])
const { customers, count, isLoading, isError, error } = useAdminCustomers({
q,
limit: PAGE_SIZE,
offset: pageIndex * PAGE_SIZE,
...params,
})
const columns = useColumns()
@@ -62,14 +59,17 @@ export const CustomerListTable = () => {
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
state: {
pagination,
rowSelection,
},
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
})
const noRecords =
!isLoading &&
(!customers || customers.length === 0) &&
!Object.values(params).filter(Boolean).length
if (isError) {
throw error
}
@@ -90,62 +90,69 @@ export const CustomerListTable = () => {
<Query />
</div>
</div>
<div>
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
{noRecords ? (
<NoRecords />
) : (
<div>
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className=" [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th]:w-1/3"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={headerGroup.id}
className=" [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th]:w-1/3"
key={row.id}
className={clx(
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
onClick={() => navigate(`/customers/${row.original.id}`)}
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
onClick={() => navigate(`/customers/${row.original.id}`)}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
<LocalizedTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
</div>
))}
</Table.Body>
</Table>
<LocalizedTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
</div>
)}
</Container>
)
}
@@ -154,24 +161,19 @@ const CustomerActions = ({ customer }: { customer: Customer }) => {
const { t } = useTranslation()
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton size="small" variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<Link to={`/customers/${customer.id}/edit`}>
<DropdownMenu.Item
className="flex items-center gap-x-2"
onClick={(e) => e.stopPropagation()}
>
<PencilSquare className="text-ui-fg-subtle" />
{t("general.edit")}
</DropdownMenu.Item>
</Link>
</DropdownMenu.Content>
</DropdownMenu>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/customers/${customer.id}/edit`,
},
],
},
]}
/>
)
}

View File

@@ -75,7 +75,7 @@ export const CreateLocationForm = () => {
</FocusModal.Header>
<FocusModal.Body className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col items-center overflow-y-auto">
<div className="flex w-full max-w-[720px] flex-col gap-y-10 px-2 pb-6 pt-[72px]">
<div className="flex w-full max-w-[720px] flex-col gap-y-8 px-2 py-16">
<div>
<Heading className="capitalize">
{t("locations.createLocation")}

View File

@@ -1,15 +1,14 @@
import { EllipsisHorizontal, PencilSquare, Trash } from "@medusajs/icons"
import { PencilSquare, Trash } from "@medusajs/icons"
import { SalesChannel } from "@medusajs/medusa"
import { StockLocationExpandedDTO } from "@medusajs/types"
import {
Button,
Container,
DropdownMenu,
Heading,
IconButton,
StatusBadge,
Table,
clx,
usePrompt,
} from "@medusajs/ui"
import {
PaginationState,
@@ -19,9 +18,11 @@ import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { useAdminRemoveLocationFromSalesChannel } from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { NoRecords } from "../../../../../components/common/empty-table-content/empty-table-content"
type LocationSalesChannelSectionProps = {
@@ -67,6 +68,9 @@ export const LocationSalesChannelSection = ({
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
meta: {
locationId: location.id,
},
})
return (
@@ -143,28 +147,55 @@ export const LocationSalesChannelSection = ({
)
}
const SalesChannelActions = ({ id }: { id: string }) => {
const SalesChannelActions = ({
salesChannel,
locationId,
}: {
salesChannel: SalesChannel
locationId: string
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminRemoveLocationFromSalesChannel()
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("locations.removeSalesChannelsWarning", { count: 1 }),
confirmText: t("general.delete"),
cancelText: t("general.cancel"),
})
if (!res) {
return
}
await mutateAsync({
location_id: locationId,
sales_channel_id: salesChannel.id,
})
}
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item className="gap-x-2">
<PencilSquare className="text-ui-fg-subtle" />
<span>{t("general.edit")}</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item className="gap-x-2">
<Trash className="text-ui-fg-subtle" />
<span>{t("general.delete")}</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/settings/sales-channels/${salesChannel.id}/edit`,
},
{
icon: <Trash />,
label: t("general.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}
@@ -198,8 +229,17 @@ const useColumns = () => {
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => {
return <SalesChannelActions id={row.original.id} />
cell: ({ row, table }) => {
const { locationId } = table.options.meta as {
locationId: string
}
return (
<SalesChannelActions
salesChannel={row.original}
locationId={locationId}
/>
)
},
}),
],

View File

@@ -61,7 +61,7 @@ export const EditLocationForm = ({ location }: EditLocationFormProps) => {
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<Drawer.Body className="flex flex-col gap-y-10 overflow-y-auto">
<Drawer.Body className="flex flex-col gap-y-8 overflow-y-auto">
<div>
<Form.Field
control={form.control}

View File

@@ -1,15 +1,6 @@
import { EllipsisHorizontal, PencilSquare, Trash } from "@medusajs/icons"
import { PencilSquare, Trash } from "@medusajs/icons"
import { StockLocationExpandedDTO } from "@medusajs/types"
import {
Button,
Container,
DropdownMenu,
Heading,
IconButton,
Table,
clx,
usePrompt,
} from "@medusajs/ui"
import { Button, Container, Heading, Table, clx, usePrompt } from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
@@ -22,10 +13,11 @@ import {
useAdminDeleteStockLocation,
useAdminStockLocations,
} from "medusa-react"
import { MouseEvent, useMemo, useState } from "react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate, useSearchParams } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import {
NoRecords,
NoResults,
@@ -176,9 +168,7 @@ const LocationActions = ({
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteStockLocation(location.id)
const handleDelete = async (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("locations.deleteLocationWarning", {
@@ -198,30 +188,24 @@ const LocationActions = ({
}
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<Link to={`/settings/locations/${location.id}`}>
<DropdownMenu.Item onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-x-2">
<PencilSquare className="text-ui-fg-subtle" />
<span>{t("general.edit")}</span>
</div>
</DropdownMenu.Item>
</Link>
<DropdownMenu.Separator />
<DropdownMenu.Item onClick={handleDelete}>
<div className="flex items-center gap-x-2">
<Trash className="text-ui-fg-subtle" />
<span>{t("general.delete")}</span>
</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/settings/locations/${location.id}/edit`,
},
{
icon: <Trash />,
label: t("general.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}

View File

@@ -1,13 +1,11 @@
import { EllipsisHorizontal, Trash } from "@medusajs/icons"
import { PencilSquare, Trash } from "@medusajs/icons"
import type { Product } from "@medusajs/medusa"
import {
Button,
Checkbox,
CommandBar,
Container,
DropdownMenu,
Heading,
IconButton,
Table,
clx,
} from "@medusajs/ui"
@@ -32,6 +30,7 @@ import {
ProductVariantCell,
} from "../../../../../components/common/product-table-cells"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { productsLoader } from "../../loader"
@@ -172,6 +171,7 @@ export const ProductListTable = () => {
}
const ProductActions = ({ id }: { id: string }) => {
const { t } = useTranslation()
const { mutateAsync } = useAdminDeleteProduct(id)
const handleDelete = async () => {
@@ -179,21 +179,28 @@ const ProductActions = ({ id }: { id: string }) => {
}
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton size="small" variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onClick={handleDelete}>
<div className="flex items-center gap-x-2">
<Trash className="text-ui-fg-subtle" />
<span>Delete</span>
</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/products/${id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("general.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}

View File

@@ -0,0 +1,205 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, FocusModal, Heading, Input, Switch, Text } from "@medusajs/ui"
import { useAdminCreateRegion } from "medusa-react"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
type CreateRegionFormProps = {
subscribe: (state: boolean) => void
}
const CreateRegionSchema = zod.object({
name: zod.string().min(1),
currency_code: zod.string(),
includes_tax: zod.boolean(),
countries: zod.array(zod.string()),
fulfillment_providers: zod.array(zod.string()).min(1),
payment_providers: zod.array(zod.string()).min(1),
tax_rate: zod.number().min(0).max(1),
tax_code: zod.string().optional(),
})
export const CreateRegionForm = ({ subscribe }: CreateRegionFormProps) => {
const form = useForm<zod.infer<typeof CreateRegionSchema>>({
defaultValues: {
name: "",
currency_code: "",
includes_tax: false,
countries: [],
fulfillment_providers: [],
payment_providers: [],
tax_code: "",
},
resolver: zodResolver(CreateRegionSchema),
})
const {
formState: { isDirty },
} = form
useEffect(() => {
subscribe(isDirty)
}, [isDirty])
const { t } = useTranslation()
const navigate = useNavigate()
const { mutateAsync, isLoading } = useAdminCreateRegion()
const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(
{
name: values.name,
countries: values.countries,
currency_code: values.currency_code,
fulfillment_providers: values.fulfillment_providers,
payment_providers: values.payment_providers,
tax_rate: values.tax_rate,
tax_code: values.tax_code,
includes_tax: values.includes_tax,
},
{
onSuccess: ({ region }) => {
navigate(`../${region.id}`)
},
}
)
})
return (
<Form {...form}>
<form
className="flex h-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<FocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<FocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
</Button>
</FocusModal.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("general.save")}
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex h-full w-full flex-col items-center overflow-y-auto py-16">
<div className="w-full max-w-[720px] flex flex-col gap-y-8">
<div>
<Heading>{t("regions.createRegion")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("regions.createRegionHint")}
</Text>
</div>
<div className="flex flex-col gap-y-4">
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="currency_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.currency")}</Form.Label>
<Form.Control></Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="tax_rate"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.taxRate")}</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="tax_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.taxCode")}</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
<Form.Field
control={form.control}
name="includes_tax"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div>
<div className="flex items-start justify-between">
<Form.Label>
{t("fields.taxInclusivePricing")}
</Form.Label>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
<Form.Hint>{t("regions.taxInclusiveHint")}</Form.Hint>
<Form.ErrorMessage />
</div>
</Form.Item>
)
}}
/>
<div className="flex flex-col gap-y-4">
<div>
<Text size="small" leading="compact" weight="plus">
{t("fields.providers")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("regions.providersHint")}
</Text>
</div>
<div className="grid grid-cols-2 gap-4"></div>
</div>
</div>
</FocusModal.Body>
</form>
</Form>
)
}

View File

@@ -0,0 +1 @@
export * from "./create-region-form"

View File

@@ -1,200 +1,15 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, FocusModal, Heading, Input, Switch, Text } from "@medusajs/ui"
import { useAdminCreateRegion } from "medusa-react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { FocusModal } from "@medusajs/ui"
import { useNavigate } from "react-router-dom"
import { Form } from "../../../components/common/form"
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
const CreateRegionFormSchema = zod.object({
name: zod.string().min(1),
currency_code: zod.string(),
includes_tax: zod.boolean(),
countries: zod.array(zod.string()),
fulfillment_providers: zod.array(zod.string()).min(1),
payment_providers: zod.array(zod.string()).min(1),
tax_rate: zod.number().min(0).max(100),
tax_code: zod.string().optional(),
})
import { CreateRegionForm } from "./components/create-region-form"
export const RegionCreate = () => {
const [open, onOpenChange] = useRouteModalState()
const navigate = useNavigate()
const { t } = useTranslation()
const form = useForm<zod.infer<typeof CreateRegionFormSchema>>({
defaultValues: {
name: "",
currency_code: "",
includes_tax: false,
countries: [],
fulfillment_providers: [],
payment_providers: [],
tax_code: "",
},
resolver: zodResolver(CreateRegionFormSchema),
})
const { mutateAsync, isLoading } = useAdminCreateRegion()
const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(
{
name: values.name,
countries: values.countries,
currency_code: values.currency_code,
fulfillment_providers: values.fulfillment_providers,
payment_providers: values.payment_providers,
tax_rate: values.tax_rate,
tax_code: values.tax_code,
includes_tax: values.includes_tax,
},
{
onSuccess: ({ region }) => {
navigate(`../${region.id}`)
},
}
)
})
const [open, onOpenChange, subscribe] = useRouteModalState()
return (
<FocusModal open={open} onOpenChange={onOpenChange}>
<FocusModal.Content>
<Form {...form}>
<form onSubmit={handleSubmit}>
<FocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<FocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
</Button>
</FocusModal.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("general.save")}
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex flex-col items-center pt-[72px]">
<div className="w-full max-w-[720px] flex flex-col gap-y-8">
<Heading></Heading>
<div className="flex flex-col gap-y-4">
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input size="small" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="currency_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.currency")}</Form.Label>
<Form.Control></Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="tax_rate"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.taxRate")}</Form.Label>
<Form.Control>
<Input type="number" size="small" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="tax_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("fields.taxCode")}
</Form.Label>
<Form.Control>
<Input type="number" size="small" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
<Form.Field
control={form.control}
name="includes_tax"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div>
<div className="flex items-start justify-between">
<Form.Label>
{t("fields.taxInclusivePricing")}
</Form.Label>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
<Form.Hint>{t("regions.taxInclusiveHint")}</Form.Hint>
<Form.ErrorMessage />
</div>
</Form.Item>
)
}}
/>
<div className="flex flex-col gap-y-4">
<div>
<Text size="small" leading="compact" weight="plus">
{t("fields.providers")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("regions.providersHint")}
</Text>
</div>
<div className="grid grid-cols-2 gap-4"></div>
</div>
<div className="flex flex-col gap-y-4">
<div>
<Text size="small" leading="compact" weight="plus">
{t("fields.metadata")}
</Text>
</div>
</div>
</div>
</FocusModal.Body>
</form>
</Form>
<CreateRegionForm subscribe={subscribe} />
</FocusModal.Content>
</FocusModal>
)

View File

@@ -1,18 +1,11 @@
import {
BuildingTax,
EllipsisHorizontal,
PencilSquare,
Trash,
} from "@medusajs/icons"
import { PencilSquare, Trash } from "@medusajs/icons"
import { Country, Region } from "@medusajs/medusa"
import {
Badge,
Button,
Container,
Drawer,
DropdownMenu,
Heading,
IconButton,
StatusBadge,
Text,
Tooltip,
@@ -21,8 +14,8 @@ import {
import { useAdminDeleteRegion, useAdminUpdateRegion } from "medusa-react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import * as zod from "zod"
import { ActionMenu } from "../../../../../components/common/action-menu"
type RegionGeneralSectionProps = {
region: Region
@@ -113,38 +106,28 @@ const RegionActions = ({ region }: { region: Region }) => {
}
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<Link to="edit" relative="route">
<DropdownMenu.Item>
<div className="flex items-center gap-x-2">
<PencilSquare className="text-ui-fg-subtle" />
<span>{t("general.edit")}</span>
</div>
</DropdownMenu.Item>
</Link>
<Link to={`/settings/taxes/${region.id}`}>
<DropdownMenu.Item>
<div className="flex items-center gap-x-2">
<BuildingTax className="text-ui-fg-subtle" />
<span>Tax settings</span>
</div>
</DropdownMenu.Item>
</Link>
<DropdownMenu.Separator />
<DropdownMenu.Item onClick={handleDelete}>
<div className="flex items-center gap-x-2">
<Trash className="text-ui-fg-subtle" />
<span>Delete</span>
</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/settings/regions/${region.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("general.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}

View File

@@ -1,5 +1,5 @@
import { Region, ShippingOption } from "@medusajs/medusa"
import { Container, StatusBadge, Table, clx } from "@medusajs/ui"
import { Container, Heading, StatusBadge, Table, clx } from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
@@ -17,15 +17,18 @@ type RegionShippingOptionSectionProps = {
region: Region
}
const PAGE_SIZE = 20
// TODO: Need to fix pagination and search for shipping options
export const RegionShippingOptionSection = ({
region,
}: RegionShippingOptionSectionProps) => {
const { shipping_options, count, isError, error, isLoading } =
useAdminShippingOptions({
region_id: region.id,
is_return: false,
})
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
pageSize: count || 0,
})
const pagination = useMemo(
@@ -36,11 +39,6 @@ export const RegionShippingOptionSection = ({
[pageIndex, pageSize]
)
const { shipping_options, count, isError, error, isLoading } =
useAdminShippingOptions({
region_id: region.id,
})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const columns = useShippingOptionColumns()
@@ -48,7 +46,7 @@ export const RegionShippingOptionSection = ({
const table = useReactTable({
data: shipping_options ?? [],
columns,
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
pageCount: count ? 1 : 0,
state: {
pagination,
rowSelection,
@@ -59,17 +57,17 @@ export const RegionShippingOptionSection = ({
onRowSelectionChange: setRowSelection,
})
if (isLoading) {
return <div>Loading...</div>
}
const { t } = useTranslation()
if (isError) {
throw error
}
return (
<Container className="p-0">
<div className="px-6 py-4">{/* Filters go here */}</div>
<Container className="p-0 divide-y">
<div className="px-6 py-4">
<Heading level="h2">{t("regions.shippingOptions")}</Heading>
</div>
<Table>
<Table.Header>
{table.getHeaderGroups().map((headerGroup) => {
@@ -121,7 +119,7 @@ export const RegionShippingOptionSection = ({
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
pageSize={count ?? 0}
/>
</Container>
)

View File

@@ -14,8 +14,6 @@ export const regionLoader = async ({ params }: LoaderFunctionArgs) => {
const id = params.id
const query = regionQuery(id!)
console.log("regionLoader", query)
return (
queryClient.getQueryData<Response<AdminProductsRes>>(query.queryKey) ??
(await queryClient.fetchQuery(query))

View File

@@ -1,5 +1,5 @@
import { useAdminRegion } from "medusa-react"
import { Outlet, useNavigate, useParams } from "react-router-dom"
import { Outlet, json, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { RegionGeneralSection } from "./components/region-general-section"
@@ -8,28 +8,19 @@ import { RegionShippingOptionSection } from "./components/region-shipping-option
export const RegionDetail = () => {
const { id } = useParams()
const { region, isLoading, isError, error } = useAdminRegion(id!)
const navigate = useNavigate()
// TODO: Move to loading.tsx and set as Suspense fallback for the route
if (isLoading) {
return <div>Loading</div>
}
// TODO: Move to error.tsx and set as ErrorBoundary for the route
if (isError || !region) {
const err = error ? JSON.parse(JSON.stringify(error)) : null
return (
<div>
{(err as Error & { status: number })?.status === 404 ? (
<div>Not found</div>
) : (
<div>Something went wrong!</div>
)}
</div>
)
}
if (error) {
throw error
}
console.log("RegionDetail")
throw json("An unknown error occurred", 500)
}
return (
<div className="flex flex-col gap-y-2">

View File

@@ -1,27 +1,26 @@
import { EllipsisHorizontal, PencilSquare, Trash } from "@medusajs/icons"
import { PencilSquare, Trash } from "@medusajs/icons"
import { Region } from "@medusajs/medusa"
import {
Button,
Container,
DropdownMenu,
Heading,
IconButton,
Table,
Tooltip,
clx,
usePrompt,
} from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { useAdminRegions } from "medusa-react"
import { useAdminDeleteRegion, useAdminRegions } from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
const PAGE_SIZE = 50
@@ -43,8 +42,6 @@ export const RegionListTable = () => {
[pageIndex, pageSize]
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const { regions, count, isLoading, isError, error } = useAdminRegions({
limit: PAGE_SIZE,
offset: pageIndex * PAGE_SIZE,
@@ -58,10 +55,8 @@ export const RegionListTable = () => {
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
state: {
pagination,
rowSelection,
},
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
})
@@ -140,32 +135,52 @@ export const RegionListTable = () => {
const RegionActions = ({ region }: { region: Region }) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteRegion(region.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("regions.deleteRegionWarning", {
name: region.name,
}),
verificationText: region.name,
verificationInstruction: t("general.typeToConfirm"),
confirmText: t("general.confirm"),
cancelText: t("general.cancel"),
})
if (!res) {
return
}
await mutateAsync(undefined)
}
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild onClick={(e) => e.stopPropagation()}>
<IconButton variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<Link to={`/settings/regions/${region.id}/edit`}>
<DropdownMenu.Item onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-x-2">
<PencilSquare className="text-ui-fg-subtle" />
<span>{t("general.edit")}</span>
</div>
</DropdownMenu.Item>
</Link>
<DropdownMenu.Separator />
<DropdownMenu.Item onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-x-2">
<Trash className="text-ui-fg-subtle" />
<span>{t("general.delete")}</span>
</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
<ActionMenu
groups={[
{
actions: [
{
label: t("general.edit"),
to: `/settings/regions/${region.id}/edit`,
icon: <PencilSquare />,
},
],
},
{
actions: [
{
label: t("general.delete"),
onClick: handleDelete,
icon: <Trash />,
},
],
},
]}
/>
)
}

View File

@@ -2,8 +2,6 @@ import { Outlet } from "react-router-dom"
import { RegionListTable } from "./components/region-list-table"
export const RegionList = () => {
console.log("RegionList")
return (
<div className="flex flex-col gap-y-2">
<RegionListTable />

View File

@@ -86,7 +86,7 @@ export const CreateSalesChannelForm = ({
</FocusModal.Header>
<FocusModal.Body className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col items-center overflow-y-auto">
<div className="flex w-full max-w-[720px] flex-col gap-y-10 px-2 pb-6 pt-[72px]">
<div className="flex w-full max-w-[720px] flex-col gap-y-8 px-2 py-16">
<div>
<Heading className="capitalize">
{t("salesChannels.createSalesChannel")}

View File

@@ -1,18 +1,11 @@
import { EllipsisHorizontal, PencilSquare, Trash } from "@medusajs/icons"
import { PencilSquare, Trash } from "@medusajs/icons"
import { SalesChannel } from "@medusajs/medusa"
import {
Container,
DropdownMenu,
Heading,
IconButton,
StatusBadge,
Text,
usePrompt,
} from "@medusajs/ui"
import { Container, Heading, StatusBadge, Text, usePrompt } from "@medusajs/ui"
import { useAdminDeleteSalesChannel } from "medusa-react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
type SalesChannelGeneralSection = {
salesChannel: SalesChannel
@@ -33,6 +26,8 @@ export const SalesChannelGeneralSection = ({
description: t("salesChannels.deleteSalesChannelWarning", {
name: salesChannel.name,
}),
verificationInstruction: t("general.typeToConfirm"),
verificationText: salesChannel.name,
confirmText: t("general.delete"),
cancelText: t("general.cancel"),
})
@@ -61,26 +56,28 @@ export const SalesChannelGeneralSection = ({
<StatusBadge color={salesChannel.is_disabled ? "red" : "green"}>
{t(`general.${salesChannel.is_disabled ? "disabled" : "enabled"}`)}
</StatusBadge>
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<Link to={`/settings/sales-channels/${salesChannel.id}/edit`}>
<DropdownMenu.Item className="gap-x-2">
<PencilSquare className="text-ui-fg-subtle" />
<span>{t("general.edit")}</span>
</DropdownMenu.Item>
</Link>
<DropdownMenu.Separator />
<DropdownMenu.Item className="gap-x-2" onClick={handleDelete}>
<Trash className="text-ui-fg-subtle" />
<span>{t("general.delete")}</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/settings/sales-channels/${salesChannel.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("general.delete"),
onClick: handleDelete,
},
],
},
]}
/>
</div>
</div>
</Container>

View File

@@ -1,13 +1,11 @@
import { EllipsisHorizontal, PencilSquare, Trash } from "@medusajs/icons"
import { PencilSquare, Trash } from "@medusajs/icons"
import { Product, SalesChannel } from "@medusajs/medusa"
import {
Button,
Checkbox,
CommandBar,
Container,
DropdownMenu,
Heading,
IconButton,
Table,
clx,
usePrompt,
@@ -36,6 +34,7 @@ import {
} from "../../../../../components/common/product-table-cells"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { FilterGroup } from "../../../../../components/filtering/filter-group"
import { OrderBy } from "../../../../../components/filtering/order-by"
import { Query } from "../../../../../components/filtering/query"
@@ -324,25 +323,27 @@ const ProductListCellActions = ({
}
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<Link to={`/products/${productId}`}>
<DropdownMenu.Item className="gap-x-2">
<PencilSquare />
<span>Edit</span>
</DropdownMenu.Item>
</Link>
<DropdownMenu.Separator />
<DropdownMenu.Item onClick={onRemove} className="gap-x-2">
<Trash />
<span>{t("general.remove")}</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/products/${productId}`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("general.remove"),
onClick: onRemove,
},
],
},
]}
/>
)
}

View File

@@ -1,14 +1,13 @@
import { EllipsisHorizontal, PencilSquare, Trash } from "@medusajs/icons"
import { PencilSquare, Trash } from "@medusajs/icons"
import { SalesChannel } from "@medusajs/medusa"
import {
Button,
Container,
DropdownMenu,
Heading,
IconButton,
StatusBadge,
Table,
clx,
usePrompt,
} from "@medusajs/ui"
import {
PaginationState,
@@ -22,6 +21,7 @@ import { useAdminDeleteSalesChannel, useAdminSalesChannels } from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { OrderBy } from "../../../../../components/filtering/order-by"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
@@ -162,29 +162,57 @@ export const SalesChannelListTable = () => {
)
}
const SalesChannelActions = ({ id }: { id: string }) => {
const { mutateAsync } = useAdminDeleteSalesChannel(id)
const SalesChannelActions = ({
salesChannel,
}: {
salesChannel: SalesChannel
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteSalesChannel(salesChannel.id)
const handleDelete = async () => {
const confirm = await prompt({
title: t("general.areYouSure"),
description: t("salesChannels.deleteSalesChannelWarning", {
name: salesChannel.name,
}),
verificationInstruction: t("general.typeToConfirm"),
verificationText: salesChannel.name,
confirmText: t("general.delete"),
cancelText: t("general.cancel"),
})
if (!confirm) {
return
}
await mutateAsync()
}
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item className="gap-x-2">
<PencilSquare className="text-ui-fg-subtle" />
<span>{t("general.edit")}</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item className="gap-x-2">
<Trash className="text-ui-fg-subtle" />
<span>{t("general.delete")}</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/settings/sales-channels/${salesChannel.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("general.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}
@@ -223,7 +251,7 @@ const useColumns = () => {
columnHelper.display({
id: "actions",
cell: ({ row }) => {
return <SalesChannelActions id={row.original.id} />
return <SalesChannelActions salesChannel={row.original} />
},
}),
],

View File

@@ -19,11 +19,12 @@ import {
useReactTable,
} from "@tanstack/react-table"
import { useAdminCurrencies, useAdminUpdateStore } from "medusa-react"
import { FormEvent, useMemo, useRef, useState } from "react"
import { FormEvent, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { OrderBy } from "../../../../../components/filtering/order-by"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useHandleTableScroll } from "../../../../../hooks/use-handle-table-scroll"
import { useQueryParams } from "../../../../../hooks/use-query-params"
type AddCurrenciesFormProps = {
@@ -86,20 +87,7 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
const { mutateAsync, isLoading: isMutating } = useAdminUpdateStore()
const tableContainerRef = useRef<HTMLDivElement>(null)
// Listen for if the table container that has overflow-y: auto is scrolled, and if true set some state
const [isScrolled, setIsScrolled] = useState(false)
const handleScroll = () => {
if (tableContainerRef.current) {
setIsScrolled(
tableContainerRef.current.scrollTop > 0 &&
tableContainerRef.current.scrollTop <
tableContainerRef.current.scrollHeight
)
}
}
const { handleScroll, isScrolled, tableContainerRef } = useHandleTableScroll()
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
@@ -129,10 +117,6 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
})
}
if (isLoading) {
return <div>Loading...</div>
}
if (isError) {
throw error
}

View File

@@ -1,13 +1,11 @@
import { BuildingTax, EllipsisHorizontal, Trash } from "@medusajs/icons"
import { Trash } from "@medusajs/icons"
import { Currency, Store } from "@medusajs/medusa"
import {
Button,
Checkbox,
CommandBar,
Container,
DropdownMenu,
Heading,
IconButton,
StatusBadge,
Table,
clx,
@@ -25,6 +23,7 @@ import { useAdminUpdateStore } from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { ActionMenu } from "../../../../../../components/common/action-menu"
import { LocalizedTablePagination } from "../../../../../../components/localization/localized-table-pagination"
type StoreCurrencySectionProps = {
@@ -180,12 +179,14 @@ const CurrencyActions = ({
const { t } = useTranslation()
const prompt = usePrompt()
const handleDeleteCurrency = async () => {
const handleRemove = async () => {
const result = await prompt({
title: t("general.areYouSure"),
description: t("store.removeCurrencyWarning", {
count: 1,
}),
verificationInstruction: t("general.typeToConfirm"),
verificationText: currency.name,
confirmText: t("general.remove"),
cancelText: t("general.cancel"),
})
@@ -200,28 +201,19 @@ const CurrencyActions = ({
}
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item>
<div className="flex items-center gap-x-2">
<BuildingTax className="text-ui-fg-subtle" />
<span>{t("general.remove")}</span>
</div>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onClick={handleDeleteCurrency}>
<div className="flex items-center gap-x-2">
<Trash className="text-ui-fg-subtle" />
<span>{t("general.remove")}</span>
</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
<ActionMenu
groups={[
{
actions: [
{
icon: <Trash />,
label: t("general.remove"),
onClick: handleRemove,
},
],
},
]}
/>
)
}

View File

@@ -1,20 +1,19 @@
import preset from "@medusajs/ui-preset";
import path from "path";
import path from "path"
const rootProject = path.join(
process.cwd(),
"../../apps/server/src/admin/**/*.{js,jsx,ts,tsx}"
);
)
// get the path of the dependency "@medusajs/ui"
const medusaUI = path.join(
path.dirname(require.resolve("@medusajs/ui")),
"**/*.{js,jsx,ts,tsx}"
);
)
/** @type {import('tailwindcss').Config} */
module.exports = {
presets: [preset],
presets: [require("@medusajs/ui-preset")],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
@@ -25,5 +24,5 @@ module.exports = {
theme: {
extend: {},
},
plugins: [],
};
plugins: [require("@headlessui/tailwindcss")],
}

View File

@@ -73,7 +73,7 @@ const DrawerContent = React.forwardRef<
<DrawerPrimitives.Content
ref={ref}
className={clx(
"bg-ui-bg-base shadow-elevation-modal border-ui-border-base fixed inset-y-2 flex flex-1 flex-col rounded-lg border focus-visible:outline-none max-sm:inset-x-2 sm:right-2 sm:max-w-[560px] md:w-full",
"bg-ui-bg-base shadow-elevation-modal border-ui-border-base fixed inset-y-2 flex w-full flex-1 flex-col rounded-lg border focus:outline-none max-sm:inset-x-2 max-sm:w-[calc(100%-16px)] sm:right-2 sm:max-w-[560px]",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-right-1/2 data-[state=open]:slide-in-from-right-1/2 duration-200",
className
)}

View File

@@ -185,7 +185,7 @@ const Item = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={clx(
"txt-compact-medium bg-ui-bg-base grid cursor-pointer grid-cols-[20px_1fr] gap-x-2 rounded-md px-3 py-2 outline-none transition-colors",
"bg-ui-bg-base grid cursor-pointer grid-cols-[20px_1fr] gap-x-2 rounded-md px-3 py-2 outline-none transition-colors",
"hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-hover",
{
"txt-compact-medium data-[state=checked]:txt-compact-medium-plus":

View File

@@ -6076,6 +6076,28 @@ __metadata:
languageName: node
linkType: hard
"@headlessui/react@npm:^1.7.18":
version: 1.7.18
resolution: "@headlessui/react@npm:1.7.18"
dependencies:
"@tanstack/react-virtual": ^3.0.0-beta.60
client-only: ^0.0.1
peerDependencies:
react: ^16 || ^17 || ^18
react-dom: ^16 || ^17 || ^18
checksum: 2d88d10874879182d4b9ed9a7779266032214034481129ba544e858d3624c8d12333e6a9d9d8263f2f116bc823bcfd43a2d1f69800fbf6a47b34d989370346e5
languageName: node
linkType: hard
"@headlessui/tailwindcss@npm:^0.2.0":
version: 0.2.0
resolution: "@headlessui/tailwindcss@npm:0.2.0"
peerDependencies:
tailwindcss: ^3.0
checksum: b641bef150e4ee18afd068c1af8e83c582a3131903ed1a310abbdb463b6150428a9b4c38e42cb296469fb20857f5542ae34f681cbff62f47d52332fbc5117479
languageName: node
linkType: hard
"@hookform/error-message@npm:^2.0.1":
version: 2.0.1
resolution: "@hookform/error-message@npm:2.0.1"
@@ -7942,6 +7964,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "@medusajs/dashboard@workspace:packages/admin-next/dashboard"
dependencies:
"@headlessui/react": ^1.7.18
"@headlessui/tailwindcss": ^0.2.0
"@hookform/resolvers": 3.3.2
"@medusajs/icons": "workspace:^"
"@medusajs/medusa": "workspace:^"
@@ -16412,6 +16436,18 @@ __metadata:
languageName: node
linkType: hard
"@tanstack/react-virtual@npm:^3.0.0-beta.60":
version: 3.0.1
resolution: "@tanstack/react-virtual@npm:3.0.1"
dependencies:
"@tanstack/virtual-core": 3.0.0
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 2b9464dab8a975734b651211402a4eaf10e4ae6d9570138576891db350cfabe11e444fea8f17ef0b26ef8ede0adc230bec9c8fe859e61ca66147831076b77bb2
languageName: node
linkType: hard
"@tanstack/table-core@npm:8.10.7":
version: 8.10.7
resolution: "@tanstack/table-core@npm:8.10.7"
@@ -16426,6 +16462,13 @@ __metadata:
languageName: node
linkType: hard
"@tanstack/virtual-core@npm:3.0.0":
version: 3.0.0
resolution: "@tanstack/virtual-core@npm:3.0.0"
checksum: d0899fe470b43f80a2ccc567a01138cc015900f13d91b82abc784fc7bc46419e9c8b99c102a5c6ee76d15933db61aecc68c0ae8e92e9cb9ded9ae2f51a01cf4c
languageName: node
linkType: hard
"@testing-library/dom@npm:^8.3.0, @testing-library/dom@npm:^8.5.0":
version: 8.20.1
resolution: "@testing-library/dom@npm:8.20.1"
@@ -22640,6 +22683,13 @@ __metadata:
languageName: node
linkType: hard
"client-only@npm:^0.0.1":
version: 0.0.1
resolution: "client-only@npm:0.0.1"
checksum: 9d6cfd0c19e1c96a434605added99dff48482152af791ec4172fb912a71cff9027ff174efd8cdb2160cc7f377543e0537ffc462d4f279bc4701de3f2a3c4b358
languageName: node
linkType: hard
"client-sessions@npm:^0.8.0":
version: 0.8.0
resolution: "client-sessions@npm:0.8.0"