Initial commit: backend, storefront, vendor-panel added

This commit is contained in:
2025-08-01 11:05:32 +08:00
commit 08174125d2
2958 changed files with 310810 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
VITE_MEDUSA_BASE='/'
VITE_MEDUSA_STOREFRONT_URL=http://localhost:3000
VITE_MEDUSA_BACKEND_URL=http://localhost:9000
VITE_TALK_JS_APP_ID=
VITE_DISABLE_SELLERS_REGISTRATION=false

28
vendor-panel/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
.env.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vercel

8
vendor-panel/.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"endOfLine": "auto",
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"arrowParens": "always"
}

100
vendor-panel/README.md Normal file
View File

@@ -0,0 +1,100 @@
![B2C Storefront Cover](https://cdn.prod.website-files.com/6790aeffc4b432ccaf1b56e5/683051d0fd663550f5233ecb_ca2d007b9ac4c0d8c2f6afef398711bf_Readme-Vendor-Panel.png)
<div align="center">
<h1> Vendor Panel
<br>
for <a href="https://github.com/mercurjs/mercur">Mercur</a> - Open Source Marketplace Platform </h1>
<!-- Shields.io Badges -->
<a href="https://github.com/mercurjs/mercur/tree/main?tab=MIT-1-ov-file">
<img alt="License" src="https://img.shields.io/badge/license-MIT-blue.svg" />
</a>
<a href="#">
<img alt="PRs Welcome" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" />
</a>
<a href="https://mercurjs.com/contact">
<img alt="Support" src="https://img.shields.io/badge/support-contact%20author-blueviolet.svg" />
</a>
<!-- Website Links -->
<p>
<a href="https://vendor.mercurjs.com/">🛍️ Vendor Panel Demo </a> · <a href="https://mercurjs.com/">Mercur Website</a> · <a href="https://docs.mercurjs.com/">📃 Explore the docs</a>
</p>
</div>
## Vendor Panel for Mercur
The Vendor Panel is a pivotal component of the MercurJS ecosystem, designed to provide vendors with an intuitive interface to oversee their marketplace activities.
- Product Management: Add, edit, and organize products with ease.
- Order Tracking: Monitor order statuses and manage fulfillment processes.
- Store Customization: Update vendor store details
- Review Handling: Engage with customer feedback to improve service quality.
- Analytics Dashboard: Gain insights into sales performance and customer behavior.
### Vendor Panel - Product Management View
![Vendor Store - Frontend View](https://cdn.prod.website-files.com/6790aeffc4b432ccaf1b56e5/68304fb2466a73f093aa5965_Adding%20Products%20_%20Mercur.png)
### Vendor Store - Frontend View
![Vendor Store - Frontend View](https://cdn.prod.website-files.com/6790aeffc4b432ccaf1b56e5/68304b8674abb6fff86a2dbf_Cart%20and%20Vendor%20Page%20_%20Mercur%20B2C%20Storefront.png)
# Part of Mercur
<a href="https://github.com/mercurjs/mercur">Mercur</a> is an open source marketplace platform that allows you to create high-quality experiences for shoppers and vendors while having the most popular Open Source commerce platform MedusaJS as a foundation.
Mercur is a platform to start, customize, manage, and scale your marketplace for every business model with a modern technology stack.
![Mercur](https://cdn.prod.website-files.com/6790aeffc4b432ccaf1b56e5/67a1020f202572832c954ead_6b96703adfe74613f85133f83a19b1f0_Fleek%20Tilt%20-%20Readme.png)
# Quickstart
## Installation
Clone the repository
```js
git clone https://github.com/mercurjs/vendor-panel.git
```
&nbsp;
Go to directory
```js
cd vendor-panel
```
&nbsp;
Install dependencies
```js
npm install
```
&nbsp;
Make a .env.local file and copy the code below
```js
VITE_MEDUSA_BASE='/'
VITE_MEDUSA_STOREFRONT_URL=http://localhost:3000
VITE_MEDUSA_BACKEND_URL=http://localhost:9000
VITE_TALK_JS_APP_ID=<talkjs public key here>
VITE_DISABLE_SELLERS_REGISTRATION=false
```
&nbsp;
Start storefront
```js
npm run dev
```
&nbsp;
## Guides
<a href="https://talkjs.com/docs/Reference/Concepts/Sessions/" target="_blank">How
to get TalkJs App ID</a>

13
vendor-panel/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mercur Vendor Panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

15207
vendor-panel/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

103
vendor-panel/package.json Normal file
View File

@@ -0,0 +1,103 @@
{
"name": "vendor-panel",
"version": "1.0.0",
"scripts": {
"generate:static": "node ./scripts/generate-currencies.js && prettier --write ./src/lib/currencies.ts",
"dev": "vite",
"build": "tsup && node ./scripts/generate-types.js",
"build:preview": "vite build",
"build:admin": "medusa build",
"preview": "vite preview",
"test": "vitest --run",
"i18n:validate": "node ./scripts/i18n/validate-translation.js",
"i18n:schema": "node ./scripts/i18n/generate-schema.js",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write ."
},
"main": "dist/app.js",
"module": "dist/app.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/app.mjs",
"require": "./dist/app.js",
"types": "./dist/index.d.ts"
},
"./css": {
"import": "./dist/app.css",
"require": "./dist/app.css"
},
"./root": "./",
"./package.json": "./package.json"
},
"repository": {
"type": "git",
"url": "https://https://github.com/mercurjs/vendor-panel"
},
"files": [
"package.json",
"src",
"index.html",
"dist"
],
"dependencies": {
"@ariakit/react": "^0.4.15",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@hookform/error-message": "^2.0.1",
"@hookform/resolvers": "3.4.2",
"@medusajs/admin-shared": "^2.5.0",
"@medusajs/icons": "^2.5.0",
"@medusajs/js-sdk": "^2.5.0",
"@medusajs/ui": "~4.0.5",
"@talkjs/react": "^0.1.11",
"@tanstack/react-query": "5.64.2",
"@tanstack/react-table": "8.20.5",
"@tanstack/react-virtual": "^3.8.3",
"@uiw/react-json-view": "^2.0.0-alpha.17",
"cmdk": "^0.2.0",
"date-fns": "^3.6.0",
"i18next": "23.7.11",
"i18next-browser-languagedetector": "7.2.0",
"i18next-http-backend": "2.4.2",
"lodash": "^4.17.21",
"match-sorter": "^6.3.4",
"motion": "^11.15.0",
"qs": "^6.12.0",
"radix-ui": "1.1.2",
"react": "^18.2.0",
"react-country-flag": "^3.1.0",
"react-currency-input-field": "^3.6.11",
"react-day-picker": "^9.6.3",
"react-dom": "^18.2.0",
"react-helmet-async": "^2.0.5",
"react-hook-form": "7.49.1",
"react-i18next": "13.5.0",
"react-jwt": "^1.2.0",
"react-router-dom": "6.20.1",
"recharts": "^2.15.1",
"talkjs": "^0.35.0",
"zod": "3.22.4"
},
"devDependencies": {
"@medusajs/admin-shared": "^2.5.0",
"@medusajs/admin-vite-plugin": "^2.5.0",
"@medusajs/types": "^2.5.0",
"@medusajs/ui-preset": "^2.5.0",
"@types/node": "^20.11.15",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@vitejs/plugin-react": "4.2.1",
"ajv": "^8.17.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"prettier": "^3.1.1",
"tailwindcss": "^3.4.1",
"tsup": "^8.0.2",
"typescript": "5.2.2",
"vite": "^5.4.14",
"vite-plugin-inspect": "^0.8.7",
"vitest": "^3.0.5"
},
"packageManager": "yarn@3.2.1"
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,38 @@
<svg
width='44'
height='44'
viewBox='0 0 44 44'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<rect
width='44'
height='44'
rx='10'
fill='#FAFAFA'
/>
<g clipPath='url(#clip0_11_175)'>
<path
d='M8 7V20.7349L19.821 13.8675L8 7Z'
fill='#4C24DD'
/>
<path
d='M36.0002 37.0001V23.2651L24.1792 30.1326L36.0002 37.0001Z'
fill='#4C24DD'
/>
<path
d='M8 23.2651V37L36 20.7349V7L8 23.2651Z'
fill='#4C24DD'
/>
</g>
<defs>
<clipPath id='clip0_11_175'>
<rect
width='28'
height='30'
fill='white'
transform='translate(8 7)'
/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 916 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,46 @@
async function generateCurrencies() {
const { currencies } = await import(
"@medusajs/medusa/dist/utils/currencies.js"
)
const fs = await import("fs")
const path = await import("path")
const record = Object.entries(currencies).reduce((acc, [key, values]) => {
const code = values.code
const symbol_native = values.symbol_native
const name = values.name
const decimal_digits = values.decimal_digits
acc[key] = {
code,
name,
symbol_native,
decimal_digits,
}
return acc
}, {})
const json = JSON.stringify(record, null, 2)
const dest = path.join(__dirname, "../src/lib/currencies.ts")
const destDir = path.dirname(dest)
const fileContent = `/** This file is auto-generated. Do not modify it manually. */\ntype CurrencyInfo = { code: string; name: string; symbol_native: string; decimal_digits: number }\n\nexport const currencies: Record<string, CurrencyInfo> = ${json}`
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true })
}
fs.writeFileSync(dest, fileContent)
}
;(async () => {
console.log("Generating currency info")
try {
await generateCurrencies()
console.log("Currency info generated")
} catch (e) {
console.error(e)
}
})()

View File

@@ -0,0 +1,38 @@
/**
* We can't use the `tsc` command to generate types for the project because it
* will generate types for each file in the project, which isn't needed. We only
* need a single file that exports the App component.
*/
async function generateTypes() {
const fs = require("fs")
const path = require("path")
const distDir = path.resolve(__dirname, "../dist")
const filePath = path.join(distDir, "index.d.ts")
const fileContent = `
import * as react_jsx_runtime from "react/jsx-runtime"
declare const App: () => react_jsx_runtime.JSX.Element
export default App
`
// Ensure the dist directory exists
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir)
}
// Write the content to the index.d.ts file
fs.writeFileSync(filePath, fileContent.trim(), "utf8")
console.log(`File created at ${filePath}`)
}
;(async () => {
try {
await generateTypes()
} catch (e) {
console.error(e)
}
})()

View File

@@ -0,0 +1,64 @@
const fs = require("fs/promises")
const path = require("path")
const prettier = require("prettier")
const translationsDir = path.join(__dirname, "../../src/i18n/translations")
const enPath = path.join(translationsDir, "en.json")
const schemaPath = path.join(translationsDir, "$schema.json")
function generateSchemaFromObject(obj) {
if (typeof obj !== "object" || obj === null) {
return { type: typeof obj }
}
if (Array.isArray(obj)) {
return {
type: "array",
items: generateSchemaFromObject(obj[0] || "string"),
}
}
const properties = {}
const required = []
Object.entries(obj).forEach(([key, value]) => {
properties[key] = generateSchemaFromObject(value)
required.push(key)
})
return {
type: "object",
properties,
required,
additionalProperties: false,
}
}
async function outputSchema() {
const enContent = await fs.readFile(enPath, "utf-8")
const enJson = JSON.parse(enContent)
const schema = {
$schema: "http://json-schema.org/draft-07/schema#",
...generateSchemaFromObject(enJson),
}
const formattedSchema = await prettier.format(
JSON.stringify(schema, null, 2),
{
parser: "json",
}
)
await fs
.writeFile(schemaPath, formattedSchema)
.then(() => {
console.log("Schema generated successfully at:", schemaPath)
})
.catch((error) => {
console.error("Error generating schema:", error.message)
process.exit(1)
})
}
outputSchema()

View File

@@ -0,0 +1,47 @@
const Ajv = require("ajv")
const fs = require("fs")
const path = require("path")
const schema = require("../../src/i18n/translations/$schema.json")
const ajv = new Ajv({ allErrors: true })
const validate = ajv.compile(schema)
// Get file name from command line arguments
const fileName = process.argv[2]
if (!fileName) {
console.error("Please provide a file name (e.g., en.json) as an argument.")
process.exit(1)
}
const filePath = path.join(__dirname, "../../src/i18n/translations", fileName)
try {
const translations = JSON.parse(fs.readFileSync(filePath, "utf-8"))
if (!validate(translations)) {
console.error(`\nValidation failed for ${fileName}:`)
validate.errors?.forEach((error) => {
if (error.keyword === "required") {
const missingKeys = error.params.missingProperty
console.error(
` Missing required key: "${missingKeys}" at ${error.instancePath}`
)
} else if (error.keyword === "additionalProperties") {
const extraKey = error.params.additionalProperty
console.error(
` Unexpected key: "${extraKey}" at ${error.instancePath}`
)
} else {
console.error(` Error: ${error.message} at ${error.instancePath}`)
}
})
process.exit(1)
} else {
console.log(`${fileName} matches the schema.`)
process.exit(0)
}
} catch (error) {
console.error(`Error reading or parsing file: ${error.message}`)
process.exit(1)
}

26
vendor-panel/src/app.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { DashboardExtensionManager } from "./extensions"
import { Providers } from "./providers/providers"
import { RouterProvider } from "./providers/router-provider"
import displayModule from "virtual:medusa/displays"
import formModule from "virtual:medusa/forms"
import menuItemModule from "virtual:medusa/menu-items"
import widgetModule from "virtual:medusa/widgets"
import "./index.css"
function App() {
const manager = new DashboardExtensionManager({
displayModule,
formModule,
menuItemModule,
widgetModule,
})
return (
<Providers api={manager.api}>
<RouterProvider />
</Providers>
)
}
export default App

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
export * from "./protected-route"

View File

@@ -0,0 +1,45 @@
import { Spinner } from "@medusajs/icons"
import { Navigate, Outlet, useLocation } from "react-router-dom"
import { useMe } from "../../../hooks/api/users"
import { SearchProvider } from "../../../providers/search-provider"
import { SidebarProvider } from "../../../providers/sidebar-provider"
import { TalkjsProvider } from "../../../providers/talkjs-provider"
export const ProtectedRoute = () => {
const { seller, isPending, error } = useMe()
const isSuspended = seller?.store_status === "SUSPENDED"
const location = useLocation()
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner className="text-ui-fg-interactive animate-spin" />
</div>
)
}
if (!seller) {
return (
<Navigate
to={`/login${error?.message ? `?reason=${error.message}` : ""}`}
state={{ from: location }}
replace
/>
)
}
return (
<TalkjsProvider>
<SidebarProvider>
<SearchProvider>
{isSuspended && (
<div className="w-full bg-red-600 text-white p-1 text-center text-sm">
Your store is <b>suspended</b>. Please contact support.
</div>
)}
<Outlet />
</SearchProvider>
</SidebarProvider>
</TalkjsProvider>
)
}

View File

@@ -0,0 +1,123 @@
import { DropdownMenu, IconButton, clx } from "@medusajs/ui"
import { EllipsisHorizontal } from "@medusajs/icons"
import { PropsWithChildren, ReactNode } from "react"
import { Link } from "react-router-dom"
import { ConditionalTooltip } from "../conditional-tooltip"
export type Action = {
icon: ReactNode
label: string
disabled?: boolean
/**
* Optional tooltip to display when a disabled action is hovered.
*/
disabledTooltip?: string | ReactNode
} & (
| {
to: string
onClick?: never
}
| {
onClick: () => void
to?: never
}
)
export type ActionGroup = {
actions: Action[]
}
type ActionMenuProps = PropsWithChildren<{
groups: ActionGroup[]
variant?: "transparent" | "primary"
}>
export const ActionMenu = ({
groups,
variant = "transparent",
children,
}: ActionMenuProps) => {
const inner = children ?? (
<IconButton size="small" variant={variant}>
<EllipsisHorizontal />
</IconButton>
)
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>{inner}</DropdownMenu.Trigger>
<DropdownMenu.Content>
{groups.map((group, index) => {
if (!group.actions.length) {
return null
}
const isLast = index === groups.length - 1
return (
<DropdownMenu.Group key={index}>
{group.actions.map((action, index) => {
const Wrapper = action.disabledTooltip
? ({ children }: { children: ReactNode }) => (
<ConditionalTooltip
showTooltip={action.disabled}
content={action.disabledTooltip}
side="right"
>
<div>{children}</div>
</ConditionalTooltip>
)
: "div"
if (action.onClick) {
return (
<Wrapper key={index}>
<DropdownMenu.Item
disabled={action.disabled}
onClick={(e) => {
e.stopPropagation()
action.onClick()
}}
className={clx(
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
{
"[&_svg]:text-ui-fg-disabled": action.disabled,
}
)}
>
{action.icon}
<span>{action.label}</span>
</DropdownMenu.Item>
</Wrapper>
)
}
return (
<Wrapper key={index}>
<DropdownMenu.Item
className={clx(
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
{
"[&_svg]:text-ui-fg-disabled": action.disabled,
}
)}
asChild
disabled={action.disabled}
>
<Link to={action.to} onClick={(e) => e.stopPropagation()}>
{action.icon}
<span>{action.label}</span>
</Link>
</DropdownMenu.Item>
</Wrapper>
)
})}
{!isLast && <DropdownMenu.Separator />}
</DropdownMenu.Group>
)
})}
</DropdownMenu.Content>
</DropdownMenu>
)
}

View File

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

View File

