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}} of <1>{{y}} 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 ( + + ) + })} + + ))} + + + {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( + header.column.columnDef.header, + header.getContext() + )} +
+
+ {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 ( +
+ + +
+
+ { + return ( + + {t("fields.width")} + + { + const value = e.target.value + + if (value === "") { + onChange(null) + } else { + onChange(parseFloat(value)) + } + }} + {...field} + /> + + + + ) + }} + /> + { + return ( + + {t("fields.height")} + + { + const value = e.target.value + + if (value === "") { + onChange(null) + } else { + onChange(Number(value)) + } + }} + {...field} + /> + + + + ) + }} + /> + { + return ( + + {t("fields.length")} + + { + const value = e.target.value + + if (value === "") { + onChange(null) + } else { + onChange(Number(value)) + } + }} + {...field} + /> + + + + ) + }} + /> + { + return ( + + {t("fields.weight")} + + { + const value = e.target.value + + if (value === "") { + onChange(null) + } else { + onChange(Number(value)) + } + }} + {...field} + /> + + + + ) + }} + /> + { + return ( + + {t("fields.midCode")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.hsCode")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.countryOfOrigin")} + + + + + + ) + }} + /> +
+
+
+ +
+ + + + +
+
+
+ + ) +} 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.title")} + + + + + ) + }} + /> + { + return ( + + + {t("fields.subtitle")} + + + + + + ) + }} + /> +
+ + ]} + /> + +
+
+ { + return ( + + + {t("fields.handle")} + + + + + + ) + }} + /> + { + return ( + + + {t("fields.material")} + + + + + + ) + }} + /> +
+ { + return ( + + + {t("fields.description")} + + +