diff --git a/.changeset/healthy-ligers-learn.md b/.changeset/healthy-ligers-learn.md new file mode 100644 index 0000000000..6f0a2a9081 --- /dev/null +++ b/.changeset/healthy-ligers-learn.md @@ -0,0 +1,6 @@ +--- +"@medusajs/ui-preset": patch +"@medusajs/ui": patch +--- + +feat(ui,ui-preset): Update to latest version of TailwindCSS. Increase spacing between columns in component. diff --git a/.eslintignore b/.eslintignore index f7f026ec94..a2f3463632 100644 --- a/.eslintignore +++ b/.eslintignore @@ -9,6 +9,8 @@ packages/* !packages/medusa !packages/admin-ui !packages/admin +!packages/admin-next +!packages/admin-next/dashboard !packages/medusa-payment-stripe !packages/medusa-payment-paypal !packages/event-bus-redis diff --git a/.eslintrc.js b/.eslintrc.js index c34c47f13a..56edede0b3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -72,7 +72,6 @@ module.exports = { node: true, jest: true, }, - ignorePatterns: ["packages/admin-next/dashboard/**/dist"], overrides: [ { files: ["*.ts"], @@ -86,6 +85,7 @@ module.exports = { "./packages/medusa-payment-paypal/tsconfig.spec.json", "./packages/admin-ui/tsconfig.json", "./packages/admin-ui/tsconfig.spec.json", + "./packages/admin-next/dashboard/tsconfig.json", "./packages/event-bus-local/tsconfig.spec.json", "./packages/event-bus-redis/tsconfig.spec.json", "./packages/medusa-plugin-meilisearch/tsconfig.spec.json", @@ -228,23 +228,52 @@ module.exports = { }, }, { - files: ["packages/admin-next/dashboard/src/**/*.{ts,tsx}"], - env: { browser: true, es2020: true, node: true }, + files: [ + "packages/admin-next/dashboard/**/*.ts", + "packages/admin-next/dashboard/**/*.tsx", + ], + plugins: ["unused-imports", "react-refresh"], extends: [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "plugin:react/jsx-runtime", "plugin:react-hooks/recommended", ], parser: "@typescript-eslint/parser", parserOptions: { - project: "tsconfig.json", + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features + sourceType: "module", // Allows for the use of imports + project: "./packages/admin-next/dashboard/tsconfig.json", + }, + globals: { + __BASE__: "readonly", + }, + env: { + browser: true, }, - plugins: ["react-refresh"], rules: { + "prettier/prettier": "error", + "react/prop-types": "off", + "new-cap": "off", + "require-jsdoc": "off", + "valid-jsdoc": "off", "react-refresh/only-export-components": [ "warn", { allowConstantExport: true }, ], + "no-unused-expressions": "off", + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "warn", + { + vars: "all", + varsIgnorePattern: "^_", + args: "after-used", + argsIgnorePattern: "^_", + }, + ], }, }, { diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json index 537059c726..db37624a6a 100644 --- a/packages/admin-next/dashboard/package.json +++ b/packages/admin-next/dashboard/package.json @@ -22,6 +22,7 @@ "@medusajs/icons": "workspace:^", "@medusajs/ui": "workspace:^", "@radix-ui/react-collapsible": "1.0.3", + "@radix-ui/react-hover-card": "^1.0.7", "@tanstack/react-query": "4.22.0", "@tanstack/react-table": "8.10.7", "@uiw/react-json-view": "2.0.0-alpha.10", @@ -43,13 +44,14 @@ "@medusajs/types": "workspace:^", "@medusajs/ui-preset": "workspace:^", "@medusajs/vite-plugin-extension": "workspace:^", + "@types/node": "^20.11.15", "@types/react": "18.2.43", "@types/react-dom": "18.2.17", "@vitejs/plugin-react": "4.2.1", "autoprefixer": "10.4.16", "postcss": "8.4.32", "prettier": "^3.1.1", - "tailwindcss": "3.3.6", + "tailwindcss": "^3.4.1", "typescript": "5.2.2", "vite": "5.0.10" }, diff --git a/packages/admin-next/dashboard/public/locales/en/translation.json b/packages/admin-next/dashboard/public/locales/en/translation.json index 7e8bee990e..713a2c3355 100644 --- a/packages/admin-next/dashboard/public/locales/en/translation.json +++ b/packages/admin-next/dashboard/public/locales/en/translation.json @@ -23,6 +23,7 @@ "pages": "pages", "next": "Next", "prev": "Prev", + "is": "is", "extensions": "Extensions", "settings": "Settings", "general": "General", @@ -35,6 +36,8 @@ "remove": "Remove", "admin": "Admin", "store": "Store", + "items_one": "{{count}} item", + "items_other": "{{count}} items", "countSelected": "{{count}} selected", "plusCountMore": "+ {{count}} more", "areYouSure": "Are you sure?", @@ -107,7 +110,29 @@ "deleteCustomerGroupWarning": "You are about to delete the customer group {{name}}. This action cannot be undone." }, "orders": { - "domain": "Orders" + "domain": "Orders", + "paymentStatusLabel": "Payment Status", + "paymentStatus": { + "notPaid": "Not Paid", + "awaiting": "Awaiting", + "captured": "Captured", + "partiallyRefunded": "Partially Refunded", + "refunded": "Refunded", + "canceled": "Canceled", + "requresAction": "Requires Action" + }, + "fulfillmentStatusLabel": "Fulfillment Status", + "fulfillmentStatus": { + "notFulfilled": "Not Fulfilled", + "partiallyFulfilled": "Partially Fulfilled", + "fulfilled": "Fulfilled", + "partiallyShipped": "Partially Shipped", + "shipped": "Shipped", + "partiallyReturned": "Partially Returned", + "returned": "Returned", + "canceled": "Canceled", + "requresAction": "Requires Action" + } }, "draftOrders": { "domain": "Draft Orders" @@ -263,6 +288,14 @@ "total": "Total", "created": "Created", "key": "Key", + "customer": "Customer", + "date": "Date", + "order": "Order", + "fulfillment": "Fulfillment", + "payment": "Payment", + "items": "Items", + "salesChannel": "Sales Channel", + "region": "Region", "role": "Role", "sent": "Sent" } diff --git a/packages/admin-next/dashboard/src/components/common/order-table-cells/index.ts b/packages/admin-next/dashboard/src/components/common/order-table-cells/index.ts deleted file mode 100644 index 20b29f6c85..0000000000 --- a/packages/admin-next/dashboard/src/components/common/order-table-cells/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./order-table-cells" diff --git a/packages/admin-next/dashboard/src/components/common/order-table-cells/order-table-cells.tsx b/packages/admin-next/dashboard/src/components/common/order-table-cells/order-table-cells.tsx deleted file mode 100644 index 9026e45590..0000000000 --- a/packages/admin-next/dashboard/src/components/common/order-table-cells/order-table-cells.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import type { Order } from "@medusajs/medusa" -import { StatusBadge } from "@medusajs/ui" -import { format } from "date-fns" -import { getPresentationalAmount } from "../../../lib/money-amount-helpers" - -export const OrderDisplayIdCell = ({ id }: { id: Order["display_id"] }) => { - return #{id} -} - -export const OrderDateCell = ({ - date, -}: { - date: Order["created_at"] | string -}) => { - const value = new Date(date) - - return {format(value, "dd MMM, yyyy")} -} - -export const OrderFulfillmentStatusCell = ({ - status, -}: { - status: Order["fulfillment_status"] -}) => { - switch (status) { - case "not_fulfilled": - return Not fulfilled - case "partially_fulfilled": - return Partially fulfilled - case "fulfilled": - return Fulfilled - case "partially_shipped": - return Partially shipped - case "shipped": - return Shipped - case "partially_returned": - return Partially returned - case "returned": - return Returned - case "canceled": - return Canceled - case "requires_action": - return Requires action - } -} - -export const OrderPaymentStatusCell = ({ - status, -}: { - status: Order["payment_status"] -}) => { - switch (status) { - case "not_paid": - return Not paid - case "awaiting": - return Awaiting - case "captured": - return Captured - case "partially_refunded": - return Partially refunded - case "refunded": - return Refunded - case "canceled": - return Canceled - case "requires_action": - return Requires action - } -} - -// TODO: Fix formatting amount with correct division eg. EUR 1000 -> EUR 10.00 -// Source currency info from `@medusajs/medusa` definition -export const OrderTotalCell = ({ - total, - currencyCode, -}: { - total: Order["total"] - currencyCode: Order["currency_code"] -}) => { - const formatted = new Intl.NumberFormat(undefined, { - style: "currency", - currency: currencyCode, - currencyDisplay: "narrowSymbol", - }).format(0) - - const symbol = formatted.replace(/\d/g, "").replace(/[.,]/g, "").trim() - - const presentationAmount = getPresentationalAmount(total, currencyCode) - const formattedTotal = new Intl.NumberFormat(undefined, { - style: "decimal", - }).format(presentationAmount) - - return ( - - {symbol} {formattedTotal} {currencyCode.toUpperCase()} - - ) -} 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 31044999a7..7150224afb 100644 --- a/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx +++ b/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx @@ -35,13 +35,13 @@ export const Shell = ({ children }: PropsWithChildren) => { {children}{children} -
+
-
+
-
+
) @@ -76,6 +76,7 @@ const Breadcrumbs = () => {
    {crumbs.map((crumb, index) => { const isLast = index === crumbs.length - 1 + const isSingle = crumbs.length === 1 return (
  1. { ) : (
    - ... - + {!isSingle && ...} + {crumb.label}
    diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/context.tsx b/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/context.tsx new file mode 100644 index 0000000000..daacb41440 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/context.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext } from "react" + +type DataTableFilterContextValue = { + removeFilter: (key: string) => void + removeAllFilters: () => void +} + +export const DataTableFilterContext = + createContext(null) + +export const useDataTableFilterContext = () => { + const ctx = useContext(DataTableFilterContext) + if (!ctx) { + throw new Error( + "useDataTableFacetedFilterContext must be used within a DataTableFacetedFilter" + ) + } + return ctx +} diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/data-table-filter.tsx b/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/data-table-filter.tsx new file mode 100644 index 0000000000..c5ac5f6be9 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/data-table-filter.tsx @@ -0,0 +1,255 @@ +import { Button, clx } from "@medusajs/ui" +import * as Popover from "@radix-ui/react-popover" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useSearchParams } from "react-router-dom" + +import { DataTableFilterContext, useDataTableFilterContext } from "./context" +import { DateFilter } from "./date-filter" +import { SelectFilter } from "./select-filter" + +type Option = { + label: string + value: unknown +} + +export type Filter = { + key: string + label: string +} & ( + | { + type: "select" + options: Option[] + multiple?: boolean + searchable?: boolean + } + | { + type: "date" + options?: never + } +) + +type DataTableFilterProps = { + filters: Filter[] + prefix?: string +} + +export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => { + const [searchParams] = useSearchParams() + const [open, setOpen] = useState(false) + + const [activeFilters, setActiveFilters] = useState( + getInitialFilters({ searchParams, filters, prefix }) + ) + + const availableFilters = filters.filter( + (f) => !activeFilters.find((af) => af.key === f.key) + ) + + /** + * If there are any filters in the URL that are not in the active filters, + * add them to the active filters. This ensures that we display the filters + * if a user navigates to a page with filters in the URL. + */ + const initialMount = useRef(true) + + useEffect(() => { + if (initialMount.current) { + const params = new URLSearchParams(searchParams) + + filters.forEach((filter) => { + const key = prefix ? `${prefix}_${filter.key}` : filter.key + const value = params.get(key) + if (value && !activeFilters.find((af) => af.key === filter.key)) { + console.log("adding filter", filter.key, "to active filters") + if (filter.type === "select") { + setActiveFilters((prev) => [ + ...prev, + { + ...filter, + multiple: filter.multiple, + options: filter.options, + openOnMount: false, + }, + ]) + } else { + setActiveFilters((prev) => [ + ...prev, + { ...filter, openOnMount: false }, + ]) + } + } + }) + } + + initialMount.current = false + }, [activeFilters, filters, prefix, searchParams]) + + const addFilter = (filter: Filter) => { + setOpen(false) + setActiveFilters((prev) => [...prev, { ...filter, openOnMount: true }]) + } + + const removeFilter = useCallback((key: string) => { + setActiveFilters((prev) => prev.filter((f) => f.key !== key)) + }, []) + + const removeAllFilters = useCallback(() => { + setActiveFilters([]) + }, []) + + return ( + ({ + removeFilter, + removeAllFilters, + }), + [removeAllFilters, removeFilter] + )} + > +
    + {activeFilters.map((filter) => { + if (filter.type === "select") { + return ( + + ) + } + + return ( + + ) + })} + {availableFilters.length > 0 && ( + + + + + + { + const hasOpenFilter = activeFilters.find( + (filter) => filter.openOnMount + ) + + if (hasOpenFilter) { + e.preventDefault() + } + }} + > + {availableFilters.map((filter) => { + return ( +
    { + addFilter(filter) + }} + > + {filter.label} +
    + ) + })} +
    +
    +
    + )} + {activeFilters.length > 0 && ( + + )} +
    +
    + ) +} + +type ClearAllFiltersProps = { + filters: Filter[] + prefix?: string +} + +const ClearAllFilters = ({ filters, prefix }: ClearAllFiltersProps) => { + const { removeAllFilters } = useDataTableFilterContext() + const [_, setSearchParams] = useSearchParams() + + const handleRemoveAll = () => { + setSearchParams((prev) => { + const newValues = new URLSearchParams(prev) + + filters.forEach((filter) => { + newValues.delete(prefix ? `${prefix}_${filter.key}` : filter.key) + }) + + return newValues + }) + + removeAllFilters() + } + + return ( + + ) +} + +const getInitialFilters = ({ + searchParams, + filters, + prefix, +}: { + searchParams: URLSearchParams + filters: Filter[] + prefix?: string +}) => { + const params = new URLSearchParams(searchParams) + const activeFilters: (Filter & { openOnMount: boolean })[] = [] + + filters.forEach((filter) => { + const key = prefix ? `${prefix}_${filter.key}` : filter.key + const value = params.get(key) + if (value) { + if (filter.type === "select") { + activeFilters.push({ + ...filter, + multiple: filter.multiple, + options: filter.options, + openOnMount: false, + }) + } else { + activeFilters.push({ ...filter, openOnMount: false }) + } + } + }) + + return activeFilters +} diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/date-filter.tsx b/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/date-filter.tsx new file mode 100644 index 0000000000..fd851b26b2 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/date-filter.tsx @@ -0,0 +1,322 @@ +import { EllipseMiniSolid, XMarkMini } from "@medusajs/icons" +import { DatePicker, Text, clx } from "@medusajs/ui" +import * as Popover from "@radix-ui/react-popover" +import { format } from "date-fns" +import isEqual from "lodash/isEqual" +import { MouseEvent, useState } from "react" + +import { useSelectedParams } from "../hooks" +import { useDataTableFilterContext } from "./context" +import { IFilter } from "./types" + +type DateFilterProps = IFilter + +type DateComparisonOperator = { + gte?: string + lte?: string + lt?: string + gt?: string +} + +export const DateFilter = ({ + filter, + prefix, + openOnMount, +}: DateFilterProps) => { + const [open, setOpen] = useState(openOnMount) + const [showCustom, setShowCustom] = useState(false) + const { key, label } = filter + const { removeFilter } = useDataTableFilterContext() + const selectedParams = useSelectedParams({ param: key, prefix }) + + const handleSelectPreset = (value: DateComparisonOperator) => { + selectedParams.add(JSON.stringify(value)) + setShowCustom(false) + } + + const handleSelectCustom = () => { + selectedParams.delete() + setShowCustom((prev) => !prev) + } + + const currentValue = selectedParams.get() + + const currentDateComparison = parseDateComparison(currentValue) + const customStartValue = getDateFromComparison(currentDateComparison, "gte") + const customEndValue = getDateFromComparison(currentDateComparison, "lte") + + const handleCustomDateChange = ( + value: Date | undefined, + pos: "start" | "end" + ) => { + const key = pos === "start" ? "gte" : "lte" + const dateValue = value ? value.toISOString() : undefined + + selectedParams.add( + JSON.stringify({ + ...(currentDateComparison || {}), + [key]: dateValue, + }) + ) + } + + const getDisplayValueFromPresets = () => { + const preset = presets.find((p) => isEqual(p.value, currentDateComparison)) + return preset?.label + } + + const formatCustomDate = (date: Date | undefined) => { + return date ? format(date, "dd MMM, yyyy") : undefined + } + + const getCustomDisplayValue = () => { + const formattedDates = [customStartValue, customEndValue].map( + formatCustomDate + ) + return formattedDates.filter(Boolean).join(" - ") + } + + const displayValue = getDisplayValueFromPresets() || getCustomDisplayValue() + + const handleRemove = () => { + selectedParams.delete() + removeFilter(key) + } + + let timeoutId: ReturnType | null = null + + const handleOpenChange = (open: boolean) => { + setOpen(open) + + if (timeoutId) { + clearTimeout(timeoutId) + } + + if (!open && !currentValue.length) { + timeoutId = setTimeout(() => { + removeFilter(key) + }, 200) + } + } + + return ( + + + + { + if (e.target instanceof HTMLElement) { + if ( + e.target.attributes.getNamedItem("data-name")?.value === + "filters_menu_content" + ) { + e.preventDefault() + } + } + }} + > +
      + {presets.map((preset) => { + const isSelected = selectedParams + .get() + .includes(JSON.stringify(preset.value)) + return ( +
    • + +
    • + ) + })} +
    • + +
    • +
    + {showCustom && ( +
    +
    +
    + + Starting + +
    +
    + handleCustomDateChange(d, "start")} + /> +
    +
    +
    +
    + + Ending + +
    +
    + { + handleCustomDateChange(d, "end") + }} + /> +
    +
    +
    + )} +
    +
    +
    + ) +} + +type DateDisplayProps = { + label: string + value?: string + onRemove: () => void +} + +const DateDisplay = ({ label, value, onRemove }: DateDisplayProps) => { + const handleRemove = (e: MouseEvent) => { + e.stopPropagation() + onRemove() + } + + return ( + +
    +
    + + {label} + +
    + {value && ( +
    +
    + + {value} + +
    +
    + )} + {value && ( +
    + +
    + )} +
    +
    + ) +} + +const today = new Date() +today.setHours(0, 0, 0, 0) + +const presets: { label: string; value: DateComparisonOperator }[] = [ + { + label: "Today", + value: { + gte: today.toISOString(), + }, + }, + { + label: "Last 7 days", + value: { + gte: new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days ago + }, + }, + { + label: "Last 30 days", + value: { + gte: new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days ago + }, + }, + { + label: "Last 90 days", + value: { + gte: new Date(today.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(), // 90 days ago + }, + }, + { + label: "Last 12 months", + value: { + gte: new Date(today.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString(), // 365 days ago + }, + }, +] + +const parseDateComparison = (value: string[]) => { + return value?.length + ? (JSON.parse(value.join(",")) as DateComparisonOperator) + : null +} + +const getDateFromComparison = ( + comparison: DateComparisonOperator | null, + key: "gte" | "lte" +) => { + return comparison?.[key] ? new Date(comparison[key] as string) : undefined +} diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/index.ts b/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/index.ts new file mode 100644 index 0000000000..8363bf9a5a --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/index.ts @@ -0,0 +1 @@ +export * from "./data-table-filter" diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/select-filter.tsx b/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/select-filter.tsx new file mode 100644 index 0000000000..cf4a1d784f --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/select-filter.tsx @@ -0,0 +1,261 @@ +import { CheckMini, EllipseMiniSolid, XMarkMini } from "@medusajs/icons" +import { Text, clx } from "@medusajs/ui" +import * as Popover from "@radix-ui/react-popover" +import { Command } from "cmdk" +import { MouseEvent, useState } from "react" +import { useTranslation } from "react-i18next" + +import { useSelectedParams } from "../hooks" +import { useDataTableFilterContext } from "./context" +import { IFilter } from "./types" + +interface SelectFilterProps extends IFilter { + options: { label: string; value: unknown }[] + multiple?: boolean + searchable?: boolean +} + +export const SelectFilter = ({ + filter, + prefix, + multiple, + searchable, + options, + openOnMount, +}: SelectFilterProps) => { + const [open, setOpen] = useState(openOnMount) + const [search, setSearch] = useState("") + const [searchRef, setSearchRef] = useState(null) + + const { t } = useTranslation() + const { removeFilter } = useDataTableFilterContext() + + const { key, label } = filter + const selectedParams = useSelectedParams({ param: key, prefix, multiple }) + const currentValue = selectedParams.get() + + const labelValues = currentValue + .map((v) => options.find((o) => o.value === v)?.label) + .filter(Boolean) as string[] + + const handleRemove = () => { + selectedParams.delete() + removeFilter(key) + } + + let timeoutId: ReturnType | null = null + + const handleOpenChange = (open: boolean) => { + setOpen(open) + + if (timeoutId) { + clearTimeout(timeoutId) + } + + if (!open && !currentValue.length) { + timeoutId = setTimeout(() => { + removeFilter(key) + }, 200) + } + } + + const handleClearSearch = () => { + setSearch("") + if (searchRef) { + searchRef.focus() + } + } + + const handleSelect = (value: unknown) => { + const isSelected = selectedParams.get().includes(String(value)) + + if (isSelected) { + selectedParams.delete(String(value)) + } else { + selectedParams.add(String(value)) + } + } + + return ( + + + + { + if (e.target instanceof HTMLElement) { + if ( + e.target.attributes.getNamedItem("data-name")?.value === + "filters_menu_content" + ) { + e.preventDefault() + e.stopPropagation() + } + } + }} + > + + {searchable && ( +
    +
    + +
    + +
    +
    +
    + )} + + + {t("general.noResultsTitle")} + + + + {options.map((option) => { + const isSelected = selectedParams + .get() + .includes(String(option.value)) + + return ( + { + handleSelect(option.value) + }} + > +
    + {multiple ? : } +
    + {option.label} +
    + ) + })} +
    +
    +
    +
    +
    + ) +} + +type SelectDisplayProps = { + label: string + value?: string | string[] + onRemove: () => void +} + +export const SelectDisplay = ({ + label, + value, + onRemove, +}: SelectDisplayProps) => { + const { t } = useTranslation() + const v = value ? (Array.isArray(value) ? value : [value]) : null + const count = v?.length || 0 + + const handleRemove = (e: MouseEvent) => { + e.stopPropagation() + onRemove() + } + + return ( + +
    +
    0, + } + )} + > + + {label} + +
    +
    + {count > 0 && ( +
    + + {t("general.is")} + +
    + )} + {count > 0 && ( +
    + + {v?.join(", ")} + +
    + )} +
    + {v && v.length > 0 && ( +
    + +
    + )} +
    +
    + ) +} diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/types.ts b/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/types.ts new file mode 100644 index 0000000000..d5dfb5e0a8 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-filter/types.ts @@ -0,0 +1,8 @@ +export interface IFilter { + filter: { + key: string + label: string + } + openOnMount?: boolean + prefix?: string +} diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-order-by/data-table-order-by.tsx b/packages/admin-next/dashboard/src/components/table/data-table/data-table-order-by/data-table-order-by.tsx new file mode 100644 index 0000000000..43cec0d537 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-order-by/data-table-order-by.tsx @@ -0,0 +1,157 @@ +import { ArrowUpDown } from "@medusajs/icons" +import { DropdownMenu, IconButton } from "@medusajs/ui" +import { useState } from "react" +import { useTranslation } from "react-i18next" +import { useSearchParams } from "react-router-dom" + +type DataTableOrderByProps = { + keys: (keyof TData)[] + prefix?: string +} + +enum SortDirection { + ASC = "asc", + DESC = "desc", +} + +type SortState = { + key?: string + dir: SortDirection +} + +const initState = (params: URLSearchParams, prefix?: string): SortState => { + const param = prefix ? `${prefix}_order` : "order" + const sortParam = params.get(param) + + if (!sortParam) { + return { + dir: SortDirection.ASC, + } + } + + const dir = sortParam.startsWith("-") ? SortDirection.DESC : SortDirection.ASC + const key = sortParam.replace("-", "") + + return { + key, + dir, + } +} + +const formatKey = (key: string) => { + const words = key.split("_") + const formattedWords = words.map((word, index) => { + if (index === 0) { + return word.charAt(0).toUpperCase() + word.slice(1) + } else { + return word + } + }) + return formattedWords.join(" ") +} + +export const DataTableOrderBy = ({ + keys, + prefix, +}: DataTableOrderByProps) => { + const [searchParams, setSearchParams] = useSearchParams() + const [state, setState] = useState<{ + key?: string + dir: SortDirection + }>(initState(searchParams, prefix)) + const param = prefix ? `${prefix}_order` : "order" + const { t } = useTranslation() + + const handleDirChange = (dir: string) => { + setState((prev) => ({ + ...prev, + dir: dir as SortDirection, + })) + updateOrderParam({ + key: state.key, + dir: dir as SortDirection, + }) + } + + const handleKeyChange = (value: string) => { + setState((prev) => ({ + ...prev, + key: value, + })) + + updateOrderParam({ + key: value, + dir: state.dir, + }) + } + + const updateOrderParam = (state: SortState) => { + if (!state.key) { + setSearchParams((prev) => { + prev.delete(param) + return prev + }) + + return + } + + const orderParam = + state.dir === SortDirection.ASC ? state.key : `-${state.key}` + setSearchParams((prev) => { + prev.set(param, orderParam) + return prev + }) + } + + return ( + + + + + + + + + {keys.map((key) => { + const stringKey = String(key) + + return ( + event.preventDefault()} + > + {formatKey(stringKey)} + + ) + })} + + + + event.preventDefault()} + > + {t("general.ascending")} + 1 - 30 + + event.preventDefault()} + > + {t("general.descending")} + 30 - 1 + + + + + ) +} diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-order-by/index.ts b/packages/admin-next/dashboard/src/components/table/data-table/data-table-order-by/index.ts new file mode 100644 index 0000000000..6761c82fb8 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-order-by/index.ts @@ -0,0 +1 @@ +export * from "./data-table-order-by" diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-query/data-table-query.tsx b/packages/admin-next/dashboard/src/components/table/data-table/data-table-query/data-table-query.tsx new file mode 100644 index 0000000000..60cfe1ba0e --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-query/data-table-query.tsx @@ -0,0 +1,32 @@ +import { Filter } from ".." +import { DataTableFilter } from "../data-table-filter" +import { DataTableOrderBy } from "../data-table-order-by" +import { DataTableSearch } from "../data-table-search" + +export interface DataTableQueryProps { + search?: boolean + orderBy?: (string | number)[] + filters?: Filter[] + prefix?: string +} + +export const DataTableQuery = ({ + search, + orderBy, + filters, + prefix, +}: DataTableQueryProps) => { + return ( +
    +
    + {filters && filters.length > 0 && ( + + )} +
    +
    + {search && } + {orderBy && } +
    +
    + ) +} diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-query/index.ts b/packages/admin-next/dashboard/src/components/table/data-table/data-table-query/index.ts new file mode 100644 index 0000000000..7449807df4 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-query/index.ts @@ -0,0 +1 @@ +export * from "./data-table-query" 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 new file mode 100644 index 0000000000..374cc0b2be --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx @@ -0,0 +1,272 @@ +import { CommandBar, Table, clx } from "@medusajs/ui" +import { + ColumnDef, + Table as ReactTable, + Row, + flexRender, +} from "@tanstack/react-table" +import { ComponentPropsWithoutRef, Fragment, UIEvent, useState } from "react" +import { useTranslation } from "react-i18next" +import { useNavigate } from "react-router-dom" +import { NoResults } from "../../../common/empty-table-content" + +type BulkCommand = { + label: string + shortcut: string + action: (selection: Record) => void +} + +export interface DataTableRootProps { + /** + * The table instance to render + */ + table: ReactTable + /** + * The columns to render + */ + columns: ColumnDef[] + /** + * Function to generate a link to navigate to when clicking on a row + */ + navigateTo?: (row: Row) => string + /** + * Bulk actions to render + */ + commands?: BulkCommand[] + /** + * The total number of items in the table + */ + count?: number + /** + * Whether to display pagination controls + */ + pagination?: boolean + /** + * Whether the table is empty due to no results from the active query + */ + noResults?: boolean +} + +/** + * TODO + * + * Add a sticky header to the table that shows the column name when scrolling through the table vertically. + * + * This is a bit tricky as we can't support horizontal scrolling and sticky headers at the same time, natively + * with CSS. We need to implement a custom solution for this. One solution is to render a duplicate table header + * using a DIV that, but it will require rerendeing the duplicate header every time the window is resized, to keep + * the columns aligned. + */ + +/** + * Table component for rendering a table with pagination, filtering and ordering. + */ +export const DataTableRoot = ({ + table, + columns, + pagination, + navigateTo, + commands, + count = 0, + noResults = false, +}: DataTableRootProps) => { + const { t } = useTranslation() + const navigate = useNavigate() + const [showStickyBorder, setShowStickyBorder] = useState(false) + + const hasSelect = columns.find((c) => c.id === "select") + const hasActions = columns.find((c) => c.id === "actions") + const hasCommandBar = commands && commands.length > 0 + + const rowSelection = table.getState().rowSelection + const { pageIndex, pageSize } = table.getState().pagination + + const colCount = columns.length - (hasSelect ? 1 : 0) - (hasActions ? 1 : 0) + const colWidth = 100 / colCount + + const handleHorizontalScroll = (e: UIEvent) => { + const scrollLeft = e.currentTarget.scrollLeft + + if (scrollLeft > 0) { + setShowStickyBorder(true) + } else { + setShowStickyBorder(false) + } + } + + return ( +
    +
    + {!noResults ? ( +
+ + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header, index) => { + const isActionHeader = header.id === "actions" + const isSelectHeader = header.id === "select" + const isSpecialHeader = isActionHeader || isSelectHeader + + const firstHeader = headerGroup.headers.findIndex( + (h) => h.id !== "select" + ) + const isFirstHeader = + firstHeader !== -1 + ? header.id === headerGroup.headers[firstHeader].id + : index === 0 + + const isStickyHeader = isSelectHeader || isFirstHeader + + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ) + })} + + + {table.getRowModel().rows.map((row) => { + const to = navigateTo ? navigateTo(row) : undefined + return ( + navigate(to) : undefined} + > + {row.getVisibleCells().map((cell, index) => { + const visibleCells = row.getVisibleCells() + const isSelectCell = cell.id === "select" + + const firstCell = visibleCells.findIndex( + (h) => h.id !== "select" + ) + const isFirstCell = + firstCell !== -1 + ? cell.id === visibleCells[firstCell].id + : index === 0 + + const isStickyCell = isSelectCell || isFirstCell + + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ) + })} + + ) + })} + +
+ ) : ( +
+ +
+ )} + + {pagination && ( + + )} + {hasCommandBar && ( + + + + {t("general.countSelected", { + count: Object.keys(rowSelection).length, + })} + + + {commands?.map((command, index) => { + return ( + + command.action(rowSelection)} + /> + {index < commands.length - 1 && } + + ) + })} + + + )} + + ) +} + +type PaginationProps = Omit< + ComponentPropsWithoutRef, + "translations" +> + +const Pagination = (props: PaginationProps) => { + const { t } = useTranslation() + + const translations = { + of: t("general.of"), + results: t("general.results"), + pages: t("general.pages"), + prev: t("general.prev"), + next: t("general.next"), + } + + return +} diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/index.ts b/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/index.ts new file mode 100644 index 0000000000..8d47458cf9 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/index.ts @@ -0,0 +1 @@ +export * from "./data-table-root" diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-search/data-table-search.tsx b/packages/admin-next/dashboard/src/components/table/data-table/data-table-search/data-table-search.tsx new file mode 100644 index 0000000000..f1bdb01027 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-search/data-table-search.tsx @@ -0,0 +1,57 @@ +import { Input } from "@medusajs/ui" +import { ChangeEvent, useCallback, useEffect } from "react" +import { useTranslation } from "react-i18next" + +import { debounce } from "lodash" +import { useSelectedParams } from "../hooks" + +type DataTableSearchProps = { + placeholder?: string + prefix?: string +} + +export const DataTableSearch = ({ + placeholder, + prefix, +}: DataTableSearchProps) => { + const { t } = useTranslation() + const placeholderText = placeholder || t("general.search") + const selectedParams = useSelectedParams({ + param: "q", + prefix, + multiple: false, + }) + + const query = selectedParams.get() + + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedOnChange = useCallback( + debounce((e: ChangeEvent) => { + const value = e.target.value + + if (!value) { + selectedParams.delete() + } else { + selectedParams.add(value) + } + }, 500), + [selectedParams] + ) + + useEffect(() => { + return () => { + debouncedOnChange.cancel() + } + }, [debouncedOnChange]) + + return ( + + ) +} diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-search/index.ts b/packages/admin-next/dashboard/src/components/table/data-table/data-table-search/index.ts new file mode 100644 index 0000000000..1f19f481be --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-search/index.ts @@ -0,0 +1 @@ +export * from "./data-table-search" diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-skeleton/data-table-skeleton.tsx b/packages/admin-next/dashboard/src/components/table/data-table/data-table-skeleton/data-table-skeleton.tsx new file mode 100644 index 0000000000..56174606a5 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-skeleton/data-table-skeleton.tsx @@ -0,0 +1,115 @@ +import { Table, clx } from "@medusajs/ui" +import { ColumnDef } from "@tanstack/react-table" +import { Skeleton } from "../../../common/skeleton" + +type DataTableSkeletonProps = { + columns: ColumnDef[] + rowCount: number + searchable: boolean + orderBy: boolean + filterable: boolean + pagination: boolean +} + +export const DataTableSkeleton = ({ + columns, + rowCount, + filterable, + searchable, + orderBy, + pagination, +}: DataTableSkeletonProps) => { + const rows = Array.from({ length: rowCount }, (_, i) => i) + + const hasToolbar = filterable || searchable || orderBy + const hasSearchOrOrder = searchable || orderBy + + const hasSelect = columns.find((c) => c.id === "select") + const hasActions = columns.find((c) => c.id === "actions") + const colCount = columns.length - (hasSelect ? 1 : 0) - (hasActions ? 1 : 0) + const colWidth = 100 / colCount + + return ( +
+ {hasToolbar && ( +
+ {filterable && } + {hasSearchOrOrder && ( +
+ {searchable && } + {orderBy && } +
+ )} +
+ )} + + + + {columns.map((col, i) => { + const isSelectHeader = col.id === "select" + const isActionsHeader = col.id === "actions" + + const isSpecialHeader = isSelectHeader || isActionsHeader + + return ( + + {isActionsHeader ? null : ( + + )} + + ) + })} + + + + {rows.map((_, j) => ( + + {columns.map((col, k) => { + const isSpecialCell = + col.id === "select" || col.id === "actions" + + return ( + + + + ) + })} + + ))} + +
+ {pagination && ( +
+ +
+ + + +
+
+ )} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-skeleton/index.ts b/packages/admin-next/dashboard/src/components/table/data-table/data-table-skeleton/index.ts new file mode 100644 index 0000000000..fbed89a8c7 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-skeleton/index.ts @@ -0,0 +1 @@ +export * from "./data-table-skeleton" 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 new file mode 100644 index 0000000000..09446bb367 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table.tsx @@ -0,0 +1,74 @@ +import { memo } from "react" +import { NoRecords } from "../../common/empty-table-content" +import { DataTableQuery, DataTableQueryProps } from "./data-table-query" +import { DataTableRoot, DataTableRootProps } from "./data-table-root" +import { DataTableSkeleton } from "./data-table-skeleton" + +interface DataTableProps + extends DataTableRootProps, + DataTableQueryProps { + isLoading?: boolean + rowCount: number + queryObject?: Record +} + +const MemoizedDataTableRoot = memo(DataTableRoot) as typeof DataTableRoot +const MemoizedDataTableQuery = memo(DataTableQuery) + +export const DataTable = ({ + table, + columns, + pagination, + navigateTo, + commands, + count = 0, + search = false, + orderBy, + filters, + prefix, + queryObject = {}, + rowCount, + isLoading = false, +}: DataTableProps) => { + if (isLoading) { + return ( + + ) + } + + const noQuery = + Object.values(queryObject).filter((v) => Boolean(v)).length === 0 + const noResults = !isLoading && count === 0 && !noQuery + const noRecords = !isLoading && count === 0 && noQuery + + if (noRecords) { + return + } + + return ( +
+ + +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/table/data-table/hooks.tsx b/packages/admin-next/dashboard/src/components/table/data-table/hooks.tsx new file mode 100644 index 0000000000..aeda072454 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/hooks.tsx @@ -0,0 +1,73 @@ +import { useSearchParams } from "react-router-dom" + +export const useSelectedParams = ({ + param, + prefix, + multiple = false, +}: { + param: string + prefix?: string + multiple?: boolean +}) => { + const [searchParams, setSearchParams] = useSearchParams() + const identifier = prefix ? `${prefix}_${param}` : param + const offsetKey = prefix ? `${prefix}_offset` : "offset" + + const add = (value: string) => { + setSearchParams((prev) => { + const newValue = new URLSearchParams(prev) + + const updateMultipleValues = () => { + const existingValues = newValue.get(identifier)?.split(",") || [] + + if (!existingValues.includes(value)) { + existingValues.push(value) + newValue.set(identifier, existingValues.join(",")) + } + } + + const updateSingleValue = () => { + newValue.set(identifier, value) + } + + multiple ? updateMultipleValues() : updateSingleValue() + newValue.delete(offsetKey) + + return newValue + }) + } + + const deleteParam = (value?: string) => { + const deleteMultipleValues = (prev: URLSearchParams) => { + const existingValues = prev.get(identifier)?.split(",") || [] + const index = existingValues.indexOf(value || "") + if (index > -1) { + existingValues.splice(index, 1) + prev.set(identifier, existingValues.join(",")) + } + } + + const deleteSingleValue = (prev: URLSearchParams) => { + prev.delete(identifier) + } + + setSearchParams((prev) => { + if (value) { + multiple ? deleteMultipleValues(prev) : deleteSingleValue(prev) + if (!prev.get(identifier)) { + prev.delete(identifier) + } + } else { + prev.delete(identifier) + } + prev.delete(offsetKey) + return prev + }) + } + + const get = () => { + return searchParams.get(identifier)?.split(",").filter(Boolean) || [] + } + + return { add, delete: deleteParam, get } +} diff --git a/packages/admin-next/dashboard/src/components/table/data-table/index.ts b/packages/admin-next/dashboard/src/components/table/data-table/index.ts new file mode 100644 index 0000000000..78f00d949d --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/data-table/index.ts @@ -0,0 +1,2 @@ +export * from "./data-table" +export type { Filter } from "./data-table-filter" diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/common/date-cell/date-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/common/date-cell/date-cell.tsx new file mode 100644 index 0000000000..44dda62fb4 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/common/date-cell/date-cell.tsx @@ -0,0 +1,41 @@ +import { Tooltip } from "@medusajs/ui" +import format from "date-fns/format" +import { useTranslation } from "react-i18next" + +type DateCellProps = { + date: Date +} + +export const DateCell = ({ date }: DateCellProps) => { + const value = new Date(date) + value.setMinutes(value.getMinutes() - value.getTimezoneOffset()) + + const hour12 = Intl.DateTimeFormat().resolvedOptions().hour12 + const timestampFormat = hour12 ? "dd MMM yyyy hh:MM a" : "dd MMM yyyy HH:MM" + + return ( +
+ {`${format( + value, + timestampFormat + )}`} + } + > + {format(value, "dd MMM yyyy")} + +
+ ) +} + +export const DateHeader = () => { + const { t } = useTranslation() + + return ( +
+ {t("fields.date")} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/common/date-cell/index.ts b/packages/admin-next/dashboard/src/components/table/table-cells/common/date-cell/index.ts new file mode 100644 index 0000000000..9bf7e52ed0 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/common/date-cell/index.ts @@ -0,0 +1 @@ +export * from "./date-cell" diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/common/status-cell/index.ts b/packages/admin-next/dashboard/src/components/table/table-cells/common/status-cell/index.ts new file mode 100644 index 0000000000..8cc5d6026b --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/common/status-cell/index.ts @@ -0,0 +1 @@ +export * from "./status-cell" diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/common/status-cell/status-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/common/status-cell/status-cell.tsx new file mode 100644 index 0000000000..168a94117e --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/common/status-cell/status-cell.tsx @@ -0,0 +1,32 @@ +import { clx } from "@medusajs/ui" +import { PropsWithChildren } from "react" + +type StatusCellProps = PropsWithChildren<{ + color?: "green" | "red" | "blue" | "orange" | "grey" | "purple" +}> + +export const StatusCell = ({ color, children }: StatusCellProps) => { + return ( +
+
+
+
+ {children} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/customer-cell/customer-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/order/customer-cell/customer-cell.tsx new file mode 100644 index 0000000000..af558b959c --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/customer-cell/customer-cell.tsx @@ -0,0 +1,29 @@ +import { Customer } from "@medusajs/medusa" +import { useTranslation } from "react-i18next" + +export const CustomerCell = ({ customer }: { customer: Customer | null }) => { + if (!customer) { + return - + } + + const { first_name, last_name, email } = customer + const name = [first_name, last_name].filter(Boolean).join(" ") + + return ( +
+
+ {name || email} +
+
+ ) +} + +export const CustomerHeader = () => { + const { t } = useTranslation() + + return ( +
+ {t("fields.customer")} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/customer-cell/index.ts b/packages/admin-next/dashboard/src/components/table/table-cells/order/customer-cell/index.ts new file mode 100644 index 0000000000..dbdd97615a --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/customer-cell/index.ts @@ -0,0 +1 @@ +export * from "./customer-cell" diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/display-id-cell/display-id-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/order/display-id-cell/display-id-cell.tsx new file mode 100644 index 0000000000..e4f2e48aeb --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/display-id-cell/display-id-cell.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from "react-i18next" + +export const DisplayIdCell = ({ displayId }: { displayId: number }) => { + return ( +
+ #{displayId} +
+ ) +} + +export const DisplayIdHeader = () => { + const { t } = useTranslation() + + return ( +
+ {t("fields.order")} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/display-id-cell/index.ts b/packages/admin-next/dashboard/src/components/table/table-cells/order/display-id-cell/index.ts new file mode 100644 index 0000000000..057f15de5b --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/display-id-cell/index.ts @@ -0,0 +1 @@ +export * from "./display-id-cell" diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/fulfillment-status-cell/fulfillment-status-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/order/fulfillment-status-cell/fulfillment-status-cell.tsx new file mode 100644 index 0000000000..d899e85468 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/fulfillment-status-cell/fulfillment-status-cell.tsx @@ -0,0 +1,46 @@ +import type { FulfillmentStatus } from "@medusajs/medusa" +import { useTranslation } from "react-i18next" +import { StatusCell } from "../../common/status-cell" + +type FulfillmentStatusCellProps = { + status: FulfillmentStatus +} + +export const FulfillmentStatusCell = ({ + status, +}: FulfillmentStatusCellProps) => { + const { t } = useTranslation() + + const [label, color] = { + not_fulfilled: [t("orders.fulfillmentStatus.notFulfilled"), "red"], + partially_fulfilled: [ + t("orders.fulfillmentStatus.partiallyFulfilled"), + "orange", + ], + fulfilled: [t("orders.fulfillmentStatus.fulfilled"), "green"], + partially_shipped: [ + t("orders.fulfillmentStatus.partiallyShipped"), + "orange", + ], + shipped: [t("orders.fulfillmentStatus.shipped"), "green"], + partially_returned: [ + t("orders.fulfillmentStatus.partiallyReturned"), + "orange", + ], + returned: [t("orders.fulfillmentStatus.returned"), "green"], + canceled: [t("orders.fulfillmentStatus.canceled"), "red"], + requires_action: [t("orders.fulfillmentStatus.requresAction"), "orange"], + }[status] as [string, "red" | "orange" | "green"] + + return {label} +} + +export const FulfillmentStatusHeader = () => { + const { t } = useTranslation() + + return ( +
+ {t("fields.fulfillment")} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/fulfillment-status-cell/index.ts b/packages/admin-next/dashboard/src/components/table/table-cells/order/fulfillment-status-cell/index.ts new file mode 100644 index 0000000000..a0f92c11b9 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/fulfillment-status-cell/index.ts @@ -0,0 +1 @@ +export * from "./fulfillment-status-cell" diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/items-cell/index.ts b/packages/admin-next/dashboard/src/components/table/table-cells/order/items-cell/index.ts new file mode 100644 index 0000000000..8df6791eae --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/items-cell/index.ts @@ -0,0 +1 @@ +export * from "./items-cell" diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/items-cell/items-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/order/items-cell/items-cell.tsx new file mode 100644 index 0000000000..fc0c3b3403 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/items-cell/items-cell.tsx @@ -0,0 +1,26 @@ +import type { LineItem } from "@medusajs/medusa" +import { useTranslation } from "react-i18next" + +export const ItemsCell = ({ items }: { items: LineItem[] }) => { + const { t } = useTranslation() + + return ( +
+ + {t("general.items", { + count: items.length, + })} + +
+ ) +} + +export const ItemsHeader = () => { + const { t } = useTranslation() + + return ( +
+ {t("fields.items")} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/payment-status-cell/index.ts b/packages/admin-next/dashboard/src/components/table/table-cells/order/payment-status-cell/index.ts new file mode 100644 index 0000000000..f6c4b069ee --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/payment-status-cell/index.ts @@ -0,0 +1 @@ +export * from "./payment-status-cell" diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/payment-status-cell/payment-status-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/order/payment-status-cell/payment-status-cell.tsx new file mode 100644 index 0000000000..db8828fa26 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/payment-status-cell/payment-status-cell.tsx @@ -0,0 +1,33 @@ +import type { PaymentStatus } from "@medusajs/medusa" +import { useTranslation } from "react-i18next" +import { StatusCell } from "../../common/status-cell" + +type PaymentStatusCellProps = { + status: PaymentStatus +} + +export const PaymentStatusCell = ({ status }: PaymentStatusCellProps) => { + const { t } = useTranslation() + + const [label, color] = { + not_paid: [t("orders.paymentStatus.notPaid"), "red"], + awaiting: [t("orders.paymentStatus.awaiting"), "orange"], + captured: [t("orders.paymentStatus.captured"), "green"], + refunded: [t("orders.paymentStatus.refunded"), "green"], + partially_refunded: [t("orders.paymentStatus.partiallyRefunded"), "orange"], + canceled: [t("orders.paymentStatus.canceled"), "red"], + requires_action: [t("orders.paymentStatus.requresAction"), "orange"], + }[status] as [string, "red" | "orange" | "green"] + + return {label} +} + +export const PaymentStatusHeader = () => { + const { t } = useTranslation() + + return ( +
+ {t("fields.payment")} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/sales-channel-cell/index.ts b/packages/admin-next/dashboard/src/components/table/table-cells/order/sales-channel-cell/index.ts new file mode 100644 index 0000000000..8040cf7b1b --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/sales-channel-cell/index.ts @@ -0,0 +1 @@ +export * from "./sales-channel-cell" diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/sales-channel-cell/sales-channel-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/order/sales-channel-cell/sales-channel-cell.tsx new file mode 100644 index 0000000000..17e249d041 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/sales-channel-cell/sales-channel-cell.tsx @@ -0,0 +1,30 @@ +import { SalesChannel } from "@medusajs/medusa" +import { useTranslation } from "react-i18next" + +export const SalesChannelCell = ({ + channel, +}: { + channel: SalesChannel | null +}) => { + if (!channel) { + return - + } + + const { name } = channel + + return ( +
+ {name} +
+ ) +} + +export const SalesChannelHeader = () => { + const { t } = useTranslation() + + return ( +
+ {t("fields.salesChannel")} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/total-cell/index.ts b/packages/admin-next/dashboard/src/components/table/table-cells/order/total-cell/index.ts new file mode 100644 index 0000000000..a58e61c257 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/total-cell/index.ts @@ -0,0 +1 @@ +export * from "./total-cell" diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/total-cell/total-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/order/total-cell/total-cell.tsx new file mode 100644 index 0000000000..58646ee933 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/total-cell/total-cell.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from "react-i18next" +import { getPresentationalAmount } from "../../../../../lib/money-amount-helpers" + +type TotalCellProps = { + currencyCode: string + total: number | null +} + +export const TotalCell = ({ currencyCode, total }: TotalCellProps) => { + if (!total) { + return - + } + + const formatted = new Intl.NumberFormat(undefined, { + style: "currency", + currency: currencyCode, + currencyDisplay: "narrowSymbol", + }).format(0) + + const symbol = formatted.replace(/\d/g, "").replace(/[.,]/g, "").trim() + + const presentationAmount = getPresentationalAmount(total, currencyCode) + const formattedTotal = new Intl.NumberFormat(undefined, { + style: "decimal", + }).format(presentationAmount) + + return ( +
+ + {symbol} {formattedTotal} {currencyCode.toUpperCase()} + +
+ ) +} + +export const TotalHeader = () => { + const { t } = useTranslation() + + return ( +
+ {t("fields.total")} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/hooks/table/columns/use-order-table-columns.tsx b/packages/admin-next/dashboard/src/hooks/table/columns/use-order-table-columns.tsx new file mode 100644 index 0000000000..490db287b1 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/table/columns/use-order-table-columns.tsx @@ -0,0 +1,133 @@ +import { Order } from "@medusajs/medusa" +import { + ColumnDef, + ColumnDefBase, + createColumnHelper, +} from "@tanstack/react-table" +import { useMemo } from "react" +import { + DateCell, + DateHeader, +} from "../../../components/table/table-cells/common/date-cell" +import { + DisplayIdCell, + DisplayIdHeader, +} from "../../../components/table/table-cells/order/display-id-cell" +import { + FulfillmentStatusCell, + FulfillmentStatusHeader, +} from "../../../components/table/table-cells/order/fulfillment-status-cell" +import { + ItemsCell, + ItemsHeader, +} from "../../../components/table/table-cells/order/items-cell" +import { + PaymentStatusCell, + PaymentStatusHeader, +} from "../../../components/table/table-cells/order/payment-status-cell" +import { + SalesChannelCell, + SalesChannelHeader, +} from "../../../components/table/table-cells/order/sales-channel-cell" +import { + TotalCell, + TotalHeader, +} from "../../../components/table/table-cells/order/total-cell" + +// We have to use any here, as the type of Order is so complex that it lags the TS server +const columnHelper = createColumnHelper() + +type UseOrderTableColumnsProps = { + exclude?: string[] +} + +export const useOrderTableColumns = (props: UseOrderTableColumnsProps) => { + const { exclude = [] } = props ?? {} + + const columns = useMemo( + () => [ + columnHelper.accessor("display_id", { + header: () => , + cell: ({ getValue }) => { + const id = getValue() + + return + }, + }), + columnHelper.accessor("created_at", { + header: () => , + cell: ({ getValue }) => { + const date = new Date(getValue()) + + return + }, + }), + columnHelper.accessor("sales_channel", { + header: () => , + cell: ({ getValue }) => { + const channel = getValue() + + return + }, + }), + columnHelper.accessor("payment_status", { + header: () => , + cell: ({ getValue }) => { + const status = getValue() + + return + }, + }), + columnHelper.accessor("fulfillment_status", { + header: () => , + cell: ({ getValue }) => { + const status = getValue() + + return + }, + }), + columnHelper.accessor("items", { + header: () => , + cell: ({ getValue }) => { + const items = getValue() + + return + }, + }), + columnHelper.accessor("total", { + header: () => , + cell: ({ getValue, row }) => { + const total = getValue() + const currencyCode = row.original.currency_code + + return + }, + }), + ], + [] + ) + + const isAccessorColumnDef = ( + c: any + ): c is ColumnDef & { accessorKey: string } => { + return c.accessorKey !== undefined + } + + const isDisplayColumnDef = ( + c: any + ): c is ColumnDef & { id: string } => { + return c.id !== undefined + } + + const shouldExclude = >(c: TDef) => { + if (isAccessorColumnDef(c)) { + return exclude.includes(c.accessorKey) + } else if (isDisplayColumnDef(c)) { + return exclude.includes(c.id) + } + + return false + } + + return columns.filter((c) => !shouldExclude(c)) as ColumnDef[] +} diff --git a/packages/admin-next/dashboard/src/hooks/table/filters/use-order-table-filters.tsx b/packages/admin-next/dashboard/src/hooks/table/filters/use-order-table-filters.tsx new file mode 100644 index 0000000000..aa5b309f05 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/table/filters/use-order-table-filters.tsx @@ -0,0 +1,154 @@ +import { useAdminRegions, useAdminSalesChannels } from "medusa-react" +import { useTranslation } from "react-i18next" + +import type { Filter } from "../../../components/table/data-table" + +export const useOrderTableFilters = (): Filter[] => { + const { t } = useTranslation() + + const { regions } = useAdminRegions({ + limit: 1000, + fields: "id,name", + expand: "", + }) + + const { sales_channels } = useAdminSalesChannels({ + limit: 1000, + fields: "id,name", + expand: "", + }) + + let filters: Filter[] = [] + + if (regions) { + const regionFilter: Filter = { + key: "region_id", + label: t("fields.region"), + type: "select", + options: regions.map((r) => ({ + label: r.name, + value: r.id, + })), + multiple: true, + searchable: true, + } + + filters = [...filters, regionFilter] + } + + if (sales_channels) { + const salesChannelFilter: Filter = { + key: "sales_channel_id", + label: t("fields.salesChannel"), + type: "select", + multiple: true, + searchable: true, + options: sales_channels.map((s) => ({ + label: s.name, + value: s.id, + })), + } + + filters = [...filters, salesChannelFilter] + } + + const paymentStatusFilter: Filter = { + key: "payment_status", + label: t("orders.paymentStatusLabel"), + type: "select", + multiple: true, + options: [ + { + label: t("orders.paymentStatus.notPaid"), + value: "not_paid", + }, + { + label: t("orders.paymentStatus.awaiting"), + value: "awaiting", + }, + { + label: t("orders.paymentStatus.captured"), + value: "captured", + }, + { + label: t("orders.paymentStatus.refunded"), + value: "refunded", + }, + { + label: t("orders.paymentStatus.partiallyRefunded"), + value: "partially_refunded", + }, + { + label: t("orders.paymentStatus.canceled"), + value: "canceled", + }, + { + label: t("orders.paymentStatus.requresAction"), + value: "requires_action", + }, + ], + } + + const fulfillmentStatusFilter: Filter = { + key: "fulfillment_status", + label: t("orders.fulfillmentStatusLabel"), + type: "select", + multiple: true, + options: [ + { + label: t("orders.fulfillmentStatus.notFulfilled"), + value: "not_fulfilled", + }, + { + label: t("orders.fulfillmentStatus.fulfilled"), + value: "fulfilled", + }, + { + label: t("orders.fulfillmentStatus.partiallyFulfilled"), + value: "partially_fulfilled", + }, + { + label: t("orders.fulfillmentStatus.returned"), + value: "returned", + }, + { + label: t("orders.fulfillmentStatus.partiallyReturned"), + value: "partially_returned", + }, + { + label: t("orders.fulfillmentStatus.shipped"), + value: "shipped", + }, + { + label: t("orders.fulfillmentStatus.partiallyShipped"), + value: "partially_shipped", + }, + { + label: t("orders.fulfillmentStatus.canceled"), + value: "canceled", + }, + { + label: t("orders.fulfillmentStatus.requresAction"), + value: "requires_action", + }, + ], + } + + const dateFilters: Filter[] = [ + { label: "Created At", key: "created_at" }, + { label: "Updated At", key: "updated_at" }, + ].map((f) => ({ + key: f.key, + label: f.label, + type: "date", + })) + + filters = [ + ...filters, + paymentStatusFilter, + fulfillmentStatusFilter, + ...dateFilters, + ] + + return filters +} diff --git a/packages/admin-next/dashboard/src/hooks/table/query/use-order-table-query.tsx b/packages/admin-next/dashboard/src/hooks/table/query/use-order-table-query.tsx new file mode 100644 index 0000000000..bcf52dad14 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/table/query/use-order-table-query.tsx @@ -0,0 +1,58 @@ +import { AdminGetOrdersParams } from "@medusajs/medusa" +import { useQueryParams } from "../../use-query-params" + +type UseOrderTableQueryProps = { + prefix?: string + pageSize?: number +} + +/** + * TODO: Enable `order` query param when staging is updated + */ + +export const useOrderTableQuery = ({ + prefix, + pageSize = 50, +}: UseOrderTableQueryProps) => { + const queryObject = useQueryParams( + [ + "offset", + "q", + "created_at", + "updated_at", + "region_id", + "sales_channel_id", + "payment_status", + "fulfillment_status", + ], + prefix + ) + + const { + offset, + sales_channel_id, + created_at, + updated_at, + fulfillment_status, + payment_status, + region_id, + q, + } = queryObject + + const searchParams: AdminGetOrdersParams = { + limit: pageSize, + offset: offset ? Number(offset) : 0, + sales_channel_id: sales_channel_id?.split(","), + fulfillment_status: fulfillment_status?.split(","), + payment_status: payment_status?.split(","), + created_at: created_at ? JSON.parse(created_at) : undefined, + updated_at: updated_at ? JSON.parse(updated_at) : undefined, + region_id: region_id?.split(","), + q, + } + + return { + searchParams, + raw: queryObject, + } +} diff --git a/packages/admin-next/dashboard/src/hooks/use-data-table.tsx b/packages/admin-next/dashboard/src/hooks/use-data-table.tsx new file mode 100644 index 0000000000..5be85018f4 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/use-data-table.tsx @@ -0,0 +1,112 @@ +import { + ColumnDef, + OnChangeFn, + PaginationState, + Row, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table" +import { useEffect, useMemo, useState } from "react" +import { useSearchParams } from "react-router-dom" + +type UseDataTableProps = { + data?: TData[] + columns: ColumnDef[] + count?: number + pageSize?: number + enableRowSelection?: boolean | ((row: Row) => boolean) + enablePagination?: boolean + getRowId?: (original: TData, index: number) => string + prefix?: string +} + +export const useDataTable = ({ + data = [], + columns, + count = 0, + pageSize: _pageSize = 50, + enablePagination = true, + enableRowSelection = false, + getRowId, + prefix, +}: UseDataTableProps) => { + const [searchParams, setSearchParams] = useSearchParams() + const offsetKey = `${prefix ? `${prefix}_` : ""}offset` + const offset = searchParams.get(offsetKey) + + const [{ pageIndex, pageSize }, setPagination] = useState({ + pageIndex: offset ? Math.ceil(Number(offset) / _pageSize) : 0, + pageSize: _pageSize, + }) + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + }), + [pageIndex, pageSize] + ) + const [rowSelection, setRowSelection] = useState({}) + + useEffect(() => { + if (!enablePagination) { + return + } + + const index = offset ? Math.ceil(Number(offset) / _pageSize) : 0 + + if (index === pageIndex) { + return + } + + setPagination((prev) => ({ + ...prev, + pageIndex: index, + })) + }, [offset, enablePagination, _pageSize, pageIndex]) + + const onPaginationChange = ( + updater: (old: PaginationState) => PaginationState + ) => { + const state = updater(pagination) + const { pageIndex, pageSize } = state + + setSearchParams((prev) => { + if (!pageIndex) { + prev.delete(offsetKey) + return prev + } + + const newSearch = new URLSearchParams(prev) + newSearch.set(offsetKey, String(pageIndex * pageSize)) + + return newSearch + }) + + setPagination(state) + return state + } + + const table = useReactTable({ + data, + columns, + state: { + rowSelection, + pagination: enablePagination ? pagination : undefined, + }, + pageCount: Math.ceil((count ?? 0) / pageSize), + enableRowSelection, + getRowId, + onRowSelectionChange: enableRowSelection ? setRowSelection : undefined, + onPaginationChange: enablePagination + ? (onPaginationChange as OnChangeFn) + : undefined, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: enablePagination + ? getPaginationRowModel() + : undefined, + manualPagination: enablePagination ? true : undefined, + }) + + return { table } +} diff --git a/packages/admin-next/dashboard/src/hooks/use-query-params.tsx b/packages/admin-next/dashboard/src/hooks/use-query-params.tsx index afb282362e..623975e5fb 100644 --- a/packages/admin-next/dashboard/src/hooks/use-query-params.tsx +++ b/packages/admin-next/dashboard/src/hooks/use-query-params.tsx @@ -1,15 +1,23 @@ import { useSearchParams } from "react-router-dom" +type QueryParams = { + [key in T]: string | undefined +} + export function useQueryParams( - keys: T[] -): Record { + keys: T[], + prefix?: string +): QueryParams { const [params] = useSearchParams() // Use a type assertion to initialize the result - const result = {} as Record + const result = {} as QueryParams keys.forEach((key) => { - result[key] = params.get(key) || undefined + const prefixedKey = prefix ? `${prefix}_${key}` : key + const value = params.get(prefixedKey) || undefined + + result[key] = value }) return result 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 dbdb2d99dd..3cb8603b76 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 @@ -74,7 +74,7 @@ const router = createBrowserRouter([ children: [ { index: true, - lazy: () => import("../../routes/orders/list"), + lazy: () => import("../../routes/orders/order-list"), }, { path: ":id", diff --git a/packages/admin-next/dashboard/src/routes/customers/customer-detail/components/customer-order-section/customer-order-section.tsx b/packages/admin-next/dashboard/src/routes/customers/customer-detail/components/customer-order-section/customer-order-section.tsx index 026ad1f84d..a2f307c568 100644 --- a/packages/admin-next/dashboard/src/routes/customers/customer-detail/components/customer-order-section/customer-order-section.tsx +++ b/packages/admin-next/dashboard/src/routes/customers/customer-detail/components/customer-order-section/customer-order-section.tsx @@ -1,99 +1,62 @@ -import { ReceiptPercent } from "@medusajs/icons" -import { Customer, Order } from "@medusajs/medusa" -import { Button, Container, Heading, Table, clx } from "@medusajs/ui" -import { - PaginationState, - RowSelectionState, - createColumnHelper, - flexRender, - getCoreRowModel, - useReactTable, -} from "@tanstack/react-table" +import { Customer } from "@medusajs/medusa" +import { Button, Container, Heading } from "@medusajs/ui" import { useAdminOrders } from "medusa-react" -import { useMemo, useState } from "react" import { useTranslation } from "react-i18next" -import { useNavigate } from "react-router-dom" -import { ActionMenu } from "../../../../../components/common/action-menu" -import { NoRecords } from "../../../../../components/common/empty-table-content" -import { - OrderDateCell, - OrderDisplayIdCell, - OrderFulfillmentStatusCell, - OrderPaymentStatusCell, - OrderTotalCell, -} from "../../../../../components/common/order-table-cells" -import { Query } from "../../../../../components/filtering/query" -import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination" -import { useQueryParams } from "../../../../../hooks/use-query-params" +import { DataTable } from "../../../../../components/table/data-table" +import { useOrderTableColumns } from "../../../../../hooks/table/columns/use-order-table-columns" +import { useOrderTableFilters } from "../../../../../hooks/table/filters/use-order-table-filters" +import { useOrderTableQuery } from "../../../../../hooks/table/query/use-order-table-query" +import { useDataTable } from "../../../../../hooks/use-data-table" type CustomerGeneralSectionProps = { customer: Customer } const PAGE_SIZE = 10 +const DEFAULT_RELATIONS = "customer,items,sales_channel" +const DEFAULT_FIELDS = + "id,status,display_id,created_at,email,fulfillment_status,payment_status,total,currency_code" export const CustomerOrderSection = ({ customer, }: CustomerGeneralSectionProps) => { const { t } = useTranslation() - const navigate = useNavigate() - const [{ pageIndex, pageSize }, setPagination] = useState({ - pageIndex: 0, + const { searchParams, raw } = useOrderTableQuery({ pageSize: PAGE_SIZE, }) - - const pagination = useMemo( - () => ({ - pageIndex, - pageSize, - }), - [pageIndex, pageSize] - ) - - const [rowSelection, setRowSelection] = useState({}) - - const params = useQueryParams(["q"]) const { orders, count, isLoading, isError, error } = useAdminOrders( { customer_id: customer.id, - limit: PAGE_SIZE, - offset: pageIndex * PAGE_SIZE, - fields: - "id,status,display_id,created_at,email,fulfillment_status,payment_status,total,currency_code", - ...params, + expand: DEFAULT_RELATIONS, + fields: DEFAULT_FIELDS, + ...searchParams, }, { keepPreviousData: true, } ) - const columns = useColumns() + const columns = useOrderTableColumns({ + exclude: ["customer"], + }) + const filters = useOrderTableFilters() - const table = useReactTable({ + const { table } = useDataTable({ data: orders ?? [], columns, - pageCount: Math.ceil((count ?? 0) / PAGE_SIZE), - state: { - pagination, - rowSelection, - }, - onPaginationChange: setPagination, - onRowSelectionChange: setRowSelection, - getCoreRowModel: getCoreRowModel(), - manualPagination: true, + enablePagination: true, + count, + pageSize: PAGE_SIZE, }) - const noRecords = - Object.values(params).every((v) => !v) && !isLoading && !orders?.length - if (isError) { throw error } return ( - -
+ +
{t("orders.domain")}
- {!noRecords && ( -
-
-
- -
-
- )} - {noRecords ? ( - - ) : ( -
- - - {table.getHeaderGroups().map((headerGroup) => { - return ( - - {headerGroup.headers.map((header) => { - return ( - - {flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ) - })} - - ) - })} - - - {table.getRowModel().rows.map((row) => ( - navigate(`/orders/${row.original.id}`)} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - - ))} - -
- -
- )} + `/orders/${row.original.id}`} + filters={filters} + count={count} + isLoading={isLoading} + rowCount={PAGE_SIZE} + orderBy={["display_id", "created_at", "updated_at"]} + search={true} + queryObject={raw} + />
) } - -const OrderActions = ({ order }: { order: Order }) => { - const { t } = useTranslation() - - return ( - , - label: t("customers.viewOrder"), - to: `/orders/${order.id}/edit`, - }, - ], - }, - ]} - /> - ) -} - -const columnHelper = createColumnHelper() - -const useColumns = () => { - const { t } = useTranslation() - - return useMemo( - () => [ - columnHelper.accessor("display_id", { - header: "Order", - cell: ({ getValue }) => , - }), - columnHelper.accessor("created_at", { - header: "Date", - cell: ({ getValue }) => , - }), - columnHelper.accessor("fulfillment_status", { - header: "Fulfillment Status", - cell: ({ getValue }) => ( - - ), - }), - columnHelper.accessor("payment_status", { - header: "Payment Status", - cell: ({ getValue }) => , - }), - columnHelper.accessor("total", { - header: () => t("fields.total"), - cell: ({ getValue, row }) => ( - - ), - }), - columnHelper.display({ - id: "actions", - cell: ({ row }) => , - }), - ], - [t] - ) -} diff --git a/packages/admin-next/dashboard/src/routes/orders/list/index.ts b/packages/admin-next/dashboard/src/routes/orders/list/index.ts deleted file mode 100644 index ad7ea56183..0000000000 --- a/packages/admin-next/dashboard/src/routes/orders/list/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { OrderList as Component } from "./list"; diff --git a/packages/admin-next/dashboard/src/routes/orders/list/list.tsx b/packages/admin-next/dashboard/src/routes/orders/list/list.tsx deleted file mode 100644 index d5498116ac..0000000000 --- a/packages/admin-next/dashboard/src/routes/orders/list/list.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Container, Heading } from "@medusajs/ui"; - -export const OrderList = () => { - return ( -
- - Orders - -
- ); -}; diff --git a/packages/admin-next/dashboard/src/routes/orders/order-list/components/order-list-table/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-list/components/order-list-table/index.ts new file mode 100644 index 0000000000..f652070734 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-list/components/order-list-table/index.ts @@ -0,0 +1 @@ +export * from "./order-list-table" 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 new file mode 100644 index 0000000000..05ba27416a --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-list/components/order-list-table/order-list-table.tsx @@ -0,0 +1,67 @@ +import { Container, Heading } from "@medusajs/ui" +import { useAdminOrders } from "medusa-react" +import { useTranslation } from "react-i18next" +import { DataTable } from "../../../../../components/table/data-table/data-table" +import { useOrderTableColumns } from "../../../../../hooks/table/columns/use-order-table-columns" +import { useOrderTableFilters } from "../../../../../hooks/table/filters/use-order-table-filters" +import { useOrderTableQuery } from "../../../../../hooks/table/query/use-order-table-query" +import { useDataTable } from "../../../../../hooks/use-data-table" + +const PAGE_SIZE = 50 +const DEFAULT_RELATIONS = "customer,items,sales_channel" +const DEFAULT_FIELDS = + "id,status,display_id,created_at,email,fulfillment_status,payment_status,total,currency_code" + +export const OrderListTable = () => { + const { t } = useTranslation() + const { searchParams, raw } = useOrderTableQuery({ + pageSize: PAGE_SIZE, + }) + + const { orders, count, isError, error, isLoading } = useAdminOrders( + { + expand: DEFAULT_RELATIONS, + fields: DEFAULT_FIELDS, + ...searchParams, + }, + { + keepPreviousData: true, + } + ) + + const filters = useOrderTableFilters() + const columns = useOrderTableColumns({}) + + const { table } = useDataTable({ + data: orders ?? [], + columns, + enablePagination: true, + count, + pageSize: PAGE_SIZE, + }) + + if (isError) { + throw error + } + + return ( + +
+ {t("orders.domain")} +
+ `/orders/${row.original.id}`} + filters={filters} + count={count} + search + isLoading={isLoading} + rowCount={PAGE_SIZE} + orderBy={["display_id", "created_at", "updated_at"]} + queryObject={raw} + /> +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-list/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-list/index.ts new file mode 100644 index 0000000000..0d3535fb84 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-list/index.ts @@ -0,0 +1 @@ +export { OrderList as Component } from "./order-list" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-list/order-list.tsx b/packages/admin-next/dashboard/src/routes/orders/order-list/order-list.tsx new file mode 100644 index 0000000000..587cb88bf5 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-list/order-list.tsx @@ -0,0 +1,9 @@ +import { OrderListTable } from "./components/order-list-table" + +export const OrderList = () => { + return ( +
+ +
+ ) +} diff --git a/packages/design-system/ui-preset/package.json b/packages/design-system/ui-preset/package.json index 53bb8ebf8f..22776839f6 100644 --- a/packages/design-system/ui-preset/package.json +++ b/packages/design-system/ui-preset/package.json @@ -31,7 +31,7 @@ }, "devDependencies": { "@medusajs/toolbox": "^0.0.1", - "tailwindcss": "^3.3.2", + "tailwindcss": "^3.4.1", "tsup": "^7.1.0", "typescript": "^5.1.6" }, diff --git a/packages/design-system/ui/package.json b/packages/design-system/ui/package.json index d0b0734d22..9653eff581 100644 --- a/packages/design-system/ui/package.json +++ b/packages/design-system/ui/package.json @@ -72,7 +72,7 @@ "resize-observer-polyfill": "^1.5.1", "rimraf": "^5.0.1", "storybook": "^7.0.23", - "tailwindcss": "^3.3.2", + "tailwindcss": "^3.4.1", "tsc-alias": "^1.8.7", "typescript": "^5.1.6", "vite": "^4.3.9", diff --git a/packages/design-system/ui/src/components/table/table.tsx b/packages/design-system/ui/src/components/table/table.tsx index 246371cbad..40f58e18d9 100644 --- a/packages/design-system/ui/src/components/table/table.tsx +++ b/packages/design-system/ui/src/components/table/table.tsx @@ -49,7 +49,7 @@ const Cell = React.forwardRef< HTMLTableCellElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + )) Cell.displayName = "Table.Cell" @@ -72,7 +72,11 @@ const HeaderCell = React.forwardRef< HTMLTableCellElement, React.TdHTMLAttributes >(({ className, ...props }, ref) => ( - + )) HeaderCell.displayName = "Table.HeaderCell" diff --git a/yarn.lock b/yarn.lock index bed468a813..a0c934b17d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8050,8 +8050,10 @@ __metadata: "@medusajs/ui-preset": "workspace:^" "@medusajs/vite-plugin-extension": "workspace:^" "@radix-ui/react-collapsible": 1.0.3 + "@radix-ui/react-hover-card": ^1.0.7 "@tanstack/react-query": 4.22.0 "@tanstack/react-table": 8.10.7 + "@types/node": ^20.11.15 "@types/react": 18.2.43 "@types/react-dom": 18.2.17 "@uiw/react-json-view": 2.0.0-alpha.10 @@ -8070,7 +8072,7 @@ __metadata: react-hook-form: 7.49.1 react-i18next: 13.5.0 react-router-dom: 6.20.1 - tailwindcss: 3.3.6 + tailwindcss: ^3.4.1 typescript: 5.2.2 vite: 5.0.10 zod: 3.22.4 @@ -8655,7 +8657,7 @@ __metadata: dependencies: "@medusajs/toolbox": ^0.0.1 "@tailwindcss/forms": ^0.5.3 - tailwindcss: ^3.3.2 + tailwindcss: ^3.4.1 tailwindcss-animate: ^1.0.6 tsup: ^7.1.0 typescript: ^5.1.6 @@ -8726,7 +8728,7 @@ __metadata: rimraf: ^5.0.1 storybook: ^7.0.23 tailwind-merge: ^1.13.2 - tailwindcss: ^3.3.2 + tailwindcss: ^3.4.1 tsc-alias: ^1.8.7 typescript: ^5.1.6 vite: ^4.3.9 @@ -10424,6 +10426,34 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-hover-card@npm:^1.0.7": + version: 1.0.7 + resolution: "@radix-ui/react-hover-card@npm:1.0.7" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/primitive": 1.0.1 + "@radix-ui/react-compose-refs": 1.0.1 + "@radix-ui/react-context": 1.0.1 + "@radix-ui/react-dismissable-layer": 1.0.5 + "@radix-ui/react-popper": 1.1.3 + "@radix-ui/react-portal": 1.0.4 + "@radix-ui/react-presence": 1.0.1 + "@radix-ui/react-primitive": 1.0.3 + "@radix-ui/react-use-controllable-state": 1.0.1 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: f29f3da5bd9a967b5a35e91ac2d1b223191c7a074550d9d9cc10a0c0baf62ba0705b32912a7d2ef1ea5c27dd5e130a9fda9cbe6c2a7f3c2037ed5dfed89aa8cc + languageName: node + linkType: hard + "@radix-ui/react-id@npm:1.0.0": version: 1.0.0 resolution: "@radix-ui/react-id@npm:1.0.0" @@ -17668,6 +17698,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^20.11.15": + version: 20.11.15 + resolution: "@types/node@npm:20.11.15" + dependencies: + undici-types: ~5.26.4 + checksum: 7dfab4208fedc02e9584c619551906f46ade7955bb929b1e32e354a50522eb532d6bfb2844fdaad2c8dca03be84a590674460c64cb101e1a33bb318e1ec448d4 + languageName: node + linkType: hard + "@types/node@npm:^8.5.7": version: 8.10.66 resolution: "@types/node@npm:8.10.66" @@ -47893,72 +47932,6 @@ __metadata: languageName: node linkType: hard -"tailwindcss@npm:3.3.6": - version: 3.3.6 - resolution: "tailwindcss@npm:3.3.6" - dependencies: - "@alloc/quick-lru": ^5.2.0 - arg: ^5.0.2 - chokidar: ^3.5.3 - didyoumean: ^1.2.2 - dlv: ^1.1.3 - fast-glob: ^3.3.0 - glob-parent: ^6.0.2 - is-glob: ^4.0.3 - jiti: ^1.19.1 - lilconfig: ^2.1.0 - micromatch: ^4.0.5 - normalize-path: ^3.0.0 - object-hash: ^3.0.0 - picocolors: ^1.0.0 - postcss: ^8.4.23 - postcss-import: ^15.1.0 - postcss-js: ^4.0.1 - postcss-load-config: ^4.0.1 - postcss-nested: ^6.0.1 - postcss-selector-parser: ^6.0.11 - resolve: ^1.22.2 - sucrase: ^3.32.0 - bin: - tailwind: lib/cli.js - tailwindcss: lib/cli.js - checksum: 69caade773249cb963c33e81f85b7fc423dcb74b416727483f434f4e12874187f633970c9de864fa96736289abaf71189314a53589ada0be6c09ccb0e8b78391 - languageName: node - linkType: hard - -"tailwindcss@npm:^3.3.2": - version: 3.3.4 - resolution: "tailwindcss@npm:3.3.4" - dependencies: - "@alloc/quick-lru": ^5.2.0 - arg: ^5.0.2 - chokidar: ^3.5.3 - didyoumean: ^1.2.2 - dlv: ^1.1.3 - fast-glob: ^3.3.0 - glob-parent: ^6.0.2 - is-glob: ^4.0.3 - jiti: ^1.19.1 - lilconfig: ^2.1.0 - micromatch: ^4.0.5 - normalize-path: ^3.0.0 - object-hash: ^3.0.0 - picocolors: ^1.0.0 - postcss: ^8.4.23 - postcss-import: ^15.1.0 - postcss-js: ^4.0.1 - postcss-load-config: ^4.0.1 - postcss-nested: ^6.0.1 - postcss-selector-parser: ^6.0.11 - resolve: ^1.22.2 - sucrase: ^3.32.0 - bin: - tailwind: lib/cli.js - tailwindcss: lib/cli.js - checksum: a1a0c8c1793b1b1b67503484fe924dc84f79e74c1ddc576095d616eaecc18bbd8fcdbf7c62e07a181673466f4913ebc20d92b93b87da730148b05f7c95e6c83e - languageName: node - linkType: hard - "tailwindcss@npm:^3.3.6": version: 3.4.0 resolution: "tailwindcss@npm:3.4.0" @@ -47992,6 +47965,39 @@ __metadata: languageName: node linkType: hard +"tailwindcss@npm:^3.4.1": + version: 3.4.1 + resolution: "tailwindcss@npm:3.4.1" + dependencies: + "@alloc/quick-lru": ^5.2.0 + arg: ^5.0.2 + chokidar: ^3.5.3 + didyoumean: ^1.2.2 + dlv: ^1.1.3 + fast-glob: ^3.3.0 + glob-parent: ^6.0.2 + is-glob: ^4.0.3 + jiti: ^1.19.1 + lilconfig: ^2.1.0 + micromatch: ^4.0.5 + normalize-path: ^3.0.0 + object-hash: ^3.0.0 + picocolors: ^1.0.0 + postcss: ^8.4.23 + postcss-import: ^15.1.0 + postcss-js: ^4.0.1 + postcss-load-config: ^4.0.1 + postcss-nested: ^6.0.1 + postcss-selector-parser: ^6.0.11 + resolve: ^1.22.2 + sucrase: ^3.32.0 + bin: + tailwind: lib/cli.js + tailwindcss: lib/cli.js + checksum: eec3d758f1cd4f51ab3b4c201927c3ecd18e55f8ac94256af60276aaf8d1df78f9dddb5e9fb1e057dfa7cea3c1356add4994cc3d42da9739df874e67047e656f + languageName: node + linkType: hard + "tapable@npm:^1.0.0, tapable@npm:^1.1.3": version: 1.1.3 resolution: "tapable@npm:1.1.3"