feat(dashboard, medusa, medusa-js, medusa-react, icons): DataGrid, partial Product domain, and ProductVariant hook (#6428)
The PR for the Products section is growing quite large, so I would like to merge this PR that contains a lot of the ground work before moving onto finalizing the rest of the domain. **Note** Since the PR contains changes to the core, that the dashboard depends on, the staging env will not work. To preview this PR, you will need to run it locally. ## `@medusajs/medusa` **What** - Adds missing query params to `GET /admin/products/:id/variants` - `options.values` has been added to the default relations of admin product endpoints. ## `medusa-react` **What** - Adds missing hook for `GET /admin/products/:id/variants` ## `@medusajs/dashboard` - Adds base implementation for `DataGrid` component (formerly `BulkEditor`) (WIP) - Adds `/products` overview page - Adds partial `/products/create` page for creating new products (WIP - need to go over design w/ Ludvig before continuing) - Adds `/products/:id` details page - Adds `/products/:id/gallery` page for inspecting a products images in fullscreen. - Adds `/products/:id/edit` page for editing the general information of a product - Adds `/products/:id/attributes` page for editing the attributes information of a product - Adds `/products/:id/sales-channels` page for editing which sales channels a product is available in - Fixes a bug in `DataTable` where a table with two fixed columns would not display correctly For the review its not important to test the DataGrid, as it is still WIP, and I need to go through some minor changes to the behaviour with Ludvig, as virtualizing it adds some constraints. ## `@medusajs/icons` **What** - Pulls latest icons from Figma ## TODO in next PR - [ ] Fix the typing of POST /admin/products/:id as it is currently not possible to delete any of the nullable fields once they have been added. Be aware of this when reviewing this PR. - [ ] Wrap up `/products/create` page - [ ] Add `/products/:id/media` page for managing media associated with the product. - [ ] Add `/products/id/options` for managing product options (need Ludvig to rethink this as the current API is very limited and we can implement the current design as is.) - [ ] Add `/products/:id/variants/:id` page for editing a variant. (Possibly concat all of these into one BulkEditor page?)
This commit is contained in:
1 parent
c3e30224c7
commit
44d43e8155
131 files changed
+5741
-598
No files matched your search
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/client-types": patch
|
||||||
|
"@medusajs/icons": patch
|
||||||
|
"medusa-react": patch
|
||||||
|
"@medusajs/medusa-js": patch
|
||||||
|
"@medusajs/medusa": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(medusa,medusa-js,medusa-react,icons): Fixes GET /admin/products/:id/variants endpoint in the core, and medusa-js and medusa-react. Pulls latest icons from Figma into `@medusajs/icons`.
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"@radix-ui/react-hover-card": "^1.0.7",
|
"@radix-ui/react-hover-card": "^1.0.7",
|
||||||
"@tanstack/react-query": "4.22.0",
|
"@tanstack/react-query": "4.22.0",
|
||||||
"@tanstack/react-table": "8.10.7",
|
"@tanstack/react-table": "8.10.7",
|
||||||
|
"@tanstack/react-virtual": "^3.0.4",
|
||||||
"@uiw/react-json-view": "2.0.0-alpha.10",
|
"@uiw/react-json-view": "2.0.0-alpha.10",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.0",
|
||||||
"date-fns": "^3.2.0",
|
"date-fns": "^3.2.0",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"react-hook-form": "7.49.1",
|
"react-hook-form": "7.49.1",
|
||||||
"react-i18next": "13.5.0",
|
"react-i18next": "13.5.0",
|
||||||
"react-jwt": "^1.2.0",
|
"react-jwt": "^1.2.0",
|
||||||
|
"react-resizable-panels": "^2.0.9",
|
||||||
"react-router-dom": "6.20.1",
|
"react-router-dom": "6.20.1",
|
||||||
"zod": "3.22.4"
|
"zod": "3.22.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,14 +48,47 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"edit": "Edit"
|
"edit": "Edit",
|
||||||
|
"download": "Download"
|
||||||
|
},
|
||||||
|
"errorBoundary": {
|
||||||
|
"badRequestTitle": "Bad request",
|
||||||
|
"badRequestMessage": "The request was invalid.",
|
||||||
|
"notFoundTitle": "Not found",
|
||||||
|
"notFoundMessage": "The page you are looking for does not exist.",
|
||||||
|
"internalServerErrorTitle": "Internal server error",
|
||||||
|
"internalServerErrorMessage": "An error occurred on the server.",
|
||||||
|
"defaultTitle": "An error occurred",
|
||||||
|
"defaultMessage": "An error occurred while rendering this page."
|
||||||
},
|
},
|
||||||
"products": {
|
"products": {
|
||||||
"domain": "Products",
|
"domain": "Products",
|
||||||
|
"createProductTitle": "Create Product",
|
||||||
|
"createProductHint": "Create a new product to sell in your store.",
|
||||||
|
"deleteWarning": "You are about to delete the product {{title}}. This action cannot be undone.",
|
||||||
"variants": "Variants",
|
"variants": "Variants",
|
||||||
|
"attributes": "Attributes",
|
||||||
|
"editProduct": "Edit Product",
|
||||||
|
"editAttributes": "Edit Attributes",
|
||||||
|
"organization": "Organization",
|
||||||
|
"editOrganization": "Edit Organization",
|
||||||
|
"options": "Options",
|
||||||
|
"editOptions": "Edit Options",
|
||||||
|
"media": "Media",
|
||||||
|
"editMedia": "Edit Media",
|
||||||
|
"deleteMedia_one": "You are about to delete {{count}} media item. This action cannot be undone.",
|
||||||
|
"deleteMedia_other": "You are about to delete {{count}} media items. This action cannot be undone.",
|
||||||
|
"deleteMediaAndThumbnail_one": "You are about to delete {{count}} media item including the thumbnail. This action cannot be undone.",
|
||||||
|
"deleteMediaAndThumbnail_other": "You are about to delete {{count}} media items including the thumbnail. This action cannot be undone.",
|
||||||
|
"gallery": "Gallery",
|
||||||
|
"titleHint": "Give your product a short and clear title.<0/>50-60 characters is the recommended length for search engines.",
|
||||||
|
"descriptionHint": "Give your product a short and clear description.<0/>120-160 characters is the recommended length for search engines.",
|
||||||
|
"handleTooltip": "The handle is used to reference the product in your storefront. If not specified, the handle will be generated from the product title.",
|
||||||
"availableInSalesChannels": "Available in <0>{{x}}</0> of <1>{{y}}</1> sales channels",
|
"availableInSalesChannels": "Available in <0>{{x}}</0> of <1>{{y}}</1> sales channels",
|
||||||
|
"noSalesChannels": "Not available in any sales channels",
|
||||||
"variantCount_one": "{{count}} variant",
|
"variantCount_one": "{{count}} variant",
|
||||||
"variantCount_other": "{{count}} variants",
|
"variantCount_other": "{{count}} variants",
|
||||||
|
"deleteVariantWarning": "You are about to delete the variant {{title}}. This action cannot be undone.",
|
||||||
"productStatus": {
|
"productStatus": {
|
||||||
"draft": "Draft",
|
"draft": "Draft",
|
||||||
"published": "Published",
|
"published": "Published",
|
||||||
@@ -365,6 +398,17 @@
|
|||||||
"tag": "Tag",
|
"tag": "Tag",
|
||||||
"dateIssued": "Date issued",
|
"dateIssued": "Date issued",
|
||||||
"issuedDate": "Issued date",
|
"issuedDate": "Issued date",
|
||||||
"expiryDate": "Expiry date"
|
"expiryDate": "Expiry date",
|
||||||
|
"height": "Height",
|
||||||
|
"width": "Width",
|
||||||
|
"length": "Length",
|
||||||
|
"weight": "Weight",
|
||||||
|
"midCode": "MID Code",
|
||||||
|
"hsCode": "HS Code",
|
||||||
|
"countryOfOrigin": "Country of Origin",
|
||||||
|
"material": "Material",
|
||||||
|
"thumbnail": "Thumbnail",
|
||||||
|
"sku": "SKU",
|
||||||
|
"managedInventory": "Managed inventory"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
import { forwardRef } from "react"
|
import { ComponentPropsWithoutRef, forwardRef } from "react"
|
||||||
|
|
||||||
import { TrianglesMini } from "@medusajs/icons"
|
import { TrianglesMini } from "@medusajs/icons"
|
||||||
import { clx } from "@medusajs/ui"
|
import { clx } from "@medusajs/ui"
|
||||||
@@ -7,7 +7,7 @@ import { countries } from "../../../lib/countries"
|
|||||||
|
|
||||||
export const CountrySelect = forwardRef<
|
export const CountrySelect = forwardRef<
|
||||||
HTMLSelectElement,
|
HTMLSelectElement,
|
||||||
React.ComponentPropsWithoutRef<"select"> & { placeholder?: string }
|
ComponentPropsWithoutRef<"select"> & { placeholder?: string }
|
||||||
>(({ className, disabled, placeholder, ...props }, ref) => {
|
>(({ className, disabled, placeholder, ...props }, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ export const CountrySelect = forwardRef<
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<TrianglesMini
|
<TrianglesMini
|
||||||
className={clx(
|
className={clx(
|
||||||
"absolute right-2 top-1/2 -translate-y-1/2 text-ui-fg-muted transition-fg pointer-events-none",
|
"text-ui-fg-muted transition-fg pointer-events-none absolute right-2 top-1/2 -translate-y-1/2",
|
||||||
{
|
{
|
||||||
"text-ui-fg-disabled": disabled,
|
"text-ui-fg-disabled": disabled,
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ export const CountrySelect = forwardRef<
|
|||||||
<select
|
<select
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={clx(
|
className={clx(
|
||||||
"appearance-none bg-ui-bg-field shadow-buttons-neutral transition-fg flex w-full select-none items-center justify-between rounded-md outline-none px-2 py-1 txt-compact-small",
|
"bg-ui-bg-field shadow-buttons-neutral transition-fg txt-compact-small flex w-full select-none appearance-none items-center justify-between rounded-md px-2 py-1.5 outline-none",
|
||||||
"placeholder:text-ui-fg-muted text-ui-fg-base",
|
"placeholder:text-ui-fg-muted text-ui-fg-base",
|
||||||
"hover:bg-ui-bg-field-hover",
|
"hover:bg-ui-bg-field-hover",
|
||||||
"focus-visible:shadow-borders-interactive-with-active data-[state=open]:!shadow-borders-interactive-with-active",
|
"focus-visible:shadow-borders-interactive-with-active data-[state=open]:!shadow-borders-interactive-with-active",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Input, Text } from "@medusajs/ui"
|
||||||
|
import { ComponentProps, ElementRef, forwardRef } from "react"
|
||||||
|
|
||||||
|
export const HandleInput = forwardRef<
|
||||||
|
ElementRef<typeof Input>,
|
||||||
|
ComponentProps<typeof Input>
|
||||||
|
>((props, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 z-10 flex w-8 items-center justify-center border-r">
|
||||||
|
<Text
|
||||||
|
className="text-ui-fg-muted"
|
||||||
|
size="small"
|
||||||
|
leading="compact"
|
||||||
|
weight="plus"
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Input ref={ref} {...props} className="pl-10" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
HandleInput.displayName = "HandleInput"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./handle-input"
|
||||||
+44
-7
@@ -1,22 +1,59 @@
|
|||||||
import { Navigate, useLocation, useRouteError } from "react-router-dom"
|
import { Navigate, useLocation, useRouteError } from "react-router-dom"
|
||||||
|
|
||||||
|
import { ExclamationCircle } from "@medusajs/icons"
|
||||||
|
import { Text } from "@medusajs/ui"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import { isAxiosError } from "../../../lib/is-axios-error"
|
import { isAxiosError } from "../../../lib/is-axios-error"
|
||||||
|
|
||||||
|
// WIP - Need to allow wrapping <Outlet> with ErrorBoundary for more granular error handling.
|
||||||
export const ErrorBoundary = () => {
|
export const ErrorBoundary = () => {
|
||||||
const error = useRouteError()
|
const error = useRouteError()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
let code: number | null = null
|
||||||
|
|
||||||
if (isAxiosError(error)) {
|
if (isAxiosError(error)) {
|
||||||
if (error.response?.status === 404) {
|
|
||||||
return <Navigate to="/404" />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
return <Navigate to="/login" state={{ from: location }} replace />
|
return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Catch other server errors
|
code = error.response?.status ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Actual catch-all error page
|
let title: string
|
||||||
return <div>Dang!</div>
|
let message: string
|
||||||
|
|
||||||
|
switch (code) {
|
||||||
|
case 400:
|
||||||
|
title = t("errorBoundary.badRequestTitle")
|
||||||
|
message = t("errorBoundary.badRequestMessage")
|
||||||
|
break
|
||||||
|
case 404:
|
||||||
|
title = t("errorBoundary.notFoundTitle")
|
||||||
|
message = t("errorBoundary.notFoundMessage")
|
||||||
|
break
|
||||||
|
case 500:
|
||||||
|
title = t("errorBoundary.internalServerErrorTitle")
|
||||||
|
message = t("errorBoundary.internalServerErrorMessage")
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
title = t("errorBoundary.defaultTitle")
|
||||||
|
message = t("errorBoundary.defaultMessage")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full min-h-screen items-center justify-center">
|
||||||
|
<div className="text-ui-fg-subtle flex flex-col items-center gap-y-2">
|
||||||
|
<ExclamationCircle />
|
||||||
|
<Text size="small" leading="compact" weight="plus">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" className="text-ui-fg-muted">
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Product, ProductVariant } from "@medusajs/medusa"
|
||||||
|
import { Button, Container } from "@medusajs/ui"
|
||||||
|
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
|
||||||
|
import { useAdminProducts } from "medusa-react"
|
||||||
|
import { useEffect, useMemo } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import * as zod from "zod"
|
||||||
|
|
||||||
|
import { Thumbnail } from "../common/thumbnail"
|
||||||
|
import { DataGrid } from "./data-grid"
|
||||||
|
import { TextField } from "./grid-fields/common/text-field"
|
||||||
|
import { DisplayField } from "./grid-fields/non-interactive/display-field"
|
||||||
|
import { DataGridMeta } from "./types"
|
||||||
|
|
||||||
|
const ProductEditorSchema = zod.object({
|
||||||
|
products: zod.record(
|
||||||
|
zod.object({
|
||||||
|
variants: zod.record(
|
||||||
|
zod.object({
|
||||||
|
title: zod.string(),
|
||||||
|
sku: zod.string(),
|
||||||
|
ean: zod.string().optional(),
|
||||||
|
upc: zod.string().optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
type ProductEditorSchemaType = zod.infer<typeof ProductEditorSchema>
|
||||||
|
type VariantObject = ProductEditorSchemaType["products"]["id"]["variants"]
|
||||||
|
|
||||||
|
const getVariantRows = (row: Product | ProductVariant) => {
|
||||||
|
if ("variants" in row) {
|
||||||
|
return row.variants
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo component to test the data grid.
|
||||||
|
*
|
||||||
|
* To be deleted when the feature is implemented.
|
||||||
|
*/
|
||||||
|
export const DataGridDemo = () => {
|
||||||
|
const form = useForm<ProductEditorSchemaType>({
|
||||||
|
resolver: zodResolver(ProductEditorSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { setValue } = form
|
||||||
|
|
||||||
|
const { products, isLoading } = useAdminProducts(
|
||||||
|
{
|
||||||
|
expand: "variants,variants.prices",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && products) {
|
||||||
|
products.forEach((product) => {
|
||||||
|
setValue(`products.${product.id}.variants`, {
|
||||||
|
...product.variants.reduce((variants, variant) => {
|
||||||
|
variants[variant.id!] = {
|
||||||
|
title: variant.title || "",
|
||||||
|
sku: variant.sku || "",
|
||||||
|
ean: variant.ean || "",
|
||||||
|
upc: variant.upc || "",
|
||||||
|
}
|
||||||
|
return variants
|
||||||
|
}, {} as VariantObject),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [products, isLoading, setValue])
|
||||||
|
|
||||||
|
const columns = useColumns()
|
||||||
|
|
||||||
|
const initializing = isLoading || !products
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit((data) => {
|
||||||
|
console.log("submitting", data)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="overflow-hidden p-0">
|
||||||
|
<DataGrid
|
||||||
|
isLoading={initializing}
|
||||||
|
data={products as Product[]}
|
||||||
|
columns={columns}
|
||||||
|
state={form}
|
||||||
|
getSubRows={getVariantRows}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-x-2 border-t p-4">
|
||||||
|
<Button size="small" onClick={handleSubmit}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to determine if a row is a product or a variant.
|
||||||
|
*/
|
||||||
|
const isProduct = (row: Product | ProductVariant): row is Product => {
|
||||||
|
return "variants" in row
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<Product | ProductVariant>()
|
||||||
|
|
||||||
|
const useColumns = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const colDefs: ColumnDef<Product | ProductVariant>[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
columnHelper.display({
|
||||||
|
id: t("fields.title"),
|
||||||
|
header: "Title",
|
||||||
|
cell: ({ row, table }) => {
|
||||||
|
const entity = row.original
|
||||||
|
|
||||||
|
if (isProduct(entity)) {
|
||||||
|
return (
|
||||||
|
<DisplayField>
|
||||||
|
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
|
||||||
|
<Thumbnail src={entity.thumbnail} />
|
||||||
|
<span className="truncate">{entity.title}</span>
|
||||||
|
</div>
|
||||||
|
</DisplayField>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
|
||||||
|
field={`products.${entity.product_id}.variants.${entity.id}.title`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
size: 350,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("sku", {
|
||||||
|
header: t("fields.sku"),
|
||||||
|
cell: ({ row, table }) => {
|
||||||
|
const entity = row.original
|
||||||
|
|
||||||
|
if (isProduct(entity)) {
|
||||||
|
return <DisplayField />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
|
||||||
|
field={`products.${entity.product_id}.variants.${entity.id}.sku`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("ean", {
|
||||||
|
header: "EAN",
|
||||||
|
cell: ({ row, table }) => {
|
||||||
|
const entity = row.original
|
||||||
|
|
||||||
|
if (isProduct(entity)) {
|
||||||
|
return <DisplayField />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
|
||||||
|
field={`products.${entity.product_id}.variants.${entity.id}.ean`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("upc", {
|
||||||
|
header: "UPC",
|
||||||
|
cell: ({ row, table }) => {
|
||||||
|
const entity = row.original
|
||||||
|
|
||||||
|
if (isProduct(entity)) {
|
||||||
|
return <DisplayField />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
|
||||||
|
field={`products.${entity.product_id}.variants.${entity.id}.upc`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
return colDefs
|
||||||
|
}
|
||||||
+630
@@ -0,0 +1,630 @@
|
|||||||
|
import { clx } from "@medusajs/ui"
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
Row,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||||
|
import {
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import { FieldValues, Path, UseFormReturn } from "react-hook-form"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
useCommandHistory,
|
||||||
|
} from "../../../../hooks/use-command-history"
|
||||||
|
|
||||||
|
type FieldCoordinates = {
|
||||||
|
column: number
|
||||||
|
row: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataGridRootProps<
|
||||||
|
TData,
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
> {
|
||||||
|
data: TData[]
|
||||||
|
columns: ColumnDef<TData>[]
|
||||||
|
state: UseFormReturn<TFieldValues>
|
||||||
|
getSubRows: (row: TData) => TData[] | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW_HEIGHT = 40
|
||||||
|
|
||||||
|
export const DataGridRoot = <
|
||||||
|
TData,
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
>({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
state,
|
||||||
|
getSubRows,
|
||||||
|
}: DataGridRootProps<TData, TFieldValues>) => {
|
||||||
|
const tableContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const { execute, undo, redo, canRedo, canUndo } = useCommandHistory()
|
||||||
|
const { register, control, getValues, setValue } = state
|
||||||
|
|
||||||
|
const grid = useReactTable({
|
||||||
|
data: data,
|
||||||
|
columns,
|
||||||
|
getSubRows,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
meta: {
|
||||||
|
register: register,
|
||||||
|
control: control,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { flatRows } = grid.getRowModel()
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: flatRows.length,
|
||||||
|
estimateSize: () => ROW_HEIGHT,
|
||||||
|
getScrollElement: () => tableContainerRef.current,
|
||||||
|
measureElement:
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
navigator.userAgent.indexOf("Firefox") === -1
|
||||||
|
? (element) => element?.getBoundingClientRect().height
|
||||||
|
: undefined,
|
||||||
|
overscan: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [anchor, setAnchor] = useState<FieldCoordinates | null>(null)
|
||||||
|
|
||||||
|
const [isSelecting, setIsSelecting] = useState(false)
|
||||||
|
const [selection, setSelection] = useState<FieldCoordinates[]>([])
|
||||||
|
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [dragSelection, setDragSelection] = useState<FieldCoordinates[]>([])
|
||||||
|
|
||||||
|
const handleFocusInner = (target: HTMLElement) => {
|
||||||
|
const editableField = target.querySelector("[data-field-id]")
|
||||||
|
|
||||||
|
if (editableField instanceof HTMLInputElement) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editableField.focus()
|
||||||
|
editableField.setSelectionRange(
|
||||||
|
editableField.value.length,
|
||||||
|
editableField.value.length
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseDown = (e: ReactMouseEvent<HTMLTableCellElement>) => {
|
||||||
|
const target = e.target
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the click was on a presentation element.
|
||||||
|
* If so, we don't want to set the anchor.
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
target.querySelector("[data-role=presentation]")
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowIndex = parseInt(e.currentTarget.dataset.rowIndex!)
|
||||||
|
const columnIndex = parseInt(e.currentTarget.dataset.columnIndex!)
|
||||||
|
|
||||||
|
const isAnchor = getIsAnchor(rowIndex, columnIndex)
|
||||||
|
|
||||||
|
if (e.detail === 2 || isAnchor) {
|
||||||
|
handleFocusInner(e.currentTarget)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinates: FieldCoordinates = {
|
||||||
|
row: rowIndex,
|
||||||
|
column: columnIndex,
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelection([coordinates])
|
||||||
|
setAnchor(coordinates)
|
||||||
|
setIsSelecting(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragDown = (e: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIsAnchor = (rowIndex: number, columnIndex: number) => {
|
||||||
|
return anchor?.row === rowIndex && anchor?.column === columnIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseOver = (e: ReactMouseEvent<HTMLTableCellElement>) => {
|
||||||
|
/**
|
||||||
|
* If we're not dragging and not selecting or there is no anchor,
|
||||||
|
* then we don't want to do anything.
|
||||||
|
*/
|
||||||
|
if ((!isSelecting && !isDragging) || !anchor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = e.target
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the click was on a presentation element.
|
||||||
|
* If so, we don't want to add it to the selection.
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
target.querySelector("[data-role=presentation]")
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowIndex = parseInt(e.currentTarget.dataset.rowIndex!)
|
||||||
|
const columnIndex = parseInt(e.currentTarget.dataset.columnIndex!)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the target column is not the same as the anchor column,
|
||||||
|
* we don't want to add it to the selection.
|
||||||
|
*/
|
||||||
|
if (anchor?.column !== columnIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction =
|
||||||
|
rowIndex > anchor.row ? "down" : rowIndex < anchor.row ? "up" : "none"
|
||||||
|
|
||||||
|
const last = selection[selection.length - 1] ?? anchor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current cell is a direct neighbour of the last cell
|
||||||
|
* in the selection.
|
||||||
|
*/
|
||||||
|
const isNeighbour = Math.abs(rowIndex - last.row) === 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the current cell is a neighbour, we can simply update
|
||||||
|
* the selection based on the direction.
|
||||||
|
*/
|
||||||
|
if (isNeighbour) {
|
||||||
|
if (isSelecting) {
|
||||||
|
setSelection((prev) => {
|
||||||
|
return prev
|
||||||
|
.filter((cell) => {
|
||||||
|
if (direction === "down") {
|
||||||
|
return (
|
||||||
|
(cell.row <= rowIndex && cell.row >= anchor.row) ||
|
||||||
|
cell.row === anchor.row
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === "up") {
|
||||||
|
return (
|
||||||
|
(cell.row >= rowIndex && cell.row <= anchor.row) ||
|
||||||
|
cell.row === anchor.row
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cell.row === anchor.row
|
||||||
|
})
|
||||||
|
.concat({ row: rowIndex, column: columnIndex })
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
if (anchor.row === rowIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDragSelection((prev) => {
|
||||||
|
return prev
|
||||||
|
.filter((cell) => {
|
||||||
|
if (direction === "down") {
|
||||||
|
return (
|
||||||
|
(cell.row <= rowIndex && cell.row >= anchor.row) ||
|
||||||
|
cell.row === anchor.row
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === "up") {
|
||||||
|
return (
|
||||||
|
(cell.row >= rowIndex && cell.row <= anchor.row) ||
|
||||||
|
cell.row === anchor.row
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cell.row === anchor.row
|
||||||
|
})
|
||||||
|
.concat({ row: rowIndex, column: columnIndex })
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the current cell is not a neighbour, we instead
|
||||||
|
* need to calculate all the valid cells between the
|
||||||
|
* anchor and the current cell.
|
||||||
|
*/
|
||||||
|
let cells: FieldCoordinates[] = []
|
||||||
|
|
||||||
|
function selectCell(i: number, columnIndex: number) {
|
||||||
|
const possibleCell = tableContainerRef.current?.querySelector(
|
||||||
|
`[data-row-index="${i}"][data-column-index="${columnIndex}"]`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!possibleCell) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPresentation = possibleCell.querySelector(
|
||||||
|
"[data-role=presentation]"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isPresentation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cells.push({ row: i, column: columnIndex })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === "down") {
|
||||||
|
for (let i = anchor.row; i <= rowIndex; i++) {
|
||||||
|
selectCell(i, columnIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === "up") {
|
||||||
|
for (let i = anchor.row; i >= rowIndex; i--) {
|
||||||
|
selectCell(i, columnIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelecting) {
|
||||||
|
setSelection(cells)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
cells = cells.filter((cell) => cell.row !== anchor.row)
|
||||||
|
|
||||||
|
setDragSelection(cells)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIsDragTarget = (rowIndex: number, columnIndex: number) => {
|
||||||
|
return dragSelection.some(
|
||||||
|
(cell) => cell.row === rowIndex && cell.column === columnIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIsSelected = (rowIndex: number, columnIndex: number) => {
|
||||||
|
return selection.some(
|
||||||
|
(cell) => cell.row === rowIndex && cell.column === columnIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSelectionIds = useCallback((fields: FieldCoordinates[]) => {
|
||||||
|
return fields
|
||||||
|
.map((field) => {
|
||||||
|
const element = document.querySelector(
|
||||||
|
`[data-row-index="${field.row}"][data-column-index="${field.column}"]`
|
||||||
|
) as HTMLTableCellElement
|
||||||
|
|
||||||
|
return element
|
||||||
|
?.querySelector("[data-field-id]")
|
||||||
|
?.getAttribute("data-field-id")
|
||||||
|
})
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getSelectionValues = useCallback(
|
||||||
|
(ids: string[]): string[] => {
|
||||||
|
const rawValues = ids.map((id) => {
|
||||||
|
return getValues(id as Path<TFieldValues>)
|
||||||
|
})
|
||||||
|
|
||||||
|
return rawValues.map((v) => JSON.stringify(v))
|
||||||
|
},
|
||||||
|
[getValues]
|
||||||
|
)
|
||||||
|
|
||||||
|
const setSelectionValues = useCallback(
|
||||||
|
(ids: string[], values: string[]) => {
|
||||||
|
ids.forEach((id, i) => {
|
||||||
|
const value = values[i]
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(id as Path<TFieldValues>, JSON.parse(value), {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[setValue]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleCopy = useCallback(
|
||||||
|
(e: ClipboardEvent) => {
|
||||||
|
if (selection.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldIds = getSelectionIds(selection)
|
||||||
|
const values = getSelectionValues(fieldIds)
|
||||||
|
|
||||||
|
const clipboardData = values.join("\n")
|
||||||
|
|
||||||
|
e.clipboardData?.setData("text/plain", clipboardData)
|
||||||
|
e.preventDefault()
|
||||||
|
},
|
||||||
|
[selection, getSelectionIds, getSelectionValues]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePaste = useCallback(
|
||||||
|
(e: ClipboardEvent) => {
|
||||||
|
const data = e.clipboardData?.getData("text/plain")
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldIds = getSelectionIds(selection)
|
||||||
|
|
||||||
|
const prev = getSelectionValues(fieldIds)
|
||||||
|
const next = data.split("\n")
|
||||||
|
|
||||||
|
const command = new GridCommand({
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
selection: fieldIds,
|
||||||
|
setter: setSelectionValues,
|
||||||
|
})
|
||||||
|
|
||||||
|
execute(command)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
selection,
|
||||||
|
execute,
|
||||||
|
getSelectionValues,
|
||||||
|
setSelectionValues,
|
||||||
|
getSelectionIds,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleCommandHistory = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (!canRedo && !canUndo) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key.toLowerCase() === "z" && e.metaKey && !e.shiftKey) {
|
||||||
|
console.log(canUndo)
|
||||||
|
e.preventDefault()
|
||||||
|
undo()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key.toLowerCase() === "z" && e.metaKey && e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
redo()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[undo, redo, canRedo, canUndo]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleEndDrag = useCallback(() => {
|
||||||
|
if (!anchor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldIds = getSelectionIds(dragSelection)
|
||||||
|
const anchorId = getSelectionIds([anchor])
|
||||||
|
|
||||||
|
const anchorValue = getSelectionValues(anchorId)?.[0]
|
||||||
|
|
||||||
|
const prev = getSelectionValues(fieldIds)
|
||||||
|
const next = prev.map(() => anchorValue)
|
||||||
|
|
||||||
|
const command = new GridCommand({
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
selection: fieldIds,
|
||||||
|
setter: setSelectionValues,
|
||||||
|
})
|
||||||
|
|
||||||
|
execute(command)
|
||||||
|
|
||||||
|
setSelection(dragSelection)
|
||||||
|
setDragSelection([])
|
||||||
|
setIsDragging(false)
|
||||||
|
}, [
|
||||||
|
anchor,
|
||||||
|
getSelectionIds,
|
||||||
|
dragSelection,
|
||||||
|
getSelectionValues,
|
||||||
|
setSelectionValues,
|
||||||
|
execute,
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(
|
||||||
|
(_e: MouseEvent) => {
|
||||||
|
if (isSelecting) {
|
||||||
|
setIsSelecting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
handleEndDrag()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDragging, isSelecting, handleEndDrag]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("mouseup", handleMouseUp)
|
||||||
|
document.addEventListener("copy", handleCopy)
|
||||||
|
document.addEventListener("paste", handlePaste)
|
||||||
|
document.addEventListener("keydown", handleCommandHistory)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp)
|
||||||
|
document.removeEventListener("copy", handleCopy)
|
||||||
|
document.removeEventListener("paste", handlePaste)
|
||||||
|
document.removeEventListener("keydown", handleCommandHistory)
|
||||||
|
}
|
||||||
|
}, [handleMouseUp, handleCopy, handlePaste, handleCommandHistory])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="border-b p-4"></div>
|
||||||
|
<div
|
||||||
|
ref={tableContainerRef}
|
||||||
|
style={{
|
||||||
|
overflow: "auto",
|
||||||
|
position: "relative",
|
||||||
|
height: "600px",
|
||||||
|
userSelect: isSelecting || isDragging ? "none" : "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table className="text-ui-fg-subtle grid">
|
||||||
|
<thead className="txt-compact-small-plus bg-ui-bg-subtle sticky top-0 z-[1] grid">
|
||||||
|
{grid.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id} className="flex h-10 w-full">
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={header.id}
|
||||||
|
style={{
|
||||||
|
width: header.getSize(),
|
||||||
|
}}
|
||||||
|
className="bg-ui-bg-base flex items-center border-b border-r px-4 py-2.5"
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody
|
||||||
|
className="relative grid"
|
||||||
|
style={{
|
||||||
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const row = flatRows[virtualRow.index] as Row<TData>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
ref={(node) => rowVirtualizer.measureElement(node)}
|
||||||
|
key={row.id}
|
||||||
|
style={{
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell, index) => {
|
||||||
|
const isAnchor = getIsAnchor(virtualRow.index, index)
|
||||||
|
const isSelected = getIsSelected(virtualRow.index, index)
|
||||||
|
const isDragTarget = getIsDragTarget(
|
||||||
|
virtualRow.index,
|
||||||
|
index
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={cell.id}
|
||||||
|
style={{
|
||||||
|
width: cell.column.getSize(),
|
||||||
|
}}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseOver={handleMouseOver}
|
||||||
|
data-row-index={virtualRow.index}
|
||||||
|
data-column-index={index}
|
||||||
|
className={clx(
|
||||||
|
"bg-ui-bg-base has-[[data-role='presentation']]:bg-ui-bg-subtle relative flex items-center border-b border-r p-0 outline-none",
|
||||||
|
"after:transition-fg after:border-ui-fg-interactive after:invisible after:absolute after:-bottom-px after:-left-px after:-right-px after:-top-px after:box-border after:border-[2px] after:content-['']",
|
||||||
|
{
|
||||||
|
"after:visible": isAnchor,
|
||||||
|
"bg-ui-bg-highlight": isSelected,
|
||||||
|
"bg-ui-bg-base-hover": isDragTarget,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
{isAnchor && (
|
||||||
|
<div
|
||||||
|
onMouseDown={handleDragDown}
|
||||||
|
className="bg-ui-fg-interactive absolute bottom-0 right-0 z-[3] size-1.5 cursor-ns-resize"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type GridCommandArgs = {
|
||||||
|
selection: string[]
|
||||||
|
setter: (selection: string[], values: string[]) => void
|
||||||
|
prev: string[]
|
||||||
|
next: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class GridCommand implements Command {
|
||||||
|
private _selection: string[]
|
||||||
|
|
||||||
|
private _prev: string[]
|
||||||
|
private _next: string[]
|
||||||
|
|
||||||
|
private _setter: (selection: string[], values: string[]) => void
|
||||||
|
|
||||||
|
constructor({ selection, setter, prev, next }: GridCommandArgs) {
|
||||||
|
this._selection = selection
|
||||||
|
this._setter = setter
|
||||||
|
this._prev = prev
|
||||||
|
this._next = next
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
this._setter(this._selection, this._next)
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
this._setter(this._selection, this._prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
redo() {
|
||||||
|
this.execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./data-grid-root"
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { Table } from "@medusajs/ui"
|
||||||
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { Skeleton } from "../../../common/skeleton"
|
||||||
|
|
||||||
|
type DataTableSkeletonProps = {
|
||||||
|
columns: ColumnDef<any, any>[]
|
||||||
|
rowCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataGridSkeleton = ({
|
||||||
|
columns,
|
||||||
|
rowCount,
|
||||||
|
}: DataTableSkeletonProps) => {
|
||||||
|
const rows = Array.from({ length: rowCount }, (_, i) => i)
|
||||||
|
|
||||||
|
const colCount = columns.length
|
||||||
|
const colWidth = 100 / colCount
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
{columns.map((_col, i) => {
|
||||||
|
return (
|
||||||
|
<Table.HeaderCell
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: `${colWidth}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton className="h-7" />
|
||||||
|
</Table.HeaderCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{rows.map((_, j) => (
|
||||||
|
<Table.Row key={j}>
|
||||||
|
{columns.map((_col, k) => {
|
||||||
|
return (
|
||||||
|
<Table.Cell key={k}>
|
||||||
|
<Skeleton className="h-7" />
|
||||||
|
</Table.Cell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./data-grid-skeleton"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { FieldValues } from "react-hook-form"
|
||||||
|
import { DataGridRoot, DataGridRootProps } from "./data-grid-root"
|
||||||
|
import { DataGridSkeleton } from "./data-grid-skeleton"
|
||||||
|
|
||||||
|
interface DataGridProps<TData, TFieldValues extends FieldValues = any>
|
||||||
|
extends DataGridRootProps<TData, TFieldValues> {
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataGrid = <TData, TFieldValues extends FieldValues = any>({
|
||||||
|
isLoading,
|
||||||
|
...props
|
||||||
|
}: DataGridProps<TData, TFieldValues>) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isLoading ? (
|
||||||
|
<DataGridSkeleton columns={props.columns} rowCount={10} />
|
||||||
|
) : (
|
||||||
|
<DataGridRoot {...props} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./data-grid"
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { Select } from "@medusajs/ui"
|
||||||
|
import { Controller, FieldValues } from "react-hook-form"
|
||||||
|
import { FieldProps } from "../../../types"
|
||||||
|
|
||||||
|
interface BooleanFieldProps<TFieldValues extends FieldValues = any>
|
||||||
|
extends FieldProps<TFieldValues> {}
|
||||||
|
|
||||||
|
export const BooleanField = <TFieldValues extends FieldValues = any>({
|
||||||
|
field,
|
||||||
|
meta,
|
||||||
|
}: BooleanFieldProps<TFieldValues>) => {
|
||||||
|
const { control } = meta
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={field}
|
||||||
|
render={({ field: { value, onChange, ref, ...rest } }) => {
|
||||||
|
return <Select value={value} onValueChange={onChange}></Select>
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./boolean-field"
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./text-field"
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
import { FieldValues } from "react-hook-form"
|
||||||
|
import { FieldProps } from "../../../types"
|
||||||
|
|
||||||
|
interface TextFieldProps<TFieldValues extends FieldValues = any>
|
||||||
|
extends FieldProps<TFieldValues> {}
|
||||||
|
|
||||||
|
export const TextField = <TFieldValues extends FieldValues = any>({
|
||||||
|
field,
|
||||||
|
meta,
|
||||||
|
}: TextFieldProps<TFieldValues>) => {
|
||||||
|
const { register } = meta
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center px-4 py-2.5">
|
||||||
|
<input
|
||||||
|
className="txt-compact-small text-ui-fg-subtle w-full bg-transparent outline-none"
|
||||||
|
data-input-field="true"
|
||||||
|
data-field-id={field}
|
||||||
|
data-field-type="text"
|
||||||
|
{...register(field)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import { PropsWithChildren } from "react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field for displaying non-editable data in a grid.
|
||||||
|
*/
|
||||||
|
export const DisplayField = ({ children }: PropsWithChildren) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex size-full cursor-not-allowed items-center justify-center px-4 py-2.5"
|
||||||
|
data-role="presentation"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./display-field"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Control, FieldValues, Path, UseFormRegister } from "react-hook-form"
|
||||||
|
|
||||||
|
export type DataGridMeta<TFieldValues extends FieldValues = FieldValues> = {
|
||||||
|
register: UseFormRegister<TFieldValues>
|
||||||
|
control: Control<TFieldValues>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldProps<TFieldValues extends FieldValues = FieldValues> {
|
||||||
|
field: Path<TFieldValues>
|
||||||
|
meta: DataGridMeta<TFieldValues>
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
CircleHalfSolid,
|
CircleHalfSolid,
|
||||||
CogSixTooth,
|
CogSixTooth,
|
||||||
MagnifyingGlass,
|
MagnifyingGlass,
|
||||||
Sidebar,
|
SidebarRight,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
} from "@medusajs/icons"
|
} from "@medusajs/icons"
|
||||||
import { Avatar, DropdownMenu, IconButton, Kbd, Text, clx } from "@medusajs/ui"
|
import { Avatar, DropdownMenu, IconButton, Kbd, Text, clx } from "@medusajs/ui"
|
||||||
@@ -330,14 +330,14 @@ const ToggleSidebar = () => {
|
|||||||
variant="transparent"
|
variant="transparent"
|
||||||
onClick={() => toggle("desktop")}
|
onClick={() => toggle("desktop")}
|
||||||
>
|
>
|
||||||
<Sidebar className="text-ui-fg-muted" />
|
<SidebarRight className="text-ui-fg-muted" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
className="hidden max-lg:flex"
|
className="hidden max-lg:flex"
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
onClick={() => toggle("mobile")}
|
onClick={() => toggle("mobile")}
|
||||||
>
|
>
|
||||||
<Sidebar className="text-ui-fg-muted" />
|
<SidebarRight className="text-ui-fg-muted" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+48
-19
@@ -45,6 +45,10 @@ export interface DataTableRootProps<TData> {
|
|||||||
* Whether the table is empty due to no results from the active query
|
* Whether the table is empty due to no results from the active query
|
||||||
*/
|
*/
|
||||||
noResults?: boolean
|
noResults?: boolean
|
||||||
|
/**
|
||||||
|
* The layout of the table
|
||||||
|
*/
|
||||||
|
layout?: "fill" | "fit"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +73,7 @@ export const DataTableRoot = <TData,>({
|
|||||||
commands,
|
commands,
|
||||||
count = 0,
|
count = 0,
|
||||||
noResults = false,
|
noResults = false,
|
||||||
|
layout = "fit",
|
||||||
}: DataTableRootProps<TData>) => {
|
}: DataTableRootProps<TData>) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -101,8 +106,18 @@ export const DataTableRoot = <TData,>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div
|
||||||
<div onScroll={handleHorizontalScroll} className="w-full overflow-x-auto">
|
className={clx("flex w-full flex-col overflow-hidden", {
|
||||||
|
"flex flex-1 flex-col": layout === "fill",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onScroll={handleHorizontalScroll}
|
||||||
|
className={clx("w-full", {
|
||||||
|
"min-h-0 flex-grow overflow-auto": layout === "fill",
|
||||||
|
"overflow-x-auto": layout === "fit",
|
||||||
|
})}
|
||||||
|
>
|
||||||
{!noResults ? (
|
{!noResults ? (
|
||||||
<Table className="w-full">
|
<Table className="w-full">
|
||||||
<Table.Header className="border-t-0">
|
<Table.Header className="border-t-0">
|
||||||
@@ -144,8 +159,12 @@ export const DataTableRoot = <TData,>({
|
|||||||
className={clx({
|
className={clx({
|
||||||
"bg-ui-bg-base sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
"bg-ui-bg-base sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||||
isStickyHeader,
|
isStickyHeader,
|
||||||
|
"left-[68px]":
|
||||||
|
isStickyHeader && hasSelect && !isSelectHeader,
|
||||||
"after:bg-ui-border-base":
|
"after:bg-ui-border-base":
|
||||||
showStickyBorder && isStickyHeader,
|
showStickyBorder &&
|
||||||
|
isStickyHeader &&
|
||||||
|
!isSpecialHeader,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
@@ -179,14 +198,14 @@ export const DataTableRoot = <TData,>({
|
|||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell, index) => {
|
{row.getVisibleCells().map((cell, index) => {
|
||||||
const visibleCells = row.getVisibleCells()
|
const visibleCells = row.getVisibleCells()
|
||||||
const isSelectCell = cell.id === "select"
|
const isSelectCell = cell.column.id === "select"
|
||||||
|
|
||||||
const firstCell = visibleCells.findIndex(
|
const firstCell = visibleCells.findIndex(
|
||||||
(h) => h.id !== "select"
|
(h) => h.column.id !== "select"
|
||||||
)
|
)
|
||||||
const isFirstCell =
|
const isFirstCell =
|
||||||
firstCell !== -1
|
firstCell !== -1
|
||||||
? cell.id === visibleCells[firstCell].id
|
? cell.column.id === visibleCells[firstCell].column.id
|
||||||
: index === 0
|
: index === 0
|
||||||
|
|
||||||
const isStickyCell = isSelectCell || isFirstCell
|
const isStickyCell = isSelectCell || isFirstCell
|
||||||
@@ -197,8 +216,10 @@ export const DataTableRoot = <TData,>({
|
|||||||
className={clx("has-[a]:cursor-pointer", {
|
className={clx("has-[a]:cursor-pointer", {
|
||||||
"bg-ui-bg-base group-data-[selected=true]/row:bg-ui-bg-highlight group-data-[selected=true]/row:group-hover/row:bg-ui-bg-highlight-hover group-[:has(td_a:focus)]/row:bg-ui-bg-base-pressed group-hover/row:bg-ui-bg-base-hover transition-fg sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
"bg-ui-bg-base group-data-[selected=true]/row:bg-ui-bg-highlight group-data-[selected=true]/row:group-hover/row:bg-ui-bg-highlight-hover group-[:has(td_a:focus)]/row:bg-ui-bg-base-pressed group-hover/row:bg-ui-bg-base-hover transition-fg sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||||
isStickyCell,
|
isStickyCell,
|
||||||
|
"left-[68px]":
|
||||||
|
isStickyCell && hasSelect && !isSelectCell,
|
||||||
"after:bg-ui-border-base":
|
"after:bg-ui-border-base":
|
||||||
showStickyBorder && isStickyCell,
|
showStickyBorder && isStickyCell && !isSelectCell,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
@@ -214,22 +235,24 @@ export const DataTableRoot = <TData,>({
|
|||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
) : (
|
) : (
|
||||||
<div className="border-b">
|
<div className={clx({ "border-b": layout === "fit" })}>
|
||||||
<NoResults />
|
<NoResults />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{pagination && (
|
{pagination && (
|
||||||
<Pagination
|
<div className={clx({ "border-t": layout === "fill" })}>
|
||||||
canNextPage={table.getCanNextPage()}
|
<Pagination
|
||||||
canPreviousPage={table.getCanPreviousPage()}
|
canNextPage={table.getCanNextPage()}
|
||||||
nextPage={table.nextPage}
|
canPreviousPage={table.getCanPreviousPage()}
|
||||||
previousPage={table.previousPage}
|
nextPage={table.nextPage}
|
||||||
count={count}
|
previousPage={table.previousPage}
|
||||||
pageIndex={pageIndex}
|
count={count}
|
||||||
pageCount={table.getPageCount()}
|
pageIndex={pageIndex}
|
||||||
pageSize={pageSize}
|
pageCount={table.getPageCount()}
|
||||||
/>
|
pageSize={pageSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasCommandBar && (
|
{hasCommandBar && (
|
||||||
<CommandBar open={!!Object.keys(rowSelection).length}>
|
<CommandBar open={!!Object.keys(rowSelection).length}>
|
||||||
@@ -275,5 +298,11 @@ const Pagination = (props: PaginationProps) => {
|
|||||||
next: t("general.next"),
|
next: t("general.next"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Table.Pagination {...props} translations={translations} />
|
return (
|
||||||
|
<Table.Pagination
|
||||||
|
className="flex-shrink-0"
|
||||||
|
{...props}
|
||||||
|
translations={translations}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { clx } from "@medusajs/ui"
|
||||||
import { memo } from "react"
|
import { memo } from "react"
|
||||||
import { NoRecords } from "../../common/empty-table-content"
|
import { NoRecords } from "../../common/empty-table-content"
|
||||||
import { DataTableQuery, DataTableQueryProps } from "./data-table-query"
|
import { DataTableQuery, DataTableQueryProps } from "./data-table-query"
|
||||||
@@ -5,14 +6,15 @@ import { DataTableRoot, DataTableRootProps } from "./data-table-root"
|
|||||||
import { DataTableSkeleton } from "./data-table-skeleton"
|
import { DataTableSkeleton } from "./data-table-skeleton"
|
||||||
|
|
||||||
interface DataTableProps<TData>
|
interface DataTableProps<TData>
|
||||||
extends DataTableRootProps<TData>,
|
extends Omit<DataTableRootProps<TData>, "noResults">,
|
||||||
DataTableQueryProps {
|
DataTableQueryProps {
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
rowCount: number
|
pageSize: number
|
||||||
queryObject?: Record<string, any>
|
queryObject?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
const MemoizedDataTableRoot = memo(DataTableRoot) as typeof DataTableRoot
|
// Maybe we should use the memoized version of DataTableRoot
|
||||||
|
// const MemoizedDataTableRoot = memo(DataTableRoot) as typeof DataTableRoot
|
||||||
const MemoizedDataTableQuery = memo(DataTableQuery)
|
const MemoizedDataTableQuery = memo(DataTableQuery)
|
||||||
|
|
||||||
export const DataTable = <TData,>({
|
export const DataTable = <TData,>({
|
||||||
@@ -27,14 +29,15 @@ export const DataTable = <TData,>({
|
|||||||
filters,
|
filters,
|
||||||
prefix,
|
prefix,
|
||||||
queryObject = {},
|
queryObject = {},
|
||||||
rowCount,
|
pageSize,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
layout = "fit",
|
||||||
}: DataTableProps<TData>) => {
|
}: DataTableProps<TData>) => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<DataTableSkeleton
|
<DataTableSkeleton
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowCount={rowCount}
|
rowCount={pageSize}
|
||||||
searchable={search}
|
searchable={search}
|
||||||
filterable={!!filters?.length}
|
filterable={!!filters?.length}
|
||||||
orderBy={!!orderBy?.length}
|
orderBy={!!orderBy?.length}
|
||||||
@@ -53,14 +56,18 @@ export const DataTable = <TData,>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="divide-y">
|
<div
|
||||||
|
className={clx("divide-y", {
|
||||||
|
"flex h-full flex-col overflow-hidden": layout === "fill",
|
||||||
|
})}
|
||||||
|
>
|
||||||
<MemoizedDataTableQuery
|
<MemoizedDataTableQuery
|
||||||
search={search}
|
search={search}
|
||||||
orderBy={orderBy}
|
orderBy={orderBy}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
prefix={prefix}
|
prefix={prefix}
|
||||||
/>
|
/>
|
||||||
<MemoizedDataTableRoot
|
<DataTableRoot
|
||||||
table={table}
|
table={table}
|
||||||
count={count}
|
count={count}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@@ -68,6 +75,7 @@ export const DataTable = <TData,>({
|
|||||||
navigateTo={navigateTo}
|
navigateTo={navigateTo}
|
||||||
commands={commands}
|
commands={commands}
|
||||||
noResults={noResults}
|
noResults={noResults}
|
||||||
|
layout={layout}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { PlaceholderCell } from "../../common/placeholder-cell"
|
||||||
|
|
||||||
|
type DescriptionCellProps = {
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DescriptionCell = ({ description }: DescriptionCellProps) => {
|
||||||
|
if (!description) {
|
||||||
|
return <PlaceholderCell />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center overflow-hidden">
|
||||||
|
<span className="truncate">{description}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DescriptionHeader = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">{t("fields.description")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./description-cell"
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./name-cell"
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { PlaceholderCell } from "../../common/placeholder-cell"
|
||||||
|
|
||||||
|
type NameCellProps = {
|
||||||
|
name?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NameCell = ({ name }: NameCellProps) => {
|
||||||
|
if (!name) {
|
||||||
|
return <PlaceholderCell />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center overflow-hidden">
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NameHeader = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">{t("fields.name")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import { SalesChannel } from "@medusajs/medusa"
|
||||||
|
import { createColumnHelper } from "@tanstack/react-table"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
DescriptionCell,
|
||||||
|
DescriptionHeader,
|
||||||
|
} from "../../../components/table/table-cells/sales-channel/description-cell"
|
||||||
|
import {
|
||||||
|
NameCell,
|
||||||
|
NameHeader,
|
||||||
|
} from "../../../components/table/table-cells/sales-channel/name-cell"
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<SalesChannel>()
|
||||||
|
|
||||||
|
export const useSalesChannelTableColumns = () => {
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
columnHelper.accessor("name", {
|
||||||
|
header: () => <NameHeader />,
|
||||||
|
cell: ({ getValue }) => <NameCell name={getValue()} />,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("description", {
|
||||||
|
header: () => <DescriptionHeader />,
|
||||||
|
cell: ({ getValue }) => <DescriptionCell description={getValue()} />,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Filter } from "../../../components/table/data-table"
|
||||||
|
|
||||||
|
export const useSalesChannelTableFilters = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const dateFilters: Filter[] = [
|
||||||
|
{ label: t("fields.createdAt"), key: "created_at" },
|
||||||
|
{ label: t("fields.updatedAt"), key: "updated_at" },
|
||||||
|
].map((f) => ({
|
||||||
|
key: f.key,
|
||||||
|
label: f.label,
|
||||||
|
type: "date",
|
||||||
|
}))
|
||||||
|
|
||||||
|
return dateFilters
|
||||||
|
}
|
||||||
@@ -23,7 +23,8 @@ export const useCustomerTableQuery = ({
|
|||||||
prefix
|
prefix
|
||||||
)
|
)
|
||||||
|
|
||||||
const { offset, groups, has_account, q, order } = queryObject
|
const { offset, groups, created_at, updated_at, has_account, q, order } =
|
||||||
|
queryObject
|
||||||
|
|
||||||
const searchParams: AdminGetCustomersParams = {
|
const searchParams: AdminGetCustomersParams = {
|
||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
@@ -31,12 +32,8 @@ export const useCustomerTableQuery = ({
|
|||||||
groups: groups?.split(","),
|
groups: groups?.split(","),
|
||||||
has_account: has_account ? has_account === "true" : undefined,
|
has_account: has_account ? has_account === "true" : undefined,
|
||||||
order,
|
order,
|
||||||
created_at: queryObject.created_at
|
created_at: created_at ? JSON.parse(created_at) : undefined,
|
||||||
? JSON.parse(queryObject.created_at)
|
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
|
||||||
: undefined,
|
|
||||||
updated_at: queryObject.updated_at
|
|
||||||
? JSON.parse(queryObject.updated_at)
|
|
||||||
: undefined,
|
|
||||||
q,
|
q,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { AdminGetSalesChannelsParams } from "@medusajs/medusa"
|
||||||
|
import { useQueryParams } from "../../use-query-params"
|
||||||
|
|
||||||
|
type UseCustomerTableQueryProps = {
|
||||||
|
prefix?: string
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSalesChannelTableQuery = ({
|
||||||
|
prefix,
|
||||||
|
pageSize = 20,
|
||||||
|
}: UseCustomerTableQueryProps) => {
|
||||||
|
const queryObject = useQueryParams(
|
||||||
|
["offset", "q", "order", "created_at", "updated_at"],
|
||||||
|
prefix
|
||||||
|
)
|
||||||
|
|
||||||
|
const { offset, created_at, updated_at, q, order } = queryObject
|
||||||
|
|
||||||
|
const searchParams: AdminGetSalesChannelsParams = {
|
||||||
|
limit: pageSize,
|
||||||
|
offset: offset ? Number(offset) : 0,
|
||||||
|
order,
|
||||||
|
created_at: created_at ? JSON.parse(created_at) : undefined,
|
||||||
|
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
|
||||||
|
q,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchParams,
|
||||||
|
raw: queryObject,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useCallback, useState } from "react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base interface for a command that can be managed
|
||||||
|
* by the `useCommandHistory` hook.
|
||||||
|
*/
|
||||||
|
export interface Command {
|
||||||
|
execute: () => void
|
||||||
|
undo: () => void
|
||||||
|
redo: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage a history of commands that can be undone, redone, and executed.
|
||||||
|
*/
|
||||||
|
export const useCommandHistory = (maxHistory = 20) => {
|
||||||
|
const [past, setPast] = useState<Command[]>([])
|
||||||
|
const [future, setFuture] = useState<Command[]>([])
|
||||||
|
|
||||||
|
const canUndo = past.length > 0
|
||||||
|
const canRedo = future.length > 0
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
if (!canUndo) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = past[past.length - 1]
|
||||||
|
const newPast = past.slice(0, past.length - 1)
|
||||||
|
|
||||||
|
previous.undo()
|
||||||
|
|
||||||
|
setPast(newPast)
|
||||||
|
setFuture([previous, ...future.slice(0, maxHistory - 1)])
|
||||||
|
}, [canUndo, future, past, maxHistory])
|
||||||
|
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
if (!canRedo) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = future[0]
|
||||||
|
const newFuture = future.slice(1)
|
||||||
|
|
||||||
|
next.redo()
|
||||||
|
|
||||||
|
setPast([...past, next].slice(0, maxHistory - 1))
|
||||||
|
setFuture(newFuture)
|
||||||
|
}, [canRedo, future, past, maxHistory])
|
||||||
|
|
||||||
|
const execute = useCallback(
|
||||||
|
(command: Command) => {
|
||||||
|
command.execute()
|
||||||
|
setPast((past) => [...past, command].slice(0, maxHistory - 1))
|
||||||
|
setFuture([])
|
||||||
|
},
|
||||||
|
[maxHistory]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
execute,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
OnChangeFn,
|
OnChangeFn,
|
||||||
PaginationState,
|
PaginationState,
|
||||||
Row,
|
Row,
|
||||||
|
RowSelectionState,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
@@ -16,6 +17,10 @@ type UseDataTableProps<TData> = {
|
|||||||
count?: number
|
count?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
enableRowSelection?: boolean | ((row: Row<TData>) => boolean)
|
enableRowSelection?: boolean | ((row: Row<TData>) => boolean)
|
||||||
|
rowSelection?: {
|
||||||
|
state: RowSelectionState
|
||||||
|
updater: OnChangeFn<RowSelectionState>
|
||||||
|
}
|
||||||
enablePagination?: boolean
|
enablePagination?: boolean
|
||||||
getRowId?: (original: TData, index: number) => string
|
getRowId?: (original: TData, index: number) => string
|
||||||
meta?: Record<string, unknown>
|
meta?: Record<string, unknown>
|
||||||
@@ -29,6 +34,7 @@ export const useDataTable = <TData,>({
|
|||||||
pageSize: _pageSize = 20,
|
pageSize: _pageSize = 20,
|
||||||
enablePagination = true,
|
enablePagination = true,
|
||||||
enableRowSelection = false,
|
enableRowSelection = false,
|
||||||
|
rowSelection: _rowSelection,
|
||||||
getRowId,
|
getRowId,
|
||||||
meta,
|
meta,
|
||||||
prefix,
|
prefix,
|
||||||
@@ -48,7 +54,9 @@ export const useDataTable = <TData,>({
|
|||||||
}),
|
}),
|
||||||
[pageIndex, pageSize]
|
[pageIndex, pageSize]
|
||||||
)
|
)
|
||||||
const [rowSelection, setRowSelection] = useState({})
|
const [localRowSelection, setLocalRowSelection] = useState({})
|
||||||
|
const rowSelection = _rowSelection?.state ?? localRowSelection
|
||||||
|
const setRowSelection = _rowSelection?.updater ?? setLocalRowSelection
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enablePagination) {
|
if (!enablePagination) {
|
||||||
@@ -93,7 +101,7 @@ export const useDataTable = <TData,>({
|
|||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: {
|
||||||
rowSelection,
|
rowSelection: rowSelection, // We always pass a selection state to the table even if it's not enabled
|
||||||
pagination: enablePagination ? pagination : undefined,
|
pagination: enablePagination ? pagination : undefined,
|
||||||
},
|
},
|
||||||
pageCount: Math.ceil((count ?? 0) / pageSize),
|
pageCount: Math.ceil((count ?? 0) / pageSize),
|
||||||
|
|||||||
@@ -119,8 +119,14 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: true,
|
path: "",
|
||||||
lazy: () => import("../../routes/products/product-list"),
|
lazy: () => import("../../routes/products/product-list"),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "create",
|
||||||
|
lazy: () => import("../../routes/products/product-create"),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ":id",
|
path: ":id",
|
||||||
@@ -128,6 +134,30 @@ const router = createBrowserRouter([
|
|||||||
handle: {
|
handle: {
|
||||||
crumb: (data: AdminProductsRes) => data.product.title,
|
crumb: (data: AdminProductsRes) => data.product.title,
|
||||||
},
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "edit",
|
||||||
|
lazy: () => import("../../routes/products/product-edit"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "sales-channels",
|
||||||
|
lazy: () =>
|
||||||
|
import("../../routes/products/product-sales-channels"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "attributes",
|
||||||
|
lazy: () =>
|
||||||
|
import("../../routes/products/product-attributes"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "options",
|
||||||
|
lazy: () => import("../../routes/products/product-options"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "gallery",
|
||||||
|
lazy: () => import("../../routes/products/product-gallery"),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -107,7 +107,7 @@ export const CollectionProductSection = ({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
search
|
search
|
||||||
pagination
|
pagination
|
||||||
rowCount={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
navigateTo={({ original }) => `/products/${original.id}`}
|
navigateTo={({ original }) => `/products/${original.id}`}
|
||||||
count={count}
|
count={count}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@ export const CollectionListTable = () => {
|
|||||||
<DataTable
|
<DataTable
|
||||||
table={table}
|
table={table}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowCount={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
count={count}
|
count={count}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
orderBy={["title", "handle", "created_at", "updated_at"]}
|
orderBy={["title", "handle", "created_at", "updated_at"]}
|
||||||
|
|||||||
+1
-1
@@ -96,7 +96,7 @@ export const CustomerGroupCustomerSection = ({
|
|||||||
<DataTable
|
<DataTable
|
||||||
table={table}
|
table={table}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowCount={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
count={count}
|
count={count}
|
||||||
navigateTo={(row) => `/customers/${row.id}`}
|
navigateTo={(row) => `/customers/${row.id}`}
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ export const CustomerGroupListTable = () => {
|
|||||||
<DataTable
|
<DataTable
|
||||||
table={table}
|
table={table}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowCount={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
count={count}
|
count={count}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
search
|
search
|
||||||
|
|||||||
+1
-1
@@ -72,7 +72,7 @@ export const CustomerOrderSection = ({
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
count={count}
|
count={count}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
rowCount={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
orderBy={["display_id", "created_at", "updated_at"]}
|
orderBy={["display_id", "created_at", "updated_at"]}
|
||||||
search={true}
|
search={true}
|
||||||
queryObject={raw}
|
queryObject={raw}
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ export const CustomerListTable = () => {
|
|||||||
<DataTable
|
<DataTable
|
||||||
table={table}
|
table={table}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowCount={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
count={count}
|
count={count}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
orderBy={[
|
orderBy={[
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,7 @@ export const GiftCardListTable = () => {
|
|||||||
count={count}
|
count={count}
|
||||||
search
|
search
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
rowCount={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
orderBy={["created_at", "updated_at"]}
|
orderBy={["created_at", "updated_at"]}
|
||||||
queryObject={raw}
|
queryObject={raw}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ export const OrderListTable = () => {
|
|||||||
count={count}
|
count={count}
|
||||||
search
|
search
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
rowCount={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
orderBy={["display_id", "created_at", "updated_at"]}
|
orderBy={["display_id", "created_at", "updated_at"]}
|
||||||
queryObject={raw}
|
queryObject={raw}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./product-attributes-form"
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Product } from "@medusajs/medusa"
|
||||||
|
import { Button, Drawer, Input } from "@medusajs/ui"
|
||||||
|
import { useAdminUpdateProduct } from "medusa-react"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import * as zod from "zod"
|
||||||
|
|
||||||
|
import { CountrySelect } from "../../../../../components/common/country-select"
|
||||||
|
import { Form } from "../../../../../components/common/form"
|
||||||
|
|
||||||
|
type ProductAttributesFormProps = {
|
||||||
|
product: Product
|
||||||
|
subscribe: (state: boolean) => void
|
||||||
|
onSuccessfulSubmit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const dimension = zod
|
||||||
|
.union([zod.string(), zod.number()])
|
||||||
|
.transform((value) => {
|
||||||
|
if (value === "") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Number(value)
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
|
||||||
|
const ProductAttributesSchema = zod.object({
|
||||||
|
weight: dimension,
|
||||||
|
length: dimension,
|
||||||
|
width: dimension,
|
||||||
|
height: dimension,
|
||||||
|
mid_code: zod.string().optional(),
|
||||||
|
hs_code: zod.string().optional(),
|
||||||
|
origin_country: zod.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ProductAttributesForm = ({
|
||||||
|
product,
|
||||||
|
subscribe,
|
||||||
|
onSuccessfulSubmit,
|
||||||
|
}: ProductAttributesFormProps) => {
|
||||||
|
const form = useForm<zod.infer<typeof ProductAttributesSchema>>({
|
||||||
|
defaultValues: {
|
||||||
|
height: product.height ? product.height : null,
|
||||||
|
width: product.width ? product.width : null,
|
||||||
|
length: product.length ? product.length : null,
|
||||||
|
weight: product.weight ? product.weight : null,
|
||||||
|
mid_code: product.mid_code || "",
|
||||||
|
hs_code: product.hs_code || "",
|
||||||
|
origin_country: product.origin_country || "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ProductAttributesSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
formState: { isDirty },
|
||||||
|
} = form
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
subscribe(isDirty)
|
||||||
|
}, [isDirty, subscribe])
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { mutateAsync, isLoading } = useAdminUpdateProduct(product.id)
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit(async (data) => {
|
||||||
|
await mutateAsync(
|
||||||
|
{
|
||||||
|
weight: data.weight ? data.weight : undefined,
|
||||||
|
length: data.length ? data.length : undefined,
|
||||||
|
width: data.width ? data.width : undefined,
|
||||||
|
height: data.height ? data.height : undefined,
|
||||||
|
mid_code: data.mid_code,
|
||||||
|
hs_code: data.hs_code,
|
||||||
|
origin_country: data.origin_country,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
onSuccessfulSubmit()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleSubmit} className="flex h-full flex-col">
|
||||||
|
<Drawer.Body>
|
||||||
|
<div className="flex h-full flex-col gap-y-8">
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="width"
|
||||||
|
render={({ field: { onChange, value, ...field } }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.width")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
|
||||||
|
if (value === "") {
|
||||||
|
onChange(null)
|
||||||
|
} else {
|
||||||
|
onChange(parseFloat(value))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="height"
|
||||||
|
render={({ field: { onChange, value, ...field } }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.height")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
|
||||||
|
if (value === "") {
|
||||||
|
onChange(null)
|
||||||
|
} else {
|
||||||
|
onChange(Number(value))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="length"
|
||||||
|
render={({ field: { onChange, value, ...field } }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.length")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
|
||||||
|
if (value === "") {
|
||||||
|
onChange(null)
|
||||||
|
} else {
|
||||||
|
onChange(Number(value))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="weight"
|
||||||
|
render={({ field: { onChange, value, ...field } }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.weight")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
|
||||||
|
if (value === "") {
|
||||||
|
onChange(null)
|
||||||
|
} else {
|
||||||
|
onChange(Number(value))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="mid_code"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.midCode")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="hs_code"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.hsCode")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="origin_country"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.countryOfOrigin")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<CountrySelect {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Drawer.Body>
|
||||||
|
<Drawer.Footer>
|
||||||
|
<div className="flex items-center justify-end gap-x-2">
|
||||||
|
<Drawer.Close asChild>
|
||||||
|
<Button size="small" variant="secondary">
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
</Drawer.Close>
|
||||||
|
<Button size="small" type="submit" isLoading={isLoading}>
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Drawer.Footer>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ProductAttributes as Component } from "./product-attributes"
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
import { Drawer, Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminProduct } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
|
||||||
|
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
|
||||||
|
import { ProductAttributesForm } from "./components/product-attributes-form"
|
||||||
|
|
||||||
|
export const ProductAttributes = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
const [open, onOpenChange, subscribe] = useRouteModalState()
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { product, isLoading, isError, error } = useAdminProduct(id!)
|
||||||
|
|
||||||
|
const handleSuccessfulSubmit = () => {
|
||||||
|
onOpenChange(false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Drawer.Content>
|
||||||
|
<Drawer.Header>
|
||||||
|
<Heading>{t("products.editAttributes")}</Heading>
|
||||||
|
</Drawer.Header>
|
||||||
|
{!isLoading && product && (
|
||||||
|
<ProductAttributesForm
|
||||||
|
product={product}
|
||||||
|
onSuccessfulSubmit={handleSuccessfulSubmit}
|
||||||
|
subscribe={subscribe}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Drawer.Content>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
+366
@@ -0,0 +1,366 @@
|
|||||||
|
import { Button, Checkbox, Heading, Input, Text, Textarea } from "@medusajs/ui"
|
||||||
|
import { Trans, useTranslation } from "react-i18next"
|
||||||
|
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"
|
||||||
|
|
||||||
|
import { SalesChannel } from "@medusajs/medusa"
|
||||||
|
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
|
||||||
|
import { useAdminSalesChannels } from "medusa-react"
|
||||||
|
import { Fragment, useMemo, useState } from "react"
|
||||||
|
import { CountrySelect } from "../../../../../components/common/country-select"
|
||||||
|
import { Form } from "../../../../../components/common/form"
|
||||||
|
import { HandleInput } from "../../../../../components/common/handle-input"
|
||||||
|
import { DataTable } from "../../../../../components/table/data-table"
|
||||||
|
import { useSalesChannelTableColumns } from "../../../../../hooks/table/columns/use-sales-channel-table-columns"
|
||||||
|
import { useSalesChannelTableFilters } from "../../../../../hooks/table/filters/use-sales-channel-table-filters"
|
||||||
|
import { useSalesChannelTableQuery } from "../../../../../hooks/table/query/use-sales-channel-table-query"
|
||||||
|
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||||
|
import { CreateProductFormReturn } from "./create-product-form"
|
||||||
|
|
||||||
|
type CreateProductPropsProps = {
|
||||||
|
form: CreateProductFormReturn
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, onOpenChange] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
className="flex h-full justify-center overflow-hidden"
|
||||||
|
>
|
||||||
|
<Panel
|
||||||
|
className="flex h-full w-full flex-col items-center"
|
||||||
|
minSize={open ? 50 : 100}
|
||||||
|
>
|
||||||
|
<div className="flex size-full flex-col items-center overflow-auto p-16">
|
||||||
|
<div className="flex w-full max-w-[736px] flex-col justify-center px-2 pb-2">
|
||||||
|
<div className="flex flex-col gap-y-1">
|
||||||
|
<Heading>{t("products.createProductTitle")}</Heading>
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
{t("products.createProductHint")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-y-8 divide-y [&>div]:pt-8">
|
||||||
|
<div id="general" className="flex flex-col gap-y-8">
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-x-4">
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.title")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="subtitle"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label optional>
|
||||||
|
{t("fields.subtitle")}
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Text
|
||||||
|
size="small"
|
||||||
|
leading="compact"
|
||||||
|
className="text-ui-fg-subtle"
|
||||||
|
>
|
||||||
|
<Trans
|
||||||
|
i18nKey="products.titleHint"
|
||||||
|
t={t}
|
||||||
|
components={[<br key="break" />]}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4">
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="handle"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label
|
||||||
|
tooltip={t("products.handleTooltip")}
|
||||||
|
optional
|
||||||
|
>
|
||||||
|
{t("fields.handle")}
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<HandleInput {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="material"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label optional>
|
||||||
|
{t("fields.material")}
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label optional>
|
||||||
|
{t("fields.description")}
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Textarea {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.Hint>
|
||||||
|
<Trans
|
||||||
|
i18nKey={"products.descriptionHint"}
|
||||||
|
components={[<br key="break" />]}
|
||||||
|
/>
|
||||||
|
</Form.Hint>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div id="organize" className="flex flex-col gap-y-8">
|
||||||
|
<Heading level="h2">{t("products.organization")}</Heading>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onOpenChange(!open)}
|
||||||
|
>
|
||||||
|
{t("actions.edit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div id="attributes" className="flex flex-col gap-y-8">
|
||||||
|
<Heading level="h2">{t("products.attributes")}</Heading>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="width"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label optional>{t("fields.width")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} type="number" min={0} />
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="length"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label optional>{t("fields.length")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} type="number" min={0} />
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="height"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label optional>{t("fields.height")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} type="number" min={0} />
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="weight"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label optional>{t("fields.weight")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} type="number" min={0} />
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="origin_country"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label optional>
|
||||||
|
{t("fields.countryOfOrigin")}
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<CountrySelect {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<Fragment>
|
||||||
|
<PanelResizeHandle className="bg-ui-bg-subtle group flex items-center justify-center border-x px-[4.5px] outline-none">
|
||||||
|
{/* Is this meant to be resizable? And if so we need some kind of focus state for the handle cc: @ludvig18 */}
|
||||||
|
<div className="bg-ui-fg-disabled group-focus-visible:bg-ui-fg-muted transition-fg h-6 w-[3px] rounded-full" />
|
||||||
|
</PanelResizeHandle>
|
||||||
|
<Panel defaultSize={50} maxSize={50} minSize={40}>
|
||||||
|
<AddSalesChannelsDrawer onCancel={() => onOpenChange(false)} />
|
||||||
|
</Panel>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</PanelGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const AddSalesChannelsDrawer = ({ onCancel }: { onCancel: () => void }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [selection, setSelection] = useState<RowSelectionState>({})
|
||||||
|
|
||||||
|
const { searchParams, raw } = useSalesChannelTableQuery({
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
const { sales_channels, count, isLoading, isError, error } =
|
||||||
|
useAdminSalesChannels({
|
||||||
|
...searchParams,
|
||||||
|
})
|
||||||
|
|
||||||
|
const filters = useSalesChannelTableFilters()
|
||||||
|
const columns = useColumns()
|
||||||
|
|
||||||
|
const { table } = useDataTable({
|
||||||
|
data: sales_channels ?? [],
|
||||||
|
columns,
|
||||||
|
count: sales_channels?.length ?? 0,
|
||||||
|
enablePagination: true,
|
||||||
|
enableRowSelection: true,
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
rowSelection: {
|
||||||
|
state: selection,
|
||||||
|
updater: setSelection,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex-1">
|
||||||
|
<DataTable
|
||||||
|
table={table}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
filters={filters}
|
||||||
|
isLoading={isLoading}
|
||||||
|
layout="fill"
|
||||||
|
orderBy={["name", "created_at", "updated_at"]}
|
||||||
|
queryObject={raw}
|
||||||
|
search
|
||||||
|
pagination
|
||||||
|
count={count}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-x-2 border-t px-6 py-4">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onCancel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => {}} type="button">
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<SalesChannel>()
|
||||||
|
|
||||||
|
const useColumns = () => {
|
||||||
|
const base = useSalesChannelTableColumns()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
columnHelper.display({
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
table.getIsSomePageRowsSelected()
|
||||||
|
? "indeterminate"
|
||||||
|
: table.getIsAllPageRowsSelected()
|
||||||
|
}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
table.toggleAllPageRowsSelected(!!value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...base,
|
||||||
|
],
|
||||||
|
[base]
|
||||||
|
)
|
||||||
|
}
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Button, FocusModal } from "@medusajs/ui"
|
||||||
|
import { useAdminCreateProduct } from "medusa-react"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { UseFormReturn, useForm } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import * as zod from "zod"
|
||||||
|
|
||||||
|
import { Form } from "../../../../../components/common/form"
|
||||||
|
import { CreateProductDetails } from "./create-product-details"
|
||||||
|
|
||||||
|
type CreateProductFormProps = {
|
||||||
|
subscribe: (state: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateProductSchema = zod.object({
|
||||||
|
title: zod.string(),
|
||||||
|
subtitle: zod.string().optional(),
|
||||||
|
handle: zod.string().optional(),
|
||||||
|
material: zod.string().optional(),
|
||||||
|
description: zod.string().optional(),
|
||||||
|
discountable: zod.boolean(),
|
||||||
|
sales_channels: zod.array(zod.string()).optional(),
|
||||||
|
width: zod.string().optional(),
|
||||||
|
length: zod.string().optional(),
|
||||||
|
height: zod.string().optional(),
|
||||||
|
weight: zod.string().optional(),
|
||||||
|
origin_country: zod.string().optional(),
|
||||||
|
mid_code: zod.string().optional(),
|
||||||
|
hs_code: zod.string().optional(),
|
||||||
|
variants: zod.array(
|
||||||
|
zod.object({
|
||||||
|
variant_rank: zod.number(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
type Schema = zod.infer<typeof CreateProductSchema>
|
||||||
|
export type CreateProductFormReturn = UseFormReturn<Schema>
|
||||||
|
|
||||||
|
export const CreateProductForm = ({ subscribe }: CreateProductFormProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const form = useForm<Schema>({
|
||||||
|
defaultValues: {
|
||||||
|
title: "",
|
||||||
|
subtitle: "",
|
||||||
|
handle: "",
|
||||||
|
material: "",
|
||||||
|
description: "",
|
||||||
|
discountable: true,
|
||||||
|
height: "",
|
||||||
|
length: "",
|
||||||
|
weight: "",
|
||||||
|
width: "",
|
||||||
|
origin_country: "",
|
||||||
|
mid_code: "",
|
||||||
|
hs_code: "",
|
||||||
|
sales_channels: [],
|
||||||
|
variants: [],
|
||||||
|
},
|
||||||
|
resolver: zodResolver(CreateProductSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
formState: { isDirty },
|
||||||
|
} = form
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
subscribe(isDirty)
|
||||||
|
}, [subscribe, isDirty])
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = useAdminCreateProduct()
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit(async (values) => {
|
||||||
|
await mutateAsync(
|
||||||
|
{
|
||||||
|
title: values.title,
|
||||||
|
discountable: values.discountable,
|
||||||
|
is_giftcard: false,
|
||||||
|
width: values.width ? parseFloat(values.width) : undefined,
|
||||||
|
length: values.length ? parseFloat(values.length) : undefined,
|
||||||
|
height: values.height ? parseFloat(values.height) : undefined,
|
||||||
|
weight: values.weight ? parseFloat(values.weight) : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleSubmit} className="flex h-full flex-col">
|
||||||
|
<FocusModal.Header>
|
||||||
|
<div className="flex items-center justify-end gap-x-2">
|
||||||
|
<FocusModal.Close asChild>
|
||||||
|
<Button variant="secondary" size="small">
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
</FocusModal.Close>
|
||||||
|
<Button type="submit" size="small" isLoading={isLoading}>
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FocusModal.Header>
|
||||||
|
<FocusModal.Body className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex flex-1 flex-col items-center overflow-y-auto">
|
||||||
|
<div className="flex h-full w-full">
|
||||||
|
<CreateProductDetails form={form} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FocusModal.Body>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./create-product-form"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ProductCreate as Component } from "./product-create"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { FocusModal } from "@medusajs/ui"
|
||||||
|
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
|
||||||
|
import { CreateProductForm } from "./components/create-product-form"
|
||||||
|
|
||||||
|
export const ProductCreate = () => {
|
||||||
|
const [open, onOpenChange, subscribe] = useRouteModalState()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FocusModal open={open} onOpenChange={onOpenChange}>
|
||||||
|
<FocusModal.Content>
|
||||||
|
<CreateProductForm subscribe={subscribe} />
|
||||||
|
</FocusModal.Content>
|
||||||
|
</FocusModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
+85
-11
@@ -1,18 +1,92 @@
|
|||||||
import { Product } from "@medusajs/medusa";
|
import { PencilSquare } from "@medusajs/icons"
|
||||||
import { Container, Heading } from "@medusajs/ui";
|
import { Product } from "@medusajs/medusa"
|
||||||
|
import { Container, Heading, Text } from "@medusajs/ui"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
|
||||||
type ProductAttributeSectionProps = {
|
type ProductAttributeSectionProps = {
|
||||||
product: Product;
|
product: Product
|
||||||
};
|
}
|
||||||
|
|
||||||
export const ProductAttributeSection = ({
|
export const ProductAttributeSection = ({
|
||||||
product,
|
product,
|
||||||
}: ProductAttributeSectionProps) => {
|
}: ProductAttributeSectionProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Container className="divide-y p-0">
|
||||||
<Container>
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<Heading level="h2">Attributes</Heading>
|
<Heading level="h2">{t("products.attributes")}</Heading>
|
||||||
</Container>
|
<ActionMenu
|
||||||
</div>
|
groups={[
|
||||||
);
|
{
|
||||||
};
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("actions.edit"),
|
||||||
|
to: "attributes",
|
||||||
|
icon: <PencilSquare />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{t("fields.height")}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" leading="compact" className="text-pretty">
|
||||||
|
{product.height ?? "-"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{t("fields.width")}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" leading="compact">
|
||||||
|
{product.width ?? "-"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{t("fields.length")}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" leading="compact">
|
||||||
|
{product.length ?? "-"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{t("fields.weight")}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" leading="compact">
|
||||||
|
{product.weight ?? "-"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{t("fields.midCode")}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" leading="compact">
|
||||||
|
{product.mid_code ?? "-"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{t("fields.hsCode")}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" leading="compact">
|
||||||
|
{product.hs_code ?? "-"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{t("fields.countryOfOrigin")}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" leading="compact">
|
||||||
|
{product.origin_country ?? "-"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
+95
-97
@@ -1,109 +1,107 @@
|
|||||||
import { EllipseGreenSolid, EllipsisHorizontal } from "@medusajs/icons";
|
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||||
import { Product, ProductTag } from "@medusajs/medusa";
|
import { Product } from "@medusajs/medusa"
|
||||||
import {
|
import { Container, Heading, StatusBadge, Text, usePrompt } from "@medusajs/ui"
|
||||||
Badge,
|
import { useAdminDeleteProduct } from "medusa-react"
|
||||||
Button,
|
import { useTranslation } from "react-i18next"
|
||||||
Container,
|
import { useNavigate } from "react-router-dom"
|
||||||
Heading,
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
IconButton,
|
|
||||||
Text,
|
|
||||||
} from "@medusajs/ui";
|
|
||||||
import { type ReactNode } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
type ProductGeneralSectionProps = {
|
type ProductGeneralSectionProps = {
|
||||||
product: Product;
|
product: Product
|
||||||
};
|
}
|
||||||
|
|
||||||
export const ProductGeneralSection = ({
|
export const ProductGeneralSection = ({
|
||||||
product,
|
product,
|
||||||
}: ProductGeneralSectionProps) => {
|
}: ProductGeneralSectionProps) => {
|
||||||
return (
|
const { t } = useTranslation()
|
||||||
<div>
|
const prompt = usePrompt()
|
||||||
<Container>
|
const navigate = useNavigate()
|
||||||
<div className="flex flex-col gap-y-6">
|
|
||||||
<div className="flex flex-col gap-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Heading>{product.title}</Heading>
|
|
||||||
<div className="flex items-center gap-x-2">
|
|
||||||
<Button variant="secondary">
|
|
||||||
<EllipseGreenSolid />
|
|
||||||
Published
|
|
||||||
</Button>
|
|
||||||
<IconButton>
|
|
||||||
<EllipsisHorizontal />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="max-w-[90%]">
|
|
||||||
<Text className="text-ui-fg-subtle">{product.description}</Text>
|
|
||||||
</div>
|
|
||||||
<ProductTags tags={product.tags} />
|
|
||||||
</div>
|
|
||||||
<ProductDetails product={product} />
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProductTagsProps = {
|
const { mutateAsync } = useAdminDeleteProduct(product.id)
|
||||||
tags?: ProductTag[] | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProductTags = ({ tags }: ProductTagsProps) => {
|
const handleDelete = async () => {
|
||||||
if (!tags || tags.length === 0) {
|
const res = await prompt({
|
||||||
return null;
|
title: t("general.areYouSure"),
|
||||||
|
description: t("products.deleteWarning", {
|
||||||
|
title: product.title,
|
||||||
|
}),
|
||||||
|
confirmText: t("actions.delete"),
|
||||||
|
cancelText: t("actions.cancel"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await mutateAsync(undefined, {
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate("..")
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex flex-wrap gap-2">
|
<Container className="divide-y p-0">
|
||||||
{tags.map((t) => {
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
return (
|
<Heading>{product.title}</Heading>
|
||||||
<Badge key={t.id} color="grey">
|
<div className="flex items-center gap-x-4">
|
||||||
{t.value}
|
<StatusBadge color="green">Published</StatusBadge>
|
||||||
</Badge>
|
<ActionMenu
|
||||||
);
|
groups={[
|
||||||
})}
|
{
|
||||||
</div>
|
actions: [
|
||||||
);
|
{
|
||||||
};
|
label: t("actions.edit"),
|
||||||
|
to: "edit",
|
||||||
type ProductDetailProps = {
|
icon: <PencilSquare />,
|
||||||
label: string;
|
},
|
||||||
value?: ReactNode;
|
],
|
||||||
};
|
},
|
||||||
|
{
|
||||||
const ProductDetail = ({ label, value }: ProductDetailProps) => {
|
actions: [
|
||||||
return (
|
{
|
||||||
<div className="flex items-center justify-between">
|
label: t("actions.delete"),
|
||||||
<Text>{label}</Text>
|
onClick: handleDelete,
|
||||||
<Text>{value ? value : "-"}</Text>
|
icon: <Trash />,
|
||||||
</div>
|
},
|
||||||
);
|
],
|
||||||
};
|
},
|
||||||
|
]}
|
||||||
type ProductDetailsProps = {
|
/>
|
||||||
product: Product;
|
</div>
|
||||||
};
|
|
||||||
|
|
||||||
const ProductDetails = ({ product }: ProductDetailsProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-y-2">
|
|
||||||
<Text weight="plus">{t("general.details")}</Text>
|
|
||||||
<div className="flex flex-col gap-y-2 text-ui-fg-subtle">
|
|
||||||
<ProductDetail label={t("fields.subtitle")} value={product.subtitle} />
|
|
||||||
<ProductDetail label={t("fields.handle")} value={product.handle} />
|
|
||||||
<ProductDetail label={t("fields.categories")} value={undefined} />
|
|
||||||
<ProductDetail label={t("fields.type")} value={undefined} />
|
|
||||||
<ProductDetail label={t("fields.collection")} value={undefined} />
|
|
||||||
<ProductDetail
|
|
||||||
label={t("fields.discountable")}
|
|
||||||
value={product.discountable ? "True" : "False"}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
|
||||||
);
|
<Text size="small" weight="plus" leading="compact">
|
||||||
};
|
{t("fields.description")}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" leading="compact" className="text-pretty">
|
||||||
|
{product.description}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{t("fields.subtitle")}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" leading="compact" className="text-pretty">
|
||||||
|
{product.subtitle ?? "-"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{t("fields.handle")}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" leading="compact" className="text-pretty">
|
||||||
|
/{product.handle}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{t("fields.discountable")}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" leading="compact" className="text-pretty">
|
||||||
|
{product.discountable ? t("fields.true") : t("fields.false")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
+169
-24
@@ -1,36 +1,181 @@
|
|||||||
import { Product } from "@medusajs/medusa";
|
import { PencilSquare, Photo, ThumbnailBadge } from "@medusajs/icons"
|
||||||
import { Container, Heading } from "@medusajs/ui";
|
import { Product } from "@medusajs/medusa"
|
||||||
|
import {
|
||||||
|
Checkbox,
|
||||||
|
CommandBar,
|
||||||
|
Container,
|
||||||
|
Heading,
|
||||||
|
Tooltip,
|
||||||
|
clx,
|
||||||
|
usePrompt,
|
||||||
|
} from "@medusajs/ui"
|
||||||
|
import { useAdminUpdateProduct } from "medusa-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
|
||||||
type ProductMedisaSectionProps = {
|
type ProductMedisaSectionProps = {
|
||||||
product: Product;
|
product: Product
|
||||||
};
|
}
|
||||||
|
|
||||||
export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const prompt = usePrompt()
|
||||||
|
const [selection, setSelection] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
const media = getMedia(product)
|
||||||
|
|
||||||
|
const handleCheckedChange = (id: string) => {
|
||||||
|
setSelection((prev) => {
|
||||||
|
if (prev[id]) {
|
||||||
|
const { [id]: _, ...rest } = prev
|
||||||
|
return rest
|
||||||
|
} else {
|
||||||
|
return { ...prev, [id]: true }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mutateAsync } = useAdminUpdateProduct(product.id)
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
const ids = Object.keys(selection)
|
||||||
|
const includingThumbnail = ids.some(
|
||||||
|
(id) => media.find((m) => m.id === id)?.isThumbnail
|
||||||
|
)
|
||||||
|
|
||||||
|
const res = await prompt({
|
||||||
|
title: t("general.areYouSure"),
|
||||||
|
description: includingThumbnail
|
||||||
|
? t("products.deleteMediaAndThumbnail", {
|
||||||
|
count: ids.length,
|
||||||
|
})
|
||||||
|
: t("products.deleteMedia", {
|
||||||
|
count: ids.length,
|
||||||
|
}),
|
||||||
|
confirmText: t("actions.delete"),
|
||||||
|
cancelText: t("actions.cancel"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaToKeep = product.images
|
||||||
|
.filter((i) => !ids.includes(i.id))
|
||||||
|
.map((i) => i.url)
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
images: mediaToKeep,
|
||||||
|
thumbnail: includingThumbnail ? "" : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Container className="divide-y p-0">
|
||||||
<Container className="flex flex-col gap-y-4">
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<div>
|
<Heading level="h2">{t("products.media")}</Heading>
|
||||||
<Heading level="h2">Media</Heading>
|
<ActionMenu
|
||||||
</div>
|
groups={[
|
||||||
{product.images?.length > 0 && (
|
{
|
||||||
<div className="grid grid-cols-3 gap-4">
|
actions: [
|
||||||
{product.images.map((i) => {
|
{
|
||||||
return (
|
label: t("actions.edit"),
|
||||||
|
to: "media",
|
||||||
|
icon: <PencilSquare />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("products.gallery"),
|
||||||
|
to: "gallery",
|
||||||
|
icon: <Photo />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{media && (
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-4 px-6 py-4">
|
||||||
|
{media.map((i) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="shadow-elevation-card-rest hover:shadow-elevation-card-hover transition-fg group relative aspect-square size-full cursor-pointer overflow-hidden rounded-[8px]"
|
||||||
|
key={i.id}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="overflow-hidden flex items-center justify-center h-24"
|
className={clx(
|
||||||
key={i.id}
|
"transition-fg invisible absolute right-2 top-2 opacity-0 group-hover:visible group-hover:opacity-100",
|
||||||
|
{
|
||||||
|
"visible opacity-100": Object.keys(selection).length > 0,
|
||||||
|
}
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selection[i.id] || false}
|
||||||
|
onCheckedChange={() => handleCheckedChange(i.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{i.isThumbnail && (
|
||||||
|
<div className="absolute left-2 top-2">
|
||||||
|
<Tooltip content={t("fields.thumbnail")}>
|
||||||
|
<ThumbnailBadge />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Link to={`gallery?img=${i.id}`}>
|
||||||
<img
|
<img
|
||||||
src={i.url}
|
src={i.url}
|
||||||
alt={`${product.title} image`}
|
alt={`${product.title} image`}
|
||||||
className="h-full object-contain rounded-md"
|
className="size-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Link>
|
||||||
);
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<CommandBar open={!!Object.keys(selection).length}>
|
||||||
|
<CommandBar.Bar>
|
||||||
|
<CommandBar.Value>
|
||||||
|
{t("general.countSelected", {
|
||||||
|
count: Object.keys(selection).length,
|
||||||
})}
|
})}
|
||||||
</div>
|
</CommandBar.Value>
|
||||||
)}
|
<CommandBar.Seperator />
|
||||||
</Container>
|
<CommandBar.Command
|
||||||
</div>
|
action={handleDelete}
|
||||||
);
|
label={t("actions.delete")}
|
||||||
};
|
shortcut="d"
|
||||||
|
/>
|
||||||
|
</CommandBar.Bar>
|
||||||
|
</CommandBar>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Media = {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
isThumbnail: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMedia = (product: Product) => {
|
||||||
|
const { images = [], thumbnail } = product
|
||||||
|
|
||||||
|
const media: Media[] = images.map((image) => ({
|
||||||
|
id: image.id,
|
||||||
|
url: image.url,
|
||||||
|
isThumbnail: image.url === thumbnail,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (thumbnail && !media.some((mediaItem) => mediaItem.url === thumbnail)) {
|
||||||
|
media.unshift({
|
||||||
|
id: "img_thumbnail",
|
||||||
|
url: thumbnail,
|
||||||
|
isThumbnail: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return media
|
||||||
|
}
|
||||||
+60
-11
@@ -1,18 +1,67 @@
|
|||||||
import { Product } from "@medusajs/medusa";
|
import { PencilSquare } from "@medusajs/icons"
|
||||||
import { Container, Heading } from "@medusajs/ui";
|
import { Product, ProductOption } from "@medusajs/medusa"
|
||||||
|
import { Badge, Container, Heading, Text } from "@medusajs/ui"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
|
||||||
type ProductOptionSectionProps = {
|
type ProductOptionSectionProps = {
|
||||||
product: Product;
|
product: Product
|
||||||
};
|
}
|
||||||
|
|
||||||
export const ProductOptionSection = ({
|
export const ProductOptionSection = ({
|
||||||
product,
|
product,
|
||||||
}: ProductOptionSectionProps) => {
|
}: ProductOptionSectionProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Container className="divide-y p-0">
|
||||||
<Container>
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<Heading level="h2">Options</Heading>
|
<Heading level="h2">{t("products.options")}</Heading>
|
||||||
</Container>
|
<ActionMenu
|
||||||
</div>
|
groups={[
|
||||||
);
|
{
|
||||||
};
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("actions.edit"),
|
||||||
|
to: "options",
|
||||||
|
icon: <PencilSquare />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{product.options.map((option) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.id}
|
||||||
|
className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4"
|
||||||
|
>
|
||||||
|
<Text size="small" leading="compact" weight="plus">
|
||||||
|
{option.title}
|
||||||
|
</Text>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{getUnqiueValues(option).map((value) => {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={value}
|
||||||
|
size="2xsmall"
|
||||||
|
className="flex min-w-[20px] items-center justify-center"
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUnqiueValues = (option: ProductOption) => {
|
||||||
|
const values = option.values.map((v) => v.value)
|
||||||
|
|
||||||
|
return Array.from(new Set(values))
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./product-organization-section"
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
import { PencilSquare } from "@medusajs/icons"
|
||||||
|
import { Product } from "@medusajs/medusa"
|
||||||
|
import { Badge, Container, Heading, Text } from "@medusajs/ui"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
|
||||||
|
type ProductOrganizationSectionProps = {
|
||||||
|
product: Product
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProductOrganizationSection = ({
|
||||||
|
product,
|
||||||
|
}: ProductOrganizationSectionProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="divide-y p-0">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<Heading level="h2">{t("products.organization")}</Heading>
|
||||||
|
<ActionMenu
|
||||||
|
groups={[
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("actions.edit"),
|
||||||
|
to: "organization",
|
||||||
|
icon: <PencilSquare />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{t("fields.tags")}
|
||||||
|
</Text>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{product.tags.length > 0
|
||||||
|
? product.tags.map((tag) => (
|
||||||
|
<Badge key={tag.id} className="w-fit" size="2xsmall" asChild>
|
||||||
|
<Link to={`/products?tags=${tag.id}`}>{tag.value}</Link>
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{t("fields.type")}
|
||||||
|
</Text>
|
||||||
|
{product.type ? (
|
||||||
|
<Badge size="2xsmall" className="w-fit" asChild>
|
||||||
|
<Link to={`/products?type_id=${product.type_id}`}>
|
||||||
|
{product.type.value}
|
||||||
|
</Link>
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Text size="small" leading="compact">
|
||||||
|
-
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{t("fields.collection")}
|
||||||
|
</Text>
|
||||||
|
{product.collection ? (
|
||||||
|
<Badge size="2xsmall" color="blue" className="w-fit" asChild>
|
||||||
|
<Link to={`/collections/${product.collection.id}`}>
|
||||||
|
{product.collection.title}
|
||||||
|
</Link>
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Text size="small" leading="compact">
|
||||||
|
-
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
|
||||||
|
<Text size="small" weight="plus" leading="compact">
|
||||||
|
{t("fields.categories")}
|
||||||
|
</Text>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{product.categories.length > 0
|
||||||
|
? product.categories.map((pcat) => (
|
||||||
|
<Badge
|
||||||
|
key={pcat.id}
|
||||||
|
className="w-fit"
|
||||||
|
color="purple"
|
||||||
|
size="2xsmall"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link to={`/categories/${pcat.id}`}>{pcat.name}</Link>
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
+89
-38
@@ -1,54 +1,105 @@
|
|||||||
import { Channels } from "@medusajs/icons";
|
import { Channels, PencilSquare } from "@medusajs/icons"
|
||||||
import { Product } from "@medusajs/medusa";
|
import { Product } from "@medusajs/medusa"
|
||||||
import { Container, Heading, Text } from "@medusajs/ui";
|
import { Container, Heading, Text, Tooltip } from "@medusajs/ui"
|
||||||
import { useAdminSalesChannels } from "medusa-react";
|
import { useAdminSalesChannels } from "medusa-react"
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next"
|
||||||
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
|
||||||
type ProductSalesChannelSectionProps = {
|
type ProductSalesChannelSectionProps = {
|
||||||
product: Product;
|
product: Product
|
||||||
};
|
}
|
||||||
|
|
||||||
export const ProductSalesChannelSection = ({
|
export const ProductSalesChannelSection = ({
|
||||||
product,
|
product,
|
||||||
}: ProductSalesChannelSectionProps) => {
|
}: ProductSalesChannelSectionProps) => {
|
||||||
const { count } = useAdminSalesChannels();
|
const { count } = useAdminSalesChannels()
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const availableInSalesChannels =
|
const availableInSalesChannels =
|
||||||
product.sales_channels?.map((sc) => ({
|
product.sales_channels?.map((sc) => ({
|
||||||
id: sc.id,
|
id: sc.id,
|
||||||
name: sc.name,
|
name: sc.name,
|
||||||
})) ?? [];
|
})) ?? []
|
||||||
|
|
||||||
|
const firstChannels = availableInSalesChannels.slice(0, 3)
|
||||||
|
const restChannels = availableInSalesChannels.slice(3)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Container className="flex flex-col gap-y-4 px-6 py-4">
|
||||||
<Container className="flex flex-col gap-y-4">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<Heading level="h2">{t("fields.sales_channels")}</Heading>
|
||||||
<Heading level="h2">{t("fields.sales_channels")}</Heading>
|
<ActionMenu
|
||||||
</div>
|
groups={[
|
||||||
<div>
|
{
|
||||||
<div className="w-10 h-10 rounded-[8px] bg-ui-bg-base shadow-borders-base flex items-center justify-center">
|
actions: [
|
||||||
<div className="h-8 w-8 rounded-[4px] bg-ui-bg-component flex items-center justify-center">
|
{
|
||||||
<Channels className="text-ui-fg-subtle" />
|
label: t("actions.edit"),
|
||||||
</div>
|
to: "sales-channels",
|
||||||
|
icon: <PencilSquare />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[28px_1fr] items-center gap-x-3">
|
||||||
|
<div className="bg-ui-bg-base shadow-borders-base flex size-7 items-center justify-center rounded-md">
|
||||||
|
<div className="bg-ui-bg-component flex size-6 items-center justify-center rounded-[4px]">
|
||||||
|
<Channels className="text-ui-fg-subtle" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{availableInSalesChannels.length > 0 ? (
|
||||||
<Text className="text-ui-fg-subtle">
|
<div className="flex items-center gap-x-1">
|
||||||
<Trans
|
<Text size="small" leading="compact">
|
||||||
i18nKey="products.availableInSalesChannels"
|
{firstChannels.map((sc) => sc.name).join(", ")}
|
||||||
values={{
|
</Text>
|
||||||
x: availableInSalesChannels.length,
|
{restChannels.length > 0 && (
|
||||||
y: count ?? 0,
|
<Tooltip
|
||||||
}}
|
content={
|
||||||
components={[
|
<ul>
|
||||||
<span className="text-ui-fg-base txt-compact-medium-plus" />,
|
{restChannels.map((sc) => (
|
||||||
<span className="text-ui-fg-base txt-compact-medium-plus" />,
|
<li key={sc.id}>{sc.name}</li>
|
||||||
]}
|
))}
|
||||||
/>
|
</ul>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="small"
|
||||||
|
leading="compact"
|
||||||
|
className="text-ui-fg-subtle"
|
||||||
|
>
|
||||||
|
{`+${restChannels.length}`}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text size="small" leading="compact" className="text-ui-fg-subtle">
|
||||||
|
{t("products.noSalesChannels")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
)}
|
||||||
</Container>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
);
|
<Text className="text-ui-fg-subtle" size="small" leading="compact">
|
||||||
};
|
<Trans
|
||||||
|
i18nKey="products.availableInSalesChannels"
|
||||||
|
values={{
|
||||||
|
x: availableInSalesChannels.length,
|
||||||
|
y: count ?? 0,
|
||||||
|
}}
|
||||||
|
components={[
|
||||||
|
<span
|
||||||
|
key="x"
|
||||||
|
className="text-ui-fg-base txt-compact-medium-plus"
|
||||||
|
/>,
|
||||||
|
<span
|
||||||
|
key="y"
|
||||||
|
className="text-ui-fg-base txt-compact-medium-plus"
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
export * from "./product-thumbnail-section";
|
|
||||||
-31
@@ -1,31 +0,0 @@
|
|||||||
import { Product } from "@medusajs/medusa";
|
|
||||||
import { Container, Heading } from "@medusajs/ui";
|
|
||||||
|
|
||||||
type ProductThumbnailSectionProps = {
|
|
||||||
product: Product;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProductThumbnailSection = ({
|
|
||||||
product,
|
|
||||||
}: ProductThumbnailSectionProps) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Container className="flex flex-col gap-y-4">
|
|
||||||
<div>
|
|
||||||
<Heading level="h2">Thumbnail</Heading>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
{product.thumbnail && (
|
|
||||||
<div className="overflow-hidden flex items-center justify-center h-24">
|
|
||||||
<img
|
|
||||||
src={product.thumbnail}
|
|
||||||
alt={`${product.title} thumbnail`}
|
|
||||||
className="h-full object-contain rounded-md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export * from "./product-variant-section";
|
export * from "./product-variant-section"
|
||||||
+83
-11
@@ -1,18 +1,90 @@
|
|||||||
import { Product } from "@medusajs/medusa";
|
import { PencilSquare } from "@medusajs/icons"
|
||||||
import { Container, Heading } from "@medusajs/ui";
|
import { Product } from "@medusajs/medusa"
|
||||||
|
import { Container, Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminProductVariants } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
import { DataTable } from "../../../../../components/table/data-table"
|
||||||
|
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||||
|
import { useProductVariantTableColumns } from "./use-variant-table-columns"
|
||||||
|
import { useProductVariantTableFilters } from "./use-variant-table-filters"
|
||||||
|
import { useProductVariantTableQuery } from "./use-variant-table-query"
|
||||||
|
|
||||||
type ProductVariantSectionProps = {
|
type ProductVariantSectionProps = {
|
||||||
product: Product;
|
product: Product
|
||||||
};
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
export const ProductVariantSection = ({
|
export const ProductVariantSection = ({
|
||||||
product,
|
product,
|
||||||
}: ProductVariantSectionProps) => {
|
}: ProductVariantSectionProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { searchParams, raw } = useProductVariantTableQuery({
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
const { variants, count, isLoading, isError, error } =
|
||||||
|
useAdminProductVariants(
|
||||||
|
product.id,
|
||||||
|
{
|
||||||
|
...searchParams,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const filters = useProductVariantTableFilters()
|
||||||
|
const columns = useProductVariantTableColumns(product)
|
||||||
|
|
||||||
|
const { table } = useDataTable({
|
||||||
|
data: variants ?? [],
|
||||||
|
columns,
|
||||||
|
count,
|
||||||
|
enablePagination: true,
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
meta: {
|
||||||
|
product,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Container className="divide-y p-0">
|
||||||
<Container>
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<Heading level="h2">Variants</Heading>
|
<Heading level="h2">{t("products.variants")}</Heading>
|
||||||
</Container>
|
<ActionMenu
|
||||||
</div>
|
groups={[
|
||||||
);
|
{
|
||||||
};
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("actions.edit"),
|
||||||
|
to: "variants",
|
||||||
|
icon: <PencilSquare />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
table={table}
|
||||||
|
columns={columns}
|
||||||
|
filters={filters}
|
||||||
|
count={count}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
isLoading={isLoading}
|
||||||
|
orderBy={["title", "created_at", "updated_at"]}
|
||||||
|
pagination
|
||||||
|
search
|
||||||
|
queryObject={raw}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||||
|
import { Product, ProductVariant } from "@medusajs/medusa"
|
||||||
|
import { Badge, usePrompt } from "@medusajs/ui"
|
||||||
|
import { createColumnHelper } from "@tanstack/react-table"
|
||||||
|
import { useAdminDeleteVariant } from "medusa-react"
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
import { PlaceholderCell } from "../../../../../components/table/table-cells/common/placeholder-cell"
|
||||||
|
|
||||||
|
const VariantActions = ({
|
||||||
|
variant,
|
||||||
|
product,
|
||||||
|
}: {
|
||||||
|
variant: ProductVariant
|
||||||
|
product: Product
|
||||||
|
}) => {
|
||||||
|
const { mutateAsync } = useAdminDeleteVariant(product.id)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const prompt = usePrompt()
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
const res = await prompt({
|
||||||
|
title: t("general.areYouSure"),
|
||||||
|
description: t("products.deleteVariantWarning", {
|
||||||
|
title: variant.title,
|
||||||
|
}),
|
||||||
|
confirmText: t("actions.delete"),
|
||||||
|
cancelText: t("actions.cancel"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await mutateAsync(variant.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionMenu
|
||||||
|
groups={[
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("actions.edit"),
|
||||||
|
to: `variants/${variant.id}`,
|
||||||
|
icon: <PencilSquare />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("actions.delete"),
|
||||||
|
onClick: handleDelete,
|
||||||
|
icon: <Trash />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<ProductVariant>()
|
||||||
|
|
||||||
|
export const useProductVariantTableColumns = (product?: Product) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const optionColumns = useMemo(() => {
|
||||||
|
return product
|
||||||
|
? product.options?.map((o) => {
|
||||||
|
return columnHelper.display({
|
||||||
|
id: o.id,
|
||||||
|
header: () => (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">{o.title}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const value = row.original.options.find(
|
||||||
|
(op) => op.option_id === o.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return <PlaceholderCell />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center overflow-hidden">
|
||||||
|
<Badge
|
||||||
|
size="2xsmall"
|
||||||
|
className="flex min-w-[20px] items-center justify-center"
|
||||||
|
>
|
||||||
|
{value.value}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
}, [product])
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
columnHelper.accessor("title", {
|
||||||
|
header: () => (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">{t("fields.title")}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<div className="flex h-full w-full items-center overflow-hidden">
|
||||||
|
<span className="truncate">{getValue()}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("sku", {
|
||||||
|
header: () => (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">{t("fields.sku")}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue()
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return <PlaceholderCell />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center overflow-hidden">
|
||||||
|
<span className="truncate">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...optionColumns,
|
||||||
|
columnHelper.display({
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row, table }) => {
|
||||||
|
const { product } = table.options.meta as { product: Product }
|
||||||
|
|
||||||
|
return <VariantActions variant={row.original} product={product} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[t, optionColumns]
|
||||||
|
)
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Filter } from "../../../../../components/table/data-table"
|
||||||
|
|
||||||
|
export const useProductVariantTableFilters = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
let filters: Filter[] = []
|
||||||
|
|
||||||
|
const manageInventoryFilter: Filter = {
|
||||||
|
key: "manage_inventory",
|
||||||
|
label: t("fields.managedInventory"),
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: t("fields.true"),
|
||||||
|
value: "true",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("fields.false"),
|
||||||
|
value: "false",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFilters: Filter[] = [
|
||||||
|
{ label: t("fields.createdAt"), key: "created_at" },
|
||||||
|
{ label: t("fields.updatedAt"), key: "updated_at" },
|
||||||
|
].map((f) => ({
|
||||||
|
key: f.key,
|
||||||
|
label: f.label,
|
||||||
|
type: "date",
|
||||||
|
}))
|
||||||
|
|
||||||
|
filters = [...filters, manageInventoryFilter, ...dateFilters]
|
||||||
|
|
||||||
|
return filters
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import { AdminGetProductsVariantsParams } from "@medusajs/medusa"
|
||||||
|
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
||||||
|
|
||||||
|
export const useProductVariantTableQuery = ({
|
||||||
|
pageSize,
|
||||||
|
prefix,
|
||||||
|
}: {
|
||||||
|
pageSize: number
|
||||||
|
prefix?: string
|
||||||
|
}) => {
|
||||||
|
const queryObject = useQueryParams(
|
||||||
|
["offset", "q", "manage_inventory", "order", "created_at", "updated_at"],
|
||||||
|
prefix
|
||||||
|
)
|
||||||
|
|
||||||
|
const { offset, manage_inventory, created_at, updated_at, q, order } =
|
||||||
|
queryObject
|
||||||
|
|
||||||
|
const searchParams: AdminGetProductsVariantsParams = {
|
||||||
|
limit: pageSize,
|
||||||
|
offset: offset ? Number(offset) : 0,
|
||||||
|
manage_inventory: manage_inventory
|
||||||
|
? manage_inventory === "true"
|
||||||
|
: undefined,
|
||||||
|
order,
|
||||||
|
created_at: created_at ? JSON.parse(created_at) : undefined,
|
||||||
|
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
|
||||||
|
q,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchParams,
|
||||||
|
raw: queryObject,
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-23
@@ -1,5 +1,5 @@
|
|||||||
import { useAdminProduct } from "medusa-react"
|
import { useAdminProduct } from "medusa-react"
|
||||||
import { useLoaderData, useParams } from "react-router-dom"
|
import { Outlet, useLoaderData, useParams } from "react-router-dom"
|
||||||
|
|
||||||
import { JsonViewSection } from "../../../components/common/json-view-section"
|
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||||
import { ProductAttributeSection } from "./components/product-attribute-section"
|
import { ProductAttributeSection } from "./components/product-attribute-section"
|
||||||
@@ -7,7 +7,6 @@ import { ProductGeneralSection } from "./components/product-general-section"
|
|||||||
import { ProductMediaSection } from "./components/product-media-section"
|
import { ProductMediaSection } from "./components/product-media-section"
|
||||||
import { ProductOptionSection } from "./components/product-option-section"
|
import { ProductOptionSection } from "./components/product-option-section"
|
||||||
import { ProductSalesChannelSection } from "./components/product-sales-channel-section"
|
import { ProductSalesChannelSection } from "./components/product-sales-channel-section"
|
||||||
import { ProductThumbnailSection } from "./components/product-thumbnail-section"
|
|
||||||
import { ProductVariantSection } from "./components/product-variant-section"
|
import { ProductVariantSection } from "./components/product-variant-section"
|
||||||
import { productLoader } from "./loader"
|
import { productLoader } from "./loader"
|
||||||
|
|
||||||
@@ -15,6 +14,7 @@ import after from "medusa-admin:widgets/product/details/after"
|
|||||||
import before from "medusa-admin:widgets/product/details/before"
|
import before from "medusa-admin:widgets/product/details/before"
|
||||||
import sideAfter from "medusa-admin:widgets/product/details/side/after"
|
import sideAfter from "medusa-admin:widgets/product/details/side/after"
|
||||||
import sideBefore from "medusa-admin:widgets/product/details/side/before"
|
import sideBefore from "medusa-admin:widgets/product/details/side/before"
|
||||||
|
import { ProductOrganizationSection } from "./components/product-organization-section"
|
||||||
|
|
||||||
export const ProductDetail = () => {
|
export const ProductDetail = () => {
|
||||||
const initialData = useLoaderData() as Awaited<
|
const initialData = useLoaderData() as Awaited<
|
||||||
@@ -30,23 +30,12 @@ export const ProductDetail = () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: Move to loading.tsx and set as Suspense fallback for the route
|
if (isLoading || !product) {
|
||||||
if (isLoading) {
|
return <div>Loading...</div>
|
||||||
return <div>Loading</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Move to error.tsx and set as ErrorBoundary for the route
|
if (isError) {
|
||||||
if (isError || !product) {
|
throw error
|
||||||
const err = error ? JSON.parse(JSON.stringify(error)) : null
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{(err as Error & { status: number })?.status === 404 ? (
|
|
||||||
<div>Not found</div>
|
|
||||||
) : (
|
|
||||||
<div>Something went wrong!</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,10 +50,9 @@ export const ProductDetail = () => {
|
|||||||
<div className="grid grid-cols-1 gap-x-4 lg:grid-cols-[1fr,400px]">
|
<div className="grid grid-cols-1 gap-x-4 lg:grid-cols-[1fr,400px]">
|
||||||
<div className="flex flex-col gap-y-2">
|
<div className="flex flex-col gap-y-2">
|
||||||
<ProductGeneralSection product={product} />
|
<ProductGeneralSection product={product} />
|
||||||
<ProductSalesChannelSection product={product} />
|
<ProductMediaSection product={product} />
|
||||||
<ProductOptionSection product={product} />
|
<ProductOptionSection product={product} />
|
||||||
<ProductVariantSection product={product} />
|
<ProductVariantSection product={product} />
|
||||||
<ProductAttributeSection product={product} />
|
|
||||||
<div className="flex flex-col gap-y-2 lg:hidden">
|
<div className="flex flex-col gap-y-2 lg:hidden">
|
||||||
{sideBefore.widgets.map((w, i) => {
|
{sideBefore.widgets.map((w, i) => {
|
||||||
return (
|
return (
|
||||||
@@ -73,8 +61,9 @@ export const ProductDetail = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<ProductThumbnailSection product={product} />
|
<ProductSalesChannelSection product={product} />
|
||||||
<ProductMediaSection product={product} />
|
<ProductOrganizationSection product={product} />
|
||||||
|
<ProductAttributeSection product={product} />
|
||||||
{sideAfter.widgets.map((w, i) => {
|
{sideAfter.widgets.map((w, i) => {
|
||||||
return (
|
return (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
@@ -100,8 +89,9 @@ export const ProductDetail = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<ProductThumbnailSection product={product} />
|
<ProductSalesChannelSection product={product} />
|
||||||
<ProductMediaSection product={product} />
|
<ProductOrganizationSection product={product} />
|
||||||
|
<ProductAttributeSection product={product} />
|
||||||
{sideAfter.widgets.map((w, i) => {
|
{sideAfter.widgets.map((w, i) => {
|
||||||
return (
|
return (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
@@ -111,6 +101,7 @@ export const ProductDetail = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+275
@@ -0,0 +1,275 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Product } from "@medusajs/medusa"
|
||||||
|
import { ProductStatus } from "@medusajs/types"
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Drawer,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
} from "@medusajs/ui"
|
||||||
|
import { useAdminUpdateProduct } from "medusa-react"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { Trans, useTranslation } from "react-i18next"
|
||||||
|
import * as zod from "zod"
|
||||||
|
|
||||||
|
import { Form } from "../../../../../components/common/form"
|
||||||
|
|
||||||
|
type EditProductFormProps = {
|
||||||
|
product: Product
|
||||||
|
subscribe: (state: boolean) => void
|
||||||
|
onSuccessfulSubmit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditProductSchema = zod.object({
|
||||||
|
status: zod.enum(["draft", "published", "proposed", "rejected"]),
|
||||||
|
title: zod.string().min(1),
|
||||||
|
subtitle: zod.string(),
|
||||||
|
handle: zod.string().min(1),
|
||||||
|
material: zod.string(),
|
||||||
|
description: zod.string(),
|
||||||
|
discountable: zod.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const EditProductForm = ({
|
||||||
|
product,
|
||||||
|
subscribe,
|
||||||
|
onSuccessfulSubmit,
|
||||||
|
}: EditProductFormProps) => {
|
||||||
|
const form = useForm<zod.infer<typeof EditProductSchema>>({
|
||||||
|
defaultValues: {
|
||||||
|
status: product.status,
|
||||||
|
title: product.title,
|
||||||
|
material: product.material || "",
|
||||||
|
subtitle: product.subtitle || "",
|
||||||
|
handle: product.handle || "",
|
||||||
|
description: product.description || "",
|
||||||
|
discountable: product.discountable,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(EditProductSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
formState: { isDirty },
|
||||||
|
} = form
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
subscribe(isDirty)
|
||||||
|
}, [isDirty, subscribe])
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { mutateAsync, isLoading } = useAdminUpdateProduct(product.id)
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit(async (data) => {
|
||||||
|
await mutateAsync(
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
status: data.status as ProductStatus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
onSuccessfulSubmit()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleSubmit} className="flex h-full flex-col">
|
||||||
|
<Drawer.Body>
|
||||||
|
<div className="flex h-full flex-col gap-y-8">
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field: { onChange, ref, ...field } }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.status")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Select {...field} onValueChange={onChange}>
|
||||||
|
<Select.Trigger ref={ref}>
|
||||||
|
<Select.Value />
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
"draft",
|
||||||
|
"published",
|
||||||
|
"proposed",
|
||||||
|
"rejected",
|
||||||
|
] as const
|
||||||
|
).map((status) => {
|
||||||
|
return (
|
||||||
|
<Select.Item key={status} value={status}>
|
||||||
|
{t(`products.productStatus.${status}`)}
|
||||||
|
</Select.Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Select.Content>
|
||||||
|
</Select>
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.title")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="subtitle"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label optional>{t("fields.subtitle")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
size="small"
|
||||||
|
leading="compact"
|
||||||
|
className="text-ui-fg-subtle"
|
||||||
|
>
|
||||||
|
<Trans
|
||||||
|
i18nKey="products.titleHint"
|
||||||
|
t={t}
|
||||||
|
components={[<br key="break" />]}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="handle"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.handle")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 z-10 flex w-8 items-center justify-center border-r">
|
||||||
|
<Text
|
||||||
|
className="text-ui-fg-muted"
|
||||||
|
size="small"
|
||||||
|
leading="compact"
|
||||||
|
weight="plus"
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Input {...field} className="pl-10" />
|
||||||
|
</div>
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="material"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label optional>{t("fields.material")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label optional>
|
||||||
|
{t("fields.description")}
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Textarea {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
<Form.Hint>
|
||||||
|
<Trans
|
||||||
|
i18nKey="products.descriptionHint"
|
||||||
|
t={t}
|
||||||
|
components={[<br key="break" />]}
|
||||||
|
/>
|
||||||
|
</Form.Hint>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="discountable"
|
||||||
|
render={({ field: { value, onChange, ...field } }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Form.Label optional>
|
||||||
|
{t("fields.discountable")}
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Switch
|
||||||
|
{...field}
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
</div>
|
||||||
|
<Form.Hint className="!mt-1">
|
||||||
|
When unchecked discounts will not be applied to this
|
||||||
|
product.
|
||||||
|
</Form.Hint>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Drawer.Body>
|
||||||
|
<Drawer.Footer>
|
||||||
|
<div className="flex items-center justify-end gap-x-2">
|
||||||
|
<Drawer.Close asChild>
|
||||||
|
<Button size="small" variant="secondary">
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
</Drawer.Close>
|
||||||
|
<Button size="small" type="submit" isLoading={isLoading}>
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Drawer.Footer>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./edit-product-form"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ProductEdit as Component } from "./product-edit"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Drawer, Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminProduct } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
|
||||||
|
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
|
||||||
|
import { EditProductForm } from "./components/edit-product-form"
|
||||||
|
|
||||||
|
export const ProductEdit = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
const [open, onOpenChange, subscribe] = useRouteModalState()
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { product, isLoading, isError, error } = useAdminProduct(id!)
|
||||||
|
|
||||||
|
const handleSuccessfulSubmit = () => {
|
||||||
|
onOpenChange(false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Drawer.Content>
|
||||||
|
<Drawer.Header>
|
||||||
|
<Heading>{t("products.editProduct")}</Heading>
|
||||||
|
</Drawer.Header>
|
||||||
|
{!isLoading && product && (
|
||||||
|
<EditProductForm
|
||||||
|
product={product}
|
||||||
|
onSuccessfulSubmit={handleSuccessfulSubmit}
|
||||||
|
subscribe={subscribe}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Drawer.Content>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ProductGallery as Component } from "./product-gallery"
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
import {
|
||||||
|
ArrowDownTray,
|
||||||
|
ChevronLeftMini,
|
||||||
|
ChevronRightMini,
|
||||||
|
ThumbnailBadge,
|
||||||
|
Trash,
|
||||||
|
XMarkMini,
|
||||||
|
} from "@medusajs/icons"
|
||||||
|
import { Product } from "@medusajs/medusa"
|
||||||
|
import { Button, IconButton, Kbd, Tooltip } from "@medusajs/ui"
|
||||||
|
import * as Dialog from "@radix-ui/react-dialog"
|
||||||
|
import { Variants, motion } from "framer-motion"
|
||||||
|
import { useAdminProduct } from "medusa-react"
|
||||||
|
import { useCallback, useEffect, useMemo } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Link, useParams, useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
|
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
|
||||||
|
|
||||||
|
export const ProductGallery = () => {
|
||||||
|
const [open, onOpenChange] = useRouteModalState()
|
||||||
|
|
||||||
|
const { id } = useParams()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
|
const { product, isLoading, isError, error } = useAdminProduct(id!)
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const media = useMemo(() => {
|
||||||
|
return product ? getMedia(product) : []
|
||||||
|
}, [product])
|
||||||
|
|
||||||
|
const currentId = searchParams.get("img") ?? media[0]?.id
|
||||||
|
const currentIndex = media.findIndex((m) => m.id === currentId)
|
||||||
|
|
||||||
|
const paginate = useCallback(
|
||||||
|
(newDirection: number) => {
|
||||||
|
const adjustment = newDirection > 0 ? 1 : -1
|
||||||
|
const newIndex = (currentIndex + adjustment + media.length) % media.length
|
||||||
|
setSearchParams({ img: media[newIndex].id }, { replace: true })
|
||||||
|
},
|
||||||
|
[currentIndex, media, setSearchParams]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowRight") {
|
||||||
|
e.preventDefault()
|
||||||
|
paginate(1)
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault()
|
||||||
|
paginate(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [paginate])
|
||||||
|
|
||||||
|
const indicatorVariants: Variants = {
|
||||||
|
active: {
|
||||||
|
width: "16px",
|
||||||
|
backgroundColor: "var(--fg-subtle)",
|
||||||
|
},
|
||||||
|
inactive: {
|
||||||
|
width: "6px",
|
||||||
|
backgroundColor: "var(--fg-muted)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root modal open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Content className="bg-ui-bg-subtle dark fixed inset-0 grid-rows-[32px_1fr_6px] pb-16 pt-4 outline-none">
|
||||||
|
<div className="flex items-center justify-between px-4">
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<IconButton size="small" variant="transparent">
|
||||||
|
<XMarkMini className="text-ui-fg-subtle" />
|
||||||
|
</IconButton>
|
||||||
|
</Dialog.Close>
|
||||||
|
<Kbd>esc</Kbd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<Tooltip
|
||||||
|
content={t("actions.delete")}
|
||||||
|
side="bottom"
|
||||||
|
className="dark"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
disabled={isLoading}
|
||||||
|
aria-label="Delete image"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Trash aria-hidden className="text-ui-fg-subtle" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
content={t("actions.download")}
|
||||||
|
side="bottom"
|
||||||
|
className="dark"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
disabled={isLoading}
|
||||||
|
aria-label="Download image"
|
||||||
|
size="small"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link to={media[currentIndex].url} download>
|
||||||
|
<ArrowDownTray aria-hidden className="text-ui-fg-subtle" />
|
||||||
|
</Link>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Button
|
||||||
|
disabled={isLoading}
|
||||||
|
size="small"
|
||||||
|
variant="secondary"
|
||||||
|
className="text-ui-fg-subtle"
|
||||||
|
>
|
||||||
|
{t("products.editMedia")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<main className="relative flex h-full w-screen items-center justify-center py-16">
|
||||||
|
<div className="absolute h-full max-w-[100vw] rounded-2xl py-16">
|
||||||
|
<div className="relative h-full w-fit">
|
||||||
|
{media[currentIndex].isThumbnail && (
|
||||||
|
<div className="absolute left-3 top-3">
|
||||||
|
<Tooltip content={t("fields.thumbnail")} className="dark">
|
||||||
|
<ThumbnailBadge />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
src={media[currentIndex].url}
|
||||||
|
alt=""
|
||||||
|
className="object-fit h-full rounded-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={() => paginate(-1)}
|
||||||
|
className="absolute left-4 top-1/2 z-[2] rounded-full"
|
||||||
|
>
|
||||||
|
<ChevronLeftMini className="text-ui-fg-subtle" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => paginate(1)}
|
||||||
|
className="absolute right-4 top-1/2 z-[2] rounded-full"
|
||||||
|
>
|
||||||
|
<ChevronRightMini className="text-ui-fg-subtle" />
|
||||||
|
</IconButton>
|
||||||
|
</main>
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
{media.map((img, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={img.id}
|
||||||
|
className="h-1.5 rounded-full"
|
||||||
|
variants={indicatorVariants}
|
||||||
|
animate={index === currentIndex ? "active" : "inactive"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Media = {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
isThumbnail: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMedia = (product: Product) => {
|
||||||
|
const { images = [], thumbnail } = product
|
||||||
|
|
||||||
|
const media: Media[] = images.map((image) => ({
|
||||||
|
id: image.id,
|
||||||
|
url: image.url,
|
||||||
|
isThumbnail: image.url === thumbnail,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (thumbnail && !media.some((mediaItem) => mediaItem.url === thumbnail)) {
|
||||||
|
media.unshift({
|
||||||
|
id: "img_thumbnail",
|
||||||
|
url: thumbnail,
|
||||||
|
isThumbnail: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return media
|
||||||
|
}
|
||||||
+62
-206
@@ -1,180 +1,101 @@
|
|||||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||||
import type { Product } from "@medusajs/medusa"
|
import type { Product } from "@medusajs/medusa"
|
||||||
import {
|
import { Button, Container, Heading, usePrompt } from "@medusajs/ui"
|
||||||
Button,
|
import { createColumnHelper } from "@tanstack/react-table"
|
||||||
Checkbox,
|
|
||||||
CommandBar,
|
|
||||||
Container,
|
|
||||||
Heading,
|
|
||||||
Table,
|
|
||||||
clx,
|
|
||||||
} from "@medusajs/ui"
|
|
||||||
import {
|
|
||||||
PaginationState,
|
|
||||||
RowSelectionState,
|
|
||||||
createColumnHelper,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table"
|
|
||||||
import { useAdminDeleteProduct, useAdminProducts } from "medusa-react"
|
import { useAdminDeleteProduct, useAdminProducts } from "medusa-react"
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useLoaderData, useNavigate } from "react-router-dom"
|
import { Link, Outlet, useLoaderData } from "react-router-dom"
|
||||||
|
|
||||||
import {
|
|
||||||
ProductAvailabilityCell,
|
|
||||||
ProductCollectionCell,
|
|
||||||
ProductStatusCell,
|
|
||||||
ProductTitleCell,
|
|
||||||
ProductVariantCell,
|
|
||||||
} from "../../../../../components/common/product-table-cells"
|
|
||||||
|
|
||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
|
import { DataTable } from "../../../../../components/table/data-table"
|
||||||
|
import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns"
|
||||||
|
import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters"
|
||||||
|
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
|
||||||
|
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||||
import { productsLoader } from "../../loader"
|
import { productsLoader } from "../../loader"
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
export const ProductListTable = () => {
|
export const ProductListTable = () => {
|
||||||
const navigate = useNavigate()
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: PAGE_SIZE,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
|
||||||
|
|
||||||
const initialData = useLoaderData() as Awaited<
|
const initialData = useLoaderData() as Awaited<
|
||||||
ReturnType<ReturnType<typeof productsLoader>>
|
ReturnType<ReturnType<typeof productsLoader>>
|
||||||
>
|
>
|
||||||
|
|
||||||
const { products, count } = useAdminProducts(
|
const { searchParams, raw } = useProductTableQuery({ pageSize: PAGE_SIZE })
|
||||||
|
const { products, count, isLoading, isError, error } = useAdminProducts(
|
||||||
{
|
{
|
||||||
limit: PAGE_SIZE,
|
...searchParams,
|
||||||
offset: pageIndex * PAGE_SIZE,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
initialData,
|
initialData,
|
||||||
|
keepPreviousData: true,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const filters = useProductTableFilters()
|
||||||
const columns = useColumns()
|
const columns = useColumns()
|
||||||
|
|
||||||
const pagination = useMemo(
|
const { table } = useDataTable({
|
||||||
() => ({
|
|
||||||
pageIndex,
|
|
||||||
pageSize,
|
|
||||||
}),
|
|
||||||
[pageIndex, pageSize]
|
|
||||||
)
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: (products ?? []) as Product[],
|
data: (products ?? []) as Product[],
|
||||||
columns,
|
columns,
|
||||||
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
|
count,
|
||||||
state: {
|
enablePagination: true,
|
||||||
pagination,
|
pageSize: PAGE_SIZE,
|
||||||
rowSelection,
|
getRowId: (row) => row.id,
|
||||||
},
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
onRowSelectionChange: setRowSelection,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
manualPagination: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="overflow-hidden p-0">
|
<Container className="divide-y p-0">
|
||||||
<div className="flex items-center justify-between px-6 py-4">
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<Heading level="h2">{t("products.domain")}</Heading>
|
<Heading level="h2">{t("products.domain")}</Heading>
|
||||||
<Button size="small" variant="secondary">
|
<Button size="small" variant="secondary" asChild>
|
||||||
{t("actions.create")}
|
<Link to="create">{t("actions.create")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<DataTable
|
||||||
<Table>
|
table={table}
|
||||||
<Table.Header>
|
columns={columns}
|
||||||
{table.getHeaderGroups().map((headerGroup) => {
|
count={count}
|
||||||
return (
|
pageSize={PAGE_SIZE}
|
||||||
<Table.Row
|
filters={filters}
|
||||||
key={headerGroup.id}
|
search
|
||||||
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap"
|
pagination
|
||||||
>
|
isLoading={isLoading}
|
||||||
{headerGroup.headers.map((header) => {
|
queryObject={raw}
|
||||||
return (
|
navigateTo={(row) => `${row.original.id}`}
|
||||||
<Table.HeaderCell key={header.id}>
|
orderBy={["title", "created_at", "updated_at"]}
|
||||||
{flexRender(
|
/>
|
||||||
header.column.columnDef.header,
|
<Outlet />
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</Table.HeaderCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Table.Row>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body className="border-b-0">
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<Table.Row
|
|
||||||
key={row.id}
|
|
||||||
className={clx(
|
|
||||||
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
|
||||||
{
|
|
||||||
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
|
||||||
row.getIsSelected(),
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
onClick={() => navigate(`/products/${row.original.id}`)}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<Table.Cell key={cell.id}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</Table.Cell>
|
|
||||||
))}
|
|
||||||
</Table.Row>
|
|
||||||
))}
|
|
||||||
</Table.Body>
|
|
||||||
</Table>
|
|
||||||
<LocalizedTablePagination
|
|
||||||
canNextPage={table.getCanNextPage()}
|
|
||||||
canPreviousPage={table.getCanPreviousPage()}
|
|
||||||
nextPage={table.nextPage}
|
|
||||||
previousPage={table.previousPage}
|
|
||||||
count={count ?? 0}
|
|
||||||
pageIndex={pageIndex}
|
|
||||||
pageCount={table.getPageCount()}
|
|
||||||
pageSize={PAGE_SIZE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<CommandBar open={!!Object.keys(rowSelection).length}>
|
|
||||||
<CommandBar.Bar>
|
|
||||||
<CommandBar.Value>
|
|
||||||
{t("general.countSelected", {
|
|
||||||
count: Object.keys(rowSelection).length,
|
|
||||||
})}
|
|
||||||
</CommandBar.Value>
|
|
||||||
<CommandBar.Seperator />
|
|
||||||
<CommandBar.Command
|
|
||||||
action={() => {
|
|
||||||
console.log("Delete")
|
|
||||||
}}
|
|
||||||
shortcut="d"
|
|
||||||
label={t("actions.delete")}
|
|
||||||
/>
|
|
||||||
</CommandBar.Bar>
|
|
||||||
</CommandBar>
|
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProductActions = ({ id }: { id: string }) => {
|
const ProductActions = ({ product }: { product: Product }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { mutateAsync } = useAdminDeleteProduct(id)
|
const prompt = usePrompt()
|
||||||
|
const { mutateAsync } = useAdminDeleteProduct(product.id)
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
|
const res = await prompt({
|
||||||
|
title: t("general.areYouSure"),
|
||||||
|
description: t("products.deleteWarning", {
|
||||||
|
title: product.title,
|
||||||
|
}),
|
||||||
|
confirmText: t("actions.delete"),
|
||||||
|
cancelText: t("actions.cancel"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await mutateAsync()
|
await mutateAsync()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +107,7 @@ const ProductActions = ({ id }: { id: string }) => {
|
|||||||
{
|
{
|
||||||
icon: <PencilSquare />,
|
icon: <PencilSquare />,
|
||||||
label: t("actions.edit"),
|
label: t("actions.edit"),
|
||||||
to: `/products/${id}/edit`,
|
to: `/products/${product.id}/edit`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -207,84 +128,19 @@ const ProductActions = ({ id }: { id: string }) => {
|
|||||||
const columnHelper = createColumnHelper<Product>()
|
const columnHelper = createColumnHelper<Product>()
|
||||||
|
|
||||||
const useColumns = () => {
|
const useColumns = () => {
|
||||||
const { t } = useTranslation()
|
const base = useProductTableColumns()
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
columnHelper.display({
|
...base,
|
||||||
id: "select",
|
|
||||||
header: ({ table }) => {
|
|
||||||
return (
|
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
table.getIsSomePageRowsSelected()
|
|
||||||
? "indeterminate"
|
|
||||||
: table.getIsAllPageRowsSelected()
|
|
||||||
}
|
|
||||||
onCheckedChange={(value) =>
|
|
||||||
table.toggleAllPageRowsSelected(!!value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return (
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("title", {
|
|
||||||
header: t("fields.title"),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return <ProductTitleCell product={row.original} />
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("collection", {
|
|
||||||
header: t("fields.collection"),
|
|
||||||
cell: (cell) => {
|
|
||||||
const collection = cell.getValue()
|
|
||||||
|
|
||||||
return <ProductCollectionCell collection={collection} />
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("sales_channels", {
|
|
||||||
header: t("fields.availability"),
|
|
||||||
cell: (cell) => {
|
|
||||||
const salesChannels = cell.getValue()
|
|
||||||
|
|
||||||
return <ProductAvailabilityCell salesChannels={salesChannels ?? []} />
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("variants", {
|
|
||||||
header: t("fields.inventory"),
|
|
||||||
cell: (cell) => {
|
|
||||||
const variants = cell.getValue()
|
|
||||||
|
|
||||||
return <ProductVariantCell variants={variants} />
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("status", {
|
|
||||||
header: t("fields.status"),
|
|
||||||
cell: (cell) => {
|
|
||||||
const value = cell.getValue()
|
|
||||||
|
|
||||||
return <ProductStatusCell status={value} />
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
columnHelper.display({
|
columnHelper.display({
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return <ProductActions id={row.original.id} />
|
return <ProductActions product={row.original} />
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
[t]
|
[base]
|
||||||
)
|
)
|
||||||
|
|
||||||
return columns
|
return columns
|
||||||
|
|||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Product } from "@medusajs/medusa"
|
||||||
|
import { Button, Drawer } from "@medusajs/ui"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import * as zod from "zod"
|
||||||
|
import { Form } from "../../../../../components/common/form"
|
||||||
|
|
||||||
|
type EditProductOptionsFormProps = {
|
||||||
|
product: Product
|
||||||
|
handleSuccess: () => void
|
||||||
|
subscribe: (state: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditProductOptionsSchema = zod.object({})
|
||||||
|
|
||||||
|
export const EditProductOptionsForm = (props: EditProductOptionsFormProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const form = useForm<zod.infer<typeof EditProductOptionsSchema>>({
|
||||||
|
resolver: zodResolver(EditProductOptionsSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<Drawer.Body className="flex flex-1 flex-col gap-y-8 overflow-auto"></Drawer.Body>
|
||||||
|
<Drawer.Footer>
|
||||||
|
<div className="flex items-center justify-end gap-x-2">
|
||||||
|
<Drawer.Close asChild>
|
||||||
|
<Button variant="secondary" size="small">
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
</Drawer.Close>
|
||||||
|
<Button type="submit" size="small">
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Drawer.Footer>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./edit-product-options-form"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ProductOptions as Component } from "./product-options"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Drawer, Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminProduct } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
|
||||||
|
import { EditProductOptionsForm } from "./components/edit-product-options-form"
|
||||||
|
|
||||||
|
export const ProductOptions = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, onOpenChange, subscribe] = useRouteModalState()
|
||||||
|
|
||||||
|
const { product, isLoading, isError, error } = useAdminProduct(id!)
|
||||||
|
|
||||||
|
const handleSuccess = () => {
|
||||||
|
onOpenChange(false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Drawer.Content>
|
||||||
|
<Drawer.Header>
|
||||||
|
<Heading>{t("products.editOptions")}</Heading>
|
||||||
|
</Drawer.Header>
|
||||||
|
{!isLoading && product && (
|
||||||
|
<EditProductOptionsForm
|
||||||
|
product={product}
|
||||||
|
handleSuccess={handleSuccess}
|
||||||
|
subscribe={subscribe}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Drawer.Content>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
import { Product, SalesChannel } from "@medusajs/medusa"
|
||||||
|
import { Button, Checkbox, FocusModal } from "@medusajs/ui"
|
||||||
|
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
|
||||||
|
import { useAdminSalesChannels, useAdminUpdateProduct } from "medusa-react"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { DataTable } from "../../../../../components/table/data-table"
|
||||||
|
import { useSalesChannelTableColumns } from "../../../../../hooks/table/columns/use-sales-channel-table-columns"
|
||||||
|
import { useSalesChannelTableFilters } from "../../../../../hooks/table/filters/use-sales-channel-table-filters"
|
||||||
|
import { useSalesChannelTableQuery } from "../../../../../hooks/table/query/use-sales-channel-table-query"
|
||||||
|
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||||
|
|
||||||
|
type EditSalesChannelsFormProps = {
|
||||||
|
product: Product
|
||||||
|
subscribe: (state: boolean) => void
|
||||||
|
onSuccessfulSubmit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
export const EditSalesChannelsForm = ({
|
||||||
|
product,
|
||||||
|
subscribe,
|
||||||
|
onSuccessfulSubmit,
|
||||||
|
}: EditSalesChannelsFormProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const initialState =
|
||||||
|
product.sales_channels?.reduce((acc, curr) => {
|
||||||
|
acc[curr.id] = true
|
||||||
|
return acc
|
||||||
|
}, {} as RowSelectionState) ?? {}
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
|
useState<RowSelectionState>(initialState)
|
||||||
|
|
||||||
|
const isDirty = Object.entries(initialState).some(
|
||||||
|
([key, value]) => value !== rowSelection[key]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
subscribe(isDirty)
|
||||||
|
}, [isDirty, subscribe])
|
||||||
|
|
||||||
|
const { searchParams, raw } = useSalesChannelTableQuery({
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
const { sales_channels, count, isLoading, isError, error } =
|
||||||
|
useAdminSalesChannels(
|
||||||
|
{
|
||||||
|
...searchParams,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const filters = useSalesChannelTableFilters()
|
||||||
|
const columns = useColumns()
|
||||||
|
|
||||||
|
const { table } = useDataTable({
|
||||||
|
data: sales_channels ?? [],
|
||||||
|
columns,
|
||||||
|
count,
|
||||||
|
enablePagination: true,
|
||||||
|
enableRowSelection: true,
|
||||||
|
rowSelection: {
|
||||||
|
state: rowSelection,
|
||||||
|
updater: setRowSelection,
|
||||||
|
},
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading: isMutating } = useAdminUpdateProduct(
|
||||||
|
product.id
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const selected = Object.keys(rowSelection).filter((key) => {
|
||||||
|
return rowSelection[key]
|
||||||
|
})
|
||||||
|
|
||||||
|
const sales_channels = selected.map((id) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await mutateAsync(
|
||||||
|
{
|
||||||
|
sales_channels,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
onSuccessfulSubmit()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
<FocusModal.Header>
|
||||||
|
<div className="flex items-center justify-end gap-x-2">
|
||||||
|
<FocusModal.Close asChild>
|
||||||
|
<Button size="small" variant="secondary">
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
</FocusModal.Close>
|
||||||
|
<Button size="small" isLoading={isMutating} onClick={handleSubmit}>
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FocusModal.Header>
|
||||||
|
<FocusModal.Body>
|
||||||
|
<DataTable
|
||||||
|
table={table}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
isLoading={isLoading}
|
||||||
|
count={count}
|
||||||
|
filters={filters}
|
||||||
|
search
|
||||||
|
pagination
|
||||||
|
orderBy={["name", "created_at", "updated_at"]}
|
||||||
|
queryObject={raw}
|
||||||
|
layout="fill"
|
||||||
|
/>
|
||||||
|
</FocusModal.Body>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<SalesChannel>()
|
||||||
|
|
||||||
|
const useColumns = () => {
|
||||||
|
const columns = useSalesChannelTableColumns()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
columnHelper.display({
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
table.getIsSomePageRowsSelected()
|
||||||
|
? "indeterminate"
|
||||||
|
: table.getIsAllPageRowsSelected()
|
||||||
|
}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
table.toggleAllPageRowsSelected(!!value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...columns,
|
||||||
|
],
|
||||||
|
[columns]
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./edit-sales-channels-form"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ProductSalesChannels as Component } from "./product-sales-channels"
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import { FocusModal } from "@medusajs/ui"
|
||||||
|
import { useAdminProduct } from "medusa-react"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
|
||||||
|
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
|
||||||
|
import { EditSalesChannelsForm } from "./components/edit-sales-channels-form"
|
||||||
|
|
||||||
|
export const ProductSalesChannels = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
const [open, onOpenChange, subscribe] = useRouteModalState()
|
||||||
|
|
||||||
|
const { product, isLoading, isError, error } = useAdminProduct(id!)
|
||||||
|
|
||||||
|
const handleSuccessfulSubmit = () => {
|
||||||
|
onOpenChange(false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FocusModal open={open} onOpenChange={onOpenChange}>
|
||||||
|
<FocusModal.Content>
|
||||||
|
{!isLoading && product && (
|
||||||
|
<EditSalesChannelsForm
|
||||||
|
product={product}
|
||||||
|
onSuccessfulSubmit={handleSuccessfulSubmit}
|
||||||
|
subscribe={subscribe}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FocusModal.Content>
|
||||||
|
</FocusModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import BarsArrowDown from "../bars-arrow-down"
|
||||||
|
|
||||||
|
describe("BarsArrowDown", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<BarsArrowDown data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import ChartPie from "../chart-pie"
|
||||||
|
|
||||||
|
describe("ChartPie", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<ChartPie data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import FolderIllustration from "../folder-illustration"
|
||||||
|
|
||||||
|
describe("FolderIllustration", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<FolderIllustration data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import FolderOpenIllustration from "../folder-open-illustration"
|
||||||
|
|
||||||
|
describe("FolderOpenIllustration", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<FolderOpenIllustration data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cleanup, render, screen } from "@testing-library/react"
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
import Sidebar from "../sidebar"
|
import Loader from "../loader"
|
||||||
|
|
||||||
describe("Sidebar", () => {
|
describe("Loader", () => {
|
||||||
it("should render the icon without errors", async () => {
|
it("should render the icon without errors", async () => {
|
||||||
render(<Sidebar data-testid="icon" />)
|
render(<Loader data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
const svgElement = screen.getByTestId("icon")
|
const svgElement = screen.getByTestId("icon")
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import QueueList from "../queue-list"
|
||||||
|
|
||||||
|
describe("QueueList", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<QueueList data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import SidebarLeft from "../sidebar-left"
|
||||||
|
|
||||||
|
describe("SidebarLeft", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<SidebarLeft data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import SidebarRight from "../sidebar-right"
|
||||||
|
|
||||||
|
describe("SidebarRight", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<SidebarRight data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import SquareBlueSolid from "../square-blue-solid"
|
||||||
|
|
||||||
|
describe("SquareBlueSolid", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<SquareBlueSolid data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import SquareGreenSolid from "../square-green-solid"
|
||||||
|
|
||||||
|
describe("SquareGreenSolid", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<SquareGreenSolid data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import SquareGreySolid from "../square-grey-solid"
|
||||||
|
|
||||||
|
describe("SquareGreySolid", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<SquareGreySolid data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import SquareOrangeSolid from "../square-orange-solid"
|
||||||
|
|
||||||
|
describe("SquareOrangeSolid", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<SquareOrangeSolid data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import SquarePurpleSolid from "../square-purple-solid"
|
||||||
|
|
||||||
|
describe("SquarePurpleSolid", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<SquarePurpleSolid data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import SquareRedSolid from "../square-red-solid"
|
||||||
|
|
||||||
|
describe("SquareRedSolid", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<SquareRedSolid data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import Stopwatch from "../stopwatch"
|
||||||
|
|
||||||
|
describe("Stopwatch", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<Stopwatch data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import ThumbnailBadge from "../thumbnail-badge"
|
||||||
|
|
||||||
|
describe("ThumbnailBadge", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<ThumbnailBadge data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import VerifiedBadge from "../verified-badge"
|
||||||
|
|
||||||
|
describe("VerifiedBadge", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<VerifiedBadge data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react"
|
||||||
|
|
||||||
|
import WandSparkle from "../wand-sparkle"
|
||||||
|
|
||||||
|
describe("WandSparkle", () => {
|
||||||
|
it("should render the icon without errors", async () => {
|
||||||
|
render(<WandSparkle data-testid="icon" />)
|
||||||
|
|
||||||
|
|
||||||
|
const svgElement = screen.getByTestId("icon")
|
||||||
|
|
||||||
|
expect(svgElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user