feat(admin-sdk,admin-bundler,admin-shared,medusa): Restructure admin packages (#8988)
**What** - Renames /admin-next -> /admin - Renames @medusajs/admin-sdk -> @medusajs/admin-bundler - Creates a new package called @medusajs/admin-sdk that will hold all tooling relevant to creating admin extensions. This is currently `defineRouteConfig` and `defineWidgetConfig`, but will eventually also export methods for adding custom fields, register translation, etc. - cc: @shahednasser we should update the examples in the docs so these functions are imported from `@medusajs/admin-sdk`. People will also need to install the package in their project, as it's no longer a transient dependency. - cc: @olivermrbl we might want to publish a changelog when this is merged, as it is a breaking change, and will require people to import the `defineXConfig` from the new package instead of `@medusajs/admin-shared`. - Updates CODEOWNERS so /admin packages does not require a review from the UI team.
This commit is contained in:
1 parent
beaa851302
commit
0fe1201435
1440 files changed
+122
-86
No files matched your search
@@ -0,0 +1,31 @@
|
||||
import { Toaster, TooltipProvider } from "@medusajs/ui"
|
||||
import { QueryClientProvider } from "@tanstack/react-query"
|
||||
import { HelmetProvider } from "react-helmet-async"
|
||||
|
||||
import { I18n } from "./components/utilities/i18n"
|
||||
import { queryClient } from "./lib/query-client"
|
||||
import { I18nProvider } from "./providers/i18n-provider"
|
||||
import { RouterProvider } from "./providers/router-provider"
|
||||
import { ThemeProvider } from "./providers/theme-provider"
|
||||
|
||||
import "./index.css"
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<HelmetProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<I18n />
|
||||
<TooltipProvider>
|
||||
<I18nProvider>
|
||||
<RouterProvider />
|
||||
</I18nProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</HelmetProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
export * from "./protected-route"
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { Spinner } from "@medusajs/icons"
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom"
|
||||
import { useMe } from "../../../hooks/api/users"
|
||||
import { SearchProvider } from "../../../providers/search-provider"
|
||||
import { SidebarProvider } from "../../../providers/sidebar-provider"
|
||||
|
||||
export const ProtectedRoute = () => {
|
||||
const { user, isLoading } = useMe()
|
||||
const location = useLocation()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner className="text-ui-fg-interactive animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<SearchProvider>
|
||||
<Outlet />
|
||||
</SearchProvider>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { DropdownMenu, IconButton, clx } from "@medusajs/ui"
|
||||
|
||||
import { EllipsisHorizontal } from "@medusajs/icons"
|
||||
import { ReactNode } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
export type Action = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
disabled?: boolean
|
||||
} & (
|
||||
| {
|
||||
to: string
|
||||
onClick?: never
|
||||
}
|
||||
| {
|
||||
onClick: () => void
|
||||
to?: never
|
||||
}
|
||||
)
|
||||
|
||||
export type ActionGroup = {
|
||||
actions: Action[]
|
||||
}
|
||||
|
||||
type ActionMenuProps = {
|
||||
groups: ActionGroup[]
|
||||
}
|
||||
|
||||
export const ActionMenu = ({ groups }: ActionMenuProps) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<IconButton size="small" variant="transparent">
|
||||
<EllipsisHorizontal />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
{groups.map((group, index) => {
|
||||
if (!group.actions.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isLast = index === groups.length - 1
|
||||
|
||||
return (
|
||||
<DropdownMenu.Group key={index}>
|
||||
{group.actions.map((action, index) => {
|
||||
if (action.onClick) {
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
disabled={action.disabled}
|
||||
key={index}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
action.onClick()
|
||||
}}
|
||||
className={clx(
|
||||
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
|
||||
{
|
||||
"[&_svg]:text-ui-fg-disabled": action.disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<DropdownMenu.Item
|
||||
className={clx(
|
||||
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
|
||||
{
|
||||
"[&_svg]:text-ui-fg-disabled": action.disabled,
|
||||
}
|
||||
)}
|
||||
asChild
|
||||
disabled={action.disabled}
|
||||
>
|
||||
<Link to={action.to} onClick={(e) => e.stopPropagation()}>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!isLast && <DropdownMenu.Separator />}
|
||||
</DropdownMenu.Group>
|
||||
)
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./action-menu"
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { Badge, Tooltip, clx } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type BadgeListSummaryProps = {
|
||||
/**
|
||||
* Number of initial items to display
|
||||
* @default 2
|
||||
*/
|
||||
n?: number
|
||||
/**
|
||||
* List of strings to display as abbreviated list
|
||||
*/
|
||||
list: string[]
|
||||
/**
|
||||
* Is the summary displayed inline.
|
||||
* Determines whether the center text is truncated if there is no space in the container
|
||||
*/
|
||||
inline?: boolean
|
||||
/**
|
||||
* Whether the badges should be rounded
|
||||
*/
|
||||
rounded?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const BadgeListSummary = ({
|
||||
list,
|
||||
className,
|
||||
inline,
|
||||
rounded = false,
|
||||
n = 2,
|
||||
}: BadgeListSummaryProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const title = t("general.plusCount", {
|
||||
count: list.length - n,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"text-ui-fg-subtle txt-compact-small gap-x-2 overflow-hidden",
|
||||
{
|
||||
"inline-flex": inline,
|
||||
flex: !inline,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{list.slice(0, n).map((item) => {
|
||||
return (
|
||||
<Badge rounded={rounded ? "full" : "base"} key={item} size="2xsmall">
|
||||
{item}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
|
||||
{list.length > n && (
|
||||
<div className="whitespace-nowrap">
|
||||
<Tooltip
|
||||
content={
|
||||
<ul>
|
||||
{list.slice(n).map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
rounded={rounded ? "full" : "base"}
|
||||
size="2xsmall"
|
||||
className="cursor-default whitespace-nowrap"
|
||||
>
|
||||
{title}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./badge-list-summary"
|
||||
@@ -0,0 +1,96 @@
|
||||
import { DropdownMenu, clx } from "@medusajs/ui"
|
||||
|
||||
import { PropsWithChildren, ReactNode } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
type Action = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
disabled?: boolean
|
||||
} & (
|
||||
| {
|
||||
to: string
|
||||
onClick?: never
|
||||
}
|
||||
| {
|
||||
onClick: () => void
|
||||
to?: never
|
||||
}
|
||||
)
|
||||
|
||||
type ActionGroup = {
|
||||
actions: Action[]
|
||||
}
|
||||
|
||||
type ActionMenuProps = {
|
||||
groups: ActionGroup[]
|
||||
}
|
||||
|
||||
export const ButtonMenu = ({
|
||||
groups,
|
||||
children,
|
||||
}: PropsWithChildren<ActionMenuProps>) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger asChild>{children}</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
{groups.map((group, index) => {
|
||||
if (!group.actions.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isLast = index === groups.length - 1
|
||||
|
||||
return (
|
||||
<DropdownMenu.Group key={index}>
|
||||
{group.actions.map((action, index) => {
|
||||
if (action.onClick) {
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
disabled={action.disabled}
|
||||
key={index}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
action.onClick()
|
||||
}}
|
||||
className={clx(
|
||||
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
|
||||
{
|
||||
"[&_svg]:text-ui-fg-disabled": action.disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<DropdownMenu.Item
|
||||
className={clx(
|
||||
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
|
||||
{
|
||||
"[&_svg]:text-ui-fg-disabled": action.disabled,
|
||||
}
|
||||
)}
|
||||
asChild
|
||||
disabled={action.disabled}
|
||||
>
|
||||
<Link to={action.to} onClick={(e) => e.stopPropagation()}>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!isLast && <DropdownMenu.Separator />}
|
||||
</DropdownMenu.Group>
|
||||
)
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./action-menu"
|
||||
@@ -0,0 +1,111 @@
|
||||
import { XMarkMini } from "@medusajs/icons"
|
||||
import { Button, clx } from "@medusajs/ui"
|
||||
import { Children, PropsWithChildren, createContext, useContext } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type ChipGroupVariant = "base" | "component"
|
||||
|
||||
type ChipGroupProps = PropsWithChildren<{
|
||||
onClearAll?: () => void
|
||||
onRemove?: (index: number) => void
|
||||
variant?: ChipGroupVariant
|
||||
className?: string
|
||||
}>
|
||||
|
||||
type GroupContextValue = {
|
||||
onRemove?: (index: number) => void
|
||||
variant: ChipGroupVariant
|
||||
}
|
||||
|
||||
const GroupContext = createContext<GroupContextValue | null>(null)
|
||||
|
||||
const useGroupContext = () => {
|
||||
const context = useContext(GroupContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useGroupContext must be used within a ChipGroup component")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Group = ({
|
||||
onClearAll,
|
||||
onRemove,
|
||||
variant = "component",
|
||||
className,
|
||||
children,
|
||||
}: ChipGroupProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const showClearAll = !!onClearAll && Children.count(children) > 0
|
||||
|
||||
return (
|
||||
<GroupContext.Provider value={{ onRemove, variant }}>
|
||||
<ul
|
||||
role="application"
|
||||
className={clx("flex flex-wrap items-center gap-2", className)}
|
||||
>
|
||||
{children}
|
||||
{showClearAll && (
|
||||
<li>
|
||||
<Button
|
||||
size="small"
|
||||
variant="transparent"
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="text-ui-fg-muted active:text-ui-fg-subtle"
|
||||
>
|
||||
{t("actions.clearAll")}
|
||||
</Button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</GroupContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type ChipProps = PropsWithChildren<{
|
||||
index: number
|
||||
className?: string
|
||||
}>
|
||||
|
||||
const Chip = ({ index, className, children }: ChipProps) => {
|
||||
const { onRemove, variant } = useGroupContext()
|
||||
|
||||
return (
|
||||
<li
|
||||
className={clx(
|
||||
"bg-ui-bg-component shadow-borders-base flex items-center divide-x overflow-hidden rounded-md",
|
||||
{
|
||||
"bg-ui-bg-component": variant === "component",
|
||||
"bg-ui-bg-base-": variant === "base",
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="txt-compact-small-plus text-ui-fg-subtle flex items-center justify-center px-2 py-1">
|
||||
{children}
|
||||
</span>
|
||||
{!!onRemove && (
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
type="button"
|
||||
className={clx(
|
||||
"text-ui-fg-muted active:text-ui-fg-subtle transition-fg flex items-center justify-center p-1",
|
||||
{
|
||||
"hover:bg-ui-bg-component-hover active:bg-ui-bg-component-pressed":
|
||||
variant === "component",
|
||||
"hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed":
|
||||
variant === "base",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<XMarkMini />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export const ChipGroup = Object.assign(Group, { Chip })
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./chip-group"
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { Tooltip } from "@medusajs/ui"
|
||||
import { ComponentPropsWithoutRef, PropsWithChildren } from "react"
|
||||
|
||||
type ConditionalTooltipProps = PropsWithChildren<
|
||||
ComponentPropsWithoutRef<typeof Tooltip> & {
|
||||
showTooltip?: boolean
|
||||
}
|
||||
>
|
||||
|
||||
export const ConditionalTooltip = ({
|
||||
children,
|
||||
showTooltip = false,
|
||||
...props
|
||||
}: ConditionalTooltipProps) => {
|
||||
if (showTooltip) {
|
||||
return <Tooltip {...props}>{children}</Tooltip>
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./conditional-tooltip"
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Avatar, Copy, Text } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { getFormattedAddress, isSameAddress } from "../../../lib/addresses"
|
||||
|
||||
const ID = ({ data }: { data: HttpTypes.AdminOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const id = data.customer_id
|
||||
const name = getOrderCustomer(data)
|
||||
const email = data.email
|
||||
const fallback = (name || email || "").charAt(0).toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("fields.id")}
|
||||
</Text>
|
||||
<Link
|
||||
to={`/customers/${id}`}
|
||||
className="focus:shadow-borders-focus rounded-[4px] outline-none transition-shadow"
|
||||
>
|
||||
<div className="flex items-center gap-x-2 overflow-hidden">
|
||||
<Avatar size="2xsmall" fallback={fallback} />
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-subtle hover:text-ui-fg-base transition-fg truncate"
|
||||
>
|
||||
{name || email}
|
||||
</Text>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Company = ({ data }: { data: HttpTypes.AdminOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
const company =
|
||||
data.shipping_address?.company || data.billing_address?.company
|
||||
|
||||
if (!company) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("fields.company")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact" className="truncate">
|
||||
{company}
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Contact = ({ data }: { data: HttpTypes.AdminOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const phone = data.shipping_address?.phone || data.billing_address?.phone
|
||||
const email = data.email || ""
|
||||
|
||||
return (
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("orders.customer.contactLabel")}
|
||||
</Text>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-pretty break-all"
|
||||
>
|
||||
{email}
|
||||
</Text>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Copy content={email} className="text-ui-fg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
{phone && (
|
||||
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-pretty break-all"
|
||||
>
|
||||
{phone}
|
||||
</Text>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Copy content={email} className="text-ui-fg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AddressPrint = ({
|
||||
address,
|
||||
type,
|
||||
}: {
|
||||
address:
|
||||
| HttpTypes.AdminOrder["shipping_address"]
|
||||
| HttpTypes.AdminOrder["billing_address"]
|
||||
type: "shipping" | "billing"
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{type === "shipping"
|
||||
? t("addresses.shippingAddress.label")
|
||||
: t("addresses.billingAddress.label")}
|
||||
</Text>
|
||||
{address ? (
|
||||
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
|
||||
<Text size="small" leading="compact">
|
||||
{getFormattedAddress({ address }).map((line, i) => {
|
||||
return (
|
||||
<span key={i} className="break-words">
|
||||
{line}
|
||||
<br />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</Text>
|
||||
<div className="flex justify-end">
|
||||
<Copy
|
||||
content={getFormattedAddress({ address }).join("\n")}
|
||||
className="text-ui-fg-muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Text size="small" leading="compact">
|
||||
-
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Addresses = ({ data }: { data: HttpTypes.AdminOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="divide-y">
|
||||
<AddressPrint address={data.shipping_address} type="shipping" />
|
||||
{!isSameAddress(data.shipping_address, data.billing_address) ? (
|
||||
<AddressPrint address={data.billing_address} type="billing" />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
weight="plus"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
{t("addresses.billingAddress.label")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact" className="text-ui-fg-muted">
|
||||
{t("addresses.billingAddress.sameAsShipping")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CustomerInfo = Object.assign(
|
||||
{},
|
||||
{
|
||||
ID,
|
||||
Company,
|
||||
Contact,
|
||||
Addresses,
|
||||
}
|
||||
)
|
||||
|
||||
const getOrderCustomer = (obj: HttpTypes.AdminOrder) => {
|
||||
const { first_name: sFirstName, last_name: sLastName } =
|
||||
obj.shipping_address || {}
|
||||
const { first_name: bFirstName, last_name: bLastName } =
|
||||
obj.billing_address || {}
|
||||
const { first_name: cFirstName, last_name: cLastName } = obj.customer || {}
|
||||
|
||||
const customerName = [cFirstName, cLastName].filter(Boolean).join(" ")
|
||||
const shippingName = [sFirstName, sLastName].filter(Boolean).join(" ")
|
||||
const billingName = [bFirstName, bLastName].filter(Boolean).join(" ")
|
||||
|
||||
const name = customerName || shippingName || billingName
|
||||
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./customer-info"
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { Text, clx } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useDate } from "../../../hooks/use-date"
|
||||
|
||||
type DateRangeDisplayProps = {
|
||||
startsAt?: Date | string | null
|
||||
endsAt?: Date | string | null
|
||||
showTime?: boolean
|
||||
}
|
||||
|
||||
export const DateRangeDisplay = ({
|
||||
startsAt,
|
||||
endsAt,
|
||||
showTime = false,
|
||||
}: DateRangeDisplayProps) => {
|
||||
const startDate = startsAt ? new Date(startsAt) : null
|
||||
const endDate = endsAt ? new Date(endsAt) : null
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { getFullDate } = useDate()
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="shadow-elevation-card-rest bg-ui-bg-component text-ui-fg-subtle flex items-center gap-x-3 rounded-md px-3 py-1.5">
|
||||
<Bar date={startDate} />
|
||||
<div>
|
||||
<Text weight="plus" size="small">
|
||||
{t("fields.startDate")}
|
||||
</Text>
|
||||
<Text size="small" className="tabular-nums">
|
||||
{startDate
|
||||
? getFullDate({
|
||||
date: startDate,
|
||||
includeTime: showTime,
|
||||
})
|
||||
: "-"}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shadow-elevation-card-rest bg-ui-bg-component text-ui-fg-subtle flex items-center gap-x-3 rounded-md px-3 py-1.5">
|
||||
<Bar date={endDate} />
|
||||
<div>
|
||||
<Text size="small" weight="plus">
|
||||
{t("fields.endDate")}
|
||||
</Text>
|
||||
<Text size="small" className="tabular-nums">
|
||||
{endDate
|
||||
? getFullDate({
|
||||
date: endDate,
|
||||
includeTime: showTime,
|
||||
})
|
||||
: "-"}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Bar = ({ date }: { date: Date | null }) => {
|
||||
const now = new Date()
|
||||
|
||||
const isDateInFuture = date && date > now
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx("bg-ui-tag-neutral-icon h-8 w-1 rounded-full", {
|
||||
"bg-ui-tag-orange-icon": isDateInFuture,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./date-range-display"
|
||||
@@ -0,0 +1,11 @@
|
||||
import format from "date-fns/format"
|
||||
|
||||
export function formatDate(date: string | Date) {
|
||||
const value = new Date(date)
|
||||
value.setMinutes(value.getMinutes() - value.getTimezoneOffset())
|
||||
|
||||
const hour12 = Intl.DateTimeFormat().resolvedOptions().hour12
|
||||
const timestampFormat = hour12 ? "dd MMM yyyy hh:MM a" : "dd MMM yyyy HH:MM"
|
||||
|
||||
return format(value, timestampFormat)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { ComponentPropsWithoutRef } from "react"
|
||||
|
||||
interface DividerProps
|
||||
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||
orientation?: "horizontal" | "vertical"
|
||||
variant?: "dashed" | "solid"
|
||||
}
|
||||
|
||||
export const Divider = ({
|
||||
orientation = "horizontal",
|
||||
variant = "solid",
|
||||
className,
|
||||
...props
|
||||
}: DividerProps) => {
|
||||
return (
|
||||
<div
|
||||
aria-orientation={orientation}
|
||||
role="separator"
|
||||
className={clx(
|
||||
"border-ui-border-base",
|
||||
{
|
||||
"w-full border-t":
|
||||
orientation === "horizontal" && variant === "solid",
|
||||
"h-full border-l": orientation === "vertical" && variant === "solid",
|
||||
"bg-transparent": variant === "dashed",
|
||||
"h-px w-full bg-[linear-gradient(90deg,var(--border-strong)_1px,transparent_1px)] bg-[length:4px_1px]":
|
||||
variant === "dashed" && orientation === "horizontal",
|
||||
"h-full w-px bg-[linear-gradient(0deg,var(--border-strong)_1px,transparent_1px)] bg-[length:1px_4px]":
|
||||
variant === "dashed" && orientation === "vertical",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./divider"
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { ExclamationCircle, MagnifyingGlass, PlusMini } from "@medusajs/icons"
|
||||
import { Button, Text, clx } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
export type NoResultsProps = {
|
||||
title?: string
|
||||
message?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const NoResults = ({ title, message, className }: NoResultsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"flex h-[400px] w-full items-center justify-center",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-y-2">
|
||||
<MagnifyingGlass />
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{title ?? t("general.noResultsTitle")}
|
||||
</Text>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{message ?? t("general.noResultsMessage")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ActionProps = {
|
||||
action?: {
|
||||
to: string
|
||||
label: string
|
||||
}
|
||||
}
|
||||
|
||||
type NoRecordsProps = {
|
||||
title?: string
|
||||
message?: string
|
||||
className?: string
|
||||
buttonVariant?: string
|
||||
} & ActionProps
|
||||
|
||||
const DefaultButton = ({ action }: ActionProps) =>
|
||||
action && (
|
||||
<Link to={action.to}>
|
||||
<Button variant="secondary" size="small">
|
||||
{action.label}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
|
||||
const TransparentIconLeftButton = ({ action }: ActionProps) =>
|
||||
action && (
|
||||
<Link to={action.to}>
|
||||
<Button variant="transparent" className="text-ui-fg-interactive">
|
||||
<PlusMini /> {action.label}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
|
||||
export const NoRecords = ({
|
||||
title,
|
||||
message,
|
||||
action,
|
||||
className,
|
||||
buttonVariant = "default",
|
||||
}: NoRecordsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"flex h-[400px] w-full flex-col items-center justify-center gap-y-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-y-3">
|
||||
<ExclamationCircle className="text-ui-fg-subtle" />
|
||||
|
||||
<div className="flex flex-col items-center gap-y-1">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{title ?? t("general.noRecordsTitle")}
|
||||
</Text>
|
||||
|
||||
<Text size="small" className="text-ui-fg-muted">
|
||||
{message ?? t("general.noRecordsMessage")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{buttonVariant === "default" && <DefaultButton action={action} />}
|
||||
{buttonVariant === "transparentIconLeft" && (
|
||||
<TransparentIconLeftButton action={action} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./empty-table-content"
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ArrowDownTray, Spinner } from "@medusajs/icons"
|
||||
import { IconButton, Text } from "@medusajs/ui"
|
||||
import { ActionGroup, ActionMenu } from "../action-menu"
|
||||
|
||||
export const FilePreview = ({
|
||||
filename,
|
||||
url,
|
||||
loading,
|
||||
activity,
|
||||
actions,
|
||||
hideThumbnail,
|
||||
}: {
|
||||
filename: string
|
||||
url?: string
|
||||
loading?: boolean
|
||||
activity?: string
|
||||
actions?: ActionGroup[]
|
||||
hideThumbnail?: boolean
|
||||
}) => {
|
||||
return (
|
||||
<div className="shadow-elevation-card-rest bg-ui-bg-component transition-fg rounded-md px-3 py-2">
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
{!hideThumbnail && <FileThumbnail />}
|
||||
<div className="flex flex-col justify-center">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="truncate max-w-[260px]"
|
||||
>
|
||||
{filename}
|
||||
</Text>
|
||||
|
||||
{loading && !!activity && (
|
||||
<Text
|
||||
leading="compact"
|
||||
size="xsmall"
|
||||
className="text-ui-fg-interactive"
|
||||
>
|
||||
{activity}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <Spinner className="animate-spin" />}
|
||||
{!loading && actions && <ActionMenu groups={actions} />}
|
||||
{!loading && url && (
|
||||
<IconButton variant="transparent" asChild>
|
||||
<a href={url} download={filename ?? `${Date.now()}`}>
|
||||
<ArrowDownTray />
|
||||
</a>
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FileThumbnail = () => {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="32"
|
||||
viewBox="0 0 24 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M20 31.75H4C1.92893 31.75 0.25 30.0711 0.25 28V4C0.25 1.92893 1.92893 0.25 4 0.25H15.9431C16.9377 0.25 17.8915 0.645088 18.5948 1.34835L22.6516 5.4052C23.3549 6.10847 23.75 7.06229 23.75 8.05685V28C23.75 30.0711 22.0711 31.75 20 31.75Z"
|
||||
fill="url(#paint0_linear_6594_388107)"
|
||||
stroke="url(#paint1_linear_6594_388107)"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
<path
|
||||
opacity="0.4"
|
||||
d="M17.7857 12.8125V13.5357H10.3393V10.9643H15.9375C16.9569 10.9643 17.7857 11.7931 17.7857 12.8125ZM6.21429 16.9107V15.0893H8.78571V16.9107H6.21429ZM10.3393 16.9107V15.0893H17.7857V16.9107H10.3393ZM15.9375 21.0357H10.3393V18.4643H17.7857V19.1875C17.7857 20.2069 16.9569 21.0357 15.9375 21.0357ZM6.21429 19.1875V18.4643H8.78571V21.0357H8.0625C7.0431 21.0357 6.21429 20.2069 6.21429 19.1875ZM8.0625 10.9643H8.78571V13.5357H6.21429V12.8125C6.21429 11.7931 7.0431 10.9643 8.0625 10.9643Z"
|
||||
fill="url(#paint2_linear_6594_388107)"
|
||||
stroke="url(#paint3_linear_6594_388107)"
|
||||
stroke-width="0.428571"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_6594_388107"
|
||||
x1="12"
|
||||
y1="0"
|
||||
x2="12"
|
||||
y2="32"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#F4F4F5" />
|
||||
<stop offset="1" stop-color="#E4E4E7" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_6594_388107"
|
||||
x1="12"
|
||||
y1="0"
|
||||
x2="12"
|
||||
y2="32"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#E4E4E7" />
|
||||
<stop offset="1" stop-color="#D4D4D8" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_6594_388107"
|
||||
x1="12"
|
||||
y1="10.75"
|
||||
x2="12"
|
||||
y2="21.25"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#52525B" />
|
||||
<stop offset="1" stop-color="#A1A1AA" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_6594_388107"
|
||||
x1="12"
|
||||
y1="10.75"
|
||||
x2="12"
|
||||
y2="21.25"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#18181B" />
|
||||
<stop offset="1" stop-color="#52525B" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./file-preview"
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ArrowDownTray } from "@medusajs/icons"
|
||||
import { Text, clx } from "@medusajs/ui"
|
||||
import { ChangeEvent, DragEvent, useRef, useState } from "react"
|
||||
|
||||
export interface FileType {
|
||||
id: string
|
||||
url: string
|
||||
file: File
|
||||
}
|
||||
|
||||
export interface FileUploadProps {
|
||||
label: string
|
||||
multiple?: boolean
|
||||
hint?: string
|
||||
hasError?: boolean
|
||||
formats: string[]
|
||||
onUploaded: (files: FileType[]) => void
|
||||
}
|
||||
|
||||
export const FileUpload = ({
|
||||
label,
|
||||
hint,
|
||||
multiple = true,
|
||||
hasError,
|
||||
formats,
|
||||
onUploaded,
|
||||
}: FileUploadProps) => {
|
||||
const [isDragOver, setIsDragOver] = useState<boolean>(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropZoneRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const handleOpenFileSelector = () => {
|
||||
inputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
if (!files) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDragOver(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (
|
||||
!dropZoneRef.current ||
|
||||
dropZoneRef.current.contains(event.relatedTarget as Node)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDragOver(false)
|
||||
}
|
||||
|
||||
const handleUploaded = (files: FileList | null) => {
|
||||
if (!files) {
|
||||
return
|
||||
}
|
||||
|
||||
const fileList = Array.from(files)
|
||||
const fileObj = fileList.map((file) => {
|
||||
const id = Math.random().toString(36).substring(7)
|
||||
|
||||
const previewUrl = URL.createObjectURL(file)
|
||||
return {
|
||||
id: id,
|
||||
url: previewUrl,
|
||||
file,
|
||||
}
|
||||
})
|
||||
|
||||
onUploaded(fileObj)
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
setIsDragOver(false)
|
||||
|
||||
handleUploaded(event.dataTransfer?.files)
|
||||
}
|
||||
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
handleUploaded(event.target.files)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
ref={dropZoneRef}
|
||||
type="button"
|
||||
onClick={handleOpenFileSelector}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={clx(
|
||||
"bg-ui-bg-component border-ui-border-strong transition-fg group flex w-full flex-col items-center gap-y-2 rounded-lg border border-dashed p-8",
|
||||
"hover:border-ui-border-interactive focus:border-ui-border-interactive",
|
||||
"focus:shadow-borders-focus outline-none focus:border-solid",
|
||||
{
|
||||
"!border-ui-border-error": hasError,
|
||||
"!border-ui-border-interactive": isDragOver,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="text-ui-fg-subtle group-disabled:text-ui-fg-disabled flex items-center gap-x-2">
|
||||
<ArrowDownTray />
|
||||
<Text>{label}</Text>
|
||||
</div>
|
||||
{!!hint && (
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-muted group-disabled:text-ui-fg-disabled"
|
||||
>
|
||||
{hint}
|
||||
</Text>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
hidden
|
||||
ref={inputRef}
|
||||
onChange={handleFileChange}
|
||||
type="file"
|
||||
accept={formats.join(",")}
|
||||
multiple={multiple}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./file-upload"
|
||||
@@ -0,0 +1,223 @@
|
||||
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 React, {
|
||||
ReactNode,
|
||||
createContext,
|
||||
forwardRef,
|
||||
useContext,
|
||||
useId,
|
||||
} from "react"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
} from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
const Provider = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const Field = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = useContext(FormFieldContext)
|
||||
const itemContext = useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within a FormField")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formLabelId: `${id}-form-item-label`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formErrorMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
const Item = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={clx("flex flex-col space-y-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
Item.displayName = "Form.Item"
|
||||
|
||||
const Label = forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitives.Root> & {
|
||||
optional?: boolean
|
||||
tooltip?: ReactNode
|
||||
icon?: ReactNode
|
||||
}
|
||||
>(({ className, optional = false, tooltip, icon, ...props }, ref) => {
|
||||
const { formLabelId, formItemId } = useFormField()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<LabelComponent
|
||||
id={formLabelId}
|
||||
ref={ref}
|
||||
className={clx(className)}
|
||||
htmlFor={formItemId}
|
||||
size="small"
|
||||
weight="plus"
|
||||
{...props}
|
||||
/>
|
||||
{tooltip && (
|
||||
<Tooltip content={tooltip}>
|
||||
<InformationCircleSolid className="text-ui-fg-muted" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{icon}
|
||||
{optional && (
|
||||
<Text size="small" leading="compact" className="text-ui-fg-muted">
|
||||
({t("fields.optional")})
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Label.displayName = "Form.Label"
|
||||
|
||||
const Control = forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const {
|
||||
error,
|
||||
formItemId,
|
||||
formDescriptionId,
|
||||
formErrorMessageId,
|
||||
formLabelId,
|
||||
} = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formErrorMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
aria-labelledby={formLabelId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Control.displayName = "Form.Control"
|
||||
|
||||
const Hint = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<HintComponent
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Hint.displayName = "Form.Hint"
|
||||
|
||||
const ErrorMessage = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formErrorMessageId } = useFormField()
|
||||
const msg = error ? String(error?.message) : children
|
||||
|
||||
if (!msg || msg === "undefined") {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<HintComponent
|
||||
ref={ref}
|
||||
id={formErrorMessageId}
|
||||
className={className}
|
||||
variant={error ? "error" : "info"}
|
||||
{...props}
|
||||
>
|
||||
{msg}
|
||||
</HintComponent>
|
||||
)
|
||||
})
|
||||
ErrorMessage.displayName = "Form.ErrorMessage"
|
||||
|
||||
const Form = Object.assign(Provider, {
|
||||
Item,
|
||||
Label,
|
||||
Control,
|
||||
Hint,
|
||||
ErrorMessage,
|
||||
Field,
|
||||
})
|
||||
|
||||
export { Form }
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./form";
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { ReactNode, Ref, RefAttributes, forwardRef } from "react"
|
||||
|
||||
export function genericForwardRef<T, P = {}>(
|
||||
render: (props: P, ref: Ref<T>) => ReactNode
|
||||
): (props: P & RefAttributes<T>) => ReactNode {
|
||||
return forwardRef(render) as any
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./generic-forward-ref"
|
||||
@@ -0,0 +1,38 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { PropsWithChildren } from "react"
|
||||
|
||||
type IconAvatarProps = PropsWithChildren<{
|
||||
className?: string
|
||||
size?: "small" | "large" | "xlarge"
|
||||
}>
|
||||
|
||||
/**
|
||||
* Use this component when a design calls for an avatar with an icon.
|
||||
*
|
||||
* The `<Avatar/>` component from `@medusajs/ui` does not support passing an icon as a child.
|
||||
*/
|
||||
export const IconAvatar = ({
|
||||
size = "small",
|
||||
children,
|
||||
className,
|
||||
}: IconAvatarProps) => {
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"shadow-borders-base flex size-7 items-center justify-center",
|
||||
"[&>div]:bg-ui-bg-field [&>div]:text-ui-fg-subtle [&>div]:flex [&>div]:size-6 [&>div]:items-center [&>div]:justify-center",
|
||||
{
|
||||
"size-7 rounded-md [&>div]:size-6 [&>div]:rounded-[4px]":
|
||||
size === "small",
|
||||
"size-10 rounded-lg [&>div]:size-9 [&>div]:rounded-[6px]":
|
||||
size === "large",
|
||||
"size-12 rounded-xl [&>div]:size-11 [&>div]:rounded-[10px]":
|
||||
size === "xlarge",
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./icon-avatar"
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./infinite-list"
|
||||
@@ -0,0 +1,135 @@
|
||||
import { QueryKey, useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { ReactNode, useEffect, useMemo, useRef } from "react"
|
||||
import { toast } from "@medusajs/ui"
|
||||
import { Spinner } from "@medusajs/icons"
|
||||
|
||||
type InfiniteListProps<TResponse, TEntity, TParams> = {
|
||||
queryKey: QueryKey
|
||||
queryFn: (params: TParams) => Promise<TResponse>
|
||||
queryOptions?: { enabled?: boolean }
|
||||
renderItem: (item: TEntity) => ReactNode
|
||||
renderEmpty: () => ReactNode
|
||||
responseKey: keyof TResponse
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export const InfiniteList = <
|
||||
TResponse extends { count: number; offset: number; limit: number },
|
||||
TEntity extends { id: string },
|
||||
TParams extends { offset?: number; limit?: number },
|
||||
>({
|
||||
queryKey,
|
||||
queryFn,
|
||||
queryOptions,
|
||||
renderItem,
|
||||
renderEmpty,
|
||||
responseKey,
|
||||
pageSize = 20,
|
||||
}: InfiniteListProps<TResponse, TEntity, TParams>) => {
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
hasPreviousPage,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
isPending,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return await queryFn({
|
||||
limit: pageSize,
|
||||
offset: pageParam,
|
||||
} as TParams)
|
||||
},
|
||||
initialPageParam: 0,
|
||||
maxPages: 5,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const moreItemsExist = lastPage.count > lastPage.offset + lastPage.limit
|
||||
return moreItemsExist ? lastPage.offset + lastPage.limit : undefined
|
||||
},
|
||||
getPreviousPageParam: (firstPage) => {
|
||||
const moreItemsExist = firstPage.offset !== 0
|
||||
return moreItemsExist
|
||||
? Math.max(firstPage.offset - firstPage.limit, 0)
|
||||
: undefined
|
||||
},
|
||||
...queryOptions,
|
||||
})
|
||||
|
||||
const items = useMemo(() => {
|
||||
return data?.pages.flatMap((p) => p[responseKey] as TEntity[]) ?? []
|
||||
}, [data, responseKey])
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
const startObserver = useRef<IntersectionObserver>()
|
||||
const endObserver = useRef<IntersectionObserver>()
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
// Define the new observers after we stop fetching
|
||||
if (!isFetching) {
|
||||
// Define the new observers after paginating
|
||||
startObserver.current = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && hasPreviousPage) {
|
||||
fetchPreviousPage()
|
||||
}
|
||||
})
|
||||
|
||||
endObserver.current = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
})
|
||||
|
||||
// Register the new observers to observe the new first and last children
|
||||
startObserver.current?.observe(parentRef.current!.firstChild as Element)
|
||||
endObserver.current?.observe(parentRef.current!.lastChild as Element)
|
||||
}
|
||||
|
||||
// Clear the old observers
|
||||
return () => {
|
||||
startObserver.current?.disconnect()
|
||||
endObserver.current?.disconnect()
|
||||
}
|
||||
}, [
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
isFetching,
|
||||
isPending,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
}, [error])
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<Spinner className="animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="h-full">
|
||||
{items?.length
|
||||
? items.map((item) => <div key={item.id}>{renderItem(item)}</div>)
|
||||
: renderEmpty()}
|
||||
|
||||
{isFetching && (
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<Spinner className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./inline-tip"
|
||||
@@ -0,0 +1,60 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { ComponentPropsWithoutRef, forwardRef } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
interface InlineTipProps extends ComponentPropsWithoutRef<"div"> {
|
||||
/**
|
||||
* The label to display in the tip.
|
||||
*/
|
||||
label?: string
|
||||
/**
|
||||
* The variant of the tip.
|
||||
*/
|
||||
variant?: "tip" | "warning"
|
||||
}
|
||||
|
||||
/**
|
||||
* A component for rendering inline tips. Useful for providing additional information or context.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <InlineTip label="Info">
|
||||
* This is an info tip.
|
||||
* </InlineTip>
|
||||
* ```
|
||||
*
|
||||
* TODO: Move to `@medusajs/ui` package.
|
||||
*/
|
||||
export const InlineTip = forwardRef<HTMLDivElement, InlineTipProps>(
|
||||
({ variant = "tip", label, className, children, ...props }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const labelValue =
|
||||
label || (variant === "warning" ? t("general.warning") : t("general.tip"))
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"bg-ui-bg-component txt-small text-ui-fg-subtle grid grid-cols-[4px_1fr] items-start gap-3 rounded-lg border p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
className={clx("w-4px bg-ui-tag-neutral-icon h-full rounded-full", {
|
||||
"bg-ui-tag-orange-icon": variant === "warning",
|
||||
})}
|
||||
/>
|
||||
<div className="text-pretty">
|
||||
<strong className="txt-small-plus text-ui-fg-base">
|
||||
{labelValue}:
|
||||
</strong>{" "}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
InlineTip.displayName = "InlineTip"
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./json-view-section"
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
ArrowUpRightOnBox,
|
||||
Check,
|
||||
SquareTwoStack,
|
||||
TriangleDownMini,
|
||||
XMarkMini,
|
||||
} from "@medusajs/icons"
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Drawer,
|
||||
Heading,
|
||||
IconButton,
|
||||
Kbd,
|
||||
} from "@medusajs/ui"
|
||||
import Primitive from "@uiw/react-json-view"
|
||||
import { CSSProperties, MouseEvent, Suspense, useState } from "react"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
|
||||
type JsonViewSectionProps = {
|
||||
data: object
|
||||
title?: string
|
||||
}
|
||||
|
||||
export const JsonViewSection = ({ data }: JsonViewSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const numberOfKeys = Object.keys(data).length
|
||||
|
||||
return (
|
||||
<Container className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Heading level="h2">{t("json.header")}</Heading>
|
||||
<Badge size="2xsmall" rounded="full">
|
||||
{t("json.numberOfKeys", {
|
||||
count: numberOfKeys,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<Drawer>
|
||||
<Drawer.Trigger asChild>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="transparent"
|
||||
className="text-ui-fg-muted hover:text-ui-fg-subtle"
|
||||
>
|
||||
<ArrowUpRightOnBox />
|
||||
</IconButton>
|
||||
</Drawer.Trigger>
|
||||
<Drawer.Content className="bg-ui-contrast-bg-base text-ui-code-fg-subtle !shadow-elevation-commandbar overflow-hidden border border-none max-md:inset-x-2 max-md:max-w-[calc(100%-16px)]">
|
||||
<div className="bg-ui-code-bg-base flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Drawer.Title asChild>
|
||||
<Heading className="text-ui-contrast-fg-primary">
|
||||
<Trans
|
||||
i18nKey="json.drawer.header"
|
||||
count={numberOfKeys}
|
||||
components={[
|
||||
<span key="count-span" className="text-ui-fg-subtle" />,
|
||||
]}
|
||||
/>
|
||||
</Heading>
|
||||
</Drawer.Title>
|
||||
<Drawer.Description className="sr-only">
|
||||
{t("json.drawer.description")}
|
||||
</Drawer.Description>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Kbd className="bg-ui-contrast-bg-subtle border-ui-contrast-border-base text-ui-contrast-fg-secondary">
|
||||
esc
|
||||
</Kbd>
|
||||
<Drawer.Close asChild>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="transparent"
|
||||
className="text-ui-contrast-fg-secondary hover:text-ui-contrast-fg-primary hover:bg-ui-contrast-bg-base-hover active:bg-ui-contrast-bg-base-pressed focus-visible:bg-ui-contrast-bg-base-hover focus-visible:shadow-borders-interactive-with-active"
|
||||
>
|
||||
<XMarkMini />
|
||||
</IconButton>
|
||||
</Drawer.Close>
|
||||
</div>
|
||||
</div>
|
||||
<Drawer.Body className="flex flex-1 flex-col overflow-hidden px-[5px] py-0 pb-[5px]">
|
||||
<div className="bg-ui-contrast-bg-subtle flex-1 overflow-auto rounded-b-[4px] rounded-t-lg p-3">
|
||||
<Suspense
|
||||
fallback={<div className="flex size-full flex-col"></div>}
|
||||
>
|
||||
<Primitive
|
||||
value={data}
|
||||
displayDataTypes={false}
|
||||
style={
|
||||
{
|
||||
"--w-rjv-font-family": "Roboto Mono, monospace",
|
||||
"--w-rjv-line-color": "var(--contrast-border-base)",
|
||||
"--w-rjv-curlybraces-color":
|
||||
"var(--contrast-fg-secondary)",
|
||||
"--w-rjv-brackets-color": "var(--contrast-fg-secondary)",
|
||||
"--w-rjv-key-string": "var(--contrast-fg-primary)",
|
||||
"--w-rjv-info-color": "var(--contrast-fg-secondary)",
|
||||
"--w-rjv-type-string-color": "var(--tag-green-icon)",
|
||||
"--w-rjv-quotes-string-color": "var(--tag-green-icon)",
|
||||
"--w-rjv-type-boolean-color": "var(--tag-orange-icon)",
|
||||
"--w-rjv-type-int-color": "var(--tag-orange-icon)",
|
||||
"--w-rjv-type-float-color": "var(--tag-orange-icon)",
|
||||
"--w-rjv-type-bigint-color": "var(--tag-orange-icon)",
|
||||
"--w-rjv-key-number": "var(--contrast-fg-secondary)",
|
||||
"--w-rjv-arrow-color": "var(--contrast-fg-secondary)",
|
||||
"--w-rjv-copied-color": "var(--contrast-fg-secondary)",
|
||||
"--w-rjv-copied-success-color":
|
||||
"var(--contrast-fg-primary)",
|
||||
"--w-rjv-colon-color": "var(--contrast-fg-primary)",
|
||||
"--w-rjv-ellipsis-color": "var(--contrast-fg-secondary)",
|
||||
} as CSSProperties
|
||||
}
|
||||
collapsed={1}
|
||||
>
|
||||
<Primitive.Quote render={() => <span />} />
|
||||
<Primitive.Null
|
||||
render={() => (
|
||||
<span className="text-ui-tag-red-icon">null</span>
|
||||
)}
|
||||
/>
|
||||
<Primitive.Undefined
|
||||
render={() => (
|
||||
<span className="text-ui-tag-blue-icon">undefined</span>
|
||||
)}
|
||||
/>
|
||||
<Primitive.CountInfo
|
||||
render={(_props, { value }) => {
|
||||
return (
|
||||
<span className="text-ui-contrast-fg-secondary ml-2">
|
||||
{t("general.items", {
|
||||
count: Object.keys(value as object).length,
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Primitive.Arrow>
|
||||
<TriangleDownMini className="text-ui-contrast-fg-secondary -ml-[0.5px]" />
|
||||
</Primitive.Arrow>
|
||||
<Primitive.Colon>
|
||||
<span className="mr-1">:</span>
|
||||
</Primitive.Colon>
|
||||
<Primitive.Copied
|
||||
render={({ style }, { value }) => {
|
||||
return <Copied style={style} value={value} />
|
||||
}}
|
||||
/>
|
||||
</Primitive>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Drawer.Body>
|
||||
</Drawer.Content>
|
||||
</Drawer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
type CopiedProps = {
|
||||
style?: CSSProperties
|
||||
value: object | undefined
|
||||
}
|
||||
|
||||
const Copied = ({ style, value }: CopiedProps) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handler = (e: MouseEvent<HTMLSpanElement>) => {
|
||||
e.stopPropagation()
|
||||
setCopied(true)
|
||||
|
||||
if (typeof value === "string") {
|
||||
navigator.clipboard.writeText(value)
|
||||
} else {
|
||||
const json = JSON.stringify(value, null, 2)
|
||||
navigator.clipboard.writeText(json)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const styl = { whiteSpace: "nowrap", width: "20px" }
|
||||
|
||||
if (copied) {
|
||||
return (
|
||||
<span style={{ ...style, ...styl }}>
|
||||
<Check className="text-ui-contrast-fg-primary" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span style={{ ...style, ...styl }} onClick={handler}>
|
||||
<SquareTwoStack className="text-ui-contrast-fg-secondary" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./link-button"
|
||||
@@ -0,0 +1,29 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { ComponentPropsWithoutRef } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
interface LinkButtonProps extends ComponentPropsWithoutRef<typeof Link> {
|
||||
variant?: "primary" | "interactive"
|
||||
}
|
||||
|
||||
export const LinkButton = ({
|
||||
className,
|
||||
variant = "interactive",
|
||||
...props
|
||||
}: LinkButtonProps) => {
|
||||
return (
|
||||
<Link
|
||||
className={clx(
|
||||
"transition-fg txt-compact-small-plus rounded-[4px] outline-none",
|
||||
"focus-visible:shadow-borders-focus",
|
||||
{
|
||||
"text-ui-fg-interactive hover:text-ui-fg-interactive-hover":
|
||||
variant === "interactive",
|
||||
"text-ui-fg-base hover:text-ui-fg-subtle": variant === "primary",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ListSummary } from "./list-summary"
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Tooltip, clx } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type ListSummaryProps = {
|
||||
/**
|
||||
* Number of initial items to display
|
||||
* @default 2
|
||||
*/
|
||||
n?: number
|
||||
/**
|
||||
* List of strings to display as abbreviated list
|
||||
*/
|
||||
list: string[]
|
||||
/**
|
||||
* Is the summary displayed inline.
|
||||
* Determines whether the center text is truncated if there is no space in the container
|
||||
*/
|
||||
inline?: boolean
|
||||
variant?: "base" | "compact"
|
||||
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ListSummary = ({
|
||||
list,
|
||||
className,
|
||||
variant = "compact",
|
||||
inline,
|
||||
n = 2,
|
||||
}: ListSummaryProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const title = t("general.plusCountMore", {
|
||||
count: list.length - n,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"text-ui-fg-subtle gap-x-1 overflow-hidden",
|
||||
{
|
||||
"inline-flex": inline,
|
||||
flex: !inline,
|
||||
"txt-compact-small": variant === "compact",
|
||||
"txt-small": variant === "base",
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 truncate">
|
||||
<span className="truncate">{list.slice(0, n).join(", ")}</span>
|
||||
</div>
|
||||
{list.length > n && (
|
||||
<div className="whitespace-nowrap">
|
||||
<Tooltip
|
||||
content={
|
||||
<ul>
|
||||
{list.slice(n).map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
>
|
||||
<span className="cursor-default whitespace-nowrap">{title}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./logo-box"
|
||||
@@ -0,0 +1,74 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { Transition, motion } from "framer-motion"
|
||||
|
||||
type LogoBoxProps = {
|
||||
className?: string
|
||||
checked?: boolean
|
||||
containerTransition?: Transition
|
||||
pathTransition?: Transition
|
||||
}
|
||||
|
||||
export const LogoBox = ({
|
||||
className,
|
||||
checked,
|
||||
containerTransition = {
|
||||
duration: 0.8,
|
||||
delay: 0.5,
|
||||
ease: [0, 0.71, 0.2, 1.01],
|
||||
},
|
||||
pathTransition = {
|
||||
duration: 0.8,
|
||||
delay: 0.6,
|
||||
ease: [0.1, 0.8, 0.2, 1.01],
|
||||
},
|
||||
}: LogoBoxProps) => {
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"size-14 bg-ui-button-neutral shadow-buttons-neutral relative flex items-center justify-center rounded-xl",
|
||||
"after:button-neutral-gradient after:inset-0 after:content-['']",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{checked && (
|
||||
<motion.div
|
||||
className="size-5 absolute -right-[5px] -top-1 flex items-center justify-center rounded-full border-[0.5px] border-[rgba(3,7,18,0.2)] bg-[#3B82F6] bg-gradient-to-b from-white/0 to-white/20 shadow-[0px_1px_2px_0px_rgba(3,7,18,0.12),0px_1px_2px_0px_rgba(255,255,255,0.10)_inset,0px_-1px_5px_0px_rgba(255,255,255,0.10)_inset,0px_0px_0px_0px_rgba(3,7,18,0.06)_inset]"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={containerTransition}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<motion.path
|
||||
d="M5.8335 10.4167L9.16683 13.75L14.1668 6.25"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={pathTransition}
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
)}
|
||||
<svg
|
||||
width="36"
|
||||
height="38"
|
||||
viewBox="0 0 36 38"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M30.85 6.16832L22.2453 1.21782C19.4299 -0.405941 15.9801 -0.405941 13.1648 1.21782L4.52043 6.16832C1.74473 7.79208 0 10.802 0 14.0099V23.9505C0 27.198 1.74473 30.1683 4.52043 31.7921L13.1251 36.7822C15.9405 38.4059 19.3903 38.4059 22.2056 36.7822L30.8103 31.7921C33.6257 30.1683 35.3307 27.198 35.3307 23.9505V14.0099C35.41 10.802 33.6653 7.79208 30.85 6.16832ZM17.6852 27.8317C12.8079 27.8317 8.8426 23.8713 8.8426 19C8.8426 14.1287 12.8079 10.1683 17.6852 10.1683C22.5625 10.1683 26.5674 14.1287 26.5674 19C26.5674 23.8713 22.6022 27.8317 17.6852 27.8317Z"
|
||||
className="fill-ui-button-inverted relative drop-shadow-sm"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./metadata-section"
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ArrowUpRightOnBox } from "@medusajs/icons"
|
||||
import { Badge, Container, Heading, IconButton } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
type MetadataSectionProps<TData extends object> = {
|
||||
data: TData
|
||||
href?: string
|
||||
}
|
||||
|
||||
export const MetadataSection = <TData extends object>({
|
||||
data,
|
||||
href = "metadata/edit",
|
||||
}: MetadataSectionProps<TData>) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!("metadata" in data)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const numberOfKeys = data.metadata ? Object.keys(data.metadata).length : 0
|
||||
|
||||
return (
|
||||
<Container className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Heading level="h2">{t("metadata.header")}</Heading>
|
||||
<Badge size="2xsmall" rounded="full">
|
||||
{t("metadata.numberOfKeys", {
|
||||
count: numberOfKeys,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="transparent"
|
||||
className="text-ui-fg-muted hover:text-ui-fg-subtle"
|
||||
asChild
|
||||
>
|
||||
<Link to={href}>
|
||||
<ArrowUpRightOnBox />
|
||||
</Link>
|
||||
</IconButton>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./section-row"
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Text, clx } from "@medusajs/ui"
|
||||
import { ReactNode } from "react"
|
||||
|
||||
export type SectionRowProps = {
|
||||
title: string
|
||||
value?: ReactNode | string | null
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
export const SectionRow = ({ title, value, actions }: SectionRowProps) => {
|
||||
const isValueString = typeof value === "string" || !value
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
`text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4`,
|
||||
{
|
||||
"grid-cols-[1fr_1fr_28px]": !!actions,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{isValueString ? (
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="whitespace-pre-line text-pretty"
|
||||
>
|
||||
{value ?? "-"}
|
||||
</Text>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">{value}</div>
|
||||
)}
|
||||
|
||||
{actions && <div>{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./skeleton"
|
||||
@@ -0,0 +1,327 @@
|
||||
import { Container, Heading, Text, clx } from "@medusajs/ui"
|
||||
import { CSSProperties, ComponentPropsWithoutRef } from "react"
|
||||
|
||||
type SkeletonProps = {
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
export const Skeleton = ({ className, style }: SkeletonProps) => {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={clx(
|
||||
"bg-ui-bg-component h-3 w-3 animate-pulse rounded-[4px]",
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type TextSkeletonProps = {
|
||||
size?: ComponentPropsWithoutRef<typeof Text>["size"]
|
||||
leading?: ComponentPropsWithoutRef<typeof Text>["leading"]
|
||||
characters?: number
|
||||
}
|
||||
|
||||
type HeadingSkeletonProps = {
|
||||
level?: ComponentPropsWithoutRef<typeof Heading>["level"]
|
||||
characters?: number
|
||||
}
|
||||
|
||||
export const HeadingSkeleton = ({
|
||||
level = "h1",
|
||||
characters = 10,
|
||||
}: HeadingSkeletonProps) => {
|
||||
let charWidth = 9
|
||||
|
||||
switch (level) {
|
||||
case "h1":
|
||||
charWidth = 11
|
||||
break
|
||||
case "h2":
|
||||
charWidth = 10
|
||||
break
|
||||
case "h3":
|
||||
charWidth = 9
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
className={clx({
|
||||
"h-7": level === "h1",
|
||||
"h-6": level === "h2",
|
||||
"h-5": level === "h3",
|
||||
})}
|
||||
style={{
|
||||
width: `${charWidth * characters}px`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const TextSkeleton = ({
|
||||
size = "small",
|
||||
leading = "compact",
|
||||
characters = 10,
|
||||
}: TextSkeletonProps) => {
|
||||
let charWidth = 9
|
||||
|
||||
switch (size) {
|
||||
case "xlarge":
|
||||
charWidth = 13
|
||||
break
|
||||
case "large":
|
||||
charWidth = 11
|
||||
break
|
||||
case "base":
|
||||
charWidth = 10
|
||||
break
|
||||
case "small":
|
||||
charWidth = 9
|
||||
break
|
||||
case "xsmall":
|
||||
charWidth = 8
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
className={clx({
|
||||
"h-5": size === "xsmall",
|
||||
"h-6": size === "small",
|
||||
"h-7": size === "base",
|
||||
"h-8": size === "xlarge",
|
||||
"!h-5": leading === "compact",
|
||||
})}
|
||||
style={{
|
||||
width: `${charWidth * characters}px`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const IconButtonSkeleton = () => {
|
||||
return <Skeleton className="h-7 w-7 rounded-md" />
|
||||
}
|
||||
|
||||
type GeneralSectionSkeletonProps = {
|
||||
rowCount?: number
|
||||
}
|
||||
|
||||
export const GeneralSectionSkeleton = ({
|
||||
rowCount,
|
||||
}: GeneralSectionSkeletonProps) => {
|
||||
const rows = Array.from({ length: rowCount ?? 0 }, (_, i) => i)
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0" aria-hidden>
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<HeadingSkeleton characters={16} />
|
||||
<IconButtonSkeleton />
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row}
|
||||
className="grid grid-cols-2 items-center px-6 py-4"
|
||||
aria-hidden
|
||||
>
|
||||
<TextSkeleton size="small" leading="compact" characters={12} />
|
||||
<TextSkeleton size="small" leading="compact" characters={24} />
|
||||
</div>
|
||||
))}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export const TableFooterSkeleton = ({ layout }: { layout: "fill" | "fit" }) => {
|
||||
return (
|
||||
<div
|
||||
className={clx("flex items-center justify-between p-4", {
|
||||
"border-t": layout === "fill",
|
||||
})}
|
||||
>
|
||||
<Skeleton className="h-7 w-[138px]" />
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Skeleton className="h-7 w-24" />
|
||||
<Skeleton className="h-7 w-11" />
|
||||
<Skeleton className="h-7 w-11" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TableSkeletonProps = {
|
||||
rowCount?: number
|
||||
search?: boolean
|
||||
filters?: boolean
|
||||
orderBy?: boolean
|
||||
pagination?: boolean
|
||||
layout?: "fit" | "fill"
|
||||
}
|
||||
|
||||
export const TableSkeleton = ({
|
||||
rowCount = 10,
|
||||
search = true,
|
||||
filters = true,
|
||||
orderBy = true,
|
||||
pagination = true,
|
||||
layout = "fit",
|
||||
}: TableSkeletonProps) => {
|
||||
// Row count + header row
|
||||
const totalRowCount = rowCount + 1
|
||||
|
||||
const rows = Array.from({ length: totalRowCount }, (_, i) => i)
|
||||
const hasToolbar = search || filters || orderBy
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={clx({
|
||||
"flex h-full flex-col overflow-hidden": layout === "fill",
|
||||
})}
|
||||
>
|
||||
{hasToolbar && (
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
{filters && <Skeleton className="h-7 w-full max-w-[135px]" />}
|
||||
{(search || orderBy) && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
{search && <Skeleton className="h-7 w-[160px]" />}
|
||||
{orderBy && <Skeleton className="h-7 w-7" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col divide-y border-y">
|
||||
{rows.map((row) => (
|
||||
<Skeleton key={row} className="h-10 w-full rounded-none" />
|
||||
))}
|
||||
</div>
|
||||
{pagination && <TableFooterSkeleton layout={layout} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TableSectionSkeleton = (props: TableSkeletonProps) => {
|
||||
return (
|
||||
<Container className="divide-y p-0" aria-hidden>
|
||||
<div className="flex items-center justify-between px-6 py-4" aria-hidden>
|
||||
<HeadingSkeleton level="h2" characters={16} />
|
||||
<IconButtonSkeleton />
|
||||
</div>
|
||||
<TableSkeleton {...props} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export const JsonViewSectionSkeleton = () => {
|
||||
return (
|
||||
<Container className="divide-y p-0" aria-hidden>
|
||||
<div className="flex items-center justify-between px-6 py-4" aria-hidden>
|
||||
<div aria-hidden className="flex items-center gap-x-4">
|
||||
<HeadingSkeleton level="h2" characters={16} />
|
||||
<Skeleton className="h-5 w-12 rounded-md" />
|
||||
</div>
|
||||
<IconButtonSkeleton />
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
type SingleColumnPageSkeletonProps = {
|
||||
sections?: number
|
||||
showJSON?: boolean
|
||||
showMetadata?: boolean
|
||||
}
|
||||
|
||||
export const SingleColumnPageSkeleton = ({
|
||||
sections = 2,
|
||||
showJSON = false,
|
||||
showMetadata = false,
|
||||
}: SingleColumnPageSkeletonProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
{Array.from({ length: sections }, (_, i) => i).map((section) => {
|
||||
return (
|
||||
<Skeleton
|
||||
key={section}
|
||||
className={clx("h-full max-h-[460px] w-full rounded-lg", {
|
||||
// First section is smaller on most pages, this gives us less
|
||||
// layout shifting in general,
|
||||
"max-h-[219px]": section === 0,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{showMetadata && <Skeleton className="h-[60px] w-full rounded-lg" />}
|
||||
{showJSON && <Skeleton className="h-[60px] w-full rounded-lg" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TwoColumnPageSkeletonProps = {
|
||||
mainSections?: number
|
||||
sidebarSections?: number
|
||||
showJSON?: boolean
|
||||
showMetadata?: boolean
|
||||
}
|
||||
|
||||
export const TwoColumnPageSkeleton = ({
|
||||
mainSections = 2,
|
||||
sidebarSections = 1,
|
||||
showJSON = false,
|
||||
showMetadata = true,
|
||||
}: TwoColumnPageSkeletonProps) => {
|
||||
const showExtraData = showJSON || showMetadata
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="flex flex-col gap-x-4 gap-y-3 xl:flex-row xl:items-start">
|
||||
<div className="flex w-full flex-col gap-y-3">
|
||||
{Array.from({ length: mainSections }, (_, i) => i).map((section) => {
|
||||
return (
|
||||
<Skeleton
|
||||
key={section}
|
||||
className={clx("h-full max-h-[460px] w-full rounded-lg", {
|
||||
"max-h-[219px]": section === 0,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{showExtraData && (
|
||||
<div className="hidden flex-col gap-y-3 xl:flex">
|
||||
{showMetadata && (
|
||||
<Skeleton className="h-[60px] w-full rounded-lg" />
|
||||
)}
|
||||
{showJSON && <Skeleton className="h-[60px] w-full rounded-lg" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full max-w-[100%] flex-col gap-y-3 xl:mt-0 xl:max-w-[440px]">
|
||||
{Array.from({ length: sidebarSections }, (_, i) => i).map(
|
||||
(section) => {
|
||||
return (
|
||||
<Skeleton
|
||||
key={section}
|
||||
className={clx("h-full max-h-[320px] w-full rounded-lg", {
|
||||
"max-h-[140px]": section === 0,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)}
|
||||
{showExtraData && (
|
||||
<div className="flex flex-col gap-y-3 xl:hidden">
|
||||
{showMetadata && (
|
||||
<Skeleton className="h-[60px] w-full rounded-lg" />
|
||||
)}
|
||||
{showJSON && <Skeleton className="h-[60px] w-full rounded-lg" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./sortable-list"
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
Active,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
DraggableSyntheticListeners,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
defaultDropAnimationSideEffects,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DropAnimation,
|
||||
type UniqueIdentifier,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { DotsSix } from "@medusajs/icons"
|
||||
import { IconButton, clx } from "@medusajs/ui"
|
||||
import {
|
||||
CSSProperties,
|
||||
Fragment,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
type SortableBaseItem = {
|
||||
id: UniqueIdentifier
|
||||
}
|
||||
|
||||
interface SortableListProps<TItem extends SortableBaseItem> {
|
||||
items: TItem[]
|
||||
onChange: (items: TItem[]) => void
|
||||
renderItem: (item: TItem, index: number) => ReactNode
|
||||
}
|
||||
|
||||
const List = <TItem extends SortableBaseItem>({
|
||||
items,
|
||||
onChange,
|
||||
renderItem,
|
||||
}: SortableListProps<TItem>) => {
|
||||
const [active, setActive] = useState<Active | null>(null)
|
||||
|
||||
const [activeItem, activeIndex] = useMemo(() => {
|
||||
if (active === null) {
|
||||
return [null, null]
|
||||
}
|
||||
|
||||
const index = items.findIndex(({ id }) => id === active.id)
|
||||
|
||||
return [items[index], index]
|
||||
}, [active, items])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const handleDragStart = ({ active }: DragStartEvent) => {
|
||||
setActive(active)
|
||||
}
|
||||
|
||||
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (over && active.id !== over.id) {
|
||||
const activeIndex = items.findIndex(({ id }) => id === active.id)
|
||||
const overIndex = items.findIndex(({ id }) => id === over.id)
|
||||
|
||||
onChange(arrayMove(items, activeIndex, overIndex))
|
||||
}
|
||||
|
||||
setActive(null)
|
||||
}
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActive(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<Overlay>
|
||||
{activeItem && activeIndex !== null
|
||||
? renderItem(activeItem, activeIndex)
|
||||
: null}
|
||||
</Overlay>
|
||||
<SortableContext items={items}>
|
||||
<ul
|
||||
role="application"
|
||||
className="flex list-inside list-none list-image-none flex-col p-0"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.id}>{renderItem(item, index)}</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
const dropAnimationConfig: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: "0.4",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
type SortableOverlayProps = PropsWithChildren
|
||||
|
||||
const Overlay = ({ children }: SortableOverlayProps) => {
|
||||
return (
|
||||
<DragOverlay
|
||||
className="shadow-elevation-card-hover overflow-hidden rounded-md [&>li]:border-b-0"
|
||||
dropAnimation={dropAnimationConfig}
|
||||
>
|
||||
{children}
|
||||
</DragOverlay>
|
||||
)
|
||||
}
|
||||
|
||||
type SortableItemProps<TItem extends SortableBaseItem> = PropsWithChildren<{
|
||||
id: TItem["id"]
|
||||
className?: string
|
||||
}>
|
||||
|
||||
type SortableItemContextValue = {
|
||||
attributes: Record<string, any>
|
||||
listeners: DraggableSyntheticListeners
|
||||
ref: (node: HTMLElement | null) => void
|
||||
isDragging: boolean
|
||||
}
|
||||
|
||||
const SortableItemContext = createContext<SortableItemContextValue | null>(null)
|
||||
|
||||
const useSortableItemContext = () => {
|
||||
const context = useContext(SortableItemContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useSortableItemContext must be used within a SortableItemContext"
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Item = <TItem extends SortableBaseItem>({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
}: SortableItemProps<TItem>) => {
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id })
|
||||
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
attributes,
|
||||
listeners,
|
||||
ref: setActivatorNodeRef,
|
||||
isDragging,
|
||||
}),
|
||||
[attributes, listeners, setActivatorNodeRef, isDragging]
|
||||
)
|
||||
|
||||
const style: CSSProperties = {
|
||||
opacity: isDragging ? 0.4 : undefined,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<SortableItemContext.Provider value={context}>
|
||||
<li
|
||||
className={clx("transition-fg flex flex-1 list-none", className)}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
</SortableItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const DragHandle = () => {
|
||||
const { attributes, listeners, ref } = useSortableItemContext()
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="transparent"
|
||||
size="small"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
ref={ref}
|
||||
className="cursor-grab touch-none active:cursor-grabbing"
|
||||
>
|
||||
<DotsSix className="text-ui-fg-muted" />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
export const SortableList = Object.assign(List, {
|
||||
Item,
|
||||
DragHandle,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./sortable-tree"
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
DroppableContainer,
|
||||
KeyboardCode,
|
||||
KeyboardCoordinateGetter,
|
||||
closestCorners,
|
||||
getFirstCollision,
|
||||
} from "@dnd-kit/core"
|
||||
|
||||
import type { SensorContext } from "./types"
|
||||
import { getProjection } from "./utils"
|
||||
|
||||
const directions: string[] = [
|
||||
KeyboardCode.Down,
|
||||
KeyboardCode.Right,
|
||||
KeyboardCode.Up,
|
||||
KeyboardCode.Left,
|
||||
]
|
||||
|
||||
const horizontal: string[] = [KeyboardCode.Left, KeyboardCode.Right]
|
||||
|
||||
export const sortableTreeKeyboardCoordinates: (
|
||||
context: SensorContext,
|
||||
indentationWidth: number
|
||||
) => KeyboardCoordinateGetter =
|
||||
(context, indentationWidth) =>
|
||||
(
|
||||
event,
|
||||
{
|
||||
currentCoordinates,
|
||||
context: {
|
||||
active,
|
||||
over,
|
||||
collisionRect,
|
||||
droppableRects,
|
||||
droppableContainers,
|
||||
},
|
||||
}
|
||||
) => {
|
||||
if (directions.includes(event.code)) {
|
||||
if (!active || !collisionRect) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const {
|
||||
current: { items, offset },
|
||||
} = context
|
||||
|
||||
if (horizontal.includes(event.code) && over?.id) {
|
||||
const { depth, maxDepth, minDepth } = getProjection(
|
||||
items,
|
||||
active.id,
|
||||
over.id,
|
||||
offset,
|
||||
indentationWidth
|
||||
)
|
||||
|
||||
switch (event.code) {
|
||||
case KeyboardCode.Left:
|
||||
if (depth > minDepth) {
|
||||
return {
|
||||
...currentCoordinates,
|
||||
x: currentCoordinates.x - indentationWidth,
|
||||
}
|
||||
}
|
||||
break
|
||||
case KeyboardCode.Right:
|
||||
if (depth < maxDepth) {
|
||||
return {
|
||||
...currentCoordinates,
|
||||
x: currentCoordinates.x + indentationWidth,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const containers: DroppableContainer[] = []
|
||||
|
||||
droppableContainers.forEach((container) => {
|
||||
if (container?.disabled || container.id === over?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = droppableRects.get(container.id)
|
||||
|
||||
if (!rect) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.code) {
|
||||
case KeyboardCode.Down:
|
||||
if (collisionRect.top < rect.top) {
|
||||
containers.push(container)
|
||||
}
|
||||
break
|
||||
case KeyboardCode.Up:
|
||||
if (collisionRect.top > rect.top) {
|
||||
containers.push(container)
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
const collisions = closestCorners({
|
||||
active,
|
||||
collisionRect,
|
||||
pointerCoordinates: null,
|
||||
droppableRects,
|
||||
droppableContainers: containers,
|
||||
})
|
||||
let closestId = getFirstCollision(collisions, "id")
|
||||
|
||||
if (closestId === over?.id && collisions.length > 1) {
|
||||
closestId = collisions[1].id
|
||||
}
|
||||
|
||||
if (closestId && over?.id) {
|
||||
const activeRect = droppableRects.get(active.id)
|
||||
const newRect = droppableRects.get(closestId)
|
||||
const newDroppable = droppableContainers.get(closestId)
|
||||
|
||||
if (activeRect && newRect && newDroppable) {
|
||||
const newIndex = items.findIndex(({ id }) => id === closestId)
|
||||
const newItem = items[newIndex]
|
||||
const activeIndex = items.findIndex(({ id }) => id === active.id)
|
||||
const activeItem = items[activeIndex]
|
||||
|
||||
if (newItem && activeItem) {
|
||||
const { depth } = getProjection(
|
||||
items,
|
||||
active.id,
|
||||
closestId,
|
||||
(newItem.depth - activeItem.depth) * indentationWidth,
|
||||
indentationWidth
|
||||
)
|
||||
const isBelow = newIndex > activeIndex
|
||||
const modifier = isBelow ? 1 : -1
|
||||
const offset = 0
|
||||
|
||||
const newCoordinates = {
|
||||
x: newRect.left + depth * indentationWidth,
|
||||
y: newRect.top + modifier * offset,
|
||||
}
|
||||
|
||||
return newCoordinates
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { UniqueIdentifier } from "@dnd-kit/core"
|
||||
import { AnimateLayoutChanges, useSortable } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { CSSProperties } from "react"
|
||||
|
||||
import { TreeItem, TreeItemProps } from "./tree-item"
|
||||
import { iOS } from "./utils"
|
||||
|
||||
interface SortableTreeItemProps extends TreeItemProps {
|
||||
id: UniqueIdentifier
|
||||
}
|
||||
|
||||
const animateLayoutChanges: AnimateLayoutChanges = ({
|
||||
isSorting,
|
||||
wasDragging,
|
||||
}) => {
|
||||
return isSorting || wasDragging ? false : true
|
||||
}
|
||||
|
||||
export function SortableTreeItem({
|
||||
id,
|
||||
depth,
|
||||
disabled,
|
||||
...props
|
||||
}: SortableTreeItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
isSorting,
|
||||
listeners,
|
||||
setDraggableNodeRef,
|
||||
setDroppableNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id,
|
||||
animateLayoutChanges,
|
||||
disabled,
|
||||
})
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeItem
|
||||
ref={setDraggableNodeRef}
|
||||
wrapperRef={setDroppableNodeRef}
|
||||
style={style}
|
||||
depth={depth}
|
||||
ghost={isDragging}
|
||||
disableSelection={iOS}
|
||||
disableInteraction={isSorting}
|
||||
disabled={disabled}
|
||||
handleProps={{
|
||||
listeners,
|
||||
attributes,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
Announcements,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragMoveEvent,
|
||||
DragOverEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
DropAnimation,
|
||||
KeyboardSensor,
|
||||
MeasuringStrategy,
|
||||
PointerSensor,
|
||||
UniqueIdentifier,
|
||||
closestCenter,
|
||||
defaultDropAnimation,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
|
||||
import { sortableTreeKeyboardCoordinates } from "./keyboard-coordinates"
|
||||
import { SortableTreeItem } from "./sortable-tree-item"
|
||||
import type { FlattenedItem, SensorContext, TreeItem } from "./types"
|
||||
import {
|
||||
buildTree,
|
||||
flattenTree,
|
||||
getChildCount,
|
||||
getProjection,
|
||||
removeChildrenOf,
|
||||
} from "./utils"
|
||||
|
||||
const measuring = {
|
||||
droppable: {
|
||||
strategy: MeasuringStrategy.Always,
|
||||
},
|
||||
}
|
||||
|
||||
const dropAnimationConfig: DropAnimation = {
|
||||
keyframes({ transform }) {
|
||||
return [
|
||||
{ opacity: 1, transform: CSS.Transform.toString(transform.initial) },
|
||||
{
|
||||
opacity: 0,
|
||||
transform: CSS.Transform.toString({
|
||||
...transform.final,
|
||||
x: transform.final.x + 5,
|
||||
y: transform.final.y + 5,
|
||||
}),
|
||||
},
|
||||
]
|
||||
},
|
||||
easing: "ease-out",
|
||||
sideEffects({ active }) {
|
||||
active.node.animate([{ opacity: 0 }, { opacity: 1 }], {
|
||||
duration: defaultDropAnimation.duration,
|
||||
easing: defaultDropAnimation.easing,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
interface Props<T extends TreeItem> {
|
||||
collapsible?: boolean
|
||||
childrenProp?: string
|
||||
items: T[]
|
||||
indentationWidth?: number
|
||||
/**
|
||||
* Enable drag for all items or provide a function to enable drag for specific items.
|
||||
* @default true
|
||||
*/
|
||||
enableDrag?: boolean | ((item: T) => boolean)
|
||||
onChange: (
|
||||
updatedItem: {
|
||||
id: UniqueIdentifier
|
||||
parentId: UniqueIdentifier | null
|
||||
index: number
|
||||
},
|
||||
items: T[]
|
||||
) => void
|
||||
renderValue: (item: T) => ReactNode
|
||||
}
|
||||
|
||||
export function SortableTree<T extends TreeItem>({
|
||||
collapsible = true,
|
||||
childrenProp = "children", // "children" is the default children prop name
|
||||
enableDrag = true,
|
||||
items = [],
|
||||
indentationWidth = 40,
|
||||
onChange,
|
||||
renderValue,
|
||||
}: Props<T>) {
|
||||
const [collapsedState, setCollapsedState] = useState<
|
||||
Record<UniqueIdentifier, boolean>
|
||||
>({})
|
||||
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
|
||||
const [overId, setOverId] = useState<UniqueIdentifier | null>(null)
|
||||
const [offsetLeft, setOffsetLeft] = useState(0)
|
||||
const [currentPosition, setCurrentPosition] = useState<{
|
||||
parentId: UniqueIdentifier | null
|
||||
overId: UniqueIdentifier
|
||||
} | null>(null)
|
||||
|
||||
const flattenedItems = useMemo(() => {
|
||||
const flattenedTree = flattenTree(items, childrenProp)
|
||||
const collapsedItems = flattenedTree.reduce<UniqueIdentifier[]>(
|
||||
(acc, item) => {
|
||||
const { id } = item
|
||||
const children = (item[childrenProp] || []) as FlattenedItem[]
|
||||
const collapsed = collapsedState[id]
|
||||
|
||||
return collapsed && children.length ? [...acc, id] : acc
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return removeChildrenOf(
|
||||
flattenedTree,
|
||||
activeId ? [activeId, ...collapsedItems] : collapsedItems,
|
||||
childrenProp
|
||||
)
|
||||
}, [activeId, items, childrenProp, collapsedState])
|
||||
|
||||
const projected =
|
||||
activeId && overId
|
||||
? getProjection(
|
||||
flattenedItems,
|
||||
activeId,
|
||||
overId,
|
||||
offsetLeft,
|
||||
indentationWidth
|
||||
)
|
||||
: null
|
||||
|
||||
const sensorContext: SensorContext = useRef({
|
||||
items: flattenedItems,
|
||||
offset: offsetLeft,
|
||||
})
|
||||
|
||||
const [coordinateGetter] = useState(() =>
|
||||
sortableTreeKeyboardCoordinates(sensorContext, indentationWidth)
|
||||
)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter,
|
||||
})
|
||||
)
|
||||
|
||||
const sortedIds = useMemo(
|
||||
() => flattenedItems.map(({ id }) => id),
|
||||
[flattenedItems]
|
||||
)
|
||||
|
||||
const activeItem = activeId
|
||||
? flattenedItems.find(({ id }) => id === activeId)
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
sensorContext.current = {
|
||||
items: flattenedItems,
|
||||
offset: offsetLeft,
|
||||
}
|
||||
}, [flattenedItems, offsetLeft])
|
||||
|
||||
function handleDragStart({ active: { id: activeId } }: DragStartEvent) {
|
||||
setActiveId(activeId)
|
||||
setOverId(activeId)
|
||||
|
||||
const activeItem = flattenedItems.find(({ id }) => id === activeId)
|
||||
|
||||
if (activeItem) {
|
||||
setCurrentPosition({
|
||||
parentId: activeItem.parentId,
|
||||
overId: activeId,
|
||||
})
|
||||
}
|
||||
|
||||
document.body.style.setProperty("cursor", "grabbing")
|
||||
}
|
||||
|
||||
function handleDragMove({ delta }: DragMoveEvent) {
|
||||
setOffsetLeft(delta.x)
|
||||
}
|
||||
|
||||
function handleDragOver({ over }: DragOverEvent) {
|
||||
setOverId(over?.id ?? null)
|
||||
}
|
||||
|
||||
function handleDragEnd({ active, over }: DragEndEvent) {
|
||||
resetState()
|
||||
|
||||
if (projected && over) {
|
||||
const { depth, parentId } = projected
|
||||
const clonedItems: FlattenedItem[] = JSON.parse(
|
||||
JSON.stringify(flattenTree(items, childrenProp))
|
||||
)
|
||||
const overIndex = clonedItems.findIndex(({ id }) => id === over.id)
|
||||
|
||||
const activeIndex = clonedItems.findIndex(({ id }) => id === active.id)
|
||||
const activeTreeItem = clonedItems[activeIndex]
|
||||
|
||||
clonedItems[activeIndex] = { ...activeTreeItem, depth, parentId }
|
||||
|
||||
const sortedItems = arrayMove(clonedItems, activeIndex, overIndex)
|
||||
|
||||
const { items: newItems, update } = buildTree<T>(
|
||||
sortedItems,
|
||||
overIndex,
|
||||
childrenProp
|
||||
)
|
||||
|
||||
onChange(update, newItems)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragCancel() {
|
||||
resetState()
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
setOverId(null)
|
||||
setActiveId(null)
|
||||
setOffsetLeft(0)
|
||||
setCurrentPosition(null)
|
||||
|
||||
document.body.style.setProperty("cursor", "")
|
||||
}
|
||||
|
||||
function handleCollapse(id: UniqueIdentifier) {
|
||||
setCollapsedState((state) => ({
|
||||
...state,
|
||||
[id]: state[id] ? false : true,
|
||||
}))
|
||||
}
|
||||
|
||||
function getMovementAnnouncement(
|
||||
eventName: string,
|
||||
activeId: UniqueIdentifier,
|
||||
overId?: UniqueIdentifier
|
||||
) {
|
||||
if (overId && projected) {
|
||||
if (eventName !== "onDragEnd") {
|
||||
if (
|
||||
currentPosition &&
|
||||
projected.parentId === currentPosition.parentId &&
|
||||
overId === currentPosition.overId
|
||||
) {
|
||||
return
|
||||
} else {
|
||||
setCurrentPosition({
|
||||
parentId: projected.parentId,
|
||||
overId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const clonedItems: FlattenedItem[] = JSON.parse(
|
||||
JSON.stringify(flattenTree(items, childrenProp))
|
||||
)
|
||||
const overIndex = clonedItems.findIndex(({ id }) => id === overId)
|
||||
const activeIndex = clonedItems.findIndex(({ id }) => id === activeId)
|
||||
const sortedItems = arrayMove(clonedItems, activeIndex, overIndex)
|
||||
|
||||
const previousItem = sortedItems[overIndex - 1]
|
||||
|
||||
let announcement
|
||||
const movedVerb = eventName === "onDragEnd" ? "dropped" : "moved"
|
||||
const nestedVerb = eventName === "onDragEnd" ? "dropped" : "nested"
|
||||
|
||||
if (!previousItem) {
|
||||
const nextItem = sortedItems[overIndex + 1]
|
||||
announcement = `${activeId} was ${movedVerb} before ${nextItem.id}.`
|
||||
} else {
|
||||
if (projected.depth > previousItem.depth) {
|
||||
announcement = `${activeId} was ${nestedVerb} under ${previousItem.id}.`
|
||||
} else {
|
||||
let previousSibling: FlattenedItem | undefined = previousItem
|
||||
while (previousSibling && projected.depth < previousSibling.depth) {
|
||||
const parentId: UniqueIdentifier | null = previousSibling.parentId
|
||||
previousSibling = sortedItems.find(({ id }) => id === parentId)
|
||||
}
|
||||
|
||||
if (previousSibling) {
|
||||
announcement = `${activeId} was ${movedVerb} after ${previousSibling.id}.`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return announcement
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const announcements: Announcements = {
|
||||
onDragStart({ active }) {
|
||||
return `Picked up ${active.id}.`
|
||||
},
|
||||
onDragMove({ active, over }) {
|
||||
return getMovementAnnouncement("onDragMove", active.id, over?.id)
|
||||
},
|
||||
onDragOver({ active, over }) {
|
||||
return getMovementAnnouncement("onDragOver", active.id, over?.id)
|
||||
},
|
||||
onDragEnd({ active, over }) {
|
||||
return getMovementAnnouncement("onDragEnd", active.id, over?.id)
|
||||
},
|
||||
onDragCancel({ active }) {
|
||||
return `Moving was cancelled. ${active.id} was dropped in its original position.`
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
accessibility={{ announcements }}
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
measuring={measuring}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
|
||||
{flattenedItems.map((item) => {
|
||||
const { id, depth } = item
|
||||
const children = (item[childrenProp] || []) as FlattenedItem[]
|
||||
|
||||
const disabled =
|
||||
typeof enableDrag === "function"
|
||||
? !enableDrag(item as unknown as T)
|
||||
: !enableDrag
|
||||
|
||||
return (
|
||||
<SortableTreeItem
|
||||
key={id}
|
||||
id={id}
|
||||
value={renderValue(item as unknown as T)}
|
||||
disabled={disabled}
|
||||
depth={id === activeId && projected ? projected.depth : depth}
|
||||
indentationWidth={indentationWidth}
|
||||
collapsed={Boolean(collapsedState[id] && children.length)}
|
||||
childCount={children.length}
|
||||
onCollapse={
|
||||
collapsible && children.length
|
||||
? () => handleCollapse(id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{createPortal(
|
||||
<DragOverlay dropAnimation={dropAnimationConfig}>
|
||||
{activeId && activeItem ? (
|
||||
<SortableTreeItem
|
||||
id={activeId}
|
||||
depth={activeItem.depth}
|
||||
clone
|
||||
childCount={getChildCount(items, activeId, childrenProp) + 1}
|
||||
value={renderValue(activeItem as unknown as T)}
|
||||
indentationWidth={0}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { forwardRef, HTMLAttributes, ReactNode } from "react"
|
||||
|
||||
import {
|
||||
DotsSix,
|
||||
FolderIllustration,
|
||||
FolderOpenIllustration,
|
||||
Swatch,
|
||||
TriangleRightMini,
|
||||
} from "@medusajs/icons"
|
||||
import { Badge, clx, IconButton } from "@medusajs/ui"
|
||||
import { HandleProps } from "./types"
|
||||
|
||||
export interface TreeItemProps
|
||||
extends Omit<HTMLAttributes<HTMLLIElement>, "id"> {
|
||||
childCount?: number
|
||||
clone?: boolean
|
||||
collapsed?: boolean
|
||||
depth: number
|
||||
disableInteraction?: boolean
|
||||
disableSelection?: boolean
|
||||
ghost?: boolean
|
||||
handleProps?: HandleProps
|
||||
indentationWidth: number
|
||||
value: ReactNode
|
||||
disabled?: boolean
|
||||
onCollapse?(): void
|
||||
wrapperRef?(node: HTMLLIElement): void
|
||||
}
|
||||
|
||||
export const TreeItem = forwardRef<HTMLDivElement, TreeItemProps>(
|
||||
(
|
||||
{
|
||||
childCount,
|
||||
clone,
|
||||
depth,
|
||||
disableSelection,
|
||||
disableInteraction,
|
||||
ghost,
|
||||
handleProps,
|
||||
indentationWidth,
|
||||
collapsed,
|
||||
onCollapse,
|
||||
style,
|
||||
value,
|
||||
disabled,
|
||||
wrapperRef,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<li
|
||||
ref={wrapperRef}
|
||||
style={
|
||||
{
|
||||
paddingLeft: `${indentationWidth * depth}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={clx("-mb-px list-none", {
|
||||
"pointer-events-none": disableInteraction,
|
||||
"select-none": disableSelection,
|
||||
"[&:first-of-type>div]:border-t-0": !clone,
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
className={clx(
|
||||
"bg-ui-bg-base transition-fg relative flex items-center gap-x-3 border-y px-6 py-2.5",
|
||||
{
|
||||
"border-l": depth > 0,
|
||||
"shadow-elevation-flyout bg-ui-bg-base w-fit rounded-lg border-none pr-6 opacity-80":
|
||||
clone,
|
||||
"bg-ui-bg-base-hover z-[1] opacity-50": ghost,
|
||||
"bg-ui-bg-disabled": disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Handle {...handleProps} disabled={disabled} />
|
||||
<Collapse
|
||||
collapsed={collapsed}
|
||||
onCollapse={onCollapse}
|
||||
clone={clone}
|
||||
/>
|
||||
<Icon
|
||||
childrenCount={childCount}
|
||||
collapsed={collapsed}
|
||||
clone={clone}
|
||||
/>
|
||||
<Value value={value} />
|
||||
<ChildrenCount clone={clone} childrenCount={childCount} />
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
)
|
||||
TreeItem.displayName = "TreeItem"
|
||||
|
||||
const Handle = ({
|
||||
listeners,
|
||||
attributes,
|
||||
disabled,
|
||||
}: HandleProps & { disabled?: boolean }) => {
|
||||
return (
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="transparent"
|
||||
type="button"
|
||||
className={clx("cursor-grab", { "cursor-not-allowed": disabled })}
|
||||
disabled={disabled}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<DotsSix />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
type IconProps = {
|
||||
childrenCount?: number
|
||||
collapsed?: boolean
|
||||
clone?: boolean
|
||||
}
|
||||
|
||||
const Icon = ({ childrenCount, collapsed, clone }: IconProps) => {
|
||||
const isBranch = clone ? childrenCount && childrenCount > 1 : childrenCount
|
||||
const isOpen = clone ? false : !collapsed
|
||||
|
||||
return (
|
||||
<div className="flex size-7 items-center justify-center">
|
||||
{isBranch ? (
|
||||
isOpen ? (
|
||||
<FolderOpenIllustration />
|
||||
) : (
|
||||
<FolderIllustration />
|
||||
)
|
||||
) : (
|
||||
<Swatch className="text-ui-fg-muted" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CollapseProps = {
|
||||
collapsed?: boolean
|
||||
onCollapse?: () => void
|
||||
clone?: boolean
|
||||
}
|
||||
|
||||
const Collapse = ({ collapsed, onCollapse, clone }: CollapseProps) => {
|
||||
if (clone) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!onCollapse) {
|
||||
return <div className="size-7" role="presentation" />
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="transparent"
|
||||
onClick={onCollapse}
|
||||
type="button"
|
||||
>
|
||||
<TriangleRightMini
|
||||
className={clx("text-ui-fg-subtle transition-transform", {
|
||||
"rotate-90": !collapsed,
|
||||
})}
|
||||
/>
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
type ValueProps = {
|
||||
value: ReactNode
|
||||
}
|
||||
|
||||
const Value = ({ value }: ValueProps) => {
|
||||
return (
|
||||
<div className="txt-compact-small text-ui-fg-subtle flex-grow truncate">
|
||||
{value}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ChildrenCountProps = {
|
||||
clone?: boolean
|
||||
childrenCount?: number
|
||||
}
|
||||
|
||||
const ChildrenCount = ({ clone, childrenCount }: ChildrenCountProps) => {
|
||||
if (!clone || !childrenCount) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (clone && childrenCount <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge size="2xsmall" color="blue" className="absolute -right-2 -top-2">
|
||||
{childrenCount}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core"
|
||||
import { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities"
|
||||
import type { MutableRefObject } from "react"
|
||||
|
||||
export interface TreeItem extends Record<string, unknown> {
|
||||
id: UniqueIdentifier
|
||||
}
|
||||
|
||||
export interface FlattenedItem extends TreeItem {
|
||||
parentId: UniqueIdentifier | null
|
||||
depth: number
|
||||
index: number
|
||||
}
|
||||
|
||||
export type SensorContext = MutableRefObject<{
|
||||
items: FlattenedItem[]
|
||||
offset: number
|
||||
}>
|
||||
|
||||
export type HandleProps = {
|
||||
attributes?: DraggableAttributes | undefined
|
||||
listeners?: SyntheticListenerMap | undefined
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import type { UniqueIdentifier } from "@dnd-kit/core"
|
||||
import { arrayMove } from "@dnd-kit/sortable"
|
||||
|
||||
import type { FlattenedItem, TreeItem } from "./types"
|
||||
|
||||
export const iOS = /iPad|iPhone|iPod/.test(navigator.platform)
|
||||
|
||||
function getDragDepth(offset: number, indentationWidth: number) {
|
||||
return Math.round(offset / indentationWidth)
|
||||
}
|
||||
|
||||
export function getProjection(
|
||||
items: FlattenedItem[],
|
||||
activeId: UniqueIdentifier,
|
||||
overId: UniqueIdentifier,
|
||||
dragOffset: number,
|
||||
indentationWidth: number
|
||||
) {
|
||||
const overItemIndex = items.findIndex(({ id }) => id === overId)
|
||||
const activeItemIndex = items.findIndex(({ id }) => id === activeId)
|
||||
const activeItem = items[activeItemIndex]
|
||||
const newItems = arrayMove(items, activeItemIndex, overItemIndex)
|
||||
const previousItem = newItems[overItemIndex - 1]
|
||||
const nextItem = newItems[overItemIndex + 1]
|
||||
const dragDepth = getDragDepth(dragOffset, indentationWidth)
|
||||
const projectedDepth = activeItem.depth + dragDepth
|
||||
const maxDepth = getMaxDepth({
|
||||
previousItem,
|
||||
})
|
||||
const minDepth = getMinDepth({ nextItem })
|
||||
let depth = projectedDepth
|
||||
|
||||
if (projectedDepth >= maxDepth) {
|
||||
depth = maxDepth
|
||||
} else if (projectedDepth < minDepth) {
|
||||
depth = minDepth
|
||||
}
|
||||
|
||||
return { depth, maxDepth, minDepth, parentId: getParentId() }
|
||||
|
||||
function getParentId() {
|
||||
if (depth === 0 || !previousItem) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (depth === previousItem.depth) {
|
||||
return previousItem.parentId
|
||||
}
|
||||
|
||||
if (depth > previousItem.depth) {
|
||||
return previousItem.id
|
||||
}
|
||||
|
||||
const newParent = newItems
|
||||
.slice(0, overItemIndex)
|
||||
.reverse()
|
||||
.find((item) => item.depth === depth)?.parentId
|
||||
|
||||
return newParent ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function getMaxDepth({ previousItem }: { previousItem: FlattenedItem }) {
|
||||
if (previousItem) {
|
||||
return previousItem.depth + 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function getMinDepth({ nextItem }: { nextItem: FlattenedItem }) {
|
||||
if (nextItem) {
|
||||
return nextItem.depth
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function flatten<T extends TreeItem>(
|
||||
items: T[],
|
||||
parentId: UniqueIdentifier | null = null,
|
||||
depth = 0,
|
||||
childrenProp: string
|
||||
): FlattenedItem[] {
|
||||
return items.reduce<FlattenedItem[]>((acc, item, index) => {
|
||||
const children = (item[childrenProp] || []) as T[]
|
||||
|
||||
return [
|
||||
...acc,
|
||||
{ ...item, parentId, depth, index },
|
||||
...flatten(children, item.id, depth + 1, childrenProp),
|
||||
]
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function flattenTree<T extends TreeItem>(
|
||||
items: T[],
|
||||
childrenProp: string
|
||||
): FlattenedItem[] {
|
||||
return flatten(items, undefined, undefined, childrenProp)
|
||||
}
|
||||
|
||||
type ItemUpdate = {
|
||||
id: UniqueIdentifier
|
||||
parentId: UniqueIdentifier | null
|
||||
index: number
|
||||
}
|
||||
|
||||
export function buildTree<T extends TreeItem>(
|
||||
flattenedItems: FlattenedItem[],
|
||||
newIndex: number,
|
||||
childrenProp: string
|
||||
): { items: T[]; update: ItemUpdate } {
|
||||
const root = { id: "root", [childrenProp]: [] } as T
|
||||
const nodes: Record<string, T> = { [root.id]: root }
|
||||
const items = flattenedItems.map((item) => ({ ...item, [childrenProp]: [] }))
|
||||
|
||||
let update: {
|
||||
id: UniqueIdentifier | null
|
||||
parentId: UniqueIdentifier | null
|
||||
index: number
|
||||
} = {
|
||||
id: null,
|
||||
parentId: null,
|
||||
index: 0,
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const {
|
||||
id,
|
||||
index: _index,
|
||||
depth: _depth,
|
||||
parentId: _parentId,
|
||||
...rest
|
||||
} = item
|
||||
const children = (item[childrenProp] || []) as T[]
|
||||
|
||||
const parentId = _parentId ?? root.id
|
||||
const parent = nodes[parentId] ?? findItem(items, parentId)
|
||||
|
||||
nodes[id] = { id, [childrenProp]: children } as T
|
||||
;(parent[childrenProp] as T[]).push({
|
||||
id,
|
||||
...rest,
|
||||
[childrenProp]: children,
|
||||
} as T)
|
||||
|
||||
/**
|
||||
* Get the information for them item that was moved to the `newIndex`.
|
||||
*/
|
||||
if (index === newIndex) {
|
||||
const parentChildren = parent[childrenProp] as FlattenedItem[]
|
||||
|
||||
update = {
|
||||
id: item.id,
|
||||
parentId: parent.id === "root" ? null : parent.id,
|
||||
index: parentChildren.length - 1,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!update.id) {
|
||||
throw new Error("Could not find item")
|
||||
}
|
||||
|
||||
return {
|
||||
items: root[childrenProp] as T[],
|
||||
update: update as ItemUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
export function findItem<T extends TreeItem>(
|
||||
items: T[],
|
||||
itemId: UniqueIdentifier
|
||||
) {
|
||||
return items.find(({ id }) => id === itemId)
|
||||
}
|
||||
|
||||
export function findItemDeep<T extends TreeItem>(
|
||||
items: T[],
|
||||
itemId: UniqueIdentifier,
|
||||
childrenProp: string
|
||||
): TreeItem | undefined {
|
||||
for (const item of items) {
|
||||
const { id } = item
|
||||
const children = (item[childrenProp] || []) as T[]
|
||||
|
||||
if (id === itemId) {
|
||||
return item
|
||||
}
|
||||
|
||||
if (children.length) {
|
||||
const child = findItemDeep(children, itemId, childrenProp)
|
||||
|
||||
if (child) {
|
||||
return child
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function setProperty<TItem extends TreeItem, T extends keyof TItem>(
|
||||
items: TItem[],
|
||||
id: UniqueIdentifier,
|
||||
property: T,
|
||||
childrenProp: keyof TItem, // Make childrenProp a key of TItem
|
||||
setter: (value: TItem[T]) => TItem[T]
|
||||
): TItem[] {
|
||||
return items.map((item) => {
|
||||
if (item.id === id) {
|
||||
return {
|
||||
...item,
|
||||
[property]: setter(item[property]),
|
||||
}
|
||||
}
|
||||
|
||||
const children = item[childrenProp] as TItem[] | undefined
|
||||
|
||||
if (children && children.length) {
|
||||
return {
|
||||
...item,
|
||||
[childrenProp]: setProperty(
|
||||
children,
|
||||
id,
|
||||
property,
|
||||
childrenProp,
|
||||
setter
|
||||
),
|
||||
} as TItem // Explicitly cast to TItem
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
function countChildren<T extends TreeItem>(
|
||||
items: T[],
|
||||
count = 0,
|
||||
childrenProp: string
|
||||
): number {
|
||||
return items.reduce((acc, item) => {
|
||||
const children = (item[childrenProp] || []) as T[]
|
||||
|
||||
if (children.length) {
|
||||
return countChildren(children, acc + 1, childrenProp)
|
||||
}
|
||||
|
||||
return acc + 1
|
||||
}, count)
|
||||
}
|
||||
|
||||
export function getChildCount<T extends TreeItem>(
|
||||
items: T[],
|
||||
id: UniqueIdentifier,
|
||||
childrenProp: string
|
||||
) {
|
||||
const item = findItemDeep(items, id, childrenProp)
|
||||
|
||||
const children = (item?.[childrenProp] || []) as T[]
|
||||
|
||||
return item ? countChildren(children, 0, childrenProp) : 0
|
||||
}
|
||||
|
||||
export function removeChildrenOf(
|
||||
items: FlattenedItem[],
|
||||
ids: UniqueIdentifier[],
|
||||
childrenProp: string
|
||||
) {
|
||||
const excludeParentIds = [...ids]
|
||||
|
||||
return items.filter((item) => {
|
||||
if (item.parentId && excludeParentIds.includes(item.parentId)) {
|
||||
const children = (item[childrenProp] || []) as FlattenedItem[]
|
||||
|
||||
if (children.length) {
|
||||
excludeParentIds.push(item.id)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function listItemsWithChildren<T extends TreeItem>(
|
||||
items: T[],
|
||||
childrenProp: string
|
||||
): T[] {
|
||||
return items.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
[childrenProp]: item[childrenProp]
|
||||
? listItemsWithChildren(item[childrenProp] as TreeItem[], childrenProp)
|
||||
: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./switch-box"
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Switch } from "@medusajs/ui"
|
||||
import { ReactNode } from "react"
|
||||
import { ControllerProps, FieldPath, FieldValues } from "react-hook-form"
|
||||
|
||||
import { Form } from "../../common/form"
|
||||
|
||||
interface HeadlessControllerProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends Omit<ControllerProps<TFieldValues, TName>, "render"> {}
|
||||
|
||||
interface SwitchBoxProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends HeadlessControllerProps<TFieldValues, TName>{
|
||||
label: string
|
||||
description: string
|
||||
optional?: boolean
|
||||
tooltip?: ReactNode
|
||||
/**
|
||||
* Callback for performing additional actions when the checked state changes.
|
||||
* This does not intercept the form control, it is only used for injecting side-effects.
|
||||
*/
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for the Switch component to be used with `react-hook-form`.
|
||||
*
|
||||
* Use this component whenever a design calls for wrapping the Switch component
|
||||
* in a container with a label and description.
|
||||
*/
|
||||
export const SwitchBox = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
label,
|
||||
description,
|
||||
optional = false,
|
||||
tooltip,
|
||||
onCheckedChange,
|
||||
...props
|
||||
}: SwitchBoxProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<Form.Field
|
||||
{...props}
|
||||
render={({ field: { value, onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="bg-ui-bg-component shadow-elevation-card-rest flex items-start gap-x-3 rounded-lg p-3">
|
||||
<Form.Control>
|
||||
<Switch {...field} checked={value} onCheckedChange={(e) => {
|
||||
onCheckedChange?.(e)
|
||||
onChange(e)
|
||||
}} />
|
||||
</Form.Control>
|
||||
<div>
|
||||
<Form.Label optional={optional} tooltip={tooltip}>
|
||||
{label}
|
||||
</Form.Label>
|
||||
<Form.Hint>{description}</Form.Hint>
|
||||
</div>
|
||||
</div>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BuildingTax } from "@medusajs/icons"
|
||||
import { Tooltip, clx } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type IncludesTaxTooltipProps = {
|
||||
includesTax?: boolean
|
||||
}
|
||||
|
||||
export const IncludesTaxTooltip = ({
|
||||
includesTax,
|
||||
}: IncludesTaxTooltipProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
maxWidth={999}
|
||||
content={
|
||||
includesTax
|
||||
? t("general.includesTaxTooltip")
|
||||
: t("general.excludesTaxTooltip")
|
||||
}
|
||||
>
|
||||
<BuildingTax className={clx({ "text-ui-fg-muted": !includesTax })} />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./thumbnail";
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Photo } from "@medusajs/icons"
|
||||
|
||||
type ThumbnailProps = {
|
||||
src?: string | null
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export const Thumbnail = ({ src, alt }: ThumbnailProps) => {
|
||||
return (
|
||||
<div className="bg-ui-bg-component flex h-8 w-6 items-center justify-center overflow-hidden rounded-[4px]">
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="h-full w-full object-cover object-center"
|
||||
/>
|
||||
) : (
|
||||
<Photo className="text-ui-fg-subtle" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./user-link"
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Avatar, Text } from "@medusajs/ui"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
type UserLinkProps = {
|
||||
id: string
|
||||
first_name?: string | null
|
||||
last_name?: string | null
|
||||
email: string
|
||||
type?: "customer" | "user"
|
||||
}
|
||||
|
||||
export const UserLink = ({
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
type = "user",
|
||||
}: UserLinkProps) => {
|
||||
const name = [first_name, last_name].filter(Boolean).join(" ")
|
||||
const fallback = name ? name.slice(0, 1) : email.slice(0, 1)
|
||||
const link = type === "user" ? `/settings/users/${id}` : `/customers/${id}`
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={link}
|
||||
className="flex items-center gap-x-2 w-fit transition-fg hover:text-ui-fg-subtle outline-none focus-visible:shadow-borders-focus rounded-md"
|
||||
>
|
||||
<Avatar size="2xsmall" fallback={fallback.toUpperCase()} />
|
||||
<Text size="small" leading="compact" weight="regular">
|
||||
{name || email}
|
||||
</Text>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { Checkbox } from "@medusajs/ui"
|
||||
import { Controller, ControllerRenderProps } from "react-hook-form"
|
||||
|
||||
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
|
||||
import { useDataGridCell, useDataGridCellError } from "../hooks"
|
||||
import { DataGridCellProps, InputProps } from "../types"
|
||||
import { DataGridCellContainer } from "./data-grid-cell-container"
|
||||
|
||||
export const DataGridBooleanCell = <TData, TValue = any>({
|
||||
context,
|
||||
disabled,
|
||||
}: DataGridCellProps<TData, TValue> & { disabled?: boolean }) => {
|
||||
const { field, control, renderProps } = useDataGridCell({
|
||||
context,
|
||||
})
|
||||
const errorProps = useDataGridCellError({ context })
|
||||
|
||||
const { container, input } = renderProps
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={field}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<DataGridCellContainer {...container} {...errorProps}>
|
||||
<Inner field={field} inputProps={input} disabled={disabled} />
|
||||
</DataGridCellContainer>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const Inner = ({
|
||||
field,
|
||||
inputProps,
|
||||
disabled,
|
||||
}: {
|
||||
field: ControllerRenderProps<any, string>
|
||||
inputProps: InputProps
|
||||
disabled?: boolean
|
||||
}) => {
|
||||
const { ref, value, onBlur, name, disabled: fieldDisabled } = field
|
||||
const {
|
||||
ref: inputRef,
|
||||
onBlur: onInputBlur,
|
||||
onChange,
|
||||
onFocus,
|
||||
...attributes
|
||||
} = inputProps
|
||||
|
||||
const combinedRefs = useCombinedRefs(ref, inputRef)
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
disabled={disabled || fieldDisabled}
|
||||
name={name}
|
||||
checked={value}
|
||||
onCheckedChange={(newValue) => onChange(newValue === true, value)}
|
||||
onFocus={onFocus}
|
||||
onBlur={() => {
|
||||
onBlur()
|
||||
onInputBlur()
|
||||
}}
|
||||
ref={combinedRefs}
|
||||
tabIndex={-1}
|
||||
{...attributes}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { ErrorMessage } from "@hookform/error-message"
|
||||
import { ExclamationCircle } from "@medusajs/icons"
|
||||
import { Tooltip, clx } from "@medusajs/ui"
|
||||
import { PropsWithChildren } from "react"
|
||||
import { get } from "react-hook-form"
|
||||
|
||||
import { DataGridCellContainerProps, DataGridErrorRenderProps } from "../types"
|
||||
import { DataGridRowErrorIndicator } from "./data-grid-row-error-indicator"
|
||||
|
||||
export const DataGridCellContainer = ({
|
||||
isAnchor,
|
||||
isSelected,
|
||||
isDragSelected,
|
||||
field,
|
||||
showOverlay,
|
||||
placeholder,
|
||||
innerProps,
|
||||
overlayProps,
|
||||
children,
|
||||
errors,
|
||||
rowErrors,
|
||||
}: DataGridCellContainerProps & DataGridErrorRenderProps<any>) => {
|
||||
const error = get(errors, field)
|
||||
const hasError = !!error
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"bg-ui-bg-base group/cell relative flex size-full items-center gap-x-2 px-4 py-2.5 outline-none",
|
||||
{
|
||||
"bg-ui-tag-red-bg text-ui-tag-red-text":
|
||||
hasError && !isAnchor && !isSelected && !isDragSelected,
|
||||
"ring-ui-bg-interactive ring-2 ring-inset": isAnchor,
|
||||
"bg-ui-bg-highlight [&:has([data-field]:focus)]:bg-ui-bg-base":
|
||||
isSelected || isAnchor,
|
||||
"bg-ui-bg-subtle": isDragSelected && !isAnchor,
|
||||
}
|
||||
)}
|
||||
tabIndex={-1}
|
||||
{...innerProps}
|
||||
>
|
||||
<ErrorMessage
|
||||
name={field}
|
||||
errors={errors}
|
||||
render={({ message }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Tooltip content={message} delayDuration={0}>
|
||||
<ExclamationCircle className="text-ui-tag-red-icon z-[3]" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-[1] flex size-full items-center justify-center">
|
||||
<RenderChildren isAnchor={isAnchor} placeholder={placeholder}>
|
||||
{children}
|
||||
</RenderChildren>
|
||||
</div>
|
||||
<DataGridRowErrorIndicator rowErrors={rowErrors} />
|
||||
{showOverlay && (
|
||||
<div
|
||||
{...overlayProps}
|
||||
data-cell-overlay="true"
|
||||
className="absolute inset-0 z-[2] size-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RenderChildren = ({
|
||||
isAnchor,
|
||||
placeholder,
|
||||
children,
|
||||
}: PropsWithChildren<
|
||||
Pick<DataGridCellContainerProps, "isAnchor" | "placeholder">
|
||||
>) => {
|
||||
if (!isAnchor && placeholder) {
|
||||
return placeholder
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
// Not currently used, re-implement or delete depending on whether there is a need for it in the future.
|
||||
|
||||
// import { TrianglesMini } from "@medusajs/icons"
|
||||
// import { clx } from "@medusajs/ui"
|
||||
// import { ComponentPropsWithoutRef, forwardRef, memo } from "react"
|
||||
// import { Controller, ControllerRenderProps } from "react-hook-form"
|
||||
|
||||
// import { useCombinedRefs } from "../../../hooks/use-combined-refs"
|
||||
// import { countries } from "../../../lib/data/countries"
|
||||
// import { useDataGridCell } from "../hooks"
|
||||
// import { DataGridCellProps, InputProps } from "../types"
|
||||
// import { DataGridCellContainer } from "./data-grid-cell-container"
|
||||
|
||||
// export const DataGridCountrySelectCell = <TData, TValue = any>({
|
||||
// field,
|
||||
// context,
|
||||
// }: DataGridCellProps<TData, TValue>) => {
|
||||
// const { control, renderProps } = useDataGridCell({
|
||||
// field,
|
||||
// context,
|
||||
// type: "select",
|
||||
// })
|
||||
|
||||
// const { container, input } = renderProps
|
||||
|
||||
// return (
|
||||
// <Controller
|
||||
// control={control}
|
||||
// name={field}
|
||||
// render={({ field: { value, onChange: _, disabled, ...field } }) => {
|
||||
// return (
|
||||
// <DataGridCellContainer
|
||||
// {...container}
|
||||
// placeholder={
|
||||
// <DataGridCountryCellPlaceholder
|
||||
// value={value}
|
||||
// disabled={disabled}
|
||||
// attributes={attributes}
|
||||
// />
|
||||
// }
|
||||
// >
|
||||
// <MemoizedDataGridCountryCell
|
||||
// value={value}
|
||||
// onChange={(e) => onChange(e.target.value, value)}
|
||||
// disabled={disabled}
|
||||
// {...attributes}
|
||||
// {...field}
|
||||
// />
|
||||
// </DataGridCellContainer>
|
||||
// )
|
||||
// }}
|
||||
// />
|
||||
// )
|
||||
// }
|
||||
|
||||
// const Inner = ({
|
||||
// field,
|
||||
// inputProps,
|
||||
// }: {
|
||||
// field: ControllerRenderProps<any, string>
|
||||
// inputProps: InputProps
|
||||
// }) => {
|
||||
// const { value, onChange, onBlur, ref, ...rest } = field
|
||||
// const { ref: inputRef, onBlur: onInputBlur, ...input } = inputProps
|
||||
|
||||
// const combinedRefs = useCombinedRefs(inputRef, ref)
|
||||
|
||||
// return (
|
||||
// <MemoizedDataGridCountryCell
|
||||
// value={value}
|
||||
// onChange={(e) => onChange(e.target.value, value)}
|
||||
// onBlur={() => {
|
||||
// onBlur()
|
||||
// onInputBlur()
|
||||
// }}
|
||||
// ref={combinedRefs}
|
||||
// {...input}
|
||||
// {...rest}
|
||||
// />
|
||||
// )
|
||||
// }
|
||||
|
||||
// const DataGridCountryCellPlaceholder = ({
|
||||
// value,
|
||||
// disabled,
|
||||
// attributes,
|
||||
// }: {
|
||||
// value?: string
|
||||
// disabled?: boolean
|
||||
// attributes: Record<string, any>
|
||||
// }) => {
|
||||
// const country = countries.find((c) => c.iso_2 === value)
|
||||
|
||||
// return (
|
||||
// <div className="relative flex size-full" {...attributes}>
|
||||
// <TrianglesMini
|
||||
// className={clx(
|
||||
// "text-ui-fg-muted transition-fg pointer-events-none absolute right-4 top-1/2 -translate-y-1/2",
|
||||
// {
|
||||
// "text-ui-fg-disabled": disabled,
|
||||
// }
|
||||
// )}
|
||||
// />
|
||||
// <div
|
||||
// className={clx(
|
||||
// "txt-compact-small w-full appearance-none bg-transparent px-4 py-2.5 outline-none"
|
||||
// )}
|
||||
// >
|
||||
// {country?.display_name}
|
||||
// </div>
|
||||
// </div>
|
||||
// )
|
||||
// }
|
||||
|
||||
// const DataGridCountryCellImpl = forwardRef<
|
||||
// HTMLSelectElement,
|
||||
// ComponentPropsWithoutRef<"select">
|
||||
// >(({ disabled, className, ...props }, ref) => {
|
||||
// return (
|
||||
// <div className="relative flex size-full">
|
||||
// <TrianglesMini
|
||||
// className={clx(
|
||||
// "text-ui-fg-muted transition-fg pointer-events-none absolute right-4 top-1/2 -translate-y-1/2",
|
||||
// {
|
||||
// "text-ui-fg-disabled": disabled,
|
||||
// }
|
||||
// )}
|
||||
// />
|
||||
// <select
|
||||
// {...props}
|
||||
// ref={ref}
|
||||
// className={clx(
|
||||
// "txt-compact-small w-full appearance-none bg-transparent px-4 py-2.5 outline-none",
|
||||
// className
|
||||
// )}
|
||||
// >
|
||||
// <option value=""></option>
|
||||
// {countries.map((country) => (
|
||||
// <option key={country.iso_2} value={country.iso_2}>
|
||||
// {country.display_name}
|
||||
// </option>
|
||||
// ))}
|
||||
// </select>
|
||||
// </div>
|
||||
// )
|
||||
// })
|
||||
// DataGridCountryCellImpl.displayName = "DataGridCountryCell"
|
||||
|
||||
// const MemoizedDataGridCountryCell = memo(DataGridCountryCellImpl)
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
import CurrencyInput, {
|
||||
CurrencyInputProps,
|
||||
formatValue,
|
||||
} from "react-currency-input-field"
|
||||
import { Controller, ControllerRenderProps } from "react-hook-form"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
|
||||
import { CurrencyInfo, currencies } from "../../../lib/data/currencies"
|
||||
import { useDataGridCell, useDataGridCellError } from "../hooks"
|
||||
import { DataGridCellProps, InputProps } from "../types"
|
||||
import { DataGridCellContainer } from "./data-grid-cell-container"
|
||||
|
||||
interface DataGridCurrencyCellProps<TData, TValue = any>
|
||||
extends DataGridCellProps<TData, TValue> {
|
||||
code: string
|
||||
}
|
||||
|
||||
export const DataGridCurrencyCell = <TData, TValue = any>({
|
||||
context,
|
||||
code,
|
||||
}: DataGridCurrencyCellProps<TData, TValue>) => {
|
||||
const { field, control, renderProps } = useDataGridCell({
|
||||
context,
|
||||
})
|
||||
const errorProps = useDataGridCellError({ context })
|
||||
|
||||
const { container, input } = renderProps
|
||||
|
||||
const currency = currencies[code.toUpperCase()]
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={field}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<DataGridCellContainer {...container} {...errorProps}>
|
||||
<Inner field={field} inputProps={input} currencyInfo={currency} />
|
||||
</DataGridCellContainer>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const Inner = ({
|
||||
field,
|
||||
inputProps,
|
||||
currencyInfo,
|
||||
}: {
|
||||
field: ControllerRenderProps<any, string>
|
||||
inputProps: InputProps
|
||||
currencyInfo: CurrencyInfo
|
||||
}) => {
|
||||
const { value, onChange: _, onBlur, ref, ...rest } = field
|
||||
const {
|
||||
ref: inputRef,
|
||||
onBlur: onInputBlur,
|
||||
onFocus,
|
||||
onChange,
|
||||
...attributes
|
||||
} = inputProps
|
||||
|
||||
const formatter = useCallback(
|
||||
(value?: string | number) => {
|
||||
const ensuredValue =
|
||||
typeof value === "number" ? value.toString() : value || ""
|
||||
|
||||
return formatValue({
|
||||
value: ensuredValue,
|
||||
decimalScale: currencyInfo.decimal_digits,
|
||||
disableGroupSeparators: true,
|
||||
decimalSeparator: ".",
|
||||
})
|
||||
},
|
||||
[currencyInfo]
|
||||
)
|
||||
|
||||
const [localValue, setLocalValue] = useState<string | number>(value || "")
|
||||
|
||||
const handleValueChange: CurrencyInputProps["onValueChange"] = (
|
||||
value,
|
||||
_name,
|
||||
_values
|
||||
) => {
|
||||
if (!value) {
|
||||
setLocalValue("")
|
||||
return
|
||||
}
|
||||
|
||||
setLocalValue(value)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let update = value
|
||||
|
||||
// The component we use is a bit fidly when the value is updated externally
|
||||
// so we need to ensure a format that will result in the cell being formatted correctly
|
||||
// according to the users locale on the next render.
|
||||
if (!isNaN(Number(value))) {
|
||||
update = formatter(update)
|
||||
}
|
||||
|
||||
setLocalValue(update)
|
||||
}, [value, formatter])
|
||||
|
||||
const combinedRed = useCombinedRefs(inputRef, ref)
|
||||
|
||||
return (
|
||||
<div className="relative flex size-full items-center">
|
||||
<span
|
||||
className="txt-compact-small text-ui-fg-muted pointer-events-none absolute left-0 w-fit min-w-4"
|
||||
aria-hidden
|
||||
>
|
||||
{currencyInfo.symbol_native}
|
||||
</span>
|
||||
<CurrencyInput
|
||||
{...rest}
|
||||
{...attributes}
|
||||
ref={combinedRed}
|
||||
className="txt-compact-small w-full flex-1 cursor-default appearance-none bg-transparent pl-8 text-right outline-none"
|
||||
value={localValue || undefined}
|
||||
onValueChange={handleValueChange}
|
||||
formatValueOnBlur
|
||||
onBlur={() => {
|
||||
onBlur()
|
||||
onInputBlur()
|
||||
|
||||
onChange(localValue, value)
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
decimalScale={currencyInfo.decimal_digits}
|
||||
decimalsLimit={currencyInfo.decimal_digits}
|
||||
autoComplete="off"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import { XMark } from "@medusajs/icons"
|
||||
import {
|
||||
Button,
|
||||
clx,
|
||||
Heading,
|
||||
IconButton,
|
||||
Input,
|
||||
Kbd,
|
||||
Text,
|
||||
} from "@medusajs/ui"
|
||||
import * as Dialog from "@radix-ui/react-dialog"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
const useDataGridShortcuts = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const shortcuts = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.undo"),
|
||||
keys: {
|
||||
Mac: ["⌘", "Z"],
|
||||
Windows: ["Ctrl", "Z"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.redo"),
|
||||
keys: {
|
||||
Mac: ["⇧", "⌘", "Z"],
|
||||
Windows: ["Shift", "Ctrl", "Z"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.copy"),
|
||||
keys: {
|
||||
Mac: ["⌘", "C"],
|
||||
Windows: ["Ctrl", "C"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.paste"),
|
||||
keys: {
|
||||
Mac: ["⌘", "V"],
|
||||
Windows: ["Ctrl", "V"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.edit"),
|
||||
keys: {
|
||||
Mac: ["↵"],
|
||||
Windows: ["Enter"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.delete"),
|
||||
keys: {
|
||||
Mac: ["⌫"],
|
||||
Windows: ["Backspace"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.clear"),
|
||||
keys: {
|
||||
Mac: ["Space"],
|
||||
Windows: ["Space"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.moveUp"),
|
||||
keys: {
|
||||
Mac: ["↑"],
|
||||
Windows: ["↑"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.moveDown"),
|
||||
keys: {
|
||||
Mac: ["↓"],
|
||||
Windows: ["↓"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.moveLeft"),
|
||||
keys: {
|
||||
Mac: ["←"],
|
||||
Windows: ["←"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.moveRight"),
|
||||
keys: {
|
||||
Mac: ["→"],
|
||||
Windows: ["→"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.moveTop"),
|
||||
keys: {
|
||||
Mac: ["⌘", "↑"],
|
||||
Windows: ["Ctrl", "↑"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.moveBottom"),
|
||||
keys: {
|
||||
Mac: ["⌘", "↓"],
|
||||
Windows: ["Ctrl", "↓"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.selectDown"),
|
||||
keys: {
|
||||
Mac: ["⇧", "↓"],
|
||||
Windows: ["Shift", "↓"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.selectUp"),
|
||||
keys: {
|
||||
Mac: ["⇧", "↑"],
|
||||
Windows: ["Shift", "↑"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.selectColumnDown"),
|
||||
keys: {
|
||||
Mac: ["⇧", "⌘", "↓"],
|
||||
Windows: ["Shift", "Ctrl", "↓"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.selectColumnUp"),
|
||||
keys: {
|
||||
Mac: ["⇧", "⌘", "↑"],
|
||||
Windows: ["Shift", "Ctrl", "↑"],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.focusToolbar"),
|
||||
keys: {
|
||||
Mac: ["⌃", "⌥", ","],
|
||||
Windows: ["Ctrl", "Alt", ","],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("dataGrid.shortcuts.commands.focusCancel"),
|
||||
keys: {
|
||||
Mac: ["⌃", "⌥", "."],
|
||||
Windows: ["Ctrl", "Alt", "."],
|
||||
},
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
return shortcuts
|
||||
}
|
||||
|
||||
type DataGridKeyboardShortcutModalProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const DataGridKeyboardShortcutModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DataGridKeyboardShortcutModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchValue, onSearchValueChange] = useState("")
|
||||
const shortcuts = useDataGridShortcuts()
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
return shortcuts.filter((shortcut) =>
|
||||
shortcut.label.toLowerCase().includes(searchValue.toLowerCase())
|
||||
)
|
||||
}, [searchValue, shortcuts])
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Trigger asChild>
|
||||
<Button size="small" variant="secondary">
|
||||
{t("dataGrid.shortcuts.label")}
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
className={clx(
|
||||
"bg-ui-bg-overlay fixed inset-0",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
)}
|
||||
/>
|
||||
<Dialog.Content className="bg-ui-bg-subtle shadow-elevation-modal fixed left-[50%] top-[50%] flex h-full max-h-[612px] w-full max-w-[560px] translate-x-[-50%] translate-y-[-50%] flex-col divide-y overflow-hidden rounded-lg outline-none">
|
||||
<div className="flex flex-col gap-y-3 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Dialog.Title asChild>
|
||||
<Heading>{t("app.menus.user.shortcuts")}</Heading>
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="sr-only"></Dialog.Description>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Kbd>esc</Kbd>
|
||||
<Dialog.Close asChild>
|
||||
<IconButton variant="transparent" size="small">
|
||||
<XMark />
|
||||
</IconButton>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
type="search"
|
||||
value={searchValue}
|
||||
autoFocus
|
||||
onChange={(e) => onSearchValueChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y overflow-y-auto">
|
||||
{searchResults.map((shortcut, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="text-ui-fg-subtle flex items-center justify-between px-6 py-3"
|
||||
>
|
||||
<Text size="small">{shortcut.label}</Text>
|
||||
<div className="flex items-center gap-x-1">
|
||||
{shortcut.keys.Mac?.map((key, index) => {
|
||||
return (
|
||||
<div className="flex items-center gap-x-1" key={index}>
|
||||
<Kbd>{key}</Kbd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Controller, ControllerRenderProps } from "react-hook-form"
|
||||
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
|
||||
import { useDataGridCell, useDataGridCellError } from "../hooks"
|
||||
import { DataGridCellProps, InputProps } from "../types"
|
||||
import { DataGridCellContainer } from "./data-grid-cell-container"
|
||||
|
||||
export const DataGridNumberCell = <TData, TValue = any>({
|
||||
context,
|
||||
...rest
|
||||
}: DataGridCellProps<TData, TValue> & {
|
||||
min?: number
|
||||
max?: number
|
||||
placeholder?: string
|
||||
}) => {
|
||||
const { field, control, renderProps } = useDataGridCell({
|
||||
context,
|
||||
})
|
||||
const errorProps = useDataGridCellError({ context })
|
||||
|
||||
const { container, input } = renderProps
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={field}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<DataGridCellContainer {...container} {...errorProps}>
|
||||
<Inner field={field} inputProps={input} {...rest} />
|
||||
</DataGridCellContainer>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const Inner = ({
|
||||
field,
|
||||
inputProps,
|
||||
...props
|
||||
}: {
|
||||
field: ControllerRenderProps<any, string>
|
||||
inputProps: InputProps
|
||||
min?: number
|
||||
max?: number
|
||||
placeholder?: string
|
||||
}) => {
|
||||
const { ref, value, onChange: _, onBlur, ...fieldProps } = field
|
||||
const {
|
||||
ref: inputRef,
|
||||
onChange,
|
||||
onBlur: onInputBlur,
|
||||
onFocus,
|
||||
...attributes
|
||||
} = inputProps
|
||||
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value)
|
||||
}, [value])
|
||||
|
||||
const combinedRefs = useCombinedRefs(inputRef, ref)
|
||||
|
||||
return (
|
||||
<div className="size-full">
|
||||
<input
|
||||
ref={combinedRefs}
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
onBlur()
|
||||
onInputBlur()
|
||||
|
||||
// We propagate the change to the field only when the input is blurred
|
||||
onChange(localValue, value)
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
className={clx(
|
||||
"txt-compact-small size-full bg-transparent outline-none",
|
||||
"placeholder:text-ui-fg-muted"
|
||||
)}
|
||||
tabIndex={-1}
|
||||
{...props}
|
||||
{...fieldProps}
|
||||
{...attributes}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { PropsWithChildren } from "react"
|
||||
|
||||
import { useDataGridCellError } from "../hooks"
|
||||
import { DataGridCellProps } from "../types"
|
||||
import { DataGridRowErrorIndicator } from "./data-grid-row-error-indicator"
|
||||
|
||||
type DataGridReadonlyCellProps<TData, TValue = any> = DataGridCellProps<
|
||||
TData,
|
||||
TValue
|
||||
> &
|
||||
PropsWithChildren
|
||||
|
||||
export const DataGridReadonlyCell = <TData, TValue = any>({
|
||||
context,
|
||||
children,
|
||||
}: DataGridReadonlyCellProps<TData, TValue>) => {
|
||||
const { rowErrors } = useDataGridCellError({ context })
|
||||
|
||||
return (
|
||||
<div className="bg-ui-bg-subtle txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center justify-between overflow-hidden px-4 py-2.5 outline-none">
|
||||
<span className="truncate">{children}</span>
|
||||
<DataGridRowErrorIndicator rowErrors={rowErrors} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,916 @@
|
||||
import {
|
||||
Adjustments,
|
||||
AdjustmentsDone,
|
||||
ExclamationCircle,
|
||||
} from "@medusajs/icons"
|
||||
import { Button, DropdownMenu, clx } from "@medusajs/ui"
|
||||
import {
|
||||
Cell,
|
||||
CellContext,
|
||||
Column,
|
||||
ColumnDef,
|
||||
Row,
|
||||
VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { VirtualItem, useVirtualizer } from "@tanstack/react-virtual"
|
||||
import React, {
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { FieldValues, UseFormReturn } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useCommandHistory } from "../../../hooks/use-command-history"
|
||||
import { ConditionalTooltip } from "../../common/conditional-tooltip"
|
||||
import { DataGridContext } from "../context"
|
||||
import {
|
||||
useDataGridCellHandlers,
|
||||
useDataGridCellMetadata,
|
||||
useDataGridCellSnapshot,
|
||||
useDataGridClipboardEvents,
|
||||
useDataGridColumnVisibility,
|
||||
useDataGridErrorHighlighting,
|
||||
useDataGridFormHandlers,
|
||||
useDataGridKeydownEvent,
|
||||
useDataGridMouseUpEvent,
|
||||
useDataGridNavigation,
|
||||
useDataGridQueryTool,
|
||||
} from "../hooks"
|
||||
import { DataGridMatrix } from "../models"
|
||||
import { DataGridCoordinates, GridColumnOption } from "../types"
|
||||
import { isCellMatch, isSpecialFocusKey } from "../utils"
|
||||
import { DataGridKeyboardShortcutModal } from "./data-grid-keyboard-shortcut-modal"
|
||||
export interface DataGridRootProps<
|
||||
TData,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
> {
|
||||
data?: TData[]
|
||||
columns: ColumnDef<TData>[]
|
||||
state: UseFormReturn<TFieldValues>
|
||||
getSubRows?: (row: TData) => TData[] | undefined
|
||||
onEditingChange?: (isEditing: boolean) => void
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 40
|
||||
|
||||
const getCommonPinningStyles = <TData,>(
|
||||
column: Column<TData>
|
||||
): CSSProperties => {
|
||||
const isPinned = column.getIsPinned()
|
||||
|
||||
/**
|
||||
* Since our border colors are semi-transparent, we need to set a custom border color
|
||||
* that looks the same as the actual border color, but has 100% opacity.
|
||||
*
|
||||
* We do this by checking if the current theme is dark mode, and then setting the border color
|
||||
* to the corresponding color.
|
||||
*/
|
||||
const isDarkMode = document.documentElement.classList.contains("dark")
|
||||
const BORDER_COLOR = isDarkMode ? "rgb(50,50,53)" : "rgb(228,228,231)"
|
||||
|
||||
return {
|
||||
position: isPinned ? "sticky" : "relative",
|
||||
width: column.getSize(),
|
||||
zIndex: isPinned ? 1 : 0,
|
||||
borderBottom: isPinned ? `1px solid ${BORDER_COLOR}` : undefined,
|
||||
borderRight: isPinned ? `1px solid ${BORDER_COLOR}` : undefined,
|
||||
left: isPinned === "left" ? `${column.getStart("left")}px` : undefined,
|
||||
right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* - [Minor] Add shortcuts overview modal.
|
||||
* - [Minor] Extend the commands to also support modifying the anchor and rangeEnd, to restore the previous focus after undo/redo.
|
||||
*/
|
||||
|
||||
export const DataGridRoot = <
|
||||
TData,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
>({
|
||||
data = [],
|
||||
columns,
|
||||
state,
|
||||
getSubRows,
|
||||
onEditingChange,
|
||||
}: DataGridRootProps<TData, TFieldValues>) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { redo, undo, execute } = useCommandHistory()
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
getValues,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = state
|
||||
|
||||
const [trapActive, setTrapActive] = useState(true)
|
||||
|
||||
const [anchor, setAnchor] = useState<DataGridCoordinates | null>(null)
|
||||
const [rangeEnd, setRangeEnd] = useState<DataGridCoordinates | null>(null)
|
||||
const [dragEnd, setDragEnd] = useState<DataGridCoordinates | null>(null)
|
||||
|
||||
const [isSelecting, setIsSelecting] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [rowVisibility, setRowVisibility] = useState<VisibilityState>({})
|
||||
|
||||
const grid = useReactTable({
|
||||
data: data,
|
||||
columns,
|
||||
initialState: {
|
||||
columnPinning: {
|
||||
left: [columns[0].id!],
|
||||
},
|
||||
},
|
||||
state: {
|
||||
columnVisibility,
|
||||
},
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getSubRows,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
defaultColumn: {
|
||||
size: 200,
|
||||
maxSize: 400,
|
||||
},
|
||||
})
|
||||
|
||||
const { flatRows } = grid.getRowModel()
|
||||
const flatColumns = grid.getAllFlatColumns()
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() => flatRows.filter((_, index) => rowVisibility?.[index] !== false),
|
||||
[flatRows, rowVisibility]
|
||||
)
|
||||
const visibleColumns = grid.getVisibleLeafColumns()
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: visibleRows.length,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
getScrollElement: () => containerRef.current,
|
||||
overscan: 5,
|
||||
rangeExtractor: (range) => {
|
||||
const toRender = new Set(
|
||||
Array.from(
|
||||
{ length: range.endIndex - range.startIndex + 1 },
|
||||
(_, i) => range.startIndex + i
|
||||
)
|
||||
)
|
||||
|
||||
if (anchor && visibleRows[anchor.row]) {
|
||||
toRender.add(anchor.row)
|
||||
}
|
||||
|
||||
if (rangeEnd && visibleRows[rangeEnd.row]) {
|
||||
toRender.add(rangeEnd.row)
|
||||
}
|
||||
|
||||
return Array.from(toRender).sort((a, b) => a - b)
|
||||
},
|
||||
})
|
||||
const virtualRows = rowVirtualizer.getVirtualItems()
|
||||
|
||||
const columnVirtualizer = useVirtualizer({
|
||||
count: visibleColumns.length,
|
||||
estimateSize: (index) => visibleColumns[index].getSize(),
|
||||
getScrollElement: () => containerRef.current,
|
||||
horizontal: true,
|
||||
overscan: 3,
|
||||
rangeExtractor: (range) => {
|
||||
const startIndex = range.startIndex
|
||||
const endIndex = range.endIndex
|
||||
|
||||
const toRender = new Set(
|
||||
Array.from(
|
||||
{ length: endIndex - startIndex + 1 },
|
||||
(_, i) => startIndex + i
|
||||
)
|
||||
)
|
||||
|
||||
if (anchor && visibleColumns[anchor.col]) {
|
||||
toRender.add(anchor.col)
|
||||
}
|
||||
|
||||
if (rangeEnd && visibleColumns[rangeEnd.col]) {
|
||||
toRender.add(rangeEnd.col)
|
||||
}
|
||||
|
||||
// The first column is pinned, so we always render it
|
||||
toRender.add(0)
|
||||
|
||||
return Array.from(toRender).sort((a, b) => a - b)
|
||||
},
|
||||
})
|
||||
const virtualColumns = columnVirtualizer.getVirtualItems()
|
||||
|
||||
let virtualPaddingLeft: number | undefined
|
||||
let virtualPaddingRight: number | undefined
|
||||
|
||||
if (columnVirtualizer && virtualColumns?.length) {
|
||||
virtualPaddingLeft = virtualColumns[0]?.start ?? 0
|
||||
virtualPaddingRight =
|
||||
columnVirtualizer.getTotalSize() -
|
||||
(virtualColumns[virtualColumns.length - 1]?.end ?? 0)
|
||||
}
|
||||
|
||||
const matrix = useMemo(
|
||||
() => new DataGridMatrix<TData, TFieldValues>(flatRows, columns),
|
||||
[flatRows, columns]
|
||||
)
|
||||
const queryTool = useDataGridQueryTool(containerRef)
|
||||
|
||||
const setSingleRange = useCallback(
|
||||
(coordinates: DataGridCoordinates | null) => {
|
||||
setAnchor(coordinates)
|
||||
setRangeEnd(coordinates)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const { errorCount, isHighlighted, toggleErrorHighlighting } =
|
||||
useDataGridErrorHighlighting(matrix, grid, errors)
|
||||
|
||||
const handleToggleErrorHighlighting = useCallback(() => {
|
||||
toggleErrorHighlighting(
|
||||
rowVisibility,
|
||||
columnVisibility,
|
||||
setRowVisibility,
|
||||
setColumnVisibility
|
||||
)
|
||||
}, [toggleErrorHighlighting, rowVisibility, columnVisibility])
|
||||
|
||||
const {
|
||||
columnOptions,
|
||||
handleToggleColumn,
|
||||
handleResetColumns,
|
||||
isDisabled: isColumsDisabled,
|
||||
} = useDataGridColumnVisibility(grid, matrix)
|
||||
|
||||
const handleToggleColumnVisibility = useCallback(
|
||||
(index: number) => {
|
||||
return handleToggleColumn(index)
|
||||
},
|
||||
[handleToggleColumn]
|
||||
)
|
||||
|
||||
const { navigateToField, scrollToCoordinates } = useDataGridNavigation<
|
||||
TData,
|
||||
TFieldValues
|
||||
>({
|
||||
matrix,
|
||||
queryTool,
|
||||
anchor,
|
||||
columnVirtualizer,
|
||||
rowVirtualizer,
|
||||
flatColumns,
|
||||
setColumnVisibility,
|
||||
setSingleRange,
|
||||
visibleColumns,
|
||||
visibleRows,
|
||||
})
|
||||
|
||||
const { createSnapshot, restoreSnapshot } = useDataGridCellSnapshot<
|
||||
TData,
|
||||
TFieldValues
|
||||
>({
|
||||
matrix,
|
||||
form: state,
|
||||
})
|
||||
|
||||
const onEditingChangeHandler = useCallback(
|
||||
(value: boolean) => {
|
||||
if (onEditingChange) {
|
||||
onEditingChange(value)
|
||||
}
|
||||
|
||||
if (value) {
|
||||
createSnapshot(anchor)
|
||||
}
|
||||
|
||||
setIsEditing(value)
|
||||
},
|
||||
[anchor, createSnapshot, onEditingChange]
|
||||
)
|
||||
|
||||
const { getSelectionValues, setSelectionValues } = useDataGridFormHandlers<
|
||||
TData,
|
||||
TFieldValues
|
||||
>({
|
||||
matrix,
|
||||
form: state,
|
||||
anchor,
|
||||
})
|
||||
|
||||
const { handleKeyDownEvent, handleSpecialFocusKeys } =
|
||||
useDataGridKeydownEvent<TData, TFieldValues>({
|
||||
containerRef,
|
||||
matrix,
|
||||
queryTool,
|
||||
anchor,
|
||||
rangeEnd,
|
||||
isEditing,
|
||||
setTrapActive,
|
||||
setRangeEnd,
|
||||
getSelectionValues,
|
||||
getValues,
|
||||
setSelectionValues,
|
||||
onEditingChangeHandler,
|
||||
restoreSnapshot,
|
||||
setSingleRange,
|
||||
scrollToCoordinates,
|
||||
execute,
|
||||
undo,
|
||||
redo,
|
||||
setValue,
|
||||
})
|
||||
|
||||
const { handleMouseUpEvent } = useDataGridMouseUpEvent<TData, TFieldValues>({
|
||||
matrix,
|
||||
anchor,
|
||||
dragEnd,
|
||||
setDragEnd,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
setRangeEnd,
|
||||
setIsSelecting,
|
||||
getSelectionValues,
|
||||
setSelectionValues,
|
||||
execute,
|
||||
})
|
||||
|
||||
const { handleCopyEvent, handlePasteEvent } = useDataGridClipboardEvents<
|
||||
TData,
|
||||
TFieldValues
|
||||
>({
|
||||
matrix,
|
||||
isEditing,
|
||||
anchor,
|
||||
rangeEnd,
|
||||
getSelectionValues,
|
||||
setSelectionValues,
|
||||
execute,
|
||||
})
|
||||
|
||||
const {
|
||||
getWrapperFocusHandler,
|
||||
getInputChangeHandler,
|
||||
getOverlayMouseDownHandler,
|
||||
getWrapperMouseOverHandler,
|
||||
getIsCellDragSelected,
|
||||
getIsCellSelected,
|
||||
onDragToFillStart,
|
||||
} = useDataGridCellHandlers<TData, TFieldValues>({
|
||||
matrix,
|
||||
anchor,
|
||||
rangeEnd,
|
||||
setRangeEnd,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
isSelecting,
|
||||
setIsSelecting,
|
||||
setSingleRange,
|
||||
dragEnd,
|
||||
setDragEnd,
|
||||
setValue,
|
||||
execute,
|
||||
})
|
||||
|
||||
const { getCellErrorMetadata, getCellMetadata } = useDataGridCellMetadata<
|
||||
TData,
|
||||
TFieldValues
|
||||
>({
|
||||
matrix,
|
||||
})
|
||||
|
||||
/** Effects */
|
||||
|
||||
/**
|
||||
* Register all handlers for the grid.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!trapActive) {
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDownEvent)
|
||||
window.addEventListener("mouseup", handleMouseUpEvent)
|
||||
|
||||
// Copy and paste event listeners need to be added to the window
|
||||
window.addEventListener("copy", handleCopyEvent)
|
||||
window.addEventListener("paste", handlePasteEvent)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDownEvent)
|
||||
window.removeEventListener("mouseup", handleMouseUpEvent)
|
||||
|
||||
window.removeEventListener("copy", handleCopyEvent)
|
||||
window.removeEventListener("paste", handlePasteEvent)
|
||||
}
|
||||
}, [
|
||||
trapActive,
|
||||
handleKeyDownEvent,
|
||||
handleMouseUpEvent,
|
||||
handleCopyEvent,
|
||||
handlePasteEvent,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const specialFocusHandler = (e: KeyboardEvent) => {
|
||||
if (isSpecialFocusKey(e)) {
|
||||
handleSpecialFocusKeys(e)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", specialFocusHandler)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", specialFocusHandler)
|
||||
}
|
||||
}, [handleSpecialFocusKeys])
|
||||
|
||||
const handleHeaderInteractionChange = useCallback((isActive: boolean) => {
|
||||
if (isActive) {
|
||||
setTrapActive(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Auto corrective effect for ensuring we always
|
||||
* have a range end.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!anchor) {
|
||||
return
|
||||
}
|
||||
|
||||
if (rangeEnd) {
|
||||
return
|
||||
}
|
||||
|
||||
setRangeEnd(anchor)
|
||||
}, [anchor, rangeEnd])
|
||||
|
||||
/**
|
||||
* Ensure that we set a anchor on first render.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!anchor && matrix) {
|
||||
const coords = matrix.getFirstNavigableCell()
|
||||
|
||||
if (coords) {
|
||||
setSingleRange(coords)
|
||||
}
|
||||
}
|
||||
}, [anchor, matrix, setSingleRange])
|
||||
|
||||
const values = useMemo(
|
||||
() => ({
|
||||
anchor,
|
||||
control,
|
||||
trapActive,
|
||||
errors,
|
||||
setTrapActive,
|
||||
setIsSelecting,
|
||||
setIsEditing: onEditingChangeHandler,
|
||||
setSingleRange,
|
||||
setRangeEnd,
|
||||
getWrapperFocusHandler,
|
||||
getInputChangeHandler,
|
||||
getOverlayMouseDownHandler,
|
||||
getWrapperMouseOverHandler,
|
||||
register,
|
||||
getIsCellSelected,
|
||||
getIsCellDragSelected,
|
||||
getCellMetadata,
|
||||
getCellErrorMetadata,
|
||||
navigateToField,
|
||||
}),
|
||||
[
|
||||
anchor,
|
||||
control,
|
||||
trapActive,
|
||||
errors,
|
||||
setTrapActive,
|
||||
setIsSelecting,
|
||||
onEditingChangeHandler,
|
||||
setSingleRange,
|
||||
setRangeEnd,
|
||||
getWrapperFocusHandler,
|
||||
getInputChangeHandler,
|
||||
getOverlayMouseDownHandler,
|
||||
getWrapperMouseOverHandler,
|
||||
register,
|
||||
getIsCellSelected,
|
||||
getIsCellDragSelected,
|
||||
getCellMetadata,
|
||||
getCellErrorMetadata,
|
||||
navigateToField,
|
||||
]
|
||||
)
|
||||
|
||||
const handleRestoreGridFocus = useCallback(() => {
|
||||
if (anchor && !trapActive) {
|
||||
setTrapActive(true)
|
||||
|
||||
setSingleRange(anchor)
|
||||
scrollToCoordinates(anchor, "both")
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
queryTool?.getContainer(anchor)?.focus()
|
||||
})
|
||||
}
|
||||
}, [anchor, trapActive, queryTool])
|
||||
|
||||
return (
|
||||
<DataGridContext.Provider value={values}>
|
||||
<div className="bg-ui-bg-subtle flex size-full flex-col">
|
||||
<DataGridHeader
|
||||
columnOptions={columnOptions}
|
||||
isDisabled={isColumsDisabled}
|
||||
onToggleColumn={handleToggleColumnVisibility}
|
||||
errorCount={errorCount}
|
||||
onToggleErrorHighlighting={handleToggleErrorHighlighting}
|
||||
onResetColumns={handleResetColumns}
|
||||
isHighlighted={isHighlighted}
|
||||
onHeaderInteractionChange={handleHeaderInteractionChange}
|
||||
/>
|
||||
<div className="size-full overflow-hidden">
|
||||
<div
|
||||
ref={containerRef}
|
||||
autoFocus
|
||||
tabIndex={0}
|
||||
className="relative h-full select-none overflow-auto outline-none"
|
||||
onFocus={handleRestoreGridFocus}
|
||||
onClick={handleRestoreGridFocus}
|
||||
data-container={true}
|
||||
role="application"
|
||||
>
|
||||
<div role="grid" className="text-ui-fg-subtle grid">
|
||||
<div
|
||||
role="rowgroup"
|
||||
className="txt-compact-small-plus bg-ui-bg-subtle sticky top-0 z-[1] grid"
|
||||
>
|
||||
{grid.getHeaderGroups().map((headerGroup) => (
|
||||
<div
|
||||
role="row"
|
||||
key={headerGroup.id}
|
||||
className="flex h-10 w-full"
|
||||
>
|
||||
{virtualPaddingLeft ? (
|
||||
<div
|
||||
role="presentation"
|
||||
style={{ display: "flex", width: virtualPaddingLeft }}
|
||||
/>
|
||||
) : null}
|
||||
{virtualColumns.reduce((acc, vc, index, array) => {
|
||||
const header = headerGroup.headers[vc.index]
|
||||
const previousVC = array[index - 1]
|
||||
|
||||
if (previousVC && vc.index !== previousVC.index + 1) {
|
||||
// If there's a gap between the current and previous virtual columns
|
||||
acc.push(
|
||||
<div
|
||||
key={`padding-${previousVC.index}-${vc.index}`}
|
||||
role="presentation"
|
||||
style={{
|
||||
display: "flex",
|
||||
width: `${vc.start - previousVC.end}px`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
acc.push(
|
||||
<div
|
||||
key={header.id}
|
||||
role="columnheader"
|
||||
data-column-index={vc.index}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
...getCommonPinningStyles(header.column),
|
||||
}}
|
||||
className="bg-ui-bg-base txt-compact-small-plus flex items-center border-b border-r px-4 py-2.5"
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return acc
|
||||
}, [] as ReactNode[])}
|
||||
{virtualPaddingRight ? (
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
display: "flex",
|
||||
width: virtualPaddingRight,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
role="rowgroup"
|
||||
className="relative grid"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = visibleRows[virtualRow.index] as Row<TData>
|
||||
const rowIndex = flatRows.findIndex((r) => r.id === row.id)
|
||||
|
||||
return (
|
||||
<DataGridRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowIndex={rowIndex}
|
||||
virtualRow={virtualRow}
|
||||
flatColumns={flatColumns}
|
||||
virtualColumns={virtualColumns}
|
||||
anchor={anchor}
|
||||
virtualPaddingLeft={virtualPaddingLeft}
|
||||
virtualPaddingRight={virtualPaddingRight}
|
||||
onDragToFillStart={onDragToFillStart}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DataGridContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type DataGridHeaderProps = {
|
||||
columnOptions: GridColumnOption[]
|
||||
isDisabled: boolean
|
||||
onToggleColumn: (index: number) => (value: boolean) => void
|
||||
onResetColumns: () => void
|
||||
isHighlighted: boolean
|
||||
errorCount: number
|
||||
onToggleErrorHighlighting: () => void
|
||||
onHeaderInteractionChange: (isActive: boolean) => void
|
||||
}
|
||||
|
||||
const DataGridHeader = ({
|
||||
columnOptions,
|
||||
isDisabled,
|
||||
onToggleColumn,
|
||||
onResetColumns,
|
||||
isHighlighted,
|
||||
errorCount,
|
||||
onToggleErrorHighlighting,
|
||||
onHeaderInteractionChange,
|
||||
}: DataGridHeaderProps) => {
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||
const [columnsOpen, setColumnsOpen] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Since all columns are checked by default, we can check if any column is unchecked
|
||||
const hasChanged = columnOptions.some((column) => !column.checked)
|
||||
|
||||
const handleShortcutsOpenChange = (value: boolean) => {
|
||||
onHeaderInteractionChange(value)
|
||||
setShortcutsOpen(value)
|
||||
}
|
||||
|
||||
const handleColumnsOpenChange = (value: boolean) => {
|
||||
onHeaderInteractionChange(value)
|
||||
setColumnsOpen(value)
|
||||
}
|
||||
return (
|
||||
<div className="bg-ui-bg-base flex items-center justify-between border-b p-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<DropdownMenu open={columnsOpen} onOpenChange={handleColumnsOpenChange}>
|
||||
<ConditionalTooltip
|
||||
showTooltip={isDisabled}
|
||||
content={t("dataGrid.columns.disabled")}
|
||||
>
|
||||
<DropdownMenu.Trigger asChild disabled={isDisabled}>
|
||||
<Button size="small" variant="secondary">
|
||||
{hasChanged ? <AdjustmentsDone /> : <Adjustments />}
|
||||
{t("dataGrid.columns.view")}
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
</ConditionalTooltip>
|
||||
<DropdownMenu.Content>
|
||||
{columnOptions.map((column, index) => {
|
||||
const { checked, disabled, id, name } = column
|
||||
|
||||
if (disabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onToggleColumn(index)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{name}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
{hasChanged && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="transparent"
|
||||
type="button"
|
||||
onClick={onResetColumns}
|
||||
className="text-ui-fg-muted hover:text-ui-fg-subtle"
|
||||
data-id="reset-columns"
|
||||
>
|
||||
{t("dataGrid.columns.resetToDefault")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{errorCount > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={onToggleErrorHighlighting}
|
||||
className={clx({
|
||||
"bg-ui-button-neutral-pressed": isHighlighted,
|
||||
})}
|
||||
>
|
||||
<ExclamationCircle className="text-ui-fg-subtle" />
|
||||
<span>
|
||||
{t("dataGrid.errors.count", {
|
||||
count: errorCount,
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<DataGridKeyboardShortcutModal
|
||||
open={shortcutsOpen}
|
||||
onOpenChange={handleShortcutsOpenChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type DataGridCellProps<TData> = {
|
||||
cell: Cell<TData, unknown>
|
||||
columnIndex: number
|
||||
rowIndex: number
|
||||
anchor: DataGridCoordinates | null
|
||||
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
|
||||
}
|
||||
|
||||
const DataGridCell = <TData,>({
|
||||
cell,
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
anchor,
|
||||
onDragToFillStart,
|
||||
}: DataGridCellProps<TData>) => {
|
||||
const coords: DataGridCoordinates = {
|
||||
row: rowIndex,
|
||||
col: columnIndex,
|
||||
}
|
||||
|
||||
const isAnchor = isCellMatch(coords, anchor)
|
||||
|
||||
return (
|
||||
<div
|
||||
role="gridcell"
|
||||
aria-rowindex={rowIndex}
|
||||
aria-colindex={columnIndex}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
...getCommonPinningStyles(cell.column),
|
||||
}}
|
||||
data-row-index={rowIndex}
|
||||
data-column-index={columnIndex}
|
||||
className={clx(
|
||||
"relative flex items-center border-b border-r p-0 outline-none"
|
||||
)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
{flexRender(cell.column.columnDef.cell, {
|
||||
...cell.getContext(),
|
||||
columnIndex,
|
||||
rowIndex: rowIndex,
|
||||
} as CellContext<TData, any>)}
|
||||
{isAnchor && (
|
||||
<div
|
||||
onMouseDown={onDragToFillStart}
|
||||
className="bg-ui-fg-interactive absolute bottom-0 right-0 z-[3] size-1.5 cursor-ns-resize"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type DataGridRowProps<TData> = {
|
||||
row: Row<TData>
|
||||
rowIndex: number
|
||||
virtualRow: VirtualItem<Element>
|
||||
virtualPaddingLeft?: number
|
||||
virtualPaddingRight?: number
|
||||
virtualColumns: VirtualItem<Element>[]
|
||||
flatColumns: Column<TData, unknown>[]
|
||||
anchor: DataGridCoordinates | null
|
||||
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
|
||||
}
|
||||
|
||||
const DataGridRow = <TData,>({
|
||||
row,
|
||||
rowIndex,
|
||||
virtualRow,
|
||||
virtualPaddingLeft,
|
||||
virtualPaddingRight,
|
||||
virtualColumns,
|
||||
flatColumns,
|
||||
anchor,
|
||||
onDragToFillStart,
|
||||
}: DataGridRowProps<TData>) => {
|
||||
const visibleCells = row.getVisibleCells()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="row"
|
||||
aria-rowindex={virtualRow.index}
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
|
||||
>
|
||||
{virtualPaddingLeft ? (
|
||||
<div
|
||||
role="presentation"
|
||||
style={{ display: "flex", width: virtualPaddingLeft }}
|
||||
/>
|
||||
) : null}
|
||||
{virtualColumns.reduce((acc, vc, index, array) => {
|
||||
const cell = visibleCells[vc.index]
|
||||
const column = cell.column
|
||||
const columnIndex = flatColumns.findIndex((c) => c.id === column.id)
|
||||
const previousVC = array[index - 1]
|
||||
|
||||
if (previousVC && vc.index !== previousVC.index + 1) {
|
||||
// If there's a gap between the current and previous virtual columns
|
||||
acc.push(
|
||||
<div
|
||||
key={`padding-${previousVC.index}-${vc.index}`}
|
||||
role="presentation"
|
||||
style={{
|
||||
display: "flex",
|
||||
width: `${vc.start - previousVC.end}px`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
acc.push(
|
||||
<DataGridCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
columnIndex={columnIndex}
|
||||
rowIndex={rowIndex}
|
||||
anchor={anchor}
|
||||
onDragToFillStart={onDragToFillStart}
|
||||
/>
|
||||
)
|
||||
|
||||
return acc
|
||||
}, [] as ReactNode[])}
|
||||
{virtualPaddingRight ? (
|
||||
<div
|
||||
role="presentation"
|
||||
style={{ display: "flex", width: virtualPaddingRight }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { Badge, Tooltip } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { DataGridRowError } from "../types"
|
||||
|
||||
type DataGridRowErrorIndicatorProps = {
|
||||
rowErrors: DataGridRowError[]
|
||||
}
|
||||
|
||||
export const DataGridRowErrorIndicator = ({
|
||||
rowErrors,
|
||||
}: DataGridRowErrorIndicatorProps) => {
|
||||
const rowErrorCount = rowErrors ? rowErrors.length : 0
|
||||
|
||||
if (!rowErrors || rowErrorCount <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
<ul className="flex flex-col gap-y-3">
|
||||
{rowErrors.map((error, index) => (
|
||||
<DataGridRowErrorLine key={index} error={error} />
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
delayDuration={0}
|
||||
>
|
||||
<Badge color="red" size="2xsmall" className="cursor-default">
|
||||
{rowErrorCount}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const DataGridRowErrorLine = ({
|
||||
error,
|
||||
}: {
|
||||
error: { message: string; to: () => void }
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<li className="txt-compact-small flex flex-col items-start">
|
||||
{error.message}
|
||||
<button
|
||||
type="button"
|
||||
onClick={error.to}
|
||||
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover transition-fg"
|
||||
>
|
||||
{t("dataGrid.errors.fixError")}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
// Not currently used, re-implement or delete depending on whether there is a need for it in the future.
|
||||
|
||||
// import { Select, clx } from "@medusajs/ui"
|
||||
// import { Controller } from "react-hook-form"
|
||||
// import { useDataGridCell } from "../hooks"
|
||||
// import { DataGridCellProps } from "../types"
|
||||
// import { DataGridCellContainer } from "./data-grid-cell-container"
|
||||
|
||||
// interface DataGridSelectCellProps<TData, TValue = any>
|
||||
// extends DataGridCellProps<TData, TValue> {
|
||||
// options: { label: string; value: string }[]
|
||||
// }
|
||||
|
||||
// export const DataGridSelectCell = <TData, TValue = any>({
|
||||
// context,
|
||||
// options,
|
||||
// field,
|
||||
// }: DataGridSelectCellProps<TData, TValue>) => {
|
||||
// const { control, attributes, container } = useDataGridCell({
|
||||
// field,
|
||||
// context,
|
||||
// })
|
||||
|
||||
// return (
|
||||
// <Controller
|
||||
// control={control}
|
||||
// name={field}
|
||||
// render={({ field: { onChange, ref, ...field } }) => {
|
||||
// return (
|
||||
// <DataGridCellContainer {...container}>
|
||||
// <Select {...field} onValueChange={onChange}>
|
||||
// <Select.Trigger
|
||||
// {...attributes}
|
||||
// ref={ref}
|
||||
// className={clx(
|
||||
// "h-full w-full rounded-none bg-transparent px-4 py-2.5 shadow-none",
|
||||
// "hover:bg-transparent focus:shadow-none data-[state=open]:!shadow-none"
|
||||
// )}
|
||||
// >
|
||||
// <Select.Value />
|
||||
// </Select.Trigger>
|
||||
// <Select.Content>
|
||||
// {options.map((option) => (
|
||||
// <Select.Item key={option.value} value={option.value}>
|
||||
// {option.label}
|
||||
// </Select.Item>
|
||||
// ))}
|
||||
// </Select.Content>
|
||||
// </Select>
|
||||
// </DataGridCellContainer>
|
||||
// )
|
||||
// }}
|
||||
// />
|
||||
// )
|
||||
// }
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { Skeleton } from "../../common/skeleton"
|
||||
|
||||
type DataGridSkeletonProps<TData> = {
|
||||
columns: ColumnDef<TData>[]
|
||||
rows?: number
|
||||
}
|
||||
|
||||
export const DataGridSkeleton = <TData,>({
|
||||
columns,
|
||||
rows: rowCount = 10,
|
||||
}: DataGridSkeletonProps<TData>) => {
|
||||
const rows = Array.from({ length: rowCount }, (_, i) => i)
|
||||
|
||||
const colCount = columns.length
|
||||
|
||||
return (
|
||||
<div className="bg-ui-bg-subtle size-full">
|
||||
<div className="bg-ui-bg-base border-b p-4">
|
||||
<div className="bg-ui-button-neutral h-7 w-[116px] animate-pulse rounded-md" />
|
||||
</div>
|
||||
<div className="bg-ui-bg-subtle size-full overflow-auto">
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${colCount}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{columns.map((_col, i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-ui-bg-base flex h-10 w-[200px] items-center border-b border-r px-4 py-2.5 last:border-r-0"
|
||||
>
|
||||
<Skeleton className="h-[14px] w-[164px]" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{rows.map((_, j) => (
|
||||
<div
|
||||
className="grid"
|
||||
style={{ gridTemplateColumns: `repeat(${colCount}, 1fr)` }}
|
||||
key={j}
|
||||
>
|
||||
{columns.map((_col, k) => {
|
||||
return (
|
||||
<div
|
||||
key={k}
|
||||
className="bg-ui-bg-base flex h-10 w-[200px] items-center border-b border-r px-4 py-2.5 last:border-r-0"
|
||||
>
|
||||
<Skeleton className="h-[14px] w-[164px]" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Controller, ControllerRenderProps } from "react-hook-form"
|
||||
|
||||
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
|
||||
import { useDataGridCell, useDataGridCellError } from "../hooks"
|
||||
import { DataGridCellProps, InputProps } from "../types"
|
||||
import { DataGridCellContainer } from "./data-grid-cell-container"
|
||||
|
||||
export const DataGridTextCell = <TData, TValue = any>({
|
||||
context,
|
||||
}: DataGridCellProps<TData, TValue>) => {
|
||||
const { field, control, renderProps } = useDataGridCell({
|
||||
context,
|
||||
})
|
||||
const errorProps = useDataGridCellError({ context })
|
||||
|
||||
const { container, input } = renderProps
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={field}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<DataGridCellContainer {...container} {...errorProps}>
|
||||
<Inner field={field} inputProps={input} />
|
||||
</DataGridCellContainer>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const Inner = ({
|
||||
field,
|
||||
inputProps,
|
||||
}: {
|
||||
field: ControllerRenderProps<any, string>
|
||||
inputProps: InputProps
|
||||
}) => {
|
||||
const { onChange: _, onBlur, ref, value, ...rest } = field
|
||||
const { ref: inputRef, onBlur: onInputBlur, onChange, ...input } = inputProps
|
||||
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value)
|
||||
}, [value])
|
||||
|
||||
const combinedRefs = useCombinedRefs(inputRef, ref)
|
||||
|
||||
return (
|
||||
<input
|
||||
className={clx(
|
||||
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer items-center justify-center bg-transparent outline-none",
|
||||
"focus:cursor-text"
|
||||
)}
|
||||
autoComplete="off"
|
||||
tabIndex={-1}
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
ref={combinedRefs}
|
||||
onBlur={() => {
|
||||
onBlur()
|
||||
onInputBlur()
|
||||
|
||||
// We propagate the change to the field only when the input is blurred
|
||||
onChange(localValue, value)
|
||||
}}
|
||||
{...input}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { DataGridBooleanCell } from "./data-grid-boolean-cell"
|
||||
export { DataGridCurrencyCell } from "./data-grid-currency-cell"
|
||||
export { DataGridNumberCell } from "./data-grid-number-cell"
|
||||
export { DataGridReadonlyCell as DataGridReadOnlyCell } from "./data-grid-readonly-cell"
|
||||
export { DataGridRoot, type DataGridRootProps } from "./data-grid-root"
|
||||
export { DataGridSkeleton } from "./data-grid-skeleton"
|
||||
export { DataGridTextCell } from "./data-grid-text-cell"
|
||||
@@ -0,0 +1,45 @@
|
||||
import { FocusEvent, MouseEvent, createContext } from "react"
|
||||
import {
|
||||
Control,
|
||||
FieldErrors,
|
||||
FieldValues,
|
||||
Path,
|
||||
UseFormRegister,
|
||||
} from "react-hook-form"
|
||||
import { CellErrorMetadata, CellMetadata, DataGridCoordinates } from "../types"
|
||||
|
||||
type DataGridContextType<TFieldValues extends FieldValues> = {
|
||||
// Grid state
|
||||
anchor: DataGridCoordinates | null
|
||||
trapActive: boolean
|
||||
setTrapActive: (value: boolean) => void
|
||||
errors: FieldErrors<TFieldValues>
|
||||
// Cell handlers
|
||||
getIsCellSelected: (coords: DataGridCoordinates) => boolean
|
||||
getIsCellDragSelected: (coords: DataGridCoordinates) => boolean
|
||||
// Grid handlers
|
||||
setIsEditing: (value: boolean) => void
|
||||
setIsSelecting: (value: boolean) => void
|
||||
setRangeEnd: (coords: DataGridCoordinates) => void
|
||||
setSingleRange: (coords: DataGridCoordinates) => void
|
||||
// Form state and handlers
|
||||
register: UseFormRegister<TFieldValues>
|
||||
control: Control<TFieldValues>
|
||||
getInputChangeHandler: (
|
||||
field: Path<TFieldValues>
|
||||
) => (next: any, prev: any) => void
|
||||
// Wrapper handlers
|
||||
getWrapperFocusHandler: (
|
||||
coordinates: DataGridCoordinates
|
||||
) => (e: FocusEvent<HTMLElement>) => void
|
||||
getWrapperMouseOverHandler: (
|
||||
coordinates: DataGridCoordinates
|
||||
) => ((e: MouseEvent<HTMLElement>) => void) | undefined
|
||||
getCellMetadata: (coords: DataGridCoordinates) => CellMetadata
|
||||
getCellErrorMetadata: (coords: DataGridCoordinates) => CellErrorMetadata
|
||||
navigateToField: (field: string) => void
|
||||
}
|
||||
|
||||
export const DataGridContext = createContext<DataGridContextType<any> | null>(
|
||||
null
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./data-grid-context"
|
||||
export * from "./use-data-grid-context"
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useContext } from "react"
|
||||
import { DataGridContext } from "./data-grid-context"
|
||||
|
||||
export const useDataGridContext = () => {
|
||||
const context = useContext(DataGridContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useDataGridContext must be used within a DataGridContextProvider"
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { FieldValues } from "react-hook-form"
|
||||
|
||||
import {
|
||||
DataGridBooleanCell,
|
||||
DataGridCurrencyCell,
|
||||
DataGridNumberCell,
|
||||
DataGridReadOnlyCell,
|
||||
DataGridRoot,
|
||||
DataGridSkeleton,
|
||||
DataGridTextCell,
|
||||
type DataGridRootProps,
|
||||
} from "./components"
|
||||
|
||||
interface DataGridProps<TData, TFieldValues extends FieldValues = FieldValues>
|
||||
extends DataGridRootProps<TData, TFieldValues> {
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const _DataGrid = <TData, TFieldValues extends FieldValues = FieldValues>({
|
||||
isLoading,
|
||||
...props
|
||||
}: DataGridProps<TData, TFieldValues>) => {
|
||||
return isLoading ? (
|
||||
<DataGridSkeleton
|
||||
columns={props.columns}
|
||||
rows={
|
||||
props.data?.length && props.data.length > 0 ? props.data.length : 10
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DataGridRoot {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export const DataGrid = Object.assign(_DataGrid, {
|
||||
BooleanCell: DataGridBooleanCell,
|
||||
TextCell: DataGridTextCell,
|
||||
NumberCell: DataGridNumberCell,
|
||||
CurrencyCell: DataGridCurrencyCell,
|
||||
ReadonlyCell: DataGridReadOnlyCell,
|
||||
})
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
CellContext,
|
||||
ColumnDefTemplate,
|
||||
createColumnHelper,
|
||||
HeaderContext,
|
||||
} from "@tanstack/react-table"
|
||||
import { FieldValues } from "react-hook-form"
|
||||
|
||||
import { DataGridColumnType, FieldFunction } from "../types"
|
||||
|
||||
type DataGridHelperColumnsProps<TData, TFieldValues extends FieldValues> = {
|
||||
/**
|
||||
* The id of the column.
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* The name of the column, shown in the column visibility menu.
|
||||
*/
|
||||
name?: string
|
||||
/**
|
||||
* The header template for the column.
|
||||
*/
|
||||
header: ColumnDefTemplate<HeaderContext<TData, unknown>> | undefined
|
||||
/**
|
||||
* The cell template for the column.
|
||||
*/
|
||||
cell: ColumnDefTemplate<CellContext<TData, unknown>> | undefined
|
||||
/**
|
||||
* Callback to set the field path for each cell in the column.
|
||||
* If a callback is not provided, or returns null, the cell will not be editable.
|
||||
*/
|
||||
field?: FieldFunction<TData, TFieldValues>
|
||||
/**
|
||||
* Whether the column cannot be hidden by the user.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
disableHiding?: boolean
|
||||
} & (
|
||||
| {
|
||||
field: FieldFunction<TData, TFieldValues>
|
||||
type: DataGridColumnType
|
||||
}
|
||||
| { field?: null | undefined; type?: never }
|
||||
)
|
||||
|
||||
export function createDataGridHelper<
|
||||
TData,
|
||||
TFieldValues extends FieldValues
|
||||
>() {
|
||||
const columnHelper = createColumnHelper<TData>()
|
||||
|
||||
return {
|
||||
column: ({
|
||||
id,
|
||||
name,
|
||||
header,
|
||||
cell,
|
||||
disableHiding = false,
|
||||
field,
|
||||
type,
|
||||
}: DataGridHelperColumnsProps<TData, TFieldValues>) =>
|
||||
columnHelper.display({
|
||||
id,
|
||||
header,
|
||||
cell,
|
||||
enableHiding: !disableHiding,
|
||||
meta: {
|
||||
name,
|
||||
field,
|
||||
type,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { TFunction } from "i18next"
|
||||
import { FieldPath, FieldValues } from "react-hook-form"
|
||||
import { IncludesTaxTooltip } from "../../common/tax-badge/tax-badge"
|
||||
import { DataGridCurrencyCell } from "../components/data-grid-currency-cell"
|
||||
import { DataGridReadonlyCell } from "../components/data-grid-readonly-cell"
|
||||
import { FieldContext } from "../types"
|
||||
import { createDataGridHelper } from "./create-data-grid-column-helper"
|
||||
|
||||
type CreateDataGridPriceColumnsProps<
|
||||
TData,
|
||||
TFieldValues extends FieldValues
|
||||
> = {
|
||||
currencies?: string[]
|
||||
regions?: HttpTypes.AdminRegion[]
|
||||
pricePreferences?: HttpTypes.AdminPricePreference[]
|
||||
isReadyOnly?: (context: FieldContext<TData>) => boolean
|
||||
getFieldName: (
|
||||
context: FieldContext<TData>,
|
||||
value: string
|
||||
) => FieldPath<TFieldValues> | null
|
||||
t: TFunction
|
||||
}
|
||||
|
||||
export const createDataGridPriceColumns = <
|
||||
TData,
|
||||
TFieldValues extends FieldValues
|
||||
>({
|
||||
currencies,
|
||||
regions,
|
||||
pricePreferences,
|
||||
isReadyOnly,
|
||||
getFieldName,
|
||||
t,
|
||||
}: CreateDataGridPriceColumnsProps<TData, TFieldValues>): ColumnDef<
|
||||
TData,
|
||||
unknown
|
||||
>[] => {
|
||||
const columnHelper = createDataGridHelper<TData, TFieldValues>()
|
||||
|
||||
return [
|
||||
...(currencies?.map((currency) => {
|
||||
const preference = pricePreferences?.find(
|
||||
(p) => p.attribute === "currency_code" && p.value === currency
|
||||
)
|
||||
|
||||
return columnHelper.column({
|
||||
id: `currency_prices.${currency}`,
|
||||
name: t("fields.priceTemplate", {
|
||||
regionOrCurrency: currency.toUpperCase(),
|
||||
}),
|
||||
field: (context) => {
|
||||
const isReadyOnlyValue = isReadyOnly?.(context)
|
||||
|
||||
if (isReadyOnlyValue) {
|
||||
return null
|
||||
}
|
||||
|
||||
return getFieldName(context, currency)
|
||||
},
|
||||
type: "number",
|
||||
header: () => (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
{t("fields.priceTemplate", {
|
||||
regionOrCurrency: currency.toUpperCase(),
|
||||
})}
|
||||
<IncludesTaxTooltip includesTax={preference?.is_tax_inclusive} />
|
||||
</div>
|
||||
),
|
||||
cell: (context) => {
|
||||
if (isReadyOnly?.(context)) {
|
||||
return <DataGridReadonlyCell context={context} />
|
||||
}
|
||||
|
||||
return <DataGridCurrencyCell code={currency} context={context} />
|
||||
},
|
||||
})
|
||||
}) ?? []),
|
||||
...(regions?.map((region) => {
|
||||
const preference = pricePreferences?.find(
|
||||
(p) => p.attribute === "region_id" && p.value === region.id
|
||||
)
|
||||
|
||||
return columnHelper.column({
|
||||
id: `region_prices.${region.id}`,
|
||||
name: t("fields.priceTemplate", {
|
||||
regionOrCurrency: region.name,
|
||||
}),
|
||||
field: (context) => {
|
||||
const isReadyOnlyValue = isReadyOnly?.(context)
|
||||
|
||||
if (isReadyOnlyValue) {
|
||||
return null
|
||||
}
|
||||
|
||||
return getFieldName(context, region.id)
|
||||
},
|
||||
type: "number",
|
||||
header: () => (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
{t("fields.priceTemplate", {
|
||||
regionOrCurrency: region.name,
|
||||
})}
|
||||
<IncludesTaxTooltip includesTax={preference?.is_tax_inclusive} />
|
||||
</div>
|
||||
),
|
||||
cell: (context) => {
|
||||
if (isReadyOnly?.(context)) {
|
||||
return <DataGridReadonlyCell context={context} />
|
||||
}
|
||||
|
||||
const currency = currencies?.find((c) => c === region.currency_code)
|
||||
if (!currency) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridCurrencyCell
|
||||
code={region.currency_code}
|
||||
context={context}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
}) ?? []),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./create-data-grid-column-helper"
|
||||
export * from "./create-data-grid-price-columns"
|
||||
@@ -0,0 +1,13 @@
|
||||
export * from "./use-data-grid-cell"
|
||||
export * from "./use-data-grid-cell-error"
|
||||
export * from "./use-data-grid-cell-handlers"
|
||||
export * from "./use-data-grid-cell-metadata"
|
||||
export * from "./use-data-grid-cell-snapshot"
|
||||
export * from "./use-data-grid-clipboard-events"
|
||||
export * from "./use-data-grid-column-visibility"
|
||||
export * from "./use-data-grid-error-highlighting"
|
||||
export * from "./use-data-grid-form-handlers"
|
||||
export * from "./use-data-grid-keydown-event"
|
||||
export * from "./use-data-grid-mouse-up-event"
|
||||
export * from "./use-data-grid-navigation"
|
||||
export * from "./use-data-grid-query-tool"
|
||||
@@ -0,0 +1,78 @@
|
||||
import { CellContext } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { FieldError, FieldErrors, get } from "react-hook-form"
|
||||
|
||||
import { useDataGridContext } from "../context"
|
||||
import { DataGridCellContext, DataGridRowError } from "../types"
|
||||
|
||||
type UseDataGridCellErrorOptions<TData, TValue> = {
|
||||
context: CellContext<TData, TValue>
|
||||
}
|
||||
|
||||
export const useDataGridCellError = <TextData, TValue>({
|
||||
context,
|
||||
}: UseDataGridCellErrorOptions<TextData, TValue>) => {
|
||||
const { errors, getCellErrorMetadata, navigateToField } = useDataGridContext()
|
||||
|
||||
const { rowIndex, columnIndex } = context as DataGridCellContext<
|
||||
TextData,
|
||||
TValue
|
||||
>
|
||||
|
||||
const { accessor, field } = useMemo(() => {
|
||||
return getCellErrorMetadata({ row: rowIndex, col: columnIndex })
|
||||
}, [rowIndex, columnIndex, getCellErrorMetadata])
|
||||
|
||||
const rowErrorsObject: FieldErrors | undefined =
|
||||
accessor && columnIndex === 0 ? get(errors, accessor) : undefined
|
||||
|
||||
const rowErrors: DataGridRowError[] = []
|
||||
|
||||
function collectErrors(
|
||||
errorObject: FieldErrors | FieldError | undefined,
|
||||
baseAccessor: string
|
||||
) {
|
||||
if (!errorObject) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isFieldError(errorObject)) {
|
||||
// Handle a single FieldError directly
|
||||
const message = errorObject.message
|
||||
|
||||
const to = () => navigateToField(baseAccessor)
|
||||
|
||||
if (message) {
|
||||
rowErrors.push({ message, to })
|
||||
}
|
||||
} else {
|
||||
// Traverse nested objects
|
||||
Object.keys(errorObject).forEach((key) => {
|
||||
const nestedError = errorObject[key]
|
||||
const fieldAccessor = `${baseAccessor}.${key}`
|
||||
|
||||
if (nestedError && typeof nestedError === "object") {
|
||||
collectErrors(nestedError, fieldAccessor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (rowErrorsObject && accessor) {
|
||||
collectErrors(rowErrorsObject, accessor)
|
||||
}
|
||||
|
||||
const cellError: FieldError | undefined = field
|
||||
? get(errors, field)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
errors,
|
||||
rowErrors,
|
||||
cellError,
|
||||
}
|
||||
}
|
||||
|
||||
function isFieldError(errors: FieldErrors | FieldError): errors is FieldError {
|
||||
return typeof errors === "object" && "message" in errors && "type" in errors
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import { FocusEvent, MouseEvent, useCallback } from "react"
|
||||
import { FieldValues, UseFormSetValue } from "react-hook-form"
|
||||
import { DataGridMatrix, DataGridUpdateCommand } from "../models"
|
||||
import { DataGridCoordinates } from "../types"
|
||||
|
||||
type UseDataGridCellHandlersOptions<TData, TFieldValues extends FieldValues> = {
|
||||
matrix: DataGridMatrix<TData, TFieldValues>
|
||||
anchor: DataGridCoordinates | null
|
||||
rangeEnd: DataGridCoordinates | null
|
||||
setRangeEnd: (coords: DataGridCoordinates | null) => void
|
||||
isSelecting: boolean
|
||||
setIsSelecting: (isSelecting: boolean) => void
|
||||
isDragging: boolean
|
||||
setIsDragging: (isDragging: boolean) => void
|
||||
setSingleRange: (coords: DataGridCoordinates) => void
|
||||
dragEnd: DataGridCoordinates | null
|
||||
setDragEnd: (coords: DataGridCoordinates | null) => void
|
||||
setValue: UseFormSetValue<TFieldValues>
|
||||
execute: (command: DataGridUpdateCommand) => void
|
||||
}
|
||||
|
||||
export const useDataGridCellHandlers = <
|
||||
TData,
|
||||
TFieldValues extends FieldValues
|
||||
>({
|
||||
matrix,
|
||||
anchor,
|
||||
rangeEnd,
|
||||
setRangeEnd,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
isSelecting,
|
||||
setIsSelecting,
|
||||
setSingleRange,
|
||||
dragEnd,
|
||||
setDragEnd,
|
||||
setValue,
|
||||
execute,
|
||||
}: UseDataGridCellHandlersOptions<TData, TFieldValues>) => {
|
||||
const getWrapperFocusHandler = useCallback(
|
||||
(coords: DataGridCoordinates) => {
|
||||
return (_e: FocusEvent<HTMLElement>) => {
|
||||
setSingleRange(coords)
|
||||
}
|
||||
},
|
||||
[setSingleRange]
|
||||
)
|
||||
|
||||
const getOverlayMouseDownHandler = useCallback(
|
||||
(coords: DataGridCoordinates) => {
|
||||
return (e: MouseEvent<HTMLElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
if (e.shiftKey) {
|
||||
setRangeEnd(coords)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSelecting(true)
|
||||
|
||||
setSingleRange(coords)
|
||||
}
|
||||
},
|
||||
[setIsSelecting, setRangeEnd, setSingleRange]
|
||||
)
|
||||
|
||||
const getWrapperMouseOverHandler = useCallback(
|
||||
(coords: DataGridCoordinates) => {
|
||||
if (!isDragging && !isSelecting) {
|
||||
return
|
||||
}
|
||||
|
||||
return (_e: MouseEvent<HTMLElement>) => {
|
||||
/**
|
||||
* If the column is not the same as the anchor col,
|
||||
* we don't want to select the cell.
|
||||
*/
|
||||
if (anchor?.col !== coords.col) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isSelecting) {
|
||||
setRangeEnd(coords)
|
||||
} else {
|
||||
setDragEnd(coords)
|
||||
}
|
||||
}
|
||||
},
|
||||
[anchor?.col, isDragging, isSelecting, setDragEnd, setRangeEnd]
|
||||
)
|
||||
|
||||
const getInputChangeHandler = useCallback(
|
||||
// Using `any` here as the generic type of Path<TFieldValues> will
|
||||
// not be inferred correctly.
|
||||
(field: any) => {
|
||||
return (next: any, prev: any) => {
|
||||
const command = new DataGridUpdateCommand({
|
||||
next,
|
||||
prev,
|
||||
setter: (value) => {
|
||||
setValue(field, value, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
execute(command)
|
||||
}
|
||||
},
|
||||
[setValue, execute]
|
||||
)
|
||||
|
||||
const onDragToFillStart = useCallback(
|
||||
(_e: MouseEvent<HTMLElement>) => {
|
||||
setIsDragging(true)
|
||||
},
|
||||
[setIsDragging]
|
||||
)
|
||||
|
||||
const getIsCellSelected = useCallback(
|
||||
(cell: DataGridCoordinates | null) => {
|
||||
if (!cell || !anchor || !rangeEnd) {
|
||||
return false
|
||||
}
|
||||
|
||||
return matrix.getIsCellSelected(cell, anchor, rangeEnd)
|
||||
},
|
||||
[anchor, rangeEnd, matrix]
|
||||
)
|
||||
|
||||
const getIsCellDragSelected = useCallback(
|
||||
(cell: DataGridCoordinates | null) => {
|
||||
if (!cell || !anchor || !dragEnd) {
|
||||
return false
|
||||
}
|
||||
|
||||
return matrix.getIsCellSelected(cell, anchor, dragEnd)
|
||||
},
|
||||
[anchor, dragEnd, matrix]
|
||||
)
|
||||
|
||||
return {
|
||||
getWrapperFocusHandler,
|
||||
getOverlayMouseDownHandler,
|
||||
getWrapperMouseOverHandler,
|
||||
getInputChangeHandler,
|
||||
getIsCellSelected,
|
||||
getIsCellDragSelected,
|
||||
onDragToFillStart,
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { useCallback } from "react"
|
||||
import { FieldValues } from "react-hook-form"
|
||||
import { DataGridMatrix } from "../models"
|
||||
import { CellErrorMetadata, CellMetadata, DataGridCoordinates } from "../types"
|
||||
import { generateCellId } from "../utils"
|
||||
|
||||
type UseDataGridCellMetadataOptions<TData, TFieldValues extends FieldValues> = {
|
||||
matrix: DataGridMatrix<TData, TFieldValues>
|
||||
}
|
||||
|
||||
export const useDataGridCellMetadata = <
|
||||
TData,
|
||||
TFieldValues extends FieldValues
|
||||
>({
|
||||
matrix,
|
||||
}: UseDataGridCellMetadataOptions<TData, TFieldValues>) => {
|
||||
/**
|
||||
* Creates metadata for a cell.
|
||||
*/
|
||||
const getCellMetadata = useCallback(
|
||||
(coords: DataGridCoordinates): CellMetadata => {
|
||||
const { row, col } = coords
|
||||
|
||||
const id = generateCellId(coords)
|
||||
const field = matrix.getCellField(coords)
|
||||
const type = matrix.getCellType(coords)
|
||||
|
||||
if (!field || !type) {
|
||||
throw new Error(`'field' or 'type' is null for cell ${id}`)
|
||||
}
|
||||
|
||||
const inputAttributes = {
|
||||
"data-row": row,
|
||||
"data-col": col,
|
||||
"data-cell-id": id,
|
||||
"data-field": field,
|
||||
}
|
||||
|
||||
const innerAttributes = {
|
||||
"data-container-id": id,
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
field,
|
||||
type,
|
||||
inputAttributes,
|
||||
innerAttributes,
|
||||
}
|
||||
},
|
||||
[matrix]
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates error metadata for a cell. This is used to display error messages
|
||||
* in the cell, and its containing row.
|
||||
*/
|
||||
const getCellErrorMetadata = useCallback(
|
||||
(coords: DataGridCoordinates): CellErrorMetadata => {
|
||||
const accessor = matrix.getRowAccessor(coords.row)
|
||||
const field = matrix.getCellField(coords)
|
||||
|
||||
return {
|
||||
accessor,
|
||||
field,
|
||||
}
|
||||
},
|
||||
[matrix]
|
||||
)
|
||||
|
||||
return {
|
||||
getCellMetadata,
|
||||
getCellErrorMetadata,
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useState } from "react"
|
||||
import { FieldValues, Path, UseFormReturn } from "react-hook-form"
|
||||
import { DataGridMatrix } from "../models"
|
||||
import { DataGridCellSnapshot, DataGridCoordinates } from "../types"
|
||||
|
||||
type UseDataGridCellSnapshotOptions<TData, TFieldValues extends FieldValues> = {
|
||||
matrix: DataGridMatrix<TData, TFieldValues>
|
||||
form: UseFormReturn<TFieldValues>
|
||||
}
|
||||
|
||||
export const useDataGridCellSnapshot = <
|
||||
TData,
|
||||
TFieldValues extends FieldValues
|
||||
>({
|
||||
matrix,
|
||||
form,
|
||||
}: UseDataGridCellSnapshotOptions<TData, TFieldValues>) => {
|
||||
const [snapshot, setSnapshot] = useState<DataGridCellSnapshot<TFieldValues> | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const { getValues, setValue } = form
|
||||
|
||||
/**
|
||||
* Creates a snapshot of the current cell value.
|
||||
*/
|
||||
const createSnapshot = useCallback(
|
||||
(cell: DataGridCoordinates | null) => {
|
||||
if (!cell) {
|
||||
return null
|
||||
}
|
||||
|
||||
const field = matrix.getCellField(cell)
|
||||
|
||||
if (!field) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = getValues(field as Path<TFieldValues>)
|
||||
|
||||
setSnapshot({ field, value })
|
||||
},
|
||||
[getValues, matrix]
|
||||
)
|
||||
|
||||
/**
|
||||
* Restores the cell value from the snapshot if it exists.
|
||||
*/
|
||||
const restoreSnapshot = useCallback(() => {
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
const { field, value } = snapshot
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setValue(field as Path<TFieldValues>, value)
|
||||
})
|
||||
}, [setValue, snapshot])
|
||||
|
||||
return {
|
||||
createSnapshot,
|
||||
restoreSnapshot,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { CellContext } from "@tanstack/react-table"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import { useDataGridContext } from "../context"
|
||||
import {
|
||||
DataGridCellContext,
|
||||
DataGridCellRenderProps,
|
||||
DataGridCoordinates,
|
||||
} from "../types"
|
||||
import { isCellMatch, isSpecialFocusKey } from "../utils"
|
||||
|
||||
type UseDataGridCellOptions<TData, TValue> = {
|
||||
context: CellContext<TData, TValue>
|
||||
}
|
||||
|
||||
const textCharacterRegex = /^.$/u
|
||||
const numberCharacterRegex = /^[0-9]$/u
|
||||
|
||||
export const useDataGridCell = <TData, TValue>({
|
||||
context,
|
||||
}: UseDataGridCellOptions<TData, TValue>) => {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
anchor,
|
||||
setIsEditing,
|
||||
setSingleRange,
|
||||
setIsSelecting,
|
||||
setRangeEnd,
|
||||
getWrapperFocusHandler,
|
||||
getWrapperMouseOverHandler,
|
||||
getInputChangeHandler,
|
||||
getIsCellSelected,
|
||||
getIsCellDragSelected,
|
||||
getCellMetadata,
|
||||
} = useDataGridContext()
|
||||
|
||||
const { rowIndex, columnIndex } = context as DataGridCellContext<
|
||||
TData,
|
||||
TValue
|
||||
>
|
||||
|
||||
const coords: DataGridCoordinates = useMemo(
|
||||
() => ({ row: rowIndex, col: columnIndex }),
|
||||
[rowIndex, columnIndex]
|
||||
)
|
||||
|
||||
const { id, field, type, innerAttributes, inputAttributes } = useMemo(() => {
|
||||
return getCellMetadata(coords)
|
||||
}, [coords, getCellMetadata])
|
||||
|
||||
const [showOverlay, setShowOverlay] = useState(true)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLElement>(null)
|
||||
|
||||
const handleOverlayMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.detail === 2) {
|
||||
if (inputRef.current) {
|
||||
setShowOverlay(false)
|
||||
|
||||
inputRef.current.focus()
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Only allow setting the rangeEnd if the column matches the anchor column.
|
||||
// If not we let the function continue and treat the click as if the shift key was not pressed.
|
||||
if (coords.col === anchor?.col) {
|
||||
setRangeEnd(coords)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (containerRef.current) {
|
||||
setSingleRange(coords)
|
||||
setIsSelecting(true)
|
||||
containerRef.current.focus()
|
||||
}
|
||||
},
|
||||
[coords, anchor, setRangeEnd, setSingleRange, setIsSelecting]
|
||||
)
|
||||
|
||||
const handleBooleanInnerMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.detail === 2) {
|
||||
inputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
setRangeEnd(coords)
|
||||
return
|
||||
}
|
||||
|
||||
if (containerRef.current) {
|
||||
setSingleRange(coords)
|
||||
setIsSelecting(true)
|
||||
containerRef.current.focus()
|
||||
}
|
||||
},
|
||||
[setIsSelecting, setSingleRange, setRangeEnd, coords]
|
||||
)
|
||||
|
||||
const handleInputBlur = useCallback(() => {
|
||||
setShowOverlay(true)
|
||||
setIsEditing(false)
|
||||
}, [setIsEditing])
|
||||
|
||||
const handleInputFocus = useCallback(() => {
|
||||
setShowOverlay(false)
|
||||
setIsEditing(true)
|
||||
}, [setIsEditing])
|
||||
|
||||
const validateKeyStroke = useCallback(
|
||||
(key: string) => {
|
||||
if (type === "number") {
|
||||
return numberCharacterRegex.test(key)
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
return textCharacterRegex.test(key)
|
||||
}
|
||||
|
||||
// KeyboardEvents should not be forwareded to other types of cells
|
||||
return false
|
||||
},
|
||||
[type]
|
||||
)
|
||||
|
||||
const handleContainerKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!inputRef.current || !validateKeyStroke(e.key) || !showOverlay) {
|
||||
return
|
||||
}
|
||||
|
||||
// Allow the user to undo/redo
|
||||
if (e.key.toLowerCase() === "z" && (e.ctrlKey || e.metaKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Allow the user to copy
|
||||
if (e.key.toLowerCase() === "c" && (e.ctrlKey || e.metaKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Allow the user to paste
|
||||
if (e.key.toLowerCase() === "v" && (e.ctrlKey || e.metaKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
return
|
||||
}
|
||||
|
||||
if (isSpecialFocusKey(e.nativeEvent)) {
|
||||
return
|
||||
}
|
||||
|
||||
const event = new KeyboardEvent(e.type, e.nativeEvent)
|
||||
|
||||
inputRef.current.focus()
|
||||
setShowOverlay(false)
|
||||
|
||||
// if the inputRef can use .select() then we can use it here
|
||||
if (inputRef.current instanceof HTMLInputElement) {
|
||||
inputRef.current.select()
|
||||
}
|
||||
|
||||
inputRef.current.dispatchEvent(event)
|
||||
},
|
||||
[showOverlay, validateKeyStroke]
|
||||
)
|
||||
|
||||
const isAnchor = useMemo(() => {
|
||||
return anchor ? isCellMatch(coords, anchor) : false
|
||||
}, [anchor, coords])
|
||||
|
||||
const fieldWithoutOverlay = useMemo(() => {
|
||||
return type === "boolean"
|
||||
}, [type])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAnchor && !containerRef.current?.contains(document.activeElement)) {
|
||||
containerRef.current?.focus()
|
||||
}
|
||||
}, [isAnchor])
|
||||
|
||||
const renderProps: DataGridCellRenderProps = {
|
||||
container: {
|
||||
field,
|
||||
isAnchor,
|
||||
isSelected: getIsCellSelected(coords),
|
||||
isDragSelected: getIsCellDragSelected(coords),
|
||||
showOverlay: fieldWithoutOverlay ? false : showOverlay,
|
||||
innerProps: {
|
||||
ref: containerRef,
|
||||
onMouseOver: getWrapperMouseOverHandler(coords),
|
||||
onMouseDown:
|
||||
type === "boolean" ? handleBooleanInnerMouseDown : undefined,
|
||||
onKeyDown: handleContainerKeyDown,
|
||||
onFocus: getWrapperFocusHandler(coords),
|
||||
...innerAttributes,
|
||||
},
|
||||
overlayProps: {
|
||||
onMouseDown: handleOverlayMouseDown,
|
||||
},
|
||||
},
|
||||
input: {
|
||||
ref: inputRef,
|
||||
onBlur: handleInputBlur,
|
||||
onFocus: handleInputFocus,
|
||||
onChange: getInputChangeHandler(field),
|
||||
...inputAttributes,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
field,
|
||||
register,
|
||||
control,
|
||||
renderProps,
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { useCallback } from "react"
|
||||
import { FieldValues, Path, PathValue } from "react-hook-form"
|
||||
|
||||
import { DataGridBulkUpdateCommand, DataGridMatrix } from "../models"
|
||||
import { DataGridCoordinates } from "../types"
|
||||
|
||||
type UseDataGridClipboardEventsOptions<
|
||||
TData,
|
||||
TFieldValues extends FieldValues
|
||||
> = {
|
||||
matrix: DataGridMatrix<TData, TFieldValues>
|
||||
isEditing: boolean
|
||||
anchor: DataGridCoordinates | null
|
||||
rangeEnd: DataGridCoordinates | null
|
||||
getSelectionValues: (
|
||||
fields: string[]
|
||||
) => PathValue<TFieldValues, Path<TFieldValues>>[]
|
||||
setSelectionValues: (
|
||||
fields: string[],
|
||||
values: PathValue<TFieldValues, Path<TFieldValues>>[]
|
||||
) => void
|
||||
execute: (command: DataGridBulkUpdateCommand) => void
|
||||
}
|
||||
|
||||
export const useDataGridClipboardEvents = <
|
||||
TData,
|
||||
TFieldValues extends FieldValues
|
||||
>({
|
||||
matrix,
|
||||
anchor,
|
||||
rangeEnd,
|
||||
isEditing,
|
||||
getSelectionValues,
|
||||
setSelectionValues,
|
||||
execute,
|
||||
}: UseDataGridClipboardEventsOptions<TData, TFieldValues>) => {
|
||||
const handleCopyEvent = useCallback(
|
||||
(e: ClipboardEvent) => {
|
||||
if (isEditing || !anchor || !rangeEnd) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
const fields = matrix.getFieldsInSelection(anchor, rangeEnd)
|
||||
const values = getSelectionValues(fields)
|
||||
|
||||
const text = values.map((value) => `${value}` ?? "").join("\t")
|
||||
|
||||
e.clipboardData?.setData("text/plain", text)
|
||||
},
|
||||
[isEditing, anchor, rangeEnd, matrix, getSelectionValues]
|
||||
)
|
||||
|
||||
const handlePasteEvent = useCallback(
|
||||
(e: ClipboardEvent) => {
|
||||
if (isEditing || !anchor || !rangeEnd) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
const text = e.clipboardData?.getData("text/plain")
|
||||
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = text.split("\t")
|
||||
|
||||
const fields = matrix.getFieldsInSelection(anchor, rangeEnd)
|
||||
const prev = getSelectionValues(fields)
|
||||
|
||||
const command = new DataGridBulkUpdateCommand({
|
||||
fields,
|
||||
next,
|
||||
prev,
|
||||
setter: setSelectionValues,
|
||||
})
|
||||
|
||||
execute(command)
|
||||
},
|
||||
[
|
||||
isEditing,
|
||||
anchor,
|
||||
rangeEnd,
|
||||
matrix,
|
||||
getSelectionValues,
|
||||
setSelectionValues,
|
||||
execute,
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
handleCopyEvent,
|
||||
handlePasteEvent,
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import type { Column, Table } from "@tanstack/react-table"
|
||||
import { useCallback } from "react"
|
||||
import type { FieldValues } from "react-hook-form"
|
||||
|
||||
import { DataGridMatrix } from "../models"
|
||||
import { GridColumnOption } from "../types"
|
||||
|
||||
export function useDataGridColumnVisibility<
|
||||
TData,
|
||||
TFieldValues extends FieldValues
|
||||
>(grid: Table<TData>, matrix: DataGridMatrix<TData, TFieldValues>) {
|
||||
const columns = grid.getAllLeafColumns()
|
||||
|
||||
const columnOptions: GridColumnOption[] = columns.map((column) => ({
|
||||
id: column.id,
|
||||
name: getColumnName(column),
|
||||
checked: column.getIsVisible(),
|
||||
disabled: !column.getCanHide(),
|
||||
}))
|
||||
|
||||
const handleToggleColumn = useCallback(
|
||||
(index: number) => (value: boolean) => {
|
||||
const column = columns[index]
|
||||
|
||||
if (!column.getCanHide()) {
|
||||
return
|
||||
}
|
||||
|
||||
matrix.toggleColumn(index, value)
|
||||
column.toggleVisibility(value)
|
||||
},
|
||||
[columns, matrix]
|
||||
)
|
||||
|
||||
const handleResetColumns = useCallback(() => {
|
||||
grid.setColumnVisibility({})
|
||||
}, [grid])
|
||||
|
||||
const optionCount = columnOptions.filter((c) => !c.disabled).length
|
||||
const isDisabled = optionCount === 0
|
||||
|
||||
return {
|
||||
columnOptions,
|
||||
handleToggleColumn,
|
||||
handleResetColumns,
|
||||
isDisabled,
|
||||
}
|
||||
}
|
||||
|
||||
function getColumnName<TData>(column: Column<TData, unknown>): string {
|
||||
const id = column.columnDef.id
|
||||
const enableHiding = column.columnDef.enableHiding
|
||||
const meta = column?.columnDef.meta as { name?: string } | undefined
|
||||
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
"Column is missing an id, which is a required field. Please provide an id for the column."
|
||||
)
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "development" && !meta?.name && enableHiding) {
|
||||
console.warn(
|
||||
`Column "${id}" does not have a name. You should add a name to the column definition. Falling back to the column id.`
|
||||
)
|
||||
}
|
||||
|
||||
return meta?.name || id
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { Table, VisibilityState } from "@tanstack/react-table"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { FieldError, FieldErrors, FieldValues } from "react-hook-form"
|
||||
|
||||
import { DataGridMatrix } from "../models"
|
||||
import { VisibilitySnapshot } from "../types"
|
||||
|
||||
export const useDataGridErrorHighlighting = <
|
||||
TData,
|
||||
TFieldValues extends FieldValues
|
||||
>(
|
||||
matrix: DataGridMatrix<TData, TFieldValues>,
|
||||
grid: Table<TData>,
|
||||
errors: FieldErrors<TFieldValues>
|
||||
) => {
|
||||
const [isHighlighted, setIsHighlighted] = useState<boolean>(false)
|
||||
const [visibilitySnapshot, setVisibilitySnapshot] =
|
||||
useState<VisibilitySnapshot | null>(null)
|
||||
|
||||
const { flatRows } = grid.getRowModel()
|
||||
const flatColumns = grid.getAllFlatColumns()
|
||||
|
||||
const errorPaths = findErrorPaths(errors)
|
||||
const errorCount = errorPaths.length
|
||||
|
||||
const { rowsWithErrors, columnsWithErrors } = useMemo(() => {
|
||||
const rowsWithErrors = new Set<number>()
|
||||
const columnsWithErrors = new Set<number>()
|
||||
|
||||
errorPaths.forEach((errorPath) => {
|
||||
const rowIndex = matrix.rowAccessors.findIndex(
|
||||
(accessor) =>
|
||||
accessor &&
|
||||
(errorPath === accessor || errorPath.startsWith(`${accessor}.`))
|
||||
)
|
||||
if (rowIndex !== -1) {
|
||||
rowsWithErrors.add(rowIndex)
|
||||
}
|
||||
|
||||
const columnIndex = matrix.columnAccessors.findIndex(
|
||||
(accessor) =>
|
||||
accessor &&
|
||||
(errorPath === accessor || errorPath.endsWith(`.${accessor}`))
|
||||
)
|
||||
if (columnIndex !== -1) {
|
||||
columnsWithErrors.add(columnIndex)
|
||||
}
|
||||
})
|
||||
|
||||
return { rowsWithErrors, columnsWithErrors }
|
||||
}, [errorPaths, matrix.rowAccessors, matrix.columnAccessors])
|
||||
|
||||
const toggleErrorHighlighting = useCallback(
|
||||
(
|
||||
currentRowVisibility: VisibilityState,
|
||||
currentColumnVisibility: VisibilityState,
|
||||
setRowVisibility: (visibility: VisibilityState) => void,
|
||||
setColumnVisibility: (visibility: VisibilityState) => void
|
||||
) => {
|
||||
if (isHighlighted) {
|
||||
// Clear error highlights
|
||||
if (visibilitySnapshot) {
|
||||
setRowVisibility(visibilitySnapshot.rows)
|
||||
setColumnVisibility(visibilitySnapshot.columns)
|
||||
}
|
||||
} else {
|
||||
// Highlight errors
|
||||
setVisibilitySnapshot({
|
||||
rows: { ...currentRowVisibility },
|
||||
columns: { ...currentColumnVisibility },
|
||||
})
|
||||
|
||||
const rowsToHide = flatRows
|
||||
.map((_, index) => {
|
||||
return !rowsWithErrors.has(index) ? index : undefined
|
||||
})
|
||||
.filter((index): index is number => index !== undefined)
|
||||
|
||||
const columnsToHide = flatColumns
|
||||
.map((column, index) => {
|
||||
return !columnsWithErrors.has(index) && index !== 0
|
||||
? column.id
|
||||
: undefined
|
||||
})
|
||||
.filter((id): id is string => id !== undefined)
|
||||
|
||||
setRowVisibility(
|
||||
rowsToHide.reduce((acc, row) => ({ ...acc, [row]: false }), {})
|
||||
)
|
||||
|
||||
setColumnVisibility(
|
||||
columnsToHide.reduce(
|
||||
(acc, column) => ({ ...acc, [column]: false }),
|
||||
{}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
setIsHighlighted((prev) => !prev)
|
||||
},
|
||||
[
|
||||
isHighlighted,
|
||||
visibilitySnapshot,
|
||||
flatRows,
|
||||
flatColumns,
|
||||
rowsWithErrors,
|
||||
columnsWithErrors,
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
errorCount,
|
||||
isHighlighted,
|
||||
toggleErrorHighlighting,
|
||||
}
|
||||
}
|
||||
|
||||
function findErrorPaths(
|
||||
obj: FieldErrors | FieldError,
|
||||
path: string[] = []
|
||||
): string[] {
|
||||
if (typeof obj !== "object" || obj === null) {
|
||||
return []
|
||||
}
|
||||
|
||||
if ("message" in obj && "type" in obj) {
|
||||
return [path.join(".")]
|
||||
}
|
||||
|
||||
return Object.entries(obj).flatMap(([key, value]) =>
|
||||
findErrorPaths(value, [...path, key])
|
||||
)
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { useCallback } from "react"
|
||||
import { FieldValues, Path, PathValue, UseFormReturn } from "react-hook-form"
|
||||
|
||||
import { DataGridMatrix } from "../models"
|
||||
import { DataGridColumnType, DataGridCoordinates } from "../types"
|
||||
|
||||
type UseDataGridFormHandlersOptions<TData, TFieldValues extends FieldValues> = {
|
||||
matrix: DataGridMatrix<TData, TFieldValues>
|
||||
form: UseFormReturn<TFieldValues>
|
||||
anchor: DataGridCoordinates | null
|
||||
}
|
||||
|
||||
export const useDataGridFormHandlers = <
|
||||
TData,
|
||||
TFieldValues extends FieldValues
|
||||
>({
|
||||
matrix,
|
||||
form,
|
||||
anchor,
|
||||
}: UseDataGridFormHandlersOptions<TData, TFieldValues>) => {
|
||||
const { getValues, setValue } = form
|
||||
|
||||
const getSelectionValues = useCallback(
|
||||
(fields: string[]): PathValue<TFieldValues, Path<TFieldValues>>[] => {
|
||||
if (!fields.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return fields.map((field) => {
|
||||
return getValues(field as Path<TFieldValues>)
|
||||
})
|
||||
},
|
||||
[getValues]
|
||||
)
|
||||
|
||||
const setSelectionValues = useCallback(
|
||||
async (fields: string[], values: string[]) => {
|
||||
if (!fields.length || !anchor) {
|
||||
return
|
||||
}
|
||||
|
||||
const type = matrix.getCellType(anchor)
|
||||
|
||||
if (!type) {
|
||||
return
|
||||
}
|
||||
|
||||
const convertedValues = convertArrayToPrimitive(values, type)
|
||||
|
||||
fields.forEach((field, index) => {
|
||||
if (!field) {
|
||||
return
|
||||
}
|
||||
|
||||
const valueIndex = index % values.length
|
||||
const value = convertedValues[valueIndex] as PathValue<
|
||||
TFieldValues,
|
||||
Path<TFieldValues>
|
||||
>
|
||||
|
||||
setValue(field as Path<TFieldValues>, value, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
})
|
||||
})
|
||||
},
|
||||
[matrix, anchor, setValue]
|
||||
)
|
||||
|
||||
return {
|
||||
getSelectionValues,
|
||||
setSelectionValues,
|
||||
}
|
||||
}
|
||||
|
||||
function convertToNumber(value: string | number): number {
|
||||
if (typeof value === "number") {
|
||||
return value
|
||||
}
|
||||
|
||||
const converted = Number(value)
|
||||
|
||||
if (isNaN(converted)) {
|
||||
throw new Error(`String "${value}" cannot be converted to number.`)
|
||||
}
|
||||
|
||||
return converted
|
||||
}
|
||||
|
||||
function convertToBoolean(value: string | boolean): boolean {
|
||||
if (typeof value === "boolean") {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === "undefined" || value === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lowerValue = value.toLowerCase()
|
||||
|
||||
if (lowerValue === "true" || lowerValue === "false") {
|
||||
return lowerValue === "true"
|
||||
}
|
||||
|
||||
throw new Error(`String "${value}" cannot be converted to boolean.`)
|
||||
}
|
||||
|
||||
function covertToString(value: any): string {
|
||||
if (typeof value === "undefined" || value === null) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function convertArrayToPrimitive(
|
||||
values: any[],
|
||||
type: DataGridColumnType
|
||||
): any[] {
|
||||
switch (type) {
|
||||
case "number":
|
||||
return values.map(convertToNumber)
|
||||
case "boolean":
|
||||
return values.map(convertToBoolean)
|
||||
case "text":
|
||||
return values.map(covertToString)
|
||||
default:
|
||||
throw new Error(`Unsupported target type "${type}".`)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1440 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user