feat(dashboard): DataTable component (#6297)
This commit is contained in:
committed by
GitHub
parent
a7be5d7b6d
commit
8cbf6c60fe
6
.changeset/healthy-ligers-learn.md
Normal file
6
.changeset/healthy-ligers-learn.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/ui-preset": patch
|
||||||
|
"@medusajs/ui": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(ui,ui-preset): Update to latest version of TailwindCSS. Increase spacing between columns in <Table /> component.
|
||||||
@@ -9,6 +9,8 @@ packages/*
|
|||||||
!packages/medusa
|
!packages/medusa
|
||||||
!packages/admin-ui
|
!packages/admin-ui
|
||||||
!packages/admin
|
!packages/admin
|
||||||
|
!packages/admin-next
|
||||||
|
!packages/admin-next/dashboard
|
||||||
!packages/medusa-payment-stripe
|
!packages/medusa-payment-stripe
|
||||||
!packages/medusa-payment-paypal
|
!packages/medusa-payment-paypal
|
||||||
!packages/event-bus-redis
|
!packages/event-bus-redis
|
||||||
|
|||||||
43
.eslintrc.js
43
.eslintrc.js
@@ -72,7 +72,6 @@ module.exports = {
|
|||||||
node: true,
|
node: true,
|
||||||
jest: true,
|
jest: true,
|
||||||
},
|
},
|
||||||
ignorePatterns: ["packages/admin-next/dashboard/**/dist"],
|
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
files: ["*.ts"],
|
files: ["*.ts"],
|
||||||
@@ -86,6 +85,7 @@ module.exports = {
|
|||||||
"./packages/medusa-payment-paypal/tsconfig.spec.json",
|
"./packages/medusa-payment-paypal/tsconfig.spec.json",
|
||||||
"./packages/admin-ui/tsconfig.json",
|
"./packages/admin-ui/tsconfig.json",
|
||||||
"./packages/admin-ui/tsconfig.spec.json",
|
"./packages/admin-ui/tsconfig.spec.json",
|
||||||
|
"./packages/admin-next/dashboard/tsconfig.json",
|
||||||
"./packages/event-bus-local/tsconfig.spec.json",
|
"./packages/event-bus-local/tsconfig.spec.json",
|
||||||
"./packages/event-bus-redis/tsconfig.spec.json",
|
"./packages/event-bus-redis/tsconfig.spec.json",
|
||||||
"./packages/medusa-plugin-meilisearch/tsconfig.spec.json",
|
"./packages/medusa-plugin-meilisearch/tsconfig.spec.json",
|
||||||
@@ -228,23 +228,52 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ["packages/admin-next/dashboard/src/**/*.{ts,tsx}"],
|
files: [
|
||||||
env: { browser: true, es2020: true, node: true },
|
"packages/admin-next/dashboard/**/*.ts",
|
||||||
|
"packages/admin-next/dashboard/**/*.tsx",
|
||||||
|
],
|
||||||
|
plugins: ["unused-imports", "react-refresh"],
|
||||||
extends: [
|
extends: [
|
||||||
"eslint:recommended",
|
"plugin:react/recommended",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:react/jsx-runtime",
|
||||||
"plugin:react-hooks/recommended",
|
"plugin:react-hooks/recommended",
|
||||||
],
|
],
|
||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: "tsconfig.json",
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features
|
||||||
|
sourceType: "module", // Allows for the use of imports
|
||||||
|
project: "./packages/admin-next/dashboard/tsconfig.json",
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
__BASE__: "readonly",
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
},
|
},
|
||||||
plugins: ["react-refresh"],
|
|
||||||
rules: {
|
rules: {
|
||||||
|
"prettier/prettier": "error",
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"new-cap": "off",
|
||||||
|
"require-jsdoc": "off",
|
||||||
|
"valid-jsdoc": "off",
|
||||||
"react-refresh/only-export-components": [
|
"react-refresh/only-export-components": [
|
||||||
"warn",
|
"warn",
|
||||||
{ allowConstantExport: true },
|
{ allowConstantExport: true },
|
||||||
],
|
],
|
||||||
|
"no-unused-expressions": "off",
|
||||||
|
"unused-imports/no-unused-imports": "error",
|
||||||
|
"unused-imports/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
vars: "all",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
args: "after-used",
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"@medusajs/icons": "workspace:^",
|
"@medusajs/icons": "workspace:^",
|
||||||
"@medusajs/ui": "workspace:^",
|
"@medusajs/ui": "workspace:^",
|
||||||
"@radix-ui/react-collapsible": "1.0.3",
|
"@radix-ui/react-collapsible": "1.0.3",
|
||||||
|
"@radix-ui/react-hover-card": "^1.0.7",
|
||||||
"@tanstack/react-query": "4.22.0",
|
"@tanstack/react-query": "4.22.0",
|
||||||
"@tanstack/react-table": "8.10.7",
|
"@tanstack/react-table": "8.10.7",
|
||||||
"@uiw/react-json-view": "2.0.0-alpha.10",
|
"@uiw/react-json-view": "2.0.0-alpha.10",
|
||||||
@@ -43,13 +44,14 @@
|
|||||||
"@medusajs/types": "workspace:^",
|
"@medusajs/types": "workspace:^",
|
||||||
"@medusajs/ui-preset": "workspace:^",
|
"@medusajs/ui-preset": "workspace:^",
|
||||||
"@medusajs/vite-plugin-extension": "workspace:^",
|
"@medusajs/vite-plugin-extension": "workspace:^",
|
||||||
|
"@types/node": "^20.11.15",
|
||||||
"@types/react": "18.2.43",
|
"@types/react": "18.2.43",
|
||||||
"@types/react-dom": "18.2.17",
|
"@types/react-dom": "18.2.17",
|
||||||
"@vitejs/plugin-react": "4.2.1",
|
"@vitejs/plugin-react": "4.2.1",
|
||||||
"autoprefixer": "10.4.16",
|
"autoprefixer": "10.4.16",
|
||||||
"postcss": "8.4.32",
|
"postcss": "8.4.32",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
"tailwindcss": "3.3.6",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
"vite": "5.0.10"
|
"vite": "5.0.10"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"pages": "pages",
|
"pages": "pages",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"prev": "Prev",
|
"prev": "Prev",
|
||||||
|
"is": "is",
|
||||||
"extensions": "Extensions",
|
"extensions": "Extensions",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
@@ -35,6 +36,8 @@
|
|||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"store": "Store",
|
"store": "Store",
|
||||||
|
"items_one": "{{count}} item",
|
||||||
|
"items_other": "{{count}} items",
|
||||||
"countSelected": "{{count}} selected",
|
"countSelected": "{{count}} selected",
|
||||||
"plusCountMore": "+ {{count}} more",
|
"plusCountMore": "+ {{count}} more",
|
||||||
"areYouSure": "Are you sure?",
|
"areYouSure": "Are you sure?",
|
||||||
@@ -107,7 +110,29 @@
|
|||||||
"deleteCustomerGroupWarning": "You are about to delete the customer group {{name}}. This action cannot be undone."
|
"deleteCustomerGroupWarning": "You are about to delete the customer group {{name}}. This action cannot be undone."
|
||||||
},
|
},
|
||||||
"orders": {
|
"orders": {
|
||||||
"domain": "Orders"
|
"domain": "Orders",
|
||||||
|
"paymentStatusLabel": "Payment Status",
|
||||||
|
"paymentStatus": {
|
||||||
|
"notPaid": "Not Paid",
|
||||||
|
"awaiting": "Awaiting",
|
||||||
|
"captured": "Captured",
|
||||||
|
"partiallyRefunded": "Partially Refunded",
|
||||||
|
"refunded": "Refunded",
|
||||||
|
"canceled": "Canceled",
|
||||||
|
"requresAction": "Requires Action"
|
||||||
|
},
|
||||||
|
"fulfillmentStatusLabel": "Fulfillment Status",
|
||||||
|
"fulfillmentStatus": {
|
||||||
|
"notFulfilled": "Not Fulfilled",
|
||||||
|
"partiallyFulfilled": "Partially Fulfilled",
|
||||||
|
"fulfilled": "Fulfilled",
|
||||||
|
"partiallyShipped": "Partially Shipped",
|
||||||
|
"shipped": "Shipped",
|
||||||
|
"partiallyReturned": "Partially Returned",
|
||||||
|
"returned": "Returned",
|
||||||
|
"canceled": "Canceled",
|
||||||
|
"requresAction": "Requires Action"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"draftOrders": {
|
"draftOrders": {
|
||||||
"domain": "Draft Orders"
|
"domain": "Draft Orders"
|
||||||
@@ -263,6 +288,14 @@
|
|||||||
"total": "Total",
|
"total": "Total",
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
"key": "Key",
|
"key": "Key",
|
||||||
|
"customer": "Customer",
|
||||||
|
"date": "Date",
|
||||||
|
"order": "Order",
|
||||||
|
"fulfillment": "Fulfillment",
|
||||||
|
"payment": "Payment",
|
||||||
|
"items": "Items",
|
||||||
|
"salesChannel": "Sales Channel",
|
||||||
|
"region": "Region",
|
||||||
"role": "Role",
|
"role": "Role",
|
||||||
"sent": "Sent"
|
"sent": "Sent"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./order-table-cells"
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import type { Order } from "@medusajs/medusa"
|
|
||||||
import { StatusBadge } from "@medusajs/ui"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
import { getPresentationalAmount } from "../../../lib/money-amount-helpers"
|
|
||||||
|
|
||||||
export const OrderDisplayIdCell = ({ id }: { id: Order["display_id"] }) => {
|
|
||||||
return <span>#{id}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OrderDateCell = ({
|
|
||||||
date,
|
|
||||||
}: {
|
|
||||||
date: Order["created_at"] | string
|
|
||||||
}) => {
|
|
||||||
const value = new Date(date)
|
|
||||||
|
|
||||||
return <span>{format(value, "dd MMM, yyyy")}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OrderFulfillmentStatusCell = ({
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
status: Order["fulfillment_status"]
|
|
||||||
}) => {
|
|
||||||
switch (status) {
|
|
||||||
case "not_fulfilled":
|
|
||||||
return <StatusBadge color="grey">Not fulfilled</StatusBadge>
|
|
||||||
case "partially_fulfilled":
|
|
||||||
return <StatusBadge color="orange">Partially fulfilled</StatusBadge>
|
|
||||||
case "fulfilled":
|
|
||||||
return <StatusBadge color="green">Fulfilled</StatusBadge>
|
|
||||||
case "partially_shipped":
|
|
||||||
return <StatusBadge color="orange">Partially shipped</StatusBadge>
|
|
||||||
case "shipped":
|
|
||||||
return <StatusBadge color="green">Shipped</StatusBadge>
|
|
||||||
case "partially_returned":
|
|
||||||
return <StatusBadge color="orange">Partially returned</StatusBadge>
|
|
||||||
case "returned":
|
|
||||||
return <StatusBadge color="green">Returned</StatusBadge>
|
|
||||||
case "canceled":
|
|
||||||
return <StatusBadge color="red">Canceled</StatusBadge>
|
|
||||||
case "requires_action":
|
|
||||||
return <StatusBadge color="orange">Requires action</StatusBadge>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OrderPaymentStatusCell = ({
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
status: Order["payment_status"]
|
|
||||||
}) => {
|
|
||||||
switch (status) {
|
|
||||||
case "not_paid":
|
|
||||||
return <StatusBadge color="grey">Not paid</StatusBadge>
|
|
||||||
case "awaiting":
|
|
||||||
return <StatusBadge color="orange">Awaiting</StatusBadge>
|
|
||||||
case "captured":
|
|
||||||
return <StatusBadge color="green">Captured</StatusBadge>
|
|
||||||
case "partially_refunded":
|
|
||||||
return <StatusBadge color="orange">Partially refunded</StatusBadge>
|
|
||||||
case "refunded":
|
|
||||||
return <StatusBadge color="green">Refunded</StatusBadge>
|
|
||||||
case "canceled":
|
|
||||||
return <StatusBadge color="red">Canceled</StatusBadge>
|
|
||||||
case "requires_action":
|
|
||||||
return <StatusBadge color="orange">Requires action</StatusBadge>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Fix formatting amount with correct division eg. EUR 1000 -> EUR 10.00
|
|
||||||
// Source currency info from `@medusajs/medusa` definition
|
|
||||||
export const OrderTotalCell = ({
|
|
||||||
total,
|
|
||||||
currencyCode,
|
|
||||||
}: {
|
|
||||||
total: Order["total"]
|
|
||||||
currencyCode: Order["currency_code"]
|
|
||||||
}) => {
|
|
||||||
const formatted = new Intl.NumberFormat(undefined, {
|
|
||||||
style: "currency",
|
|
||||||
currency: currencyCode,
|
|
||||||
currencyDisplay: "narrowSymbol",
|
|
||||||
}).format(0)
|
|
||||||
|
|
||||||
const symbol = formatted.replace(/\d/g, "").replace(/[.,]/g, "").trim()
|
|
||||||
|
|
||||||
const presentationAmount = getPresentationalAmount(total, currencyCode)
|
|
||||||
const formattedTotal = new Intl.NumberFormat(undefined, {
|
|
||||||
style: "decimal",
|
|
||||||
}).format(presentationAmount)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
{symbol} {formattedTotal} {currencyCode.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -35,13 +35,13 @@ export const Shell = ({ children }: PropsWithChildren) => {
|
|||||||
<MobileSidebarContainer>{children}</MobileSidebarContainer>
|
<MobileSidebarContainer>{children}</MobileSidebarContainer>
|
||||||
<DesktopSidebarContainer>{children}</DesktopSidebarContainer>
|
<DesktopSidebarContainer>{children}</DesktopSidebarContainer>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col h-screen w-full">
|
<div className="flex flex-col h-screen w-full overflow-auto">
|
||||||
<Topbar />
|
<Topbar />
|
||||||
<div className="flex h-full w-full flex-col items-center overflow-y-auto">
|
<main className="flex h-full w-full flex-col items-center overflow-y-auto">
|
||||||
<Gutter>
|
<Gutter>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Gutter>
|
</Gutter>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -76,6 +76,7 @@ const Breadcrumbs = () => {
|
|||||||
<ol className={clx("text-ui-fg-muted flex items-center select-none")}>
|
<ol className={clx("text-ui-fg-muted flex items-center select-none")}>
|
||||||
{crumbs.map((crumb, index) => {
|
{crumbs.map((crumb, index) => {
|
||||||
const isLast = index === crumbs.length - 1
|
const isLast = index === crumbs.length - 1
|
||||||
|
const isSingle = crumbs.length === 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
@@ -93,8 +94,13 @@ const Breadcrumbs = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<span className="block md:hidden">...</span>
|
{!isSingle && <span className="block lg:hidden">...</span>}
|
||||||
<span key={index} className="hidden md:block">
|
<span
|
||||||
|
key={index}
|
||||||
|
className={clx({
|
||||||
|
"hidden lg:block": !isSingle,
|
||||||
|
})}
|
||||||
|
>
|
||||||
{crumb.label}
|
{crumb.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createContext, useContext } from "react"
|
||||||
|
|
||||||
|
type DataTableFilterContextValue = {
|
||||||
|
removeFilter: (key: string) => void
|
||||||
|
removeAllFilters: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTableFilterContext =
|
||||||
|
createContext<DataTableFilterContextValue | null>(null)
|
||||||
|
|
||||||
|
export const useDataTableFilterContext = () => {
|
||||||
|
const ctx = useContext(DataTableFilterContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"useDataTableFacetedFilterContext must be used within a DataTableFacetedFilter"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { Button, clx } from "@medusajs/ui"
|
||||||
|
import * as Popover from "@radix-ui/react-popover"
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
|
import { DataTableFilterContext, useDataTableFilterContext } from "./context"
|
||||||
|
import { DateFilter } from "./date-filter"
|
||||||
|
import { SelectFilter } from "./select-filter"
|
||||||
|
|
||||||
|
type Option = {
|
||||||
|
label: string
|
||||||
|
value: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Filter = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
type: "select"
|
||||||
|
options: Option[]
|
||||||
|
multiple?: boolean
|
||||||
|
searchable?: boolean
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "date"
|
||||||
|
options?: never
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type DataTableFilterProps = {
|
||||||
|
filters: Filter[]
|
||||||
|
prefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const [activeFilters, setActiveFilters] = useState(
|
||||||
|
getInitialFilters({ searchParams, filters, prefix })
|
||||||
|
)
|
||||||
|
|
||||||
|
const availableFilters = filters.filter(
|
||||||
|
(f) => !activeFilters.find((af) => af.key === f.key)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If there are any filters in the URL that are not in the active filters,
|
||||||
|
* add them to the active filters. This ensures that we display the filters
|
||||||
|
* if a user navigates to a page with filters in the URL.
|
||||||
|
*/
|
||||||
|
const initialMount = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialMount.current) {
|
||||||
|
const params = new URLSearchParams(searchParams)
|
||||||
|
|
||||||
|
filters.forEach((filter) => {
|
||||||
|
const key = prefix ? `${prefix}_${filter.key}` : filter.key
|
||||||
|
const value = params.get(key)
|
||||||
|
if (value && !activeFilters.find((af) => af.key === filter.key)) {
|
||||||
|
console.log("adding filter", filter.key, "to active filters")
|
||||||
|
if (filter.type === "select") {
|
||||||
|
setActiveFilters((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
...filter,
|
||||||
|
multiple: filter.multiple,
|
||||||
|
options: filter.options,
|
||||||
|
openOnMount: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
setActiveFilters((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ ...filter, openOnMount: false },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
initialMount.current = false
|
||||||
|
}, [activeFilters, filters, prefix, searchParams])
|
||||||
|
|
||||||
|
const addFilter = (filter: Filter) => {
|
||||||
|
setOpen(false)
|
||||||
|
setActiveFilters((prev) => [...prev, { ...filter, openOnMount: true }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFilter = useCallback((key: string) => {
|
||||||
|
setActiveFilters((prev) => prev.filter((f) => f.key !== key))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeAllFilters = useCallback(() => {
|
||||||
|
setActiveFilters([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTableFilterContext.Provider
|
||||||
|
value={useMemo(
|
||||||
|
() => ({
|
||||||
|
removeFilter,
|
||||||
|
removeAllFilters,
|
||||||
|
}),
|
||||||
|
[removeAllFilters, removeFilter]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="max-w-2/3 flex flex-wrap items-center gap-2">
|
||||||
|
{activeFilters.map((filter) => {
|
||||||
|
if (filter.type === "select") {
|
||||||
|
return (
|
||||||
|
<SelectFilter
|
||||||
|
key={filter.key}
|
||||||
|
filter={filter}
|
||||||
|
prefix={prefix}
|
||||||
|
options={filter.options}
|
||||||
|
multiple={filter.multiple}
|
||||||
|
searchable={filter.searchable}
|
||||||
|
openOnMount={filter.openOnMount}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DateFilter
|
||||||
|
key={filter.key}
|
||||||
|
filter={filter}
|
||||||
|
prefix={prefix}
|
||||||
|
openOnMount={filter.openOnMount}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{availableFilters.length > 0 && (
|
||||||
|
<Popover.Root modal open={open} onOpenChange={setOpen}>
|
||||||
|
<Popover.Trigger asChild id="filters_menu_trigger">
|
||||||
|
<Button size="small" variant="secondary">
|
||||||
|
Add filter
|
||||||
|
</Button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
className={clx(
|
||||||
|
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout z-[1] h-full max-h-[200px] w-[300px] overflow-hidden rounded-lg p-1 outline-none"
|
||||||
|
)}
|
||||||
|
data-name="filters_menu_content"
|
||||||
|
align="start"
|
||||||
|
sideOffset={8}
|
||||||
|
collisionPadding={8}
|
||||||
|
onCloseAutoFocus={(e) => {
|
||||||
|
const hasOpenFilter = activeFilters.find(
|
||||||
|
(filter) => filter.openOnMount
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasOpenFilter) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{availableFilters.map((filter) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-ui-bg-base hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-pressed text-ui-fg-base data-[disabled]:text-ui-fg-disabled txt-compact-small relative flex cursor-pointer select-none items-center rounded-md px-2 py-1.5 outline-none transition-colors data-[disabled]:pointer-events-none"
|
||||||
|
role="menuitem"
|
||||||
|
key={filter.key}
|
||||||
|
onClick={() => {
|
||||||
|
addFilter(filter)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
)}
|
||||||
|
{activeFilters.length > 0 && (
|
||||||
|
<ClearAllFilters filters={filters} prefix={prefix} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DataTableFilterContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClearAllFiltersProps = {
|
||||||
|
filters: Filter[]
|
||||||
|
prefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClearAllFilters = ({ filters, prefix }: ClearAllFiltersProps) => {
|
||||||
|
const { removeAllFilters } = useDataTableFilterContext()
|
||||||
|
const [_, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
|
const handleRemoveAll = () => {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const newValues = new URLSearchParams(prev)
|
||||||
|
|
||||||
|
filters.forEach((filter) => {
|
||||||
|
newValues.delete(prefix ? `${prefix}_${filter.key}` : filter.key)
|
||||||
|
})
|
||||||
|
|
||||||
|
return newValues
|
||||||
|
})
|
||||||
|
|
||||||
|
removeAllFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveAll}
|
||||||
|
className={clx(
|
||||||
|
"text-ui-fg-muted transition-fg txt-compact-small-plus rounded-md px-2 py-1",
|
||||||
|
"hover:text-ui-fg-subtle",
|
||||||
|
"focus-visible:shadow-borders-focus"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitialFilters = ({
|
||||||
|
searchParams,
|
||||||
|
filters,
|
||||||
|
prefix,
|
||||||
|
}: {
|
||||||
|
searchParams: URLSearchParams
|
||||||
|
filters: Filter[]
|
||||||
|
prefix?: string
|
||||||
|
}) => {
|
||||||
|
const params = new URLSearchParams(searchParams)
|
||||||
|
const activeFilters: (Filter & { openOnMount: boolean })[] = []
|
||||||
|
|
||||||
|
filters.forEach((filter) => {
|
||||||
|
const key = prefix ? `${prefix}_${filter.key}` : filter.key
|
||||||
|
const value = params.get(key)
|
||||||
|
if (value) {
|
||||||
|
if (filter.type === "select") {
|
||||||
|
activeFilters.push({
|
||||||
|
...filter,
|
||||||
|
multiple: filter.multiple,
|
||||||
|
options: filter.options,
|
||||||
|
openOnMount: false,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
activeFilters.push({ ...filter, openOnMount: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return activeFilters
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import { EllipseMiniSolid, XMarkMini } from "@medusajs/icons"
|
||||||
|
import { DatePicker, Text, clx } from "@medusajs/ui"
|
||||||
|
import * as Popover from "@radix-ui/react-popover"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
import isEqual from "lodash/isEqual"
|
||||||
|
import { MouseEvent, useState } from "react"
|
||||||
|
|
||||||
|
import { useSelectedParams } from "../hooks"
|
||||||
|
import { useDataTableFilterContext } from "./context"
|
||||||
|
import { IFilter } from "./types"
|
||||||
|
|
||||||
|
type DateFilterProps = IFilter
|
||||||
|
|
||||||
|
type DateComparisonOperator = {
|
||||||
|
gte?: string
|
||||||
|
lte?: string
|
||||||
|
lt?: string
|
||||||
|
gt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateFilter = ({
|
||||||
|
filter,
|
||||||
|
prefix,
|
||||||
|
openOnMount,
|
||||||
|
}: DateFilterProps) => {
|
||||||
|
const [open, setOpen] = useState(openOnMount)
|
||||||
|
const [showCustom, setShowCustom] = useState(false)
|
||||||
|
const { key, label } = filter
|
||||||
|
const { removeFilter } = useDataTableFilterContext()
|
||||||
|
const selectedParams = useSelectedParams({ param: key, prefix })
|
||||||
|
|
||||||
|
const handleSelectPreset = (value: DateComparisonOperator) => {
|
||||||
|
selectedParams.add(JSON.stringify(value))
|
||||||
|
setShowCustom(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectCustom = () => {
|
||||||
|
selectedParams.delete()
|
||||||
|
setShowCustom((prev) => !prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValue = selectedParams.get()
|
||||||
|
|
||||||
|
const currentDateComparison = parseDateComparison(currentValue)
|
||||||
|
const customStartValue = getDateFromComparison(currentDateComparison, "gte")
|
||||||
|
const customEndValue = getDateFromComparison(currentDateComparison, "lte")
|
||||||
|
|
||||||
|
const handleCustomDateChange = (
|
||||||
|
value: Date | undefined,
|
||||||
|
pos: "start" | "end"
|
||||||
|
) => {
|
||||||
|
const key = pos === "start" ? "gte" : "lte"
|
||||||
|
const dateValue = value ? value.toISOString() : undefined
|
||||||
|
|
||||||
|
selectedParams.add(
|
||||||
|
JSON.stringify({
|
||||||
|
...(currentDateComparison || {}),
|
||||||
|
[key]: dateValue,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDisplayValueFromPresets = () => {
|
||||||
|
const preset = presets.find((p) => isEqual(p.value, currentDateComparison))
|
||||||
|
return preset?.label
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCustomDate = (date: Date | undefined) => {
|
||||||
|
return date ? format(date, "dd MMM, yyyy") : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCustomDisplayValue = () => {
|
||||||
|
const formattedDates = [customStartValue, customEndValue].map(
|
||||||
|
formatCustomDate
|
||||||
|
)
|
||||||
|
return formattedDates.filter(Boolean).join(" - ")
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayValue = getDisplayValueFromPresets() || getCustomDisplayValue()
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
selectedParams.delete()
|
||||||
|
removeFilter(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
setOpen(open)
|
||||||
|
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open && !currentValue.length) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
removeFilter(key)
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Root open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DateDisplay label={label} value={displayValue} onRemove={handleRemove} />
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
data-name="date_filter_content"
|
||||||
|
align="start"
|
||||||
|
sideOffset={8}
|
||||||
|
collisionPadding={24}
|
||||||
|
className={clx(
|
||||||
|
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout max-h-[var(--radix-popper-available-height)] w-[300px] overflow-hidden rounded-lg"
|
||||||
|
)}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
if (e.target instanceof HTMLElement) {
|
||||||
|
if (
|
||||||
|
e.target.attributes.getNamedItem("data-name")?.value ===
|
||||||
|
"filters_menu_content"
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ul className="w-full p-1">
|
||||||
|
{presets.map((preset) => {
|
||||||
|
const isSelected = selectedParams
|
||||||
|
.get()
|
||||||
|
.includes(JSON.stringify(preset.value))
|
||||||
|
return (
|
||||||
|
<li key={preset.label}>
|
||||||
|
<button
|
||||||
|
className="bg-ui-bg-base hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-pressed text-ui-fg-base data-[disabled]:text-ui-fg-disabled txt-compact-small relative flex w-full cursor-pointer select-none items-center rounded-md px-2 py-1.5 outline-none transition-colors data-[disabled]:pointer-events-none"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
handleSelectPreset(preset.value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"transition-fg flex h-5 w-5 items-center justify-center",
|
||||||
|
{
|
||||||
|
"[&_svg]:invisible": !isSelected,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<EllipseMiniSolid />
|
||||||
|
</div>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="bg-ui-bg-base hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-pressed text-ui-fg-base data-[disabled]:text-ui-fg-disabled txt-compact-small relative flex w-full cursor-pointer select-none items-center rounded-md px-2 py-1.5 outline-none transition-colors data-[disabled]:pointer-events-none"
|
||||||
|
type="button"
|
||||||
|
onClick={handleSelectCustom}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"transition-fg flex h-5 w-5 items-center justify-center",
|
||||||
|
{
|
||||||
|
"[&_svg]:invisible": !showCustom,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<EllipseMiniSolid />
|
||||||
|
</div>
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{showCustom && (
|
||||||
|
<div className="border-t px-1 pb-3 pt-1">
|
||||||
|
<div>
|
||||||
|
<div className="px-2 py-1">
|
||||||
|
<Text size="xsmall" leading="compact" weight="plus">
|
||||||
|
Starting
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1">
|
||||||
|
<DatePicker
|
||||||
|
placeholder="MM/DD/YYYY"
|
||||||
|
toDate={customEndValue}
|
||||||
|
value={customStartValue}
|
||||||
|
onChange={(d) => handleCustomDateChange(d, "start")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="px-2 py-1">
|
||||||
|
<Text size="xsmall" leading="compact" weight="plus">
|
||||||
|
Ending
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1">
|
||||||
|
<DatePicker
|
||||||
|
placeholder="MM/DD/YYYY"
|
||||||
|
fromDate={customStartValue}
|
||||||
|
value={customEndValue || undefined}
|
||||||
|
onChange={(d) => {
|
||||||
|
handleCustomDateChange(d, "end")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DateDisplayProps = {
|
||||||
|
label: string
|
||||||
|
value?: string
|
||||||
|
onRemove: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateDisplay = ({ label, value, onRemove }: DateDisplayProps) => {
|
||||||
|
const handleRemove = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRemove()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Trigger
|
||||||
|
asChild
|
||||||
|
className={clx(
|
||||||
|
"bg-ui-bg-field transition-fg shadow-borders-base text-ui-fg-subtle flex cursor-pointer select-none items-center rounded-md",
|
||||||
|
"hover:bg-ui-bg-field-hover",
|
||||||
|
"data-[state=open]:bg-ui-bg-field-hover"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={clx("flex items-center justify-center px-2 py-1", {
|
||||||
|
"border-r": !!value,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{value && (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div key={value} className="border-r p-1 px-2">
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{value && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={handleRemove}
|
||||||
|
className={clx(
|
||||||
|
"text-ui-fg-muted transition-fg flex items-center justify-center p-1",
|
||||||
|
"hover:bg-ui-bg-subtle-hover",
|
||||||
|
"active:bg-ui-bg-subtle-pressed active:text-ui-fg-base"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<XMarkMini />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popover.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const presets: { label: string; value: DateComparisonOperator }[] = [
|
||||||
|
{
|
||||||
|
label: "Today",
|
||||||
|
value: {
|
||||||
|
gte: today.toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 7 days",
|
||||||
|
value: {
|
||||||
|
gte: new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days ago
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 30 days",
|
||||||
|
value: {
|
||||||
|
gte: new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days ago
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 90 days",
|
||||||
|
value: {
|
||||||
|
gte: new Date(today.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(), // 90 days ago
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 12 months",
|
||||||
|
value: {
|
||||||
|
gte: new Date(today.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString(), // 365 days ago
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const parseDateComparison = (value: string[]) => {
|
||||||
|
return value?.length
|
||||||
|
? (JSON.parse(value.join(",")) as DateComparisonOperator)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDateFromComparison = (
|
||||||
|
comparison: DateComparisonOperator | null,
|
||||||
|
key: "gte" | "lte"
|
||||||
|
) => {
|
||||||
|
return comparison?.[key] ? new Date(comparison[key] as string) : undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./data-table-filter"
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { CheckMini, EllipseMiniSolid, XMarkMini } from "@medusajs/icons"
|
||||||
|
import { Text, clx } from "@medusajs/ui"
|
||||||
|
import * as Popover from "@radix-ui/react-popover"
|
||||||
|
import { Command } from "cmdk"
|
||||||
|
import { MouseEvent, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { useSelectedParams } from "../hooks"
|
||||||
|
import { useDataTableFilterContext } from "./context"
|
||||||
|
import { IFilter } from "./types"
|
||||||
|
|
||||||
|
interface SelectFilterProps extends IFilter {
|
||||||
|
options: { label: string; value: unknown }[]
|
||||||
|
multiple?: boolean
|
||||||
|
searchable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectFilter = ({
|
||||||
|
filter,
|
||||||
|
prefix,
|
||||||
|
multiple,
|
||||||
|
searchable,
|
||||||
|
options,
|
||||||
|
openOnMount,
|
||||||
|
}: SelectFilterProps) => {
|
||||||
|
const [open, setOpen] = useState(openOnMount)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [searchRef, setSearchRef] = useState<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { removeFilter } = useDataTableFilterContext()
|
||||||
|
|
||||||
|
const { key, label } = filter
|
||||||
|
const selectedParams = useSelectedParams({ param: key, prefix, multiple })
|
||||||
|
const currentValue = selectedParams.get()
|
||||||
|
|
||||||
|
const labelValues = currentValue
|
||||||
|
.map((v) => options.find((o) => o.value === v)?.label)
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
selectedParams.delete()
|
||||||
|
removeFilter(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
setOpen(open)
|
||||||
|
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open && !currentValue.length) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
removeFilter(key)
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setSearch("")
|
||||||
|
if (searchRef) {
|
||||||
|
searchRef.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = (value: unknown) => {
|
||||||
|
const isSelected = selectedParams.get().includes(String(value))
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
selectedParams.delete(String(value))
|
||||||
|
} else {
|
||||||
|
selectedParams.add(String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<SelectDisplay
|
||||||
|
label={label}
|
||||||
|
value={labelValues}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
/>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
hideWhenDetached
|
||||||
|
align="start"
|
||||||
|
sideOffset={8}
|
||||||
|
collisionPadding={8}
|
||||||
|
className={clx(
|
||||||
|
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout z-[1] h-full max-h-[200px] w-[300px] overflow-hidden rounded-lg outline-none"
|
||||||
|
)}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
if (e.target instanceof HTMLElement) {
|
||||||
|
if (
|
||||||
|
e.target.attributes.getNamedItem("data-name")?.value ===
|
||||||
|
"filters_menu_content"
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Command className="h-full">
|
||||||
|
{searchable && (
|
||||||
|
<div className="border-b p-1">
|
||||||
|
<div className="grid grid-cols-[1fr_20px] gap-x-2 rounded-md px-2 py-1">
|
||||||
|
<Command.Input
|
||||||
|
ref={setSearchRef}
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
className="txt-compact-small placeholder:text-ui-fg-muted outline-none"
|
||||||
|
placeholder="Search"
|
||||||
|
/>
|
||||||
|
<div className="flex h-5 w-5 items-center justify-center">
|
||||||
|
<button
|
||||||
|
disabled={!search}
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
className={clx(
|
||||||
|
"transition-fg text-ui-fg-muted focus-visible:bg-ui-bg-base-pressed rounded-md outline-none",
|
||||||
|
{
|
||||||
|
invisible: !search,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<XMarkMini />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Command.Empty className="txt-compact-small flex items-center justify-center p-1">
|
||||||
|
<span className="w-full px-2 py-1 text-center">
|
||||||
|
{t("general.noResultsTitle")}
|
||||||
|
</span>
|
||||||
|
</Command.Empty>
|
||||||
|
<Command.List className="h-full max-h-[163px] min-h-[0] overflow-auto p-1 outline-none">
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selectedParams
|
||||||
|
.get()
|
||||||
|
.includes(String(option.value))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Command.Item
|
||||||
|
key={String(option.value)}
|
||||||
|
className="bg-ui-bg-base hover:bg-ui-bg-base-hover aria-selected:bg-ui-bg-base-pressed focus-visible:bg-ui-bg-base-pressed text-ui-fg-base data-[disabled]:text-ui-fg-disabled txt-compact-small relative flex cursor-pointer select-none items-center gap-x-2 rounded-md px-2 py-1.5 outline-none transition-colors data-[disabled]:pointer-events-none"
|
||||||
|
value={option.label}
|
||||||
|
onSelect={() => {
|
||||||
|
handleSelect(option.value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"transition-fg flex h-5 w-5 items-center justify-center",
|
||||||
|
{
|
||||||
|
"[&_svg]:invisible": !isSelected,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{multiple ? <CheckMini /> : <EllipseMiniSolid />}
|
||||||
|
</div>
|
||||||
|
{option.label}
|
||||||
|
</Command.Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Command.List>
|
||||||
|
</Command>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectDisplayProps = {
|
||||||
|
label: string
|
||||||
|
value?: string | string[]
|
||||||
|
onRemove: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectDisplay = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onRemove,
|
||||||
|
}: SelectDisplayProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const v = value ? (Array.isArray(value) ? value : [value]) : null
|
||||||
|
const count = v?.length || 0
|
||||||
|
|
||||||
|
const handleRemove = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRemove()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"bg-ui-bg-field transition-fg shadow-borders-base text-ui-fg-subtle flex cursor-pointer select-none items-center overflow-hidden rounded-md",
|
||||||
|
"hover:bg-ui-bg-field-hover",
|
||||||
|
"data-[state=open]:bg-ui-bg-field-hover"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"flex items-center justify-center whitespace-nowrap px-2 py-1",
|
||||||
|
{
|
||||||
|
"border-r": count > 0,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-center overflow-hidden">
|
||||||
|
{count > 0 && (
|
||||||
|
<div className="border-r p-1 px-2">
|
||||||
|
<Text
|
||||||
|
size="small"
|
||||||
|
weight="plus"
|
||||||
|
leading="compact"
|
||||||
|
className="text-ui-fg-muted"
|
||||||
|
>
|
||||||
|
{t("general.is")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{count > 0 && (
|
||||||
|
<div className="flex-1 overflow-hidden border-r p-1 px-2">
|
||||||
|
<Text
|
||||||
|
size="small"
|
||||||
|
leading="compact"
|
||||||
|
weight="plus"
|
||||||
|
className="truncate text-nowrap"
|
||||||
|
>
|
||||||
|
{v?.join(", ")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{v && v.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={handleRemove}
|
||||||
|
className={clx(
|
||||||
|
"text-ui-fg-muted transition-fg flex items-center justify-center p-1",
|
||||||
|
"hover:bg-ui-bg-subtle-hover",
|
||||||
|
"active:bg-ui-bg-subtle-pressed active:text-ui-fg-base"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<XMarkMini />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popover.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface IFilter {
|
||||||
|
filter: {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
openOnMount?: boolean
|
||||||
|
prefix?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { ArrowUpDown } from "@medusajs/icons"
|
||||||
|
import { DropdownMenu, IconButton } from "@medusajs/ui"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
|
type DataTableOrderByProps<TData> = {
|
||||||
|
keys: (keyof TData)[]
|
||||||
|
prefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SortDirection {
|
||||||
|
ASC = "asc",
|
||||||
|
DESC = "desc",
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortState = {
|
||||||
|
key?: string
|
||||||
|
dir: SortDirection
|
||||||
|
}
|
||||||
|
|
||||||
|
const initState = (params: URLSearchParams, prefix?: string): SortState => {
|
||||||
|
const param = prefix ? `${prefix}_order` : "order"
|
||||||
|
const sortParam = params.get(param)
|
||||||
|
|
||||||
|
if (!sortParam) {
|
||||||
|
return {
|
||||||
|
dir: SortDirection.ASC,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = sortParam.startsWith("-") ? SortDirection.DESC : SortDirection.ASC
|
||||||
|
const key = sortParam.replace("-", "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatKey = (key: string) => {
|
||||||
|
const words = key.split("_")
|
||||||
|
const formattedWords = words.map((word, index) => {
|
||||||
|
if (index === 0) {
|
||||||
|
return word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
|
} else {
|
||||||
|
return word
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return formattedWords.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTableOrderBy = <TData,>({
|
||||||
|
keys,
|
||||||
|
prefix,
|
||||||
|
}: DataTableOrderByProps<TData>) => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const [state, setState] = useState<{
|
||||||
|
key?: string
|
||||||
|
dir: SortDirection
|
||||||
|
}>(initState(searchParams, prefix))
|
||||||
|
const param = prefix ? `${prefix}_order` : "order"
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const handleDirChange = (dir: string) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
dir: dir as SortDirection,
|
||||||
|
}))
|
||||||
|
updateOrderParam({
|
||||||
|
key: state.key,
|
||||||
|
dir: dir as SortDirection,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyChange = (value: string) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
key: value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
updateOrderParam({
|
||||||
|
key: value,
|
||||||
|
dir: state.dir,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateOrderParam = (state: SortState) => {
|
||||||
|
if (!state.key) {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
prev.delete(param)
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderParam =
|
||||||
|
state.dir === SortDirection.ASC ? state.key : `-${state.key}`
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
prev.set(param, orderParam)
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<IconButton size="small">
|
||||||
|
<ArrowUpDown />
|
||||||
|
</IconButton>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content className="z-[1]" align="end">
|
||||||
|
<DropdownMenu.RadioGroup
|
||||||
|
value={state.key}
|
||||||
|
onValueChange={handleKeyChange}
|
||||||
|
>
|
||||||
|
{keys.map((key) => {
|
||||||
|
const stringKey = String(key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.RadioItem
|
||||||
|
key={stringKey}
|
||||||
|
value={stringKey}
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
{formatKey(stringKey)}
|
||||||
|
</DropdownMenu.RadioItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenu.RadioGroup>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.RadioGroup
|
||||||
|
value={state.dir}
|
||||||
|
onValueChange={handleDirChange}
|
||||||
|
>
|
||||||
|
<DropdownMenu.RadioItem
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
value="asc"
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
{t("general.ascending")}
|
||||||
|
<DropdownMenu.Label>1 - 30</DropdownMenu.Label>
|
||||||
|
</DropdownMenu.RadioItem>
|
||||||
|
<DropdownMenu.RadioItem
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
value="desc"
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
{t("general.descending")}
|
||||||
|
<DropdownMenu.Label>30 - 1</DropdownMenu.Label>
|
||||||
|
</DropdownMenu.RadioItem>
|
||||||
|
</DropdownMenu.RadioGroup>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./data-table-order-by"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Filter } from ".."
|
||||||
|
import { DataTableFilter } from "../data-table-filter"
|
||||||
|
import { DataTableOrderBy } from "../data-table-order-by"
|
||||||
|
import { DataTableSearch } from "../data-table-search"
|
||||||
|
|
||||||
|
export interface DataTableQueryProps {
|
||||||
|
search?: boolean
|
||||||
|
orderBy?: (string | number)[]
|
||||||
|
filters?: Filter[]
|
||||||
|
prefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTableQuery = ({
|
||||||
|
search,
|
||||||
|
orderBy,
|
||||||
|
filters,
|
||||||
|
prefix,
|
||||||
|
}: DataTableQueryProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-x-4 px-6 py-4">
|
||||||
|
<div className="w-full max-w-[60%]">
|
||||||
|
{filters && filters.length > 0 && (
|
||||||
|
<DataTableFilter filters={filters} prefix={prefix} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-x-2">
|
||||||
|
{search && <DataTableSearch prefix={prefix} />}
|
||||||
|
{orderBy && <DataTableOrderBy keys={orderBy} prefix={prefix} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./data-table-query"
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { CommandBar, Table, clx } from "@medusajs/ui"
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
Table as ReactTable,
|
||||||
|
Row,
|
||||||
|
flexRender,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { ComponentPropsWithoutRef, Fragment, UIEvent, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { NoResults } from "../../../common/empty-table-content"
|
||||||
|
|
||||||
|
type BulkCommand = {
|
||||||
|
label: string
|
||||||
|
shortcut: string
|
||||||
|
action: (selection: Record<string, boolean>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableRootProps<TData, TValue> {
|
||||||
|
/**
|
||||||
|
* The table instance to render
|
||||||
|
*/
|
||||||
|
table: ReactTable<TData>
|
||||||
|
/**
|
||||||
|
* The columns to render
|
||||||
|
*/
|
||||||
|
columns: ColumnDef<TData, TValue>[]
|
||||||
|
/**
|
||||||
|
* Function to generate a link to navigate to when clicking on a row
|
||||||
|
*/
|
||||||
|
navigateTo?: (row: Row<TData>) => string
|
||||||
|
/**
|
||||||
|
* Bulk actions to render
|
||||||
|
*/
|
||||||
|
commands?: BulkCommand[]
|
||||||
|
/**
|
||||||
|
* The total number of items in the table
|
||||||
|
*/
|
||||||
|
count?: number
|
||||||
|
/**
|
||||||
|
* Whether to display pagination controls
|
||||||
|
*/
|
||||||
|
pagination?: boolean
|
||||||
|
/**
|
||||||
|
* Whether the table is empty due to no results from the active query
|
||||||
|
*/
|
||||||
|
noResults?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO
|
||||||
|
*
|
||||||
|
* Add a sticky header to the table that shows the column name when scrolling through the table vertically.
|
||||||
|
*
|
||||||
|
* This is a bit tricky as we can't support horizontal scrolling and sticky headers at the same time, natively
|
||||||
|
* with CSS. We need to implement a custom solution for this. One solution is to render a duplicate table header
|
||||||
|
* using a DIV that, but it will require rerendeing the duplicate header every time the window is resized, to keep
|
||||||
|
* the columns aligned.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table component for rendering a table with pagination, filtering and ordering.
|
||||||
|
*/
|
||||||
|
export const DataTableRoot = <TData, TValue>({
|
||||||
|
table,
|
||||||
|
columns,
|
||||||
|
pagination,
|
||||||
|
navigateTo,
|
||||||
|
commands,
|
||||||
|
count = 0,
|
||||||
|
noResults = false,
|
||||||
|
}: DataTableRootProps<TData, TValue>) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [showStickyBorder, setShowStickyBorder] = useState(false)
|
||||||
|
|
||||||
|
const hasSelect = columns.find((c) => c.id === "select")
|
||||||
|
const hasActions = columns.find((c) => c.id === "actions")
|
||||||
|
const hasCommandBar = commands && commands.length > 0
|
||||||
|
|
||||||
|
const rowSelection = table.getState().rowSelection
|
||||||
|
const { pageIndex, pageSize } = table.getState().pagination
|
||||||
|
|
||||||
|
const colCount = columns.length - (hasSelect ? 1 : 0) - (hasActions ? 1 : 0)
|
||||||
|
const colWidth = 100 / colCount
|
||||||
|
|
||||||
|
const handleHorizontalScroll = (e: UIEvent<HTMLDivElement>) => {
|
||||||
|
const scrollLeft = e.currentTarget.scrollLeft
|
||||||
|
|
||||||
|
if (scrollLeft > 0) {
|
||||||
|
setShowStickyBorder(true)
|
||||||
|
} else {
|
||||||
|
setShowStickyBorder(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div onScroll={handleHorizontalScroll} className="w-full overflow-x-auto">
|
||||||
|
{!noResults ? (
|
||||||
|
<Table className="w-full">
|
||||||
|
<Table.Header className="border-t-0">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => {
|
||||||
|
return (
|
||||||
|
<Table.Row
|
||||||
|
key={headerGroup.id}
|
||||||
|
className={clx({
|
||||||
|
"border-b-0 [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap":
|
||||||
|
hasActions,
|
||||||
|
"[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap":
|
||||||
|
hasSelect,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{headerGroup.headers.map((header, index) => {
|
||||||
|
const isActionHeader = header.id === "actions"
|
||||||
|
const isSelectHeader = header.id === "select"
|
||||||
|
const isSpecialHeader = isActionHeader || isSelectHeader
|
||||||
|
|
||||||
|
const firstHeader = headerGroup.headers.findIndex(
|
||||||
|
(h) => h.id !== "select"
|
||||||
|
)
|
||||||
|
const isFirstHeader =
|
||||||
|
firstHeader !== -1
|
||||||
|
? header.id === headerGroup.headers[firstHeader].id
|
||||||
|
: index === 0
|
||||||
|
|
||||||
|
const isStickyHeader = isSelectHeader || isFirstHeader
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.HeaderCell
|
||||||
|
data-table-header-id={header.id}
|
||||||
|
key={header.id}
|
||||||
|
style={{
|
||||||
|
width: !isSpecialHeader
|
||||||
|
? `${colWidth}%`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
className={clx({
|
||||||
|
"bg-ui-bg-base sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||||
|
isStickyHeader,
|
||||||
|
"after:bg-ui-border-base":
|
||||||
|
showStickyBorder && isStickyHeader,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</Table.HeaderCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Row>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body className="border-b-0">
|
||||||
|
{table.getRowModel().rows.map((row) => {
|
||||||
|
const to = navigateTo ? navigateTo(row) : undefined
|
||||||
|
return (
|
||||||
|
<Table.Row
|
||||||
|
key={row.id}
|
||||||
|
className={clx(
|
||||||
|
"transition-fg group/row [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
||||||
|
"[&:has(td_a:focus-visible)_td]:bg-ui-bg-base-pressed",
|
||||||
|
{
|
||||||
|
"cursor-pointer": !!to,
|
||||||
|
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||||
|
row.getIsSelected(),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
onClick={to ? () => navigate(to) : undefined}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell, index) => {
|
||||||
|
const visibleCells = row.getVisibleCells()
|
||||||
|
const isSelectCell = cell.id === "select"
|
||||||
|
|
||||||
|
const firstCell = visibleCells.findIndex(
|
||||||
|
(h) => h.id !== "select"
|
||||||
|
)
|
||||||
|
const isFirstCell =
|
||||||
|
firstCell !== -1
|
||||||
|
? cell.id === visibleCells[firstCell].id
|
||||||
|
: index === 0
|
||||||
|
|
||||||
|
const isStickyCell = isSelectCell || isFirstCell
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Cell
|
||||||
|
key={cell.id}
|
||||||
|
className={clx("has-[a]:cursor-pointer", {
|
||||||
|
"bg-ui-bg-base group-[:has(td_a:focus)]/row:bg-ui-bg-base-pressed group-hover/row:bg-ui-bg-base-hover transition-fg sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||||
|
isStickyCell,
|
||||||
|
"after:bg-ui-border-base":
|
||||||
|
showStickyBorder && isStickyCell,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Row>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="border-b">
|
||||||
|
<NoResults />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{pagination && (
|
||||||
|
<Pagination
|
||||||
|
canNextPage={table.getCanNextPage()}
|
||||||
|
canPreviousPage={table.getCanPreviousPage()}
|
||||||
|
nextPage={table.nextPage}
|
||||||
|
previousPage={table.previousPage}
|
||||||
|
count={count}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageCount={table.getPageCount()}
|
||||||
|
pageSize={pageSize}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasCommandBar && (
|
||||||
|
<CommandBar open={!!Object.keys(rowSelection).length}>
|
||||||
|
<CommandBar.Bar>
|
||||||
|
<CommandBar.Value>
|
||||||
|
{t("general.countSelected", {
|
||||||
|
count: Object.keys(rowSelection).length,
|
||||||
|
})}
|
||||||
|
</CommandBar.Value>
|
||||||
|
<CommandBar.Seperator />
|
||||||
|
{commands?.map((command, index) => {
|
||||||
|
return (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<CommandBar.Command
|
||||||
|
label={command.label}
|
||||||
|
shortcut={command.shortcut}
|
||||||
|
action={() => command.action(rowSelection)}
|
||||||
|
/>
|
||||||
|
{index < commands.length - 1 && <CommandBar.Seperator />}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CommandBar.Bar>
|
||||||
|
</CommandBar>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationProps = Omit<
|
||||||
|
ComponentPropsWithoutRef<typeof Table.Pagination>,
|
||||||
|
"translations"
|
||||||
|
>
|
||||||
|
|
||||||
|
const Pagination = (props: PaginationProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
of: t("general.of"),
|
||||||
|
results: t("general.results"),
|
||||||
|
pages: t("general.pages"),
|
||||||
|
prev: t("general.prev"),
|
||||||
|
next: t("general.next"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Table.Pagination {...props} translations={translations} />
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./data-table-root"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Input } from "@medusajs/ui"
|
||||||
|
import { ChangeEvent, useCallback, useEffect } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { debounce } from "lodash"
|
||||||
|
import { useSelectedParams } from "../hooks"
|
||||||
|
|
||||||
|
type DataTableSearchProps = {
|
||||||
|
placeholder?: string
|
||||||
|
prefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTableSearch = ({
|
||||||
|
placeholder,
|
||||||
|
prefix,
|
||||||
|
}: DataTableSearchProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const placeholderText = placeholder || t("general.search")
|
||||||
|
const selectedParams = useSelectedParams({
|
||||||
|
param: "q",
|
||||||
|
prefix,
|
||||||
|
multiple: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const query = selectedParams.get()
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const debouncedOnChange = useCallback(
|
||||||
|
debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
selectedParams.delete()
|
||||||
|
} else {
|
||||||
|
selectedParams.add(value)
|
||||||
|
}
|
||||||
|
}, 500),
|
||||||
|
[selectedParams]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
debouncedOnChange.cancel()
|
||||||
|
}
|
||||||
|
}, [debouncedOnChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
size="small"
|
||||||
|
defaultValue={query?.[0] || undefined}
|
||||||
|
onChange={debouncedOnChange}
|
||||||
|
placeholder={placeholderText}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./data-table-search"
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Table, clx } from "@medusajs/ui"
|
||||||
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { Skeleton } from "../../../common/skeleton"
|
||||||
|
|
||||||
|
type DataTableSkeletonProps = {
|
||||||
|
columns: ColumnDef<any, any>[]
|
||||||
|
rowCount: number
|
||||||
|
searchable: boolean
|
||||||
|
orderBy: boolean
|
||||||
|
filterable: boolean
|
||||||
|
pagination: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTableSkeleton = ({
|
||||||
|
columns,
|
||||||
|
rowCount,
|
||||||
|
filterable,
|
||||||
|
searchable,
|
||||||
|
orderBy,
|
||||||
|
pagination,
|
||||||
|
}: DataTableSkeletonProps) => {
|
||||||
|
const rows = Array.from({ length: rowCount }, (_, i) => i)
|
||||||
|
|
||||||
|
const hasToolbar = filterable || searchable || orderBy
|
||||||
|
const hasSearchOrOrder = searchable || orderBy
|
||||||
|
|
||||||
|
const hasSelect = columns.find((c) => c.id === "select")
|
||||||
|
const hasActions = columns.find((c) => c.id === "actions")
|
||||||
|
const colCount = columns.length - (hasSelect ? 1 : 0) - (hasActions ? 1 : 0)
|
||||||
|
const colWidth = 100 / colCount
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{hasToolbar && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
{filterable && <Skeleton className="h-7 w-full max-w-[160px]" />}
|
||||||
|
{hasSearchOrOrder && (
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
{searchable && <Skeleton className="h-7 w-[160px]" />}
|
||||||
|
{orderBy && <Skeleton className="h-7 w-7" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Table>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row
|
||||||
|
className={clx({
|
||||||
|
"border-b-0 [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap":
|
||||||
|
hasActions,
|
||||||
|
"[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap":
|
||||||
|
hasSelect,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{columns.map((col, i) => {
|
||||||
|
const isSelectHeader = col.id === "select"
|
||||||
|
const isActionsHeader = col.id === "actions"
|
||||||
|
|
||||||
|
const isSpecialHeader = isSelectHeader || isActionsHeader
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.HeaderCell
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: !isSpecialHeader ? `${colWidth}%` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isActionsHeader ? null : (
|
||||||
|
<Skeleton
|
||||||
|
className={clx("h-7", {
|
||||||
|
"w-7": isSelectHeader,
|
||||||
|
"w-full": !isSelectHeader,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Table.HeaderCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{rows.map((_, j) => (
|
||||||
|
<Table.Row key={j}>
|
||||||
|
{columns.map((col, k) => {
|
||||||
|
const isSpecialCell =
|
||||||
|
col.id === "select" || col.id === "actions"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Cell key={k}>
|
||||||
|
<Skeleton
|
||||||
|
className={clx("h-7", {
|
||||||
|
"w-7": isSpecialCell,
|
||||||
|
"w-full": !isSpecialCell,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Table.Cell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
{pagination && (
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./data-table-skeleton"
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { memo } from "react"
|
||||||
|
import { NoRecords } from "../../common/empty-table-content"
|
||||||
|
import { DataTableQuery, DataTableQueryProps } from "./data-table-query"
|
||||||
|
import { DataTableRoot, DataTableRootProps } from "./data-table-root"
|
||||||
|
import { DataTableSkeleton } from "./data-table-skeleton"
|
||||||
|
|
||||||
|
interface DataTableProps<TData, TValue>
|
||||||
|
extends DataTableRootProps<TData, TValue>,
|
||||||
|
DataTableQueryProps {
|
||||||
|
isLoading?: boolean
|
||||||
|
rowCount: number
|
||||||
|
queryObject?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const MemoizedDataTableRoot = memo(DataTableRoot) as typeof DataTableRoot
|
||||||
|
const MemoizedDataTableQuery = memo(DataTableQuery)
|
||||||
|
|
||||||
|
export const DataTable = <TData, TValue>({
|
||||||
|
table,
|
||||||
|
columns,
|
||||||
|
pagination,
|
||||||
|
navigateTo,
|
||||||
|
commands,
|
||||||
|
count = 0,
|
||||||
|
search = false,
|
||||||
|
orderBy,
|
||||||
|
filters,
|
||||||
|
prefix,
|
||||||
|
queryObject = {},
|
||||||
|
rowCount,
|
||||||
|
isLoading = false,
|
||||||
|
}: DataTableProps<TData, TValue>) => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<DataTableSkeleton
|
||||||
|
columns={columns}
|
||||||
|
rowCount={rowCount}
|
||||||
|
searchable={search}
|
||||||
|
filterable={!!filters?.length}
|
||||||
|
orderBy={!!orderBy?.length}
|
||||||
|
pagination={!!pagination}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const noQuery =
|
||||||
|
Object.values(queryObject).filter((v) => Boolean(v)).length === 0
|
||||||
|
const noResults = !isLoading && count === 0 && !noQuery
|
||||||
|
const noRecords = !isLoading && count === 0 && noQuery
|
||||||
|
|
||||||
|
if (noRecords) {
|
||||||
|
return <NoRecords />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="divide-y">
|
||||||
|
<MemoizedDataTableQuery
|
||||||
|
search={search}
|
||||||
|
orderBy={orderBy}
|
||||||
|
filters={filters}
|
||||||
|
prefix={prefix}
|
||||||
|
/>
|
||||||
|
<MemoizedDataTableRoot
|
||||||
|
table={table}
|
||||||
|
count={count}
|
||||||
|
columns={columns}
|
||||||
|
pagination
|
||||||
|
navigateTo={navigateTo}
|
||||||
|
commands={commands}
|
||||||
|
noResults={noResults}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
|
export const useSelectedParams = ({
|
||||||
|
param,
|
||||||
|
prefix,
|
||||||
|
multiple = false,
|
||||||
|
}: {
|
||||||
|
param: string
|
||||||
|
prefix?: string
|
||||||
|
multiple?: boolean
|
||||||
|
}) => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const identifier = prefix ? `${prefix}_${param}` : param
|
||||||
|
const offsetKey = prefix ? `${prefix}_offset` : "offset"
|
||||||
|
|
||||||
|
const add = (value: string) => {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const newValue = new URLSearchParams(prev)
|
||||||
|
|
||||||
|
const updateMultipleValues = () => {
|
||||||
|
const existingValues = newValue.get(identifier)?.split(",") || []
|
||||||
|
|
||||||
|
if (!existingValues.includes(value)) {
|
||||||
|
existingValues.push(value)
|
||||||
|
newValue.set(identifier, existingValues.join(","))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSingleValue = () => {
|
||||||
|
newValue.set(identifier, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
multiple ? updateMultipleValues() : updateSingleValue()
|
||||||
|
newValue.delete(offsetKey)
|
||||||
|
|
||||||
|
return newValue
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteParam = (value?: string) => {
|
||||||
|
const deleteMultipleValues = (prev: URLSearchParams) => {
|
||||||
|
const existingValues = prev.get(identifier)?.split(",") || []
|
||||||
|
const index = existingValues.indexOf(value || "")
|
||||||
|
if (index > -1) {
|
||||||
|
existingValues.splice(index, 1)
|
||||||
|
prev.set(identifier, existingValues.join(","))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSingleValue = (prev: URLSearchParams) => {
|
||||||
|
prev.delete(identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
if (value) {
|
||||||
|
multiple ? deleteMultipleValues(prev) : deleteSingleValue(prev)
|
||||||
|
if (!prev.get(identifier)) {
|
||||||
|
prev.delete(identifier)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
prev.delete(identifier)
|
||||||
|
}
|
||||||
|
prev.delete(offsetKey)
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = () => {
|
||||||
|
return searchParams.get(identifier)?.split(",").filter(Boolean) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
return { add, delete: deleteParam, get }
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./data-table"
|
||||||
|
export type { Filter } from "./data-table-filter"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Tooltip } from "@medusajs/ui"
|
||||||
|
import format from "date-fns/format"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
type DateCellProps = {
|
||||||
|
date: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateCell = ({ date }: DateCellProps) => {
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full w-full items-center overflow-hidden">
|
||||||
|
<Tooltip
|
||||||
|
className="z-10"
|
||||||
|
content={
|
||||||
|
<span className="text-pretty">{`${format(
|
||||||
|
value,
|
||||||
|
timestampFormat
|
||||||
|
)}`}</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">{format(value, "dd MMM yyyy")}</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateHeader = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">{t("fields.date")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./date-cell"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./status-cell"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { clx } from "@medusajs/ui"
|
||||||
|
import { PropsWithChildren } from "react"
|
||||||
|
|
||||||
|
type StatusCellProps = PropsWithChildren<{
|
||||||
|
color?: "green" | "red" | "blue" | "orange" | "grey" | "purple"
|
||||||
|
}>
|
||||||
|
|
||||||
|
export const StatusCell = ({ color, children }: StatusCellProps) => {
|
||||||
|
return (
|
||||||
|
<div className="txt-compact-small text-ui-fg-subtle flex h-full w-full items-center gap-x-0.5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
className="flex size-5 items-center justify-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"h-2 w-2 rounded-sm shadow-[0px_0px_0px_1px_rgba(0,0,0,0.12)_inset]",
|
||||||
|
{
|
||||||
|
"bg-ui-tag-neutral-icon": color === "grey",
|
||||||
|
"bg-ui-tag-green-icon": color === "green",
|
||||||
|
"bg-ui-tag-red-icon": color === "red",
|
||||||
|
"bg-ui-tag-blue-icon": color === "blue",
|
||||||
|
"bg-ui-tag-orange-icon": color === "orange",
|
||||||
|
"bg-ui-tag-purple-icon": color === "purple",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="truncate">{children}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Customer } from "@medusajs/medusa"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
export const CustomerCell = ({ customer }: { customer: Customer | null }) => {
|
||||||
|
if (!customer) {
|
||||||
|
return <span className="text-ui-fg-muted">-</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const { first_name, last_name, email } = customer
|
||||||
|
const name = [first_name, last_name].filter(Boolean).join(" ")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<div>
|
||||||
|
<span className="truncate">{name || email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomerHeader = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">{t("fields.customer")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./customer-cell"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
export const DisplayIdCell = ({ displayId }: { displayId: number }) => {
|
||||||
|
return (
|
||||||
|
<div className="text-ui-fg-subtle txt-compact-small flex h-full w-full items-center overflow-hidden">
|
||||||
|
<span className="truncate">#{displayId}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DisplayIdHeader = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">{t("fields.order")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./display-id-cell"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { FulfillmentStatus } from "@medusajs/medusa"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { StatusCell } from "../../common/status-cell"
|
||||||
|
|
||||||
|
type FulfillmentStatusCellProps = {
|
||||||
|
status: FulfillmentStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FulfillmentStatusCell = ({
|
||||||
|
status,
|
||||||
|
}: FulfillmentStatusCellProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const [label, color] = {
|
||||||
|
not_fulfilled: [t("orders.fulfillmentStatus.notFulfilled"), "red"],
|
||||||
|
partially_fulfilled: [
|
||||||
|
t("orders.fulfillmentStatus.partiallyFulfilled"),
|
||||||
|
"orange",
|
||||||
|
],
|
||||||
|
fulfilled: [t("orders.fulfillmentStatus.fulfilled"), "green"],
|
||||||
|
partially_shipped: [
|
||||||
|
t("orders.fulfillmentStatus.partiallyShipped"),
|
||||||
|
"orange",
|
||||||
|
],
|
||||||
|
shipped: [t("orders.fulfillmentStatus.shipped"), "green"],
|
||||||
|
partially_returned: [
|
||||||
|
t("orders.fulfillmentStatus.partiallyReturned"),
|
||||||
|
"orange",
|
||||||
|
],
|
||||||
|
returned: [t("orders.fulfillmentStatus.returned"), "green"],
|
||||||
|
canceled: [t("orders.fulfillmentStatus.canceled"), "red"],
|
||||||
|
requires_action: [t("orders.fulfillmentStatus.requresAction"), "orange"],
|
||||||
|
}[status] as [string, "red" | "orange" | "green"]
|
||||||
|
|
||||||
|
return <StatusCell color={color}>{label}</StatusCell>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FulfillmentStatusHeader = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">{t("fields.fulfillment")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./fulfillment-status-cell"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./items-cell"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { LineItem } from "@medusajs/medusa"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
export const ItemsCell = ({ items }: { items: LineItem[] }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center overflow-hidden">
|
||||||
|
<span className="truncate">
|
||||||
|
{t("general.items", {
|
||||||
|
count: items.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemsHeader = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">{t("fields.items")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./payment-status-cell"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { PaymentStatus } from "@medusajs/medusa"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { StatusCell } from "../../common/status-cell"
|
||||||
|
|
||||||
|
type PaymentStatusCellProps = {
|
||||||
|
status: PaymentStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaymentStatusCell = ({ status }: PaymentStatusCellProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const [label, color] = {
|
||||||
|
not_paid: [t("orders.paymentStatus.notPaid"), "red"],
|
||||||
|
awaiting: [t("orders.paymentStatus.awaiting"), "orange"],
|
||||||
|
captured: [t("orders.paymentStatus.captured"), "green"],
|
||||||
|
refunded: [t("orders.paymentStatus.refunded"), "green"],
|
||||||
|
partially_refunded: [t("orders.paymentStatus.partiallyRefunded"), "orange"],
|
||||||
|
canceled: [t("orders.paymentStatus.canceled"), "red"],
|
||||||
|
requires_action: [t("orders.paymentStatus.requresAction"), "orange"],
|
||||||
|
}[status] as [string, "red" | "orange" | "green"]
|
||||||
|
|
||||||
|
return <StatusCell color={color}>{label}</StatusCell>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaymentStatusHeader = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">{t("fields.payment")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./sales-channel-cell"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { SalesChannel } from "@medusajs/medusa"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
export const SalesChannelCell = ({
|
||||||
|
channel,
|
||||||
|
}: {
|
||||||
|
channel: SalesChannel | null
|
||||||
|
}) => {
|
||||||
|
if (!channel) {
|
||||||
|
return <span className="text-ui-fg-muted">-</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name } = channel
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center overflow-hidden">
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SalesChannelHeader = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">{t("fields.salesChannel")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./total-cell"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { getPresentationalAmount } from "../../../../../lib/money-amount-helpers"
|
||||||
|
|
||||||
|
type TotalCellProps = {
|
||||||
|
currencyCode: string
|
||||||
|
total: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TotalCell = ({ currencyCode, total }: TotalCellProps) => {
|
||||||
|
if (!total) {
|
||||||
|
return <span className="text-ui-fg-muted">-</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = new Intl.NumberFormat(undefined, {
|
||||||
|
style: "currency",
|
||||||
|
currency: currencyCode,
|
||||||
|
currencyDisplay: "narrowSymbol",
|
||||||
|
}).format(0)
|
||||||
|
|
||||||
|
const symbol = formatted.replace(/\d/g, "").replace(/[.,]/g, "").trim()
|
||||||
|
|
||||||
|
const presentationAmount = getPresentationalAmount(total, currencyCode)
|
||||||
|
const formattedTotal = new Intl.NumberFormat(undefined, {
|
||||||
|
style: "decimal",
|
||||||
|
}).format(presentationAmount)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-end overflow-hidden">
|
||||||
|
<span className="truncate">
|
||||||
|
{symbol} {formattedTotal} {currencyCode.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TotalHeader = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-end">
|
||||||
|
<span className="truncate">{t("fields.total")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Order } from "@medusajs/medusa"
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
ColumnDefBase,
|
||||||
|
createColumnHelper,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
DateCell,
|
||||||
|
DateHeader,
|
||||||
|
} from "../../../components/table/table-cells/common/date-cell"
|
||||||
|
import {
|
||||||
|
DisplayIdCell,
|
||||||
|
DisplayIdHeader,
|
||||||
|
} from "../../../components/table/table-cells/order/display-id-cell"
|
||||||
|
import {
|
||||||
|
FulfillmentStatusCell,
|
||||||
|
FulfillmentStatusHeader,
|
||||||
|
} from "../../../components/table/table-cells/order/fulfillment-status-cell"
|
||||||
|
import {
|
||||||
|
ItemsCell,
|
||||||
|
ItemsHeader,
|
||||||
|
} from "../../../components/table/table-cells/order/items-cell"
|
||||||
|
import {
|
||||||
|
PaymentStatusCell,
|
||||||
|
PaymentStatusHeader,
|
||||||
|
} from "../../../components/table/table-cells/order/payment-status-cell"
|
||||||
|
import {
|
||||||
|
SalesChannelCell,
|
||||||
|
SalesChannelHeader,
|
||||||
|
} from "../../../components/table/table-cells/order/sales-channel-cell"
|
||||||
|
import {
|
||||||
|
TotalCell,
|
||||||
|
TotalHeader,
|
||||||
|
} from "../../../components/table/table-cells/order/total-cell"
|
||||||
|
|
||||||
|
// We have to use any here, as the type of Order is so complex that it lags the TS server
|
||||||
|
const columnHelper = createColumnHelper<Order>()
|
||||||
|
|
||||||
|
type UseOrderTableColumnsProps = {
|
||||||
|
exclude?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOrderTableColumns = (props: UseOrderTableColumnsProps) => {
|
||||||
|
const { exclude = [] } = props ?? {}
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
columnHelper.accessor("display_id", {
|
||||||
|
header: () => <DisplayIdHeader />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const id = getValue()
|
||||||
|
|
||||||
|
return <DisplayIdCell displayId={id} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("created_at", {
|
||||||
|
header: () => <DateHeader />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const date = new Date(getValue())
|
||||||
|
|
||||||
|
return <DateCell date={date} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("sales_channel", {
|
||||||
|
header: () => <SalesChannelHeader />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const channel = getValue()
|
||||||
|
|
||||||
|
return <SalesChannelCell channel={channel} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("payment_status", {
|
||||||
|
header: () => <PaymentStatusHeader />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const status = getValue()
|
||||||
|
|
||||||
|
return <PaymentStatusCell status={status} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("fulfillment_status", {
|
||||||
|
header: () => <FulfillmentStatusHeader />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const status = getValue()
|
||||||
|
|
||||||
|
return <FulfillmentStatusCell status={status} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("items", {
|
||||||
|
header: () => <ItemsHeader />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const items = getValue()
|
||||||
|
|
||||||
|
return <ItemsCell items={items} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("total", {
|
||||||
|
header: () => <TotalHeader />,
|
||||||
|
cell: ({ getValue, row }) => {
|
||||||
|
const total = getValue()
|
||||||
|
const currencyCode = row.original.currency_code
|
||||||
|
|
||||||
|
return <TotalCell currencyCode={currencyCode} total={total} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const isAccessorColumnDef = (
|
||||||
|
c: any
|
||||||
|
): c is ColumnDef<Order> & { accessorKey: string } => {
|
||||||
|
return c.accessorKey !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDisplayColumnDef = (
|
||||||
|
c: any
|
||||||
|
): c is ColumnDef<Order> & { id: string } => {
|
||||||
|
return c.id !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldExclude = <TDef extends ColumnDefBase<Order, any>>(c: TDef) => {
|
||||||
|
if (isAccessorColumnDef(c)) {
|
||||||
|
return exclude.includes(c.accessorKey)
|
||||||
|
} else if (isDisplayColumnDef(c)) {
|
||||||
|
return exclude.includes(c.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return columns.filter((c) => !shouldExclude(c)) as ColumnDef<Order>[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useAdminRegions, useAdminSalesChannels } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import type { Filter } from "../../../components/table/data-table"
|
||||||
|
|
||||||
|
export const useOrderTableFilters = (): Filter[] => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { regions } = useAdminRegions({
|
||||||
|
limit: 1000,
|
||||||
|
fields: "id,name",
|
||||||
|
expand: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const { sales_channels } = useAdminSalesChannels({
|
||||||
|
limit: 1000,
|
||||||
|
fields: "id,name",
|
||||||
|
expand: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
let filters: Filter[] = []
|
||||||
|
|
||||||
|
if (regions) {
|
||||||
|
const regionFilter: Filter = {
|
||||||
|
key: "region_id",
|
||||||
|
label: t("fields.region"),
|
||||||
|
type: "select",
|
||||||
|
options: regions.map((r) => ({
|
||||||
|
label: r.name,
|
||||||
|
value: r.id,
|
||||||
|
})),
|
||||||
|
multiple: true,
|
||||||
|
searchable: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
filters = [...filters, regionFilter]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sales_channels) {
|
||||||
|
const salesChannelFilter: Filter = {
|
||||||
|
key: "sales_channel_id",
|
||||||
|
label: t("fields.salesChannel"),
|
||||||
|
type: "select",
|
||||||
|
multiple: true,
|
||||||
|
searchable: true,
|
||||||
|
options: sales_channels.map((s) => ({
|
||||||
|
label: s.name,
|
||||||
|
value: s.id,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
filters = [...filters, salesChannelFilter]
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentStatusFilter: Filter = {
|
||||||
|
key: "payment_status",
|
||||||
|
label: t("orders.paymentStatusLabel"),
|
||||||
|
type: "select",
|
||||||
|
multiple: true,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: t("orders.paymentStatus.notPaid"),
|
||||||
|
value: "not_paid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("orders.paymentStatus.awaiting"),
|
||||||
|
value: "awaiting",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("orders.paymentStatus.captured"),
|
||||||
|
value: "captured",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("orders.paymentStatus.refunded"),
|
||||||
|
value: "refunded",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("orders.paymentStatus.partiallyRefunded"),
|
||||||
|
value: "partially_refunded",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("orders.paymentStatus.canceled"),
|
||||||
|
value: "canceled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("orders.paymentStatus.requresAction"),
|
||||||
|
value: "requires_action",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const fulfillmentStatusFilter: Filter = {
|
||||||
|
key: "fulfillment_status",
|
||||||
|
label: t("orders.fulfillmentStatusLabel"),
|
||||||
|
type: "select",
|
||||||
|
multiple: true,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: t("orders.fulfillmentStatus.notFulfilled"),
|
||||||
|
value: "not_fulfilled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("orders.fulfillmentStatus.fulfilled"),
|
||||||
|
value: "fulfilled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("orders.fulfillmentStatus.partiallyFulfilled"),
|
||||||
|
value: "partially_fulfilled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("orders.fulfillmentStatus.returned"),
|
||||||
|
value: "returned",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("orders.fulfillmentStatus.partiallyReturned"),
|
||||||
|
value: "partially_returned",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("orders.fulfillmentStatus.shipped"),
|
||||||
|
value: "shipped",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("orders.fulfillmentStatus.partiallyShipped"),
|
||||||
|
value: "partially_shipped",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("orders.fulfillmentStatus.canceled"),
|
||||||
|
value: "canceled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("orders.fulfillmentStatus.requresAction"),
|
||||||
|
value: "requires_action",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFilters: Filter[] = [
|
||||||
|
{ label: "Created At", key: "created_at" },
|
||||||
|
{ label: "Updated At", key: "updated_at" },
|
||||||
|
].map((f) => ({
|
||||||
|
key: f.key,
|
||||||
|
label: f.label,
|
||||||
|
type: "date",
|
||||||
|
}))
|
||||||
|
|
||||||
|
filters = [
|
||||||
|
...filters,
|
||||||
|
paymentStatusFilter,
|
||||||
|
fulfillmentStatusFilter,
|
||||||
|
...dateFilters,
|
||||||
|
]
|
||||||
|
|
||||||
|
return filters
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { AdminGetOrdersParams } from "@medusajs/medusa"
|
||||||
|
import { useQueryParams } from "../../use-query-params"
|
||||||
|
|
||||||
|
type UseOrderTableQueryProps = {
|
||||||
|
prefix?: string
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: Enable `order` query param when staging is updated
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const useOrderTableQuery = ({
|
||||||
|
prefix,
|
||||||
|
pageSize = 50,
|
||||||
|
}: UseOrderTableQueryProps) => {
|
||||||
|
const queryObject = useQueryParams(
|
||||||
|
[
|
||||||
|
"offset",
|
||||||
|
"q",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"region_id",
|
||||||
|
"sales_channel_id",
|
||||||
|
"payment_status",
|
||||||
|
"fulfillment_status",
|
||||||
|
],
|
||||||
|
prefix
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
offset,
|
||||||
|
sales_channel_id,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
fulfillment_status,
|
||||||
|
payment_status,
|
||||||
|
region_id,
|
||||||
|
q,
|
||||||
|
} = queryObject
|
||||||
|
|
||||||
|
const searchParams: AdminGetOrdersParams = {
|
||||||
|
limit: pageSize,
|
||||||
|
offset: offset ? Number(offset) : 0,
|
||||||
|
sales_channel_id: sales_channel_id?.split(","),
|
||||||
|
fulfillment_status: fulfillment_status?.split(","),
|
||||||
|
payment_status: payment_status?.split(","),
|
||||||
|
created_at: created_at ? JSON.parse(created_at) : undefined,
|
||||||
|
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
|
||||||
|
region_id: region_id?.split(","),
|
||||||
|
q,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchParams,
|
||||||
|
raw: queryObject,
|
||||||
|
}
|
||||||
|
}
|
||||||
112
packages/admin-next/dashboard/src/hooks/use-data-table.tsx
Normal file
112
packages/admin-next/dashboard/src/hooks/use-data-table.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
OnChangeFn,
|
||||||
|
PaginationState,
|
||||||
|
Row,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
|
type UseDataTableProps<TData, TValue> = {
|
||||||
|
data?: TData[]
|
||||||
|
columns: ColumnDef<TData, TValue>[]
|
||||||
|
count?: number
|
||||||
|
pageSize?: number
|
||||||
|
enableRowSelection?: boolean | ((row: Row<TData>) => boolean)
|
||||||
|
enablePagination?: boolean
|
||||||
|
getRowId?: (original: TData, index: number) => string
|
||||||
|
prefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDataTable = <TData, TValue>({
|
||||||
|
data = [],
|
||||||
|
columns,
|
||||||
|
count = 0,
|
||||||
|
pageSize: _pageSize = 50,
|
||||||
|
enablePagination = true,
|
||||||
|
enableRowSelection = false,
|
||||||
|
getRowId,
|
||||||
|
prefix,
|
||||||
|
}: UseDataTableProps<TData, TValue>) => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const offsetKey = `${prefix ? `${prefix}_` : ""}offset`
|
||||||
|
const offset = searchParams.get(offsetKey)
|
||||||
|
|
||||||
|
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: offset ? Math.ceil(Number(offset) / _pageSize) : 0,
|
||||||
|
pageSize: _pageSize,
|
||||||
|
})
|
||||||
|
const pagination = useMemo(
|
||||||
|
() => ({
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[pageIndex, pageSize]
|
||||||
|
)
|
||||||
|
const [rowSelection, setRowSelection] = useState({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enablePagination) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = offset ? Math.ceil(Number(offset) / _pageSize) : 0
|
||||||
|
|
||||||
|
if (index === pageIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPagination((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageIndex: index,
|
||||||
|
}))
|
||||||
|
}, [offset, enablePagination, _pageSize, pageIndex])
|
||||||
|
|
||||||
|
const onPaginationChange = (
|
||||||
|
updater: (old: PaginationState) => PaginationState
|
||||||
|
) => {
|
||||||
|
const state = updater(pagination)
|
||||||
|
const { pageIndex, pageSize } = state
|
||||||
|
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
if (!pageIndex) {
|
||||||
|
prev.delete(offsetKey)
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSearch = new URLSearchParams(prev)
|
||||||
|
newSearch.set(offsetKey, String(pageIndex * pageSize))
|
||||||
|
|
||||||
|
return newSearch
|
||||||
|
})
|
||||||
|
|
||||||
|
setPagination(state)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
rowSelection,
|
||||||
|
pagination: enablePagination ? pagination : undefined,
|
||||||
|
},
|
||||||
|
pageCount: Math.ceil((count ?? 0) / pageSize),
|
||||||
|
enableRowSelection,
|
||||||
|
getRowId,
|
||||||
|
onRowSelectionChange: enableRowSelection ? setRowSelection : undefined,
|
||||||
|
onPaginationChange: enablePagination
|
||||||
|
? (onPaginationChange as OnChangeFn<PaginationState>)
|
||||||
|
: undefined,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: enablePagination
|
||||||
|
? getPaginationRowModel()
|
||||||
|
: undefined,
|
||||||
|
manualPagination: enablePagination ? true : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { table }
|
||||||
|
}
|
||||||
@@ -1,15 +1,23 @@
|
|||||||
import { useSearchParams } from "react-router-dom"
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
|
type QueryParams<T extends string> = {
|
||||||
|
[key in T]: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function useQueryParams<T extends string>(
|
export function useQueryParams<T extends string>(
|
||||||
keys: T[]
|
keys: T[],
|
||||||
): Record<T, string | undefined> {
|
prefix?: string
|
||||||
|
): QueryParams<T> {
|
||||||
const [params] = useSearchParams()
|
const [params] = useSearchParams()
|
||||||
|
|
||||||
// Use a type assertion to initialize the result
|
// Use a type assertion to initialize the result
|
||||||
const result = {} as Record<T, string | undefined>
|
const result = {} as QueryParams<T>
|
||||||
|
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
result[key] = params.get(key) || undefined
|
const prefixedKey = prefix ? `${prefix}_${key}` : key
|
||||||
|
const value = params.get(prefixedKey) || undefined
|
||||||
|
|
||||||
|
result[key] = value
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: true,
|
index: true,
|
||||||
lazy: () => import("../../routes/orders/list"),
|
lazy: () => import("../../routes/orders/order-list"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ":id",
|
path: ":id",
|
||||||
|
|||||||
@@ -1,99 +1,62 @@
|
|||||||
import { ReceiptPercent } from "@medusajs/icons"
|
import { Customer } from "@medusajs/medusa"
|
||||||
import { Customer, Order } from "@medusajs/medusa"
|
import { Button, Container, Heading } from "@medusajs/ui"
|
||||||
import { Button, Container, Heading, Table, clx } from "@medusajs/ui"
|
|
||||||
import {
|
|
||||||
PaginationState,
|
|
||||||
RowSelectionState,
|
|
||||||
createColumnHelper,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table"
|
|
||||||
import { useAdminOrders } from "medusa-react"
|
import { useAdminOrders } from "medusa-react"
|
||||||
import { useMemo, useState } from "react"
|
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { DataTable } from "../../../../../components/table/data-table"
|
||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
import { useOrderTableColumns } from "../../../../../hooks/table/columns/use-order-table-columns"
|
||||||
import { NoRecords } from "../../../../../components/common/empty-table-content"
|
import { useOrderTableFilters } from "../../../../../hooks/table/filters/use-order-table-filters"
|
||||||
import {
|
import { useOrderTableQuery } from "../../../../../hooks/table/query/use-order-table-query"
|
||||||
OrderDateCell,
|
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||||
OrderDisplayIdCell,
|
|
||||||
OrderFulfillmentStatusCell,
|
|
||||||
OrderPaymentStatusCell,
|
|
||||||
OrderTotalCell,
|
|
||||||
} from "../../../../../components/common/order-table-cells"
|
|
||||||
import { Query } from "../../../../../components/filtering/query"
|
|
||||||
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
|
|
||||||
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
|
||||||
|
|
||||||
type CustomerGeneralSectionProps = {
|
type CustomerGeneralSectionProps = {
|
||||||
customer: Customer
|
customer: Customer
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 10
|
||||||
|
const DEFAULT_RELATIONS = "customer,items,sales_channel"
|
||||||
|
const DEFAULT_FIELDS =
|
||||||
|
"id,status,display_id,created_at,email,fulfillment_status,payment_status,total,currency_code"
|
||||||
|
|
||||||
export const CustomerOrderSection = ({
|
export const CustomerOrderSection = ({
|
||||||
customer,
|
customer,
|
||||||
}: CustomerGeneralSectionProps) => {
|
}: CustomerGeneralSectionProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
const { searchParams, raw } = useOrderTableQuery({
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
})
|
})
|
||||||
|
|
||||||
const pagination = useMemo(
|
|
||||||
() => ({
|
|
||||||
pageIndex,
|
|
||||||
pageSize,
|
|
||||||
}),
|
|
||||||
[pageIndex, pageSize]
|
|
||||||
)
|
|
||||||
|
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
|
||||||
|
|
||||||
const params = useQueryParams(["q"])
|
|
||||||
const { orders, count, isLoading, isError, error } = useAdminOrders(
|
const { orders, count, isLoading, isError, error } = useAdminOrders(
|
||||||
{
|
{
|
||||||
customer_id: customer.id,
|
customer_id: customer.id,
|
||||||
limit: PAGE_SIZE,
|
expand: DEFAULT_RELATIONS,
|
||||||
offset: pageIndex * PAGE_SIZE,
|
fields: DEFAULT_FIELDS,
|
||||||
fields:
|
...searchParams,
|
||||||
"id,status,display_id,created_at,email,fulfillment_status,payment_status,total,currency_code",
|
|
||||||
...params,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns = useColumns()
|
const columns = useOrderTableColumns({
|
||||||
|
exclude: ["customer"],
|
||||||
|
})
|
||||||
|
const filters = useOrderTableFilters()
|
||||||
|
|
||||||
const table = useReactTable({
|
const { table } = useDataTable({
|
||||||
data: orders ?? [],
|
data: orders ?? [],
|
||||||
columns,
|
columns,
|
||||||
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
|
enablePagination: true,
|
||||||
state: {
|
count,
|
||||||
pagination,
|
pageSize: PAGE_SIZE,
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
onRowSelectionChange: setRowSelection,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
manualPagination: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const noRecords =
|
|
||||||
Object.values(params).every((v) => !v) && !isLoading && !orders?.length
|
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="p-0 divide-y">
|
<Container className="divide-y p-0">
|
||||||
<div className="flex items-center justify-between py-4 px-6">
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<Heading level="h2">{t("orders.domain")}</Heading>
|
<Heading level="h2">{t("orders.domain")}</Heading>
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<Button size="small" variant="secondary">
|
<Button size="small" variant="secondary">
|
||||||
@@ -101,140 +64,19 @@ export const CustomerOrderSection = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!noRecords && (
|
<DataTable
|
||||||
<div className="flex items-center justify-between py-4 px-6">
|
columns={columns}
|
||||||
<div></div>
|
table={table}
|
||||||
<div className="flex items-center gap-x-2">
|
pagination
|
||||||
<Query />
|
navigateTo={(row) => `/orders/${row.original.id}`}
|
||||||
</div>
|
filters={filters}
|
||||||
</div>
|
count={count}
|
||||||
)}
|
isLoading={isLoading}
|
||||||
{noRecords ? (
|
rowCount={PAGE_SIZE}
|
||||||
<NoRecords />
|
orderBy={["display_id", "created_at", "updated_at"]}
|
||||||
) : (
|
search={true}
|
||||||
<div>
|
queryObject={raw}
|
||||||
<Table>
|
/>
|
||||||
<Table.Header className="border-t-0">
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => {
|
|
||||||
return (
|
|
||||||
<Table.Row
|
|
||||||
key={headerGroup.id}
|
|
||||||
className=" [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th]:w-1/5"
|
|
||||||
>
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
return (
|
|
||||||
<Table.HeaderCell key={header.id}>
|
|
||||||
{flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</Table.HeaderCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Table.Row>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body className="border-b-0">
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<Table.Row
|
|
||||||
key={row.id}
|
|
||||||
className={clx(
|
|
||||||
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
|
||||||
{
|
|
||||||
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
|
||||||
row.getIsSelected(),
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
onClick={() => navigate(`/orders/${row.original.id}`)}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<Table.Cell key={cell.id}>
|
|
||||||
{flexRender(
|
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext()
|
|
||||||
)}
|
|
||||||
</Table.Cell>
|
|
||||||
))}
|
|
||||||
</Table.Row>
|
|
||||||
))}
|
|
||||||
</Table.Body>
|
|
||||||
</Table>
|
|
||||||
<LocalizedTablePagination
|
|
||||||
canNextPage={table.getCanNextPage()}
|
|
||||||
canPreviousPage={table.getCanPreviousPage()}
|
|
||||||
nextPage={table.nextPage}
|
|
||||||
previousPage={table.previousPage}
|
|
||||||
count={count ?? 0}
|
|
||||||
pageIndex={pageIndex}
|
|
||||||
pageCount={Math.ceil((count ?? 0) / PAGE_SIZE)}
|
|
||||||
pageSize={PAGE_SIZE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const OrderActions = ({ order }: { order: Order }) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionMenu
|
|
||||||
groups={[
|
|
||||||
{
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
icon: <ReceiptPercent />,
|
|
||||||
label: t("customers.viewOrder"),
|
|
||||||
to: `/orders/${order.id}/edit`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<any>()
|
|
||||||
|
|
||||||
const useColumns = () => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => [
|
|
||||||
columnHelper.accessor("display_id", {
|
|
||||||
header: "Order",
|
|
||||||
cell: ({ getValue }) => <OrderDisplayIdCell id={getValue()} />,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("created_at", {
|
|
||||||
header: "Date",
|
|
||||||
cell: ({ getValue }) => <OrderDateCell date={getValue()} />,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("fulfillment_status", {
|
|
||||||
header: "Fulfillment Status",
|
|
||||||
cell: ({ getValue }) => (
|
|
||||||
<OrderFulfillmentStatusCell status={getValue()} />
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("payment_status", {
|
|
||||||
header: "Payment Status",
|
|
||||||
cell: ({ getValue }) => <OrderPaymentStatusCell status={getValue()} />,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("total", {
|
|
||||||
header: () => t("fields.total"),
|
|
||||||
cell: ({ getValue, row }) => (
|
|
||||||
<OrderTotalCell
|
|
||||||
total={getValue()}
|
|
||||||
currencyCode={row.original.currency_code}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
columnHelper.display({
|
|
||||||
id: "actions",
|
|
||||||
cell: ({ row }) => <OrderActions order={row.original} />,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
[t]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { OrderList as Component } from "./list";
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Container, Heading } from "@medusajs/ui";
|
|
||||||
|
|
||||||
export const OrderList = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Container>
|
|
||||||
<Heading>Orders</Heading>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./order-list-table"
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Container, Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminOrders } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { DataTable } from "../../../../../components/table/data-table/data-table"
|
||||||
|
import { useOrderTableColumns } from "../../../../../hooks/table/columns/use-order-table-columns"
|
||||||
|
import { useOrderTableFilters } from "../../../../../hooks/table/filters/use-order-table-filters"
|
||||||
|
import { useOrderTableQuery } from "../../../../../hooks/table/query/use-order-table-query"
|
||||||
|
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
const DEFAULT_RELATIONS = "customer,items,sales_channel"
|
||||||
|
const DEFAULT_FIELDS =
|
||||||
|
"id,status,display_id,created_at,email,fulfillment_status,payment_status,total,currency_code"
|
||||||
|
|
||||||
|
export const OrderListTable = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { searchParams, raw } = useOrderTableQuery({
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { orders, count, isError, error, isLoading } = useAdminOrders(
|
||||||
|
{
|
||||||
|
expand: DEFAULT_RELATIONS,
|
||||||
|
fields: DEFAULT_FIELDS,
|
||||||
|
...searchParams,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const filters = useOrderTableFilters()
|
||||||
|
const columns = useOrderTableColumns({})
|
||||||
|
|
||||||
|
const { table } = useDataTable({
|
||||||
|
data: orders ?? [],
|
||||||
|
columns,
|
||||||
|
enablePagination: true,
|
||||||
|
count,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="divide-y p-0">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<Heading>{t("orders.domain")}</Heading>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
table={table}
|
||||||
|
pagination
|
||||||
|
navigateTo={(row) => `/orders/${row.original.id}`}
|
||||||
|
filters={filters}
|
||||||
|
count={count}
|
||||||
|
search
|
||||||
|
isLoading={isLoading}
|
||||||
|
rowCount={PAGE_SIZE}
|
||||||
|
orderBy={["display_id", "created_at", "updated_at"]}
|
||||||
|
queryObject={raw}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { OrderList as Component } from "./order-list"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { OrderListTable } from "./components/order-list-table"
|
||||||
|
|
||||||
|
export const OrderList = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-y-2">
|
||||||
|
<OrderListTable />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@medusajs/toolbox": "^0.0.1",
|
"@medusajs/toolbox": "^0.0.1",
|
||||||
"tailwindcss": "^3.3.2",
|
"tailwindcss": "^3.4.1",
|
||||||
"tsup": "^7.1.0",
|
"tsup": "^7.1.0",
|
||||||
"typescript": "^5.1.6"
|
"typescript": "^5.1.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
"resize-observer-polyfill": "^1.5.1",
|
"resize-observer-polyfill": "^1.5.1",
|
||||||
"rimraf": "^5.0.1",
|
"rimraf": "^5.0.1",
|
||||||
"storybook": "^7.0.23",
|
"storybook": "^7.0.23",
|
||||||
"tailwindcss": "^3.3.2",
|
"tailwindcss": "^3.4.1",
|
||||||
"tsc-alias": "^1.8.7",
|
"tsc-alias": "^1.8.7",
|
||||||
"typescript": "^5.1.6",
|
"typescript": "^5.1.6",
|
||||||
"vite": "^4.3.9",
|
"vite": "^4.3.9",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const Cell = React.forwardRef<
|
|||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
React.HTMLAttributes<HTMLTableCellElement>
|
React.HTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<td ref={ref} className={clx("h-12 pr-3", className)} {...props} />
|
<td ref={ref} className={clx("h-12 pr-6", className)} {...props} />
|
||||||
))
|
))
|
||||||
Cell.displayName = "Table.Cell"
|
Cell.displayName = "Table.Cell"
|
||||||
|
|
||||||
@@ -72,7 +72,11 @@ const HeaderCell = React.forwardRef<
|
|||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<th ref={ref} className={clx("h-10 pr-3 text-left", className)} {...props} />
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={clx("txt-compact-small-plus h-12 pr-6 text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
HeaderCell.displayName = "Table.HeaderCell"
|
HeaderCell.displayName = "Table.HeaderCell"
|
||||||
|
|
||||||
|
|||||||
144
yarn.lock
144
yarn.lock
@@ -8050,8 +8050,10 @@ __metadata:
|
|||||||
"@medusajs/ui-preset": "workspace:^"
|
"@medusajs/ui-preset": "workspace:^"
|
||||||
"@medusajs/vite-plugin-extension": "workspace:^"
|
"@medusajs/vite-plugin-extension": "workspace:^"
|
||||||
"@radix-ui/react-collapsible": 1.0.3
|
"@radix-ui/react-collapsible": 1.0.3
|
||||||
|
"@radix-ui/react-hover-card": ^1.0.7
|
||||||
"@tanstack/react-query": 4.22.0
|
"@tanstack/react-query": 4.22.0
|
||||||
"@tanstack/react-table": 8.10.7
|
"@tanstack/react-table": 8.10.7
|
||||||
|
"@types/node": ^20.11.15
|
||||||
"@types/react": 18.2.43
|
"@types/react": 18.2.43
|
||||||
"@types/react-dom": 18.2.17
|
"@types/react-dom": 18.2.17
|
||||||
"@uiw/react-json-view": 2.0.0-alpha.10
|
"@uiw/react-json-view": 2.0.0-alpha.10
|
||||||
@@ -8070,7 +8072,7 @@ __metadata:
|
|||||||
react-hook-form: 7.49.1
|
react-hook-form: 7.49.1
|
||||||
react-i18next: 13.5.0
|
react-i18next: 13.5.0
|
||||||
react-router-dom: 6.20.1
|
react-router-dom: 6.20.1
|
||||||
tailwindcss: 3.3.6
|
tailwindcss: ^3.4.1
|
||||||
typescript: 5.2.2
|
typescript: 5.2.2
|
||||||
vite: 5.0.10
|
vite: 5.0.10
|
||||||
zod: 3.22.4
|
zod: 3.22.4
|
||||||
@@ -8655,7 +8657,7 @@ __metadata:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@medusajs/toolbox": ^0.0.1
|
"@medusajs/toolbox": ^0.0.1
|
||||||
"@tailwindcss/forms": ^0.5.3
|
"@tailwindcss/forms": ^0.5.3
|
||||||
tailwindcss: ^3.3.2
|
tailwindcss: ^3.4.1
|
||||||
tailwindcss-animate: ^1.0.6
|
tailwindcss-animate: ^1.0.6
|
||||||
tsup: ^7.1.0
|
tsup: ^7.1.0
|
||||||
typescript: ^5.1.6
|
typescript: ^5.1.6
|
||||||
@@ -8726,7 +8728,7 @@ __metadata:
|
|||||||
rimraf: ^5.0.1
|
rimraf: ^5.0.1
|
||||||
storybook: ^7.0.23
|
storybook: ^7.0.23
|
||||||
tailwind-merge: ^1.13.2
|
tailwind-merge: ^1.13.2
|
||||||
tailwindcss: ^3.3.2
|
tailwindcss: ^3.4.1
|
||||||
tsc-alias: ^1.8.7
|
tsc-alias: ^1.8.7
|
||||||
typescript: ^5.1.6
|
typescript: ^5.1.6
|
||||||
vite: ^4.3.9
|
vite: ^4.3.9
|
||||||
@@ -10424,6 +10426,34 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@radix-ui/react-hover-card@npm:^1.0.7":
|
||||||
|
version: 1.0.7
|
||||||
|
resolution: "@radix-ui/react-hover-card@npm:1.0.7"
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime": ^7.13.10
|
||||||
|
"@radix-ui/primitive": 1.0.1
|
||||||
|
"@radix-ui/react-compose-refs": 1.0.1
|
||||||
|
"@radix-ui/react-context": 1.0.1
|
||||||
|
"@radix-ui/react-dismissable-layer": 1.0.5
|
||||||
|
"@radix-ui/react-popper": 1.1.3
|
||||||
|
"@radix-ui/react-portal": 1.0.4
|
||||||
|
"@radix-ui/react-presence": 1.0.1
|
||||||
|
"@radix-ui/react-primitive": 1.0.3
|
||||||
|
"@radix-ui/react-use-controllable-state": 1.0.1
|
||||||
|
peerDependencies:
|
||||||
|
"@types/react": "*"
|
||||||
|
"@types/react-dom": "*"
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
"@types/react":
|
||||||
|
optional: true
|
||||||
|
"@types/react-dom":
|
||||||
|
optional: true
|
||||||
|
checksum: f29f3da5bd9a967b5a35e91ac2d1b223191c7a074550d9d9cc10a0c0baf62ba0705b32912a7d2ef1ea5c27dd5e130a9fda9cbe6c2a7f3c2037ed5dfed89aa8cc
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@radix-ui/react-id@npm:1.0.0":
|
"@radix-ui/react-id@npm:1.0.0":
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
resolution: "@radix-ui/react-id@npm:1.0.0"
|
resolution: "@radix-ui/react-id@npm:1.0.0"
|
||||||
@@ -17668,6 +17698,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/node@npm:^20.11.15":
|
||||||
|
version: 20.11.15
|
||||||
|
resolution: "@types/node@npm:20.11.15"
|
||||||
|
dependencies:
|
||||||
|
undici-types: ~5.26.4
|
||||||
|
checksum: 7dfab4208fedc02e9584c619551906f46ade7955bb929b1e32e354a50522eb532d6bfb2844fdaad2c8dca03be84a590674460c64cb101e1a33bb318e1ec448d4
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@types/node@npm:^8.5.7":
|
"@types/node@npm:^8.5.7":
|
||||||
version: 8.10.66
|
version: 8.10.66
|
||||||
resolution: "@types/node@npm:8.10.66"
|
resolution: "@types/node@npm:8.10.66"
|
||||||
@@ -47893,72 +47932,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"tailwindcss@npm:3.3.6":
|
|
||||||
version: 3.3.6
|
|
||||||
resolution: "tailwindcss@npm:3.3.6"
|
|
||||||
dependencies:
|
|
||||||
"@alloc/quick-lru": ^5.2.0
|
|
||||||
arg: ^5.0.2
|
|
||||||
chokidar: ^3.5.3
|
|
||||||
didyoumean: ^1.2.2
|
|
||||||
dlv: ^1.1.3
|
|
||||||
fast-glob: ^3.3.0
|
|
||||||
glob-parent: ^6.0.2
|
|
||||||
is-glob: ^4.0.3
|
|
||||||
jiti: ^1.19.1
|
|
||||||
lilconfig: ^2.1.0
|
|
||||||
micromatch: ^4.0.5
|
|
||||||
normalize-path: ^3.0.0
|
|
||||||
object-hash: ^3.0.0
|
|
||||||
picocolors: ^1.0.0
|
|
||||||
postcss: ^8.4.23
|
|
||||||
postcss-import: ^15.1.0
|
|
||||||
postcss-js: ^4.0.1
|
|
||||||
postcss-load-config: ^4.0.1
|
|
||||||
postcss-nested: ^6.0.1
|
|
||||||
postcss-selector-parser: ^6.0.11
|
|
||||||
resolve: ^1.22.2
|
|
||||||
sucrase: ^3.32.0
|
|
||||||
bin:
|
|
||||||
tailwind: lib/cli.js
|
|
||||||
tailwindcss: lib/cli.js
|
|
||||||
checksum: 69caade773249cb963c33e81f85b7fc423dcb74b416727483f434f4e12874187f633970c9de864fa96736289abaf71189314a53589ada0be6c09ccb0e8b78391
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"tailwindcss@npm:^3.3.2":
|
|
||||||
version: 3.3.4
|
|
||||||
resolution: "tailwindcss@npm:3.3.4"
|
|
||||||
dependencies:
|
|
||||||
"@alloc/quick-lru": ^5.2.0
|
|
||||||
arg: ^5.0.2
|
|
||||||
chokidar: ^3.5.3
|
|
||||||
didyoumean: ^1.2.2
|
|
||||||
dlv: ^1.1.3
|
|
||||||
fast-glob: ^3.3.0
|
|
||||||
glob-parent: ^6.0.2
|
|
||||||
is-glob: ^4.0.3
|
|
||||||
jiti: ^1.19.1
|
|
||||||
lilconfig: ^2.1.0
|
|
||||||
micromatch: ^4.0.5
|
|
||||||
normalize-path: ^3.0.0
|
|
||||||
object-hash: ^3.0.0
|
|
||||||
picocolors: ^1.0.0
|
|
||||||
postcss: ^8.4.23
|
|
||||||
postcss-import: ^15.1.0
|
|
||||||
postcss-js: ^4.0.1
|
|
||||||
postcss-load-config: ^4.0.1
|
|
||||||
postcss-nested: ^6.0.1
|
|
||||||
postcss-selector-parser: ^6.0.11
|
|
||||||
resolve: ^1.22.2
|
|
||||||
sucrase: ^3.32.0
|
|
||||||
bin:
|
|
||||||
tailwind: lib/cli.js
|
|
||||||
tailwindcss: lib/cli.js
|
|
||||||
checksum: a1a0c8c1793b1b1b67503484fe924dc84f79e74c1ddc576095d616eaecc18bbd8fcdbf7c62e07a181673466f4913ebc20d92b93b87da730148b05f7c95e6c83e
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"tailwindcss@npm:^3.3.6":
|
"tailwindcss@npm:^3.3.6":
|
||||||
version: 3.4.0
|
version: 3.4.0
|
||||||
resolution: "tailwindcss@npm:3.4.0"
|
resolution: "tailwindcss@npm:3.4.0"
|
||||||
@@ -47992,6 +47965,39 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"tailwindcss@npm:^3.4.1":
|
||||||
|
version: 3.4.1
|
||||||
|
resolution: "tailwindcss@npm:3.4.1"
|
||||||
|
dependencies:
|
||||||
|
"@alloc/quick-lru": ^5.2.0
|
||||||
|
arg: ^5.0.2
|
||||||
|
chokidar: ^3.5.3
|
||||||
|
didyoumean: ^1.2.2
|
||||||
|
dlv: ^1.1.3
|
||||||
|
fast-glob: ^3.3.0
|
||||||
|
glob-parent: ^6.0.2
|
||||||
|
is-glob: ^4.0.3
|
||||||
|
jiti: ^1.19.1
|
||||||
|
lilconfig: ^2.1.0
|
||||||
|
micromatch: ^4.0.5
|
||||||
|
normalize-path: ^3.0.0
|
||||||
|
object-hash: ^3.0.0
|
||||||
|
picocolors: ^1.0.0
|
||||||
|
postcss: ^8.4.23
|
||||||
|
postcss-import: ^15.1.0
|
||||||
|
postcss-js: ^4.0.1
|
||||||
|
postcss-load-config: ^4.0.1
|
||||||
|
postcss-nested: ^6.0.1
|
||||||
|
postcss-selector-parser: ^6.0.11
|
||||||
|
resolve: ^1.22.2
|
||||||
|
sucrase: ^3.32.0
|
||||||
|
bin:
|
||||||
|
tailwind: lib/cli.js
|
||||||
|
tailwindcss: lib/cli.js
|
||||||
|
checksum: eec3d758f1cd4f51ab3b4c201927c3ecd18e55f8ac94256af60276aaf8d1df78f9dddb5e9fb1e057dfa7cea3c1356add4994cc3d42da9739df874e67047e656f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"tapable@npm:^1.0.0, tapable@npm:^1.1.3":
|
"tapable@npm:^1.0.0, tapable@npm:^1.1.3":
|
||||||
version: 1.1.3
|
version: 1.1.3
|
||||||
resolution: "tapable@npm:1.1.3"
|
resolution: "tapable@npm:1.1.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user