@@ -0,0 +1,81 @@
import { Badge, Tooltip, clx } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
type BadgeListSummaryProps = {
/**
* Number of initial items to display
* @default 2
*/
n?: number
/**
* List of strings to display as abbreviated list
*/
list: string[]
/**
* Is the summary displayed inline.
* Determines whether the center text is truncated if there is no space in the container
*/
inline?: boolean
/**
* Whether the badges should be rounded
*/
rounded?: boolean
className?: string
}
export const BadgeListSummary = ({
list,
className,
inline,
rounded = false,
n = 2,
}: BadgeListSummaryProps) => {
const { t } = useTranslation()
const title = t("general.plusCount", {
count: list.length - n,
})
return (
<div
className={clx(
"text-ui-fg-subtle txt-compact-small gap-x-2 overflow-hidden",
{
"inline-flex": inline,
flex: !inline,
},
className
)}
>
{list.slice(0, n).map((item) => {
return (
<Badge rounded={rounded ? "full" : "base"} key={item} size="2xsmall">
{item}
</Badge>
)
})}
{list.length > n && (
<div className="whitespace-nowrap">
<Tooltip
content={
<ul>
{list.slice(n).map((c) => (
<li key={c}>{c}</li>
))}
</ul>
}
>
<Badge
rounded={rounded ? "full" : "base"}
size="2xsmall"
className="cursor-default whitespace-nowrap"
>
{title}
</Badge>
</Tooltip>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./badge-list-summary"

View File

@@ -0,0 +1,81 @@
"use client"
import * as React from "react"
import { DayPicker } from "react-day-picker"
import { ChevronLeft, ChevronRight } from "@medusajs/icons"
import { clx } from "@medusajs/ui"
import "react-day-picker/src/style.css"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={clx("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4 flex flex-col items-center",
caption: "flex justify-center pt-1 relative items-center ",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: clx(
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: clx(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: clx(
"h-8 w-8 p-0 font-normal aria-selected:opacity-100 text-center"
),
button_next: "absolute right-0",
button_previous: "absolute left-0",
day_range_start: "day-range-start",
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
PreviousMonthButton: ({ className, ...props }) => (
<ChevronLeft
className={clx("absolute left-2 top-5", className)}
{...props}
/>
),
NextMonthButton: ({ className, ...props }) => (
<ChevronRight
className={clx("absolute right-2 top-5", className)}
{...props}
/>
),
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@@ -0,0 +1,111 @@
import { XMarkMini } from "@medusajs/icons"
import { Button, clx } from "@medusajs/ui"
import { Children, PropsWithChildren, createContext, useContext } from "react"
import { useTranslation } from "react-i18next"
type ChipGroupVariant = "base" | "component"
type ChipGroupProps = PropsWithChildren<{
onClearAll?: () => void
onRemove?: (index: number) => void
variant?: ChipGroupVariant
className?: string
}>
type GroupContextValue = {
onRemove?: (index: number) => void
variant: ChipGroupVariant
}
const GroupContext = createContext<GroupContextValue | null>(null)
const useGroupContext = () => {
const context = useContext(GroupContext)
if (!context) {
throw new Error("useGroupContext must be used within a ChipGroup component")
}
return context
}
const Group = ({
onClearAll,
onRemove,
variant = "component",
className,
children,
}: ChipGroupProps) => {
const { t } = useTranslation()
const showClearAll = !!onClearAll && Children.count(children) > 0
return (
<GroupContext.Provider value={{ onRemove, variant }}>
<ul
role="application"
className={clx("flex flex-wrap items-center gap-2", className)}
>
{children}
{showClearAll && (
<li>
<Button
size="small"
variant="transparent"
type="button"
onClick={onClearAll}
className="text-ui-fg-muted active:text-ui-fg-subtle"
>
{t("actions.clearAll")}
</Button>
</li>
)}
</ul>
</GroupContext.Provider>
)
}
type ChipProps = PropsWithChildren<{
index: number
className?: string
}>
const Chip = ({ index, className, children }: ChipProps) => {
const { onRemove, variant } = useGroupContext()
return (
<li
className={clx(
"bg-ui-bg-component shadow-borders-base flex items-stretch divide-x overflow-hidden rounded-md",
{
"bg-ui-bg-component": variant === "component",
"bg-ui-bg-base-": variant === "base",
},
className
)}
>
<span className="txt-compact-small-plus text-ui-fg-subtle flex items-center justify-center px-2 py-1">
{children}
</span>
{!!onRemove && (
<button
onClick={() => onRemove(index)}
type="button"
className={clx(
"text-ui-fg-muted active:text-ui-fg-subtle transition-fg flex items-center justify-center p-1",
{
"hover:bg-ui-bg-component-hover active:bg-ui-bg-component-pressed":
variant === "component",
"hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed":
variant === "base",
}
)}
>
<XMarkMini />
</button>
)}
</li>
)
}
export const ChipGroup = Object.assign(Group, { Chip })

View File

@@ -0,0 +1 @@
export * from "./chip-group"

View File

@@ -0,0 +1,20 @@
import { Tooltip } from "@medusajs/ui"
import { ComponentPropsWithoutRef, PropsWithChildren } from "react"
type ConditionalTooltipProps = PropsWithChildren<
ComponentPropsWithoutRef<typeof Tooltip> & {
showTooltip?: boolean
}
>
export const ConditionalTooltip = ({
children,
showTooltip = false,
...props
}: ConditionalTooltipProps) => {
if (showTooltip) {
return <Tooltip {...props}>{children}</Tooltip>
}
return children
}

View File

@@ -0,0 +1 @@
export * from "./conditional-tooltip"

View File

@@ -0,0 +1,202 @@
import { Avatar, Copy, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { HttpTypes } from "@medusajs/types"
import { getFormattedAddress, isSameAddress } from "../../../lib/addresses"
const ID = ({ data }: { data: HttpTypes.AdminOrder }) => {
const { t } = useTranslation()
const id = data.customer_id
const name = getOrderCustomer(data)
const email = data.email
const fallback = (name || email || "").charAt(0).toUpperCase()
return (
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.id")}
</Text>
<Link
to={`/customers/${id}`}
className="focus:shadow-borders-focus rounded-[4px] outline-none transition-shadow"
>
<div className="flex items-center gap-x-2 overflow-hidden">
<Avatar size="2xsmall" fallback={fallback} />
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle hover:text-ui-fg-base transition-fg truncate"
>
{name || email}
</Text>
</div>
</Link>
</div>
)
}
const Company = ({ data }: { data: HttpTypes.AdminOrder }) => {
const { t } = useTranslation()
const company =
data.shipping_address?.company || data.billing_address?.company
if (!company) {
return null
}
return (
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.company")}
</Text>
<Text size="small" leading="compact" className="truncate">
{company}
</Text>
</div>
)
}
const Contact = ({ data }: { data: HttpTypes.AdminOrder }) => {
const { t } = useTranslation()
const phone = data.shipping_address?.phone || data.billing_address?.phone
const email = data.email || ""
return (
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("orders.customer.contactLabel")}
</Text>
<div className="flex flex-col gap-y-2">
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
<Text
size="small"
leading="compact"
className="text-pretty break-all"
>
{email}
</Text>
<div className="flex justify-end">
<Copy content={email} className="text-ui-fg-muted" />
</div>
</div>
{phone && (
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
<Text
size="small"
leading="compact"
className="text-pretty break-all"
>
{phone}
</Text>
<div className="flex justify-end">
<Copy content={email} className="text-ui-fg-muted" />
</div>
</div>
)}
</div>
</div>
)
}
const AddressPrint = ({
address,
type,
}: {
address:
| HttpTypes.AdminOrder["shipping_address"]
| HttpTypes.AdminOrder["billing_address"]
type: "shipping" | "billing"
}) => {
const { t } = useTranslation()
return (
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{type === "shipping"
? t("addresses.shippingAddress.label")
: t("addresses.billingAddress.label")}
</Text>
{address ? (
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
<Text size="small" leading="compact">
{getFormattedAddress({ address }).map((line, i) => {
return (
<span key={i} className="break-words">
{line}
<br />
</span>
)
})}
</Text>
<div className="flex justify-end">
<Copy
content={getFormattedAddress({ address }).join("\n")}
className="text-ui-fg-muted"
/>
</div>
</div>
) : (
<Text size="small" leading="compact">
-
</Text>
)}
</div>
)
}
const Addresses = ({ data }: { data: HttpTypes.AdminOrder }) => {
const { t } = useTranslation()
return (
<div className="divide-y">
<AddressPrint address={data.shipping_address} type="shipping" />
{!isSameAddress(data.shipping_address, data.billing_address) ? (
<AddressPrint address={data.billing_address} type="billing" />
) : (
<div className="grid grid-cols-2 items-center px-6 py-4">
<Text
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-subtle"
>
{t("addresses.billingAddress.label")}
</Text>
<Text size="small" leading="compact" className="text-ui-fg-muted">
{t("addresses.billingAddress.sameAsShipping")}
</Text>
</div>
)}
</div>
)
}
export const CustomerInfo = Object.assign(
{},
{
ID,
Company,
Contact,
Addresses,
}
)
const getOrderCustomer = (obj: HttpTypes.AdminOrder) => {
const { first_name: sFirstName, last_name: sLastName } =
obj.shipping_address || {}
const { first_name: bFirstName, last_name: bLastName } =
obj.billing_address || {}
const { first_name: cFirstName, last_name: cLastName } = obj.customer || {}
const customerName = [cFirstName, cLastName].filter(Boolean).join(" ")
const shippingName = [sFirstName, sLastName].filter(Boolean).join(" ")
const billingName = [bFirstName, bLastName].filter(Boolean).join(" ")
const name = customerName || shippingName || billingName
return name
}

View File

@@ -0,0 +1 @@
export * from "./customer-info"

View File

@@ -0,0 +1,73 @@
import { Text, clx } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useDate } from "../../../hooks/use-date"
type DateRangeDisplayProps = {
startsAt?: Date | string | null
endsAt?: Date | string | null
showTime?: boolean
}
export const DateRangeDisplay = ({
startsAt,
endsAt,
showTime = false,
}: DateRangeDisplayProps) => {
const startDate = startsAt ? new Date(startsAt) : null
const endDate = endsAt ? new Date(endsAt) : null
const { t } = useTranslation()
const { getFullDate } = useDate()
return (
<div className="grid gap-3 md:grid-cols-2">
<div className="shadow-elevation-card-rest bg-ui-bg-component text-ui-fg-subtle flex items-center gap-x-3 rounded-md px-3 py-1.5">
<Bar date={startDate} />
<div>
<Text weight="plus" size="small">
{t("fields.startDate")}
</Text>
<Text size="small" className="tabular-nums">
{startDate
? getFullDate({
date: startDate,
includeTime: showTime,
})
: "-"}
</Text>
</div>
</div>
<div className="shadow-elevation-card-rest bg-ui-bg-component text-ui-fg-subtle flex items-center gap-x-3 rounded-md px-3 py-1.5">
<Bar date={endDate} />
<div>
<Text size="small" weight="plus">
{t("fields.endDate")}
</Text>
<Text size="small" className="tabular-nums">
{endDate
? getFullDate({
date: endDate,
includeTime: showTime,
})
: "-"}
</Text>
</div>
</div>
</div>
)
}
const Bar = ({ date }: { date: Date | null }) => {
const now = new Date()
const isDateInFuture = date && date > now
return (
<div
className={clx("bg-ui-tag-neutral-icon h-8 w-1 rounded-full", {
"bg-ui-tag-orange-icon": isDateInFuture,
})}
/>
)
}

View File

@@ -0,0 +1 @@
export * from "./date-range-display"

View File

@@ -0,0 +1,30 @@
import { useTranslation } from "react-i18next"
import { useState } from "react"
import copy from "copy-to-clipboard"
import { clx, toast, Tooltip } from "@medusajs/ui"
type DisplayIdProps = {
id: string
className?: string
}
function DisplayId({ id, className }: DisplayIdProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const onClick = () => {
copy(id)
toast.success(t("actions.idCopiedToClipboard"))
}
return (
<Tooltip maxWidth={260} content={id} open={open} onOpenChange={setOpen}>
<span onClick={onClick} className={clx("cursor-pointer", className)}>
#{id.slice(-7)}
</span>
</Tooltip>
)
}
export default DisplayId

View File

@@ -0,0 +1 @@
export * from "./display-id"

View File

@@ -0,0 +1,103 @@
import { ExclamationCircle, MagnifyingGlass, PlusMini } from "@medusajs/icons"
import { Button, Text, clx } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
export type NoResultsProps = {
title?: string
message?: string
className?: string
}
export const NoResults = ({ title, message, className }: NoResultsProps) => {
const { t } = useTranslation()
return (
<div
className={clx(
"flex h-[400px] w-full items-center justify-center",
className
)}
>
<div className="flex flex-col items-center gap-y-2">
<MagnifyingGlass />
<Text size="small" leading="compact" weight="plus">
{title ?? t("general.noResultsTitle")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{message ?? t("general.noResultsMessage")}
</Text>
</div>
</div>
)
}
type ActionProps = {
action?: {
to: string
label: string
}
}
type NoRecordsProps = {
title?: string
message?: string
className?: string
buttonVariant?: string
} & ActionProps
const DefaultButton = ({ action }: ActionProps) =>
action && (
<Link to={action.to}>
<Button variant="secondary" size="small">
{action.label}
</Button>
</Link>
)
const TransparentIconLeftButton = ({ action }: ActionProps) =>
action && (
<Link to={action.to}>
<Button variant="transparent" className="text-ui-fg-interactive">
<PlusMini /> {action.label}
</Button>
</Link>
)
export const NoRecords = ({
title,
message,
action,
className,
buttonVariant = "default",
}: NoRecordsProps) => {
const { t } = useTranslation()
return (
<div
className={clx(
"flex h-[400px] w-full flex-col items-center justify-center gap-y-4",
className
)}
>
<div className="flex flex-col items-center gap-y-3">
<ExclamationCircle className="text-ui-fg-subtle" />
<div className="flex flex-col items-center gap-y-1">
<Text size="small" leading="compact" weight="plus">
{title ?? t("general.noRecordsTitle")}
</Text>
<Text size="small" className="text-ui-fg-muted">
{message ?? t("general.noRecordsMessage")}
</Text>
</div>
</div>
{buttonVariant === "default" && <DefaultButton action={action} />}
{buttonVariant === "transparentIconLeft" && (
<TransparentIconLeftButton action={action} />
)}
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./empty-table-content"

View File

@@ -0,0 +1,130 @@
import { ArrowDownTray, Spinner } from "@medusajs/icons"
import { IconButton, Text } from "@medusajs/ui"
import { ActionGroup, ActionMenu } from "../action-menu"
export const FilePreview = ({
filename,
url,
loading,
activity,
actions,
hideThumbnail,
}: {
filename: string
url?: string
loading?: boolean
activity?: string
actions?: ActionGroup[]
hideThumbnail?: boolean
}) => {
return (
<div className="shadow-elevation-card-rest bg-ui-bg-component transition-fg rounded-md px-3 py-2">
<div className="flex flex-row items-center justify-between gap-2">
<div className="flex flex-row items-center gap-3">
{!hideThumbnail && <FileThumbnail />}
<div className="flex flex-col justify-center">
<Text
size="small"
leading="compact"
className="truncate max-w-[260px]"
>
{filename}
</Text>
{loading && !!activity && (
<Text
leading="compact"
size="xsmall"
className="text-ui-fg-interactive"
>
{activity}
</Text>
)}
</div>
</div>
{loading && <Spinner className="animate-spin" />}
{!loading && actions && <ActionMenu groups={actions} />}
{!loading && url && (
<IconButton variant="transparent" asChild>
<a href={url} download={filename ?? `${Date.now()}`}>
<ArrowDownTray />
</a>
</IconButton>
)}
</div>
</div>
)
}
const FileThumbnail = () => {
return (
<svg
width="24"
height="32"
viewBox="0 0 24 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 31.75H4C1.92893 31.75 0.25 30.0711 0.25 28V4C0.25 1.92893 1.92893 0.25 4 0.25H15.9431C16.9377 0.25 17.8915 0.645088 18.5948 1.34835L22.6516 5.4052C23.3549 6.10847 23.75 7.06229 23.75 8.05685V28C23.75 30.0711 22.0711 31.75 20 31.75Z"
fill="url(#paint0_linear_6594_388107)"
stroke="url(#paint1_linear_6594_388107)"
stroke-width="0.5"
/>
<path
opacity="0.4"
d="M17.7857 12.8125V13.5357H10.3393V10.9643H15.9375C16.9569 10.9643 17.7857 11.7931 17.7857 12.8125ZM6.21429 16.9107V15.0893H8.78571V16.9107H6.21429ZM10.3393 16.9107V15.0893H17.7857V16.9107H10.3393ZM15.9375 21.0357H10.3393V18.4643H17.7857V19.1875C17.7857 20.2069 16.9569 21.0357 15.9375 21.0357ZM6.21429 19.1875V18.4643H8.78571V21.0357H8.0625C7.0431 21.0357 6.21429 20.2069 6.21429 19.1875ZM8.0625 10.9643H8.78571V13.5357H6.21429V12.8125C6.21429 11.7931 7.0431 10.9643 8.0625 10.9643Z"
fill="url(#paint2_linear_6594_388107)"
stroke="url(#paint3_linear_6594_388107)"
stroke-width="0.428571"
/>
<defs>
<linearGradient
id="paint0_linear_6594_388107"
x1="12"
y1="0"
x2="12"
y2="32"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F4F4F5" />
<stop offset="1" stop-color="#E4E4E7" />
</linearGradient>
<linearGradient
id="paint1_linear_6594_388107"
x1="12"
y1="0"
x2="12"
y2="32"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#E4E4E7" />
<stop offset="1" stop-color="#D4D4D8" />
</linearGradient>
<linearGradient
id="paint2_linear_6594_388107"
x1="12"
y1="10.75"
x2="12"
y2="21.25"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#52525B" />
<stop offset="1" stop-color="#A1A1AA" />
</linearGradient>
<linearGradient
id="paint3_linear_6594_388107"
x1="12"
y1="10.75"
x2="12"
y2="21.25"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#18181B" />
<stop offset="1" stop-color="#52525B" />
</linearGradient>
</defs>
</svg>
)
}

View File

@@ -0,0 +1 @@
export * from "./file-preview"

View File

@@ -0,0 +1,149 @@
import { ArrowDownTray } from "@medusajs/icons"
import { Text, clx } from "@medusajs/ui"
import { ChangeEvent, DragEvent, useRef, useState } from "react"
export interface FileType {
id: string
url: string
file: File
}
export interface FileUploadProps {
label: string
multiple?: boolean
hint?: string
hasError?: boolean
formats: string[]
onUploaded: (files: FileType[]) => void
uploadedImage?: string
}
export const FileUpload = ({
label,
hint,
multiple = true,
hasError,
formats,
onUploaded,
uploadedImage = "",
}: FileUploadProps) => {
const [isDragOver, setIsDragOver] = useState<boolean>(false)
const inputRef = useRef<HTMLInputElement>(null)
const dropZoneRef = useRef<HTMLButtonElement>(null)
const handleOpenFileSelector = () => {
inputRef.current?.click()
}
const handleDragEnter = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
const files = event.dataTransfer?.files
if (!files) {
return
}
setIsDragOver(true)
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
if (
!dropZoneRef.current ||
dropZoneRef.current.contains(event.relatedTarget as Node)
) {
return
}
setIsDragOver(false)
}
const handleUploaded = (files: FileList | null) => {
if (!files) {
return
}
const fileList = Array.from(files)
const fileObj = fileList.map((file) => {
const id = Math.random().toString(36).substring(7)
const previewUrl = URL.createObjectURL(file)
return {
id: id,
url: previewUrl,
file,
}
})
onUploaded(fileObj)
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
setIsDragOver(false)
handleUploaded(event.dataTransfer?.files)
}
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
handleUploaded(event.target.files)
}
return (
<div>
<button
ref={dropZoneRef}
type="button"
onClick={handleOpenFileSelector}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
className={clx(
"bg-ui-bg-component border-ui-border-strong transition-fg group flex w-full flex-col items-center gap-y-2 rounded-lg border border-dashed p-8",
"hover:border-ui-border-interactive focus:border-ui-border-interactive",
"focus:shadow-borders-focus outline-none focus:border-solid",
{
"!border-ui-border-error": hasError,
"!border-ui-border-interactive": isDragOver,
}
)}
>
{uploadedImage ? (
<div>
<img src={uploadedImage} className="w-32 h-32 rounded-md" />
</div>
) : (
<>
<div className="text-ui-fg-subtle group-disabled:text-ui-fg-disabled flex items-center gap-x-2">
<ArrowDownTray />
<Text>{label}</Text>
</div>
{!!hint && (
<Text
size="small"
leading="compact"
className="text-ui-fg-muted group-disabled:text-ui-fg-disabled"
>
{hint}
</Text>
)}
</>
)}
</button>
<input
hidden
ref={inputRef}
onChange={handleFileChange}
type="file"
accept={formats.join(",")}
multiple={multiple}
/>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./file-upload"

View File

@@ -0,0 +1,222 @@
import { InformationCircleSolid } from "@medusajs/icons"
import {
Hint as HintComponent,
Label as LabelComponent,
Text,
Tooltip,
clx,
} from "@medusajs/ui"
import { Label as RadixLabel, Slot } from "radix-ui"
import React, {
ReactNode,
createContext,
forwardRef,
useContext,
useId,
} from "react"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
useFormState,
} from "react-hook-form"
import { useTranslation } from "react-i18next"
const Provider = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const Field = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
type FormItemContextValue = {
id: string
}
const FormItemContext = createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const useFormField = () => {
const fieldContext = useContext(FormFieldContext)
const itemContext = useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within a FormField")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formLabelId: `${id}-form-item-label`,
formDescriptionId: `${id}-form-item-description`,
formErrorMessageId: `${id}-form-item-message`,
...fieldState,
}
}
const Item = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
ref={ref}
className={clx("flex flex-col space-y-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
)
Item.displayName = "Form.Item"
const Label = forwardRef<
React.ElementRef<typeof RadixLabel.Root>,
React.ComponentPropsWithoutRef<typeof RadixLabel.Root> & {
optional?: boolean
tooltip?: ReactNode
icon?: ReactNode
}
>(({ className, optional = false, tooltip, icon, ...props }, ref) => {
const { formLabelId, formItemId } = useFormField()
const { t } = useTranslation()
return (
<div className="flex items-center gap-x-1">
<LabelComponent
id={formLabelId}
ref={ref}
className={clx(className)}
htmlFor={formItemId}
size="small"
weight="plus"
{...props}
/>
{tooltip && (
<Tooltip content={tooltip}>
<InformationCircleSolid className="text-ui-fg-muted" />
</Tooltip>
)}
{icon}
{optional && (
<Text size="small" leading="compact" className="text-ui-fg-muted">
({t("fields.optional")})
</Text>
)}
</div>
)
})
Label.displayName = "Form.Label"
const Control = forwardRef<
React.ElementRef<typeof Slot.Root>,
React.ComponentPropsWithoutRef<typeof Slot.Root>
>(({ ...props }, ref) => {
const {
error,
formItemId,
formDescriptionId,
formErrorMessageId,
formLabelId,
} = useFormField()
return (
<Slot.Root
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formErrorMessageId}`
}
aria-invalid={!!error}
aria-labelledby={formLabelId}
{...props}
/>
)
})
Control.displayName = "Form.Control"
const Hint = forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<HintComponent
ref={ref}
id={formDescriptionId}
className={className}
{...props}
/>
)
})
Hint.displayName = "Form.Hint"
const ErrorMessage = forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formErrorMessageId } = useFormField()
const msg = error ? String(error?.message) : children
if (!msg || msg === "undefined") {
return null
}
return (
<HintComponent
ref={ref}
id={formErrorMessageId}
className={className}
variant={error ? "error" : "info"}
{...props}
>
{msg}
</HintComponent>
)
})
ErrorMessage.displayName = "Form.ErrorMessage"
const Form = Object.assign(Provider, {
Item,
Label,
Control,
Hint,
ErrorMessage,
Field,
})
export { Form }

View File

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

View File

@@ -0,0 +1,38 @@
import { clx } from "@medusajs/ui"
import { PropsWithChildren } from "react"
type IconAvatarProps = PropsWithChildren<{
className?: string
size?: "small" | "large" | "xlarge"
}>
/**
* Use this component when a design calls for an avatar with an icon.
*
* The `<Avatar/>` component from `@medusajs/ui` does not support passing an icon as a child.
*/
export const IconAvatar = ({
size = "small",
children,
className,
}: IconAvatarProps) => {
return (
<div
className={clx(
"shadow-borders-base flex size-7 items-center justify-center",
"[&>div]:bg-ui-bg-field [&>div]:text-ui-fg-subtle [&>div]:flex [&>div]:size-6 [&>div]:items-center [&>div]:justify-center",
{
"size-7 rounded-md [&>div]:size-6 [&>div]:rounded-[4px]":
size === "small",
"size-10 rounded-lg [&>div]:size-9 [&>div]:rounded-[6px]":
size === "large",
"size-12 rounded-xl [&>div]:size-11 [&>div]:rounded-[10px]":
size === "xlarge",
},
className
)}
>
<div>{children}</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./icon-avatar"

View File

@@ -0,0 +1,25 @@
import { clx } from "@medusajs/ui"
import imagesConverter from "../../../utils/images-conventer"
export default function ImageAvatar({
src,
size = 6,
rounded = false,
}: {
src: string
size?: number
rounded?: boolean
}) {
const formattedSrc = imagesConverter(src)
return (
<img
src={formattedSrc}
alt="avatar"
className={clx(
`w-${size} h-${size} border rounded-md`,
rounded && "rounded-full"
)}
/>
)
}

View File

@@ -0,0 +1 @@
export { default as ImageAvatar } from "./image-avatar"

View File

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

View File

@@ -0,0 +1,135 @@
import { QueryKey, useInfiniteQuery } from "@tanstack/react-query"
import { ReactNode, useEffect, useMemo, useRef } from "react"
import { toast } from "@medusajs/ui"
import { Spinner } from "@medusajs/icons"
type InfiniteListProps<TResponse, TEntity, TParams> = {
queryKey: QueryKey
queryFn: (params: TParams) => Promise<TResponse>
queryOptions?: { enabled?: boolean }
renderItem: (item: TEntity) => ReactNode
renderEmpty: () => ReactNode
responseKey: keyof TResponse
pageSize?: number
}
export const InfiniteList = <
TResponse extends { count: number; offset: number; limit: number },
TEntity extends { id: string },
TParams extends { offset?: number; limit?: number },
>({
queryKey,
queryFn,
queryOptions,
renderItem,
renderEmpty,
responseKey,
pageSize = 20,
}: InfiniteListProps<TResponse, TEntity, TParams>) => {
const {
data,
error,
fetchNextPage,
fetchPreviousPage,
hasPreviousPage,
hasNextPage,
isFetching,
isPending,
} = useInfiniteQuery({
queryKey: queryKey,
queryFn: async ({ pageParam = 0 }) => {
return await queryFn({
limit: pageSize,
offset: pageParam,
} as TParams)
},
initialPageParam: 0,
maxPages: 5,
getNextPageParam: (lastPage) => {
const moreItemsExist = lastPage.count > lastPage.offset + lastPage.limit
return moreItemsExist ? lastPage.offset + lastPage.limit : undefined
},
getPreviousPageParam: (firstPage) => {
const moreItemsExist = firstPage.offset !== 0
return moreItemsExist
? Math.max(firstPage.offset - firstPage.limit, 0)
: undefined
},
...queryOptions,
})
const items = useMemo(() => {
return data?.pages.flatMap((p) => p[responseKey] as TEntity[]) ?? []
}, [data, responseKey])
const parentRef = useRef<HTMLDivElement>(null)
const startObserver = useRef<IntersectionObserver>()
const endObserver = useRef<IntersectionObserver>()
useEffect(() => {
if (isPending) {
return
}
// Define the new observers after we stop fetching
if (!isFetching) {
// Define the new observers after paginating
startObserver.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasPreviousPage) {
fetchPreviousPage()
}
})
endObserver.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage()
}
})
// Register the new observers to observe the new first and last children
startObserver.current?.observe(parentRef.current!.firstChild as Element)
endObserver.current?.observe(parentRef.current!.lastChild as Element)
}
// Clear the old observers
return () => {
startObserver.current?.disconnect()
endObserver.current?.disconnect()
}
}, [
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetching,
isPending,
])
useEffect(() => {
if (error) {
toast.error(error.message)
}
}, [error])
if (isPending) {
return (
<div className="flex h-full flex-col items-center justify-center">
<Spinner className="animate-spin" />
</div>
)
}
return (
<div ref={parentRef} className="h-full">
{items?.length
? items.map((item) => <div key={item.id}>{renderItem(item)}</div>)
: renderEmpty()}
{isFetching && (
<div className="flex flex-col items-center justify-center py-4">
<Spinner className="animate-spin" />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./json-view-section"

View File

@@ -0,0 +1,198 @@
import {
ArrowUpRightOnBox,
Check,
SquareTwoStack,
TriangleDownMini,
XMarkMini,
} from "@medusajs/icons"
import {
Badge,
Container,
Drawer,
Heading,
IconButton,
Kbd,
} from "@medusajs/ui"
import Primitive from "@uiw/react-json-view"
import { CSSProperties, MouseEvent, Suspense, useState } from "react"
import { Trans, useTranslation } from "react-i18next"
type JsonViewSectionProps = {
data: object
title?: string
}
export const JsonViewSection = ({ data }: JsonViewSectionProps) => {
const { t } = useTranslation()
const numberOfKeys = Object.keys(data).length
return (
<Container className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-x-4">
<Heading level="h2">{t("json.header")}</Heading>
<Badge size="2xsmall" rounded="full">
{t("json.numberOfKeys", {
count: numberOfKeys,
})}
</Badge>
</div>
<Drawer>
<Drawer.Trigger asChild>
<IconButton
size="small"
variant="transparent"
className="text-ui-fg-muted hover:text-ui-fg-subtle"
>
<ArrowUpRightOnBox />
</IconButton>
</Drawer.Trigger>
<Drawer.Content className="bg-ui-contrast-bg-base text-ui-code-fg-subtle !shadow-elevation-commandbar overflow-hidden border border-none max-md:inset-x-2 max-md:max-w-[calc(100%-16px)]">
<div className="bg-ui-code-bg-base flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-x-4">
<Drawer.Title asChild>
<Heading className="text-ui-contrast-fg-primary">
<Trans
i18nKey="json.drawer.header"
count={numberOfKeys}
components={[
<span key="count-span" className="text-ui-fg-subtle" />,
]}
/>
</Heading>
</Drawer.Title>
<Drawer.Description className="sr-only">
{t("json.drawer.description")}
</Drawer.Description>
</div>
<div className="flex items-center gap-x-2">
<Kbd className="bg-ui-contrast-bg-subtle border-ui-contrast-border-base text-ui-contrast-fg-secondary">
esc
</Kbd>
<Drawer.Close asChild>
<IconButton
size="small"
variant="transparent"
className="text-ui-contrast-fg-secondary hover:text-ui-contrast-fg-primary hover:bg-ui-contrast-bg-base-hover active:bg-ui-contrast-bg-base-pressed focus-visible:bg-ui-contrast-bg-base-hover focus-visible:shadow-borders-interactive-with-active"
>
<XMarkMini />
</IconButton>
</Drawer.Close>
</div>
</div>
<Drawer.Body className="flex flex-1 flex-col overflow-hidden px-[5px] py-0 pb-[5px]">
<div className="bg-ui-contrast-bg-subtle flex-1 overflow-auto rounded-b-[4px] rounded-t-lg p-3">
<Suspense
fallback={<div className="flex size-full flex-col"></div>}
>
<Primitive
value={data}
displayDataTypes={false}
style={
{
"--w-rjv-font-family": "Roboto Mono, monospace",
"--w-rjv-line-color": "var(--contrast-border-base)",
"--w-rjv-curlybraces-color":
"var(--contrast-fg-secondary)",
"--w-rjv-brackets-color": "var(--contrast-fg-secondary)",
"--w-rjv-key-string": "var(--contrast-fg-primary)",
"--w-rjv-info-color": "var(--contrast-fg-secondary)",
"--w-rjv-type-string-color": "var(--tag-green-icon)",
"--w-rjv-quotes-string-color": "var(--tag-green-icon)",
"--w-rjv-type-boolean-color": "var(--tag-orange-icon)",
"--w-rjv-type-int-color": "var(--tag-orange-icon)",
"--w-rjv-type-float-color": "var(--tag-orange-icon)",
"--w-rjv-type-bigint-color": "var(--tag-orange-icon)",
"--w-rjv-key-number": "var(--contrast-fg-secondary)",
"--w-rjv-arrow-color": "var(--contrast-fg-secondary)",
"--w-rjv-copied-color": "var(--contrast-fg-secondary)",
"--w-rjv-copied-success-color":
"var(--contrast-fg-primary)",
"--w-rjv-colon-color": "var(--contrast-fg-primary)",
"--w-rjv-ellipsis-color": "var(--contrast-fg-secondary)",
} as CSSProperties
}
collapsed={1}
>
<Primitive.Quote render={() => <span />} />
<Primitive.Null
render={() => (
<span className="text-ui-tag-red-icon">null</span>
)}
/>
<Primitive.Undefined
render={() => (
<span className="text-ui-tag-blue-icon">undefined</span>
)}
/>
<Primitive.CountInfo
render={(_props, { value }) => {
return (
<span className="text-ui-contrast-fg-secondary ml-2">
{t("general.items", {
count: Object.keys(value as object).length,
})}
</span>
)
}}
/>
<Primitive.Arrow>
<TriangleDownMini className="text-ui-contrast-fg-secondary -ml-[0.5px]" />
</Primitive.Arrow>
<Primitive.Colon>
<span className="mr-1">:</span>
</Primitive.Colon>
<Primitive.Copied
render={({ style }, { value }) => {
return <Copied style={style} value={value} />
}}
/>
</Primitive>
</Suspense>
</div>
</Drawer.Body>
</Drawer.Content>
</Drawer>
</Container>
)
}
type CopiedProps = {
style?: CSSProperties
value: object | undefined
}
const Copied = ({ style, value }: CopiedProps) => {
const [copied, setCopied] = useState(false)
const handler = (e: MouseEvent<HTMLSpanElement>) => {
e.stopPropagation()
setCopied(true)
if (typeof value === "string") {
navigator.clipboard.writeText(value)
} else {
const json = JSON.stringify(value, null, 2)
navigator.clipboard.writeText(json)
}
setTimeout(() => {
setCopied(false)
}, 2000)
}
const styl = { whiteSpace: "nowrap", width: "20px" }
if (copied) {
return (
<span style={{ ...style, ...styl }}>
<Check className="text-ui-contrast-fg-primary" />
</span>
)
}
return (
<span style={{ ...style, ...styl }} onClick={handler}>
<SquareTwoStack className="text-ui-contrast-fg-secondary" />
</span>
)
}

View File

@@ -0,0 +1 @@
export * from "./link-button"

View File

@@ -0,0 +1,29 @@
import { clx } from "@medusajs/ui"
import { ComponentPropsWithoutRef } from "react"
import { Link } from "react-router-dom"
interface LinkButtonProps extends ComponentPropsWithoutRef<typeof Link> {
variant?: "primary" | "interactive"
}
export const LinkButton = ({
className,
variant = "interactive",
...props
}: LinkButtonProps) => {
return (
<Link
className={clx(
"transition-fg txt-compact-small-plus rounded-[4px] outline-none",
"focus-visible:shadow-borders-focus",
{
"text-ui-fg-interactive hover:text-ui-fg-interactive-hover":
variant === "interactive",
"text-ui-fg-base hover:text-ui-fg-subtle": variant === "primary",
},
className
)}
{...props}
/>
)
}

View File

@@ -0,0 +1 @@
export { ListSummary } from "./list-summary"

View File

@@ -0,0 +1,70 @@
import { Tooltip, clx } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
type ListSummaryProps = {
/**
* Number of initial items to display
* @default 2
*/
n?: number
/**
* List of strings to display as abbreviated list
*/
list: string[]
/**
* Is the summary displayed inline.
* Determines whether the center text is truncated if there is no space in the container
*/
inline?: boolean
variant?: "base" | "compact"
className?: string
}
export const ListSummary = ({
list,
className,
variant = "compact",
inline,
n = 2,
}: ListSummaryProps) => {
const { t } = useTranslation()
const title = t("general.plusCountMore", {
count: list.length - n,
})
return (
<div
className={clx(
"text-ui-fg-subtle gap-x-1 overflow-hidden",
{
"inline-flex": inline,
flex: !inline,
"txt-compact-small": variant === "compact",
"txt-small": variant === "base",
},
className
)}
>
<div className="flex-1 truncate">
<span className="truncate">{list.slice(0, n).join(", ")}</span>
</div>
{list.length > n && (
<div className="whitespace-nowrap">
<Tooltip
content={
<ul>
{list.slice(n).map((c) => (
<li key={c}>{c}</li>
))}
</ul>
}
>
<span className="cursor-default whitespace-nowrap">{title}</span>
</Tooltip>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { motion } from "motion/react"
import { IconAvatar } from "../icon-avatar"
export default function AvatarBox({
checked,
size = 44,
}: {
checked?: boolean
size?: number
}) {
return (
<IconAvatar
size={size === 44 ? "xlarge" : "small"}
className="bg-ui-button-neutral shadow-buttons-neutral after:button-neutral-gradient relative mb-4 flex items-center justify-center rounded-xl after:inset-0 after:content-['']"
>
{checked && (
<motion.div
className="absolute -right-[5px] -top-1 flex size-5 items-center justify-center rounded-full border-[0.5px] border-[rgba(3,7,18,0.2)] bg-[#3B82F6] bg-gradient-to-b from-white/0 to-white/20 shadow-[0px_1px_2px_0px_rgba(3,7,18,0.12),0px_1px_2px_0px_rgba(255,255,255,0.10)_inset,0px_-1px_5px_0px_rgba(255,255,255,0.10)_inset,0px_0px_0px_0px_rgba(3,7,18,0.06)_inset]"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 1.2,
delay: 0.8,
ease: [0, 0.71, 0.2, 1.01],
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<motion.path
d="M5.8335 10.4167L9.16683 13.75L14.1668 6.25"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{
duration: 1.3,
delay: 1.1,
bounce: 0.6,
ease: [0.1, 0.8, 0.2, 1.01],
}}
/>
</svg>
</motion.div>
)}
<svg
width="44"
height="44"
viewBox="0 0 44 44"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="44" height="44" rx="10" fill="#FAFAFA" />
<g clipPath="url(#clip0_11_175)">
<path d="M8 7V20.7349L19.821 13.8675L8 7Z" fill="#4C24DD" />
<path
d="M36.0002 37.0001V23.2651L24.1792 30.1326L36.0002 37.0001Z"
fill="#4C24DD"
/>
<path d="M8 23.2651V37L36 20.7349V7L8 23.2651Z" fill="#4C24DD" />
</g>
<defs>
<clipPath id="clip0_11_175">
<rect
width="28"
height="30"
fill="white"
transform="translate(8 7)"
/>
</clipPath>
</defs>
</svg>
</IconAvatar>
)
}

View File

@@ -0,0 +1,2 @@
export * from "./logo-box"
export * from "./avatar-box"

View File

@@ -0,0 +1,74 @@
import { clx } from "@medusajs/ui"
import { Transition, motion } from "motion/react"
type LogoBoxProps = {
className?: string
checked?: boolean
containerTransition?: Transition
pathTransition?: Transition
}
export const LogoBox = ({
className,
checked,
containerTransition = {
duration: 0.8,
delay: 0.5,
ease: [0, 0.71, 0.2, 1.01],
},
pathTransition = {
duration: 0.8,
delay: 0.6,
ease: [0.1, 0.8, 0.2, 1.01],
},
}: LogoBoxProps) => {
return (
<div
className={clx(
"size-14 bg-ui-button-neutral shadow-buttons-neutral relative flex items-center justify-center rounded-xl",
"after:button-neutral-gradient after:inset-0 after:content-['']",
className
)}
>
{checked && (
<motion.div
className="size-5 absolute -right-[5px] -top-1 flex items-center justify-center rounded-full border-[0.5px] border-[rgba(3,7,18,0.2)] bg-[#3B82F6] bg-gradient-to-b from-white/0 to-white/20 shadow-[0px_1px_2px_0px_rgba(3,7,18,0.12),0px_1px_2px_0px_rgba(255,255,255,0.10)_inset,0px_-1px_5px_0px_rgba(255,255,255,0.10)_inset,0px_0px_0px_0px_rgba(3,7,18,0.06)_inset]"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={containerTransition}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<motion.path
d="M5.8335 10.4167L9.16683 13.75L14.1668 6.25"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={pathTransition}
/>
</svg>
</motion.div>
)}
<svg
width="36"
height="38"
viewBox="0 0 36 38"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M30.85 6.16832L22.2453 1.21782C19.4299 -0.405941 15.9801 -0.405941 13.1648 1.21782L4.52043 6.16832C1.74473 7.79208 0 10.802 0 14.0099V23.9505C0 27.198 1.74473 30.1683 4.52043 31.7921L13.1251 36.7822C15.9405 38.4059 19.3903 38.4059 22.2056 36.7822L30.8103 31.7921C33.6257 30.1683 35.3307 27.198 35.3307 23.9505V14.0099C35.41 10.802 33.6653 7.79208 30.85 6.16832ZM17.6852 27.8317C12.8079 27.8317 8.8426 23.8713 8.8426 19C8.8426 14.1287 12.8079 10.1683 17.6852 10.1683C22.5625 10.1683 26.5674 14.1287 26.5674 19C26.5674 23.8713 22.6022 27.8317 17.6852 27.8317Z"
className="fill-ui-button-inverted relative drop-shadow-sm"
/>
</svg>
</div>
)
}

View File

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

View File

@@ -0,0 +1,49 @@
import { ArrowUpRightOnBox } from "@medusajs/icons"
import { Badge, Container, Heading, IconButton } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
type MetadataSectionProps<TData extends object> = {
data: TData
href?: string
}
export const MetadataSection = <TData extends object>({
data,
href = "metadata/edit",
}: MetadataSectionProps<TData>) => {
const { t } = useTranslation()
if (!data) {
return null
}
if (!("metadata" in data)) {
return null
}
const numberOfKeys = data.metadata ? Object.keys(data.metadata).length : 0
return (
<Container className="flex items-center justify-between">
<div className="flex items-center gap-x-3">
<Heading level="h2">{t("metadata.header")}</Heading>
<Badge size="2xsmall" rounded="full">
{t("metadata.numberOfKeys", {
count: numberOfKeys,
})}
</Badge>
</div>
<IconButton
size="small"
variant="transparent"
className="text-ui-fg-muted hover:text-ui-fg-subtle"
asChild
>
<Link to={href}>
<ArrowUpRightOnBox />
</Link>
</IconButton>
</Container>
)
}

View File

@@ -0,0 +1 @@
export * from "./progress-bar"

View File

@@ -0,0 +1,33 @@
import { motion } from "motion/react"
interface ProgressBarProps {
/**
* The duration of the animation in seconds.
*
* @default 2
*/
duration?: number
}
export const ProgressBar = ({ duration = 2 }: ProgressBarProps) => {
return (
<motion.div
className="bg-ui-fg-subtle size-full"
initial={{
width: "0%",
}}
transition={{
delay: 0.2,
duration,
ease: "linear",
}}
animate={{
width: "90%",
}}
exit={{
width: "100%",
transition: { duration: 0.2, ease: "linear" },
}}
/>
)
}

View File

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

View File

@@ -0,0 +1,58 @@
import { InformationCircleSolid } from "@medusajs/icons"
import { Text, Tooltip, clx } from "@medusajs/ui"
import { ReactNode } from "react"
export type SectionRowProps = {
title: string
value?: ReactNode | string | null
actions?: ReactNode
tooltip?: string
}
export const SectionRow = ({
title,
value,
actions,
tooltip,
}: SectionRowProps) => {
const isValueString = typeof value === "string" || !value
return (
<div
className={clx(
`text-ui-fg-subtle grid w-full grid-cols-2 items-center gap-4 px-6 py-4`,
{
"grid-cols-[1fr_1fr_28px]": !!actions,
}
)}
>
<Text
size="small"
weight="plus"
leading="compact"
className="flex items-center gap-x-2"
>
{title}
{tooltip && (
<Tooltip content={tooltip}>
<InformationCircleSolid />
</Tooltip>
)}
</Text>
{isValueString ? (
<Text
size="small"
leading="compact"
className="whitespace-pre-line text-pretty"
>
{value ?? "-"}
</Text>
) : (
<div className="flex flex-wrap gap-1">{value}</div>
)}
{actions && <div>{actions}</div>}
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./sidebar-link"

View File

@@ -0,0 +1,47 @@
import { ReactNode } from "react"
import { Link } from "react-router-dom"
import { IconAvatar } from "../icon-avatar"
import { Text } from "@medusajs/ui"
import { TriangleRightMini } from "@medusajs/icons"
export interface SidebarLinkProps {
to: string
labelKey: string
descriptionKey: string
icon: ReactNode
}
export const SidebarLink = ({
to,
labelKey,
descriptionKey,
icon,
}: SidebarLinkProps) => {
return (
<Link to={to} className="group outline-none">
<div className="flex flex-col gap-2 px-2 pb-2">
<div className="shadow-elevation-card-rest bg-ui-bg-component transition-fg hover:bg-ui-bg-component-hover active:bg-ui-bg-component-pressed group-focus-visible:shadow-borders-interactive-with-active rounded-md px-4 py-2">
<div className="flex items-center gap-4">
<IconAvatar>{icon}</IconAvatar>
<div className="flex flex-1 flex-col">
<Text size="small" leading="compact" weight="plus">
{labelKey}
</Text>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{descriptionKey}
</Text>
</div>
<div className="flex size-7 items-center justify-center">
<TriangleRightMini className="text-ui-fg-muted" />
</div>
</div>
</div>
</div>
</Link>
)
}

View File

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

View File

@@ -0,0 +1,327 @@
import { Container, Heading, Text, clx } from "@medusajs/ui"
import { CSSProperties, ComponentPropsWithoutRef } from "react"
type SkeletonProps = {
className?: string
style?: CSSProperties
}
export const Skeleton = ({ className, style }: SkeletonProps) => {
return (
<div
aria-hidden
className={clx(
"bg-ui-bg-component h-3 w-3 animate-pulse rounded-[4px]",
className
)}
style={style}
/>
)
}
type TextSkeletonProps = {
size?: ComponentPropsWithoutRef<typeof Text>["size"]
leading?: ComponentPropsWithoutRef<typeof Text>["leading"]
characters?: number
}
type HeadingSkeletonProps = {
level?: ComponentPropsWithoutRef<typeof Heading>["level"]
characters?: number
}
export const HeadingSkeleton = ({
level = "h1",
characters = 10,
}: HeadingSkeletonProps) => {
let charWidth = 9
switch (level) {
case "h1":
charWidth = 11
break
case "h2":
charWidth = 10
break
case "h3":
charWidth = 9
break
}
return (
<Skeleton
className={clx({
"h-7": level === "h1",
"h-6": level === "h2",
"h-5": level === "h3",
})}
style={{
width: `${charWidth * characters}px`,
}}
/>
)
}
export const TextSkeleton = ({
size = "small",
leading = "compact",
characters = 10,
}: TextSkeletonProps) => {
let charWidth = 9
switch (size) {
case "xlarge":
charWidth = 13
break
case "large":
charWidth = 11
break
case "base":
charWidth = 10
break
case "small":
charWidth = 9
break
case "xsmall":
charWidth = 8
break
}
return (
<Skeleton
className={clx({
"h-5": size === "xsmall",
"h-6": size === "small",
"h-7": size === "base",
"h-8": size === "xlarge",
"!h-5": leading === "compact",
})}
style={{
width: `${charWidth * characters}px`,
}}
/>
)
}
export const IconButtonSkeleton = () => {
return <Skeleton className="h-7 w-7 rounded-md" />
}
type GeneralSectionSkeletonProps = {
rowCount?: number
}
export const GeneralSectionSkeleton = ({
rowCount,
}: GeneralSectionSkeletonProps) => {
const rows = Array.from({ length: rowCount ?? 0 }, (_, i) => i)
return (
<Container className="divide-y p-0" aria-hidden>
<div className="flex items-center justify-between px-6 py-4">
<HeadingSkeleton characters={16} />
<IconButtonSkeleton />
</div>
{rows.map((row) => (
<div
key={row}
className="grid grid-cols-2 items-center px-6 py-4"
aria-hidden
>
<TextSkeleton size="small" leading="compact" characters={12} />
<TextSkeleton size="small" leading="compact" characters={24} />
</div>
))}
</Container>
)
}
export const TableFooterSkeleton = ({ layout }: { layout: "fill" | "fit" }) => {
return (
<div
className={clx("flex items-center justify-between p-4", {
"border-t": layout === "fill",
})}
>
<Skeleton className="h-7 w-[138px]" />
<div className="flex items-center gap-x-2">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-7 w-11" />
<Skeleton className="h-7 w-11" />
</div>
</div>
)
}
type TableSkeletonProps = {
rowCount?: number
search?: boolean
filters?: boolean
orderBy?: boolean
pagination?: boolean
layout?: "fit" | "fill"
}
export const TableSkeleton = ({
rowCount = 10,
search = true,
filters = true,
orderBy = true,
pagination = true,
layout = "fit",
}: TableSkeletonProps) => {
// Row count + header row
const totalRowCount = rowCount + 1
const rows = Array.from({ length: totalRowCount }, (_, i) => i)
const hasToolbar = search || filters || orderBy
return (
<div
aria-hidden
className={clx({
"flex h-full flex-col overflow-hidden": layout === "fill",
})}
>
{hasToolbar && (
<div className="flex items-center justify-between px-6 py-4">
{filters && <Skeleton className="h-7 w-full max-w-[135px]" />}
{(search || orderBy) && (
<div className="flex items-center gap-x-2">
{search && <Skeleton className="h-7 w-[160px]" />}
{orderBy && <Skeleton className="h-7 w-7" />}
</div>
)}
</div>
)}
<div className="flex flex-col divide-y border-y">
{rows.map((row) => (
<Skeleton key={row} className="h-10 w-full rounded-none" />
))}
</div>
{pagination && <TableFooterSkeleton layout={layout} />}
</div>
)
}
export const TableSectionSkeleton = (props: TableSkeletonProps) => {
return (
<Container className="divide-y p-0" aria-hidden>
<div className="flex items-center justify-between px-6 py-4" aria-hidden>
<HeadingSkeleton level="h2" characters={16} />
<IconButtonSkeleton />
</div>
<TableSkeleton {...props} />
</Container>
)
}
export const JsonViewSectionSkeleton = () => {
return (
<Container className="divide-y p-0" aria-hidden>
<div className="flex items-center justify-between px-6 py-4" aria-hidden>
<div aria-hidden className="flex items-center gap-x-4">
<HeadingSkeleton level="h2" characters={16} />
<Skeleton className="h-5 w-12 rounded-md" />
</div>
<IconButtonSkeleton />
</div>
</Container>
)
}
type SingleColumnPageSkeletonProps = {
sections?: number
showJSON?: boolean
showMetadata?: boolean
}
export const SingleColumnPageSkeleton = ({
sections = 2,
showJSON = false,
showMetadata = false,
}: SingleColumnPageSkeletonProps) => {
return (
<div className="flex flex-col gap-y-3">
{Array.from({ length: sections }, (_, i) => i).map((section) => {
return (
<Skeleton
key={section}
className={clx("h-full max-h-[460px] w-full rounded-lg", {
// First section is smaller on most pages, this gives us less
// layout shifting in general,
"max-h-[219px]": section === 0,
})}
/>
)
})}
{showMetadata && <Skeleton className="h-[60px] w-full rounded-lg" />}
{showJSON && <Skeleton className="h-[60px] w-full rounded-lg" />}
</div>
)
}
type TwoColumnPageSkeletonProps = {
mainSections?: number
sidebarSections?: number
showJSON?: boolean
showMetadata?: boolean
}
export const TwoColumnPageSkeleton = ({
mainSections = 2,
sidebarSections = 1,
showJSON = false,
showMetadata = true,
}: TwoColumnPageSkeletonProps) => {
const showExtraData = showJSON || showMetadata
return (
<div className="flex flex-col gap-y-3">
<div className="flex flex-col gap-x-4 gap-y-3 xl:flex-row xl:items-start">
<div className="flex w-full flex-col gap-y-3">
{Array.from({ length: mainSections }, (_, i) => i).map((section) => {
return (
<Skeleton
key={section}
className={clx("h-full max-h-[460px] w-full rounded-lg", {
"max-h-[219px]": section === 0,
})}
/>
)
})}
{showExtraData && (
<div className="hidden flex-col gap-y-3 xl:flex">
{showMetadata && (
<Skeleton className="h-[60px] w-full rounded-lg" />
)}
{showJSON && <Skeleton className="h-[60px] w-full rounded-lg" />}
</div>
)}
</div>
<div className="flex w-full max-w-[100%] flex-col gap-y-3 xl:mt-0 xl:max-w-[440px]">
{Array.from({ length: sidebarSections }, (_, i) => i).map(
(section) => {
return (
<Skeleton
key={section}
className={clx("h-full max-h-[320px] w-full rounded-lg", {
"max-h-[140px]": section === 0,
})}
/>
)
}
)}
{showExtraData && (
<div className="flex flex-col gap-y-3 xl:hidden">
{showMetadata && (
<Skeleton className="h-[60px] w-full rounded-lg" />
)}
{showJSON && <Skeleton className="h-[60px] w-full rounded-lg" />}
</div>
)}
</div>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,228 @@
import {
Active,
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
DraggableSyntheticListeners,
KeyboardSensor,
PointerSensor,
defaultDropAnimationSideEffects,
useSensor,
useSensors,
type DropAnimation,
type UniqueIdentifier,
} from "@dnd-kit/core"
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { DotsSix } from "@medusajs/icons"
import { IconButton, clx } from "@medusajs/ui"
import {
CSSProperties,
Fragment,
PropsWithChildren,
ReactNode,
createContext,
useContext,
useMemo,
useState,
} from "react"
type SortableBaseItem = {
id: UniqueIdentifier
}
interface SortableListProps<TItem extends SortableBaseItem> {
items: TItem[]
onChange: (items: TItem[]) => void
renderItem: (item: TItem, index: number) => ReactNode
}
const List = <TItem extends SortableBaseItem>({
items,
onChange,
renderItem,
}: SortableListProps<TItem>) => {
const [active, setActive] = useState<Active | null>(null)
const [activeItem, activeIndex] = useMemo(() => {
if (active === null) {
return [null, null]
}
const index = items.findIndex(({ id }) => id === active.id)
return [items[index], index]
}, [active, items])
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleDragStart = ({ active }: DragStartEvent) => {
setActive(active)
}
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (over && active.id !== over.id) {
const activeIndex = items.findIndex(({ id }) => id === active.id)
const overIndex = items.findIndex(({ id }) => id === over.id)
onChange(arrayMove(items, activeIndex, overIndex))
}
setActive(null)
}
const handleDragCancel = () => {
setActive(null)
}
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<Overlay>
{activeItem && activeIndex !== null
? renderItem(activeItem, activeIndex)
: null}
</Overlay>
<SortableContext items={items}>
<ul
role="application"
className="flex list-inside list-none list-image-none flex-col p-0"
>
{items.map((item, index) => (
<Fragment key={item.id}>{renderItem(item, index)}</Fragment>
))}
</ul>
</SortableContext>
</DndContext>
)
}
const dropAnimationConfig: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.4",
},
},
}),
}
type SortableOverlayProps = PropsWithChildren
const Overlay = ({ children }: SortableOverlayProps) => {
return (
<DragOverlay
className="shadow-elevation-card-hover overflow-hidden rounded-md [&>li]:border-b-0"
dropAnimation={dropAnimationConfig}
>
{children}
</DragOverlay>
)
}
type SortableItemProps<TItem extends SortableBaseItem> = PropsWithChildren<{
id: TItem["id"]
className?: string
}>
type SortableItemContextValue = {
attributes: Record<string, any>
listeners: DraggableSyntheticListeners
ref: (node: HTMLElement | null) => void
isDragging: boolean
}
const SortableItemContext = createContext<SortableItemContextValue | null>(null)
const useSortableItemContext = () => {
const context = useContext(SortableItemContext)
if (!context) {
throw new Error(
"useSortableItemContext must be used within a SortableItemContext"
)
}
return context
}
const Item = <TItem extends SortableBaseItem>({
id,
className,
children,
}: SortableItemProps<TItem>) => {
const {
attributes,
isDragging,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
} = useSortable({ id })
const context = useMemo(
() => ({
attributes,
listeners,
ref: setActivatorNodeRef,
isDragging,
}),
[attributes, listeners, setActivatorNodeRef, isDragging]
)
const style: CSSProperties = {
opacity: isDragging ? 0.4 : undefined,
transform: CSS.Translate.toString(transform),
transition,
}
return (
<SortableItemContext.Provider value={context}>
<li
className={clx("transition-fg flex flex-1 list-none", className)}
ref={setNodeRef}
style={style}
>
{children}
</li>
</SortableItemContext.Provider>
)
}
const DragHandle = () => {
const { attributes, listeners, ref } = useSortableItemContext()
return (
<IconButton
variant="transparent"
size="small"
{...attributes}
{...listeners}
ref={ref}
className="cursor-grab touch-none active:cursor-grabbing"
>
<DotsSix className="text-ui-fg-muted" />
</IconButton>
)
}
export const SortableList = Object.assign(List, {
Item,
DragHandle,
})

View File

@@ -0,0 +1 @@
export * from "./sortable-tree"

View File

@@ -0,0 +1,156 @@
import {
DroppableContainer,
KeyboardCode,
KeyboardCoordinateGetter,
closestCorners,
getFirstCollision,
} from "@dnd-kit/core"
import type { SensorContext } from "./types"
import { getProjection } from "./utils"
const directions: string[] = [
KeyboardCode.Down,
KeyboardCode.Right,
KeyboardCode.Up,
KeyboardCode.Left,
]
const horizontal: string[] = [KeyboardCode.Left, KeyboardCode.Right]
export const sortableTreeKeyboardCoordinates: (
context: SensorContext,
indentationWidth: number
) => KeyboardCoordinateGetter =
(context, indentationWidth) =>
(
event,
{
currentCoordinates,
context: {
active,
over,
collisionRect,
droppableRects,
droppableContainers,
},
}
) => {
if (directions.includes(event.code)) {
if (!active || !collisionRect) {
return
}
event.preventDefault()
const {
current: { items, offset },
} = context
if (horizontal.includes(event.code) && over?.id) {
const { depth, maxDepth, minDepth } = getProjection(
items,
active.id,
over.id,
offset,
indentationWidth
)
switch (event.code) {
case KeyboardCode.Left:
if (depth > minDepth) {
return {
...currentCoordinates,
x: currentCoordinates.x - indentationWidth,
}
}
break
case KeyboardCode.Right:
if (depth < maxDepth) {
return {
...currentCoordinates,
x: currentCoordinates.x + indentationWidth,
}
}
break
}
return undefined
}
const containers: DroppableContainer[] = []
droppableContainers.forEach((container) => {
if (container?.disabled || container.id === over?.id) {
return
}
const rect = droppableRects.get(container.id)
if (!rect) {
return
}
switch (event.code) {
case KeyboardCode.Down:
if (collisionRect.top < rect.top) {
containers.push(container)
}
break
case KeyboardCode.Up:
if (collisionRect.top > rect.top) {
containers.push(container)
}
break
}
})
const collisions = closestCorners({
active,
collisionRect,
pointerCoordinates: null,
droppableRects,
droppableContainers: containers,
})
let closestId = getFirstCollision(collisions, "id")
if (closestId === over?.id && collisions.length > 1) {
closestId = collisions[1].id
}
if (closestId && over?.id) {
const activeRect = droppableRects.get(active.id)
const newRect = droppableRects.get(closestId)
const newDroppable = droppableContainers.get(closestId)
if (activeRect && newRect && newDroppable) {
const newIndex = items.findIndex(({ id }) => id === closestId)
const newItem = items[newIndex]
const activeIndex = items.findIndex(({ id }) => id === active.id)
const activeItem = items[activeIndex]
if (newItem && activeItem) {
const { depth } = getProjection(
items,
active.id,
closestId,
(newItem.depth - activeItem.depth) * indentationWidth,
indentationWidth
)
const isBelow = newIndex > activeIndex
const modifier = isBelow ? 1 : -1
const offset = 0
const newCoordinates = {
x: newRect.left + depth * indentationWidth,
y: newRect.top + modifier * offset,
}
return newCoordinates
}
}
}
}
return undefined
}

View File

@@ -0,0 +1,62 @@
import type { UniqueIdentifier } from "@dnd-kit/core"
import { AnimateLayoutChanges, useSortable } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { CSSProperties } from "react"
import { TreeItem, TreeItemProps } from "./tree-item"
import { iOS } from "./utils"
interface SortableTreeItemProps extends TreeItemProps {
id: UniqueIdentifier
}
const animateLayoutChanges: AnimateLayoutChanges = ({
isSorting,
wasDragging,
}) => {
return isSorting || wasDragging ? false : true
}
export function SortableTreeItem({
id,
depth,
disabled,
...props
}: SortableTreeItemProps) {
const {
attributes,
isDragging,
isSorting,
listeners,
setDraggableNodeRef,
setDroppableNodeRef,
transform,
transition,
} = useSortable({
id,
animateLayoutChanges,
disabled,
})
const style: CSSProperties = {
transform: CSS.Translate.toString(transform),
transition,
}
return (
<TreeItem
ref={setDraggableNodeRef}
wrapperRef={setDroppableNodeRef}
style={style}
depth={depth}
ghost={isDragging}
disableSelection={iOS}
disableInteraction={isSorting}
disabled={disabled}
handleProps={{
listeners,
attributes,
}}
{...props}
/>
)
}

View File

@@ -0,0 +1,379 @@
import {
Announcements,
DndContext,
DragEndEvent,
DragMoveEvent,
DragOverEvent,
DragOverlay,
DragStartEvent,
DropAnimation,
KeyboardSensor,
MeasuringStrategy,
PointerSensor,
UniqueIdentifier,
closestCenter,
defaultDropAnimation,
useSensor,
useSensors,
} from "@dnd-kit/core"
import {
SortableContext,
arrayMove,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { ReactNode, useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { sortableTreeKeyboardCoordinates } from "./keyboard-coordinates"
import { SortableTreeItem } from "./sortable-tree-item"
import type { FlattenedItem, SensorContext, TreeItem } from "./types"
import {
buildTree,
flattenTree,
getChildCount,
getProjection,
removeChildrenOf,
} from "./utils"
const measuring = {
droppable: {
strategy: MeasuringStrategy.Always,
},
}
const dropAnimationConfig: DropAnimation = {
keyframes({ transform }) {
return [
{ opacity: 1, transform: CSS.Transform.toString(transform.initial) },
{
opacity: 0,
transform: CSS.Transform.toString({
...transform.final,
x: transform.final.x + 5,
y: transform.final.y + 5,
}),
},
]
},
easing: "ease-out",
sideEffects({ active }) {
active.node.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: defaultDropAnimation.duration,
easing: defaultDropAnimation.easing,
})
},
}
interface Props<T extends TreeItem> {
collapsible?: boolean
childrenProp?: string
items: T[]
indentationWidth?: number
/**
* Enable drag for all items or provide a function to enable drag for specific items.
* @default true
*/
enableDrag?: boolean | ((item: T) => boolean)
onChange: (
updatedItem: {
id: UniqueIdentifier
parentId: UniqueIdentifier | null
index: number
},
items: T[]
) => void
renderValue: (item: T) => ReactNode
}
export function SortableTree<T extends TreeItem>({
collapsible = true,
childrenProp = "children", // "children" is the default children prop name
enableDrag = true,
items = [],
indentationWidth = 40,
onChange,
renderValue,
}: Props<T>) {
const [collapsedState, setCollapsedState] = useState<
Record<UniqueIdentifier, boolean>
>({})
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
const [overId, setOverId] = useState<UniqueIdentifier | null>(null)
const [offsetLeft, setOffsetLeft] = useState(0)
const [currentPosition, setCurrentPosition] = useState<{
parentId: UniqueIdentifier | null
overId: UniqueIdentifier
} | null>(null)
const flattenedItems = useMemo(() => {
const flattenedTree = flattenTree(items, childrenProp)
const collapsedItems = flattenedTree.reduce<UniqueIdentifier[]>(
(acc, item) => {
const { id } = item
const children = (item[childrenProp] || []) as FlattenedItem[]
const collapsed = collapsedState[id]
return collapsed && children.length ? [...acc, id] : acc
},
[]
)
return removeChildrenOf(
flattenedTree,
activeId ? [activeId, ...collapsedItems] : collapsedItems,
childrenProp
)
}, [activeId, items, childrenProp, collapsedState])
const projected =
activeId && overId
? getProjection(
flattenedItems,
activeId,
overId,
offsetLeft,
indentationWidth
)
: null
const sensorContext: SensorContext = useRef({
items: flattenedItems,
offset: offsetLeft,
})
const [coordinateGetter] = useState(() =>
sortableTreeKeyboardCoordinates(sensorContext, indentationWidth)
)
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter,
})
)
const sortedIds = useMemo(
() => flattenedItems.map(({ id }) => id),
[flattenedItems]
)
const activeItem = activeId
? flattenedItems.find(({ id }) => id === activeId)
: null
useEffect(() => {
sensorContext.current = {
items: flattenedItems,
offset: offsetLeft,
}
}, [flattenedItems, offsetLeft])
function handleDragStart({ active: { id: activeId } }: DragStartEvent) {
setActiveId(activeId)
setOverId(activeId)
const activeItem = flattenedItems.find(({ id }) => id === activeId)
if (activeItem) {
setCurrentPosition({
parentId: activeItem.parentId,
overId: activeId,
})
}
document.body.style.setProperty("cursor", "grabbing")
}
function handleDragMove({ delta }: DragMoveEvent) {
setOffsetLeft(delta.x)
}
function handleDragOver({ over }: DragOverEvent) {
setOverId(over?.id ?? null)
}
function handleDragEnd({ active, over }: DragEndEvent) {
resetState()
if (projected && over) {
const { depth, parentId } = projected
const clonedItems: FlattenedItem[] = JSON.parse(
JSON.stringify(flattenTree(items, childrenProp))
)
const overIndex = clonedItems.findIndex(({ id }) => id === over.id)
const activeIndex = clonedItems.findIndex(({ id }) => id === active.id)
const activeTreeItem = clonedItems[activeIndex]
clonedItems[activeIndex] = { ...activeTreeItem, depth, parentId }
const sortedItems = arrayMove(clonedItems, activeIndex, overIndex)
const { items: newItems, update } = buildTree<T>(
sortedItems,
overIndex,
childrenProp
)
onChange(update, newItems)
}
}
function handleDragCancel() {
resetState()
}
function resetState() {
setOverId(null)
setActiveId(null)
setOffsetLeft(0)
setCurrentPosition(null)
document.body.style.setProperty("cursor", "")
}
function handleCollapse(id: UniqueIdentifier) {
setCollapsedState((state) => ({
...state,
[id]: state[id] ? false : true,
}))
}
function getMovementAnnouncement(
eventName: string,
activeId: UniqueIdentifier,
overId?: UniqueIdentifier
) {
if (overId && projected) {
if (eventName !== "onDragEnd") {
if (
currentPosition &&
projected.parentId === currentPosition.parentId &&
overId === currentPosition.overId
) {
return
} else {
setCurrentPosition({
parentId: projected.parentId,
overId,
})
}
}
const clonedItems: FlattenedItem[] = JSON.parse(
JSON.stringify(flattenTree(items, childrenProp))
)
const overIndex = clonedItems.findIndex(({ id }) => id === overId)
const activeIndex = clonedItems.findIndex(({ id }) => id === activeId)
const sortedItems = arrayMove(clonedItems, activeIndex, overIndex)
const previousItem = sortedItems[overIndex - 1]
let announcement
const movedVerb = eventName === "onDragEnd" ? "dropped" : "moved"
const nestedVerb = eventName === "onDragEnd" ? "dropped" : "nested"
if (!previousItem) {
const nextItem = sortedItems[overIndex + 1]
announcement = `${activeId} was ${movedVerb} before ${nextItem.id}.`
} else {
if (projected.depth > previousItem.depth) {
announcement = `${activeId} was ${nestedVerb} under ${previousItem.id}.`
} else {
let previousSibling: FlattenedItem | undefined = previousItem
while (previousSibling && projected.depth < previousSibling.depth) {
const parentId: UniqueIdentifier | null = previousSibling.parentId
previousSibling = sortedItems.find(({ id }) => id === parentId)
}
if (previousSibling) {
announcement = `${activeId} was ${movedVerb} after ${previousSibling.id}.`
}
}
}
return announcement
}
return
}
const announcements: Announcements = {
onDragStart({ active }) {
return `Picked up ${active.id}.`
},
onDragMove({ active, over }) {
return getMovementAnnouncement("onDragMove", active.id, over?.id)
},
onDragOver({ active, over }) {
return getMovementAnnouncement("onDragOver", active.id, over?.id)
},
onDragEnd({ active, over }) {
return getMovementAnnouncement("onDragEnd", active.id, over?.id)
},
onDragCancel({ active }) {
return `Moving was cancelled. ${active.id} was dropped in its original position.`
},
}
return (
<DndContext
accessibility={{ announcements }}
sensors={sensors}
collisionDetection={closestCenter}
measuring={measuring}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
{flattenedItems.map((item) => {
const { id, depth } = item
const children = (item[childrenProp] || []) as FlattenedItem[]
const disabled =
typeof enableDrag === "function"
? !enableDrag(item as unknown as T)
: !enableDrag
return (
<SortableTreeItem
key={id}
id={id}
value={renderValue(item as unknown as T)}
disabled={disabled}
depth={id === activeId && projected ? projected.depth : depth}
indentationWidth={indentationWidth}
collapsed={Boolean(collapsedState[id] && children.length)}
childCount={children.length}
onCollapse={
collapsible && children.length
? () => handleCollapse(id)
: undefined
}
/>
)
})}
{createPortal(
<DragOverlay dropAnimation={dropAnimationConfig}>
{activeId && activeItem ? (
<SortableTreeItem
id={activeId}
depth={activeItem.depth}
clone
childCount={getChildCount(items, activeId, childrenProp) + 1}
value={renderValue(activeItem as unknown as T)}
indentationWidth={0}
/>
) : null}
</DragOverlay>,
document.body
)}
</SortableContext>
</DndContext>
)
}

View File

@@ -0,0 +1,207 @@
import React, { forwardRef, HTMLAttributes, ReactNode } from "react"
import {
DotsSix,
FolderIllustration,
FolderOpenIllustration,
TagIllustration,
TriangleRightMini,
} from "@medusajs/icons"
import { Badge, clx, IconButton } from "@medusajs/ui"
import { HandleProps } from "./types"
export interface TreeItemProps
extends Omit<HTMLAttributes<HTMLLIElement>, "id"> {
childCount?: number
clone?: boolean
collapsed?: boolean
depth: number
disableInteraction?: boolean
disableSelection?: boolean
ghost?: boolean
handleProps?: HandleProps
indentationWidth: number
value: ReactNode
disabled?: boolean
onCollapse?(): void
wrapperRef?(node: HTMLLIElement): void
}
export const TreeItem = forwardRef<HTMLDivElement, TreeItemProps>(
(
{
childCount,
clone,
depth,
disableSelection,
disableInteraction,
ghost,
handleProps,
indentationWidth,
collapsed,
onCollapse,
style,
value,
disabled,
wrapperRef,
...props
},
ref
) => {
return (
<li
ref={wrapperRef}
style={
{
paddingLeft: `${indentationWidth * depth}px`,
} as React.CSSProperties
}
className={clx("-mb-px list-none", {
"pointer-events-none": disableInteraction,
"select-none": disableSelection,
"[&:first-of-type>div]:border-t-0": !clone,
})}
{...props}
>
<div
ref={ref}
style={style}
className={clx(
"bg-ui-bg-base transition-fg relative flex items-center gap-x-3 border-y px-6 py-2.5",
{
"border-l": depth > 0,
"shadow-elevation-flyout bg-ui-bg-base w-fit rounded-lg border-none pr-6 opacity-80":
clone,
"bg-ui-bg-base-hover z-[1] opacity-50": ghost,
"bg-ui-bg-disabled": disabled,
}
)}
>
<Handle {...handleProps} disabled={disabled} />
<Collapse
collapsed={collapsed}
onCollapse={onCollapse}
clone={clone}
/>
<Icon
childrenCount={childCount}
collapsed={collapsed}
clone={clone}
/>
<Value value={value} />
<ChildrenCount clone={clone} childrenCount={childCount} />
</div>
</li>
)
}
)
TreeItem.displayName = "TreeItem"
const Handle = ({
listeners,
attributes,
disabled,
}: HandleProps & { disabled?: boolean }) => {
return (
<IconButton
size="small"
variant="transparent"
type="button"
className={clx("cursor-grab", { "cursor-not-allowed": disabled })}
disabled={disabled}
{...attributes}
{...listeners}
>
<DotsSix />
</IconButton>
)
}
type IconProps = {
childrenCount?: number
collapsed?: boolean
clone?: boolean
}
const Icon = ({ childrenCount, collapsed, clone }: IconProps) => {
const isBranch = clone ? childrenCount && childrenCount > 1 : childrenCount
const isOpen = clone ? false : !collapsed
return (
<div className="flex size-7 items-center justify-center">
{isBranch ? (
isOpen ? (
<FolderOpenIllustration />
) : (
<FolderIllustration />
)
) : (
<TagIllustration />
)}
</div>
)
}
type CollapseProps = {
collapsed?: boolean
onCollapse?: () => void
clone?: boolean
}
const Collapse = ({ collapsed, onCollapse, clone }: CollapseProps) => {
if (clone) {
return null
}
if (!onCollapse) {
return <div className="size-7" role="presentation" />
}
return (
<IconButton
size="small"
variant="transparent"
onClick={onCollapse}
type="button"
>
<TriangleRightMini
className={clx("text-ui-fg-subtle transition-transform", {
"rotate-90": !collapsed,
})}
/>
</IconButton>
)
}
type ValueProps = {
value: ReactNode
}
const Value = ({ value }: ValueProps) => {
return (
<div className="txt-compact-small text-ui-fg-subtle flex-grow truncate">
{value}
</div>
)
}
type ChildrenCountProps = {
clone?: boolean
childrenCount?: number
}
const ChildrenCount = ({ clone, childrenCount }: ChildrenCountProps) => {
if (!clone || !childrenCount) {
return null
}
if (clone && childrenCount <= 1) {
return null
}
return (
<Badge size="2xsmall" color="blue" className="absolute -right-2 -top-2">
{childrenCount}
</Badge>
)
}

View File

@@ -0,0 +1,23 @@
import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core"
import { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities"
import type { MutableRefObject } from "react"
export interface TreeItem extends Record<string, unknown> {
id: UniqueIdentifier
}
export interface FlattenedItem extends TreeItem {
parentId: UniqueIdentifier | null
depth: number
index: number
}
export type SensorContext = MutableRefObject<{
items: FlattenedItem[]
offset: number
}>
export type HandleProps = {
attributes?: DraggableAttributes | undefined
listeners?: SyntheticListenerMap | undefined
}

View File

@@ -0,0 +1,299 @@
import type { UniqueIdentifier } from "@dnd-kit/core"
import { arrayMove } from "@dnd-kit/sortable"
import type { FlattenedItem, TreeItem } from "./types"
export const iOS = /iPad|iPhone|iPod/.test(navigator.platform)
function getDragDepth(offset: number, indentationWidth: number) {
return Math.round(offset / indentationWidth)
}
export function getProjection(
items: FlattenedItem[],
activeId: UniqueIdentifier,
overId: UniqueIdentifier,
dragOffset: number,
indentationWidth: number
) {
const overItemIndex = items.findIndex(({ id }) => id === overId)
const activeItemIndex = items.findIndex(({ id }) => id === activeId)
const activeItem = items[activeItemIndex]
const newItems = arrayMove(items, activeItemIndex, overItemIndex)
const previousItem = newItems[overItemIndex - 1]
const nextItem = newItems[overItemIndex + 1]
const dragDepth = getDragDepth(dragOffset, indentationWidth)
const projectedDepth = activeItem.depth + dragDepth
const maxDepth = getMaxDepth({
previousItem,
})
const minDepth = getMinDepth({ nextItem })
let depth = projectedDepth
if (projectedDepth >= maxDepth) {
depth = maxDepth
} else if (projectedDepth < minDepth) {
depth = minDepth
}
return { depth, maxDepth, minDepth, parentId: getParentId() }
function getParentId() {
if (depth === 0 || !previousItem) {
return null
}
if (depth === previousItem.depth) {
return previousItem.parentId
}
if (depth > previousItem.depth) {
return previousItem.id
}
const newParent = newItems
.slice(0, overItemIndex)
.reverse()
.find((item) => item.depth === depth)?.parentId
return newParent ?? null
}
}
function getMaxDepth({ previousItem }: { previousItem: FlattenedItem }) {
if (previousItem) {
return previousItem.depth + 1
}
return 0
}
function getMinDepth({ nextItem }: { nextItem: FlattenedItem }) {
if (nextItem) {
return nextItem.depth
}
return 0
}
function flatten<T extends TreeItem>(
items: T[],
parentId: UniqueIdentifier | null = null,
depth = 0,
childrenProp: string
): FlattenedItem[] {
return items.reduce<FlattenedItem[]>((acc, item, index) => {
const children = (item[childrenProp] || []) as T[]
return [
...acc,
{ ...item, parentId, depth, index },
...flatten(children, item.id, depth + 1, childrenProp),
]
}, [])
}
export function flattenTree<T extends TreeItem>(
items: T[],
childrenProp: string
): FlattenedItem[] {
return flatten(items, undefined, undefined, childrenProp)
}
type ItemUpdate = {
id: UniqueIdentifier
parentId: UniqueIdentifier | null
index: number
}
export function buildTree<T extends TreeItem>(
flattenedItems: FlattenedItem[],
newIndex: number,
childrenProp: string
): { items: T[]; update: ItemUpdate } {
const root = { id: "root", [childrenProp]: [] } as T
const nodes: Record<string, T> = { [root.id]: root }
const items = flattenedItems.map((item) => ({ ...item, [childrenProp]: [] }))
let update: {
id: UniqueIdentifier | null
parentId: UniqueIdentifier | null
index: number
} = {
id: null,
parentId: null,
index: 0,
}
items.forEach((item, index) => {
const {
id,
index: _index,
depth: _depth,
parentId: _parentId,
...rest
} = item
const children = (item[childrenProp] || []) as T[]
const parentId = _parentId ?? root.id
const parent = nodes[parentId] ?? findItem(items, parentId)
nodes[id] = { id, [childrenProp]: children } as T
;(parent[childrenProp] as T[]).push({
id,
...rest,
[childrenProp]: children,
} as T)
/**
* Get the information for them item that was moved to the `newIndex`.
*/
if (index === newIndex) {
const parentChildren = parent[childrenProp] as FlattenedItem[]
update = {
id: item.id,
parentId: parent.id === "root" ? null : parent.id,
index: parentChildren.length - 1,
}
}
})
if (!update.id) {
throw new Error("Could not find item")
}
return {
items: root[childrenProp] as T[],
update: update as ItemUpdate,
}
}
export function findItem<T extends TreeItem>(
items: T[],
itemId: UniqueIdentifier
) {
return items.find(({ id }) => id === itemId)
}
export function findItemDeep<T extends TreeItem>(
items: T[],
itemId: UniqueIdentifier,
childrenProp: string
): TreeItem | undefined {
for (const item of items) {
const { id } = item
const children = (item[childrenProp] || []) as T[]
if (id === itemId) {
return item
}
if (children.length) {
const child = findItemDeep(children, itemId, childrenProp)
if (child) {
return child
}
}
}
return undefined
}
export function setProperty<TItem extends TreeItem, T extends keyof TItem>(
items: TItem[],
id: UniqueIdentifier,
property: T,
childrenProp: keyof TItem, // Make childrenProp a key of TItem
setter: (value: TItem[T]) => TItem[T]
): TItem[] {
return items.map((item) => {
if (item.id === id) {
return {
...item,
[property]: setter(item[property]),
}
}
const children = item[childrenProp] as TItem[] | undefined
if (children && children.length) {
return {
...item,
[childrenProp]: setProperty(
children,
id,
property,
childrenProp,
setter
),
} as TItem // Explicitly cast to TItem
}
return item
})
}
function countChildren<T extends TreeItem>(
items: T[],
count = 0,
childrenProp: string
): number {
return items.reduce((acc, item) => {
const children = (item[childrenProp] || []) as T[]
if (children.length) {
return countChildren(children, acc + 1, childrenProp)
}
return acc + 1
}, count)
}
export function getChildCount<T extends TreeItem>(
items: T[],
id: UniqueIdentifier,
childrenProp: string
) {
const item = findItemDeep(items, id, childrenProp)
const children = (item?.[childrenProp] || []) as T[]
return item ? countChildren(children, 0, childrenProp) : 0
}
export function removeChildrenOf(
items: FlattenedItem[],
ids: UniqueIdentifier[],
childrenProp: string
) {
const excludeParentIds = [...ids]
return items.filter((item) => {
if (item.parentId && excludeParentIds.includes(item.parentId)) {
const children = (item[childrenProp] || []) as FlattenedItem[]
if (children.length) {
excludeParentIds.push(item.id)
}
return false
}
return true
})
}
export function listItemsWithChildren<T extends TreeItem>(
items: T[],
childrenProp: string
): T[] {
return items.map((item) => {
return {
...item,
[childrenProp]: item[childrenProp]
? listItemsWithChildren(item[childrenProp] as TreeItem[], childrenProp)
: [],
}
})
}

View File

@@ -0,0 +1,15 @@
import { Star, StarSolid } from "@medusajs/icons"
export const StarsRating = ({ rate }: { rate: number }) => {
return (
<div className="flex gap-1">
{[
...Array(5)
.keys()
.map((key: number) => {
return key < rate ? <StarSolid key={key} /> : <Star key={key} />
}),
]}
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./switch-box"

View File

@@ -0,0 +1,74 @@
import { Switch } from "@medusajs/ui"
import { ReactNode } from "react"
import { ControllerProps, FieldPath, FieldValues } from "react-hook-form"
import { Form } from "../../common/form"
interface HeadlessControllerProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> extends Omit<ControllerProps<TFieldValues, TName>, "render"> {}
interface SwitchBoxProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> extends HeadlessControllerProps<TFieldValues, TName> {
label: string
description: string
optional?: boolean
tooltip?: ReactNode
/**
* Callback for performing additional actions when the checked state changes.
* This does not intercept the form control, it is only used for injecting side-effects.
*/
onCheckedChange?: (checked: boolean) => void
}
/**
* Wrapper for the Switch component to be used with `react-hook-form`.
*
* Use this component whenever a design calls for wrapping the Switch component
* in a container with a label and description.
*/
export const SwitchBox = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
label,
description,
optional = false,
tooltip,
onCheckedChange,
...props
}: SwitchBoxProps<TFieldValues, TName>) => {
return (
<Form.Field
{...props}
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="bg-ui-bg-component shadow-elevation-card-rest flex items-start gap-x-3 rounded-lg p-3">
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={(e) => {
onCheckedChange?.(e)
onChange(e)
}}
/>
</Form.Control>
<div>
<Form.Label optional={optional} tooltip={tooltip}>
{label}
</Form.Label>
<Form.Hint>{description}</Form.Hint>
</div>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)
}

View File

@@ -0,0 +1,30 @@
import { TaxExclusive, TaxInclusive } from "@medusajs/icons"
import { Tooltip } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
type IncludesTaxTooltipProps = {
includesTax?: boolean
}
export const IncludesTaxTooltip = ({
includesTax,
}: IncludesTaxTooltipProps) => {
const { t } = useTranslation()
return (
<Tooltip
maxWidth={999}
content={
includesTax
? t("general.includesTaxTooltip")
: t("general.excludesTaxTooltip")
}
>
{includesTax ? (
<TaxInclusive className="text-ui-fg-muted shrink-0" />
) : (
<TaxExclusive className="text-ui-fg-muted shrink-0" />
)}
</Tooltip>
)
}

View File

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

View File

@@ -0,0 +1,33 @@
import { Photo } from "@medusajs/icons"
import { clx } from "@medusajs/ui"
type ThumbnailProps = {
src?: string | null
alt?: string
size?: "small" | "base" | "large"
}
export const Thumbnail = ({ src, alt, size = "base" }: ThumbnailProps) => {
return (
<div
className={clx(
"bg-ui-bg-component border-ui-border-base flex items-center justify-center overflow-hidden rounded border",
{
"h-8 w-6": size === "base",
"h-5 w-4": size === "small",
"h-12 w-12": size === "large",
}
)}
>
{src ? (
<img
src={src}
alt={alt}
className="h-full w-full object-cover object-center"
/>
) : (
<Photo className="text-ui-fg-subtle" />
)}
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./user-link"

View File

@@ -0,0 +1,45 @@
import { Avatar, Text } from "@medusajs/ui"
import { Link } from "react-router-dom"
import { useUser } from "../../../hooks/api/users"
type UserLinkProps = {
id: string
first_name?: string | null
last_name?: string | null
email: string
type?: "customer" | "user"
}
export const UserLink = ({
id,
first_name,
last_name,
email,
type = "user",
}: UserLinkProps) => {
const name = [first_name, last_name].filter(Boolean).join(" ")
const fallback = name ? name.slice(0, 1) : email.slice(0, 1)
const link = type === "user" ? `/settings/users/${id}` : `/customers/${id}`
return (
<Link
to={link}
className="flex items-center gap-x-2 w-fit transition-fg hover:text-ui-fg-subtle outline-none focus-visible:shadow-borders-focus rounded-md"
>
<Avatar size="2xsmall" fallback={fallback.toUpperCase()} />
<Text size="small" leading="compact" weight="regular">
{name || email}
</Text>
</Link>
)
}
export const By = ({ id }: { id: string }) => {
const { user } = useUser(id) // todo: extend to support customers
if (!user) {
return null
}
return <UserLink {...user} />
}

View File

@@ -0,0 +1,71 @@
import { Checkbox } from "@medusajs/ui"
import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { useDataGridCell, useDataGridCellError } from "../hooks"
import { DataGridCellProps, InputProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridBooleanCell = <TData, TValue = any>({
context,
disabled,
}: DataGridCellProps<TData, TValue> & { disabled?: boolean }) => {
const { field, control, renderProps } = useDataGridCell({
context,
})
const errorProps = useDataGridCellError({ context })
const { container, input } = renderProps
return (
<Controller
control={control}
name={field}
render={({ field }) => {
return (
<DataGridCellContainer {...container} {...errorProps}>
<Inner field={field} inputProps={input} disabled={disabled} />
</DataGridCellContainer>
)
}}
/>
)
}
const Inner = ({
field,
inputProps,
disabled,
}: {
field: ControllerRenderProps<any, string>
inputProps: InputProps
disabled?: boolean
}) => {
const { ref, value, onBlur, name, disabled: fieldDisabled } = field
const {
ref: inputRef,
onBlur: onInputBlur,
onChange,
onFocus,
...attributes
} = inputProps
const combinedRefs = useCombinedRefs(ref, inputRef)
return (
<Checkbox
disabled={disabled || fieldDisabled}
name={name}
checked={value}
onCheckedChange={(newValue) => onChange(newValue === true, value)}
onFocus={onFocus}
onBlur={() => {
onBlur()
onInputBlur()
}}
ref={combinedRefs}
tabIndex={-1}
{...attributes}
/>
)
}

View File

@@ -0,0 +1,88 @@
import { ErrorMessage } from "@hookform/error-message"
import { ExclamationCircle } from "@medusajs/icons"
import { Tooltip, clx } from "@medusajs/ui"
import { PropsWithChildren } from "react"
import { get } from "react-hook-form"
import { DataGridCellContainerProps, DataGridErrorRenderProps } from "../types"
import { DataGridRowErrorIndicator } from "./data-grid-row-error-indicator"
export const DataGridCellContainer = ({
isAnchor,
isSelected,
isDragSelected,
field,
showOverlay,
placeholder,
innerProps,
overlayProps,
children,
errors,
rowErrors,
outerComponent,
}: DataGridCellContainerProps & DataGridErrorRenderProps<any>) => {
const error = get(errors, field)
const hasError = !!error
return (
<div className="group/container relative size-full">
<div
className={clx(
"bg-ui-bg-base group/cell relative flex size-full items-center gap-x-2 px-4 py-2.5 outline-none",
{
"bg-ui-tag-red-bg text-ui-tag-red-text":
hasError && !isAnchor && !isSelected && !isDragSelected,
"ring-ui-bg-interactive ring-2 ring-inset": isAnchor,
"bg-ui-bg-highlight [&:has([data-field]:focus)]:bg-ui-bg-base":
isSelected || isAnchor,
"bg-ui-bg-subtle": isDragSelected && !isAnchor,
}
)}
tabIndex={-1}
{...innerProps}
>
<ErrorMessage
name={field}
errors={errors}
render={({ message }) => {
return (
<div className="flex items-center justify-center">
<Tooltip content={message} delayDuration={0}>
<ExclamationCircle className="text-ui-tag-red-icon z-[3]" />
</Tooltip>
</div>
)
}}
/>
<div className="relative z-[1] flex size-full items-center justify-center">
<RenderChildren isAnchor={isAnchor} placeholder={placeholder}>
{children}
</RenderChildren>
</div>
<DataGridRowErrorIndicator rowErrors={rowErrors} />
{showOverlay && (
<div
{...overlayProps}
data-cell-overlay="true"
className="absolute inset-0 z-[2]"
/>
)}
</div>
{outerComponent}
</div>
)
}
const RenderChildren = ({
isAnchor,
placeholder,
children,
}: PropsWithChildren<
Pick<DataGridCellContainerProps, "isAnchor" | "placeholder">
>) => {
if (!isAnchor && placeholder) {
return placeholder
}
return children
}

View File

@@ -0,0 +1,149 @@
// Not currently used, re-implement or delete depending on whether there is a need for it in the future.
// import { TrianglesMini } from "@medusajs/icons"
// import { clx } from "@medusajs/ui"
// import { ComponentPropsWithoutRef, forwardRef, memo } from "react"
// import { Controller, ControllerRenderProps } from "react-hook-form"
// import { useCombinedRefs } from "../../../hooks/use-combined-refs"
// import { countries } from "../../../lib/data/countries"
// import { useDataGridCell } from "../hooks"
// import { DataGridCellProps, InputProps } from "../types"
// import { DataGridCellContainer } from "./data-grid-cell-container"
// export const DataGridCountrySelectCell = <TData, TValue = any>({
// field,
// context,
// }: DataGridCellProps<TData, TValue>) => {
// const { control, renderProps } = useDataGridCell({
// field,
// context,
// type: "select",
// })
// const { container, input } = renderProps
// return (
// <Controller
// control={control}
// name={field}
// render={({ field: { value, onChange: _, disabled, ...field } }) => {
// return (
// <DataGridCellContainer
// {...container}
// placeholder={
// <DataGridCountryCellPlaceholder
// value={value}
// disabled={disabled}
// attributes={attributes}
// />
// }
// >
// <MemoizedDataGridCountryCell
// value={value}
// onChange={(e) => onChange(e.target.value, value)}
// disabled={disabled}
// {...attributes}
// {...field}
// />
// </DataGridCellContainer>
// )
// }}
// />
// )
// }
// const Inner = ({
// field,
// inputProps,
// }: {
// field: ControllerRenderProps<any, string>
// inputProps: InputProps
// }) => {
// const { value, onChange, onBlur, ref, ...rest } = field
// const { ref: inputRef, onBlur: onInputBlur, ...input } = inputProps
// const combinedRefs = useCombinedRefs(inputRef, ref)
// return (
// <MemoizedDataGridCountryCell
// value={value}
// onChange={(e) => onChange(e.target.value, value)}
// onBlur={() => {
// onBlur()
// onInputBlur()
// }}
// ref={combinedRefs}
// {...input}
// {...rest}
// />
// )
// }
// const DataGridCountryCellPlaceholder = ({
// value,
// disabled,
// attributes,
// }: {
// value?: string
// disabled?: boolean
// attributes: Record<string, any>
// }) => {
// const country = countries.find((c) => c.iso_2 === value)
// return (
// <div className="relative flex size-full" {...attributes}>
// <TrianglesMini
// className={clx(
// "text-ui-fg-muted transition-fg pointer-events-none absolute right-4 top-1/2 -translate-y-1/2",
// {
// "text-ui-fg-disabled": disabled,
// }
// )}
// />
// <div
// className={clx(
// "txt-compact-small w-full appearance-none bg-transparent px-4 py-2.5 outline-none"
// )}
// >
// {country?.display_name}
// </div>
// </div>
// )
// }
// const DataGridCountryCellImpl = forwardRef<
// HTMLSelectElement,
// ComponentPropsWithoutRef<"select">
// >(({ disabled, className, ...props }, ref) => {
// return (
// <div className="relative flex size-full">
// <TrianglesMini
// className={clx(
// "text-ui-fg-muted transition-fg pointer-events-none absolute right-4 top-1/2 -translate-y-1/2",
// {
// "text-ui-fg-disabled": disabled,
// }
// )}
// />
// <select
// {...props}
// ref={ref}
// className={clx(
// "txt-compact-small w-full appearance-none bg-transparent px-4 py-2.5 outline-none",
// className
// )}
// >
// <option value=""></option>
// {countries.map((country) => (
// <option key={country.iso_2} value={country.iso_2}>
// {country.display_name}
// </option>
// ))}
// </select>
// </div>
// )
// })
// DataGridCountryCellImpl.displayName = "DataGridCountryCell"
// const MemoizedDataGridCountryCell = memo(DataGridCountryCellImpl)

View File

@@ -0,0 +1,140 @@
import CurrencyInput, {
CurrencyInputProps,
formatValue,
} from "react-currency-input-field"
import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCallback, useEffect, useState } from "react"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { CurrencyInfo, currencies } from "../../../lib/data/currencies"
import { useDataGridCell, useDataGridCellError } from "../hooks"
import { DataGridCellProps, InputProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
interface DataGridCurrencyCellProps<TData, TValue = any>
extends DataGridCellProps<TData, TValue> {
code: string
}
export const DataGridCurrencyCell = <TData, TValue = any>({
context,
code,
}: DataGridCurrencyCellProps<TData, TValue>) => {
const { field, control, renderProps } = useDataGridCell({
context,
})
const errorProps = useDataGridCellError({ context })
const { container, input } = renderProps
const currency = currencies[code.toUpperCase()]
return (
<Controller
control={control}
name={field}
render={({ field }) => {
return (
<DataGridCellContainer {...container} {...errorProps}>
<Inner field={field} inputProps={input} currencyInfo={currency} />
</DataGridCellContainer>
)
}}
/>
)
}
const Inner = ({
field,
inputProps,
currencyInfo,
}: {
field: ControllerRenderProps<any, string>
inputProps: InputProps
currencyInfo: CurrencyInfo
}) => {
const { value, onChange: _, onBlur, ref, ...rest } = field
const {
ref: inputRef,
onBlur: onInputBlur,
onFocus,
onChange,
...attributes
} = inputProps
const formatter = useCallback(
(value?: string | number) => {
const ensuredValue =
typeof value === "number" ? value.toString() : value || ""
return formatValue({
value: ensuredValue,
decimalScale: currencyInfo.decimal_digits,
disableGroupSeparators: true,
decimalSeparator: ".",
})
},
[currencyInfo]
)
const [localValue, setLocalValue] = useState<string | number>(value || "")
const handleValueChange: CurrencyInputProps["onValueChange"] = (
value,
_name,
_values
) => {
if (!value) {
setLocalValue("")
return
}
setLocalValue(value)
}
useEffect(() => {
let update = value
// The component we use is a bit fidly when the value is updated externally
// so we need to ensure a format that will result in the cell being formatted correctly
// according to the users locale on the next render.
if (!isNaN(Number(value))) {
update = formatter(update)
}
setLocalValue(update)
}, [value, formatter])
const combinedRed = useCombinedRefs(inputRef, ref)
return (
<div className="relative flex size-full items-center">
<span
className="txt-compact-small text-ui-fg-muted pointer-events-none absolute left-0 w-fit min-w-4"
aria-hidden
>
{currencyInfo.symbol_native}
</span>
<CurrencyInput
{...rest}
{...attributes}
ref={combinedRed}
className="txt-compact-small w-full flex-1 cursor-default appearance-none bg-transparent pl-8 text-right outline-none"
value={localValue || undefined}
onValueChange={handleValueChange}
formatValueOnBlur
onBlur={() => {
onBlur()
onInputBlur()
onChange(localValue, value)
}}
onFocus={onFocus}
decimalScale={currencyInfo.decimal_digits}
decimalsLimit={currencyInfo.decimal_digits}
autoComplete="off"
tabIndex={-1}
/>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { ReactNode } from "react"
import { useDataGridDuplicateCell } from "../hooks"
interface DataGridDuplicateCellProps<TValue> {
duplicateOf: string
children?: ReactNode | ((props: { value: TValue }) => ReactNode)
}
export const DataGridDuplicateCell = <TValue,>({
duplicateOf,
children,
}: DataGridDuplicateCellProps<TValue>) => {
const { watchedValue } = useDataGridDuplicateCell({ duplicateOf })
return (
<div className="bg-ui-bg-base txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center justify-between overflow-hidden px-4 py-2.5 outline-none">
{typeof children === "function"
? children({ value: watchedValue })
: children}
</div>
)
}

View File

@@ -0,0 +1,245 @@
import { XMark } from "@medusajs/icons"
import {
Button,
clx,
Heading,
IconButton,
Input,
Kbd,
Text,
} from "@medusajs/ui"
import { Dialog as RadixDialog } from "radix-ui"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
const useDataGridShortcuts = () => {
const { t } = useTranslation()
const shortcuts = useMemo(
() => [
{
label: t("dataGrid.shortcuts.commands.undo"),
keys: {
Mac: ["⌘", "Z"],
Windows: ["Ctrl", "Z"],
},
},
{
label: t("dataGrid.shortcuts.commands.redo"),
keys: {
Mac: ["⇧", "⌘", "Z"],
Windows: ["Shift", "Ctrl", "Z"],
},
},
{
label: t("dataGrid.shortcuts.commands.copy"),
keys: {
Mac: ["⌘", "C"],
Windows: ["Ctrl", "C"],
},
},
{
label: t("dataGrid.shortcuts.commands.paste"),
keys: {
Mac: ["⌘", "V"],
Windows: ["Ctrl", "V"],
},
},
{
label: t("dataGrid.shortcuts.commands.edit"),
keys: {
Mac: ["↵"],
Windows: ["Enter"],
},
},
{
label: t("dataGrid.shortcuts.commands.delete"),
keys: {
Mac: ["⌫"],
Windows: ["Backspace"],
},
},
{
label: t("dataGrid.shortcuts.commands.clear"),
keys: {
Mac: ["Space"],
Windows: ["Space"],
},
},
{
label: t("dataGrid.shortcuts.commands.moveUp"),
keys: {
Mac: ["↑"],
Windows: ["↑"],
},
},
{
label: t("dataGrid.shortcuts.commands.moveDown"),
keys: {
Mac: ["↓"],
Windows: ["↓"],
},
},
{
label: t("dataGrid.shortcuts.commands.moveLeft"),
keys: {
Mac: ["←"],
Windows: ["←"],
},
},
{
label: t("dataGrid.shortcuts.commands.moveRight"),
keys: {
Mac: ["→"],
Windows: ["→"],
},
},
{
label: t("dataGrid.shortcuts.commands.moveTop"),
keys: {
Mac: ["⌘", "↑"],
Windows: ["Ctrl", "↑"],
},
},
{
label: t("dataGrid.shortcuts.commands.moveBottom"),
keys: {
Mac: ["⌘", "↓"],
Windows: ["Ctrl", "↓"],
},
},
{
label: t("dataGrid.shortcuts.commands.selectDown"),
keys: {
Mac: ["⇧", "↓"],
Windows: ["Shift", "↓"],
},
},
{
label: t("dataGrid.shortcuts.commands.selectUp"),
keys: {
Mac: ["⇧", "↑"],
Windows: ["Shift", "↑"],
},
},
{
label: t("dataGrid.shortcuts.commands.selectColumnDown"),
keys: {
Mac: ["⇧", "⌘", "↓"],
Windows: ["Shift", "Ctrl", "↓"],
},
},
{
label: t("dataGrid.shortcuts.commands.selectColumnUp"),
keys: {
Mac: ["⇧", "⌘", "↑"],
Windows: ["Shift", "Ctrl", "↑"],
},
},
{
label: t("dataGrid.shortcuts.commands.focusToolbar"),
keys: {
Mac: ["⌃", "⌥", ","],
Windows: ["Ctrl", "Alt", ","],
},
},
{
label: t("dataGrid.shortcuts.commands.focusCancel"),
keys: {
Mac: ["⌃", "⌥", "."],
Windows: ["Ctrl", "Alt", "."],
},
},
],
[t]
)
return shortcuts
}
type DataGridKeyboardShortcutModalProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export const DataGridKeyboardShortcutModal = ({
open,
onOpenChange,
}: DataGridKeyboardShortcutModalProps) => {
const { t } = useTranslation()
const [searchValue, onSearchValueChange] = useState("")
const shortcuts = useDataGridShortcuts()
const searchResults = useMemo(() => {
return shortcuts.filter((shortcut) =>
shortcut.label.toLowerCase().includes(searchValue.toLowerCase())
)
}, [searchValue, shortcuts])
return (
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
<RadixDialog.Trigger asChild>
<Button size="small" variant="secondary">
{t("dataGrid.shortcuts.label")}
</Button>
</RadixDialog.Trigger>
<RadixDialog.Portal>
<RadixDialog.Overlay
className={clx(
"bg-ui-bg-overlay fixed inset-0",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
)}
/>
<RadixDialog.Content className="bg-ui-bg-subtle shadow-elevation-modal fixed left-[50%] top-[50%] flex h-full max-h-[612px] w-full max-w-[560px] translate-x-[-50%] translate-y-[-50%] flex-col divide-y overflow-hidden rounded-lg outline-none">
<div className="flex flex-col gap-y-3 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<RadixDialog.Title asChild>
<Heading>{t("app.menus.user.shortcuts")}</Heading>
</RadixDialog.Title>
<RadixDialog.Description className="sr-only"></RadixDialog.Description>
</div>
<div className="flex items-center gap-x-2">
<Kbd>esc</Kbd>
<RadixDialog.Close asChild>
<IconButton variant="transparent" size="small">
<XMark />
</IconButton>
</RadixDialog.Close>
</div>
</div>
<div>
<Input
type="search"
value={searchValue}
autoFocus
onChange={(e) => onSearchValueChange(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col divide-y overflow-y-auto">
{searchResults.map((shortcut, index) => {
return (
<div
key={index}
className="text-ui-fg-subtle flex items-center justify-between px-6 py-3"
>
<Text size="small">{shortcut.label}</Text>
<div className="flex items-center gap-x-1">
{shortcut.keys.Mac?.map((key, index) => {
return (
<div className="flex items-center gap-x-1" key={index}>
<Kbd>{key}</Kbd>
</div>
)
})}
</div>
</div>
)
})}
</div>
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
)
}

View File

@@ -0,0 +1,94 @@
import { clx } from "@medusajs/ui"
import { useEffect, useState } from "react"
import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { useDataGridCell, useDataGridCellError } from "../hooks"
import { DataGridCellProps, InputProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridNumberCell = <TData, TValue = any>({
context,
...rest
}: DataGridCellProps<TData, TValue> & {
min?: number
max?: number
placeholder?: string
}) => {
const { field, control, renderProps } = useDataGridCell({
context,
})
const errorProps = useDataGridCellError({ context })
const { container, input } = renderProps
return (
<Controller
control={control}
name={field}
render={({ field }) => {
return (
<DataGridCellContainer {...container} {...errorProps}>
<Inner field={field} inputProps={input} {...rest} />
</DataGridCellContainer>
)
}}
/>
)
}
const Inner = ({
field,
inputProps,
...props
}: {
field: ControllerRenderProps<any, string>
inputProps: InputProps
min?: number
max?: number
placeholder?: string
}) => {
const { ref, value, onChange: _, onBlur, ...fieldProps } = field
const {
ref: inputRef,
onChange,
onBlur: onInputBlur,
onFocus,
...attributes
} = inputProps
const [localValue, setLocalValue] = useState(value)
useEffect(() => {
setLocalValue(value)
}, [value])
const combinedRefs = useCombinedRefs(inputRef, ref)
return (
<div className="size-full">
<input
ref={combinedRefs}
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={() => {
onBlur()
onInputBlur()
// We propagate the change to the field only when the input is blurred
onChange(localValue, value)
}}
onFocus={onFocus}
type="number"
inputMode="decimal"
className={clx(
"txt-compact-small size-full bg-transparent outline-none",
"placeholder:text-ui-fg-muted"
)}
tabIndex={-1}
{...props}
{...fieldProps}
{...attributes}
/>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { PropsWithChildren } from "react"
import { clx } from "@medusajs/ui"
import { useDataGridCellError } from "../hooks"
import { DataGridCellProps } from "../types"
import { DataGridRowErrorIndicator } from "./data-grid-row-error-indicator"
type DataGridReadonlyCellProps<TData, TValue = any> = PropsWithChildren<
DataGridCellProps<TData, TValue>
> & {
color?: "muted" | "normal"
}
export const DataGridReadonlyCell = <TData, TValue = any>({
context,
color = "muted",
children,
}: DataGridReadonlyCellProps<TData, TValue>) => {
const { rowErrors } = useDataGridCellError({ context })
return (
<div
className={clx(
"txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center justify-between overflow-hidden px-4 py-2.5 outline-none",
color === "muted" && "bg-ui-bg-subtle",
color === "normal" && "bg-ui-bg-base"
)}
>
<div className="flex-1 truncate">{children}</div>
<DataGridRowErrorIndicator rowErrors={rowErrors} />
</div>
)
}

View File

@@ -0,0 +1,939 @@
import {
Adjustments,
AdjustmentsDone,
ExclamationCircle,
} from "@medusajs/icons"
import { Button, DropdownMenu, clx } from "@medusajs/ui"
import {
Cell,
CellContext,
Column,
ColumnDef,
Row,
VisibilityState,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { VirtualItem, useVirtualizer } from "@tanstack/react-virtual"
import React, {
CSSProperties,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { FieldValues, UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useCommandHistory } from "../../../hooks/use-command-history"
import { ConditionalTooltip } from "../../common/conditional-tooltip"
import { DataGridContext } from "../context"
import {
useDataGridCellHandlers,
useDataGridCellMetadata,
useDataGridCellSnapshot,
useDataGridClipboardEvents,
useDataGridColumnVisibility,
useDataGridErrorHighlighting,
useDataGridFormHandlers,
useDataGridKeydownEvent,
useDataGridMouseUpEvent,
useDataGridNavigation,
useDataGridQueryTool,
} from "../hooks"
import { DataGridMatrix } from "../models"
import { DataGridCoordinates, GridColumnOption } from "../types"
import { isCellMatch, isSpecialFocusKey } from "../utils"
import { DataGridKeyboardShortcutModal } from "./data-grid-keyboard-shortcut-modal"
export interface DataGridRootProps<
TData,
TFieldValues extends FieldValues = FieldValues,
> {
data?: TData[]
columns: ColumnDef<TData>[]
state: UseFormReturn<TFieldValues>
getSubRows?: (row: TData) => TData[] | undefined
onEditingChange?: (isEditing: boolean) => void
disableInteractions?: boolean
multiColumnSelection?: boolean
}
const ROW_HEIGHT = 40
const getCommonPinningStyles = <TData,>(
column: Column<TData>
): CSSProperties => {
const isPinned = column.getIsPinned()
/**
* Since our border colors are semi-transparent, we need to set a custom border color
* that looks the same as the actual border color, but has 100% opacity.
*
* We do this by checking if the current theme is dark mode, and then setting the border color
* to the corresponding color.
*/
const isDarkMode = document.documentElement.classList.contains("dark")
const BORDER_COLOR = isDarkMode ? "rgb(50,50,53)" : "rgb(228,228,231)"
return {
position: isPinned ? "sticky" : "relative",
width: column.getSize(),
zIndex: isPinned ? 1 : 0,
borderBottom: isPinned ? `1px solid ${BORDER_COLOR}` : undefined,
borderRight: isPinned ? `1px solid ${BORDER_COLOR}` : undefined,
left: isPinned === "left" ? `${column.getStart("left")}px` : undefined,
right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined,
}
}
/**
* TODO:
* - [Minor] Extend the commands to also support modifying the anchor and rangeEnd, to restore the previous focus after undo/redo.
*/
export const DataGridRoot = <
TData,
TFieldValues extends FieldValues = FieldValues,
>({
data = [],
columns,
state,
getSubRows,
onEditingChange,
disableInteractions,
multiColumnSelection = false,
}: DataGridRootProps<TData, TFieldValues>) => {
const containerRef = useRef<HTMLDivElement>(null)
const { redo, undo, execute } = useCommandHistory()
const {
register,
control,
getValues,
setValue,
formState: { errors },
} = state
const [internalTrapActive, setTrapActive] = useState(true)
const trapActive = !disableInteractions && internalTrapActive
const [anchor, setAnchor] = useState<DataGridCoordinates | null>(null)
const [rangeEnd, setRangeEnd] = useState<DataGridCoordinates | null>(null)
const [dragEnd, setDragEnd] = useState<DataGridCoordinates | null>(null)
const [isSelecting, setIsSelecting] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowVisibility, setRowVisibility] = useState<VisibilityState>({})
const grid = useReactTable({
data: data,
columns,
initialState: {
columnPinning: {
left: [columns[0].id!],
},
},
state: {
columnVisibility,
},
onColumnVisibilityChange: setColumnVisibility,
getSubRows,
getCoreRowModel: getCoreRowModel(),
defaultColumn: {
size: 200,
maxSize: 400,
},
})
const { flatRows } = grid.getRowModel()
const flatColumns = grid.getAllFlatColumns()
const visibleRows = useMemo(
() => flatRows.filter((_, index) => rowVisibility?.[index] !== false),
[flatRows, rowVisibility]
)
const visibleColumns = grid.getVisibleLeafColumns()
const rowVirtualizer = useVirtualizer({
count: visibleRows.length,
estimateSize: () => ROW_HEIGHT,
getScrollElement: () => containerRef.current,
overscan: 5,
rangeExtractor: (range) => {
const toRender = new Set(
Array.from(
{ length: range.endIndex - range.startIndex + 1 },
(_, i) => range.startIndex + i
)
)
if (anchor && visibleRows[anchor.row]) {
toRender.add(anchor.row)
}
if (rangeEnd && visibleRows[rangeEnd.row]) {
toRender.add(rangeEnd.row)
}
return Array.from(toRender).sort((a, b) => a - b)
},
})
const virtualRows = rowVirtualizer.getVirtualItems()
const columnVirtualizer = useVirtualizer({
count: visibleColumns.length,
estimateSize: (index) => visibleColumns[index].getSize(),
getScrollElement: () => containerRef.current,
horizontal: true,
overscan: 3,
rangeExtractor: (range) => {
const startIndex = range.startIndex
const endIndex = range.endIndex
const toRender = new Set(
Array.from(
{ length: endIndex - startIndex + 1 },
(_, i) => startIndex + i
)
)
if (anchor && visibleColumns[anchor.col]) {
toRender.add(anchor.col)
}
if (rangeEnd && visibleColumns[rangeEnd.col]) {
toRender.add(rangeEnd.col)
}
// The first column is pinned, so we always render it
toRender.add(0)
return Array.from(toRender).sort((a, b) => a - b)
},
})
const virtualColumns = columnVirtualizer.getVirtualItems()
let virtualPaddingLeft: number | undefined
let virtualPaddingRight: number | undefined
if (columnVirtualizer && virtualColumns?.length) {
virtualPaddingLeft = virtualColumns[0]?.start ?? 0
virtualPaddingRight =
columnVirtualizer.getTotalSize() -
(virtualColumns[virtualColumns.length - 1]?.end ?? 0)
}
const matrix = useMemo(
() =>
new DataGridMatrix<TData, TFieldValues>(
flatRows,
columns,
multiColumnSelection
),
[flatRows, columns, multiColumnSelection]
)
const queryTool = useDataGridQueryTool(containerRef)
const setSingleRange = useCallback(
(coordinates: DataGridCoordinates | null) => {
setAnchor(coordinates)
setRangeEnd(coordinates)
},
[]
)
const { errorCount, isHighlighted, toggleErrorHighlighting } =
useDataGridErrorHighlighting(matrix, grid, errors)
const handleToggleErrorHighlighting = useCallback(() => {
toggleErrorHighlighting(
rowVisibility,
columnVisibility,
setRowVisibility,
setColumnVisibility
)
}, [toggleErrorHighlighting, rowVisibility, columnVisibility])
const {
columnOptions,
handleToggleColumn,
handleResetColumns,
isDisabled: isColumsDisabled,
} = useDataGridColumnVisibility(grid, matrix)
const handleToggleColumnVisibility = useCallback(
(index: number) => {
return handleToggleColumn(index)
},
[handleToggleColumn]
)
const { navigateToField, scrollToCoordinates } = useDataGridNavigation<
TData,
TFieldValues
>({
matrix,
queryTool,
anchor,
columnVirtualizer,
rowVirtualizer,
flatColumns,
setColumnVisibility,
setSingleRange,
visibleColumns,
visibleRows,
})
const { createSnapshot, restoreSnapshot } = useDataGridCellSnapshot<
TData,
TFieldValues
>({
matrix,
form: state,
})
const onEditingChangeHandler = useCallback(
(value: boolean) => {
if (onEditingChange) {
onEditingChange(value)
}
if (value) {
createSnapshot(anchor)
}
setIsEditing(value)
},
[anchor, createSnapshot, onEditingChange]
)
const { getSelectionValues, setSelectionValues } = useDataGridFormHandlers<
TData,
TFieldValues
>({
matrix,
form: state,
anchor,
})
const { handleKeyDownEvent, handleSpecialFocusKeys } =
useDataGridKeydownEvent<TData, TFieldValues>({
containerRef,
matrix,
queryTool,
anchor,
rangeEnd,
isEditing,
setTrapActive,
setRangeEnd,
getSelectionValues,
getValues,
setSelectionValues,
onEditingChangeHandler,
restoreSnapshot,
createSnapshot,
setSingleRange,
scrollToCoordinates,
execute,
undo,
redo,
setValue,
})
const { handleMouseUpEvent } = useDataGridMouseUpEvent<TData, TFieldValues>({
matrix,
anchor,
dragEnd,
setDragEnd,
isDragging,
setIsDragging,
setRangeEnd,
setIsSelecting,
getSelectionValues,
setSelectionValues,
execute,
})
const { handleCopyEvent, handlePasteEvent } = useDataGridClipboardEvents<
TData,
TFieldValues
>({
matrix,
isEditing,
anchor,
rangeEnd,
getSelectionValues,
setSelectionValues,
execute,
})
const {
getWrapperFocusHandler,
getInputChangeHandler,
getOverlayMouseDownHandler,
getWrapperMouseOverHandler,
getIsCellDragSelected,
getIsCellSelected,
onDragToFillStart,
} = useDataGridCellHandlers<TData, TFieldValues>({
matrix,
anchor,
rangeEnd,
setRangeEnd,
isDragging,
setIsDragging,
isSelecting,
setIsSelecting,
setSingleRange,
dragEnd,
setDragEnd,
setValue,
execute,
multiColumnSelection,
})
const { getCellErrorMetadata, getCellMetadata } = useDataGridCellMetadata<
TData,
TFieldValues
>({
matrix,
})
/** Effects */
/**
* Register all handlers for the grid.
*/
useEffect(() => {
if (!trapActive) {
return
}
window.addEventListener("keydown", handleKeyDownEvent)
window.addEventListener("mouseup", handleMouseUpEvent)
// Copy and paste event listeners need to be added to the window
window.addEventListener("copy", handleCopyEvent)
window.addEventListener("paste", handlePasteEvent)
return () => {
window.removeEventListener("keydown", handleKeyDownEvent)
window.removeEventListener("mouseup", handleMouseUpEvent)
window.removeEventListener("copy", handleCopyEvent)
window.removeEventListener("paste", handlePasteEvent)
}
}, [
trapActive,
handleKeyDownEvent,
handleMouseUpEvent,
handleCopyEvent,
handlePasteEvent,
])
useEffect(() => {
const specialFocusHandler = (e: KeyboardEvent) => {
if (isSpecialFocusKey(e)) {
handleSpecialFocusKeys(e)
return
}
}
window.addEventListener("keydown", specialFocusHandler)
return () => {
window.removeEventListener("keydown", specialFocusHandler)
}
}, [handleSpecialFocusKeys])
const handleHeaderInteractionChange = useCallback((isActive: boolean) => {
if (isActive) {
setTrapActive(false)
}
}, [])
/**
* Auto corrective effect for ensuring we always
* have a range end.
*/
useEffect(() => {
if (!anchor) {
return
}
if (rangeEnd) {
return
}
setRangeEnd(anchor)
}, [anchor, rangeEnd])
/**
* Ensure that we set a anchor on first render.
*/
useEffect(() => {
if (!anchor && matrix) {
const coords = matrix.getFirstNavigableCell()
if (coords) {
setSingleRange(coords)
}
}
}, [anchor, matrix, setSingleRange])
const values = useMemo(
() => ({
anchor,
control,
trapActive,
errors,
setTrapActive,
setIsSelecting,
setIsEditing: onEditingChangeHandler,
setSingleRange,
setRangeEnd,
getWrapperFocusHandler,
getInputChangeHandler,
getOverlayMouseDownHandler,
getWrapperMouseOverHandler,
register,
getIsCellSelected,
getIsCellDragSelected,
getCellMetadata,
getCellErrorMetadata,
navigateToField,
}),
[
anchor,
control,
trapActive,
errors,
setTrapActive,
setIsSelecting,
onEditingChangeHandler,
setSingleRange,
setRangeEnd,
getWrapperFocusHandler,
getInputChangeHandler,
getOverlayMouseDownHandler,
getWrapperMouseOverHandler,
register,
getIsCellSelected,
getIsCellDragSelected,
getCellMetadata,
getCellErrorMetadata,
navigateToField,
]
)
const handleRestoreGridFocus = useCallback(() => {
if (anchor && !trapActive) {
setTrapActive(true)
setSingleRange(anchor)
scrollToCoordinates(anchor, "both")
requestAnimationFrame(() => {
queryTool?.getContainer(anchor)?.focus()
})
}
}, [anchor, trapActive, setSingleRange, scrollToCoordinates, queryTool])
return (
<DataGridContext.Provider value={values}>
<div className="bg-ui-bg-subtle flex size-full flex-col">
<DataGridHeader
columnOptions={columnOptions}
isDisabled={isColumsDisabled}
onToggleColumn={handleToggleColumnVisibility}
errorCount={errorCount}
onToggleErrorHighlighting={handleToggleErrorHighlighting}
onResetColumns={handleResetColumns}
isHighlighted={isHighlighted}
onHeaderInteractionChange={handleHeaderInteractionChange}
/>
<div className="size-full overflow-hidden">
<div
ref={containerRef}
autoFocus
tabIndex={0}
className="relative h-full select-none overflow-auto outline-none"
onFocus={handleRestoreGridFocus}
onClick={handleRestoreGridFocus}
data-container={true}
role="application"
>
<div role="grid" className="text-ui-fg-subtle grid">
<div
role="rowgroup"
className="txt-compact-small-plus bg-ui-bg-subtle sticky top-0 z-[1] grid"
>
{grid.getHeaderGroups().map((headerGroup) => (
<div
role="row"
key={headerGroup.id}
className="flex h-10 w-full"
>
{virtualPaddingLeft ? (
<div
role="presentation"
style={{ display: "flex", width: virtualPaddingLeft }}
/>
) : null}
{virtualColumns.reduce((acc, vc, index, array) => {
const header = headerGroup.headers[vc.index]
const previousVC = array[index - 1]
if (previousVC && vc.index !== previousVC.index + 1) {
// If there's a gap between the current and previous virtual columns
acc.push(
<div
key={`padding-${previousVC.index}-${vc.index}`}
role="presentation"
style={{
display: "flex",
width: `${vc.start - previousVC.end}px`,
}}
/>
)
}
acc.push(
<div
key={header.id}
role="columnheader"
data-column-index={vc.index}
style={{
width: header.getSize(),
...getCommonPinningStyles(header.column),
}}
className="bg-ui-bg-base txt-compact-small-plus flex items-center border-b border-r px-4 py-2.5"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
)
return acc
}, [] as ReactNode[])}
{virtualPaddingRight ? (
<div
role="presentation"
style={{
display: "flex",
width: virtualPaddingRight,
}}
/>
) : null}
</div>
))}
</div>
<div
role="rowgroup"
className="relative grid"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{virtualRows.map((virtualRow) => {
const row = visibleRows[virtualRow.index] as Row<TData>
const rowIndex = flatRows.findIndex((r) => r.id === row.id)
return (
<DataGridRow
key={row.id}
row={row}
rowIndex={rowIndex}
virtualRow={virtualRow}
flatColumns={flatColumns}
virtualColumns={virtualColumns}
anchor={anchor}
virtualPaddingLeft={virtualPaddingLeft}
virtualPaddingRight={virtualPaddingRight}
onDragToFillStart={onDragToFillStart}
multiColumnSelection={multiColumnSelection}
/>
)
})}
</div>
</div>
</div>
</div>
</div>
</DataGridContext.Provider>
)
}
type DataGridHeaderProps = {
columnOptions: GridColumnOption[]
isDisabled: boolean
onToggleColumn: (index: number) => (value: boolean) => void
onResetColumns: () => void
isHighlighted: boolean
errorCount: number
onToggleErrorHighlighting: () => void
onHeaderInteractionChange: (isActive: boolean) => void
}
const DataGridHeader = ({
columnOptions,
isDisabled,
onToggleColumn,
onResetColumns,
isHighlighted,
errorCount,
onToggleErrorHighlighting,
onHeaderInteractionChange,
}: DataGridHeaderProps) => {
const [shortcutsOpen, setShortcutsOpen] = useState(false)
const [columnsOpen, setColumnsOpen] = useState(false)
const { t } = useTranslation()
// Since all columns are checked by default, we can check if any column is unchecked
const hasChanged = columnOptions.some((column) => !column.checked)
const handleShortcutsOpenChange = (value: boolean) => {
onHeaderInteractionChange(value)
setShortcutsOpen(value)
}
const handleColumnsOpenChange = (value: boolean) => {
onHeaderInteractionChange(value)
setColumnsOpen(value)
}
return (
<div className="bg-ui-bg-base flex items-center justify-between border-b p-4">
<div className="flex items-center gap-x-2">
<DropdownMenu open={columnsOpen} onOpenChange={handleColumnsOpenChange}>
<ConditionalTooltip
showTooltip={isDisabled}
content={t("dataGrid.columns.disabled")}
>
<DropdownMenu.Trigger asChild disabled={isDisabled}>
<Button size="small" variant="secondary">
{hasChanged ? <AdjustmentsDone /> : <Adjustments />}
{t("dataGrid.columns.view")}
</Button>
</DropdownMenu.Trigger>
</ConditionalTooltip>
<DropdownMenu.Content>
{columnOptions.map((column, index) => {
const { checked, disabled, id, name } = column
if (disabled) {
return null
}
return (
<DropdownMenu.CheckboxItem
key={id}
checked={checked}
onCheckedChange={onToggleColumn(index)}
onSelect={(e) => e.preventDefault()}
>
{name}
</DropdownMenu.CheckboxItem>
)
})}
</DropdownMenu.Content>
</DropdownMenu>
{hasChanged && (
<Button
size="small"
variant="transparent"
type="button"
onClick={onResetColumns}
className="text-ui-fg-muted hover:text-ui-fg-subtle"
data-id="reset-columns"
>
{t("dataGrid.columns.resetToDefault")}
</Button>
)}
</div>
<div className="flex items-center gap-x-2">
{errorCount > 0 && (
<Button
size="small"
variant="secondary"
type="button"
onClick={onToggleErrorHighlighting}
className={clx({
"bg-ui-button-neutral-pressed": isHighlighted,
})}
>
<ExclamationCircle className="text-ui-fg-subtle" />
<span>
{t("dataGrid.errors.count", {
count: errorCount,
})}
</span>
</Button>
)}
<DataGridKeyboardShortcutModal
open={shortcutsOpen}
onOpenChange={handleShortcutsOpenChange}
/>
</div>
</div>
)
}
type DataGridCellProps<TData> = {
cell: Cell<TData, unknown>
columnIndex: number
rowIndex: number
anchor: DataGridCoordinates | null
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
multiColumnSelection: boolean
}
const DataGridCell = <TData,>({
cell,
columnIndex,
rowIndex,
anchor,
onDragToFillStart,
multiColumnSelection,
}: DataGridCellProps<TData>) => {
const coords: DataGridCoordinates = {
row: rowIndex,
col: columnIndex,
}
const isAnchor = isCellMatch(coords, anchor)
return (
<div
role="gridcell"
aria-rowindex={rowIndex}
aria-colindex={columnIndex}
style={{
width: cell.column.getSize(),
...getCommonPinningStyles(cell.column),
}}
data-row-index={rowIndex}
data-column-index={columnIndex}
className={clx(
"relative flex items-center border-b border-r p-0 outline-none"
)}
tabIndex={-1}
>
<div className="relative h-full w-full">
{flexRender(cell.column.columnDef.cell, {
...cell.getContext(),
columnIndex,
rowIndex: rowIndex,
} as CellContext<TData, any>)}
{isAnchor && (
<div
onMouseDown={onDragToFillStart}
className={clx(
"bg-ui-fg-interactive absolute bottom-0 right-0 z-[3] size-1.5 cursor-ns-resize",
{
"cursor-nwse-resize": multiColumnSelection,
}
)}
/>
)}
</div>
</div>
)
}
type DataGridRowProps<TData> = {
row: Row<TData>
rowIndex: number
virtualRow: VirtualItem<Element>
virtualPaddingLeft?: number
virtualPaddingRight?: number
virtualColumns: VirtualItem<Element>[]
flatColumns: Column<TData, unknown>[]
anchor: DataGridCoordinates | null
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
multiColumnSelection: boolean
}
const DataGridRow = <TData,>({
row,
rowIndex,
virtualRow,
virtualPaddingLeft,
virtualPaddingRight,
virtualColumns,
flatColumns,
anchor,
onDragToFillStart,
multiColumnSelection,
}: DataGridRowProps<TData>) => {
const visibleCells = row.getVisibleCells()
return (
<div
role="row"
aria-rowindex={virtualRow.index}
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
>
{virtualPaddingLeft ? (
<div
role="presentation"
style={{ display: "flex", width: virtualPaddingLeft }}
/>
) : null}
{virtualColumns.reduce((acc, vc, index, array) => {
const cell = visibleCells[vc.index]
const column = cell.column
const columnIndex = flatColumns.findIndex((c) => c.id === column.id)
const previousVC = array[index - 1]
if (previousVC && vc.index !== previousVC.index + 1) {
// If there's a gap between the current and previous virtual columns
acc.push(
<div
key={`padding-${previousVC.index}-${vc.index}`}
role="presentation"
style={{
display: "flex",
width: `${vc.start - previousVC.end}px`,
}}
/>
)
}
acc.push(
<DataGridCell
key={cell.id}
cell={cell}
columnIndex={columnIndex}
rowIndex={rowIndex}
anchor={anchor}
onDragToFillStart={onDragToFillStart}
multiColumnSelection={multiColumnSelection}
/>
)
return acc
}, [] as ReactNode[])}
{virtualPaddingRight ? (
<div
role="presentation"
style={{ display: "flex", width: virtualPaddingRight }}
/>
) : null}
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { Badge, Tooltip } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { DataGridRowError } from "../types"
type DataGridRowErrorIndicatorProps = {
rowErrors: DataGridRowError[]
}
export const DataGridRowErrorIndicator = ({
rowErrors,
}: DataGridRowErrorIndicatorProps) => {
const rowErrorCount = rowErrors ? rowErrors.length : 0
if (!rowErrors || rowErrorCount <= 0) {
return null
}
return (
<Tooltip
content={
<ul className="flex flex-col gap-y-3">
{rowErrors.map((error, index) => (
<DataGridRowErrorLine key={index} error={error} />
))}
</ul>
}
delayDuration={0}
>
<Badge color="red" size="2xsmall" className="cursor-default">
{rowErrorCount}
</Badge>
</Tooltip>
)
}
const DataGridRowErrorLine = ({
error,
}: {
error: { message: string; to: () => void }
}) => {
const { t } = useTranslation()
return (
<li className="txt-compact-small flex flex-col items-start">
{error.message}
<button
type="button"
onClick={error.to}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover transition-fg"
>
{t("dataGrid.errors.fixError")}
</button>
</li>
)
}

View File

@@ -0,0 +1,55 @@
// Not currently used, re-implement or delete depending on whether there is a need for it in the future.
// import { Select, clx } from "@medusajs/ui"
// import { Controller } from "react-hook-form"
// import { useDataGridCell } from "../hooks"
// import { DataGridCellProps } from "../types"
// import { DataGridCellContainer } from "./data-grid-cell-container"
// interface DataGridSelectCellProps<TData, TValue = any>
// extends DataGridCellProps<TData, TValue> {
// options: { label: string; value: string }[]
// }
// export const DataGridSelectCell = <TData, TValue = any>({
// context,
// options,
// field,
// }: DataGridSelectCellProps<TData, TValue>) => {
// const { control, attributes, container } = useDataGridCell({
// field,
// context,
// })
// return (
// <Controller
// control={control}
// name={field}
// render={({ field: { onChange, ref, ...field } }) => {
// return (
// <DataGridCellContainer {...container}>
// <Select {...field} onValueChange={onChange}>
// <Select.Trigger
// {...attributes}
// ref={ref}
// className={clx(
// "h-full w-full rounded-none bg-transparent px-4 py-2.5 shadow-none",
// "hover:bg-transparent focus:shadow-none data-[state=open]:!shadow-none"
// )}
// >
// <Select.Value />
// </Select.Trigger>
// <Select.Content>
// {options.map((option) => (
// <Select.Item key={option.value} value={option.value}>
// {option.label}
// </Select.Item>
// ))}
// </Select.Content>
// </Select>
// </DataGridCellContainer>
// )
// }}
// />
// )
// }

View File

@@ -0,0 +1,63 @@
import { ColumnDef } from "@tanstack/react-table"
import { Skeleton } from "../../common/skeleton"
type DataGridSkeletonProps<TData> = {
columns: ColumnDef<TData>[]
rows?: number
}
export const DataGridSkeleton = <TData,>({
columns,
rows: rowCount = 10,
}: DataGridSkeletonProps<TData>) => {
const rows = Array.from({ length: rowCount }, (_, i) => i)
const colCount = columns.length
return (
<div className="bg-ui-bg-subtle size-full">
<div className="bg-ui-bg-base border-b p-4">
<div className="bg-ui-button-neutral h-7 w-[116px] animate-pulse rounded-md" />
</div>
<div className="bg-ui-bg-subtle size-full overflow-auto">
<div
className="grid"
style={{
gridTemplateColumns: `repeat(${colCount}, 1fr)`,
}}
>
{columns.map((_col, i) => {
return (
<div
key={i}
className="bg-ui-bg-base flex h-10 w-[200px] items-center border-b border-r px-4 py-2.5 last:border-r-0"
>
<Skeleton className="h-[14px] w-[164px]" />
</div>
)
})}
</div>
<div>
{rows.map((_, j) => (
<div
className="grid"
style={{ gridTemplateColumns: `repeat(${colCount}, 1fr)` }}
key={j}
>
{columns.map((_col, k) => {
return (
<div
key={k}
className="bg-ui-bg-base flex h-10 w-[200px] items-center border-b border-r px-4 py-2.5 last:border-r-0"
>
<Skeleton className="h-[14px] w-[164px]" />
</div>
)
})}
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,75 @@
import { clx } from "@medusajs/ui"
import { useEffect, useState } from "react"
import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { useDataGridCell, useDataGridCellError } from "../hooks"
import { DataGridCellProps, InputProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridTextCell = <TData, TValue = any>({
context,
}: DataGridCellProps<TData, TValue>) => {
const { field, control, renderProps } = useDataGridCell({
context,
})
const errorProps = useDataGridCellError({ context })
const { container, input } = renderProps
return (
<Controller
control={control}
name={field}
render={({ field }) => {
return (
<DataGridCellContainer {...container} {...errorProps}>
<Inner field={field} inputProps={input} />
</DataGridCellContainer>
)
}}
/>
)
}
const Inner = ({
field,
inputProps,
}: {
field: ControllerRenderProps<any, string>
inputProps: InputProps
}) => {
const { onChange: _, onBlur, ref, value, ...rest } = field
const { ref: inputRef, onBlur: onInputBlur, onChange, ...input } = inputProps
const [localValue, setLocalValue] = useState(value)
useEffect(() => {
setLocalValue(value)
}, [value])
const combinedRefs = useCombinedRefs(inputRef, ref)
return (
<input
className={clx(
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer items-center justify-center bg-transparent outline-none",
"focus:cursor-text"
)}
autoComplete="off"
tabIndex={-1}
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
ref={combinedRefs}
onBlur={() => {
onBlur()
onInputBlur()
// We propagate the change to the field only when the input is blurred
onChange(localValue, value)
}}
{...input}
{...rest}
/>
)
}

Some files were not shown because too many files have changed in this diff Show More