diff --git a/.changeset/three-spiders-flash.md b/.changeset/three-spiders-flash.md
new file mode 100644
index 0000000000..b6c7a22c65
--- /dev/null
+++ b/.changeset/three-spiders-flash.md
@@ -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`.
diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json
index 74fa8052d7..8e0a599e8d 100644
--- a/packages/admin-next/dashboard/package.json
+++ b/packages/admin-next/dashboard/package.json
@@ -25,6 +25,7 @@
"@radix-ui/react-hover-card": "^1.0.7",
"@tanstack/react-query": "4.22.0",
"@tanstack/react-table": "8.10.7",
+ "@tanstack/react-virtual": "^3.0.4",
"@uiw/react-json-view": "2.0.0-alpha.10",
"cmdk": "^0.2.0",
"date-fns": "^3.2.0",
@@ -38,6 +39,7 @@
"react-hook-form": "7.49.1",
"react-i18next": "13.5.0",
"react-jwt": "^1.2.0",
+ "react-resizable-panels": "^2.0.9",
"react-router-dom": "6.20.1",
"zod": "3.22.4"
},
diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json
index 57922e0724..cb690c2cbb 100644
--- a/packages/admin-next/dashboard/public/locales/en-US/translation.json
+++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json
@@ -48,14 +48,47 @@
"cancel": "Cancel",
"save": "Save",
"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": {
"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",
+ "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",
+ "noSalesChannels": "Not available in any sales channels",
"variantCount_one": "{{count}} variant",
"variantCount_other": "{{count}} variants",
+ "deleteVariantWarning": "You are about to delete the variant {{title}}. This action cannot be undone.",
"productStatus": {
"draft": "Draft",
"published": "Published",
@@ -365,6 +398,17 @@
"tag": "Tag",
"dateIssued": "Date issued",
"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"
}
}
diff --git a/packages/admin-next/dashboard/src/components/common/country-select/country-select.tsx b/packages/admin-next/dashboard/src/components/common/country-select/country-select.tsx
index 5a2dd75ef9..be27ccf5dc 100644
--- a/packages/admin-next/dashboard/src/components/common/country-select/country-select.tsx
+++ b/packages/admin-next/dashboard/src/components/common/country-select/country-select.tsx
@@ -1,4 +1,4 @@
-import { forwardRef } from "react"
+import { ComponentPropsWithoutRef, forwardRef } from "react"
import { TrianglesMini } from "@medusajs/icons"
import { clx } from "@medusajs/ui"
@@ -7,7 +7,7 @@ import { countries } from "../../../lib/countries"
export const CountrySelect = forwardRef<
HTMLSelectElement,
- React.ComponentPropsWithoutRef<"select"> & { placeholder?: string }
+ ComponentPropsWithoutRef<"select"> & { placeholder?: string }
>(({ className, disabled, placeholder, ...props }, ref) => {
const { t } = useTranslation()
@@ -15,7 +15,7 @@ export const CountrySelect = forwardRef<
,
+ ComponentProps
+>((props, ref) => {
+ return (
+
+ )
+})
+HandleInput.displayName = "HandleInput"
diff --git a/packages/admin-next/dashboard/src/components/common/handle-input/index.ts b/packages/admin-next/dashboard/src/components/common/handle-input/index.ts
new file mode 100644
index 0000000000..ed461fedb3
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/common/handle-input/index.ts
@@ -0,0 +1 @@
+export * from "./handle-input"
diff --git a/packages/admin-next/dashboard/src/components/error/error-boundary/error-boundary.tsx b/packages/admin-next/dashboard/src/components/error/error-boundary/error-boundary.tsx
index baf03c2873..0f653099f9 100644
--- a/packages/admin-next/dashboard/src/components/error/error-boundary/error-boundary.tsx
+++ b/packages/admin-next/dashboard/src/components/error/error-boundary/error-boundary.tsx
@@ -1,22 +1,59 @@
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"
+// WIP - Need to allow wrapping with ErrorBoundary for more granular error handling.
export const ErrorBoundary = () => {
const error = useRouteError()
const location = useLocation()
+ const { t } = useTranslation()
+
+ let code: number | null = null
if (isAxiosError(error)) {
- if (error.response?.status === 404) {
- return
- }
-
if (error.response?.status === 401) {
return
}
- // TODO: Catch other server errors
+ code = error.response?.status ?? null
}
- // TODO: Actual catch-all error page
- return Dang!
+ let title: string
+ 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 (
+
+
+
+
+ {title}
+
+
+ {message}
+
+
+
+ )
}
diff --git a/packages/admin-next/dashboard/src/components/grid/data-grid-demo.tsx b/packages/admin-next/dashboard/src/components/grid/data-grid-demo.tsx
new file mode 100644
index 0000000000..8a2e336d2f
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/grid/data-grid-demo.tsx
@@ -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
+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({
+ 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 (
+
+
+
+
+
+
+ )
+}
+
+/**
+ * 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()
+
+const useColumns = () => {
+ const { t } = useTranslation()
+
+ const colDefs: ColumnDef[] = useMemo(() => {
+ return [
+ columnHelper.display({
+ id: t("fields.title"),
+ header: "Title",
+ cell: ({ row, table }) => {
+ const entity = row.original
+
+ if (isProduct(entity)) {
+ return (
+
+
+
+ {entity.title}
+
+
+ )
+ }
+
+ return (
+ }
+ 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
+ }
+
+ return (
+ }
+ 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
+ }
+
+ return (
+ }
+ 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
+ }
+
+ return (
+ }
+ field={`products.${entity.product_id}.variants.${entity.id}.upc`}
+ />
+ )
+ },
+ }),
+ ]
+ }, [t])
+
+ return colDefs
+}
diff --git a/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-root/data-grid-root.tsx b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-root/data-grid-root.tsx
new file mode 100644
index 0000000000..3d7cf16b8f
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-root/data-grid-root.tsx
@@ -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[]
+ state: UseFormReturn
+ getSubRows: (row: TData) => TData[] | undefined
+}
+
+const ROW_HEIGHT = 40
+
+export const DataGridRoot = <
+ TData,
+ TFieldValues extends FieldValues = FieldValues,
+>({
+ data,
+ columns,
+ state,
+ getSubRows,
+}: DataGridRootProps) => {
+ const tableContainerRef = useRef(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(null)
+
+ const [isSelecting, setIsSelecting] = useState(false)
+ const [selection, setSelection] = useState([])
+
+ const [isDragging, setIsDragging] = useState(false)
+ const [dragSelection, setDragSelection] = useState([])
+
+ 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) => {
+ 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) => {
+ e.stopPropagation()
+ setIsDragging(true)
+ }
+
+ const getIsAnchor = (rowIndex: number, columnIndex: number) => {
+ return anchor?.row === rowIndex && anchor?.column === columnIndex
+ }
+
+ const handleMouseOver = (e: ReactMouseEvent) => {
+ /**
+ * 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)
+ })
+
+ 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, 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 (
+
+
+
+
+
+ {grid.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+ |
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ |
+ )
+ })}
+
+ ))}
+
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => {
+ const row = flatRows[virtualRow.index] as Row
+
+ return (
+ 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 (
+
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ {isAnchor && (
+
+ )}
+
+ |
+ )
+ })}
+
+ )
+ })}
+
+
+
+
+ )
+}
+
+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()
+ }
+}
diff --git a/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-root/index.ts b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-root/index.ts
new file mode 100644
index 0000000000..43300ce3be
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-root/index.ts
@@ -0,0 +1 @@
+export * from "./data-grid-root"
diff --git a/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-skeleton/data-grid-skeleton.tsx b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-skeleton/data-grid-skeleton.tsx
new file mode 100644
index 0000000000..ecd5d7dd2d
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-skeleton/data-grid-skeleton.tsx
@@ -0,0 +1,52 @@
+import { Table } from "@medusajs/ui"
+import { ColumnDef } from "@tanstack/react-table"
+import { Skeleton } from "../../../common/skeleton"
+
+type DataTableSkeletonProps = {
+ columns: ColumnDef[]
+ 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 (
+
+
+
+ {columns.map((_col, i) => {
+ return (
+
+
+
+ )
+ })}
+
+
+
+ {rows.map((_, j) => (
+
+ {columns.map((_col, k) => {
+ return (
+
+
+
+ )
+ })}
+
+ ))}
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-skeleton/index.ts b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-skeleton/index.ts
new file mode 100644
index 0000000000..7aca5e5068
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-skeleton/index.ts
@@ -0,0 +1 @@
+export * from "./data-grid-skeleton"
diff --git a/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid.tsx b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid.tsx
new file mode 100644
index 0000000000..88d9e4b4a7
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid.tsx
@@ -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
+ extends DataGridRootProps {
+ isLoading?: boolean
+}
+
+export const DataGrid = ({
+ isLoading,
+ ...props
+}: DataGridProps) => {
+ return (
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/components/grid/data-grid/index.ts b/packages/admin-next/dashboard/src/components/grid/data-grid/index.ts
new file mode 100644
index 0000000000..db8b5896fe
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/grid/data-grid/index.ts
@@ -0,0 +1 @@
+export * from "./data-grid"
diff --git a/packages/admin-next/dashboard/src/components/grid/grid-fields/common/boolean-field/boolean-field.tsx b/packages/admin-next/dashboard/src/components/grid/grid-fields/common/boolean-field/boolean-field.tsx
new file mode 100644
index 0000000000..b2f4e5be52
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/grid/grid-fields/common/boolean-field/boolean-field.tsx
@@ -0,0 +1,23 @@
+import { Select } from "@medusajs/ui"
+import { Controller, FieldValues } from "react-hook-form"
+import { FieldProps } from "../../../types"
+
+interface BooleanFieldProps
+ extends FieldProps {}
+
+export const BooleanField = ({
+ field,
+ meta,
+}: BooleanFieldProps) => {
+ const { control } = meta
+
+ return (
+ {
+ return
+ }}
+ />
+ )
+}
diff --git a/packages/admin-next/dashboard/src/components/grid/grid-fields/common/boolean-field/index.ts b/packages/admin-next/dashboard/src/components/grid/grid-fields/common/boolean-field/index.ts
new file mode 100644
index 0000000000..531987a0f7
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/grid/grid-fields/common/boolean-field/index.ts
@@ -0,0 +1 @@
+export * from "./boolean-field"
diff --git a/packages/admin-next/dashboard/src/components/grid/grid-fields/common/text-field/index.ts b/packages/admin-next/dashboard/src/components/grid/grid-fields/common/text-field/index.ts
new file mode 100644
index 0000000000..44064b65e8
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/grid/grid-fields/common/text-field/index.ts
@@ -0,0 +1 @@
+export * from "./text-field"
diff --git a/packages/admin-next/dashboard/src/components/grid/grid-fields/common/text-field/text-field.tsx b/packages/admin-next/dashboard/src/components/grid/grid-fields/common/text-field/text-field.tsx
new file mode 100644
index 0000000000..1f640983bd
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/grid/grid-fields/common/text-field/text-field.tsx
@@ -0,0 +1,24 @@
+import { FieldValues } from "react-hook-form"
+import { FieldProps } from "../../../types"
+
+interface TextFieldProps
+ extends FieldProps {}
+
+export const TextField = ({
+ field,
+ meta,
+}: TextFieldProps) => {
+ const { register } = meta
+
+ return (
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/components/grid/grid-fields/non-interactive/display-field/display-field.tsx b/packages/admin-next/dashboard/src/components/grid/grid-fields/non-interactive/display-field/display-field.tsx
new file mode 100644
index 0000000000..4385adeb98
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/grid/grid-fields/non-interactive/display-field/display-field.tsx
@@ -0,0 +1,15 @@
+import { PropsWithChildren } from "react"
+
+/**
+ * Field for displaying non-editable data in a grid.
+ */
+export const DisplayField = ({ children }: PropsWithChildren) => {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/components/grid/grid-fields/non-interactive/display-field/index.ts b/packages/admin-next/dashboard/src/components/grid/grid-fields/non-interactive/display-field/index.ts
new file mode 100644
index 0000000000..b3463481e7
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/grid/grid-fields/non-interactive/display-field/index.ts
@@ -0,0 +1 @@
+export * from "./display-field"
diff --git a/packages/admin-next/dashboard/src/components/grid/types.ts b/packages/admin-next/dashboard/src/components/grid/types.ts
new file mode 100644
index 0000000000..49ed629b00
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/grid/types.ts
@@ -0,0 +1,11 @@
+import { Control, FieldValues, Path, UseFormRegister } from "react-hook-form"
+
+export type DataGridMeta = {
+ register: UseFormRegister
+ control: Control
+}
+
+export interface FieldProps {
+ field: Path
+ meta: DataGridMeta
+}
diff --git a/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx b/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx
index a0bcf40fbc..6c671b75af 100644
--- a/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx
+++ b/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx
@@ -6,7 +6,7 @@ import {
CircleHalfSolid,
CogSixTooth,
MagnifyingGlass,
- Sidebar,
+ SidebarRight,
User as UserIcon,
} from "@medusajs/icons"
import { Avatar, DropdownMenu, IconButton, Kbd, Text, clx } from "@medusajs/ui"
@@ -330,14 +330,14 @@ const ToggleSidebar = () => {
variant="transparent"
onClick={() => toggle("desktop")}
>
-
+
toggle("mobile")}
>
-
+
)
diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx b/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx
index c0786e85bf..e06d8e502e 100644
--- a/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx
+++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx
@@ -45,6 +45,10 @@ export interface DataTableRootProps {
* Whether the table is empty due to no results from the active query
*/
noResults?: boolean
+ /**
+ * The layout of the table
+ */
+ layout?: "fill" | "fit"
}
/**
@@ -69,6 +73,7 @@ export const DataTableRoot = ({
commands,
count = 0,
noResults = false,
+ layout = "fit",
}: DataTableRootProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -101,8 +106,18 @@ export const DataTableRoot = ({
}
return (
-
-
+
+
{!noResults ? (
@@ -144,8 +159,12 @@ export const DataTableRoot = ({
className={clx({
"bg-ui-bg-base sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
isStickyHeader,
+ "left-[68px]":
+ isStickyHeader && hasSelect && !isSelectHeader,
"after:bg-ui-border-base":
- showStickyBorder && isStickyHeader,
+ showStickyBorder &&
+ isStickyHeader &&
+ !isSpecialHeader,
})}
>
{flexRender(
@@ -179,14 +198,14 @@ export const DataTableRoot = ({
>
{row.getVisibleCells().map((cell, index) => {
const visibleCells = row.getVisibleCells()
- const isSelectCell = cell.id === "select"
+ const isSelectCell = cell.column.id === "select"
const firstCell = visibleCells.findIndex(
- (h) => h.id !== "select"
+ (h) => h.column.id !== "select"
)
const isFirstCell =
firstCell !== -1
- ? cell.id === visibleCells[firstCell].id
+ ? cell.column.id === visibleCells[firstCell].column.id
: index === 0
const isStickyCell = isSelectCell || isFirstCell
@@ -197,8 +216,10 @@ export const DataTableRoot = ({
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-['']":
isStickyCell,
+ "left-[68px]":
+ isStickyCell && hasSelect && !isSelectCell,
"after:bg-ui-border-base":
- showStickyBorder && isStickyCell,
+ showStickyBorder && isStickyCell && !isSelectCell,
})}
>
{flexRender(
@@ -214,22 +235,24 @@ export const DataTableRoot = ({
) : (
-
{pagination && (
-
+
)}
{hasCommandBar && (
@@ -275,5 +298,11 @@ const Pagination = (props: PaginationProps) => {
next: t("general.next"),
}
- return
+ return (
+
+ )
}
diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table.tsx b/packages/admin-next/dashboard/src/components/table/data-table/data-table.tsx
index e603357906..88a30c9a4e 100644
--- a/packages/admin-next/dashboard/src/components/table/data-table/data-table.tsx
+++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table.tsx
@@ -1,3 +1,4 @@
+import { clx } from "@medusajs/ui"
import { memo } from "react"
import { NoRecords } from "../../common/empty-table-content"
import { DataTableQuery, DataTableQueryProps } from "./data-table-query"
@@ -5,14 +6,15 @@ import { DataTableRoot, DataTableRootProps } from "./data-table-root"
import { DataTableSkeleton } from "./data-table-skeleton"
interface DataTableProps
- extends DataTableRootProps,
+ extends Omit, "noResults">,
DataTableQueryProps {
isLoading?: boolean
- rowCount: number
+ pageSize: number
queryObject?: Record
}
-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)
export const DataTable = ({
@@ -27,14 +29,15 @@ export const DataTable = ({
filters,
prefix,
queryObject = {},
- rowCount,
+ pageSize,
isLoading = false,
+ layout = "fit",
}: DataTableProps) => {
if (isLoading) {
return (
({
}
return (
-
+
- ({
navigateTo={navigateTo}
commands={commands}
noResults={noResults}
+ layout={layout}
/>
)
diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/sales-channel/description-cell/description-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/sales-channel/description-cell/description-cell.tsx
new file mode 100644
index 0000000000..67d625faf8
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/table/table-cells/sales-channel/description-cell/description-cell.tsx
@@ -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
+ }
+
+ return (
+
+ {description}
+
+ )
+}
+
+export const DescriptionHeader = () => {
+ const { t } = useTranslation()
+
+ return (
+
+ {t("fields.description")}
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/sales-channel/description-cell/index.ts b/packages/admin-next/dashboard/src/components/table/table-cells/sales-channel/description-cell/index.ts
new file mode 100644
index 0000000000..83760e2da6
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/table/table-cells/sales-channel/description-cell/index.ts
@@ -0,0 +1 @@
+export * from "./description-cell"
diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/sales-channel/name-cell/index.ts b/packages/admin-next/dashboard/src/components/table/table-cells/sales-channel/name-cell/index.ts
new file mode 100644
index 0000000000..9002871f49
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/table/table-cells/sales-channel/name-cell/index.ts
@@ -0,0 +1 @@
+export * from "./name-cell"
diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/sales-channel/name-cell/name-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/sales-channel/name-cell/name-cell.tsx
new file mode 100644
index 0000000000..97cae7a9df
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/table/table-cells/sales-channel/name-cell/name-cell.tsx
@@ -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
+ }
+
+ return (
+
+ {name}
+
+ )
+}
+
+export const NameHeader = () => {
+ const { t } = useTranslation()
+
+ return (
+
+ {t("fields.name")}
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/hooks/table/columns/use-sales-channel-table-columns.tsx b/packages/admin-next/dashboard/src/hooks/table/columns/use-sales-channel-table-columns.tsx
new file mode 100644
index 0000000000..9dac4356c1
--- /dev/null
+++ b/packages/admin-next/dashboard/src/hooks/table/columns/use-sales-channel-table-columns.tsx
@@ -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
()
+
+export const useSalesChannelTableColumns = () => {
+ return useMemo(
+ () => [
+ columnHelper.accessor("name", {
+ header: () => ,
+ cell: ({ getValue }) => ,
+ }),
+ columnHelper.accessor("description", {
+ header: () => ,
+ cell: ({ getValue }) => ,
+ }),
+ ],
+ []
+ )
+}
diff --git a/packages/admin-next/dashboard/src/hooks/table/filters/use-sales-channel-table-filters.tsx b/packages/admin-next/dashboard/src/hooks/table/filters/use-sales-channel-table-filters.tsx
new file mode 100644
index 0000000000..f1e9719043
--- /dev/null
+++ b/packages/admin-next/dashboard/src/hooks/table/filters/use-sales-channel-table-filters.tsx
@@ -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
+}
diff --git a/packages/admin-next/dashboard/src/hooks/table/query/use-customer-table-query.tsx b/packages/admin-next/dashboard/src/hooks/table/query/use-customer-table-query.tsx
index 513cab37c2..c396dbfebe 100644
--- a/packages/admin-next/dashboard/src/hooks/table/query/use-customer-table-query.tsx
+++ b/packages/admin-next/dashboard/src/hooks/table/query/use-customer-table-query.tsx
@@ -23,7 +23,8 @@ export const useCustomerTableQuery = ({
prefix
)
- const { offset, groups, has_account, q, order } = queryObject
+ const { offset, groups, created_at, updated_at, has_account, q, order } =
+ queryObject
const searchParams: AdminGetCustomersParams = {
limit: pageSize,
@@ -31,12 +32,8 @@ export const useCustomerTableQuery = ({
groups: groups?.split(","),
has_account: has_account ? has_account === "true" : undefined,
order,
- created_at: queryObject.created_at
- ? JSON.parse(queryObject.created_at)
- : undefined,
- updated_at: queryObject.updated_at
- ? JSON.parse(queryObject.updated_at)
- : undefined,
+ created_at: created_at ? JSON.parse(created_at) : undefined,
+ updated_at: updated_at ? JSON.parse(updated_at) : undefined,
q,
}
diff --git a/packages/admin-next/dashboard/src/hooks/table/query/use-sales-channel-table-query.tsx b/packages/admin-next/dashboard/src/hooks/table/query/use-sales-channel-table-query.tsx
new file mode 100644
index 0000000000..fa21932605
--- /dev/null
+++ b/packages/admin-next/dashboard/src/hooks/table/query/use-sales-channel-table-query.tsx
@@ -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,
+ }
+}
diff --git a/packages/admin-next/dashboard/src/hooks/use-command-history.tsx b/packages/admin-next/dashboard/src/hooks/use-command-history.tsx
new file mode 100644
index 0000000000..4bb4c89cf3
--- /dev/null
+++ b/packages/admin-next/dashboard/src/hooks/use-command-history.tsx
@@ -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([])
+ const [future, setFuture] = useState([])
+
+ 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,
+ }
+}
diff --git a/packages/admin-next/dashboard/src/hooks/use-data-table.tsx b/packages/admin-next/dashboard/src/hooks/use-data-table.tsx
index ac8cb22a4f..3ef748051e 100644
--- a/packages/admin-next/dashboard/src/hooks/use-data-table.tsx
+++ b/packages/admin-next/dashboard/src/hooks/use-data-table.tsx
@@ -3,6 +3,7 @@ import {
OnChangeFn,
PaginationState,
Row,
+ RowSelectionState,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
@@ -16,6 +17,10 @@ type UseDataTableProps = {
count?: number
pageSize?: number
enableRowSelection?: boolean | ((row: Row) => boolean)
+ rowSelection?: {
+ state: RowSelectionState
+ updater: OnChangeFn
+ }
enablePagination?: boolean
getRowId?: (original: TData, index: number) => string
meta?: Record
@@ -29,6 +34,7 @@ export const useDataTable = ({
pageSize: _pageSize = 20,
enablePagination = true,
enableRowSelection = false,
+ rowSelection: _rowSelection,
getRowId,
meta,
prefix,
@@ -48,7 +54,9 @@ export const useDataTable = ({
}),
[pageIndex, pageSize]
)
- const [rowSelection, setRowSelection] = useState({})
+ const [localRowSelection, setLocalRowSelection] = useState({})
+ const rowSelection = _rowSelection?.state ?? localRowSelection
+ const setRowSelection = _rowSelection?.updater ?? setLocalRowSelection
useEffect(() => {
if (!enablePagination) {
@@ -93,7 +101,7 @@ export const useDataTable = ({
data,
columns,
state: {
- rowSelection,
+ rowSelection: rowSelection, // We always pass a selection state to the table even if it's not enabled
pagination: enablePagination ? pagination : undefined,
},
pageCount: Math.ceil((count ?? 0) / pageSize),
diff --git a/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx
index 43ac0c9c2d..1bfe955510 100644
--- a/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx
+++ b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx
@@ -119,8 +119,14 @@ const router = createBrowserRouter([
},
children: [
{
- index: true,
+ path: "",
lazy: () => import("../../routes/products/product-list"),
+ children: [
+ {
+ path: "create",
+ lazy: () => import("../../routes/products/product-create"),
+ },
+ ],
},
{
path: ":id",
@@ -128,6 +134,30 @@ const router = createBrowserRouter([
handle: {
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"),
+ },
+ ],
},
],
},
diff --git a/packages/admin-next/dashboard/src/routes/collections/collection-detail/components/collection-product-section/collection-product-section.tsx b/packages/admin-next/dashboard/src/routes/collections/collection-detail/components/collection-product-section/collection-product-section.tsx
index a58d79c012..64c20a1d26 100644
--- a/packages/admin-next/dashboard/src/routes/collections/collection-detail/components/collection-product-section/collection-product-section.tsx
+++ b/packages/admin-next/dashboard/src/routes/collections/collection-detail/components/collection-product-section/collection-product-section.tsx
@@ -107,7 +107,7 @@ export const CollectionProductSection = ({
columns={columns}
search
pagination
- rowCount={PAGE_SIZE}
+ pageSize={PAGE_SIZE}
navigateTo={({ original }) => `/products/${original.id}`}
count={count}
filters={filters}
diff --git a/packages/admin-next/dashboard/src/routes/collections/collection-list/components/collection-list-table/collection-list-table.tsx b/packages/admin-next/dashboard/src/routes/collections/collection-list/components/collection-list-table/collection-list-table.tsx
index b68a115094..81a97ca4ce 100644
--- a/packages/admin-next/dashboard/src/routes/collections/collection-list/components/collection-list-table/collection-list-table.tsx
+++ b/packages/admin-next/dashboard/src/routes/collections/collection-list/components/collection-list-table/collection-list-table.tsx
@@ -52,7 +52,7 @@ export const CollectionListTable = () => {
`/customers/${row.id}`}
diff --git a/packages/admin-next/dashboard/src/routes/customer-groups/customer-group-list/components/customer-group-list-table/customer-group-list-table.tsx b/packages/admin-next/dashboard/src/routes/customer-groups/customer-group-list/components/customer-group-list-table/customer-group-list-table.tsx
index 6561d6c1f2..3bd0d964df 100644
--- a/packages/admin-next/dashboard/src/routes/customer-groups/customer-group-list/components/customer-group-list-table/customer-group-list-table.tsx
+++ b/packages/admin-next/dashboard/src/routes/customer-groups/customer-group-list/components/customer-group-list-table/customer-group-list-table.tsx
@@ -57,7 +57,7 @@ export const CustomerGroupListTable = () => {
{
{
count={count}
search
isLoading={isLoading}
- rowCount={PAGE_SIZE}
+ pageSize={PAGE_SIZE}
orderBy={["created_at", "updated_at"]}
queryObject={raw}
/>
diff --git a/packages/admin-next/dashboard/src/routes/orders/order-list/components/order-list-table/order-list-table.tsx b/packages/admin-next/dashboard/src/routes/orders/order-list/components/order-list-table/order-list-table.tsx
index 45267be53c..04274e1863 100644
--- a/packages/admin-next/dashboard/src/routes/orders/order-list/components/order-list-table/order-list-table.tsx
+++ b/packages/admin-next/dashboard/src/routes/orders/order-list/components/order-list-table/order-list-table.tsx
@@ -58,7 +58,7 @@ export const OrderListTable = () => {
count={count}
search
isLoading={isLoading}
- rowCount={PAGE_SIZE}
+ pageSize={PAGE_SIZE}
orderBy={["display_id", "created_at", "updated_at"]}
queryObject={raw}
/>
diff --git a/packages/admin-next/dashboard/src/routes/products/product-attributes/components/product-attributes-form/index.ts b/packages/admin-next/dashboard/src/routes/products/product-attributes/components/product-attributes-form/index.ts
new file mode 100644
index 0000000000..0feeabbb90
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-attributes/components/product-attributes-form/index.ts
@@ -0,0 +1 @@
+export * from "./product-attributes-form"
diff --git a/packages/admin-next/dashboard/src/routes/products/product-attributes/components/product-attributes-form/product-attributes-form.tsx b/packages/admin-next/dashboard/src/routes/products/product-attributes/components/product-attributes-form/product-attributes-form.tsx
new file mode 100644
index 0000000000..ae5e0dfc28
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-attributes/components/product-attributes-form/product-attributes-form.tsx
@@ -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>({
+ 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 (
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-attributes/index.ts b/packages/admin-next/dashboard/src/routes/products/product-attributes/index.ts
new file mode 100644
index 0000000000..68b62df37f
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-attributes/index.ts
@@ -0,0 +1 @@
+export { ProductAttributes as Component } from "./product-attributes"
diff --git a/packages/admin-next/dashboard/src/routes/products/product-attributes/product-attributes.tsx b/packages/admin-next/dashboard/src/routes/products/product-attributes/product-attributes.tsx
new file mode 100644
index 0000000000..eebeac6be4
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-attributes/product-attributes.tsx
@@ -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 (
+
+
+
+ {t("products.editAttributes")}
+
+ {!isLoading && product && (
+
+ )}
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-create/components/create-product-form/create-product-details.tsx b/packages/admin-next/dashboard/src/routes/products/product-create/components/create-product-form/create-product-details.tsx
new file mode 100644
index 0000000000..dd6a84c1ab
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-create/components/create-product-form/create-product-details.tsx
@@ -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 (
+
+
+
+
+
+ {t("products.createProductTitle")}
+
+ {t("products.createProductHint")}
+
+
+
+
+
+
+
{
+ return (
+
+
+ {t("fields.handle")}
+
+
+
+
+
+ )
+ }}
+ />
+ {
+ return (
+
+
+ {t("fields.material")}
+
+
+
+
+
+ )
+ }}
+ />
+
+
{
+ return (
+
+
+ {t("fields.description")}
+
+
+
+
+
+ ]}
+ />
+
+
+ )
+ }}
+ />
+
+
+ {t("products.organization")}
+
+
+
+
+
+
+
+
+ {open && (
+
+
+ {/* Is this meant to be resizable? And if so we need some kind of focus state for the handle cc: @ludvig18 */}
+
+
+
+ onOpenChange(false)} />
+
+
+ )}
+
+ )
+}
+
+const PAGE_SIZE = 20
+
+const AddSalesChannelsDrawer = ({ onCancel }: { onCancel: () => void }) => {
+ const { t } = useTranslation()
+ const [selection, setSelection] = useState({})
+
+ 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 (
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const columnHelper = createColumnHelper()
+
+const useColumns = () => {
+ const base = useSalesChannelTableColumns()
+
+ return useMemo(
+ () => [
+ columnHelper.display({
+ id: "select",
+ header: ({ table }) => {
+ return (
+
+ table.toggleAllPageRowsSelected(!!value)
+ }
+ />
+ )
+ },
+ cell: ({ row }) => {
+ return (
+ row.toggleSelected(!!value)}
+ onClick={(e) => {
+ e.stopPropagation()
+ }}
+ />
+ )
+ },
+ }),
+ ...base,
+ ],
+ [base]
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-create/components/create-product-form/create-product-form.tsx b/packages/admin-next/dashboard/src/routes/products/product-create/components/create-product-form/create-product-form.tsx
new file mode 100644
index 0000000000..8f0c9fe4e6
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-create/components/create-product-form/create-product-form.tsx
@@ -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
+export type CreateProductFormReturn = UseFormReturn
+
+export const CreateProductForm = ({ subscribe }: CreateProductFormProps) => {
+ const { t } = useTranslation()
+ const form = useForm({
+ 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 (
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-create/components/create-product-form/index.ts b/packages/admin-next/dashboard/src/routes/products/product-create/components/create-product-form/index.ts
new file mode 100644
index 0000000000..fb4ce91618
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-create/components/create-product-form/index.ts
@@ -0,0 +1 @@
+export * from "./create-product-form"
diff --git a/packages/admin-next/dashboard/src/routes/products/product-create/index.ts b/packages/admin-next/dashboard/src/routes/products/product-create/index.ts
new file mode 100644
index 0000000000..1254c40ca8
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-create/index.ts
@@ -0,0 +1 @@
+export { ProductCreate as Component } from "./product-create"
diff --git a/packages/admin-next/dashboard/src/routes/products/product-create/product-create.tsx b/packages/admin-next/dashboard/src/routes/products/product-create/product-create.tsx
new file mode 100644
index 0000000000..8232bc2494
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-create/product-create.tsx
@@ -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 (
+
+
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-attribute-section/product-attribute-section.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-attribute-section/product-attribute-section.tsx
index 92e2bf8912..c16843d7ea 100644
--- a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-attribute-section/product-attribute-section.tsx
+++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-attribute-section/product-attribute-section.tsx
@@ -1,18 +1,92 @@
-import { Product } from "@medusajs/medusa";
-import { Container, Heading } from "@medusajs/ui";
+import { PencilSquare } from "@medusajs/icons"
+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 = {
- product: Product;
-};
+ product: Product
+}
export const ProductAttributeSection = ({
product,
}: ProductAttributeSectionProps) => {
+ const { t } = useTranslation()
+
return (
-
-
- Attributes
-
-
- );
-};
+
+
+
{t("products.attributes")}
+
,
+ },
+ ],
+ },
+ ]}
+ />
+
+
+
+ {t("fields.height")}
+
+
+ {product.height ?? "-"}
+
+
+
+
+ {t("fields.width")}
+
+
+ {product.width ?? "-"}
+
+
+
+
+ {t("fields.length")}
+
+
+ {product.length ?? "-"}
+
+
+
+
+ {t("fields.weight")}
+
+
+ {product.weight ?? "-"}
+
+
+
+
+ {t("fields.midCode")}
+
+
+ {product.mid_code ?? "-"}
+
+
+
+
+ {t("fields.hsCode")}
+
+
+ {product.hs_code ?? "-"}
+
+
+
+
+ {t("fields.countryOfOrigin")}
+
+
+ {product.origin_country ?? "-"}
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-general-section/product-general-section.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-general-section/product-general-section.tsx
index d4b06f8444..03e56a8566 100644
--- a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-general-section/product-general-section.tsx
+++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-general-section/product-general-section.tsx
@@ -1,109 +1,107 @@
-import { EllipseGreenSolid, EllipsisHorizontal } from "@medusajs/icons";
-import { Product, ProductTag } from "@medusajs/medusa";
-import {
- Badge,
- Button,
- Container,
- Heading,
- IconButton,
- Text,
-} from "@medusajs/ui";
-import { type ReactNode } from "react";
-import { useTranslation } from "react-i18next";
+import { PencilSquare, Trash } from "@medusajs/icons"
+import { Product } from "@medusajs/medusa"
+import { Container, Heading, StatusBadge, Text, usePrompt } from "@medusajs/ui"
+import { useAdminDeleteProduct } from "medusa-react"
+import { useTranslation } from "react-i18next"
+import { useNavigate } from "react-router-dom"
+import { ActionMenu } from "../../../../../components/common/action-menu"
type ProductGeneralSectionProps = {
- product: Product;
-};
+ product: Product
+}
export const ProductGeneralSection = ({
product,
}: ProductGeneralSectionProps) => {
- return (
-
-
-
-
-
-
{product.title}
-
-
-
-
-
-
-
-
- {product.description}
-
-
-
-
-
-
-
- );
-};
+ const { t } = useTranslation()
+ const prompt = usePrompt()
+ const navigate = useNavigate()
-type ProductTagsProps = {
- tags?: ProductTag[] | null;
-};
+ const { mutateAsync } = useAdminDeleteProduct(product.id)
-const ProductTags = ({ tags }: ProductTagsProps) => {
- if (!tags || tags.length === 0) {
- return null;
+ 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(undefined, {
+ onSuccess: () => {
+ navigate("..")
+ },
+ })
}
return (
-
- {tags.map((t) => {
- return (
-
- {t.value}
-
- );
- })}
-
- );
-};
-
-type ProductDetailProps = {
- label: string;
- value?: ReactNode;
-};
-
-const ProductDetail = ({ label, value }: ProductDetailProps) => {
- return (
-
- {label}
- {value ? value : "-"}
-
- );
-};
-
-type ProductDetailsProps = {
- product: Product;
-};
-
-const ProductDetails = ({ product }: ProductDetailsProps) => {
- const { t } = useTranslation();
-
- return (
-
-
{t("general.details")}
-
-
-
-
-
-
-
+
+
+
{product.title}
+
+
Published
+
,
+ },
+ ],
+ },
+ {
+ actions: [
+ {
+ label: t("actions.delete"),
+ onClick: handleDelete,
+ icon:
,
+ },
+ ],
+ },
+ ]}
+ />
+
-
- );
-};
+
+
+ {t("fields.description")}
+
+
+ {product.description}
+
+
+
+
+ {t("fields.subtitle")}
+
+
+ {product.subtitle ?? "-"}
+
+
+
+
+ {t("fields.handle")}
+
+
+ /{product.handle}
+
+
+
+
+ {t("fields.discountable")}
+
+
+ {product.discountable ? t("fields.true") : t("fields.false")}
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-media-section/product-media-section.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-media-section/product-media-section.tsx
index a765327b04..7cc39e0621 100644
--- a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-media-section/product-media-section.tsx
+++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-media-section/product-media-section.tsx
@@ -1,36 +1,181 @@
-import { Product } from "@medusajs/medusa";
-import { Container, Heading } from "@medusajs/ui";
+import { PencilSquare, Photo, ThumbnailBadge } from "@medusajs/icons"
+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 = {
- product: Product;
-};
+ product: Product
+}
export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
+ const { t } = useTranslation()
+ const prompt = usePrompt()
+ const [selection, setSelection] = useState
>({})
+
+ 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 (
-
-
-
- Media
-
- {product.images?.length > 0 && (
-
- {product.images.map((i) => {
- return (
+
+
+
{t("products.media")}
+
,
+ },
+ {
+ label: t("products.gallery"),
+ to: "gallery",
+ icon:
,
+ },
+ ],
+ },
+ ]}
+ />
+
+ {media && (
+
+ {media.map((i) => {
+ return (
+
0,
+ }
+ )}
>
+ handleCheckedChange(i.id)}
+ />
+
+ {i.isThumbnail && (
+
+
+
+
+
+ )}
+

-
- );
+
+
+ )
+ })}
+
+ )}
+
+
+
+ {t("general.countSelected", {
+ count: Object.keys(selection).length,
})}
-
- )}
-
-
- );
-};
+
+
+
+
+
+
+ )
+}
+
+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
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-option-section/product-option-section.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-option-section/product-option-section.tsx
index cba0159b02..24d7ac133e 100644
--- a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-option-section/product-option-section.tsx
+++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-option-section/product-option-section.tsx
@@ -1,18 +1,67 @@
-import { Product } from "@medusajs/medusa";
-import { Container, Heading } from "@medusajs/ui";
+import { PencilSquare } from "@medusajs/icons"
+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 = {
- product: Product;
-};
+ product: Product
+}
export const ProductOptionSection = ({
product,
}: ProductOptionSectionProps) => {
+ const { t } = useTranslation()
+
return (
-
-
- Options
-
-
- );
-};
+
+
+
{t("products.options")}
+
,
+ },
+ ],
+ },
+ ]}
+ />
+
+ {product.options.map((option) => {
+ return (
+
+
+ {option.title}
+
+
+ {getUnqiueValues(option).map((value) => {
+ return (
+
+ {value}
+
+ )
+ })}
+
+
+ )
+ })}
+
+ )
+}
+
+const getUnqiueValues = (option: ProductOption) => {
+ const values = option.values.map((v) => v.value)
+
+ return Array.from(new Set(values))
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-organization-section/index.ts b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-organization-section/index.ts
new file mode 100644
index 0000000000..7457b7c87a
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-organization-section/index.ts
@@ -0,0 +1 @@
+export * from "./product-organization-section"
diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-organization-section/product-organization-section.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-organization-section/product-organization-section.tsx
new file mode 100644
index 0000000000..c24aa90d83
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-organization-section/product-organization-section.tsx
@@ -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 (
+
+
+
{t("products.organization")}
+
,
+ },
+ ],
+ },
+ ]}
+ />
+
+
+
+ {t("fields.tags")}
+
+
+ {product.tags.length > 0
+ ? product.tags.map((tag) => (
+
+ {tag.value}
+
+ ))
+ : "-"}
+
+
+
+
+ {t("fields.type")}
+
+ {product.type ? (
+
+
+ {product.type.value}
+
+
+ ) : (
+
+ -
+
+ )}
+
+
+
+ {t("fields.collection")}
+
+ {product.collection ? (
+
+
+ {product.collection.title}
+
+
+ ) : (
+
+ -
+
+ )}
+
+
+
+ {t("fields.categories")}
+
+
+ {product.categories.length > 0
+ ? product.categories.map((pcat) => (
+
+ {pcat.name}
+
+ ))
+ : "-"}
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-sales-channel-section/product-sales-channel-section.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-sales-channel-section/product-sales-channel-section.tsx
index e7cac61138..e67ee16ffc 100644
--- a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-sales-channel-section/product-sales-channel-section.tsx
+++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-sales-channel-section/product-sales-channel-section.tsx
@@ -1,54 +1,105 @@
-import { Channels } from "@medusajs/icons";
-import { Product } from "@medusajs/medusa";
-import { Container, Heading, Text } from "@medusajs/ui";
-import { useAdminSalesChannels } from "medusa-react";
-import { Trans, useTranslation } from "react-i18next";
+import { Channels, PencilSquare } from "@medusajs/icons"
+import { Product } from "@medusajs/medusa"
+import { Container, Heading, Text, Tooltip } from "@medusajs/ui"
+import { useAdminSalesChannels } from "medusa-react"
+import { Trans, useTranslation } from "react-i18next"
+import { ActionMenu } from "../../../../../components/common/action-menu"
type ProductSalesChannelSectionProps = {
- product: Product;
-};
+ product: Product
+}
export const ProductSalesChannelSection = ({
product,
}: ProductSalesChannelSectionProps) => {
- const { count } = useAdminSalesChannels();
- const { t } = useTranslation();
+ const { count } = useAdminSalesChannels()
+ const { t } = useTranslation()
const availableInSalesChannels =
product.sales_channels?.map((sc) => ({
id: sc.id,
name: sc.name,
- })) ?? [];
+ })) ?? []
+
+ const firstChannels = availableInSalesChannels.slice(0, 3)
+ const restChannels = availableInSalesChannels.slice(3)
return (
-
-
-
- {t("fields.sales_channels")}
-
-
-
-
-
-
+
+
+
{t("fields.sales_channels")}
+
,
+ },
+ ],
+ },
+ ]}
+ />
+
+
+
-
-
- ,
- ,
- ]}
- />
+ {availableInSalesChannels.length > 0 ? (
+
+
+ {firstChannels.map((sc) => sc.name).join(", ")}
+
+ {restChannels.length > 0 && (
+
+ {restChannels.map((sc) => (
+ {sc.name}
+ ))}
+
+ }
+ >
+
+ {`+${restChannels.length}`}
+
+
+ )}
+
+ ) : (
+
+ {t("products.noSalesChannels")}
-
-
-
- );
-};
+ )}
+
+
+
+ ,
+ ,
+ ]}
+ />
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-thumbnail-section/index.ts b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-thumbnail-section/index.ts
deleted file mode 100644
index f3fb2a0e2b..0000000000
--- a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-thumbnail-section/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./product-thumbnail-section";
diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-thumbnail-section/product-thumbnail-section.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-thumbnail-section/product-thumbnail-section.tsx
deleted file mode 100644
index 7926e0ab91..0000000000
--- a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-thumbnail-section/product-thumbnail-section.tsx
+++ /dev/null
@@ -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 (
-
-
-
- Thumbnail
-
-
- {product.thumbnail && (
-
-

-
- )}
-
-
-
- );
-};
diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/index.ts b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/index.ts
index f979682346..a92ad5b788 100644
--- a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/index.ts
+++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/index.ts
@@ -1 +1 @@
-export * from "./product-variant-section";
+export * from "./product-variant-section"
diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx
index 8f8f331524..d5e0d3f670 100644
--- a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx
+++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx
@@ -1,18 +1,90 @@
-import { Product } from "@medusajs/medusa";
-import { Container, Heading } from "@medusajs/ui";
+import { PencilSquare } from "@medusajs/icons"
+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 = {
- product: Product;
-};
+ product: Product
+}
+
+const PAGE_SIZE = 10
export const ProductVariantSection = ({
product,
}: 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 (
-
-
- Variants
-
-
- );
-};
+
+
+
{t("products.variants")}
+
,
+ },
+ ],
+ },
+ ]}
+ />
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-columns.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-columns.tsx
new file mode 100644
index 0000000000..74e07b81c1
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-columns.tsx
@@ -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 (
+
,
+ },
+ ],
+ },
+ {
+ actions: [
+ {
+ label: t("actions.delete"),
+ onClick: handleDelete,
+ icon:
,
+ },
+ ],
+ },
+ ]}
+ />
+ )
+}
+
+const columnHelper = createColumnHelper
()
+
+export const useProductVariantTableColumns = (product?: Product) => {
+ const { t } = useTranslation()
+
+ const optionColumns = useMemo(() => {
+ return product
+ ? product.options?.map((o) => {
+ return columnHelper.display({
+ id: o.id,
+ header: () => (
+
+ {o.title}
+
+ ),
+ cell: ({ row }) => {
+ const value = row.original.options.find(
+ (op) => op.option_id === o.id
+ )
+
+ if (!value) {
+ return
+ }
+
+ return (
+
+
+ {value.value}
+
+
+ )
+ },
+ })
+ })
+ : []
+ }, [product])
+
+ return useMemo(
+ () => [
+ columnHelper.accessor("title", {
+ header: () => (
+
+ {t("fields.title")}
+
+ ),
+ cell: ({ getValue }) => (
+
+ {getValue()}
+
+ ),
+ }),
+ columnHelper.accessor("sku", {
+ header: () => (
+
+ {t("fields.sku")}
+
+ ),
+ cell: ({ getValue }) => {
+ const value = getValue()
+
+ if (!value) {
+ return
+ }
+
+ return (
+
+ {value}
+
+ )
+ },
+ }),
+ ...optionColumns,
+ columnHelper.display({
+ id: "actions",
+ cell: ({ row, table }) => {
+ const { product } = table.options.meta as { product: Product }
+
+ return
+ },
+ }),
+ ],
+ [t, optionColumns]
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-filters.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-filters.tsx
new file mode 100644
index 0000000000..7af6069d17
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-filters.tsx
@@ -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
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-query.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-query.tsx
new file mode 100644
index 0000000000..a0092fc633
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-query.tsx
@@ -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,
+ }
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/product-detail.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/product-detail.tsx
index 7f0f42009b..b823d9c715 100644
--- a/packages/admin-next/dashboard/src/routes/products/product-detail/product-detail.tsx
+++ b/packages/admin-next/dashboard/src/routes/products/product-detail/product-detail.tsx
@@ -1,5 +1,5 @@
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 { 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 { ProductOptionSection } from "./components/product-option-section"
import { ProductSalesChannelSection } from "./components/product-sales-channel-section"
-import { ProductThumbnailSection } from "./components/product-thumbnail-section"
import { ProductVariantSection } from "./components/product-variant-section"
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 sideAfter from "medusa-admin:widgets/product/details/side/after"
import sideBefore from "medusa-admin:widgets/product/details/side/before"
+import { ProductOrganizationSection } from "./components/product-organization-section"
export const ProductDetail = () => {
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) {
- return Loading
+ if (isLoading || !product) {
+ return Loading...
}
- // TODO: Move to error.tsx and set as ErrorBoundary for the route
- if (isError || !product) {
- const err = error ? JSON.parse(JSON.stringify(error)) : null
- return (
-
- {(err as Error & { status: number })?.status === 404 ? (
-
Not found
- ) : (
-
Something went wrong!
- )}
-
- )
+ if (isError) {
+ throw error
}
return (
@@ -61,10 +50,9 @@ export const ProductDetail = () => {
-
+
-
{sideBefore.widgets.map((w, i) => {
return (
@@ -73,8 +61,9 @@ export const ProductDetail = () => {
)
})}
-
-
+
+
+
{sideAfter.widgets.map((w, i) => {
return (
@@ -100,8 +89,9 @@ export const ProductDetail = () => {
)
})}
-
-
+
+
+
{sideAfter.widgets.map((w, i) => {
return (
@@ -111,6 +101,7 @@ export const ProductDetail = () => {
})}
+
)
}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-edit/components/edit-product-form/edit-product-form.tsx b/packages/admin-next/dashboard/src/routes/products/product-edit/components/edit-product-form/edit-product-form.tsx
new file mode 100644
index 0000000000..3889b91008
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-edit/components/edit-product-form/edit-product-form.tsx
@@ -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>({
+ 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 (
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-edit/components/edit-product-form/index.ts b/packages/admin-next/dashboard/src/routes/products/product-edit/components/edit-product-form/index.ts
new file mode 100644
index 0000000000..c0a3a488b3
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-edit/components/edit-product-form/index.ts
@@ -0,0 +1 @@
+export * from "./edit-product-form"
diff --git a/packages/admin-next/dashboard/src/routes/products/product-edit/index.ts b/packages/admin-next/dashboard/src/routes/products/product-edit/index.ts
new file mode 100644
index 0000000000..39e931ef5f
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-edit/index.ts
@@ -0,0 +1 @@
+export { ProductEdit as Component } from "./product-edit"
diff --git a/packages/admin-next/dashboard/src/routes/products/product-edit/product-edit.tsx b/packages/admin-next/dashboard/src/routes/products/product-edit/product-edit.tsx
new file mode 100644
index 0000000000..0da39a73c5
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-edit/product-edit.tsx
@@ -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 (
+
+
+
+ {t("products.editProduct")}
+
+ {!isLoading && product && (
+
+ )}
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-gallery/index.ts b/packages/admin-next/dashboard/src/routes/products/product-gallery/index.ts
new file mode 100644
index 0000000000..899d525721
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-gallery/index.ts
@@ -0,0 +1 @@
+export { ProductGallery as Component } from "./product-gallery"
diff --git a/packages/admin-next/dashboard/src/routes/products/product-gallery/product-gallery.tsx b/packages/admin-next/dashboard/src/routes/products/product-gallery/product-gallery.tsx
new file mode 100644
index 0000000000..83744149a0
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-gallery/product-gallery.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+ esc
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {media[currentIndex].isThumbnail && (
+
+
+
+
+
+ )}
+

+
+
+
+ paginate(-1)}
+ className="absolute left-4 top-1/2 z-[2] rounded-full"
+ >
+
+
+ paginate(1)}
+ className="absolute right-4 top-1/2 z-[2] rounded-full"
+ >
+
+
+
+
+ {media.map((img, index) => (
+
+ ))}
+
+
+
+
+ )
+}
+
+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
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-list/components/product-list-table/product-list-table.tsx b/packages/admin-next/dashboard/src/routes/products/product-list/components/product-list-table/product-list-table.tsx
index fc9e5f5308..2ac39bbb89 100644
--- a/packages/admin-next/dashboard/src/routes/products/product-list/components/product-list-table/product-list-table.tsx
+++ b/packages/admin-next/dashboard/src/routes/products/product-list/components/product-list-table/product-list-table.tsx
@@ -1,180 +1,101 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import type { Product } from "@medusajs/medusa"
-import {
- Button,
- Checkbox,
- CommandBar,
- Container,
- Heading,
- Table,
- clx,
-} from "@medusajs/ui"
-import {
- PaginationState,
- RowSelectionState,
- createColumnHelper,
- flexRender,
- getCoreRowModel,
- useReactTable,
-} from "@tanstack/react-table"
+import { Button, Container, Heading, usePrompt } from "@medusajs/ui"
+import { createColumnHelper } from "@tanstack/react-table"
import { useAdminDeleteProduct, useAdminProducts } from "medusa-react"
-import { useMemo, useState } from "react"
+import { useMemo } from "react"
import { useTranslation } from "react-i18next"
-import { useLoaderData, useNavigate } from "react-router-dom"
-
-import {
- ProductAvailabilityCell,
- ProductCollectionCell,
- ProductStatusCell,
- ProductTitleCell,
- ProductVariantCell,
-} from "../../../../../components/common/product-table-cells"
+import { Link, Outlet, useLoaderData } from "react-router-dom"
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"
-const PAGE_SIZE = 50
+const PAGE_SIZE = 20
export const ProductListTable = () => {
- const navigate = useNavigate()
const { t } = useTranslation()
- const [{ pageIndex, pageSize }, setPagination] = useState({
- pageIndex: 0,
- pageSize: PAGE_SIZE,
- })
-
- const [rowSelection, setRowSelection] = useState({})
-
const initialData = useLoaderData() as Awaited<
ReturnType>
>
- const { products, count } = useAdminProducts(
+ const { searchParams, raw } = useProductTableQuery({ pageSize: PAGE_SIZE })
+ const { products, count, isLoading, isError, error } = useAdminProducts(
{
- limit: PAGE_SIZE,
- offset: pageIndex * PAGE_SIZE,
+ ...searchParams,
},
{
initialData,
+ keepPreviousData: true,
}
)
+ const filters = useProductTableFilters()
const columns = useColumns()
- const pagination = useMemo(
- () => ({
- pageIndex,
- pageSize,
- }),
- [pageIndex, pageSize]
- )
-
- const table = useReactTable({
+ const { table } = useDataTable({
data: (products ?? []) as Product[],
columns,
- pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
- state: {
- pagination,
- rowSelection,
- },
- onPaginationChange: setPagination,
- onRowSelectionChange: setRowSelection,
- getCoreRowModel: getCoreRowModel(),
- manualPagination: true,
+ count,
+ enablePagination: true,
+ pageSize: PAGE_SIZE,
+ getRowId: (row) => row.id,
})
+ if (isError) {
+ throw error
+ }
+
return (
-
+
{t("products.domain")}
-
-
-
-
- {table.getHeaderGroups().map((headerGroup) => {
- return (
-
- {headerGroup.headers.map((header) => {
- return (
-
- {flexRender(
- header.column.columnDef.header,
- header.getContext()
- )}
-
- )
- })}
-
- )
- })}
-
-
- {table.getRowModel().rows.map((row) => (
- navigate(`/products/${row.original.id}`)}
- >
- {row.getVisibleCells().map((cell) => (
-
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
-
- ))}
-
- ))}
-
-
-
-
-
-
-
- {t("general.countSelected", {
- count: Object.keys(rowSelection).length,
- })}
-
-
- {
- console.log("Delete")
- }}
- shortcut="d"
- label={t("actions.delete")}
- />
-
-
+ `${row.original.id}`}
+ orderBy={["title", "created_at", "updated_at"]}
+ />
+
)
}
-const ProductActions = ({ id }: { id: string }) => {
+const ProductActions = ({ product }: { product: Product }) => {
const { t } = useTranslation()
- const { mutateAsync } = useAdminDeleteProduct(id)
+ const prompt = usePrompt()
+ const { mutateAsync } = useAdminDeleteProduct(product.id)
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()
}
@@ -186,7 +107,7 @@ const ProductActions = ({ id }: { id: string }) => {
{
icon: ,
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()
const useColumns = () => {
- const { t } = useTranslation()
+ const base = useProductTableColumns()
const columns = useMemo(
() => [
- columnHelper.display({
- id: "select",
- header: ({ table }) => {
- return (
-
- table.toggleAllPageRowsSelected(!!value)
- }
- />
- )
- },
- cell: ({ row }) => {
- return (
- row.toggleSelected(!!value)}
- onClick={(e) => {
- e.stopPropagation()
- }}
- />
- )
- },
- }),
- columnHelper.accessor("title", {
- header: t("fields.title"),
- cell: ({ row }) => {
- return
- },
- }),
- columnHelper.accessor("collection", {
- header: t("fields.collection"),
- cell: (cell) => {
- const collection = cell.getValue()
-
- return
- },
- }),
- columnHelper.accessor("sales_channels", {
- header: t("fields.availability"),
- cell: (cell) => {
- const salesChannels = cell.getValue()
-
- return
- },
- }),
- columnHelper.accessor("variants", {
- header: t("fields.inventory"),
- cell: (cell) => {
- const variants = cell.getValue()
-
- return
- },
- }),
- columnHelper.accessor("status", {
- header: t("fields.status"),
- cell: (cell) => {
- const value = cell.getValue()
-
- return
- },
- }),
+ ...base,
columnHelper.display({
id: "actions",
cell: ({ row }) => {
- return
+ return
},
}),
],
- [t]
+ [base]
)
return columns
diff --git a/packages/admin-next/dashboard/src/routes/products/product-options/components/edit-product-options-form/edit-product-options-form.tsx b/packages/admin-next/dashboard/src/routes/products/product-options/components/edit-product-options-form/edit-product-options-form.tsx
new file mode 100644
index 0000000000..e918f83718
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-options/components/edit-product-options-form/edit-product-options-form.tsx
@@ -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>({
+ resolver: zodResolver(EditProductOptionsSchema),
+ })
+
+ return (
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-options/components/edit-product-options-form/index.ts b/packages/admin-next/dashboard/src/routes/products/product-options/components/edit-product-options-form/index.ts
new file mode 100644
index 0000000000..e4cab80bac
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-options/components/edit-product-options-form/index.ts
@@ -0,0 +1 @@
+export * from "./edit-product-options-form"
diff --git a/packages/admin-next/dashboard/src/routes/products/product-options/index.ts b/packages/admin-next/dashboard/src/routes/products/product-options/index.ts
new file mode 100644
index 0000000000..9bd1f33231
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-options/index.ts
@@ -0,0 +1 @@
+export { ProductOptions as Component } from "./product-options"
diff --git a/packages/admin-next/dashboard/src/routes/products/product-options/product-options.tsx b/packages/admin-next/dashboard/src/routes/products/product-options/product-options.tsx
new file mode 100644
index 0000000000..14e3efa70d
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-options/product-options.tsx
@@ -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 (
+
+
+
+ {t("products.editOptions")}
+
+ {!isLoading && product && (
+
+ )}
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-sales-channels/components/edit-sales-channels-form/edit-sales-channels-form.tsx b/packages/admin-next/dashboard/src/routes/products/product-sales-channels/components/edit-sales-channels-form/edit-sales-channels-form.tsx
new file mode 100644
index 0000000000..70a277a67f
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-sales-channels/components/edit-sales-channels-form/edit-sales-channels-form.tsx
@@ -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(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 (
+
+
+
+
+
+ {t("actions.cancel")}
+
+
+
+ {t("actions.save")}
+
+
+
+
+
+
+
+ )
+}
+
+const columnHelper = createColumnHelper()
+
+const useColumns = () => {
+ const columns = useSalesChannelTableColumns()
+
+ return useMemo(
+ () => [
+ columnHelper.display({
+ id: "select",
+ header: ({ table }) => {
+ return (
+
+ table.toggleAllPageRowsSelected(!!value)
+ }
+ />
+ )
+ },
+ cell: ({ row }) => {
+ return (
+ row.toggleSelected(!!value)}
+ onClick={(e) => {
+ e.stopPropagation()
+ }}
+ />
+ )
+ },
+ }),
+ ...columns,
+ ],
+ [columns]
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-sales-channels/components/edit-sales-channels-form/index.ts b/packages/admin-next/dashboard/src/routes/products/product-sales-channels/components/edit-sales-channels-form/index.ts
new file mode 100644
index 0000000000..812e228432
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-sales-channels/components/edit-sales-channels-form/index.ts
@@ -0,0 +1 @@
+export * from "./edit-sales-channels-form"
diff --git a/packages/admin-next/dashboard/src/routes/products/product-sales-channels/index.ts b/packages/admin-next/dashboard/src/routes/products/product-sales-channels/index.ts
new file mode 100644
index 0000000000..d4773a3373
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-sales-channels/index.ts
@@ -0,0 +1 @@
+export { ProductSalesChannels as Component } from "./product-sales-channels"
diff --git a/packages/admin-next/dashboard/src/routes/products/product-sales-channels/product-sales-channels.tsx b/packages/admin-next/dashboard/src/routes/products/product-sales-channels/product-sales-channels.tsx
new file mode 100644
index 0000000000..5485cf7c0a
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-sales-channels/product-sales-channels.tsx
@@ -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 (
+
+
+ {!isLoading && product && (
+
+ )}
+
+
+ )
+}
diff --git a/packages/design-system/icons/src/components/__tests__/bars-arrow-down.spec.tsx b/packages/design-system/icons/src/components/__tests__/bars-arrow-down.spec.tsx
new file mode 100644
index 0000000000..8b08eeaa8f
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/bars-arrow-down.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/chart-pie.spec.tsx b/packages/design-system/icons/src/components/__tests__/chart-pie.spec.tsx
new file mode 100644
index 0000000000..932d0252d6
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/chart-pie.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/folder-illustration.spec.tsx b/packages/design-system/icons/src/components/__tests__/folder-illustration.spec.tsx
new file mode 100644
index 0000000000..8ff143110c
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/folder-illustration.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/folder-open-illustration.spec.tsx b/packages/design-system/icons/src/components/__tests__/folder-open-illustration.spec.tsx
new file mode 100644
index 0000000000..312407f871
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/folder-open-illustration.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/sidebar.spec.tsx b/packages/design-system/icons/src/components/__tests__/loader.spec.tsx
similarity index 72%
rename from packages/design-system/icons/src/components/__tests__/sidebar.spec.tsx
rename to packages/design-system/icons/src/components/__tests__/loader.spec.tsx
index c2476afdc3..fb750bc5db 100644
--- a/packages/design-system/icons/src/components/__tests__/sidebar.spec.tsx
+++ b/packages/design-system/icons/src/components/__tests__/loader.spec.tsx
@@ -1,11 +1,11 @@
import * as React from "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 () => {
- render()
+ render()
const svgElement = screen.getByTestId("icon")
diff --git a/packages/design-system/icons/src/components/__tests__/queue-list.spec.tsx b/packages/design-system/icons/src/components/__tests__/queue-list.spec.tsx
new file mode 100644
index 0000000000..b7e66bc182
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/queue-list.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/sidebar-left.spec.tsx b/packages/design-system/icons/src/components/__tests__/sidebar-left.spec.tsx
new file mode 100644
index 0000000000..ca38b7ea3f
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/sidebar-left.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/sidebar-right.spec.tsx b/packages/design-system/icons/src/components/__tests__/sidebar-right.spec.tsx
new file mode 100644
index 0000000000..b6f5993138
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/sidebar-right.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/square-blue-solid.spec.tsx b/packages/design-system/icons/src/components/__tests__/square-blue-solid.spec.tsx
new file mode 100644
index 0000000000..4bbeb52fc2
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/square-blue-solid.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/square-green-solid.spec.tsx b/packages/design-system/icons/src/components/__tests__/square-green-solid.spec.tsx
new file mode 100644
index 0000000000..8547b6c81a
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/square-green-solid.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/square-grey-solid.spec.tsx b/packages/design-system/icons/src/components/__tests__/square-grey-solid.spec.tsx
new file mode 100644
index 0000000000..b7f4bf7733
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/square-grey-solid.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/square-orange-solid.spec.tsx b/packages/design-system/icons/src/components/__tests__/square-orange-solid.spec.tsx
new file mode 100644
index 0000000000..5c6990f1d5
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/square-orange-solid.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/square-purple-solid.spec.tsx b/packages/design-system/icons/src/components/__tests__/square-purple-solid.spec.tsx
new file mode 100644
index 0000000000..655c606460
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/square-purple-solid.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/square-red-solid.spec.tsx b/packages/design-system/icons/src/components/__tests__/square-red-solid.spec.tsx
new file mode 100644
index 0000000000..0fb21e5cc3
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/square-red-solid.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/stopwatch.spec.tsx b/packages/design-system/icons/src/components/__tests__/stopwatch.spec.tsx
new file mode 100644
index 0000000000..20ed186749
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/stopwatch.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/thumbnail-badge.spec.tsx b/packages/design-system/icons/src/components/__tests__/thumbnail-badge.spec.tsx
new file mode 100644
index 0000000000..433ec2a527
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/thumbnail-badge.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/verified-badge.spec.tsx b/packages/design-system/icons/src/components/__tests__/verified-badge.spec.tsx
new file mode 100644
index 0000000000..6f2c5357d7
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/verified-badge.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/__tests__/wand-sparkle.spec.tsx b/packages/design-system/icons/src/components/__tests__/wand-sparkle.spec.tsx
new file mode 100644
index 0000000000..2fd880fc5e
--- /dev/null
+++ b/packages/design-system/icons/src/components/__tests__/wand-sparkle.spec.tsx
@@ -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()
+
+
+ const svgElement = screen.getByTestId("icon")
+
+ expect(svgElement).toBeInTheDocument()
+
+ cleanup()
+ })
+ })
\ No newline at end of file
diff --git a/packages/design-system/icons/src/components/bars-arrow-down.tsx b/packages/design-system/icons/src/components/bars-arrow-down.tsx
new file mode 100644
index 0000000000..aa325fc831
--- /dev/null
+++ b/packages/design-system/icons/src/components/bars-arrow-down.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+import type { IconProps } from "../types"
+const BarsArrowDown = React.forwardRef(
+ ({ color = "currentColor", ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+BarsArrowDown.displayName = "BarsArrowDown"
+export default BarsArrowDown
diff --git a/packages/design-system/icons/src/components/chart-pie.tsx b/packages/design-system/icons/src/components/chart-pie.tsx
new file mode 100644
index 0000000000..83d01f5ed0
--- /dev/null
+++ b/packages/design-system/icons/src/components/chart-pie.tsx
@@ -0,0 +1,33 @@
+import * as React from "react"
+import type { IconProps } from "../types"
+const ChartPie = React.forwardRef(
+ ({ color = "currentColor", ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+ChartPie.displayName = "ChartPie"
+export default ChartPie
diff --git a/packages/design-system/icons/src/components/figma.tsx b/packages/design-system/icons/src/components/figma.tsx
index 8889689161..5dd545fffa 100644
--- a/packages/design-system/icons/src/components/figma.tsx
+++ b/packages/design-system/icons/src/components/figma.tsx
@@ -21,7 +21,7 @@ const Figma = React.forwardRef>(
/>
(
+ ({ color = "currentColor", ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+FolderIllustration.displayName = "FolderIllustration"
+export default FolderIllustration
diff --git a/packages/design-system/icons/src/components/folder-open-illustration.tsx b/packages/design-system/icons/src/components/folder-open-illustration.tsx
new file mode 100644
index 0000000000..d86184609e
--- /dev/null
+++ b/packages/design-system/icons/src/components/folder-open-illustration.tsx
@@ -0,0 +1,112 @@
+import * as React from "react"
+import type { IconProps } from "../types"
+const FolderOpenIllustration = React.forwardRef(
+ ({ color = "currentColor", ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+FolderOpenIllustration.displayName = "FolderOpenIllustration"
+export default FolderOpenIllustration
diff --git a/packages/design-system/icons/src/components/index.ts b/packages/design-system/icons/src/components/index.ts
index 85d3ab9b62..d6e045e3a2 100644
--- a/packages/design-system/icons/src/components/index.ts
+++ b/packages/design-system/icons/src/components/index.ts
@@ -33,6 +33,7 @@ export { default as ArrowsPointingOut } from "./arrows-pointing-out"
export { default as ArrrowRight } from "./arrrow-right"
export { default as AtSymbol } from "./at-symbol"
export { default as BackwardSolid } from "./backward-solid"
+export { default as BarsArrowDown } from "./bars-arrow-down"
export { default as BarsThree } from "./bars-three"
export { default as Beaker } from "./beaker"
export { default as BellAlertSolid } from "./bell-alert-solid"
@@ -56,6 +57,7 @@ export { default as Cash } from "./cash"
export { default as ChannelsSolid } from "./channels-solid"
export { default as Channels } from "./channels"
export { default as ChartBar } from "./chart-bar"
+export { default as ChartPie } from "./chart-pie"
export { default as ChatBubbleLeftRightSolid } from "./chat-bubble-left-right-solid"
export { default as ChatBubbleLeftRight } from "./chat-bubble-left-right"
export { default as ChatBubble } from "./chat-bubble"
@@ -134,6 +136,8 @@ export { default as Facebook } from "./facebook"
export { default as Figma } from "./figma"
export { default as FlagMini } from "./flag-mini"
export { default as FlyingBox } from "./flying-box"
+export { default as FolderIllustration } from "./folder-illustration"
+export { default as FolderOpenIllustration } from "./folder-open-illustration"
export { default as FolderOpen } from "./folder-open"
export { default as Folder } from "./folder"
export { default as ForwardSolid } from "./forward-solid"
@@ -164,6 +168,7 @@ export { default as LightBulb } from "./light-bulb"
export { default as Link } from "./link"
export { default as Linkedin } from "./linkedin"
export { default as ListBullet } from "./list-bullet"
+export { default as Loader } from "./loader"
export { default as LockClosedSolidMini } from "./lock-closed-solid-mini"
export { default as LockClosedSolid } from "./lock-closed-solid"
export { default as LockOpenSolid } from "./lock-open-solid"
@@ -196,6 +201,7 @@ export { default as PuzzleSolid } from "./puzzle-solid"
export { default as Puzzle } from "./puzzle"
export { default as QuestionMarkCircle } from "./question-mark-circle"
export { default as QuestionMark } from "./question-mark"
+export { default as QueueList } from "./queue-list"
export { default as ReactJsEx } from "./react-js-ex"
export { default as ReactJs } from "./react-js"
export { default as ReceiptPercent } from "./receipt-percent"
@@ -212,7 +218,8 @@ export { default as Server } from "./server"
export { default as ShoppingBag } from "./shopping-bag"
export { default as ShoppingCartSolid } from "./shopping-cart-solid"
export { default as ShoppingCart } from "./shopping-cart"
-export { default as Sidebar } from "./sidebar"
+export { default as SidebarLeft } from "./sidebar-left"
+export { default as SidebarRight } from "./sidebar-right"
export { default as Slack } from "./slack"
export { default as Snooze } from "./snooze"
export { default as SparklesMiniSolid } from "./sparkles-mini-solid"
@@ -220,6 +227,12 @@ export { default as SparklesMini } from "./sparkles-mini"
export { default as SparklesSolid } from "./sparkles-solid"
export { default as Sparkles } from "./sparkles"
export { default as Spinner } from "./spinner"
+export { default as SquareBlueSolid } from "./square-blue-solid"
+export { default as SquareGreenSolid } from "./square-green-solid"
+export { default as SquareGreySolid } from "./square-grey-solid"
+export { default as SquareOrangeSolid } from "./square-orange-solid"
+export { default as SquarePurpleSolid } from "./square-purple-solid"
+export { default as SquareRedSolid } from "./square-red-solid"
export { default as SquareTwoStackMini } from "./square-two-stack-mini"
export { default as SquareTwoStackSolid } from "./square-two-stack-solid"
export { default as SquareTwoStack } from "./square-two-stack"
@@ -227,6 +240,7 @@ export { default as SquaresPlusSolid } from "./squares-plus-solid"
export { default as SquaresPlus } from "./squares-plus"
export { default as StarSolid } from "./star-solid"
export { default as Star } from "./star"
+export { default as Stopwatch } from "./stopwatch"
export { default as Stripe } from "./stripe"
export { default as SunSolid } from "./sun-solid"
export { default as Sun } from "./sun"
@@ -238,6 +252,7 @@ export { default as Tailwind } from "./tailwind"
export { default as Text } from "./text"
export { default as ThumbDown } from "./thumb-down"
export { default as ThumbUp } from "./thumb-up"
+export { default as ThumbnailBadge } from "./thumbnail-badge"
export { default as ToolsSolid } from "./tools-solid"
export { default as Tools } from "./tools"
export { default as Trash } from "./trash"
@@ -255,7 +270,9 @@ export { default as User } from "./user"
export { default as UsersSolid } from "./users-solid"
export { default as Users } from "./users"
export { default as Vercel } from "./vercel"
+export { default as VerifiedBadge } from "./verified-badge"
export { default as Visa } from "./visa"
+export { default as WandSparkle } from "./wand-sparkle"
export { default as Window } from "./window"
export { default as XCircleSolid } from "./x-circle-solid"
export { default as XCircle } from "./x-circle"
diff --git a/packages/design-system/icons/src/components/loader.tsx b/packages/design-system/icons/src/components/loader.tsx
new file mode 100644
index 0000000000..34e5796dfc
--- /dev/null
+++ b/packages/design-system/icons/src/components/loader.tsx
@@ -0,0 +1,58 @@
+import * as React from "react"
+import type { IconProps } from "../types"
+const Loader = React.forwardRef(
+ ({ color = "currentColor", ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Loader.displayName = "Loader"
+export default Loader
diff --git a/packages/design-system/icons/src/components/queue-list.tsx b/packages/design-system/icons/src/components/queue-list.tsx
new file mode 100644
index 0000000000..2d3b8b241f
--- /dev/null
+++ b/packages/design-system/icons/src/components/queue-list.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+import type { IconProps } from "../types"
+const QueueList = React.forwardRef(
+ ({ color = "currentColor", ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+QueueList.displayName = "QueueList"
+export default QueueList
diff --git a/packages/design-system/icons/src/components/sidebar.tsx b/packages/design-system/icons/src/components/sidebar-left.tsx
similarity index 82%
rename from packages/design-system/icons/src/components/sidebar.tsx
rename to packages/design-system/icons/src/components/sidebar-left.tsx
index e665007b02..70286c8cbc 100644
--- a/packages/design-system/icons/src/components/sidebar.tsx
+++ b/packages/design-system/icons/src/components/sidebar-left.tsx
@@ -1,6 +1,6 @@
import * as React from "react"
import type { IconProps } from "../types"
-const Sidebar = React.forwardRef(
+const SidebarLeft = React.forwardRef(
({ color = "currentColor", ...props }, ref) => {
return (
)
diff --git a/packages/design-system/icons/src/components/wand-sparkle.tsx b/packages/design-system/icons/src/components/wand-sparkle.tsx
new file mode 100644
index 0000000000..6f5c4b6055
--- /dev/null
+++ b/packages/design-system/icons/src/components/wand-sparkle.tsx
@@ -0,0 +1,37 @@
+import * as React from "react"
+import type { IconProps } from "../types"
+const WandSparkle = React.forwardRef(
+ ({ color = "currentColor", ...props }, ref) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+)
+WandSparkle.displayName = "WandSparkle"
+export default WandSparkle
diff --git a/packages/generated/client-types/src/lib/models/AdminGetProductsVariantsParams.ts b/packages/generated/client-types/src/lib/models/AdminGetProductsVariantsParams.ts
index eb9af1f272..f7fef60162 100644
--- a/packages/generated/client-types/src/lib/models/AdminGetProductsVariantsParams.ts
+++ b/packages/generated/client-types/src/lib/models/AdminGetProductsVariantsParams.ts
@@ -4,6 +4,10 @@
import { SetRelation, Merge } from "../core/ModelUtils"
export interface AdminGetProductsVariantsParams {
+ /**
+ * IDs to filter product variants by.
+ */
+ id?: string
/**
* Comma-separated fields that should be included in the returned product variants.
*/
@@ -20,4 +24,62 @@ export interface AdminGetProductsVariantsParams {
* Limit the number of product variants returned.
*/
limit?: number
+ /**
+ * Search term to search product variants' title, sku, and products' title.
+ */
+ q?: string
+ /**
+ * The field to sort the data by. By default, the sort order is ascending. To change the order to descending, prefix the field name with `-`.
+ */
+ order?: string
+ /**
+ * Filter product variants by whether their inventory is managed or not.
+ */
+ manage_inventory?: boolean
+ /**
+ * Filter product variants by whether they are allowed to be backordered or not.
+ */
+ allow_backorder?: boolean
+ /**
+ * Filter by a creation date range.
+ */
+ created_at?: {
+ /**
+ * filter by dates less than this date
+ */
+ lt?: string
+ /**
+ * filter by dates greater than this date
+ */
+ gt?: string
+ /**
+ * filter by dates less than or equal to this date
+ */
+ lte?: string
+ /**
+ * filter by dates greater than or equal to this date
+ */
+ gte?: string
+ }
+ /**
+ * Filter by an update date range.
+ */
+ updated_at?: {
+ /**
+ * filter by dates less than this date
+ */
+ lt?: string
+ /**
+ * filter by dates greater than this date
+ */
+ gt?: string
+ /**
+ * filter by dates less than or equal to this date
+ */
+ lte?: string
+ /**
+ * filter by dates greater than or equal to this date
+ */
+ gte?: string
+ }
}
diff --git a/packages/medusa-js/src/resources/admin/products.ts b/packages/medusa-js/src/resources/admin/products.ts
index 9f312018c7..7bca1c143b 100644
--- a/packages/medusa-js/src/resources/admin/products.ts
+++ b/packages/medusa-js/src/resources/admin/products.ts
@@ -1,5 +1,6 @@
import {
AdminGetProductsParams,
+ AdminGetProductsVariantsParams,
AdminPostProductsProductMetadataReq,
AdminPostProductsProductOptionsOption,
AdminPostProductsProductOptionsReq,
@@ -13,6 +14,7 @@ import {
AdminProductsListRes,
AdminProductsListTagsRes,
AdminProductsListTypesRes,
+ AdminProductsListVariantsRes,
AdminProductsRes,
} from "@medusajs/medusa"
import qs from "qs"
@@ -22,11 +24,11 @@ import BaseResource from "../base"
/**
* This class is used to send requests to [Admin Product API Routes](https://docs.medusajs.com/api/admin#products). All its method
* are available in the JS Client under the `medusa.admin.products` property.
- *
+ *
* All methods in this class require {@link AdminAuthResource.createSession | user authentication}.
- *
+ *
* Products are saleable items in a store. This also includes [saleable gift cards](https://docs.medusajs.com/modules/gift-cards/admin/manage-gift-cards#manage-gift-card-product) in a store.
- *
+ *
* Related Guide: [How to manage products](https://docs.medusajs.com/modules/products/admin/manage-products).
*/
class AdminProductsResource extends BaseResource {
@@ -35,7 +37,7 @@ class AdminProductsResource extends BaseResource {
* @param {AdminPostProductsReq} payload - The product to create.
* @param {Record} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise} Resolves to the product's details.
- *
+ *
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -62,7 +64,7 @@ class AdminProductsResource extends BaseResource {
* @param {string} id - The product's ID.
* @param {Record} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise} Resolves to the product's details.
- *
+ *
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -86,7 +88,7 @@ class AdminProductsResource extends BaseResource {
* @param {AdminPostProductsProductReq} payload - The attributes to update in a product.
* @param {Record} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise} Resolves to the product's details.
- *
+ *
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -112,7 +114,7 @@ class AdminProductsResource extends BaseResource {
* @param {string} id - The product's ID.
* @param {Record} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise} Resolves to the deletion operation's details.
- *
+ *
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -135,10 +137,10 @@ class AdminProductsResource extends BaseResource {
* @param {AdminGetProductsParams} query - Filters and pagination configurations to apply on the retrieved products.
* @param {Record} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise} Resolves to the list of products with pagination fields.
- *
+ *
* @example
* To list products:
- *
+ *
* ```ts
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -148,9 +150,9 @@ class AdminProductsResource extends BaseResource {
* console.log(products.length);
* })
* ```
- *
+ *
* To specify relations that should be retrieved within the products:
- *
+ *
* ```ts
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -162,9 +164,9 @@ class AdminProductsResource extends BaseResource {
* console.log(products.length);
* })
* ```
- *
+ *
* By default, only the first `50` records are retrieved. You can control pagination by specifying the `limit` and `offset` properties:
- *
+ *
* ```ts
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -195,7 +197,7 @@ class AdminProductsResource extends BaseResource {
/**
* @ignore
- *
+ *
* @deprecated Use {@link AdminProductTypesResource.list} instead.
*/
listTypes(
@@ -209,7 +211,7 @@ class AdminProductsResource extends BaseResource {
* Retrieve a list of Product Tags with how many times each is used in products.
* @param {Record} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise} Resolves to the list of tags.
- *
+ *
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -227,13 +229,13 @@ class AdminProductsResource extends BaseResource {
}
/**
- * Set the metadata of a product. It can be any key-value pair, which allows adding custom data to a product. Learn about how you can update and delete the metadata attribute
+ * Set the metadata of a product. It can be any key-value pair, which allows adding custom data to a product. Learn about how you can update and delete the metadata attribute
* [here](https://docs.medusajs.com/development/entities/overview#metadata-attribute).
* @param {string} id - The product's ID.
* @param {AdminPostProductsProductMetadataReq} payload - The metadata details to add, update, or delete.
* @param {Record} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise} Resolves to the product's details.
- *
+ *
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -261,7 +263,7 @@ class AdminProductsResource extends BaseResource {
* @param {AdminPostProductsProductVariantsReq} payload - The product variant to create.
* @param {Record} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise} Resolves to the product's details. You can access the variant under the `variants` property.
- *
+ *
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -298,11 +300,11 @@ class AdminProductsResource extends BaseResource {
/**
* Update a product variant's details.
* @param {string} id - The ID of the product that the variant belongs to.
- * @param {string} variantId - The ID of the product variant.
+ * @param {string} variantId - The ID of the product variant.
* @param {AdminPostProductsProductVariantsVariantReq} payload - The attributes to update in the product variant.
* @param {Record} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise} Resolves to the product's details. You can access the variant under the `variants` property.
- *
+ *
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -340,10 +342,10 @@ class AdminProductsResource extends BaseResource {
/**
* Delete a product variant.
* @param {string} id - The ID of the product that the variant belongs to.
- * @param {string} variantId - The ID of the product variant.
+ * @param {string} variantId - The ID of the product variant.
* @param {Record} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise} Resolves to the deletion operation's details.
- *
+ *
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -362,13 +364,46 @@ class AdminProductsResource extends BaseResource {
return this.client.request("DELETE", path, undefined, {}, customHeaders)
}
+ /**
+ * List the product variants associated with a product. The product variants can be filtered by fields such as `q` or `manage_inventory` passed in the `query` parameter. The product variants can also be sorted or paginated.
+ * @param {string} id - The ID of the product that the variants belongs to.
+ * @param {AdminGetProductsVariantsParams} query - Filters and pagination configurations to apply on the retrieved product variants. If undefined, the first 100 records are retrieved.
+ * @param {Record} customHeaders - Custom headers to attach to the request.
+ * @returns {ResponsePromise} Resolves to the list of product variants with pagination fields.
+ *
+ * @example
+ * import Medusa from "@medusajs/medusa-js"
+ * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
+ * // must be previously logged in or use api token
+ * medusa.admin.products.listVariants(productId, {
+ * limit: 10,
+ * })
+ * .then(({ variants, limit, offset, count }) => {
+ * console.log(variants.length);
+ * })
+ */
+ listVariants(
+ id: string,
+ query?: AdminGetProductsVariantsParams,
+ customHeaders: Record = {}
+ ): ResponsePromise {
+ let path = `/admin/products/${id}/variants`
+
+ if (query) {
+ const queryString = qs.stringify(query)
+ path = `/admin/products/${id}/variants?${queryString}`
+ }
+
+ return this.client.request("GET", path, undefined, {}, customHeaders)
+ }
+
/**
* Add a product option to a product.
* @param {string} id - The product's ID.
* @param {AdminPostProductsProductOptionsReq} payload - The option to add.
* @param {Record} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise} Resolves to the product's details. You can access the variant under the `options` property.
- *
+ *
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -396,7 +431,7 @@ class AdminProductsResource extends BaseResource {
* @param {AdminPostProductsProductOptionsOption} payload - The attributes to update in the product option.
* @param {Record} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise} Resolves to the product's details. You can access the variant under the `options` property.
- *
+ *
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
diff --git a/packages/medusa-react/src/hooks/admin/products/queries.ts b/packages/medusa-react/src/hooks/admin/products/queries.ts
index b559ec174e..d4a62d1afb 100644
--- a/packages/medusa-react/src/hooks/admin/products/queries.ts
+++ b/packages/medusa-react/src/hooks/admin/products/queries.ts
@@ -1,8 +1,10 @@
import {
AdminGetProductParams,
AdminGetProductsParams,
+ AdminGetProductsVariantsParams,
AdminProductsListRes,
AdminProductsListTagsRes,
+ AdminProductsListVariantsRes,
AdminProductsRes,
} from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
@@ -13,24 +15,33 @@ import { queryKeysFactory } from "../../utils/index"
const ADMIN_PRODUCTS_QUERY_KEY = `admin_products` as const
-export const adminProductKeys = queryKeysFactory(ADMIN_PRODUCTS_QUERY_KEY)
+export const adminProductKeys = {
+ ...queryKeysFactory(ADMIN_PRODUCTS_QUERY_KEY),
+ detailVariants(id: string, query?: any) {
+ return [
+ ...this.detail(id),
+ "variants" as const,
+ { ...(query || {}) },
+ ] as const
+ },
+}
type ProductQueryKeys = typeof adminProductKeys
/**
- * This hook retrieves a list of products. The products can be filtered by fields such as `q` or `status` passed in
+ * This hook retrieves a list of products. The products can be filtered by fields such as `q` or `status` passed in
* the `query` parameter. The products can also be sorted or paginated.
- *
+ *
* @example
* To list products:
- *
+ *
* ```tsx
* import React from "react"
* import { useAdminProducts } from "medusa-react"
- *
+ *
* const Products = () => {
* const { products, isLoading } = useAdminProducts()
- *
+ *
* return (
*
* {isLoading && Loading...}
@@ -45,21 +56,21 @@ type ProductQueryKeys = typeof adminProductKeys
*
* )
* }
- *
+ *
* export default Products
* ```
- *
+ *
* To specify relations that should be retrieved within the products:
- *
+ *
* ```tsx
* import React from "react"
* import { useAdminProducts } from "medusa-react"
- *
+ *
* const Products = () => {
* const { products, isLoading } = useAdminProducts({
* expand: "images"
* })
- *
+ *
* return (
*
* {isLoading && Loading...}
@@ -74,19 +85,19 @@ type ProductQueryKeys = typeof adminProductKeys
*
* )
* }
- *
+ *
* export default Products
* ```
- *
+ *
* By default, only the first `50` records are retrieved. You can control pagination by specifying the `limit` and `offset` properties:
- *
+ *
* ```tsx
* import React from "react"
* import { useAdminProducts } from "medusa-react"
- *
+ *
* const Products = () => {
- * const {
- * products,
+ * const {
+ * products,
* limit,
* offset,
* isLoading
@@ -95,7 +106,7 @@ type ProductQueryKeys = typeof adminProductKeys
* limit: 20,
* offset: 0
* })
- *
+ *
* return (
*
* {isLoading && Loading...}
@@ -110,10 +121,10 @@ type ProductQueryKeys = typeof adminProductKeys
*
* )
* }
- *
+ *
* export default Products
* ```
- *
+ *
* @customNamespace Hooks.Admin.Products
* @category Queries
*/
@@ -139,32 +150,32 @@ export const useAdminProducts = (
/**
* This hook retrieves a product's details.
- *
+ *
* @example
* import React from "react"
* import { useAdminProduct } from "medusa-react"
- *
+ *
* type Props = {
* productId: string
* }
- *
+ *
* const Product = ({ productId }: Props) => {
- * const {
- * product,
- * isLoading,
+ * const {
+ * product,
+ * isLoading,
* } = useAdminProduct(productId)
- *
+ *
* return (
*
* {isLoading && Loading...}
* {product && {product.title}}
- *
+ *
*
* )
* }
- *
+ *
* export default Product
- *
+ *
* @customNamespace Hooks.Admin.Products
* @category Queries
*/
@@ -192,16 +203,40 @@ export const useAdminProduct = (
return { ...data, ...rest } as const
}
+export const useAdminProductVariants = (
+ /**
+ * The product's ID.
+ */
+ id: string,
+ /**
+ * Configurations to apply on the retrieved product variants.
+ */
+ query?: AdminGetProductsVariantsParams,
+ options?: UseQueryOptionsWrapper<
+ Response,
+ Error,
+ ReturnType
+ >
+) => {
+ const { client } = useMedusa()
+ const { data, ...rest } = useQuery(
+ adminProductKeys.detailVariants(id, query),
+ () => client.admin.products.listVariants(id, query),
+ options
+ )
+ return { ...data, ...rest } as const
+}
+
/**
* This hook retrieves a list of Product Tags with how many times each is used in products.
- *
+ *
* @example
* import React from "react"
* import { useAdminProductTagUsage } from "medusa-react"
- *
+ *
* const ProductTags = (productId: string) => {
* const { tags, isLoading } = useAdminProductTagUsage()
- *
+ *
* return (
*
* {isLoading && Loading...}
@@ -216,9 +251,9 @@ export const useAdminProduct = (
*
* )
* }
- *
+ *
* export default ProductTags
- *
+ *
* @customNamespace Hooks.Admin.Products
* @category Queries
*/
diff --git a/packages/medusa/src/api/routes/admin/products/__tests__/get-product.js b/packages/medusa/src/api/routes/admin/products/__tests__/get-product.js
index f99ea63f7e..4a58d2f808 100644
--- a/packages/medusa/src/api/routes/admin/products/__tests__/get-product.js
+++ b/packages/medusa/src/api/routes/admin/products/__tests__/get-product.js
@@ -1,6 +1,6 @@
import { IdMap } from "medusa-test-utils"
-import { ProductServiceMock } from "../../../../../services/__mocks__/product"
import { request } from "../../../../../helpers/test-request"
+import { ProductServiceMock } from "../../../../../services/__mocks__/product"
describe("GET /admin/products/:id", () => {
describe("successfully gets a product", () => {
@@ -24,7 +24,7 @@ describe("GET /admin/products/:id", () => {
jest.clearAllMocks()
})
- it("calls get product from productSerice", () => {
+ it("calls get product from productService", () => {
expect(ProductServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(ProductServiceMock.retrieve).toHaveBeenCalledWith(
IdMap.getId("product1"),
@@ -59,6 +59,7 @@ describe("GET /admin/products/:id", () => {
"collection",
"images",
"options",
+ "options.values",
"profiles",
"sales_channels",
"tags",
diff --git a/packages/medusa/src/api/routes/admin/products/__tests__/list-variants.js b/packages/medusa/src/api/routes/admin/products/__tests__/list-variants.js
index bd53c04250..a7097743ef 100644
--- a/packages/medusa/src/api/routes/admin/products/__tests__/list-variants.js
+++ b/packages/medusa/src/api/routes/admin/products/__tests__/list-variants.js
@@ -1,13 +1,19 @@
import { IdMap } from "medusa-test-utils"
+import {
+ defaultAdminGetProductsVariantsFields,
+ defaultAdminGetProductsVariantsRelations,
+} from ".."
import { request } from "../../../../../helpers/test-request"
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
describe("GET /admin/products/:id/variants", () => {
describe("successfully gets a product variants", () => {
- let subject
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
- beforeAll(async () => {
- subject = await request(
+ it("should call listAndCount with the default config", async () => {
+ await request(
"GET",
`/admin/products/${IdMap.getId("product1")}/variants`,
{
@@ -18,36 +24,22 @@ describe("GET /admin/products/:id/variants", () => {
},
}
)
- })
- afterAll(() => {
- jest.clearAllMocks()
- })
-
- it("should cal the get product from productService with the expected parameters without giving any config", () => {
expect(ProductVariantServiceMock.listAndCount).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.listAndCount).toHaveBeenCalledWith(
{
product_id: IdMap.getId("product1"),
},
- {
- relations: [],
- select: ["id", "product_id"],
+ expect.objectContaining({
+ relations: defaultAdminGetProductsVariantsRelations,
+ select: defaultAdminGetProductsVariantsFields,
skip: 0,
- take: 100
- }
+ take: 100,
+ })
)
})
- it("should returns product decorated", () => {
- expect(subject.body.variants.length).toEqual(2)
- expect(subject.body.variants).toEqual(expect.arrayContaining([
- expect.objectContaining({ product_id: IdMap.getId("product1") }),
- expect.objectContaining({ product_id: IdMap.getId("product1") }),
- ]))
- })
-
- it("should call the get product from productService with the expected parameters including the config that has been given", async () => {
+ it("should call listAndCount with the provided query params", async () => {
await request(
"GET",
`/admin/products/${IdMap.getId("product1")}/variants`,
@@ -58,24 +50,24 @@ describe("GET /admin/products/:id/variants", () => {
},
},
query: {
- expand: "variants.options",
- fields: "id, variants.id",
+ expand: "product",
+ fields: "id",
limit: 10,
- }
+ },
}
)
- expect(ProductVariantServiceMock.listAndCount).toHaveBeenCalledTimes(2)
+ expect(ProductVariantServiceMock.listAndCount).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.listAndCount).toHaveBeenLastCalledWith(
{
product_id: IdMap.getId("product1"),
},
- {
- relations: ["variants.options"],
- select: ["id", "product_id", "variants.id"],
+ expect.objectContaining({
+ relations: ["product"],
+ select: ["id", "created_at"],
skip: 0,
- take: 10
- }
+ take: 10,
+ })
)
})
})
diff --git a/packages/medusa/src/api/routes/admin/products/index.ts b/packages/medusa/src/api/routes/admin/products/index.ts
index bb25aeec77..8c0c10b437 100644
--- a/packages/medusa/src/api/routes/admin/products/index.ts
+++ b/packages/medusa/src/api/routes/admin/products/index.ts
@@ -10,6 +10,7 @@ import { PricedProduct } from "../../../../types/pricing"
import { validateSalesChannelsExist } from "../../../middlewares/validators/sales-channel-existence"
import { AdminGetProductParams } from "./get-product"
import { AdminGetProductsParams } from "./list-products"
+import { AdminGetProductsVariantsParams } from "./list-variants"
const route = Router()
@@ -42,7 +43,11 @@ export default (app, featureFlagRouter: FlagRouter) => {
route.get(
"/:id/variants",
- middlewares.normalizeQuery(),
+ transformQuery(AdminGetProductsVariantsParams, {
+ defaultRelations: defaultAdminGetProductsVariantsRelations,
+ defaultFields: defaultAdminGetProductsVariantsFields,
+ isList: true,
+ }),
middlewares.wrap(require("./list-variants").default)
)
route.post(
@@ -105,6 +110,7 @@ export const defaultAdminProductRelations = [
"profiles",
"images",
"options",
+ "options.values",
"tags",
"type",
"collection",
@@ -137,7 +143,33 @@ export const defaultAdminProductFields: (keyof Product)[] = [
"metadata",
]
-export const defaultAdminGetProductsVariantsFields = ["id", "product_id"]
+export const defaultAdminGetProductsVariantsFields = [
+ "id",
+ "product_id",
+ "title",
+ "sku",
+ "inventory_quantity",
+ "allow_backorder",
+ "manage_inventory",
+ "hs_code",
+ "origin_country",
+ "mid_code",
+ "material",
+ "weight",
+ "length",
+ "height",
+ "width",
+ "created_at",
+ "updated_at",
+ "deleted_at",
+ "metadata",
+ "variant_rank",
+ "ean",
+ "upc",
+ "barcode",
+]
+
+export const defaultAdminGetProductsVariantsRelations = ["options", "prices"]
/**
* This is temporary.
diff --git a/packages/medusa/src/api/routes/admin/products/list-variants.ts b/packages/medusa/src/api/routes/admin/products/list-variants.ts
index bd1cd3f87b..de0e9fb985 100644
--- a/packages/medusa/src/api/routes/admin/products/list-variants.ts
+++ b/packages/medusa/src/api/routes/admin/products/list-variants.ts
@@ -1,12 +1,17 @@
-import { IsNumber, IsOptional, IsString } from "class-validator"
+import {
+ IsBoolean,
+ IsNumber,
+ IsOptional,
+ IsString,
+ ValidateNested,
+} from "class-validator"
import { Request, Response } from "express"
-import { ProductVariant } from "../../../../models"
+import { Transform, Type } from "class-transformer"
import { ProductVariantService } from "../../../../services"
-import { Type } from "class-transformer"
-import { defaultAdminGetProductsVariantsFields } from "./index"
-import { getRetrieveConfig } from "../../../../utils/get-query-config"
-import { validator } from "../../../../utils/validator"
+import { DateComparisonOperator } from "../../../../types/common"
+import { IsType } from "../../../../utils"
+import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
/**
* @oas [get] /admin/products/{id}/variants
@@ -14,15 +19,62 @@ import { validator } from "../../../../utils/validator"
* summary: "List a Product's Variants"
* description: |
* Retrieve a list of Product Variants associated with a Product. The variants can be paginated.
- *
- * By default, each variant will only have the `id` and `variant_id` fields. You can use the `expand` and `fields` request parameters to retrieve more fields or relations.
* x-authenticated: true
* parameters:
* - (path) id=* {string} ID of the product.
+ * - (query) id {string} IDs to filter product variants by.
* - (query) fields {string} Comma-separated fields that should be included in the returned product variants.
* - (query) expand {string} Comma-separated relations that should be expanded in the returned product variants.
* - (query) offset=0 {integer} The number of product variants to skip when retrieving the product variants.
* - (query) limit=100 {integer} Limit the number of product variants returned.
+ * - (query) q {string} Search term to search product variants' title, sku, and products' title.
+ * - (query) order {string} The field to sort the data by. By default, the sort order is ascending. To change the order to descending, prefix the field name with `-`.
+ * - (query) manage_inventory {boolean} Filter product variants by whether their inventory is managed or not.
+ * - (query) allow_backorder {boolean} Filter product variants by whether they are allowed to be backordered or not.
+ * - in: query
+ * name: created_at
+ * description: Filter by a creation date range.
+ * schema:
+ * type: object
+ * properties:
+ * lt:
+ * type: string
+ * description: filter by dates less than this date
+ * format: date
+ * gt:
+ * type: string
+ * description: filter by dates greater than this date
+ * format: date
+ * lte:
+ * type: string
+ * description: filter by dates less than or equal to this date
+ * format: date
+ * gte:
+ * type: string
+ * description: filter by dates greater than or equal to this date
+ * format: date
+ * - in: query
+ * name: updated_at
+ * description: Filter by an update date range.
+ * schema:
+ * type: object
+ * properties:
+ * lt:
+ * type: string
+ * description: filter by dates less than this date
+ * format: date
+ * gt:
+ * type: string
+ * description: filter by dates greater than this date
+ * format: date
+ * lte:
+ * type: string
+ * description: filter by dates less than or equal to this date
+ * format: date
+ * gte:
+ * type: string
+ * description: filter by dates greater than or equal to this date
+ * format: date
* x-codegen:
* method: listVariants
* queryParams: AdminGetProductsVariantsParams
@@ -61,61 +113,111 @@ import { validator } from "../../../../utils/validator"
export default async (req: Request, res: Response) => {
const { id } = req.params
- const { expand, fields, limit, offset } = await validator(
- AdminGetProductsVariantsParams,
- req.query
- )
-
- const queryConfig = getRetrieveConfig(
- defaultAdminGetProductsVariantsFields as (keyof ProductVariant)[],
- [],
- [
- ...new Set([
- ...defaultAdminGetProductsVariantsFields,
- ...(fields?.split(",") ?? []),
- ]),
- ] as (keyof ProductVariant)[],
- expand ? expand?.split(",") : undefined
- )
-
const productVariantService: ProductVariantService = req.scope.resolve(
"productVariantService"
)
+
+ const { skip, take } = req.listConfig
+
const [variants, count] = await productVariantService.listAndCount(
{
product_id: id,
+ ...req.filterableFields,
},
- {
- ...queryConfig,
- skip: offset,
- take: limit,
- }
+ req.listConfig
)
res.json({
count,
variants,
- offset,
- limit,
+ offset: skip,
+ limit: take,
})
}
export class AdminGetProductsVariantsParams {
+ /**
+ * IDs to filter product variants by.
+ */
+ @IsOptional()
+ @IsType([String, [String]])
+ id?: string | string[]
+
+ /**
+ * {@inheritDoc FindParams.fields}
+ */
@IsString()
@IsOptional()
fields?: string
+ /**
+ * {@inheritDoc FindParams.expand}
+ */
@IsString()
@IsOptional()
expand?: string
+ /**
+ * {@inheritDoc FindPaginationParams.offset}
+ * @defaultValue 0
+ */
@IsNumber()
@IsOptional()
@Type(() => Number)
offset?: number = 0
+ /**
+ * {@inheritDoc FindPaginationParams.limit}
+ * @defaultValue 100
+ */
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 100
+
+ /**
+ * Search term to search product variants' title, sku, and products' title.
+ */
+ @IsString()
+ @IsOptional()
+ q?: string
+
+ /**
+ * The field to sort the data by. By default, the sort order is ascending. To change the order to descending, prefix the field name with `-`.
+ */
+ @IsString()
+ @IsOptional()
+ order?: string
+
+ /**
+ * Filter product variants by whether their inventory is managed or not.
+ */
+ @IsBoolean()
+ @IsOptional()
+ @Transform(({ value }) => optionalBooleanMapper.get(value.toLowerCase()))
+ manage_inventory?: boolean
+
+ /**
+ * Filter product variants by whether they are allowed to be backordered or not.
+ */
+ @IsBoolean()
+ @IsOptional()
+ @Transform(({ value }) => optionalBooleanMapper.get(value.toLowerCase()))
+ allow_backorder?: boolean
+
+ /**
+ * Date filters to apply on the product variants' `created_at` date.
+ */
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => DateComparisonOperator)
+ created_at?: DateComparisonOperator
+
+ /**
+ * Date filters to apply on the product variants' `updated_at` date.
+ */
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => DateComparisonOperator)
+ updated_at?: DateComparisonOperator
}
diff --git a/packages/medusa/src/types/product.ts b/packages/medusa/src/types/product.ts
index 571cd0a881..9ef67cb1c5 100644
--- a/packages/medusa/src/types/product.ts
+++ b/packages/medusa/src/types/product.ts
@@ -1,4 +1,4 @@
-import { DateComparisonOperator, FindConfig, Selector } from "./common"
+import { Transform, Type } from "class-transformer"
import {
IsArray,
IsBoolean,
@@ -15,14 +15,14 @@ import {
ProductStatus,
SalesChannel,
} from "../models"
-import { Transform, Type } from "class-transformer"
+import { DateComparisonOperator, FindConfig, Selector } from "./common"
-import { FeatureFlagDecorators } from "../utils/feature-flag-decorators"
import { FindOperator } from "typeorm"
+import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels"
+import { FeatureFlagDecorators } from "../utils/feature-flag-decorators"
+import { optionalBooleanMapper } from "../utils/validators/is-boolean"
import { IsType } from "../utils/validators/is-type"
import { PriceListLoadConfig } from "./price-list"
-import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels"
-import { optionalBooleanMapper } from "../utils/validators/is-boolean"
/**
* Filters to apply on retrieved products.
diff --git a/yarn.lock b/yarn.lock
index 9be232a3db..cf640f8a84 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8089,6 +8089,7 @@ __metadata:
"@radix-ui/react-hover-card": ^1.0.7
"@tanstack/react-query": 4.22.0
"@tanstack/react-table": 8.10.7
+ "@tanstack/react-virtual": ^3.0.4
"@types/node": ^20.11.15
"@types/react": 18.2.43
"@types/react-dom": 18.2.17
@@ -8109,6 +8110,7 @@ __metadata:
react-hook-form: 7.49.1
react-i18next: 13.5.0
react-jwt: ^1.2.0
+ react-resizable-panels: ^2.0.9
react-router-dom: 6.20.1
tailwindcss: ^3.4.1
typescript: 5.2.2
@@ -16791,6 +16793,18 @@ __metadata:
languageName: node
linkType: hard
+"@tanstack/react-virtual@npm:^3.0.4":
+ version: 3.0.4
+ resolution: "@tanstack/react-virtual@npm:3.0.4"
+ dependencies:
+ "@tanstack/virtual-core": 3.0.0
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
+ checksum: 6222dc1254843fa52bdb93deca74282e2b8aabd17337d7b35596441bb70d1d7e333b1679acfb50240847799d63ee24eb184958ad8c7f1ca84c4ff0c13aefa7ec
+ languageName: node
+ linkType: hard
+
"@tanstack/table-core@npm:8.10.7":
version: 8.10.7
resolution: "@tanstack/table-core@npm:8.10.7"
@@ -43948,6 +43962,16 @@ __metadata:
languageName: node
linkType: hard
+"react-resizable-panels@npm:^2.0.9":
+ version: 2.0.9
+ resolution: "react-resizable-panels@npm:2.0.9"
+ peerDependencies:
+ react: ^16.14.0 || ^17.0.0 || ^18.0.0
+ react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0
+ checksum: 0302ab32d3aa7d2887a2ad58882e447a8e22cef3c5c728cde7436442fcd7475969a86bb45d1a6e285b1b4e2a9ac49d13a0e2df4b29bc63542dc99044309e211c
+ languageName: node
+ linkType: hard
+
"react-router-dom@npm:6.20.1":
version: 6.20.1
resolution: "react-router-dom@npm:6.20.1"