From 6c7641bfef088728dd546caabdae82753c7ca2e2 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Wed, 23 Oct 2024 14:02:58 +0200 Subject: [PATCH 01/34] init work --- packages/design-system/ui/package.json | 1 + .../ui/src/blocks/data-table/data-table.tsx | 10 ++++++++++ .../design-system/ui/src/blocks/data-table/index.ts | 2 ++ .../design-system/ui/src/blocks/data-table/types.ts | 4 ++++ .../ui/src/blocks/data-table/use-data-table.tsx | 9 +++++++++ yarn.lock | 3 ++- 6 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 packages/design-system/ui/src/blocks/data-table/data-table.tsx create mode 100644 packages/design-system/ui/src/blocks/data-table/index.ts create mode 100644 packages/design-system/ui/src/blocks/data-table/types.ts create mode 100644 packages/design-system/ui/src/blocks/data-table/use-data-table.tsx diff --git a/packages/design-system/ui/package.json b/packages/design-system/ui/package.json index abb6edc8d0a3c..893f727e28bec 100644 --- a/packages/design-system/ui/package.json +++ b/packages/design-system/ui/package.json @@ -96,6 +96,7 @@ "@radix-ui/react-switch": "1.1.0", "@radix-ui/react-tabs": "1.1.0", "@radix-ui/react-tooltip": "1.1.2", + "@tanstack/react-table": "^8.20.5", "clsx": "^1.2.1", "copy-to-clipboard": "^3.3.3", "cva": "1.0.0-beta.1", diff --git a/packages/design-system/ui/src/blocks/data-table/data-table.tsx b/packages/design-system/ui/src/blocks/data-table/data-table.tsx new file mode 100644 index 0000000000000..3c9edce169fc0 --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/data-table.tsx @@ -0,0 +1,10 @@ +"use client" +import * as React from "react" + +import { Table } from "@/components/table" + +const DataTable = () => { + return
+} + +export { DataTable } diff --git a/packages/design-system/ui/src/blocks/data-table/index.ts b/packages/design-system/ui/src/blocks/data-table/index.ts new file mode 100644 index 0000000000000..8a03d0a11e4a9 --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/index.ts @@ -0,0 +1,2 @@ +export * from "./data-table" +export * from "./use-data-table" diff --git a/packages/design-system/ui/src/blocks/data-table/types.ts b/packages/design-system/ui/src/blocks/data-table/types.ts new file mode 100644 index 0000000000000..358778bbce99e --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/types.ts @@ -0,0 +1,4 @@ +export type OrderByFilter = { + field: TField + label: string +} diff --git a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx new file mode 100644 index 0000000000000..0542ddc340c7d --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx @@ -0,0 +1,9 @@ +import { TableOptions, useReactTable } from "@tanstack/react-table" + +const useDataTable = (options: TableOptions) => { + const instance = useReactTable(options) + + return instance +} + +export { useDataTable } diff --git a/yarn.lock b/yarn.lock index 87f5c9e61e497..83f56be34da60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6675,6 +6675,7 @@ __metadata: "@storybook/react": ^8.3.5 "@storybook/react-vite": ^8.3.5 "@storybook/testing-library": ^0.2.2 + "@tanstack/react-table": ^8.20.5 "@testing-library/dom": ^9.3.1 "@testing-library/jest-dom": ^5.16.5 "@testing-library/react": ^14.0.0 @@ -13101,7 +13102,7 @@ __metadata: languageName: node linkType: hard -"@tanstack/react-table@npm:8.20.5": +"@tanstack/react-table@npm:8.20.5, @tanstack/react-table@npm:^8.20.5": version: 8.20.5 resolution: "@tanstack/react-table@npm:8.20.5" dependencies: From 3ac7a5c7e570f5f89db2e286ce7387841a252cff Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Thu, 24 Oct 2024 16:44:26 +0200 Subject: [PATCH 02/34] progress on column helper --- .../components/data-table-filter-menu.tsx | 14 ++ .../components/data-table-pagination.tsx | 26 ++++ .../components/data-table-search.tsx | 31 ++++ .../components/data-table-select-cell.tsx | 37 +++++ .../components/data-table-sorting-menu.tsx | 44 ++++++ .../components/data-table-table.tsx | 106 ++++++++++++++ .../components/data-table-toolbar.tsx | 18 +++ .../context/data-table-context-provider.tsx | 24 +++ .../data-table/context/data-table-context.tsx | 9 ++ .../context/use-data-table-context.tsx | 16 ++ .../blocks/data-table/data-table.stories.tsx | 137 ++++++++++++++++++ .../ui/src/blocks/data-table/data-table.tsx | 40 ++++- .../ui/src/blocks/data-table/index.ts | 1 + .../ui/src/blocks/data-table/types.ts | 17 ++- .../src/blocks/data-table/use-data-table.tsx | 72 ++++++++- .../utils/create-data-table-column-helper.tsx | 52 +++++++ .../ui/src/components/table/table.tsx | 2 +- 17 files changed, 635 insertions(+), 11 deletions(-) create mode 100644 packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx create mode 100644 packages/design-system/ui/src/blocks/data-table/components/data-table-pagination.tsx create mode 100644 packages/design-system/ui/src/blocks/data-table/components/data-table-search.tsx create mode 100644 packages/design-system/ui/src/blocks/data-table/components/data-table-select-cell.tsx create mode 100644 packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx create mode 100644 packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx create mode 100644 packages/design-system/ui/src/blocks/data-table/components/data-table-toolbar.tsx create mode 100644 packages/design-system/ui/src/blocks/data-table/context/data-table-context-provider.tsx create mode 100644 packages/design-system/ui/src/blocks/data-table/context/data-table-context.tsx create mode 100644 packages/design-system/ui/src/blocks/data-table/context/use-data-table-context.tsx create mode 100644 packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx create mode 100644 packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx new file mode 100644 index 0000000000000..45f8bee76bd96 --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx @@ -0,0 +1,14 @@ +import * as React from "react" + +import { Adjustments } from "@medusajs/icons" +import { IconButton } from "../../../components/icon-button" + +const DataTableFilterMenu = () => { + return ( + + + + ) +} + +export { DataTableFilterMenu } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-pagination.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-pagination.tsx new file mode 100644 index 0000000000000..222a0558fd0ad --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-pagination.tsx @@ -0,0 +1,26 @@ +"use client" + +import * as React from "react" + +import { Table } from "@/components/table" + +import { useDataTableContext } from "../context/use-data-table-context" + +const DataTablePagination = () => { + const { instance } = useDataTableContext() + + return ( + + ) +} + +export { DataTablePagination } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-search.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-search.tsx new file mode 100644 index 0000000000000..c547315b64edf --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-search.tsx @@ -0,0 +1,31 @@ +"use client" + +import { Input } from "@/components/input" +import * as React from "react" + +interface DataTableSearchProps { + value: string + onValueChange: (value: string) => void + autoFocus?: boolean + className?: string + placeholder?: string +} + +const DataTableSearch = ({ + value, + onValueChange, + ...props +}: DataTableSearchProps) => { + return ( + onValueChange(e.target.value)} + {...props} + /> + ) +} + +export { DataTableSearch } +export type { DataTableSearchProps } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-select-cell.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-select-cell.tsx new file mode 100644 index 0000000000000..8b52c02fedd0a --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-select-cell.tsx @@ -0,0 +1,37 @@ +import { Checkbox } from "@/components/checkbox" +import { CheckedState } from "@radix-ui/react-checkbox" +import { CellContext, HeaderContext } from "@tanstack/react-table" +import * as React from "react" + +interface DataTableSelectCellProps { + ctx: CellContext +} + +const DataTableSelectCell = ({ + ctx, +}: DataTableSelectCellProps) => { + const checked = ctx.row.getIsSelected() + const onChange = ctx.row.getToggleSelectedHandler() + + return +} + +interface DataTableSelectHeaderProps { + ctx: HeaderContext +} + +const DataTableSelectHeader = ({ + ctx, +}: DataTableSelectHeaderProps) => { + const checked = ctx.table.getIsSomePageRowsSelected() + ? "indeterminate" + : ctx.table.getIsAllPageRowsSelected() + + const onChange = (checked: CheckedState) => { + ctx.table.toggleAllPageRowsSelected(!!checked) + } + + return +} + +export { DataTableSelectCell, DataTableSelectHeader } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx new file mode 100644 index 0000000000000..abab56e20dfdc --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx @@ -0,0 +1,44 @@ +"use client" + +import * as React from "react" + +import { DropdownMenu } from "@/components/dropdown-menu" +import { DescendingSorting } from "@medusajs/icons" +import { IconButton } from "../../../components/icon-button" +import { useDataTableContext } from "../context/use-data-table-context" + +const DataTableSortingMenu = () => { + const { instance } = useDataTableContext() + + const sortableColumns = instance + .getAllColumns() + .filter((column) => column.getCanSort()) + + return ( + + + + + + + + + {sortableColumns.map((column) => ( + + {column.id} + + ))} + + + + Ascending + + Descending + + + + + ) +} + +export { DataTableSortingMenu } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx new file mode 100644 index 0000000000000..5f1e4e0c026bc --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx @@ -0,0 +1,106 @@ +import * as React from "react" + +import { Table } from "@/components/table" +import { flexRender, SortDirection } from "@tanstack/react-table" +import { clx } from "../../../utils/clx" +import { useDataTableContext } from "../context/use-data-table-context" + +const DataTableTable = () => { + const { instance } = useDataTableContext() + const columns = instance.getAllColumns() + + const hasSelect = columns.find((c) => c.id === "select") + const hasActions = columns.find((c) => c.id === "actions") + + return ( + + + {instance.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const canSort = header.column.getCanSort() + const sortDirection = header.column.getIsSorted() + + return ( + +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {canSort && ( + + )} +
+
+ ) + })} +
+ ))} +
+ + {instance.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+ ) +} + +const SortingIcon = ({ direction }: { direction: SortDirection | false }) => { + const isAscending = direction === "asc" + const isDescending = direction === "desc" + + const isSorted = isAscending || isDescending + + return ( + + + + + ) +} + +export { DataTableTable } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-toolbar.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-toolbar.tsx new file mode 100644 index 0000000000000..42d7a8faae580 --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-toolbar.tsx @@ -0,0 +1,18 @@ +import { clx } from "@/utils/clx" +import * as React from "react" + +interface DataTableToolbarProps { + className?: string + children?: React.ReactNode +} + +const DataTableToolbar = ({ children, className }: DataTableToolbarProps) => { + return ( +
+ {children} +
+ ) +} + +export { DataTableToolbar } +export type { DataTableToolbarProps } diff --git a/packages/design-system/ui/src/blocks/data-table/context/data-table-context-provider.tsx b/packages/design-system/ui/src/blocks/data-table/context/data-table-context-provider.tsx new file mode 100644 index 0000000000000..bce96b20c6f2c --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/context/data-table-context-provider.tsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" + +import { UseDataTableReturn } from "../use-data-table" +import { DataTableContext } from "./data-table-context" + +type DataTableContextProviderProps = { + instance: UseDataTableReturn + children: React.ReactNode +} + +const DataTableContextProvider = ({ + instance, + children, +}: DataTableContextProviderProps) => { + return ( + + {children} + + ) +} + +export { DataTableContextProvider } diff --git a/packages/design-system/ui/src/blocks/data-table/context/data-table-context.tsx b/packages/design-system/ui/src/blocks/data-table/context/data-table-context.tsx new file mode 100644 index 0000000000000..51a6b3aabdebd --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/context/data-table-context.tsx @@ -0,0 +1,9 @@ +import { createContext } from "react" +import { UseDataTableReturn } from "../use-data-table" + +export interface DataTableContextValue { + instance: UseDataTableReturn +} + +export const DataTableContext = + createContext | null>(null) diff --git a/packages/design-system/ui/src/blocks/data-table/context/use-data-table-context.tsx b/packages/design-system/ui/src/blocks/data-table/context/use-data-table-context.tsx new file mode 100644 index 0000000000000..53ac2fd5eb3ad --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/context/use-data-table-context.tsx @@ -0,0 +1,16 @@ +import * as React from "react" +import { DataTableContext, DataTableContextValue } from "./data-table-context" + +const useDataTableContext = (): DataTableContextValue => { + const context = React.useContext(DataTableContext) + + if (!context) { + throw new Error( + "useDataTableContext must be used within a DataTableContextProvider" + ) + } + + return context +} + +export { useDataTableContext } diff --git a/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx b/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx new file mode 100644 index 0000000000000..a6cd3622585ee --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx @@ -0,0 +1,137 @@ +import type { Meta, StoryObj } from "@storybook/react" +import * as React from "react" + +import { Container } from "@/components/container" +import { RowSelectionState } from "@tanstack/react-table" +import { Heading } from "../../components/heading" +import { DataTable } from "./data-table" +import { useDataTable } from "./use-data-table" +import { createDataTableColumnHelper } from "./utils/create-data-table-column-helper" + +const meta: Meta = { + title: "Blocks/DataTable", + component: DataTable, +} + +export default meta + +type Story = StoryObj + +type Person = { + name: string + email: string + age: number +} + +const data: Person[] = [ + { + name: "John Doe", + email: "john.doe@example.com", + age: 20, + }, + { + name: "Jane Doe", + email: "jane.doe@example.com", + age: 25, + }, + { + name: "John Smith", + email: "john.smith@example.com", + age: 30, + }, +] + +const usePeople = ({ q }: { q?: string }) => { + return React.useMemo(() => { + return { + data: data.filter((person) => + person.name.toLowerCase().includes(q?.toLowerCase() ?? "") + ), + count: data.length, + } + }, [q]) +} + +const columnHelper = createDataTableColumnHelper() + +const columns = [ + columnHelper.select({}), + columnHelper.accessor("name", { + header: "Name", + enableSorting: false, + }), + columnHelper.accessor("email", { + header: "Email", + enableSorting: true, + }), + columnHelper.accessor("age", { + header: "Age", + enableSorting: true, + }), +] + +const useDebouncedValue = (value: string, delay: number) => { + const [debouncedValue, setDebouncedValue] = React.useState(value) + const timerRef = React.useRef | null>(null) + + React.useEffect(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + + timerRef.current = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, [value]) + + return debouncedValue +} + +const BasicDemo = () => { + const [search, setSearch] = React.useState("") + const debouncedSearch = useDebouncedValue(search, 300) + + const [rowSelection, setRowSelection] = React.useState({}) + + const { data, count } = usePeople({ q: debouncedSearch }) + + const table = useDataTable({ + data, + columns, + count, + rowSelection: { + state: rowSelection, + onRowSelectionChange: setRowSelection, + }, + }) + + return ( + + + + Employees +
+ + + +
+
+ + +
+
+ ) +} + +export const Basic: Story = { + render: () => , +} diff --git a/packages/design-system/ui/src/blocks/data-table/data-table.tsx b/packages/design-system/ui/src/blocks/data-table/data-table.tsx index 3c9edce169fc0..eb5e461110fa3 100644 --- a/packages/design-system/ui/src/blocks/data-table/data-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/data-table.tsx @@ -1,10 +1,44 @@ "use client" import * as React from "react" -import { Table } from "@/components/table" +import { DataTableSearch } from "./components/data-table-search" +import { DataTableTable } from "./components/data-table-table" +import { DataTableToolbar } from "./components/data-table-toolbar" +import { DataTableContextProvider } from "./context/data-table-context-provider" +import { UseDataTableReturn } from "./use-data-table" -const DataTable = () => { - return
+import { clx } from "@/utils/clx" +import { DataTableFilterMenu } from "./components/data-table-filter-menu" +import { DataTablePagination } from "./components/data-table-pagination" +import { DataTableSortingMenu } from "./components/data-table-sorting-menu" + +interface DataTableProps { + instance: UseDataTableReturn + children?: React.ReactNode + className?: string +} + +const Root = ({ + instance, + children, + className, +}: DataTableProps) => { + return ( + +
+ {children} +
+
+ ) } +const DataTable = Object.assign(Root, { + Table: DataTableTable, + Toolbar: DataTableToolbar, + Search: DataTableSearch, + SortingMenu: DataTableSortingMenu, + FilterMenu: DataTableFilterMenu, + Pagination: DataTablePagination, +}) + export { DataTable } diff --git a/packages/design-system/ui/src/blocks/data-table/index.ts b/packages/design-system/ui/src/blocks/data-table/index.ts index 8a03d0a11e4a9..b35c56cab38b0 100644 --- a/packages/design-system/ui/src/blocks/data-table/index.ts +++ b/packages/design-system/ui/src/blocks/data-table/index.ts @@ -1,2 +1,3 @@ export * from "./data-table" export * from "./use-data-table" +export * from "./utils/create-data-table-column-helper" diff --git a/packages/design-system/ui/src/blocks/data-table/types.ts b/packages/design-system/ui/src/blocks/data-table/types.ts index 358778bbce99e..4e905254f2d5a 100644 --- a/packages/design-system/ui/src/blocks/data-table/types.ts +++ b/packages/design-system/ui/src/blocks/data-table/types.ts @@ -1,4 +1,15 @@ -export type OrderByFilter = { - field: TField - label: string +import { RowData } from "@tanstack/react-table" + +export type OrderByState = { + [K in keyof TData]?: { + label: string + value?: TData[K] + } } + +// export type FilterState = { +// [K in keyof TData]?: { +// label: string +// value?: TData[K] +// } +// } diff --git a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx index 0542ddc340c7d..6df35c75319fe 100644 --- a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx @@ -1,9 +1,73 @@ -import { TableOptions, useReactTable } from "@tanstack/react-table" +import { + getCoreRowModel, + OnChangeFn, + Row, + RowSelectionState, + TableOptions, + useReactTable, +} from "@tanstack/react-table" -const useDataTable = (options: TableOptions) => { - const instance = useReactTable(options) +interface DataTableOptions + extends Pick, "data" | "columns" | "getRowId"> { + rowSelection?: { + state: RowSelectionState + onRowSelectionChange: OnChangeFn + } + onClickRow?: (row: Row) => void + count?: number +} + +interface UseDataTableReturn + extends Pick< + ReturnType>, + | "getHeaderGroups" + | "getRowModel" + | "getCanNextPage" + | "getCanPreviousPage" + | "nextPage" + | "previousPage" + | "getPageCount" + | "getAllColumns" + > { + count: number + pageIndex: number + pageSize: number +} + +const useDataTable = ({ + count = 0, + rowSelection, + ...options +}: DataTableOptions): UseDataTableReturn => { + const instance = useReactTable({ + ...options, + getCoreRowModel: getCoreRowModel(), + state: { + rowSelection: rowSelection?.state, + }, + onRowSelectionChange: rowSelection?.onRowSelectionChange, + // All data manipulation should be handled manually, likely by a server. + manualSorting: true, + manualPagination: true, + manualFiltering: true, + }) - return instance + return { + // Table + getHeaderGroups: instance.getHeaderGroups, + getRowModel: instance.getRowModel, + getAllColumns: instance.getAllColumns, + // Pagination + getCanNextPage: instance.getCanNextPage, + getCanPreviousPage: instance.getCanPreviousPage, + nextPage: instance.nextPage, + previousPage: instance.previousPage, + getPageCount: instance.getPageCount, + pageIndex: instance.getState().pagination.pageIndex, + pageSize: instance.getState().pagination.pageSize, + count, + } } export { useDataTable } +export type { DataTableOptions, UseDataTableReturn } diff --git a/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx new file mode 100644 index 0000000000000..4df50c9ae52f6 --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx @@ -0,0 +1,52 @@ +"use client" + +import { + AccessorColumnDef as AccessorColumnDefTanstack, + CellContext, + createColumnHelper as createColumnHelperTanstack, + DisplayColumnDef, +} from "@tanstack/react-table" +import * as React from "react" +import { + DataTableSelectCell, + DataTableSelectHeader, +} from "../components/data-table-select-cell" + +type DataTableAction = { + label: string + onClick: (ctx: CellContext) => void +} + +interface ActionColumnDef + extends Omit, "id" | "cell" | "header"> { + actions: DataTableAction[] +} +interface SelectColumnDef + extends Omit, "id" | "header"> {} +interface AccessorColumnDef + extends Omit, "id"> {} + +const createDataTableColumnHelper = () => { + const { accessor, display } = createColumnHelperTanstack() + + return { + accessor, + display, + action: (props: ActionColumnDef) => + display({ + id: "action", + ...props, + }), + select: (props: SelectColumnDef) => + display({ + id: "select", + header: (ctx) => , + cell: props.cell + ? props.cell + : (ctx) => , + ...props, + }), + } +} + +export { createDataTableColumnHelper } diff --git a/packages/design-system/ui/src/components/table/table.tsx b/packages/design-system/ui/src/components/table/table.tsx index 40f58e18d9028..0bc3f6119c596 100644 --- a/packages/design-system/ui/src/components/table/table.tsx +++ b/packages/design-system/ui/src/components/table/table.tsx @@ -60,7 +60,7 @@ const Header = React.forwardRef< Date: Fri, 25 Oct 2024 15:32:22 +0200 Subject: [PATCH 03/34] add hotkey --- .../components/data-table-action-cell.tsx | 32 +++++ .../components/data-table-select-cell.tsx | 8 +- .../components/data-table-sorting-icon.tsx | 42 ++++++ .../components/data-table-sorting-menu.tsx | 24 +++- .../components/data-table-table.tsx | 136 +++++++++++------- .../blocks/data-table/data-table.stories.tsx | 54 +++++-- .../ui/src/blocks/data-table/types.ts | 52 ++++++- .../src/blocks/data-table/use-data-table.tsx | 70 ++++++++- .../utils/create-data-table-column-helper.tsx | 52 ++++--- 9 files changed, 374 insertions(+), 96 deletions(-) create mode 100644 packages/design-system/ui/src/blocks/data-table/components/data-table-action-cell.tsx create mode 100644 packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-icon.tsx diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-action-cell.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-action-cell.tsx new file mode 100644 index 0000000000000..3c73be48f30fc --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-action-cell.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" + +import { EllipsisHorizontal } from "@medusajs/icons" +import { CellContext } from "@tanstack/react-table" +import { DropdownMenu } from "../../../components/dropdown-menu" +import { IconButton } from "../../../components/icon-button" + +type DataTableActionCellProps = { + ctx: CellContext +} + +const DataTableActionCell = ({ + ctx, +}: DataTableActionCellProps) => { + return ( + + + + + + + + Edit + Delete + + + ) +} + +export { DataTableActionCell } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-select-cell.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-select-cell.tsx index 8b52c02fedd0a..e1d78ee86d9ec 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-select-cell.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-select-cell.tsx @@ -13,7 +13,13 @@ const DataTableSelectCell = ({ const checked = ctx.row.getIsSelected() const onChange = ctx.row.getToggleSelectedHandler() - return + return ( + e.stopPropagation()} + checked={checked} + onCheckedChange={onChange} + /> + ) } interface DataTableSelectHeaderProps { diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-icon.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-icon.tsx new file mode 100644 index 0000000000000..1df29eeaba0cc --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-icon.tsx @@ -0,0 +1,42 @@ +import { clx } from "@/utils/clx" +import type { SortDirection } from "@tanstack/react-table" +import * as React from "react" + +interface SortingIconProps { + direction: SortDirection | false +} + +const DataTableSortingIcon = ({ direction }: SortingIconProps) => { + const isAscending = direction === "asc" + const isDescending = direction === "desc" + + const isSorted = isAscending || isDescending + + return ( + + + + + ) +} + +export { DataTableSortingIcon } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx index abab56e20dfdc..9f235abfbff95 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { DropdownMenu } from "@/components/dropdown-menu" import { DescendingSorting } from "@medusajs/icons" +import type { Column } from "@tanstack/react-table" import { IconButton } from "../../../components/icon-button" import { useDataTableContext } from "../context/use-data-table-context" @@ -23,11 +24,13 @@ const DataTableSortingMenu = () => { - {sortableColumns.map((column) => ( - - {column.id} - - ))} + {sortableColumns.map((column) => { + return ( + + {getSortLabel(column)} + + ) + })} @@ -41,4 +44,15 @@ const DataTableSortingMenu = () => { ) } +function getSortLabel(column: Column) { + const meta = column.columnDef.meta + let headerValue: string | undefined = undefined + + if (typeof column.columnDef.header === "string") { + headerValue = column.columnDef.header + } + + return meta?.___sortMetaData?.sortLabel ?? headerValue ?? column.id +} + export { DataTableSortingMenu } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx index 5f1e4e0c026bc..6b015ddf9c3bc 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx @@ -1,36 +1,83 @@ import * as React from "react" import { Table } from "@/components/table" -import { flexRender, SortDirection } from "@tanstack/react-table" +import { flexRender } from "@tanstack/react-table" import { clx } from "../../../utils/clx" import { useDataTableContext } from "../context/use-data-table-context" +import { DataTableSortingIcon } from "./data-table-sorting-icon" const DataTableTable = () => { + const [hoveredRowId, setHoveredRowId] = React.useState(null) + const isKeyDown = React.useRef(false) + const { instance } = useDataTableContext() + const columns = instance.getAllColumns() const hasSelect = columns.find((c) => c.id === "select") - const hasActions = columns.find((c) => c.id === "actions") + const hasActions = columns.find((c) => c.id === "action") + + const colCount = columns.length - (hasSelect ? 1 : 0) - (hasActions ? 1 : 0) + const colWidth = 100 / colCount + + React.useEffect(() => { + const onKeyDownHandler = (event: KeyboardEvent) => { + // If an editable element is focused, we don't want to select a row + const isEditableElementFocused = getIsEditableElementFocused() + + if ( + event.key.toLowerCase() === "x" && + hoveredRowId && + !isKeyDown.current && + !isEditableElementFocused + ) { + isKeyDown.current = true + + const row = instance + .getRowModel() + .rows.find((r) => r.id === hoveredRowId) + + if (row && row.getCanSelect()) { + row.toggleSelected() + } + } + } + + const onKeyUpHandler = (event: KeyboardEvent) => { + if (event.key.toLowerCase() === "x") { + isKeyDown.current = false + } + } + + document.addEventListener("keydown", onKeyDownHandler) + document.addEventListener("keyup", onKeyUpHandler) + return () => { + document.removeEventListener("keydown", onKeyDownHandler) + document.removeEventListener("keyup", onKeyUpHandler) + } + }, [hoveredRowId, instance]) return ( {instance.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => { const canSort = header.column.getCanSort() const sortDirection = header.column.getIsSorted() + const sortHandler = header.column.getToggleSortingHandler() + + const isActionHeader = header.id === "action" + const isSelectHeader = header.id === "select" + const isSpecialHeader = isActionHeader || isSelectHeader return ( - +
{ header.getContext() )} {canSort && ( - )}
@@ -56,51 +103,34 @@ const DataTableTable = () => { ))}
- {instance.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} + {instance.getRowModel().rows.map((row) => { + return ( + setHoveredRowId(row.id)} + onMouseLeave={() => setHoveredRowId(null)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) + })}
) } -const SortingIcon = ({ direction }: { direction: SortDirection | false }) => { - const isAscending = direction === "asc" - const isDescending = direction === "desc" - - const isSorted = isAscending || isDescending +function getIsEditableElementFocused() { + const activeElement = !!document ? document.activeElement : null + const isEditableElementFocused = + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement?.getAttribute("contenteditable") === "true" - return ( - - - - - ) + return isEditableElementFocused } export { DataTableTable } diff --git a/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx b/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx index a6cd3622585ee..7e3a0684dd996 100644 --- a/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx +++ b/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react" import * as React from "react" import { Container } from "@/components/container" -import { RowSelectionState } from "@tanstack/react-table" +import { ColumnSort, RowSelectionState } from "@tanstack/react-table" import { Heading } from "../../components/heading" import { DataTable } from "./data-table" import { useDataTable } from "./use-data-table" @@ -41,15 +41,39 @@ const data: Person[] = [ }, ] -const usePeople = ({ q }: { q?: string }) => { +const usePeople = ({ + q, + order, +}: { + q?: string + order?: { id: string; desc: boolean } | null +}) => { return React.useMemo(() => { + const filteredData = data.filter((person) => + person.name.toLowerCase().includes(q?.toLowerCase() ?? "") + ) + + if (!order) { + return { + data: filteredData, + count: filteredData.length, + } + } + + const key = order.id as keyof Person + const desc = order.desc + + const sortedData = filteredData.sort((a, b) => { + if (a[key] < b[key]) return desc ? 1 : -1 + if (a[key] > b[key]) return order.desc ? -1 : 1 + return 0 + }) + return { - data: data.filter((person) => - person.name.toLowerCase().includes(q?.toLowerCase() ?? "") - ), - count: data.length, + data: sortedData, + count: sortedData.length, } - }, [q]) + }, [q, order]) } const columnHelper = createDataTableColumnHelper() @@ -63,10 +87,19 @@ const columns = [ columnHelper.accessor("email", { header: "Email", enableSorting: true, + sortAscLabel: "A-Z", + sortDescLabel: "Z-A", + // sortLabel: "Email", }), columnHelper.accessor("age", { header: "Age", enableSorting: true, + sortAscLabel: "Low to High", + sortDescLabel: "High to Low", + sortLabel: "Age", + }), + columnHelper.action({ + actions: [], }), ] @@ -96,8 +129,9 @@ const BasicDemo = () => { const debouncedSearch = useDebouncedValue(search, 300) const [rowSelection, setRowSelection] = React.useState({}) + const [sorting, setSorting] = React.useState(null) - const { data, count } = usePeople({ q: debouncedSearch }) + const { data, count } = usePeople({ q: debouncedSearch, order: sorting }) const table = useDataTable({ data, @@ -107,6 +141,10 @@ const BasicDemo = () => { state: rowSelection, onRowSelectionChange: setRowSelection, }, + sorting: { + state: sorting, + onSortingChange: setSorting, + }, }) return ( diff --git a/packages/design-system/ui/src/blocks/data-table/types.ts b/packages/design-system/ui/src/blocks/data-table/types.ts index 4e905254f2d5a..94cf8bd01826e 100644 --- a/packages/design-system/ui/src/blocks/data-table/types.ts +++ b/packages/design-system/ui/src/blocks/data-table/types.ts @@ -1,4 +1,14 @@ -import { RowData } from "@tanstack/react-table" +import type { + AccessorFn, + AccessorFnColumnDef, + AccessorKeyColumnDef, + CellContext, + DeepKeys, + DeepValue, + DisplayColumnDef, + IdentifiedColumnDef, + RowData, +} from "@tanstack/react-table" export type OrderByState = { [K in keyof TData]?: { @@ -13,3 +23,43 @@ export type OrderByState = { // value?: TData[K] // } // } + +type DataTableAction = { + label: string + onClick: (ctx: CellContext) => void +} + +export interface ActionColumnDef + extends Omit, "id" | "cell" | "header"> { + actions: DataTableAction[] +} + +export interface SelectColumnDef + extends Omit, "id" | "header"> {} + +export type SortableColumnDef = { + sortLabel?: string + sortAscLabel?: string + sortDescLabel?: string +} + +export type DataTableColumnHelper = { + accessor: < + TAccessor extends AccessorFn | DeepKeys, + TValue extends TAccessor extends AccessorFn + ? TReturn + : TAccessor extends DeepKeys + ? DeepValue + : never + >( + accessor: TAccessor, + column: TAccessor extends AccessorFn + ? DisplayColumnDef & SortableColumnDef + : IdentifiedColumnDef & SortableColumnDef + ) => TAccessor extends AccessorFn + ? AccessorFnColumnDef + : AccessorKeyColumnDef + display: (column: DisplayColumnDef) => DisplayColumnDef + action: (props: ActionColumnDef) => DisplayColumnDef + select: (props: SelectColumnDef) => DisplayColumnDef +} diff --git a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx index 6df35c75319fe..f6edf1abda7e2 100644 --- a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx @@ -1,17 +1,24 @@ import { + type ColumnSort, getCoreRowModel, - OnChangeFn, - Row, - RowSelectionState, - TableOptions, + type Row, + type RowSelectionState, + type SortingState, + type TableOptions, + type Updater, useReactTable, } from "@tanstack/react-table" +import * as React from "react" interface DataTableOptions extends Pick, "data" | "columns" | "getRowId"> { rowSelection?: { state: RowSelectionState - onRowSelectionChange: OnChangeFn + onRowSelectionChange: (state: RowSelectionState) => void + } + sorting?: { + state: ColumnSort | null + onSortingChange: (state: ColumnSort) => void } onClickRow?: (row: Row) => void count?: number @@ -37,15 +44,37 @@ interface UseDataTableReturn const useDataTable = ({ count = 0, rowSelection, + sorting, ...options }: DataTableOptions): UseDataTableReturn => { + const sortingStateHandler = React.useCallback( + () => + sorting?.onSortingChange + ? onSortingChangeTransformer(sorting.onSortingChange, sorting.state) + : undefined, + [sorting?.onSortingChange, sorting?.state] + ) + + const rowSelectionStateHandler = React.useCallback( + () => + rowSelection?.onRowSelectionChange + ? onRowSelectionChangeTransformer( + rowSelection.onRowSelectionChange, + rowSelection.state + ) + : undefined, + [rowSelection?.onRowSelectionChange, rowSelection?.state] + ) + const instance = useReactTable({ ...options, getCoreRowModel: getCoreRowModel(), state: { rowSelection: rowSelection?.state, + sorting: sorting?.state ? [sorting.state] : undefined, }, - onRowSelectionChange: rowSelection?.onRowSelectionChange, + onRowSelectionChange: rowSelectionStateHandler(), + onSortingChange: sortingStateHandler(), // All data manipulation should be handled manually, likely by a server. manualSorting: true, manualPagination: true, @@ -69,5 +98,34 @@ const useDataTable = ({ } } +function onSortingChangeTransformer( + onSortingChange: (state: ColumnSort) => void, + state?: ColumnSort | null +) { + return (updaterOrValue: Updater) => { + const value = + typeof updaterOrValue === "function" + ? updaterOrValue(state ? [state] : []) + : updaterOrValue + const columnSort = value[0] + + onSortingChange(columnSort) + } +} + +function onRowSelectionChangeTransformer( + onRowSelectionChange: (state: RowSelectionState) => void, + state?: RowSelectionState +) { + return (updaterOrValue: Updater) => { + const value = + typeof updaterOrValue === "function" + ? updaterOrValue(state ?? {}) + : updaterOrValue + + onRowSelectionChange(value) + } +} + export { useDataTable } export type { DataTableOptions, UseDataTableReturn } diff --git a/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx index 4df50c9ae52f6..7223d56262500 100644 --- a/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx +++ b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx @@ -1,40 +1,42 @@ "use client" -import { - AccessorColumnDef as AccessorColumnDefTanstack, - CellContext, - createColumnHelper as createColumnHelperTanstack, - DisplayColumnDef, -} from "@tanstack/react-table" +import { createColumnHelper as createColumnHelperTanstack } from "@tanstack/react-table" import * as React from "react" +import { DataTableActionCell } from "../components/data-table-action-cell" import { DataTableSelectCell, DataTableSelectHeader, } from "../components/data-table-select-cell" +import { + ActionColumnDef, + DataTableColumnHelper, + SelectColumnDef, + SortableColumnDef, +} from "../types" -type DataTableAction = { - label: string - onClick: (ctx: CellContext) => void -} +const createDataTableColumnHelper = < + TData, +>(): DataTableColumnHelper => { + const { accessor: accessorTanstack, display } = + createColumnHelperTanstack() -interface ActionColumnDef - extends Omit, "id" | "cell" | "header"> { - actions: DataTableAction[] -} -interface SelectColumnDef - extends Omit, "id" | "header"> {} -interface AccessorColumnDef - extends Omit, "id"> {} + return { + accessor: (accessor, column) => { + const { sortLabel, sortAscLabel, sortDescLabel, meta, ...rest } = + column as any & SortableColumnDef -const createDataTableColumnHelper = () => { - const { accessor, display } = createColumnHelperTanstack() + const extendedMeta = { + ___sortMetaData: { sortLabel, sortAscLabel, sortDescLabel }, + ...meta, + } - return { - accessor, + return accessorTanstack(accessor, { ...rest, meta: extendedMeta }) + }, display, action: (props: ActionColumnDef) => display({ id: "action", + cell: (ctx) => , ...props, }), select: (props: SelectColumnDef) => @@ -49,4 +51,10 @@ const createDataTableColumnHelper = () => { } } +const helper = createColumnHelperTanstack() + +helper.accessor("name", { + meta: {}, +}) + export { createDataTableColumnHelper } From 5db31502ba71e6adc4110482f8e3595fd8d10785 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:49:10 +0100 Subject: [PATCH 04/34] init work on filters --- .../components/data-table-filter-bar.tsx | 12 ++ .../components/data-table-filter-menu.tsx | 25 +++- .../components/data-table-filter.tsx | 37 ++++++ .../components/data-table-sorting-menu.tsx | 118 ++++++++++++++--- .../components/data-table-table.tsx | 124 +++++++++--------- .../blocks/data-table/data-table.stories.tsx | 47 ++++--- .../ui/src/blocks/data-table/types.ts | 4 + .../src/blocks/data-table/use-data-table.tsx | 26 ++++ .../utils/create-data-table-column-helper.tsx | 16 ++- 9 files changed, 305 insertions(+), 104 deletions(-) create mode 100644 packages/design-system/ui/src/blocks/data-table/components/data-table-filter-bar.tsx create mode 100644 packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-bar.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-bar.tsx new file mode 100644 index 0000000000000..891a24792267c --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-bar.tsx @@ -0,0 +1,12 @@ +import * as React from "react" +import { DataTableFilter } from "./data-table-filter" + +const DataTableFilterBar = () => { + return ( +
+ +
+ ) +} + +export { DataTableFilterBar } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx index 45f8bee76bd96..f63e1180f8fd2 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx @@ -1,13 +1,28 @@ import * as React from "react" -import { Adjustments } from "@medusajs/icons" +import { Funnel } from "@medusajs/icons" +import { DropdownMenu } from "../../../components/dropdown-menu" import { IconButton } from "../../../components/icon-button" +import { Tooltip } from "../../../components/tooltip" + +interface DataTableFilterMenuProps { + tooltip?: string +} + +const DataTableFilterMenu = ({ tooltip }: DataTableFilterMenuProps) => { + const Wrapper = tooltip ? Tooltip : React.Fragment -const DataTableFilterMenu = () => { return ( - - - + + + + + + + + + + ) } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx new file mode 100644 index 0000000000000..eb03c4befe8d4 --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx @@ -0,0 +1,37 @@ +import { XMark } from "@medusajs/icons" +import * as React from "react" +import { DropdownMenu } from "../../../components/dropdown-menu" +import { clx } from "../../../utils/clx" + +const DataTableFilter = () => { + return ( +
*]:txt-compact-small-plus [&>*]:flex [&>*]:items-center [&>*]:justify-center" + )} + > +
Filter
+ + +
+ ) +} + +interface DataTableFilterMenuProps { + label: string +} + +const DataTableFilterMenu = ({ label }: DataTableFilterMenuProps) => { + return ( + + + {label} + + + ) +} + +export { DataTableFilter } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx index 9f235abfbff95..60684915da7c6 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx @@ -3,49 +3,115 @@ import * as React from "react" import { DropdownMenu } from "@/components/dropdown-menu" -import { DescendingSorting } from "@medusajs/icons" +import { ArrowDownMini, ArrowUpMini, DescendingSorting } from "@medusajs/icons" import type { Column } from "@tanstack/react-table" import { IconButton } from "../../../components/icon-button" +import { Tooltip } from "../../../components/tooltip" import { useDataTableContext } from "../context/use-data-table-context" +import { SortableColumnDefMeta } from "../types" -const DataTableSortingMenu = () => { +interface DataTableSortingMenuProps { + tooltip?: string +} + +const DataTableSortingMenu = ({ tooltip }: DataTableSortingMenuProps) => { const { instance } = useDataTableContext() const sortableColumns = instance .getAllColumns() .filter((column) => column.getCanSort()) + const sorting = instance.getSorting() + + const selectedColumn = React.useMemo(() => { + return sortableColumns.find((column) => column.id === sorting?.id) + }, [sortableColumns, sorting]) + + const setKey = React.useCallback( + (key: string) => { + instance.setSorting((prev) => ({ id: key, desc: prev?.desc ?? false })) + }, + [instance] + ) + + const setDesc = React.useCallback( + (desc: string) => { + instance.setSorting((prev) => ({ + id: prev?.id ?? "", + desc: desc === "true", + })) + }, + [instance] + ) + + if (!sortableColumns.length) { + if (process.env.NODE_ENV === "development") { + console.warn( + "No sortable columns found. If you intended to sort the table, you need to set the `enableSorting` option to `true` on at least one column." + ) + } + + return null + } + + const Wrapper = tooltip ? Tooltip : React.Fragment + return ( - - - - - + + + + + + + - + {sortableColumns.map((column) => { return ( - + e.preventDefault()} + value={column.id} + key={column.id} + > {getSortLabel(column)} ) })} - - - Ascending - - Descending - - + {sorting && ( + + + + e.preventDefault()} + value="false" + className="flex items-center gap-2" + > + + {getSortDescriptor("asc", selectedColumn)} + + e.preventDefault()} + value="true" + className="flex items-center gap-2" + > + + {getSortDescriptor("desc", selectedColumn)} + + + + )} ) } function getSortLabel(column: Column) { - const meta = column.columnDef.meta + const meta = column.columnDef.meta as SortableColumnDefMeta | undefined let headerValue: string | undefined = undefined if (typeof column.columnDef.header === "string") { @@ -55,4 +121,22 @@ function getSortLabel(column: Column) { return meta?.___sortMetaData?.sortLabel ?? headerValue ?? column.id } +function getSortDescriptor( + direction: "asc" | "desc", + column?: Column +) { + if (!column) { + return null + } + + const meta = column.columnDef.meta as SortableColumnDefMeta | undefined + + switch (direction) { + case "asc": + return meta?.___sortMetaData?.sortAscLabel ?? "A-Z" + case "desc": + return meta?.___sortMetaData?.sortDescLabel ?? "Z-A" + } +} + export { DataTableSortingMenu } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx index 6b015ddf9c3bc..a410e7871857e 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx @@ -4,6 +4,7 @@ import { Table } from "@/components/table" import { flexRender } from "@tanstack/react-table" import { clx } from "../../../utils/clx" import { useDataTableContext } from "../context/use-data-table-context" +import { DataTableFilterBar } from "./data-table-filter-bar" import { DataTableSortingIcon } from "./data-table-sorting-icon" const DataTableTable = () => { @@ -58,68 +59,73 @@ const DataTableTable = () => { }, [hoveredRowId, instance]) return ( - - - {instance.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const canSort = header.column.getCanSort() - const sortDirection = header.column.getIsSorted() - const sortHandler = header.column.getToggleSortingHandler() - - const isActionHeader = header.id === "action" - const isSelectHeader = header.id === "select" - const isSpecialHeader = isActionHeader || isSelectHeader - - return ( - -
+ +
+ + {instance.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const canSort = header.column.getCanSort() + const sortDirection = header.column.getIsSorted() + const sortHandler = header.column.getToggleSortingHandler() + + const isActionHeader = header.id === "action" + const isSelectHeader = header.id === "select" + const isSpecialHeader = isActionHeader || isSelectHeader + + const Wrapper = canSort ? "button" : "div" + + return ( + - {flexRender( - header.column.columnDef.header, - header.getContext() - )} - {canSort && ( - - )} - - - ) - })} - - ))} - - - {instance.getRowModel().rows.map((row) => { - return ( - setHoveredRowId(row.id)} - onMouseLeave={() => setHoveredRowId(null)} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + )} + + + ) + })} - ) - })} - -
+ ))} + + + {instance.getRowModel().rows.map((row) => { + return ( + setHoveredRowId(row.id)} + onMouseLeave={() => setHoveredRowId(null)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) + })} + + + ) } diff --git a/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx b/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx index 7e3a0684dd996..a46cb13a6cc12 100644 --- a/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx +++ b/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx @@ -3,7 +3,9 @@ import * as React from "react" import { Container } from "@/components/container" import { ColumnSort, RowSelectionState } from "@tanstack/react-table" +import { Button } from "../../components/button" import { Heading } from "../../components/heading" +import { TooltipProvider } from "../../components/tooltip" import { DataTable } from "./data-table" import { useDataTable } from "./use-data-table" import { createDataTableColumnHelper } from "./utils/create-data-table-column-helper" @@ -82,7 +84,9 @@ const columns = [ columnHelper.select({}), columnHelper.accessor("name", { header: "Name", - enableSorting: false, + enableSorting: true, + sortAscLabel: "A-Z", + sortDescLabel: "Z-A", }), columnHelper.accessor("email", { header: "Email", @@ -148,25 +152,28 @@ const BasicDemo = () => { }) return ( - - - - Employees -
- - - -
-
- - -
-
+ + + + + Employees +
+ + + + +
+
+ + +
+
+
) } diff --git a/packages/design-system/ui/src/blocks/data-table/types.ts b/packages/design-system/ui/src/blocks/data-table/types.ts index 94cf8bd01826e..a0df593ae1765 100644 --- a/packages/design-system/ui/src/blocks/data-table/types.ts +++ b/packages/design-system/ui/src/blocks/data-table/types.ts @@ -43,6 +43,10 @@ export type SortableColumnDef = { sortDescLabel?: string } +export type SortableColumnDefMeta = { + ___sortMetaData?: SortableColumnDef +} + export type DataTableColumnHelper = { accessor: < TAccessor extends AccessorFn | DeepKeys, diff --git a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx index f6edf1abda7e2..a6ccfe1ab208e 100644 --- a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx @@ -39,6 +39,10 @@ interface UseDataTableReturn count: number pageIndex: number pageSize: number + getSorting: () => ColumnSort | null + setSorting: ( + sortingOrUpdater: ColumnSort | ((prev: ColumnSort | null) => ColumnSort) + ) => void } const useDataTable = ({ @@ -81,6 +85,25 @@ const useDataTable = ({ manualFiltering: true, }) + const getSorting = React.useCallback(() => { + return instance.getState().sorting?.[0] ?? null + }, [instance]) + + const setSorting = React.useCallback( + ( + sortingOrUpdater: ColumnSort | ((prev: ColumnSort | null) => ColumnSort) + ) => { + const currentSort = instance.getState().sorting?.[0] ?? null + const newSorting = + typeof sortingOrUpdater === "function" + ? sortingOrUpdater(currentSort) + : sortingOrUpdater + + instance.setSorting([newSorting]) + }, + [instance] + ) + return { // Table getHeaderGroups: instance.getHeaderGroups, @@ -95,6 +118,9 @@ const useDataTable = ({ pageIndex: instance.getState().pagination.pageIndex, pageSize: instance.getState().pagination.pageSize, count, + // Sorting + getSorting, + setSorting, } } diff --git a/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx index 7223d56262500..0a238242fe04e 100644 --- a/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx +++ b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx @@ -22,15 +22,25 @@ const createDataTableColumnHelper = < return { accessor: (accessor, column) => { - const { sortLabel, sortAscLabel, sortDescLabel, meta, ...rest } = - column as any & SortableColumnDef + const { + sortLabel, + sortAscLabel, + sortDescLabel, + meta, + enableSorting, + ...rest + } = column as any & SortableColumnDef const extendedMeta = { ___sortMetaData: { sortLabel, sortAscLabel, sortDescLabel }, ...meta, } - return accessorTanstack(accessor, { ...rest, meta: extendedMeta }) + return accessorTanstack(accessor, { + ...rest, + enableSorting: enableSorting ?? false, + meta: extendedMeta, + }) }, display, action: (props: ActionColumnDef) => From 8ab528d218213e314b16a7aab44c9b93b55fb24e Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Mon, 4 Nov 2024 11:36:23 +0100 Subject: [PATCH 05/34] progress --- .../components/data-table-action-cell.tsx | 48 +++++++++++++++++-- .../components/data-table-filter-menu.tsx | 2 +- .../components/data-table-filter.tsx | 16 +++++-- .../components/data-table-sorting-menu.tsx | 2 +- .../blocks/data-table/data-table.stories.tsx | 19 +++++++- .../ui/src/blocks/data-table/types.ts | 16 +++---- .../utils/create-data-table-column-helper.tsx | 8 +++- packages/design-system/ui/src/main.css | 4 +- 8 files changed, 92 insertions(+), 23 deletions(-) diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-action-cell.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-action-cell.tsx index 3c73be48f30fc..8b40db3426a9c 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-action-cell.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-action-cell.tsx @@ -6,6 +6,7 @@ import { EllipsisHorizontal } from "@medusajs/icons" import { CellContext } from "@tanstack/react-table" import { DropdownMenu } from "../../../components/dropdown-menu" import { IconButton } from "../../../components/icon-button" +import { ActionColumnDefMeta } from "../types" type DataTableActionCellProps = { ctx: CellContext @@ -14,6 +15,20 @@ type DataTableActionCellProps = { const DataTableActionCell = ({ ctx, }: DataTableActionCellProps) => { + const meta = ctx.column.columnDef.meta as + | ActionColumnDefMeta + | undefined + const actions = meta?.___actions + + if (!actions) { + if (process.env.NODE_ENV === "development") { + console.warn( + "DataTableActionCell: No actions found for column. Ensure the column is defined with the `action` helper." + ) + } + return null + } + return ( @@ -21,9 +36,36 @@ const DataTableActionCell = ({ - - Edit - Delete + + {actions.map((actionOrGroup, index) => { + const isArray = Array.isArray(actionOrGroup) + const isLast = index === actions.length - 1 + + return isArray ? ( + + {actionOrGroup.map((action) => ( + action.onClick(ctx)} + className="[&>svg]:text-ui-fg-subtle flex items-center gap-2" + > + {action.icon} + {action.label} + + ))} + {!isLast && } + + ) : ( + actionOrGroup.onClick(ctx)} + className="[&>svg]:text-ui-fg-subtle flex items-center gap-2" + > + {actionOrGroup.icon} + {actionOrGroup.label} + + ) + })} ) diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx index f63e1180f8fd2..68483a0aa32c9 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx @@ -21,7 +21,7 @@ const DataTableFilterMenu = ({ tooltip }: DataTableFilterMenuProps) => { - + ) } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx index eb03c4befe8d4..93bc2cd4c0e4a 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx @@ -7,13 +7,16 @@ const DataTableFilter = () => { return (
*]:txt-compact-small-plus [&>*]:flex [&>*]:items-center [&>*]:justify-center" )} >
Filter
-
@@ -27,9 +30,16 @@ interface DataTableFilterMenuProps { const DataTableFilterMenu = ({ label }: DataTableFilterMenuProps) => { return ( - + {label} + + + Option 1 + Option 2 + Option 3 + + ) } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx index 60684915da7c6..864ba7631eaf7 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx @@ -65,7 +65,7 @@ const DataTableSortingMenu = ({ tooltip }: DataTableSortingMenuProps) => { - + {sortableColumns.map((column) => { return ( diff --git a/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx b/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx index a46cb13a6cc12..520f112616563 100644 --- a/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx +++ b/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react" import * as React from "react" import { Container } from "@/components/container" +import { PencilSquare, Trash } from "@medusajs/icons" import { ColumnSort, RowSelectionState } from "@tanstack/react-table" import { Button } from "../../components/button" import { Heading } from "../../components/heading" @@ -93,7 +94,6 @@ const columns = [ enableSorting: true, sortAscLabel: "A-Z", sortDescLabel: "Z-A", - // sortLabel: "Email", }), columnHelper.accessor("age", { header: "Age", @@ -103,7 +103,22 @@ const columns = [ sortLabel: "Age", }), columnHelper.action({ - actions: [], + actions: [ + [ + { + label: "Edit", + onClick: () => {}, + icon: , + }, + ], + [ + { + label: "Delete", + onClick: () => {}, + icon: , + }, + ], + ], }), ] diff --git a/packages/design-system/ui/src/blocks/data-table/types.ts b/packages/design-system/ui/src/blocks/data-table/types.ts index a0df593ae1765..2e6994b92ffe7 100644 --- a/packages/design-system/ui/src/blocks/data-table/types.ts +++ b/packages/design-system/ui/src/blocks/data-table/types.ts @@ -17,21 +17,15 @@ export type OrderByState = { } } -// export type FilterState = { -// [K in keyof TData]?: { -// label: string -// value?: TData[K] -// } -// } - type DataTableAction = { label: string onClick: (ctx: CellContext) => void + icon?: React.ReactNode } export interface ActionColumnDef - extends Omit, "id" | "cell" | "header"> { - actions: DataTableAction[] + extends Pick, "meta"> { + actions: DataTableAction[] | DataTableAction[][] } export interface SelectColumnDef @@ -47,6 +41,10 @@ export type SortableColumnDefMeta = { ___sortMetaData?: SortableColumnDef } +export type ActionColumnDefMeta = { + ___actions?: DataTableAction[] | DataTableAction[][] +} + export type DataTableColumnHelper = { accessor: < TAccessor extends AccessorFn | DeepKeys, diff --git a/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx index 0a238242fe04e..03be3b445f624 100644 --- a/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx +++ b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx @@ -33,7 +33,7 @@ const createDataTableColumnHelper = < const extendedMeta = { ___sortMetaData: { sortLabel, sortAscLabel, sortDescLabel }, - ...meta, + ...(meta || {}), } return accessorTanstack(accessor, { @@ -43,10 +43,14 @@ const createDataTableColumnHelper = < }) }, display, - action: (props: ActionColumnDef) => + action: ({ actions, ...props }: ActionColumnDef) => display({ id: "action", cell: (ctx) => , + meta: { + ___actions: actions, + ...(props.meta || {}), + }, ...props, }), select: (props: SelectColumnDef) => diff --git a/packages/design-system/ui/src/main.css b/packages/design-system/ui/src/main.css index 6c1817e9939d3..b0412db5677eb 100644 --- a/packages/design-system/ui/src/main.css +++ b/packages/design-system/ui/src/main.css @@ -3,8 +3,8 @@ @tailwind utilities; @layer base { - body { - @apply !bg-ui-bg-base; + :root { + @apply bg-ui-bg-subtle text-ui-fg-base antialiased; text-rendering: optimizeLegibility; } } From 63b59e044a0cdbbf53c4d505bf451b11db825f3a Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Tue, 5 Nov 2024 17:53:34 +0100 Subject: [PATCH 06/34] sticky header and columns --- .../components/data-table-filter-menu.tsx | 11 +- .../components/data-table-pagination.tsx | 1 + .../components/data-table-search.tsx | 3 + .../components/data-table-sorting-menu.tsx | 2 +- .../components/data-table-table.tsx | 218 +++++++++++++----- .../blocks/data-table/data-table.stories.tsx | 102 +++++++- .../ui/src/blocks/data-table/data-table.tsx | 2 +- .../ui/src/blocks/data-table/types.ts | 99 +++++++- .../src/blocks/data-table/use-data-table.tsx | 54 ++++- .../utils/create-data-table-column-helper.tsx | 9 +- .../utils/create-data-table-filter-helper.ts | 14 ++ .../ui/src/components/checkbox/checkbox.tsx | 6 +- .../components/icon-button/icon-button.tsx | 13 +- 13 files changed, 445 insertions(+), 89 deletions(-) create mode 100644 packages/design-system/ui/src/blocks/data-table/utils/create-data-table-filter-helper.ts diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx index 68483a0aa32c9..07e02019796c3 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx @@ -4,12 +4,15 @@ import { Funnel } from "@medusajs/icons" import { DropdownMenu } from "../../../components/dropdown-menu" import { IconButton } from "../../../components/icon-button" import { Tooltip } from "../../../components/tooltip" +import { useDataTableContext } from "../context/use-data-table-context" -interface DataTableFilterMenuProps { +export interface DataTableFilterMenuProps { tooltip?: string } const DataTableFilterMenu = ({ tooltip }: DataTableFilterMenuProps) => { + const { instance } = useDataTableContext() + const Wrapper = tooltip ? Tooltip : React.Fragment return ( @@ -21,7 +24,11 @@ const DataTableFilterMenu = ({ tooltip }: DataTableFilterMenuProps) => { - + + {instance.getFilterOptions().map((filter) => ( + {filter.label} + ))} + ) } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-pagination.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-pagination.tsx index 222a0558fd0ad..0f9987c2aef64 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-pagination.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-pagination.tsx @@ -11,6 +11,7 @@ const DataTablePagination = () => { return ( { return ( @@ -22,6 +24,7 @@ const DataTableSearch = ({ type="search" value={value} onChange={(e) => onValueChange(e.target.value)} + className={clx("w-full flex-1 md:flex-none", className)} {...props} /> ) diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx index 864ba7631eaf7..dce35635cfcb2 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-sorting-menu.tsx @@ -10,7 +10,7 @@ import { Tooltip } from "../../../components/tooltip" import { useDataTableContext } from "../context/use-data-table-context" import { SortableColumnDefMeta } from "../types" -interface DataTableSortingMenuProps { +export interface DataTableSortingMenuProps { tooltip?: string } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx index a410e7871857e..5322c80e834e7 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx @@ -11,8 +11,13 @@ const DataTableTable = () => { const [hoveredRowId, setHoveredRowId] = React.useState(null) const isKeyDown = React.useRef(false) + const [showStickyBorder, setShowStickyBorder] = React.useState(false) + const scrollableRef = React.useRef(null) + const { instance } = useDataTableContext() + const pageIndex = instance.pageIndex + const columns = instance.getAllColumns() const hasSelect = columns.find((c) => c.id === "select") @@ -58,73 +63,162 @@ const DataTableTable = () => { } }, [hoveredRowId, instance]) + const handleHorizontalScroll = (e: React.UIEvent) => { + const scrollLeft = e.currentTarget.scrollLeft + + if (scrollLeft > 0) { + setShowStickyBorder(true) + } else { + setShowStickyBorder(false) + } + } + + React.useEffect(() => { + scrollableRef.current?.scroll({ top: 0, left: 0 }) + }, [pageIndex]) + return ( -
+
- - - {instance.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const canSort = header.column.getCanSort() - const sortDirection = header.column.getIsSorted() - const sortHandler = header.column.getToggleSortingHandler() - - const isActionHeader = header.id === "action" - const isSelectHeader = header.id === "select" - const isSpecialHeader = isActionHeader || isSelectHeader - - const Wrapper = canSort ? "button" : "div" - - return ( - - - {flexRender( - header.column.columnDef.header, - header.getContext() - )} - {canSort && ( - - )} - - - ) - })} - - ))} - - - {instance.getRowModel().rows.map((row) => { - return ( +
+
+ + {instance.getHeaderGroups().map((headerGroup) => ( setHoveredRowId(row.id)} - onMouseLeave={() => setHoveredRowId(null)} + key={headerGroup.id} + className={clx("border-b-0", { + "[&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap": + hasActions, + "[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap": + hasSelect, + })} > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + {headerGroup.headers.map((header, idx) => { + const canSort = header.column.getCanSort() + const sortDirection = header.column.getIsSorted() + const sortHandler = header.column.getToggleSortingHandler() + + const isActionHeader = header.id === "action" + const isSelectHeader = header.id === "select" + const isSpecialHeader = isActionHeader || isSelectHeader + + const Wrapper = canSort ? "button" : "div" + const isFirstColumn = hasSelect ? idx === 1 : idx === 0 + + return ( + + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {canSort && ( + + )} + + + ) + })} - ) - })} - -
+ ))} + + + {instance.getRowModel().rows.map((row) => { + return ( + setHoveredRowId(row.id)} + onMouseLeave={() => setHoveredRowId(null)} + className="group/row last:border-b-0" + > + {row.getVisibleCells().map((cell, idx) => { + const isSelectCell = cell.column.id === "select" + const isActionCell = cell.column.id === "action" + const isSpecialCell = isSelectCell || isActionCell + + const isFirstColumn = hasSelect ? idx === 1 : idx === 0 + + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ) + })} + + ) + })} + + +
) } diff --git a/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx b/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx index 520f112616563..1f2740c9e9c3a 100644 --- a/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx +++ b/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx @@ -3,13 +3,15 @@ import * as React from "react" import { Container } from "@/components/container" import { PencilSquare, Trash } from "@medusajs/icons" -import { ColumnSort, RowSelectionState } from "@tanstack/react-table" +import { RowSelectionState } from "@tanstack/react-table" import { Button } from "../../components/button" import { Heading } from "../../components/heading" import { TooltipProvider } from "../../components/tooltip" import { DataTable } from "./data-table" +import { DataTableSortingState } from "./types" import { useDataTable } from "./use-data-table" import { createDataTableColumnHelper } from "./utils/create-data-table-column-helper" +import { createDataTableFilterHelper } from "./utils/create-data-table-filter-helper" const meta: Meta = { title: "Blocks/DataTable", @@ -24,6 +26,8 @@ type Person = { name: string email: string age: number + birthday: Date + relationshipStatus: "single" | "married" | "divorced" | "widowed" } const data: Person[] = [ @@ -31,16 +35,50 @@ const data: Person[] = [ name: "John Doe", email: "john.doe@example.com", age: 20, + birthday: new Date("1990-01-01"), + relationshipStatus: "single", }, { name: "Jane Doe", email: "jane.doe@example.com", age: 25, + birthday: new Date("1995-04-01"), + relationshipStatus: "married", }, { name: "John Smith", email: "john.smith@example.com", age: 30, + birthday: new Date("1990-05-01"), + relationshipStatus: "divorced", + }, + { + name: "Jane Smith", + email: "jane.smith@example.com", + age: 35, + birthday: new Date("1995-06-01"), + relationshipStatus: "widowed", + }, + { + name: "Mike Doe", + email: "mike.doe@example.com", + age: 40, + birthday: new Date("1990-07-01"), + relationshipStatus: "single", + }, + { + name: "Emily Smith", + email: "emily.smith@example.com", + age: 45, + birthday: new Date("1995-08-01"), + relationshipStatus: "married", + }, + { + name: "Sam Doe", + email: "sam.doe@example.com", + age: 50, + birthday: new Date("1990-09-01"), + relationshipStatus: "divorced", }, ] @@ -102,6 +140,23 @@ const columns = [ sortDescLabel: "High to Low", sortLabel: "Age", }), + columnHelper.accessor("birthday", { + header: "Birthday", + cell: ({ row }) => { + return ( +
+ {row.original.birthday.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + })} +
+ ) + }, + enableSorting: true, + sortAscLabel: "Oldest to Youngest", + sortDescLabel: "Youngest to Oldest", + }), columnHelper.action({ actions: [ [ @@ -122,6 +177,36 @@ const columns = [ }), ] +const filterHelper = createDataTableFilterHelper() + +const filters = [ + filterHelper.accessor("name", { + label: "Name", + type: "text", + }), + filterHelper.accessor("birthday", { + label: "Birthday", + type: "date", + format: "date", + options: [ + { label: "Today", value: new Date() }, + { label: "Yesterday", value: new Date(Date.now() - 24 * 60 * 60 * 1000) }, + { + label: "Last Week", + value: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + }, + { + label: "Last Month", + value: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + }, + { + label: "Last Year", + value: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000), + }, + ], + }), +] + const useDebouncedValue = (value: string, delay: number) => { const [debouncedValue, setDebouncedValue] = React.useState(value) const timerRef = React.useRef | null>(null) @@ -148,7 +233,9 @@ const BasicDemo = () => { const debouncedSearch = useDebouncedValue(search, 300) const [rowSelection, setRowSelection] = React.useState({}) - const [sorting, setSorting] = React.useState(null) + const [sorting, setSorting] = React.useState( + null + ) const { data, count } = usePeople({ q: debouncedSearch, order: sorting }) @@ -156,6 +243,7 @@ const BasicDemo = () => { data, columns, count, + filters, rowSelection: { state: rowSelection, onRowSelectionChange: setRowSelection, @@ -168,11 +256,11 @@ const BasicDemo = () => { return ( - + - + Employees -
+
{ /> - +
diff --git a/packages/design-system/ui/src/blocks/data-table/data-table.tsx b/packages/design-system/ui/src/blocks/data-table/data-table.tsx index eb5e461110fa3..a744210d28540 100644 --- a/packages/design-system/ui/src/blocks/data-table/data-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/data-table.tsx @@ -25,7 +25,7 @@ const Root = ({ }: DataTableProps) => { return ( -
+
{children}
diff --git a/packages/design-system/ui/src/blocks/data-table/types.ts b/packages/design-system/ui/src/blocks/data-table/types.ts index 2e6994b92ffe7..1676e9f0e104d 100644 --- a/packages/design-system/ui/src/blocks/data-table/types.ts +++ b/packages/design-system/ui/src/blocks/data-table/types.ts @@ -3,11 +3,13 @@ import type { AccessorFnColumnDef, AccessorKeyColumnDef, CellContext, + ColumnSort, DeepKeys, DeepValue, DisplayColumnDef, IdentifiedColumnDef, RowData, + RowSelectionState, } from "@tanstack/react-table" export type OrderByState = { @@ -28,6 +30,53 @@ export interface ActionColumnDef actions: DataTableAction[] | DataTableAction[][] } +type ColumnFilterType = "text" | "select" | "date" + +interface ColumnFilter { + type: ColumnFilterType +} + +interface SelectColumnFilter extends ColumnFilter { + type: "select" + multiple?: boolean + value: string + options: { + label: string + value: string + }[] +} + +interface TextColumnFilter extends ColumnFilter { + type: "text" + value: string +} + +interface SingleDateOption { + label: string + value: Date + type: "single" +} + +interface RangeDateOption { + label: string + value: { + from: Date + to: Date + } + type: "range" +} + +interface DateColumnFilter extends ColumnFilter { + type: "date" + value: Date + format: "date" | "date-time" + options: (SingleDateOption | RangeDateOption)[] +} + +export interface FilterableColumnDef { + filter?: SelectColumnFilter | TextColumnFilter | DateColumnFilter +} + export interface SelectColumnDef extends Omit, "id" | "header"> {} @@ -37,6 +86,10 @@ export type SortableColumnDef = { sortDescLabel?: string } +export type FilterableColumnDefMeta = { + ___filterMetaData?: ColumnFilter +} + export type SortableColumnDefMeta = { ___sortMetaData?: SortableColumnDef } @@ -45,7 +98,7 @@ export type ActionColumnDefMeta = { ___actions?: DataTableAction[] | DataTableAction[][] } -export type DataTableColumnHelper = { +export interface DataTableColumnHelper { accessor: < TAccessor extends AccessorFn | DeepKeys, TValue extends TAccessor extends AccessorFn @@ -56,8 +109,8 @@ export type DataTableColumnHelper = { >( accessor: TAccessor, column: TAccessor extends AccessorFn - ? DisplayColumnDef & SortableColumnDef - : IdentifiedColumnDef & SortableColumnDef + ? DisplayColumnDef & SortableColumnDef & FilterableColumnDef + : IdentifiedColumnDef & SortableColumnDef & FilterableColumnDef ) => TAccessor extends AccessorFn ? AccessorFnColumnDef : AccessorKeyColumnDef @@ -65,3 +118,43 @@ export type DataTableColumnHelper = { action: (props: ActionColumnDef) => DisplayColumnDef select: (props: SelectColumnDef) => DisplayColumnDef } + +export interface DataTableSortingState extends ColumnSort {} +export interface DataTableRowSelectionState extends RowSelectionState {} + +type FilterType = "text" | "radio" | "select" | "date" +type FilterOption = { + label: string + value: T +} + +interface BaseFilterProps { + type: FilterType + label: string +} + +interface TextFilterProps extends BaseFilterProps { + type: "text" +} + +interface RadioFilterProps extends BaseFilterProps { + type: "radio" + options: FilterOption[] +} + +interface SelectFilterProps extends BaseFilterProps { + type: "select" + options: FilterOption[] +} + +interface DateFilterProps extends BaseFilterProps { + type: "date" + format: "date" | "date-time" + options: FilterOption[] +} + +export type DataTableFilterProps = TextFilterProps | RadioFilterProps | SelectFilterProps | DateFilterProps + +export type DataTableFilter = T & { + id: string +} \ No newline at end of file diff --git a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx index a6ccfe1ab208e..4b97d3dc60f56 100644 --- a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx @@ -1,4 +1,5 @@ import { + ColumnFiltersState, type ColumnSort, getCoreRowModel, type Row, @@ -9,9 +10,15 @@ import { useReactTable, } from "@tanstack/react-table" import * as React from "react" +import { DataTableFilter, DataTableSortingState } from "./types" interface DataTableOptions extends Pick, "data" | "columns" | "getRowId"> { + filters?: DataTableFilter[] + filtering?: { + state: ColumnFiltersState + onFilteringChange: (state: ColumnFiltersState) => void + } rowSelection?: { state: RowSelectionState onRowSelectionChange: (state: RowSelectionState) => void @@ -39,16 +46,21 @@ interface UseDataTableReturn count: number pageIndex: number pageSize: number - getSorting: () => ColumnSort | null + getSorting: () => DataTableSortingState | null setSorting: ( - sortingOrUpdater: ColumnSort | ((prev: ColumnSort | null) => ColumnSort) + sortingOrUpdater: + | DataTableSortingState + | ((prev: DataTableSortingState | null) => DataTableSortingState) ) => void + getFilterOptions: () => DataTableFilter[] + getFiltering: () => ColumnFiltersState } const useDataTable = ({ count = 0, rowSelection, sorting, + filtering, ...options }: DataTableOptions): UseDataTableReturn => { const sortingStateHandler = React.useCallback( @@ -70,13 +82,26 @@ const useDataTable = ({ [rowSelection?.onRowSelectionChange, rowSelection?.state] ) + const filteringStateHandler = React.useCallback( + () => + filtering?.onFilteringChange + ? onFilteringChangeTransformer( + filtering.onFilteringChange, + filtering.state + ) + : undefined, + [filtering?.onFilteringChange, filtering?.state] + ) + const instance = useReactTable({ ...options, getCoreRowModel: getCoreRowModel(), state: { rowSelection: rowSelection?.state, sorting: sorting?.state ? [sorting.state] : undefined, + columnFilters: filtering?.state, }, + onColumnFiltersChange: filteringStateHandler(), onRowSelectionChange: rowSelectionStateHandler(), onSortingChange: sortingStateHandler(), // All data manipulation should be handled manually, likely by a server. @@ -104,6 +129,14 @@ const useDataTable = ({ [instance] ) + const getFilterOptions = React.useCallback(() => { + return options.filters ?? [] + }, [options.filters]) + + const getFiltering = React.useCallback(() => { + return instance.getState().columnFilters ?? [] + }, [instance]) + return { // Table getHeaderGroups: instance.getHeaderGroups, @@ -121,6 +154,9 @@ const useDataTable = ({ // Sorting getSorting, setSorting, + // Filtering + getFilterOptions, + getFiltering, } } @@ -153,5 +189,19 @@ function onRowSelectionChangeTransformer( } } +function onFilteringChangeTransformer( + onFilteringChange: (state: ColumnFiltersState) => void, + state?: ColumnFiltersState +) { + return (updaterOrValue: Updater) => { + const value = + typeof updaterOrValue === "function" + ? updaterOrValue(state ?? []) + : updaterOrValue + + onFilteringChange(value) + } +} + export { useDataTable } export type { DataTableOptions, UseDataTableReturn } diff --git a/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx index 03be3b445f624..ef3b39f359b2d 100644 --- a/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx +++ b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx @@ -10,8 +10,11 @@ import { import { ActionColumnDef, DataTableColumnHelper, + FilterableColumnDef, + FilterableColumnDefMeta, SelectColumnDef, SortableColumnDef, + SortableColumnDefMeta, } from "../types" const createDataTableColumnHelper = < @@ -26,13 +29,15 @@ const createDataTableColumnHelper = < sortLabel, sortAscLabel, sortDescLabel, + filter, meta, enableSorting, ...rest - } = column as any & SortableColumnDef + } = column as any & SortableColumnDef & FilterableColumnDef - const extendedMeta = { + const extendedMeta: SortableColumnDefMeta & FilterableColumnDefMeta = { ___sortMetaData: { sortLabel, sortAscLabel, sortDescLabel }, + ___filterMetaData: filter, ...(meta || {}), } diff --git a/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-filter-helper.ts b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-filter-helper.ts new file mode 100644 index 0000000000000..633e3e702d8d7 --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-filter-helper.ts @@ -0,0 +1,14 @@ +import { DeepKeys } from "@tanstack/react-table" +import { DataTableFilter, DataTableFilterProps } from "../types" + + +const createDataTableFilterHelper = () => ({ + accessor: (accessor: DeepKeys, props: DataTableFilterProps) => ({ + id: accessor, + ...props, + }), + custom: (props: DataTableFilter) => props, +}) + +export { createDataTableFilterHelper } + diff --git a/packages/design-system/ui/src/components/checkbox/checkbox.tsx b/packages/design-system/ui/src/components/checkbox/checkbox.tsx index a26d2e9626f90..9e53d954d3003 100644 --- a/packages/design-system/ui/src/components/checkbox/checkbox.tsx +++ b/packages/design-system/ui/src/components/checkbox/checkbox.tsx @@ -19,12 +19,12 @@ const Checkbox = React.forwardRef< ref={ref} checked={checked} className={clx( - "group relative inline-flex h-5 w-5 items-center justify-center outline-none ", + "group inline-flex h-5 w-5 items-center justify-center outline-none ", className )} > -
- +
+ {checked === "indeterminate" ? : }
diff --git a/packages/design-system/ui/src/components/icon-button/icon-button.tsx b/packages/design-system/ui/src/components/icon-button/icon-button.tsx index 06911d5f46de7..d6783f67e0b76 100644 --- a/packages/design-system/ui/src/components/icon-button/icon-button.tsx +++ b/packages/design-system/ui/src/components/icon-button/icon-button.tsx @@ -7,17 +7,16 @@ import { clx } from "@/utils/clx" const iconButtonVariants = cva({ base: clx( - "transition-fg relative inline-flex w-fit items-center justify-center overflow-hidden rounded-md outline-none", - "disabled:bg-ui-bg-disabled disabled:shadow-buttons-neutral disabled:text-ui-fg-disabled disabled:after:hidden" + "transition-fg inline-flex w-fit items-center justify-center overflow-hidden rounded-md outline-none", + "disabled:bg-ui-bg-disabled disabled:shadow-buttons-neutral disabled:text-ui-fg-disabled " ), variants: { variant: { primary: clx( - "shadow-buttons-neutral text-ui-fg-subtle bg-ui-button-neutral after:button-neutral-gradient", - "hover:bg-ui-button-neutral-hover hover:after:button-neutral-hover-gradient", - "active:bg-ui-button-neutral-pressed active:after:button-neutral-pressed-gradient", - "focus-visible:shadow-buttons-neutral-focus", - "after:absolute after:inset-0 after:content-['']" + "shadow-buttons-neutral text-ui-fg-subtle bg-ui-button-neutral", + "hover:bg-ui-button-neutral-hover", + "active:bg-ui-button-neutral-pressed", + "focus-visible:shadow-buttons-neutral-focus" ), transparent: clx( "text-ui-fg-subtle bg-ui-button-transparent", From 34ee59fa306e2e5c5157d30d0cf2a0245d822b97 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Wed, 6 Nov 2024 10:36:37 +0100 Subject: [PATCH 07/34] progress on filters --- .../components/data-table-action-cell.tsx | 6 +- .../components/data-table-filter-bar.tsx | 47 ++++++++++++++- .../components/data-table-filter-menu.tsx | 21 +++++-- .../components/data-table-filter.tsx | 46 +++++++++++++-- .../blocks/data-table/data-table.stories.tsx | 18 +++++- .../src/blocks/data-table/use-data-table.tsx | 59 ++++++++++++++++--- 6 files changed, 173 insertions(+), 24 deletions(-) diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-action-cell.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-action-cell.tsx index 8b40db3426a9c..9217e54547011 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-action-cell.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-action-cell.tsx @@ -37,12 +37,12 @@ const DataTableActionCell = ({ - {actions.map((actionOrGroup, index) => { + {actions.map((actionOrGroup, idx) => { const isArray = Array.isArray(actionOrGroup) - const isLast = index === actions.length - 1 + const isLast = idx === actions.length - 1 return isArray ? ( - + {actionOrGroup.map((action) => ( { + const { instance } = useDataTableContext() + + const filterState = instance.getFiltering() + + const getFilterLabel = React.useCallback( + (filter: ColumnFilter) => { + const filterOptions = instance.getFilterOptions() + const filterOption = filterOptions.find( + (option) => option.id === filter.id + ) + return filterOption?.label ?? filter.id + }, + [instance] + ) + + const clearFilters = React.useCallback(() => { + instance.clearFilters() + }, [instance]) + + if (Object.keys(filterState).length === 0) { + return null + } + return ( -
- +
+ {Object.values(filterState).map((filter) => ( + + ))} + {Object.keys(filterState).length > 0 ? ( + + ) : null}
) } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx index 07e02019796c3..6ed6d19e16f96 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx @@ -13,20 +13,33 @@ export interface DataTableFilterMenuProps { const DataTableFilterMenu = ({ tooltip }: DataTableFilterMenuProps) => { const { instance } = useDataTableContext() + const enabledFilters = Object.keys(instance.getFiltering()) + + const filterOptions = instance + .getFilterOptions() + .filter((filter) => !enabledFilters.includes(filter.id)) + const Wrapper = tooltip ? Tooltip : React.Fragment return ( - - + - {instance.getFilterOptions().map((filter) => ( - {filter.label} + {filterOptions.map((filter) => ( + { + instance.addFilter({ id: filter.id, value: undefined }) + }} + > + {filter.label} + ))} diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx index 93bc2cd4c0e4a..931334a173330 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx @@ -1,9 +1,34 @@ import { XMark } from "@medusajs/icons" +import { ColumnFilter } from "@tanstack/react-table" import * as React from "react" import { DropdownMenu } from "../../../components/dropdown-menu" import { clx } from "../../../utils/clx" +import { useDataTableContext } from "../context/use-data-table-context" + +interface DataTableFilterProps { + filter: ColumnFilter + label: string +} + +const DataTableFilter = ({ filter, label }: DataTableFilterProps) => { + const { instance } = useDataTableContext() + const [open, setOpen] = React.useState(filter.value === undefined) + + const onOpenChange = React.useCallback( + (open: boolean) => { + if (!open && !filter.value) { + instance.removeFilter(filter.id) + } + + setOpen(open) + }, + [instance, filter.id, filter.value] + ) + + const removeFilter = React.useCallback(() => { + instance.removeFilter(filter.id) + }, [instance, filter.id]) -const DataTableFilter = () => { return (
{ "[&>*]:txt-compact-small-plus [&>*]:flex [&>*]:items-center [&>*]:justify-center" )} > -
Filter
- +
{label}
+ @@ -25,11 +55,17 @@ const DataTableFilter = () => { interface DataTableFilterMenuProps { label: string + open: boolean + onOpenChange: (open: boolean) => void } -const DataTableFilterMenu = ({ label }: DataTableFilterMenuProps) => { +const DataTableFilterMenu = ({ + label, + open, + onOpenChange, +}: DataTableFilterMenuProps) => { return ( - + {label} diff --git a/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx b/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx index 1f2740c9e9c3a..35403efaab5f4 100644 --- a/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx +++ b/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { Container } from "@/components/container" import { PencilSquare, Trash } from "@medusajs/icons" -import { RowSelectionState } from "@tanstack/react-table" +import { ColumnFilter, RowSelectionState } from "@tanstack/react-table" import { Button } from "../../components/button" import { Heading } from "../../components/heading" import { TooltipProvider } from "../../components/tooltip" @@ -23,6 +23,7 @@ export default meta type Story = StoryObj type Person = { + id: string name: string email: string age: number @@ -32,6 +33,7 @@ type Person = { const data: Person[] = [ { + id: "1", name: "John Doe", email: "john.doe@example.com", age: 20, @@ -39,6 +41,7 @@ const data: Person[] = [ relationshipStatus: "single", }, { + id: "2", name: "Jane Doe", email: "jane.doe@example.com", age: 25, @@ -46,6 +49,7 @@ const data: Person[] = [ relationshipStatus: "married", }, { + id: "3", name: "John Smith", email: "john.smith@example.com", age: 30, @@ -53,6 +57,7 @@ const data: Person[] = [ relationshipStatus: "divorced", }, { + id: "4", name: "Jane Smith", email: "jane.smith@example.com", age: 35, @@ -60,6 +65,7 @@ const data: Person[] = [ relationshipStatus: "widowed", }, { + id: "5", name: "Mike Doe", email: "mike.doe@example.com", age: 40, @@ -67,6 +73,7 @@ const data: Person[] = [ relationshipStatus: "single", }, { + id: "6", name: "Emily Smith", email: "emily.smith@example.com", age: 45, @@ -74,6 +81,7 @@ const data: Person[] = [ relationshipStatus: "married", }, { + id: "7", name: "Sam Doe", email: "sam.doe@example.com", age: 50, @@ -236,6 +244,9 @@ const BasicDemo = () => { const [sorting, setSorting] = React.useState( null ) + const [filtering, setFiltering] = React.useState< + Record + >({}) const { data, count } = usePeople({ q: debouncedSearch, order: sorting }) @@ -243,7 +254,12 @@ const BasicDemo = () => { data, columns, count, + getRowId: (row) => row.id, filters, + filtering: { + state: filtering, + onFilteringChange: setFiltering, + }, rowSelection: { state: rowSelection, onRowSelectionChange: setRowSelection, diff --git a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx index 4b97d3dc60f56..8ce24fda89d80 100644 --- a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx @@ -1,4 +1,5 @@ import { + ColumnFilter, ColumnFiltersState, type ColumnSort, getCoreRowModel, @@ -16,8 +17,8 @@ interface DataTableOptions extends Pick, "data" | "columns" | "getRowId"> { filters?: DataTableFilter[] filtering?: { - state: ColumnFiltersState - onFilteringChange: (state: ColumnFiltersState) => void + state: Record + onFilteringChange: (state: Record) => void } rowSelection?: { state: RowSelectionState @@ -53,7 +54,11 @@ interface UseDataTableReturn | ((prev: DataTableSortingState | null) => DataTableSortingState) ) => void getFilterOptions: () => DataTableFilter[] - getFiltering: () => ColumnFiltersState + getFiltering: () => Record + addFilter: (filter: ColumnFilter) => void + removeFilter: (id: string) => void + clearFilters: () => void + updateFilter: (filter: ColumnFilter) => void } const useDataTable = ({ @@ -99,7 +104,7 @@ const useDataTable = ({ state: { rowSelection: rowSelection?.state, sorting: sorting?.state ? [sorting.state] : undefined, - columnFilters: filtering?.state, + columnFilters: Object.values(filtering?.state ?? {}), }, onColumnFiltersChange: filteringStateHandler(), onRowSelectionChange: rowSelectionStateHandler(), @@ -134,9 +139,37 @@ const useDataTable = ({ }, [options.filters]) const getFiltering = React.useCallback(() => { - return instance.getState().columnFilters ?? [] + const state = instance.getState().columnFilters ?? [] + return Object.fromEntries(state.map((filter) => [filter.id, filter])) }, [instance]) + const addFilter = React.useCallback( + (filter: ColumnFilter) => { + filtering?.onFilteringChange?.({ ...getFiltering(), [filter.id]: filter }) + }, + [filtering?.onFilteringChange, getFiltering] + ) + + const removeFilter = React.useCallback( + (id: string) => { + const currentFilters = getFiltering() + delete currentFilters[id] + filtering?.onFilteringChange?.(currentFilters) + }, + [filtering?.onFilteringChange, getFiltering] + ) + + const clearFilters = React.useCallback(() => { + filtering?.onFilteringChange?.({}) + }, [filtering?.onFilteringChange]) + + const updateFilter = React.useCallback( + (filter: ColumnFilter) => { + addFilter(filter) + }, + [addFilter] + ) + return { // Table getHeaderGroups: instance.getHeaderGroups, @@ -157,6 +190,10 @@ const useDataTable = ({ // Filtering getFilterOptions, getFiltering, + addFilter, + removeFilter, + clearFilters, + updateFilter, } } @@ -190,16 +227,20 @@ function onRowSelectionChangeTransformer( } function onFilteringChangeTransformer( - onFilteringChange: (state: ColumnFiltersState) => void, - state?: ColumnFiltersState + onFilteringChange: (state: Record) => void, + state?: Record ) { return (updaterOrValue: Updater) => { const value = typeof updaterOrValue === "function" - ? updaterOrValue(state ?? []) + ? updaterOrValue(Object.values(state ?? {})) : updaterOrValue - onFilteringChange(value) + const transformedValue = Object.fromEntries( + value.map((filter) => [filter.id, filter]) + ) + + onFilteringChange(transformedValue) } } From 77dd452acc0f1f04c379258bda10f82665b90e44 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Thu, 7 Nov 2024 15:00:22 +0100 Subject: [PATCH 08/34] progress --- .../data-table-filter/select-filter.tsx | 18 +- .../admin/dashboard/src/lib/client/client.ts | 36 +++ packages/design-system/ui/package.json | 1 + .../components/data-table-filter-bar.tsx | 11 +- .../components/data-table-filter-menu.tsx | 2 +- .../components/data-table-filter.tsx | 273 +++++++++++++--- .../components/data-table-search.tsx | 23 +- .../components/data-table-table.tsx | 300 +++++++++++------- .../blocks/data-table/data-table.stories.tsx | 177 ++++++++--- .../ui/src/blocks/data-table/index.ts | 2 + .../ui/src/blocks/data-table/types.ts | 79 +---- .../src/blocks/data-table/use-data-table.tsx | 87 ++++- .../utils/create-data-table-column-helper.tsx | 17 +- .../dropdown-menu/dropdown-menu.tsx | 21 +- packages/design-system/ui/src/index.ts | 4 + yarn.lock | 8 + 16 files changed, 740 insertions(+), 319 deletions(-) diff --git a/packages/admin/dashboard/src/components/table/data-table/data-table-filter/select-filter.tsx b/packages/admin/dashboard/src/components/table/data-table/data-table-filter/select-filter.tsx index 9244823e20579..38c1ce84e97df 100644 --- a/packages/admin/dashboard/src/components/table/data-table/data-table-filter/select-filter.tsx +++ b/packages/admin/dashboard/src/components/table/data-table/data-table-filter/select-filter.tsx @@ -7,8 +7,8 @@ import { useTranslation } from "react-i18next" import { useSelectedParams } from "../hooks" import { useDataTableFilterContext } from "./context" -import { IFilter } from "./types" import FilterChip from "./filter-chip" +import { IFilter } from "./types" interface SelectFilterProps extends IFilter { options: { label: string; value: unknown }[] @@ -41,7 +41,9 @@ export const SelectFilter = ({ .map((v) => options.find((o) => o.value === v)?.label) .filter(Boolean) as string[] - const [previousValue, setPreviousValue] = useState(labelValues) + const [previousValue, setPreviousValue] = useState< + string | string[] | undefined + >(labelValues) const handleRemove = () => { selectedParams.delete() @@ -84,8 +86,16 @@ export const SelectFilter = ({ } } - const normalizedValues = labelValues ? (Array.isArray(labelValues) ? labelValues : [labelValues]) : null - const normalizedPrev = previousValue ? (Array.isArray(previousValue) ? previousValue : [previousValue]) : null + const normalizedValues = labelValues + ? Array.isArray(labelValues) + ? labelValues + : [labelValues] + : null + const normalizedPrev = previousValue + ? Array.isArray(previousValue) + ? previousValue + : [previousValue] + : null return ( diff --git a/packages/admin/dashboard/src/lib/client/client.ts b/packages/admin/dashboard/src/lib/client/client.ts index d587805978358..c1ec8a073564a 100644 --- a/packages/admin/dashboard/src/lib/client/client.ts +++ b/packages/admin/dashboard/src/lib/client/client.ts @@ -13,3 +13,39 @@ export const sdk = new Medusa({ if (typeof window !== "undefined") { ;(window as any).__sdk = sdk } + +sdk.admin.product.create({ + title: "Medusa Shirt", + options: [ + { + title: "Color", + values: [ + "Red", + "Blue", + "Green", + "Yellow", + "Purple", + "Orange", + "Pink", + "Gray", + "Brown", + "Navy", + "Teal", + ], + }, + ], + variants: [ + { + title: "Red Shirt", + options: { + Color: "Red", + }, + prices: [ + { + amount: 1000, + currency_code: "eur", + }, + ], + }, + ], +}) diff --git a/packages/design-system/ui/package.json b/packages/design-system/ui/package.json index 4220ed7842fba..4611629e2010c 100644 --- a/packages/design-system/ui/package.json +++ b/packages/design-system/ui/package.json @@ -42,6 +42,7 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { + "@faker-js/faker": "^9.2.0", "@medusajs/ui-preset": "^2.0.1", "@storybook/addon-essentials": "^8.3.5", "@storybook/addon-interactions": "^8.3.5", diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-bar.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-bar.tsx index 39bb6d5f93adc..9da084b85ed6a 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-bar.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-bar.tsx @@ -1,6 +1,9 @@ +"use client" + import { ColumnFilter } from "@tanstack/react-table" import * as React from "react" -import { Button } from "../../../components/button" + +import { Button } from "@/components/button" import { useDataTableContext } from "../context/use-data-table-context" import { DataTableFilter } from "./data-table-filter" @@ -11,7 +14,7 @@ const DataTableFilterBar = () => { const getFilterLabel = React.useCallback( (filter: ColumnFilter) => { - const filterOptions = instance.getFilterOptions() + const filterOptions = instance.getFilters() const filterOption = filterOptions.find( (option) => option.id === filter.id ) @@ -29,7 +32,7 @@ const DataTableFilterBar = () => { } return ( -
+
{Object.values(filterState).map((filter) => ( { -
+ {displayValue && ( +
+ {label} +
+ )} + + {displayValue || label} + + + {displayValue && ( + + )} +
+ + {(() => { + switch (type) { + case "select": + return ( + []} + /> + ) + case "radio": + return ( + []} + /> + ) + case "date": + return ( + []} + /> + ) + case "text": + return + default: + return null + } + })()} + +
) } -interface DataTableFilterMenuProps { - label: string +interface DataTableFilterDropdownProps { + filter: ColumnFilter + options: FilterOption[] | null + type: FilterType open: boolean onOpenChange: (open: boolean) => void } -const DataTableFilterMenu = ({ - label, - open, - onOpenChange, -}: DataTableFilterMenuProps) => { +type DataTableFilterDateContentProps = { + filter: ColumnFilter + options: FilterOption[] +} + +const DataTableFilterDateContent = ({ + filter, + options, +}: DataTableFilterDateContentProps) => { return ( - - - {label} - - - - Option 1 - Option 2 - Option 3 - - - + + {options.map((option, idx) => { + return ( + + {option.label} + + ) + })} + + ) +} + +type DataTableFilterTextContentProps = { + filter: ColumnFilter +} + +const DataTableFilterTextContent = ({ + filter, +}: DataTableFilterTextContentProps) => { + return +} + +type DataTableFilterSelectContentProps = { + filter: ColumnFilter + options: FilterOption[] +} + +const DataTableFilterSelectContent = ({ + filter, + options, +}: DataTableFilterSelectContentProps) => { + const { instance } = useDataTableContext() + + const currentValue = filter.value as string[] | undefined + + const getChecked = React.useCallback( + (value: string) => { + return (checked: boolean) => { + if (!checked) { + const newValues = currentValue?.filter((v) => v !== value) + instance.updateFilter({ + ...filter, + value: newValues, + }) + + return + } + + instance.updateFilter({ + ...filter, + value: [...(currentValue ?? []), value], + }) + } + }, + [instance, filter] + ) + + return ( + + {options.map((option) => { + return ( + e.preventDefault()} + key={option.value} + checked={currentValue?.includes(option.value)} + onCheckedChange={getChecked(option.value)} + > + {option.label} + + ) + })} + + ) +} + +type DataTableFilterRadioContentProps = { + filter: ColumnFilter + options: FilterOption[] +} + +const DataTableFilterRadioContent = ({ + filter, + options, +}: DataTableFilterRadioContentProps) => { + const { instance } = useDataTableContext() + + const onValueChange = React.useCallback( + (value: string) => { + instance.updateFilter({ ...filter, value }) + }, + [instance, filter] + ) + + return ( + + {options.map((option) => { + return ( + + {option.label} + + ) + })} + ) } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-search.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-search.tsx index 17c561df58bb5..5b7bc46e047a9 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-search.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-search.tsx @@ -3,28 +3,29 @@ import { Input } from "@/components/input" import * as React from "react" import { clx } from "../../../utils/clx" +import { useDataTableContext } from "../context/use-data-table-context" interface DataTableSearchProps { - value: string - onValueChange: (value: string) => void autoFocus?: boolean className?: string placeholder?: string } -const DataTableSearch = ({ - value, - onValueChange, - className, - ...props -}: DataTableSearchProps) => { +const DataTableSearch = ({ className, ...props }: DataTableSearchProps) => { + const { instance } = useDataTableContext() + return ( onValueChange(e.target.value)} - className={clx("w-full flex-1 md:flex-none", className)} + value={instance.search} + onChange={(e) => instance.onSearchChange(e.target.value)} + className={clx( + { + "pr-[calc(15px+2px+8px)]": instance.isLoading, + }, + className + )} {...props} /> ) diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx index 5322c80e834e7..2f1d6144827e6 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx @@ -2,12 +2,29 @@ import * as React from "react" import { Table } from "@/components/table" import { flexRender } from "@tanstack/react-table" +import { Text } from "../../../components/text" import { clx } from "../../../utils/clx" import { useDataTableContext } from "../context/use-data-table-context" +import { DataTableEmptyState } from "../types" import { DataTableFilterBar } from "./data-table-filter-bar" import { DataTableSortingIcon } from "./data-table-sorting-icon" -const DataTableTable = () => { +type EmptyStateContent = { + heading?: string + description?: string + custom?: React.ReactNode +} + +type DataTableEmptyStateProps = { + filtered?: EmptyStateContent + empty?: EmptyStateContent +} + +interface DataTableTableProps { + emptyState?: DataTableEmptyStateProps +} + +const DataTableTable = ({ emptyState }: DataTableTableProps) => { const [hoveredRowId, setHoveredRowId] = React.useState(null) const isKeyDown = React.useRef(false) @@ -23,9 +40,6 @@ const DataTableTable = () => { const hasSelect = columns.find((c) => c.id === "select") const hasActions = columns.find((c) => c.id === "action") - const colCount = columns.length - (hasSelect ? 1 : 0) - (hasActions ? 1 : 0) - const colWidth = 100 / colCount - React.useEffect(() => { const onKeyDownHandler = (event: KeyboardEvent) => { // If an editable element is focused, we don't want to select a row @@ -80,145 +94,191 @@ const DataTableTable = () => { return (
-
- - - {instance.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header, idx) => { - const canSort = header.column.getCanSort() - const sortDirection = header.column.getIsSorted() - const sortHandler = header.column.getToggleSortingHandler() - - const isActionHeader = header.id === "action" - const isSelectHeader = header.id === "select" - const isSpecialHeader = isActionHeader || isSelectHeader - - const Wrapper = canSort ? "button" : "div" - const isFirstColumn = hasSelect ? idx === 1 : idx === 0 - - return ( - - - {flexRender( - header.column.columnDef.header, - header.getContext() - )} - {canSort && ( - - )} - - - ) - })} - - ))} - - - {instance.getRowModel().rows.map((row) => { - return ( + {instance.emptyState === DataTableEmptyState.POPULATED && ( +
+
+ + {instance.getHeaderGroups().map((headerGroup) => ( setHoveredRowId(row.id)} - onMouseLeave={() => setHoveredRowId(null)} - className="group/row last:border-b-0" + key={headerGroup.id} + className={clx("border-b-0", { + "[&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap": + hasActions, + "[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap": + hasSelect, + })} > - {row.getVisibleCells().map((cell, idx) => { - const isSelectCell = cell.column.id === "select" - const isActionCell = cell.column.id === "action" - const isSpecialCell = isSelectCell || isActionCell + {headerGroup.headers.map((header, idx) => { + const canSort = header.column.getCanSort() + const sortDirection = header.column.getIsSorted() + const sortHandler = header.column.getToggleSortingHandler() + const isActionHeader = header.id === "action" + const isSelectHeader = header.id === "select" + const isSpecialHeader = isActionHeader || isSelectHeader + + const Wrapper = canSort ? "button" : "div" const isFirstColumn = hasSelect ? idx === 1 : idx === 0 return ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {canSort && ( + + )} + + ) })} - ) - })} - -
-
+ ))} + + + {instance.getRowModel().rows.map((row) => { + return ( + setHoveredRowId(row.id)} + onMouseLeave={() => setHoveredRowId(null)} + className="group/row last:border-b-0" + > + {row.getVisibleCells().map((cell, idx) => { + const isSelectCell = cell.column.id === "select" + const isActionCell = cell.column.id === "action" + const isSpecialCell = isSelectCell || isActionCell + + const isFirstColumn = hasSelect ? idx === 1 : idx === 0 + + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ) + })} + + ) + })} + + +
+ )} + +
+ ) +} + +interface DataTableEmptyStateDisplayProps { + state: DataTableEmptyState + props?: DataTableEmptyStateProps +} + +const DefaultEmptyStateContent = ({ + heading, + description, +}: EmptyStateContent) => ( +
+ + {heading} + + {description} +
+) + +const DataTableEmptyStateDisplay = ({ + state, + props, +}: DataTableEmptyStateDisplayProps) => { + if (state === DataTableEmptyState.POPULATED) { + return null + } + + const content = + state === DataTableEmptyState.EMPTY ? props?.empty : props?.filtered + + return ( +
+ {content?.custom ?? ( + + )}
) } diff --git a/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx b/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx index 35403efaab5f4..9c4f895067d1f 100644 --- a/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx +++ b/packages/design-system/ui/src/blocks/data-table/data-table.stories.tsx @@ -31,6 +31,27 @@ type Person = { relationshipStatus: "single" | "married" | "divorced" | "widowed" } +const useDebouncedValue = (value: string, delay: number) => { + const [debouncedValue, setDebouncedValue] = React.useState(value) + const timerRef = React.useRef | null>(null) + + React.useEffect(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + + timerRef.current = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, [value]) + + return debouncedValue +} + const data: Person[] = [ { id: "1", @@ -93,42 +114,75 @@ const data: Person[] = [ const usePeople = ({ q, order, + filters, }: { q?: string order?: { id: string; desc: boolean } | null + filters?: Record }) => { return React.useMemo(() => { - const filteredData = data.filter((person) => - person.name.toLowerCase().includes(q?.toLowerCase() ?? "") - ) - - if (!order) { - return { - data: filteredData, - count: filteredData.length, - } + let results = [...data] // Create a copy to avoid mutating original data + + // Apply free text search + if (q) { + results = results.filter((person) => + person.name.toLowerCase().includes(q.toLowerCase()) + ) } - const key = order.id as keyof Person - const desc = order.desc + // Apply filters + if (filters && Object.keys(filters).length > 0) { + results = results.filter((person) => { + return Object.entries(filters).every(([key, filter]) => { + if (!filter.value) return true + + const value = person[key as keyof Person] - const sortedData = filteredData.sort((a, b) => { - if (a[key] < b[key]) return desc ? 1 : -1 - if (a[key] > b[key]) return order.desc ? -1 : 1 - return 0 - }) + if (value instanceof Date && filter.value instanceof Date) { + return value.getTime() === filter.value.getTime() + } + + if (Array.isArray(filter.value)) { + return filter.value.includes(value) + } + + return filter.value === value + }) + }) + } + + // Apply sorting + if (order) { + const key = order.id as keyof Person + const desc = order.desc + + results.sort((a, b) => { + const aVal = a[key] + const bVal = b[key] + + if (aVal instanceof Date && bVal instanceof Date) { + return desc + ? bVal.getTime() - aVal.getTime() + : aVal.getTime() - bVal.getTime() + } + + if (aVal < bVal) return desc ? 1 : -1 + if (aVal > bVal) return desc ? -1 : 1 + return 0 + }) + } return { - data: sortedData, - count: sortedData.length, + data: results, + count: results.length, } - }, [q, order]) + }, [q, order, filters]) // Add filters to dependencies } const columnHelper = createDataTableColumnHelper() const columns = [ - columnHelper.select({}), + columnHelper.select(), columnHelper.accessor("name", { header: "Name", enableSorting: true, @@ -148,6 +202,20 @@ const columns = [ sortDescLabel: "High to Low", sortLabel: "Age", }), + columnHelper.accessor("relationshipStatus", { + header: "Relationship Status", + cell: ({ row }) => { + return ( +
+ {row.original.relationshipStatus.charAt(0).toUpperCase() + + row.original.relationshipStatus.slice(1)} +
+ ) + }, + enableSorting: true, + sortAscLabel: "A-Z", + sortDescLabel: "Z-A", + }), columnHelper.accessor("birthday", { header: "Birthday", cell: ({ row }) => { @@ -213,30 +281,19 @@ const filters = [ }, ], }), + filterHelper.accessor("relationshipStatus", { + label: "Relationship Status", + type: "select", + options: [ + { label: "Single", value: "single" }, + { label: "Married", value: "married" }, + { label: "Divorced", value: "divorced" }, + { label: "Widowed", value: "widowed" }, + ], + }), ] -const useDebouncedValue = (value: string, delay: number) => { - const [debouncedValue, setDebouncedValue] = React.useState(value) - const timerRef = React.useRef | null>(null) - - React.useEffect(() => { - if (timerRef.current) { - clearTimeout(timerRef.current) - } - - timerRef.current = setTimeout(() => { - setDebouncedValue(value) - }, delay) - - return () => { - if (timerRef.current) clearTimeout(timerRef.current) - } - }, [value]) - - return debouncedValue -} - -const BasicDemo = () => { +const KitchenSinkDemo = () => { const [search, setSearch] = React.useState("") const debouncedSearch = useDebouncedValue(search, 300) @@ -248,14 +305,23 @@ const BasicDemo = () => { Record >({}) - const { data, count } = usePeople({ q: debouncedSearch, order: sorting }) + const { data, count } = usePeople({ + q: debouncedSearch, + order: sorting, + filters: filtering, + }) const table = useDataTable({ data, columns, count, + isLoading: true, getRowId: (row) => row.id, filters, + search: { + state: search, + onSearchChange: setSearch, + }, filtering: { state: filtering, onFilteringChange: setFiltering, @@ -277,12 +343,7 @@ const BasicDemo = () => { Employees
- +
- + @@ -298,6 +371,6 @@ const BasicDemo = () => { ) } -export const Basic: Story = { - render: () => , +export const KitchenSink: Story = { + render: () => , } diff --git a/packages/design-system/ui/src/blocks/data-table/index.ts b/packages/design-system/ui/src/blocks/data-table/index.ts index b35c56cab38b0..8581bce7d5400 100644 --- a/packages/design-system/ui/src/blocks/data-table/index.ts +++ b/packages/design-system/ui/src/blocks/data-table/index.ts @@ -1,3 +1,5 @@ export * from "./data-table" export * from "./use-data-table" export * from "./utils/create-data-table-column-helper" +export * from "./utils/create-data-table-filter-helper" + diff --git a/packages/design-system/ui/src/blocks/data-table/types.ts b/packages/design-system/ui/src/blocks/data-table/types.ts index 1676e9f0e104d..f8fd18623cf84 100644 --- a/packages/design-system/ui/src/blocks/data-table/types.ts +++ b/packages/design-system/ui/src/blocks/data-table/types.ts @@ -30,55 +30,8 @@ export interface ActionColumnDef actions: DataTableAction[] | DataTableAction[][] } -type ColumnFilterType = "text" | "select" | "date" - -interface ColumnFilter { - type: ColumnFilterType -} - -interface SelectColumnFilter extends ColumnFilter { - type: "select" - multiple?: boolean - value: string - options: { - label: string - value: string - }[] -} - -interface TextColumnFilter extends ColumnFilter { - type: "text" - value: string -} - -interface SingleDateOption { - label: string - value: Date - type: "single" -} - -interface RangeDateOption { - label: string - value: { - from: Date - to: Date - } - type: "range" -} - -interface DateColumnFilter extends ColumnFilter { - type: "date" - value: Date - format: "date" | "date-time" - options: (SingleDateOption | RangeDateOption)[] -} - -export interface FilterableColumnDef { - filter?: SelectColumnFilter | TextColumnFilter | DateColumnFilter -} - export interface SelectColumnDef - extends Omit, "id" | "header"> {} + extends Pick, "cell" | "header"> {} export type SortableColumnDef = { sortLabel?: string @@ -86,10 +39,6 @@ export type SortableColumnDef = { sortDescLabel?: string } -export type FilterableColumnDefMeta = { - ___filterMetaData?: ColumnFilter -} - export type SortableColumnDefMeta = { ___sortMetaData?: SortableColumnDef } @@ -109,21 +58,21 @@ export interface DataTableColumnHelper { >( accessor: TAccessor, column: TAccessor extends AccessorFn - ? DisplayColumnDef & SortableColumnDef & FilterableColumnDef - : IdentifiedColumnDef & SortableColumnDef & FilterableColumnDef + ? DisplayColumnDef & SortableColumnDef + : IdentifiedColumnDef & SortableColumnDef ) => TAccessor extends AccessorFn ? AccessorFnColumnDef : AccessorKeyColumnDef display: (column: DisplayColumnDef) => DisplayColumnDef action: (props: ActionColumnDef) => DisplayColumnDef - select: (props: SelectColumnDef) => DisplayColumnDef + select: (props?: SelectColumnDef) => DisplayColumnDef } export interface DataTableSortingState extends ColumnSort {} export interface DataTableRowSelectionState extends RowSelectionState {} -type FilterType = "text" | "radio" | "select" | "date" -type FilterOption = { +export type FilterType = "text" | "radio" | "select" | "date" +export type FilterOption = { label: string value: T } @@ -133,21 +82,21 @@ interface BaseFilterProps { label: string } -interface TextFilterProps extends BaseFilterProps { +export interface TextFilterProps extends BaseFilterProps { type: "text" } -interface RadioFilterProps extends BaseFilterProps { +export interface RadioFilterProps extends BaseFilterProps { type: "radio" options: FilterOption[] } -interface SelectFilterProps extends BaseFilterProps { +export interface SelectFilterProps extends BaseFilterProps { type: "select" options: FilterOption[] } -interface DateFilterProps extends BaseFilterProps { +export interface DateFilterProps extends BaseFilterProps { type: "date" format: "date" | "date-time" options: FilterOption[] @@ -157,4 +106,10 @@ export type DataTableFilterProps = TextFilterProps | RadioFilterProps | SelectFi export type DataTableFilter = T & { id: string -} \ No newline at end of file +} + +export enum DataTableEmptyState { + EMPTY = "EMPTY", + FILTERED_EMPTY = "FILTERED_EMPTY", + POPULATED = "POPULATED", +} diff --git a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx index 8ce24fda89d80..1465233843ac2 100644 --- a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx @@ -11,10 +11,23 @@ import { useReactTable, } from "@tanstack/react-table" import * as React from "react" -import { DataTableFilter, DataTableSortingState } from "./types" +import { + DataTableEmptyState, + DataTableFilter, + DataTableSortingState, + FilterOption, + FilterType, +} from "./types" interface DataTableOptions extends Pick, "data" | "columns" | "getRowId"> { + /** + * Whether the data for the table is currently being loaded. + */ + isLoading?: boolean + /** + * The filters which the user can apply to the table. + */ filters?: DataTableFilter[] filtering?: { state: Record @@ -28,7 +41,11 @@ interface DataTableOptions state: ColumnSort | null onSortingChange: (state: ColumnSort) => void } - onClickRow?: (row: Row) => void + search?: { + state: string + onSearchChange: (state: string) => void + } + onRowClick?: (row: Row) => void count?: number } @@ -47,18 +64,27 @@ interface UseDataTableReturn count: number pageIndex: number pageSize: number + search: string + onSearchChange: (search: string) => void getSorting: () => DataTableSortingState | null setSorting: ( sortingOrUpdater: | DataTableSortingState | ((prev: DataTableSortingState | null) => DataTableSortingState) ) => void - getFilterOptions: () => DataTableFilter[] + getFilters: () => DataTableFilter[] + getFilterOptions: ( + id: string + ) => FilterOption[] | null + getFilterType: (id: string) => FilterType | null getFiltering: () => Record addFilter: (filter: ColumnFilter) => void removeFilter: (id: string) => void clearFilters: () => void updateFilter: (filter: ColumnFilter) => void + emptyState: DataTableEmptyState + isLoading: boolean + showSkeleton: boolean } const useDataTable = ({ @@ -66,6 +92,7 @@ const useDataTable = ({ rowSelection, sorting, filtering, + onRowClick, ...options }: DataTableOptions): UseDataTableReturn => { const sortingStateHandler = React.useCallback( @@ -134,10 +161,30 @@ const useDataTable = ({ [instance] ) - const getFilterOptions = React.useCallback(() => { + const getFilters = React.useCallback(() => { return options.filters ?? [] }, [options.filters]) + const getFilterOptions = React.useCallback( + (id: string) => { + const filter = getFilters().find((filter) => filter.id === id) + + if (!filter || filter.type === "text") { + return null + } + + return filter.options as FilterOption[] + }, + [getFilters] + ) + + const getFilterType = React.useCallback( + (id: string) => { + return getFilters().find((filter) => filter.id === id)?.type || null + }, + [getFilters] + ) + const getFiltering = React.useCallback(() => { const state = instance.getState().columnFilters ?? [] return Object.fromEntries(state.map((filter) => [filter.id, filter])) @@ -170,6 +217,28 @@ const useDataTable = ({ [addFilter] ) + const rows = instance.getRowModel().rows + + const emptyState = React.useMemo(() => { + const hasRows = rows.length > 0 + const hasSearch = Boolean(options.search?.state) + const hasFilters = Object.keys(filtering?.state ?? {}).length > 0 + + if (hasRows) { + return DataTableEmptyState.POPULATED + } + + if (hasSearch || hasFilters) { + return DataTableEmptyState.FILTERED_EMPTY + } + + return DataTableEmptyState.EMPTY + }, [rows, options.search?.state, filtering?.state]) + + const showSkeleton = React.useMemo(() => { + return options.isLoading === true && rows.length === 0 + }, [options.isLoading, rows]) + return { // Table getHeaderGroups: instance.getHeaderGroups, @@ -184,16 +253,26 @@ const useDataTable = ({ pageIndex: instance.getState().pagination.pageIndex, pageSize: instance.getState().pagination.pageSize, count, + // Search + search: options.search?.state ?? "", + onSearchChange: options.search?.onSearchChange ?? (() => {}), // Sorting getSorting, setSorting, // Filtering + getFilters, getFilterOptions, + getFilterType, getFiltering, addFilter, removeFilter, clearFilters, updateFilter, + // Empty State + emptyState, + // Loading + isLoading: options.isLoading ?? false, + showSkeleton, } } diff --git a/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx index ef3b39f359b2d..55883afbb22c5 100644 --- a/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx +++ b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-column-helper.tsx @@ -10,8 +10,6 @@ import { import { ActionColumnDef, DataTableColumnHelper, - FilterableColumnDef, - FilterableColumnDefMeta, SelectColumnDef, SortableColumnDef, SortableColumnDefMeta, @@ -29,15 +27,13 @@ const createDataTableColumnHelper = < sortLabel, sortAscLabel, sortDescLabel, - filter, meta, enableSorting, ...rest - } = column as any & SortableColumnDef & FilterableColumnDef + } = column as any & SortableColumnDef - const extendedMeta: SortableColumnDefMeta & FilterableColumnDefMeta = { + const extendedMeta: SortableColumnDefMeta = { ___sortMetaData: { sortLabel, sortAscLabel, sortDescLabel }, - ___filterMetaData: filter, ...(meta || {}), } @@ -58,14 +54,15 @@ const createDataTableColumnHelper = < }, ...props, }), - select: (props: SelectColumnDef) => + select: (props?: SelectColumnDef) => display({ id: "select", - header: (ctx) => , - cell: props.cell + header: props?.header + ? props.header + : (ctx) => , + cell: props?.cell ? props.cell : (ctx) => , - ...props, }), } } diff --git a/packages/design-system/ui/src/components/dropdown-menu/dropdown-menu.tsx b/packages/design-system/ui/src/components/dropdown-menu/dropdown-menu.tsx index e0388f76d4f1c..1396c22f22e4d 100644 --- a/packages/design-system/ui/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/design-system/ui/src/components/dropdown-menu/dropdown-menu.tsx @@ -48,7 +48,7 @@ const SubMenuTrigger = React.forwardRef< className={clx( "bg-ui-bg-component text-ui-fg-base txt-compact-small relative flex cursor-pointer select-none items-center rounded-md px-2 py-1.5 outline-none transition-colors", "focus-visible:bg-ui-bg-component-hover focus:bg-ui-bg-component-hover", - "active:bg-ui-bg-component-pressed", + "active:bg-ui-bg-component-hover", "data-[disabled]:text-ui-fg-disabled data-[disabled]:pointer-events-none", "data-[state=open]:!bg-ui-bg-component-hover", className @@ -56,7 +56,7 @@ const SubMenuTrigger = React.forwardRef< {...props} > {children} - + )) SubMenuTrigger.displayName = "DropdownMenu.SubMenuTrigger" @@ -130,7 +130,7 @@ const Item = React.forwardRef< className={clx( "bg-ui-bg-component text-ui-fg-base txt-compact-small relative flex cursor-pointer select-none items-center rounded-md px-2 py-1.5 outline-none transition-colors", "focus-visible:bg-ui-bg-component-hover focus:bg-ui-bg-component-hover", - "active:bg-ui-bg-component-pressed", + "active:bg-ui-bg-component-hover", "data-[disabled]:text-ui-fg-disabled data-[disabled]:pointer-events-none", className )} @@ -149,9 +149,9 @@ const CheckboxItem = React.forwardRef< (({ className, ...props }, ref) => ( )) diff --git a/packages/design-system/ui/src/index.ts b/packages/design-system/ui/src/index.ts index 526663311428d..61f549712f2cc 100644 --- a/packages/design-system/ui/src/index.ts +++ b/packages/design-system/ui/src/index.ts @@ -1,3 +1,4 @@ +// Components export { Alert } from "./components/alert" export { Avatar } from "./components/avatar" export { Badge } from "./components/badge" @@ -39,6 +40,9 @@ export { Toast } from "./components/toast" export { Toaster } from "./components/toaster" export { Tooltip, TooltipProvider } from "./components/tooltip" +// Blocks +export * from "./blocks/data-table" + // Hooks export { usePrompt } from "./hooks/use-prompt" export { useToggleState } from "./hooks/use-toggle-state" diff --git a/yarn.lock b/yarn.lock index f1b491bb3d1d7..11823869faf4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4209,6 +4209,13 @@ __metadata: languageName: node linkType: hard +"@faker-js/faker@npm:^9.2.0": + version: 9.2.0 + resolution: "@faker-js/faker@npm:9.2.0" + checksum: d711a5d206558f90e3ce9ecafe366e236fbe190b4df9d3968b512ccb87ec625843c919d16050beade88b790ed3df6332f6a837e41fba6de33e7a2f8daa67f08d + languageName: node + linkType: hard + "@floating-ui/core@npm:^1.0.0": version: 1.6.1 resolution: "@floating-ui/core@npm:1.6.1" @@ -6650,6 +6657,7 @@ __metadata: version: 0.0.0-use.local resolution: "@medusajs/ui@workspace:packages/design-system/ui" dependencies: + "@faker-js/faker": ^9.2.0 "@medusajs/icons": ^2.0.1 "@medusajs/ui-preset": ^2.0.1 "@radix-ui/react-accordion": 1.2.0 From cdd7e9353b7d3815e8f3f007049a6fe29c213b3d Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Thu, 7 Nov 2024 15:21:21 +0100 Subject: [PATCH 09/34] cleanp --- .../admin/dashboard/src/lib/client/client.ts | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/packages/admin/dashboard/src/lib/client/client.ts b/packages/admin/dashboard/src/lib/client/client.ts index c1ec8a073564a..d587805978358 100644 --- a/packages/admin/dashboard/src/lib/client/client.ts +++ b/packages/admin/dashboard/src/lib/client/client.ts @@ -13,39 +13,3 @@ export const sdk = new Medusa({ if (typeof window !== "undefined") { ;(window as any).__sdk = sdk } - -sdk.admin.product.create({ - title: "Medusa Shirt", - options: [ - { - title: "Color", - values: [ - "Red", - "Blue", - "Green", - "Yellow", - "Purple", - "Orange", - "Pink", - "Gray", - "Brown", - "Navy", - "Teal", - ], - }, - ], - variants: [ - { - title: "Red Shirt", - options: { - Color: "Red", - }, - prices: [ - { - amount: 1000, - currency_code: "eur", - }, - ], - }, - ], -}) From 6648f04ed2daa52dbbfafba58e5c7569f8358ae0 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Fri, 8 Nov 2024 12:27:01 +0100 Subject: [PATCH 10/34] work on setting up component in admin --- .eslintrc.js | 19 +- .prettierrc | 8 +- .../src/components/data-table/data-table.tsx | 69 ++++ .../src/components/data-table/index.ts | 1 + .../components/data-table-command-bar.tsx | 59 ++++ .../components/data-table-filter.tsx | 70 +++- .../components/data-table-pagination.tsx | 10 +- .../components/data-table-search.tsx | 2 +- .../components/data-table-table.tsx | 40 ++- .../blocks/data-table/data-table.stories.tsx | 326 +++++++++++------- .../ui/src/blocks/data-table/data-table.tsx | 2 + .../ui/src/blocks/data-table/index.ts | 8 + .../ui/src/blocks/data-table/types.ts | 35 +- .../src/blocks/data-table/use-data-table.tsx | 272 +++++++++++---- .../utils/create-data-table-command-helper.ts | 8 + .../utils/is-date-comparison-operator.ts | 19 + .../components/command-bar/command-bar.tsx | 2 +- .../ui/src/components/table/table.tsx | 7 +- 18 files changed, 727 insertions(+), 230 deletions(-) create mode 100644 packages/admin/dashboard/src/components/data-table/data-table.tsx create mode 100644 packages/admin/dashboard/src/components/data-table/index.ts create mode 100644 packages/design-system/ui/src/blocks/data-table/components/data-table-command-bar.tsx create mode 100644 packages/design-system/ui/src/blocks/data-table/utils/create-data-table-command-helper.ts create mode 100644 packages/design-system/ui/src/blocks/data-table/utils/is-date-comparison-operator.ts diff --git a/.eslintrc.js b/.eslintrc.js index 705010c5c6280..7220b54576a6b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -87,6 +87,11 @@ module.exports = { "./packages/admin/admin-bundler/tsconfig.json", "./packages/admin/admin-vite-plugin/tsconfig.json", + "./packages/design-system/ui/tsconfig.json", + "./packages/design-system/icons/tsconfig.json", + "./packages/design-system/ui-preset/tsconfig.json", + "./packages/design-system/toolbox/tsconfig.json", + "./packages/cli/create-medusa-app/tsconfig.json", "./packages/cli/medusa-cli/tsconfig.spec.json", "./packages/cli/oas/medusa-oas-cli/tsconfig.spec.json", @@ -167,7 +172,10 @@ module.exports = { }, }, { - files: ["packages/design-system/ui/**/*.{ts,tsx}"], + files: [ + "./packages/design-system/ui/**/*.ts", + "./packages/design-system/ui/**/*.tsx", + ], extends: [ "plugin:react/recommended", "plugin:storybook/recommended", @@ -196,7 +204,10 @@ module.exports = { }, }, { - files: ["packages/design-system/icons/**/*.{ts,tsx}"], + files: [ + "./packages/design-system/icons/**/*.ts", + "./packages/design-system/icons/**/*.tsx", + ], extends: [ "plugin:react/recommended", "plugin:@typescript-eslint/recommended", @@ -223,8 +234,8 @@ module.exports = { }, { files: [ - "packages/admin/dashboard/**/*.ts", - "packages/admin/dashboard/**/*.tsx", + "./packages/admin/dashboard/**/*.ts", + "./packages/admin/dashboard/**/*.tsx", ], plugins: ["unused-imports", "react-refresh"], extends: [ diff --git a/.prettierrc b/.prettierrc index 3718cf898655b..54e52522ce7b5 100644 --- a/.prettierrc +++ b/.prettierrc @@ -7,7 +7,13 @@ "arrowParens": "always", "overrides": [ { - "files": "./packages/admin-ui/**/*.{js,jsx,ts,tsx}", + "files": "./packages/admin/dashboard/src/**/*.{ts,tsx}", + "options": { + "plugins": ["prettier-plugin-tailwindcss"] + } + }, + { + "files": "./packages/design-system/ui/src/**/*.{ts,tsx}", "options": { "plugins": ["prettier-plugin-tailwindcss"] } diff --git a/packages/admin/dashboard/src/components/data-table/data-table.tsx b/packages/admin/dashboard/src/components/data-table/data-table.tsx new file mode 100644 index 0000000000000..d7cfe313cbf82 --- /dev/null +++ b/packages/admin/dashboard/src/components/data-table/data-table.tsx @@ -0,0 +1,69 @@ +import { + Button, + DataTableCommand, + DataTableEmptyState, + DataTableFilter, + Heading, + DataTable as Primitive, + useDataTable, +} from "@medusajs/ui" + +interface DataTableProps { + data?: TData[] + columns: any + filters?: DataTableFilter[] + commands?: DataTableCommand[] + rowCount?: number + enablePagination?: boolean + emptyState?: DataTableEmptyState +} + +export const DataTable = ({ + data = [], + columns, + filters, + commands, + rowCount = 0, + enablePagination = true, +}: DataTableProps) => { + const instance = useDataTable({ + data, + columns, + filters, + commands, + rowCount, + }) + + return ( + + + Employees +
+ + + + +
+
+ + {enablePagination && } + {commands && commands.length > 0 && ( + `${count} selected`} /> + )} +
+ ) +} diff --git a/packages/admin/dashboard/src/components/data-table/index.ts b/packages/admin/dashboard/src/components/data-table/index.ts new file mode 100644 index 0000000000000..8e2d5f828a450 --- /dev/null +++ b/packages/admin/dashboard/src/components/data-table/index.ts @@ -0,0 +1 @@ +export * from "./data-table" diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-command-bar.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-command-bar.tsx new file mode 100644 index 0000000000000..37d7c6faa7129 --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-command-bar.tsx @@ -0,0 +1,59 @@ +"use client" + +import * as React from "react" + +import { CommandBar } from "@/components/command-bar" +import { useDataTableContext } from "../context/use-data-table-context" + +export interface DataTableCommandBarProps { + selectedLabel?: ((count: number) => string) | string +} + +export const DataTableCommandBar = ({ + selectedLabel, +}: DataTableCommandBarProps) => { + const { instance } = useDataTableContext() + + const commands = instance.getCommands() + const rowSelection = instance.getRowSelection() + + const count = Object.keys(rowSelection || []).length + + const open = commands && commands.length > 0 && count > 0 + + function getSelectedLabel(count: number) { + if (typeof selectedLabel === "function") { + return selectedLabel(count) + } + + return selectedLabel + } + + if (!commands || commands.length === 0) { + return null + } + + return ( + + + {selectedLabel && ( + + {getSelectedLabel(count)} + + + )} + {commands.map((command, idx) => ( + + command.action(rowSelection)} + label={command.label} + shortcut={command.shortcut} + /> + {idx < commands.length - 1 && } + + ))} + + + ) +} diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx index 04120e58bd3d6..ee0be76f024f5 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter.tsx @@ -7,7 +7,8 @@ import { DropdownMenu } from "@/components/dropdown-menu" import { clx } from "@/utils/clx" import { XMark } from "@medusajs/icons" import { useDataTableContext } from "../context/use-data-table-context" -import { FilterOption, FilterType } from "../types" +import { DateComparisonOperator, FilterOption, FilterType } from "../types" +import { isDateComparisonOperator } from "../utils/is-date-comparison-operator" interface DataTableFilterProps { filter: ColumnFilter @@ -20,8 +21,6 @@ const DataTableFilter = ({ filter, label }: DataTableFilterProps) => { const onOpenChange = React.useCallback( (open: boolean) => { - console.log("open", open, filter.value) - if ( !open && (!filter.value || @@ -45,7 +44,7 @@ const DataTableFilter = ({ filter, label }: DataTableFilterProps) => { const value = filter.value const displayValue = React.useMemo(() => { - let displayValue: string | string[] | null = null + let displayValue: string | string[] | DateComparisonOperator | null = null if (typeof value === "string") { displayValue = options?.find((o) => o.value === value)?.label ?? null @@ -58,8 +57,23 @@ const DataTableFilter = ({ filter, label }: DataTableFilterProps) => { .join(", ") ?? null } - if (value instanceof Date) { - displayValue = options?.find((o) => o.value === value)?.label ?? null + if (isDateComparisonOperator(value)) { + displayValue = + options?.find((o) => { + if (!isDateComparisonOperator(o.value)) return false + + // Compare all possible operators + return ( + // Check if both have $gte and they're equal + (value.$gte === o.value.$gte || (!value.$gte && !o.value.$gte)) && + // Check if both have $lte and they're equal + (value.$lte === o.value.$lte || (!value.$lte && !o.value.$lte)) && + // Check if both have $gt and they're equal + (value.$gt === o.value.$gt || (!value.$gt && !o.value.$gt)) && + // Check if both have $lt and they're equal + (value.$lt === o.value.$lt || (!value.$lt && !o.value.$lt)) + ) + })?.label ?? null } return displayValue @@ -128,7 +142,7 @@ const DataTableFilter = ({ filter, label }: DataTableFilterProps) => { return ( []} + options={options as FilterOption[]} /> ) case "text": @@ -152,22 +166,48 @@ interface DataTableFilterDropdownProps { type DataTableFilterDateContentProps = { filter: ColumnFilter - options: FilterOption[] + options: FilterOption[] } const DataTableFilterDateContent = ({ filter, options, }: DataTableFilterDateContentProps) => { + const { instance } = useDataTableContext() + + const currentValue = filter.value as DateComparisonOperator | undefined + + const selectedValue = React.useMemo(() => { + if (!currentValue) return undefined + + return JSON.stringify(currentValue) + }, [currentValue]) + + const onValueChange = React.useCallback( + (valueStr: string) => { + const value = JSON.parse(valueStr) as DateComparisonOperator + instance.updateFilter({ ...filter, value }) + }, + [instance, filter] + ) + return ( - {options.map((option, idx) => { - return ( - - {option.label} - - ) - })} + + {options.map((option, idx) => { + return ( + + {option.label} + + ) + })} + ) } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-pagination.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-pagination.tsx index 0f9987c2aef64..ed740d0c7ee4c 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-pagination.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-pagination.tsx @@ -6,16 +6,21 @@ import { Table } from "@/components/table" import { useDataTableContext } from "../context/use-data-table-context" -const DataTablePagination = () => { +interface DataTablePaginationProps { + translations?: React.ComponentProps["translations"] +} + +const DataTablePagination = ({ translations }: DataTablePaginationProps) => { const { instance } = useDataTableContext() return ( { } export { DataTablePagination } +export type { DataTablePaginationProps } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-search.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-search.tsx index 5b7bc46e047a9..f45846f4c0bf3 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-search.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-search.tsx @@ -18,7 +18,7 @@ const DataTableSearch = ({ className, ...props }: DataTableSearchProps) => { instance.onSearchChange(e.target.value)} className={clx( { diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx index 2f1d6144827e6..5f87bb37036dc 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx @@ -20,7 +20,7 @@ type DataTableEmptyStateProps = { empty?: EmptyStateContent } -interface DataTableTableProps { +export interface DataTableTableProps { emptyState?: DataTableEmptyStateProps } @@ -133,7 +133,7 @@ const DataTableTable = ({ emptyState }: DataTableTableProps) => { className={clx("whitespace-nowrap", { "w-[calc(20px+24px+24px)] min-w-[calc(20px+24px+24px)] max-w-[calc(20px+24px+24px)]": isSelectHeader, - "w-[calc(20px+24px)] min-w-[calc(20px+24px)] max-w-[calc(20px+24px)]": + "w-[calc(28px+24px)] min-w-[calc(28px+24px)] max-w-[calc(28px+24px)]": isActionHeader, "after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']": isFirstColumn, @@ -185,7 +185,10 @@ const DataTableTable = ({ emptyState }: DataTableTableProps) => { key={row.id} onMouseEnter={() => setHoveredRowId(row.id)} onMouseLeave={() => setHoveredRowId(null)} - className="group/row last:border-b-0" + onClick={() => instance.onRowClick?.(row)} + className={clx("group/row last:border-b-0", { + "cursor-pointer": !!instance.onRowClick, + })} > {row.getVisibleCells().map((cell, idx) => { const isSelectCell = cell.column.id === "select" @@ -197,20 +200,23 @@ const DataTableTable = ({ emptyState }: DataTableTableProps) => { return ( = { title: "Blocks/DataTable", @@ -22,127 +29,118 @@ export default meta type Story = StoryObj -type Person = { +type Employee = { id: string name: string email: string + position: string age: number birthday: Date relationshipStatus: "single" | "married" | "divorced" | "widowed" } -const useDebouncedValue = (value: string, delay: number) => { - const [debouncedValue, setDebouncedValue] = React.useState(value) - const timerRef = React.useRef | null>(null) +const generateEmployees = (count: number): Employee[] => { + return Array.from({ length: count }, (_, i) => { + const age = faker.number.int({ min: 18, max: 65 }) + const birthday = faker.date.birthdate({ + mode: "age", + min: age, + max: age, + }) - React.useEffect(() => { - if (timerRef.current) { - clearTimeout(timerRef.current) - } - - timerRef.current = setTimeout(() => { - setDebouncedValue(value) - }, delay) - - return () => { - if (timerRef.current) clearTimeout(timerRef.current) + return { + id: i.toString(), + name: faker.person.fullName(), + email: faker.internet.email(), + position: faker.person.jobTitle(), + age, + birthday, + relationshipStatus: faker.helpers.arrayElement([ + "single", + "married", + "divorced", + "widowed", + ]), } - }, [value]) - - return debouncedValue + }) } -const data: Person[] = [ - { - id: "1", - name: "John Doe", - email: "john.doe@example.com", - age: 20, - birthday: new Date("1990-01-01"), - relationshipStatus: "single", - }, - { - id: "2", - name: "Jane Doe", - email: "jane.doe@example.com", - age: 25, - birthday: new Date("1995-04-01"), - relationshipStatus: "married", - }, - { - id: "3", - name: "John Smith", - email: "john.smith@example.com", - age: 30, - birthday: new Date("1990-05-01"), - relationshipStatus: "divorced", - }, - { - id: "4", - name: "Jane Smith", - email: "jane.smith@example.com", - age: 35, - birthday: new Date("1995-06-01"), - relationshipStatus: "widowed", - }, - { - id: "5", - name: "Mike Doe", - email: "mike.doe@example.com", - age: 40, - birthday: new Date("1990-07-01"), - relationshipStatus: "single", - }, - { - id: "6", - name: "Emily Smith", - email: "emily.smith@example.com", - age: 45, - birthday: new Date("1995-08-01"), - relationshipStatus: "married", - }, - { - id: "7", - name: "Sam Doe", - email: "sam.doe@example.com", - age: 50, - birthday: new Date("1990-09-01"), - relationshipStatus: "divorced", - }, -] +const data: Employee[] = generateEmployees(100) const usePeople = ({ q, order, filters, + offset, + limit, }: { q?: string order?: { id: string; desc: boolean } | null filters?: Record + offset?: number + limit?: number }) => { return React.useMemo(() => { - let results = [...data] // Create a copy to avoid mutating original data + let results = [...data] - // Apply free text search if (q) { results = results.filter((person) => person.name.toLowerCase().includes(q.toLowerCase()) ) } - // Apply filters if (filters && Object.keys(filters).length > 0) { results = results.filter((person) => { return Object.entries(filters).every(([key, filter]) => { if (!filter.value) return true - const value = person[key as keyof Person] - - if (value instanceof Date && filter.value instanceof Date) { - return value.getTime() === filter.value.getTime() + const value = person[key as keyof Employee] + + if (filter.id === "birthday") { + if (isDateComparisonOperator(filter.value)) { + if (!(value instanceof Date)) { + return false + } + + if (filter.value.$gte) { + const compareDate = new Date(filter.value.$gte) + if (value < compareDate) { + console.log("$gte: value < compareDate") + return false + } + } + + if (filter.value.$lte) { + const compareDate = new Date(filter.value.$lte) + if (value > compareDate) { + console.log("$lte: value > compareDate") + return false + } + } + + if (filter.value.$gt) { + const compareDate = new Date(filter.value.$gt) + if (value <= compareDate) { + console.log("$gt: value <= compareDate") + return false + } + } + + if (filter.value.$lt) { + const compareDate = new Date(filter.value.$lt) + if (value >= compareDate) { + console.log("$lt: value >= compareDate") + return false + } + } + + return true + } } if (Array.isArray(filter.value)) { + if (filter.value.length === 0) return true + return filter.value.includes(value) } @@ -153,7 +151,7 @@ const usePeople = ({ // Apply sorting if (order) { - const key = order.id as keyof Person + const key = order.id as keyof Employee const desc = order.desc results.sort((a, b) => { @@ -172,14 +170,22 @@ const usePeople = ({ }) } + if (offset) { + results = results.slice(offset) + } + + if (limit) { + results = results.slice(0, limit) + } + return { data: results, - count: results.length, + count: data.length, } - }, [q, order, filters]) // Add filters to dependencies + }, [q, order, filters, offset, limit]) // Add filters to dependencies } -const columnHelper = createDataTableColumnHelper() +const columnHelper = createDataTableColumnHelper() const columns = [ columnHelper.select(), @@ -194,6 +200,13 @@ const columns = [ enableSorting: true, sortAscLabel: "A-Z", sortDescLabel: "Z-A", + maxSize: 200, + }), + columnHelper.accessor("position", { + header: "Position", + enableSorting: true, + sortAscLabel: "A-Z", + sortDescLabel: "Z-A", }), columnHelper.accessor("age", { header: "Age", @@ -202,20 +215,6 @@ const columns = [ sortDescLabel: "High to Low", sortLabel: "Age", }), - columnHelper.accessor("relationshipStatus", { - header: "Relationship Status", - cell: ({ row }) => { - return ( -
- {row.original.relationshipStatus.charAt(0).toUpperCase() + - row.original.relationshipStatus.slice(1)} -
- ) - }, - enableSorting: true, - sortAscLabel: "A-Z", - sortDescLabel: "Z-A", - }), columnHelper.accessor("birthday", { header: "Birthday", cell: ({ row }) => { @@ -233,6 +232,20 @@ const columns = [ sortAscLabel: "Oldest to Youngest", sortDescLabel: "Youngest to Oldest", }), + columnHelper.accessor("relationshipStatus", { + header: "Relationship Status", + cell: ({ row }) => { + return ( +
+ {row.original.relationshipStatus.charAt(0).toUpperCase() + + row.original.relationshipStatus.slice(1)} +
+ ) + }, + enableSorting: true, + sortAscLabel: "A-Z", + sortDescLabel: "Z-A", + }), columnHelper.action({ actions: [ [ @@ -253,31 +266,65 @@ const columns = [ }), ] -const filterHelper = createDataTableFilterHelper() +const filterHelper = createDataTableFilterHelper() const filters = [ - filterHelper.accessor("name", { - label: "Name", - type: "text", - }), filterHelper.accessor("birthday", { label: "Birthday", type: "date", format: "date", options: [ - { label: "Today", value: new Date() }, - { label: "Yesterday", value: new Date(Date.now() - 24 * 60 * 60 * 1000) }, { - label: "Last Week", - value: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + label: "18 - 25 years old", + value: { + $lte: new Date( + new Date().setFullYear(new Date().getFullYear() - 18) + ).toISOString(), + $gte: new Date( + new Date().setFullYear(new Date().getFullYear() - 25) + ).toISOString(), + }, + }, + { + label: "26 - 35 years old", + value: { + $lte: new Date( + new Date().setFullYear(new Date().getFullYear() - 26) + ).toISOString(), + $gte: new Date( + new Date().setFullYear(new Date().getFullYear() - 35) + ).toISOString(), + }, + }, + { + label: "36 - 45 years old", + value: { + $lte: new Date( + new Date().setFullYear(new Date().getFullYear() - 36) + ).toISOString(), + $gte: new Date( + new Date().setFullYear(new Date().getFullYear() - 45) + ).toISOString(), + }, }, { - label: "Last Month", - value: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + label: "46 - 55 years old", + value: { + $lte: new Date( + new Date().setFullYear(new Date().getFullYear() - 46) + ).toISOString(), + $gte: new Date( + new Date().setFullYear(new Date().getFullYear() - 55) + ).toISOString(), + }, }, { - label: "Last Year", - value: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000), + label: "Over 55 years old", + value: { + $lt: new Date( + new Date().setFullYear(new Date().getFullYear() - 55) + ).toISOString(), + }, }, ], }), @@ -293,11 +340,30 @@ const filters = [ }), ] +const commandHelper = createDataTableCommandHelper() + +const commands = [ + commandHelper.command({ + label: "Archive", + action: (selection) => { + alert(`Archive ${Object.keys(selection).length} items`) + }, + shortcut: "A", + }), + commandHelper.command({ + label: "Delete", + action: (selection) => { + alert(`Delete ${Object.keys(selection).length} items`) + }, + shortcut: "D", + }), +] + const KitchenSinkDemo = () => { const [search, setSearch] = React.useState("") - const debouncedSearch = useDebouncedValue(search, 300) - const [rowSelection, setRowSelection] = React.useState({}) + const [rowSelection, setRowSelection] = + React.useState({}) const [sorting, setSorting] = React.useState( null ) @@ -305,19 +371,30 @@ const KitchenSinkDemo = () => { Record >({}) + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }) + const { data, count } = usePeople({ - q: debouncedSearch, + q: search, order: sorting, filters: filtering, + offset: pagination.pageIndex * pagination.pageSize, + limit: pagination.pageSize, }) const table = useDataTable({ data, columns, - count, + filters, + commands, + rowCount: count, isLoading: true, getRowId: (row) => row.id, - filters, + onRowClick: (row) => { + alert(`Navigate to ${row.id}`) + }, search: { state: search, onSearchChange: setSearch, @@ -334,6 +411,10 @@ const KitchenSinkDemo = () => { state: sorting, onSortingChange: setSorting, }, + pagination: { + state: pagination, + onPaginationChange: setPagination, + }, }) return ( @@ -365,6 +446,9 @@ const KitchenSinkDemo = () => { }} /> + `${count} selected`} + /> diff --git a/packages/design-system/ui/src/blocks/data-table/data-table.tsx b/packages/design-system/ui/src/blocks/data-table/data-table.tsx index a744210d28540..f40bc1096ae48 100644 --- a/packages/design-system/ui/src/blocks/data-table/data-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/data-table.tsx @@ -8,6 +8,7 @@ import { DataTableContextProvider } from "./context/data-table-context-provider" import { UseDataTableReturn } from "./use-data-table" import { clx } from "@/utils/clx" +import { DataTableCommandBar } from "./components/data-table-command-bar" import { DataTableFilterMenu } from "./components/data-table-filter-menu" import { DataTablePagination } from "./components/data-table-pagination" import { DataTableSortingMenu } from "./components/data-table-sorting-menu" @@ -39,6 +40,7 @@ const DataTable = Object.assign(Root, { SortingMenu: DataTableSortingMenu, FilterMenu: DataTableFilterMenu, Pagination: DataTablePagination, + CommandBar: DataTableCommandBar, }) export { DataTable } diff --git a/packages/design-system/ui/src/blocks/data-table/index.ts b/packages/design-system/ui/src/blocks/data-table/index.ts index 8581bce7d5400..6c9e3910bad14 100644 --- a/packages/design-system/ui/src/blocks/data-table/index.ts +++ b/packages/design-system/ui/src/blocks/data-table/index.ts @@ -1,5 +1,13 @@ export * from "./data-table" export * from "./use-data-table" export * from "./utils/create-data-table-column-helper" +export * from "./utils/create-data-table-command-helper" export * from "./utils/create-data-table-filter-helper" +export type { + DataTableCommand, DataTableEmptyState, DataTableFilter, DataTableFilteringState, + DataTablePaginationState, + DataTableRowSelectionState, + DataTableSortingState +} from "./types" + diff --git a/packages/design-system/ui/src/blocks/data-table/types.ts b/packages/design-system/ui/src/blocks/data-table/types.ts index f8fd18623cf84..a6e62241aefec 100644 --- a/packages/design-system/ui/src/blocks/data-table/types.ts +++ b/packages/design-system/ui/src/blocks/data-table/types.ts @@ -3,11 +3,13 @@ import type { AccessorFnColumnDef, AccessorKeyColumnDef, CellContext, + ColumnFilter, ColumnSort, DeepKeys, DeepValue, DisplayColumnDef, IdentifiedColumnDef, + PaginationState, RowData, RowSelectionState, } from "@tanstack/react-table" @@ -68,8 +70,12 @@ export interface DataTableColumnHelper { select: (props?: SelectColumnDef) => DisplayColumnDef } +interface DataTableColumnFilter extends ColumnFilter {} + export interface DataTableSortingState extends ColumnSort {} export interface DataTableRowSelectionState extends RowSelectionState {} +export interface DataTablePaginationState extends PaginationState {} +export interface DataTableFilteringState extends Record {} export type FilterType = "text" | "radio" | "select" | "date" export type FilterOption = { @@ -99,7 +105,7 @@ export interface SelectFilterProps extends BaseFilterProps { export interface DateFilterProps extends BaseFilterProps { type: "date" format: "date" | "date-time" - options: FilterOption[] + options: FilterOption[] } export type DataTableFilterProps = TextFilterProps | RadioFilterProps | SelectFilterProps | DateFilterProps @@ -113,3 +119,30 @@ export enum DataTableEmptyState { FILTERED_EMPTY = "FILTERED_EMPTY", POPULATED = "POPULATED", } + +export type DateComparisonOperator = { + /** + * The filtered date must be greater than or equal to this value. + */ + $gte?: string + /** + * The filtered date must be less than or equal to this value. + */ + $lte?: string + /** + * The filtered date must be less than this value. + */ + $lt?: string + /** + * The filtered date must be greater than this value. + */ + $gt?: string +} + +type CommandAction = (selection: DataTableRowSelectionState) => void | Promise + +export interface DataTableCommand { + label: string + action: CommandAction + shortcut: string +} diff --git a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx index 1465233843ac2..1b85d8f053e43 100644 --- a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx @@ -3,7 +3,7 @@ import { ColumnFiltersState, type ColumnSort, getCoreRowModel, - type Row, + PaginationState, type RowSelectionState, type SortingState, type TableOptions, @@ -12,41 +12,86 @@ import { } from "@tanstack/react-table" import * as React from "react" import { + DataTableCommand, DataTableEmptyState, DataTableFilter, + DataTablePaginationState, + DataTableRowSelectionState, DataTableSortingState, + DateComparisonOperator, FilterOption, FilterType, } from "./types" interface DataTableOptions extends Pick, "data" | "columns" | "getRowId"> { + /** + * The filters which the user can apply to the table. + */ + filters?: DataTableFilter[] + /** + * The commands which the user can apply to selected rows. + */ + commands?: DataTableCommand[] /** * Whether the data for the table is currently being loaded. */ isLoading?: boolean /** - * The filters which the user can apply to the table. + * The state and callback for the filtering. */ - filters?: DataTableFilter[] filtering?: { state: Record onFilteringChange: (state: Record) => void } + /** + * The state and callback for the row selection. + */ rowSelection?: { - state: RowSelectionState - onRowSelectionChange: (state: RowSelectionState) => void + state: DataTableRowSelectionState + onRowSelectionChange: (state: DataTableRowSelectionState) => void } + /** + * The state and callback for the sorting. + */ sorting?: { - state: ColumnSort | null - onSortingChange: (state: ColumnSort) => void + state: DataTableSortingState | null + onSortingChange: (state: DataTableSortingState) => void } + /** + * The state and callback for the search, with optional debounce. + */ search?: { state: string onSearchChange: (state: string) => void + /** + * Debounce time in milliseconds for the search callback. + * @default 300 + */ + debounce?: number + } + /** + * The state and callback for the pagination. + */ + pagination?: { + state: DataTablePaginationState + onPaginationChange: (state: DataTablePaginationState) => void } - onRowClick?: (row: Row) => void - count?: number + /** + * The function to execute when a row is clicked. + */ + onRowClick?: (row: TData) => void + /** + * The total count of rows. When working with pagination, this will be the total + * number of rows available, not the number of rows currently being displayed. + */ + rowCount?: number + /** + * Whether the page index should be reset the filtering, sorting, or pagination changes. + * + * @default true + */ + autoResetPageIndex?: boolean } interface UseDataTableReturn @@ -61,11 +106,6 @@ interface UseDataTableReturn | "getPageCount" | "getAllColumns" > { - count: number - pageIndex: number - pageSize: number - search: string - onSearchChange: (search: string) => void getSorting: () => DataTableSortingState | null setSorting: ( sortingOrUpdater: @@ -73,7 +113,7 @@ interface UseDataTableReturn | ((prev: DataTableSortingState | null) => DataTableSortingState) ) => void getFilters: () => DataTableFilter[] - getFilterOptions: ( + getFilterOptions: ( id: string ) => FilterOption[] | null getFilterType: (id: string) => FilterType | null @@ -82,66 +122,83 @@ interface UseDataTableReturn removeFilter: (id: string) => void clearFilters: () => void updateFilter: (filter: ColumnFilter) => void + getSearch: () => string + onSearchChange: (search: string) => void + getCommands: () => DataTableCommand[] + getRowSelection: () => DataTableRowSelectionState + onRowClick?: (row: TData) => void emptyState: DataTableEmptyState isLoading: boolean showSkeleton: boolean + pageIndex: number + pageSize: number + rowCount: number } const useDataTable = ({ - count = 0, + rowCount = 0, + filters, + commands, rowSelection, sorting, filtering, + pagination, + search, onRowClick, + autoResetPageIndex = true, ...options }: DataTableOptions): UseDataTableReturn => { - const sortingStateHandler = React.useCallback( - () => - sorting?.onSortingChange - ? onSortingChangeTransformer(sorting.onSortingChange, sorting.state) - : undefined, - [sorting?.onSortingChange, sorting?.state] - ) - - const rowSelectionStateHandler = React.useCallback( - () => - rowSelection?.onRowSelectionChange - ? onRowSelectionChangeTransformer( - rowSelection.onRowSelectionChange, - rowSelection.state - ) - : undefined, - [rowSelection?.onRowSelectionChange, rowSelection?.state] - ) - - const filteringStateHandler = React.useCallback( - () => - filtering?.onFilteringChange - ? onFilteringChangeTransformer( - filtering.onFilteringChange, - filtering.state - ) - : undefined, - [filtering?.onFilteringChange, filtering?.state] - ) + const { state: sortingState, onSortingChange } = sorting ?? {} + const sortingStateHandler = React.useCallback(() => { + return onSortingChange + ? onSortingChangeTransformer(onSortingChange, sortingState) + : undefined + }, [onSortingChange, sortingState]) + + const { state: rowSelectionState, onRowSelectionChange } = rowSelection ?? {} + const rowSelectionStateHandler = React.useCallback(() => { + return onRowSelectionChange + ? onRowSelectionChangeTransformer(onRowSelectionChange, rowSelectionState) + : undefined + }, [onRowSelectionChange, rowSelectionState]) + + const { state: filteringState, onFilteringChange } = filtering ?? {} + const filteringStateHandler = React.useCallback(() => { + return onFilteringChange + ? onFilteringChangeTransformer(onFilteringChange, filteringState) + : undefined + }, [onFilteringChange, filteringState]) + + const { state: paginationState, onPaginationChange } = pagination ?? {} + const paginationStateHandler = React.useCallback(() => { + return onPaginationChange + ? onPaginationChangeTransformer(onPaginationChange, paginationState) + : undefined + }, [onPaginationChange, paginationState]) const instance = useReactTable({ ...options, getCoreRowModel: getCoreRowModel(), state: { - rowSelection: rowSelection?.state, - sorting: sorting?.state ? [sorting.state] : undefined, - columnFilters: Object.values(filtering?.state ?? {}), + rowSelection: rowSelectionState, + sorting: sortingState ? [sortingState] : undefined, + columnFilters: Object.values(filteringState ?? {}), + pagination: paginationState, }, + rowCount, onColumnFiltersChange: filteringStateHandler(), onRowSelectionChange: rowSelectionStateHandler(), onSortingChange: sortingStateHandler(), - // All data manipulation should be handled manually, likely by a server. + onPaginationChange: paginationStateHandler(), manualSorting: true, manualPagination: true, manualFiltering: true, }) + const autoResetPageIndexHandler = React.useCallback(() => { + return autoResetPageIndex ? () => instance.setPageIndex(0) : undefined + }, [autoResetPageIndex, instance]) + const getSorting = React.useCallback(() => { return instance.getState().sorting?.[0] ?? null }, [instance]) @@ -156,17 +213,18 @@ const useDataTable = ({ ? sortingOrUpdater(currentSort) : sortingOrUpdater + autoResetPageIndexHandler()?.() instance.setSorting([newSorting]) }, - [instance] + [instance, autoResetPageIndexHandler] ) const getFilters = React.useCallback(() => { - return options.filters ?? [] - }, [options.filters]) + return filters ?? [] + }, [filters]) const getFilterOptions = React.useCallback( - (id: string) => { + (id: string) => { const filter = getFilters().find((filter) => filter.id === id) if (!filter || filter.type === "text") { @@ -192,23 +250,25 @@ const useDataTable = ({ const addFilter = React.useCallback( (filter: ColumnFilter) => { - filtering?.onFilteringChange?.({ ...getFiltering(), [filter.id]: filter }) + autoResetPageIndexHandler()?.() + onFilteringChange?.({ ...getFiltering(), [filter.id]: filter }) }, - [filtering?.onFilteringChange, getFiltering] + [onFilteringChange, getFiltering, autoResetPageIndexHandler] ) const removeFilter = React.useCallback( (id: string) => { const currentFilters = getFiltering() delete currentFilters[id] - filtering?.onFilteringChange?.(currentFilters) + autoResetPageIndexHandler()?.() + onFilteringChange?.(currentFilters) }, - [filtering?.onFilteringChange, getFiltering] + [onFilteringChange, getFiltering, autoResetPageIndexHandler] ) const clearFilters = React.useCallback(() => { - filtering?.onFilteringChange?.({}) - }, [filtering?.onFilteringChange]) + onFilteringChange?.({}) + }, [onFilteringChange]) const updateFilter = React.useCallback( (filter: ColumnFilter) => { @@ -217,12 +277,75 @@ const useDataTable = ({ [addFilter] ) + const { state: searchState, onSearchChange, debounce = 300 } = search ?? {} + + // Local state for immediate UI updates + const [localSearch, setLocalSearch] = React.useState(searchState ?? "") + const timeoutRef = React.useRef>() + + // Update local state when prop changes + React.useEffect(() => { + setLocalSearch(searchState ?? "") + }, [searchState]) + + const getSearch = React.useCallback(() => { + return localSearch + }, [localSearch]) + + const debouncedSearchChange = React.useMemo(() => { + if (!onSearchChange) { + return undefined + } + + return (value: string) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + + if (debounce <= 0) { + autoResetPageIndexHandler()?.() + onSearchChange(value) + return + } + + timeoutRef.current = setTimeout(() => { + autoResetPageIndexHandler()?.() + onSearchChange(value) + }, debounce) + } + }, [onSearchChange, debounce, autoResetPageIndexHandler]) + + // Cleanup timeout + React.useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + } + }, []) + + const onSearchChangeHandler = React.useCallback( + (search: string) => { + setLocalSearch(search) // Update local state immediately + debouncedSearchChange?.(search) // Debounce the callback + }, + [debouncedSearchChange] + ) + + const getCommands = React.useCallback(() => { + return commands ?? [] + }, [commands]) + + const getRowSelection = React.useCallback(() => { + return instance.getState().rowSelection + }, [instance]) + const rows = instance.getRowModel().rows const emptyState = React.useMemo(() => { const hasRows = rows.length > 0 - const hasSearch = Boolean(options.search?.state) - const hasFilters = Object.keys(filtering?.state ?? {}).length > 0 + const hasSearch = Boolean(searchState) + const hasFilters = Object.keys(filteringState ?? {}).length > 0 if (hasRows) { return DataTableEmptyState.POPULATED @@ -233,7 +356,7 @@ const useDataTable = ({ } return DataTableEmptyState.EMPTY - }, [rows, options.search?.state, filtering?.state]) + }, [rows, searchState, filteringState]) const showSkeleton = React.useMemo(() => { return options.isLoading === true && rows.length === 0 @@ -252,10 +375,10 @@ const useDataTable = ({ getPageCount: instance.getPageCount, pageIndex: instance.getState().pagination.pageIndex, pageSize: instance.getState().pagination.pageSize, - count, + rowCount, // Search - search: options.search?.state ?? "", - onSearchChange: options.search?.onSearchChange ?? (() => {}), + getSearch, + onSearchChange: onSearchChangeHandler, // Sorting getSorting, setSorting, @@ -268,6 +391,11 @@ const useDataTable = ({ removeFilter, clearFilters, updateFilter, + // Commands + getCommands, + getRowSelection, + // Handlers + onRowClick, // Empty State emptyState, // Loading @@ -323,5 +451,19 @@ function onFilteringChangeTransformer( } } +function onPaginationChangeTransformer( + onPaginationChange: (state: PaginationState) => void, + state?: PaginationState +) { + return (updaterOrValue: Updater) => { + const value = + typeof updaterOrValue === "function" + ? updaterOrValue(state ?? { pageIndex: 0, pageSize: 10 }) + : updaterOrValue + + onPaginationChange(value) + } +} + export { useDataTable } export type { DataTableOptions, UseDataTableReturn } diff --git a/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-command-helper.ts b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-command-helper.ts new file mode 100644 index 0000000000000..38667b4285627 --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/utils/create-data-table-command-helper.ts @@ -0,0 +1,8 @@ +import { DataTableCommand } from "../types" + +const createDataTableCommandHelper = () => ({ + command: (command: DataTableCommand) => command, +}) + +export { createDataTableCommandHelper } + diff --git a/packages/design-system/ui/src/blocks/data-table/utils/is-date-comparison-operator.ts b/packages/design-system/ui/src/blocks/data-table/utils/is-date-comparison-operator.ts new file mode 100644 index 0000000000000..e46e5c64e3f7a --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/utils/is-date-comparison-operator.ts @@ -0,0 +1,19 @@ +import { DateComparisonOperator } from "../types"; + +export function isDateComparisonOperator( + value: unknown +): value is DateComparisonOperator { + if (typeof value !== "object" || value === null) { + return false + } + + const validOperators = ["$gte", "$lte", "$gt", "$lt"] + const hasAtLeastOneOperator = validOperators.some((op) => op in value) + + const allPropertiesValid = Object.entries(value as Record) + .every(([key, val]) => + validOperators.includes(key) && (typeof val === "string" || val === undefined) + ) + + return hasAtLeastOneOperator && allPropertiesValid +} diff --git a/packages/design-system/ui/src/components/command-bar/command-bar.tsx b/packages/design-system/ui/src/components/command-bar/command-bar.tsx index 5cf1bcf6e9567..f03afd795788d 100644 --- a/packages/design-system/ui/src/components/command-bar/command-bar.tsx +++ b/packages/design-system/ui/src/components/command-bar/command-bar.tsx @@ -167,7 +167,7 @@ const Command = React.forwardRef( ) => { React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === shortcut) { + if (event.key.toLowerCase() === shortcut.toLowerCase()) { event.preventDefault() event.stopPropagation() action() diff --git a/packages/design-system/ui/src/components/table/table.tsx b/packages/design-system/ui/src/components/table/table.tsx index 0bc3f6119c596..38c8bb66c75a3 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" @@ -74,7 +74,10 @@ const HeaderCell = React.forwardRef< >(({ className, ...props }, ref) => ( )) From 91031d24f394f81a149852093c101c1039796c5d Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Fri, 8 Nov 2024 12:27:57 +0100 Subject: [PATCH 11/34] save --- .../ui/src/blocks/data-table/use-data-table.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx index 1b85d8f053e43..34b343b8e3fa1 100644 --- a/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/use-data-table.tsx @@ -15,6 +15,7 @@ import { DataTableCommand, DataTableEmptyState, DataTableFilter, + DataTableFilteringState, DataTablePaginationState, DataTableRowSelectionState, DataTableSortingState, @@ -41,8 +42,8 @@ interface DataTableOptions * The state and callback for the filtering. */ filtering?: { - state: Record - onFilteringChange: (state: Record) => void + state: DataTableFilteringState + onFilteringChange: (state: DataTableFilteringState) => void } /** * The state and callback for the row selection. From 28cc824876dc1b0be4e09c6cf8aa67b03def0fe0 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Mon, 11 Nov 2024 09:38:22 +0100 Subject: [PATCH 12/34] add example usage --- .changeset/proud-pumpkins-attack.md | 5 + .../src/components/data-table/data-table.tsx | 215 +++++++++++++++-- .../src/hooks/api/customer-groups.tsx | 2 +- .../customer-group-list-table/index.ts | 1 + .../new-customer-group-list-table.tsx | 220 ++++++++++++++++++ .../customer-group-list.tsx | 4 +- .../components/data-table-table.tsx | 12 +- .../ui/src/blocks/data-table/index.ts | 12 +- 8 files changed, 439 insertions(+), 32 deletions(-) create mode 100644 .changeset/proud-pumpkins-attack.md create mode 100644 packages/admin/dashboard/src/routes/customer-groups/customer-group-list/components/customer-group-list-table/new-customer-group-list-table.tsx diff --git a/.changeset/proud-pumpkins-attack.md b/.changeset/proud-pumpkins-attack.md new file mode 100644 index 0000000000000..c81dda1bdb9ee --- /dev/null +++ b/.changeset/proud-pumpkins-attack.md @@ -0,0 +1,5 @@ +--- +"@medusajs/dashboard": patch +--- + +fix(dashboard): Removes "variants" from the data requested by the product details page" diff --git a/packages/admin/dashboard/src/components/data-table/data-table.tsx b/packages/admin/dashboard/src/components/data-table/data-table.tsx index d7cfe313cbf82..49025af8588bf 100644 --- a/packages/admin/dashboard/src/components/data-table/data-table.tsx +++ b/packages/admin/dashboard/src/components/data-table/data-table.tsx @@ -1,21 +1,34 @@ import { Button, DataTableCommand, - DataTableEmptyState, DataTableFilter, + DataTableFilteringState, + DataTablePaginationState, + DataTableSortingState, Heading, DataTable as Primitive, useDataTable, } from "@medusajs/ui" +import { ColumnDef } from "@tanstack/react-table" +import { useState } from "react" +import { useSearchParams } from "react-router-dom" +import { useQueryParams } from "../../hooks/use-query-params" interface DataTableProps { data?: TData[] - columns: any + columns: ColumnDef[] filters?: DataTableFilter[] commands?: DataTableCommand[] rowCount?: number + getRowId: (row: TData) => string enablePagination?: boolean - emptyState?: DataTableEmptyState + enableSearch?: boolean + autoFocusSearch?: boolean + onRowClick?: (row: TData) => void + emptyState?: any + heading: string + prefix?: string + pageSize?: number } export const DataTable = ({ @@ -23,47 +36,209 @@ export const DataTable = ({ columns, filters, commands, + getRowId, rowCount = 0, enablePagination = true, + enableSearch = true, + autoFocusSearch = false, + onRowClick, + heading, + prefix, + pageSize = 10, + emptyState, }: DataTableProps) => { + const enableFiltering = filters && filters.length > 0 + const enableCommands = commands && commands.length > 0 + const enableSorting = columns.some((column) => column.enableSorting) + + const filterIds = filters?.map((f) => f.id) ?? [] + + const { offset, order, q, ...filterParams } = useQueryParams( + [ + ...filterIds, + ...(enableSorting ? ["order"] : []), + ...(enableSearch ? ["q"] : []), + ...(enablePagination ? ["offset"] : []), + ], + prefix + ) + const [_, setSearchParams] = useSearchParams() + + const [search, setSearch] = useState(q ?? "") + const handleSearchChange = (value: string) => { + setSearch(value) + setSearchParams((prev) => { + if (value) { + prev.set("q", value) + } else { + prev.delete("q") + } + + return prev + }) + } + + const [pagination, setPagination] = useState( + offset ? parsePaginationState(offset, pageSize) : { pageIndex: 0, pageSize } + ) + const handlePaginationChange = (value: DataTablePaginationState) => { + setPagination(value) + setSearchParams((prev) => { + if (value.pageIndex === 0) { + prev.delete("offset") + } else { + prev.set("offset", transformPaginationState(value).toString()) + } + + return prev + }) + } + + const [filtering, setFiltering] = useState( + parseFilterState(filterIds, filterParams) + ) + const handleFilteringChange = (value: DataTableFilteringState) => { + setFiltering(value) + + setSearchParams((prev) => { + Array.from(prev.keys()).forEach((key) => { + if (filterIds.includes(key) && !(key in value)) { + prev.delete(key) + } + }) + + Object.entries(value).forEach(([key, filter]) => { + if (filterIds.includes(key) && filter.value) { + prev.set(key, JSON.stringify(filter.value)) + } + }) + + return prev + }) + } + + const [sorting, setSorting] = useState( + order ? parseSortingState(order) : null + ) + const handleSortingChange = (value: DataTableSortingState) => { + setSorting(value) + setSearchParams((prev) => { + if (value) { + const valueToStore = transformSortingState(value) + + prev.set("order", valueToStore) + } else { + prev.delete("order") + } + + return prev + }) + } + const instance = useDataTable({ data, columns, filters, commands, rowCount, + getRowId, + onRowClick, + pagination: enablePagination + ? { + state: pagination, + onPaginationChange: handlePaginationChange, + } + : undefined, + filtering: enableFiltering + ? { + state: filtering, + onFilteringChange: handleFilteringChange, + } + : undefined, + sorting: enableSorting + ? { + state: sorting, + onSortingChange: handleSortingChange, + } + : undefined, + search: enableSearch + ? { + state: search, + onSearchChange: handleSearchChange, + } + : undefined, }) return ( - Employees + {heading}
- - + {enableSearch && ( + + )} + {enableFiltering && }
- + {enablePagination && } - {commands && commands.length > 0 && ( + {enableCommands && ( `${count} selected`} /> )}
) } + +function transformSortingState(value: DataTableSortingState) { + return value.desc ? `-${value.id}` : value.id +} + +function parseSortingState(value: string) { + return value.startsWith("-") + ? { id: value.slice(1), desc: true } + : { id: value, desc: false } +} + +function transformPaginationState(value: DataTablePaginationState) { + return value.pageIndex * value.pageSize +} + +function parsePaginationState(value: string, pageSize: number) { + const offset = parseInt(value) + + return { + pageIndex: Math.floor(offset / pageSize), + pageSize, + } +} + +function parseFilterState( + filterIds: string[], + value: Record +) { + if (!value) { + return {} + } + + const filters: DataTableFilteringState = {} + + for (const id of filterIds) { + const filterValue = value[id] + + if (filterValue) { + filters[id] = { + id, + value: JSON.parse(filterValue), + } + } + } + + return filters +} diff --git a/packages/admin/dashboard/src/hooks/api/customer-groups.tsx b/packages/admin/dashboard/src/hooks/api/customer-groups.tsx index b435b366722de..a545447992a11 100644 --- a/packages/admin/dashboard/src/hooks/api/customer-groups.tsx +++ b/packages/admin/dashboard/src/hooks/api/customer-groups.tsx @@ -40,7 +40,7 @@ export const useCustomerGroup = ( } export const useCustomerGroups = ( - query?: Record, + query?: HttpTypes.AdminGetCustomerGroupsParams, options?: Omit< UseQueryOptions< HttpTypes.AdminGetCustomerGroupsParams, diff --git a/packages/admin/dashboard/src/routes/customer-groups/customer-group-list/components/customer-group-list-table/index.ts b/packages/admin/dashboard/src/routes/customer-groups/customer-group-list/components/customer-group-list-table/index.ts index ead4d74743404..3684693dab6cc 100644 --- a/packages/admin/dashboard/src/routes/customer-groups/customer-group-list/components/customer-group-list-table/index.ts +++ b/packages/admin/dashboard/src/routes/customer-groups/customer-group-list/components/customer-group-list-table/index.ts @@ -1 +1,2 @@ export * from "./customer-group-list-table" +export * from "./new-customer-group-list-table" diff --git a/packages/admin/dashboard/src/routes/customer-groups/customer-group-list/components/customer-group-list-table/new-customer-group-list-table.tsx b/packages/admin/dashboard/src/routes/customer-groups/customer-group-list/components/customer-group-list-table/new-customer-group-list-table.tsx new file mode 100644 index 0000000000000..146e2c5ef5efa --- /dev/null +++ b/packages/admin/dashboard/src/routes/customer-groups/customer-group-list/components/customer-group-list-table/new-customer-group-list-table.tsx @@ -0,0 +1,220 @@ +import { PencilSquare, Trash } from "@medusajs/icons" +import { HttpTypes } from "@medusajs/types" +import { + Container, + createDataTableColumnHelper, + createDataTableFilterHelper, +} from "@medusajs/ui" +import { keepPreviousData } from "@tanstack/react-query" +import { ColumnDef } from "@tanstack/react-table" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" +import { useNavigate } from "react-router-dom" + +import { DataTable } from "../../../../../components/data-table" +import { useCustomerGroups } from "../../../../../hooks/api" +import { useDate } from "../../../../../hooks/use-date" +import { useQueryParams } from "../../../../../hooks/use-query-params" + +const PAGE_SIZE = 10 + +export const NewCustomerGroupListTable = () => { + const { t } = useTranslation() + const navigate = useNavigate() + + const { q, order, offset, created_at, updated_at } = useQueryParams([ + "q", + "order", + "offset", + "created_at", + "updated_at", + ]) + + const columns = useColumns() + const filters = useFilters() + + const { customer_groups, count } = useCustomerGroups( + { + q, + order, + offset: offset ? parseInt(offset) : undefined, + limit: PAGE_SIZE, + created_at: created_at ? JSON.parse(created_at) : undefined, + updated_at: updated_at ? JSON.parse(updated_at) : undefined, + fields: "id,name,created_at,updated_at,customers.id", + }, + { + placeholderData: keepPreviousData, + } + ) + + return ( + + row.id} + onRowClick={(row) => { + navigate(`/customer-groups/${row.id}`) + }} + emptyState={{ + empty: { + heading: "No customer groups", + description: "There are no customer groups to display.", + }, + filtered: { + heading: "No results", + description: + "No customer groups match the current filter criteria.", + }, + }} + pageSize={PAGE_SIZE} + /> + + ) +} + +const columnHelper = createDataTableColumnHelper() + +const useColumns = () => { + const { t } = useTranslation() + const navigate = useNavigate() + const { getFullDate } = useDate() + + return useMemo(() => { + return [ + columnHelper.accessor("name", { + header: t("fields.name"), + enableSorting: true, + }), + columnHelper.accessor("customers", { + header: t("customers.domain"), + cell: ({ row }) => { + return {row.original.customers?.length ?? 0} + }, + }), + columnHelper.accessor("created_at", { + header: t("fields.createdAt"), + cell: ({ row }) => { + return ( + + {getFullDate({ + date: row.original.created_at, + includeTime: true, + })} + + ) + }, + enableSorting: true, + }), + columnHelper.accessor("updated_at", { + header: t("fields.updatedAt"), + cell: ({ row }) => { + return ( + + {getFullDate({ + date: row.original.updated_at, + includeTime: true, + })} + + ) + }, + enableSorting: true, + }), + columnHelper.action({ + actions: [ + [ + { + icon: , + label: t("actions.edit"), + onClick: (row) => { + navigate(`/customer-groups/${row.row.original.id}/edit`) + }, + }, + ], + [ + { + icon: , + label: t("actions.delete"), + onClick: (row) => { + navigate(`/customer-groups/${row.row.original.id}`) + }, + }, + ], + ], + }), + ] as ColumnDef[] + }, [t, navigate, getFullDate]) +} + +const filterHelper = createDataTableFilterHelper() + +const useDateFilterOptions = () => { + const startOfDay = useMemo(() => { + const date = new Date() + date.setHours(0, 0, 0, 0) + return date + }, []) + + return useMemo(() => { + return [ + { + label: "Yesterday", + value: { + $lt: new Date( + startOfDay.getTime() - 24 * 60 * 60 * 1000 + ).toISOString(), + }, + }, + { + label: "Last 7 days", + value: { + $lt: new Date( + startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000 + ).toISOString(), + }, + }, + { + label: "Last 30 days", + value: { + $lt: new Date( + startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000 + ).toISOString(), + }, + }, + { + label: "Last 90 days", + value: { + $lt: new Date( + startOfDay.getTime() - 90 * 24 * 60 * 60 * 1000 + ).toISOString(), + }, + }, + ] + }, [startOfDay]) +} + +const useFilters = () => { + const { t } = useTranslation() + const dateFilterOptions = useDateFilterOptions() + + return useMemo(() => { + return [ + filterHelper.accessor("created_at", { + type: "date", + label: t("fields.createdAt"), + format: "date", + options: dateFilterOptions, + }), + filterHelper.accessor("updated_at", { + type: "date", + label: t("fields.updatedAt"), + format: "date", + options: dateFilterOptions, + }), + ] + }, [t, dateFilterOptions]) +} diff --git a/packages/admin/dashboard/src/routes/customer-groups/customer-group-list/customer-group-list.tsx b/packages/admin/dashboard/src/routes/customer-groups/customer-group-list/customer-group-list.tsx index 7230f4421813f..777efd1afd7c9 100644 --- a/packages/admin/dashboard/src/routes/customer-groups/customer-group-list/customer-group-list.tsx +++ b/packages/admin/dashboard/src/routes/customer-groups/customer-group-list/customer-group-list.tsx @@ -1,6 +1,6 @@ import { SingleColumnPage } from "../../../components/layout/pages" import { useDashboardExtension } from "../../../extensions" -import { CustomerGroupListTable } from "./components/customer-group-list-table" +import { NewCustomerGroupListTable } from "./components/customer-group-list-table" export const CustomerGroupsList = () => { const { getWidgets } = useDashboardExtension() @@ -12,7 +12,7 @@ export const CustomerGroupsList = () => { before: getWidgets("customer_group.list.before"), }} > - + ) } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx index 5f87bb37036dc..969b7661c8a16 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-table.tsx @@ -141,8 +141,10 @@ const DataTableTable = ({ emptyState }: DataTableTableProps) => { showStickyBorder && isFirstColumn, "bg-ui-bg-subtle sticky": isFirstColumn || isSelectHeader, - "left-0": isSelectHeader, - "left-[calc(20px+24px+24px)]": isFirstColumn, + "left-0": + isSelectHeader || (isFirstColumn && !hasSelect), + "left-[calc(20px+24px+24px)]": + isFirstColumn && hasSelect, })} style={ !isSpecialHeader @@ -213,8 +215,10 @@ const DataTableTable = ({ emptyState }: DataTableTableProps) => { isFirstColumn, "after:bg-ui-border-base": showStickyBorder && isFirstColumn, - "left-0": isSelectCell, - "left-[calc(20px+24px+24px)]": isFirstColumn, + "left-0": + isSelectCell || (isFirstColumn && !hasSelect), + "left-[calc(20px+24px+24px)]": + isFirstColumn && hasSelect, } )} style={ diff --git a/packages/design-system/ui/src/blocks/data-table/index.ts b/packages/design-system/ui/src/blocks/data-table/index.ts index 6c9e3910bad14..22afe3c4e527e 100644 --- a/packages/design-system/ui/src/blocks/data-table/index.ts +++ b/packages/design-system/ui/src/blocks/data-table/index.ts @@ -5,9 +5,11 @@ export * from "./utils/create-data-table-command-helper" export * from "./utils/create-data-table-filter-helper" export type { - DataTableCommand, DataTableEmptyState, DataTableFilter, DataTableFilteringState, - DataTablePaginationState, - DataTableRowSelectionState, - DataTableSortingState + DataTableCommand, + DataTableEmptyState, + DataTableFilter, + DataTableFilteringState, + DataTablePaginationState, + DataTableRowSelectionState, + DataTableSortingState, } from "./types" - From 7d4b3e6ed9918872599ffaac67e8723dfc100a03 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Mon, 11 Nov 2024 14:47:01 +0100 Subject: [PATCH 13/34] add skeleton and cleanup types --- .../common/action-menu/action-menu.tsx | 8 +- .../src/components/data-table/data-table.tsx | 109 ++++++++++++++++-- .../data-table-root/data-table-root.tsx | 2 +- .../table/data-table/data-table.tsx | 5 +- .../api-key-sales-channel-section.tsx | 4 +- .../api-key-management-list-table.tsx | 4 +- .../api-key-sales-channels-form.tsx | 4 +- .../add-campaign-promotions-form.tsx | 4 +- .../campaign-promotion-section.tsx | 4 +- .../components/campaign-list-table.tsx | 4 +- .../category-product-section.tsx | 4 +- .../category-list-table.tsx | 4 +- .../edit-category-products-form.tsx | 4 +- .../add-products-to-collection-form.tsx | 4 +- .../collection-product-section.tsx | 4 +- .../collection-list-table.tsx | 4 +- .../add-customers-form/add-customers-form.tsx | 4 +- .../customer-group-customer-section.tsx | 4 +- .../customer-group-list-table.tsx | 4 +- .../new-customer-group-list-table.tsx | 12 +- .../customer-group-section.tsx | 9 +- .../customer-order-section.tsx | 4 +- .../customer-list-table.tsx | 4 +- .../add-customer-groups-form.tsx | 6 +- .../location-list-table.tsx | 8 +- .../reservation-list-table.tsx | 10 +- .../components/inventory-list-table.tsx | 4 +- .../geo-zone-form/geo-zone-form.tsx | 4 +- .../edit-fulfillment-providers-form.tsx | 4 +- .../edit-sales-channels-form.tsx | 4 +- .../add-claim-items-table.tsx | 4 +- .../add-claim-outbound-items-table.tsx | 4 +- .../add-order-edit-items-table.tsx | 4 +- .../add-exchange-inbound-items-table.tsx | 4 +- .../add-exchange-outbound-items-table.tsx | 4 +- .../add-return-items-table.tsx | 4 +- .../order-list-table/order-list-table.tsx | 4 +- .../price-list-customer-group-rule-form.tsx | 4 +- .../price-list-products-form.tsx | 4 +- .../price-list-product-section.tsx | 4 +- .../price-list-list-table.tsx | 4 +- ...price-list-prices-add-product-ids-form.tsx | 4 +- .../product-tag-product-section.tsx | 4 +- .../product-tag-list-table.tsx | 4 +- .../product-type-product-section.tsx | 4 +- .../product-type-list-table.tsx | 4 +- .../variant-inventory-section.tsx | 8 +- .../product-create-sales-channel-drawer.tsx | 4 +- .../product-variant-section.tsx | 4 +- .../product-list-table/product-list-table.tsx | 4 +- .../edit-sales-channels-form.tsx | 4 +- .../promotion-list-table.tsx | 4 +- .../add-countries-form/add-countries-form.tsx | 4 +- .../create-region-form/create-region-form.tsx | 4 +- .../region-country-section.tsx | 4 +- .../region-list-table/region-list-table.tsx | 4 +- .../reservation-list-table.tsx | 8 +- .../return-reason-list-table.tsx | 4 +- .../add-products-to-sales-channel-form.tsx | 4 +- .../sales-channel-product-section.tsx | 4 +- .../components/sales-channel-list-table.tsx | 4 +- .../shipping-profile-list-table.tsx | 4 +- .../add-currencies-form.tsx | 4 +- .../store-currency-section.tsx | 4 +- .../components/target-form/target-form.tsx | 12 +- .../invite-user-form/invite-user-form.tsx | 4 +- .../user-list-table/user-list-table.tsx | 4 +- .../workflow-execution-list-table.tsx | 4 +- .../components/data-table-filter-bar.tsx | 36 +++++- .../components/data-table-filter-menu.tsx | 9 ++ .../components/data-table-pagination.tsx | 20 ++++ .../components/data-table-search.tsx | 9 ++ .../components/data-table-sorting-menu.tsx | 9 ++ .../components/data-table-table.tsx | 27 ++++- .../components/data-table-toolbar.tsx | 25 +++- .../blocks/data-table/data-table.stories.tsx | 2 +- .../ui/src/blocks/data-table/data-table.tsx | 13 ++- .../src/blocks/data-table/use-data-table.tsx | 58 +++++++--- .../ui/src/components/skeleton/index.ts | 1 + .../ui/src/components/skeleton/skeleton.tsx | 16 +++ packages/design-system/ui/src/index.ts | 1 + 81 files changed, 454 insertions(+), 193 deletions(-) create mode 100644 packages/design-system/ui/src/components/skeleton/index.ts create mode 100644 packages/design-system/ui/src/components/skeleton/skeleton.tsx diff --git a/packages/admin/dashboard/src/components/common/action-menu/action-menu.tsx b/packages/admin/dashboard/src/components/common/action-menu/action-menu.tsx index 58b17b186fd15..90849fba1ed48 100644 --- a/packages/admin/dashboard/src/components/common/action-menu/action-menu.tsx +++ b/packages/admin/dashboard/src/components/common/action-menu/action-menu.tsx @@ -25,13 +25,17 @@ export type ActionGroup = { type ActionMenuProps = { groups: ActionGroup[] + variant?: "transparent" | "primary" } -export const ActionMenu = ({ groups }: ActionMenuProps) => { +export const ActionMenu = ({ + groups, + variant = "transparent", +}: ActionMenuProps) => { return ( - + diff --git a/packages/admin/dashboard/src/components/data-table/data-table.tsx b/packages/admin/dashboard/src/components/data-table/data-table.tsx index 49025af8588bf..66bb3e9641c45 100644 --- a/packages/admin/dashboard/src/components/data-table/data-table.tsx +++ b/packages/admin/dashboard/src/components/data-table/data-table.tsx @@ -10,15 +10,53 @@ import { useDataTable, } from "@medusajs/ui" import { ColumnDef } from "@tanstack/react-table" -import { useState } from "react" -import { useSearchParams } from "react-router-dom" +import { ReactNode, useState } from "react" +import { useTranslation } from "react-i18next" +import { Link, useSearchParams } from "react-router-dom" + import { useQueryParams } from "../../hooks/use-query-params" +import { ActionMenu } from "../common/action-menu" + +type DataTableActionProps = { + label: string + disabled?: boolean +} & ( + | { + to: string + } + | { + onClick: () => void + } +) + +type DataTableActionMenuActionProps = { + label: string + icon: ReactNode + disabled?: boolean +} & ( + | { + to: string + } + | { + onClick: () => void + } +) + +type DataTableActionMenuGroupProps = { + actions: DataTableActionMenuActionProps[] +} + +type DataTableActionMenuProps = { + groups: DataTableActionMenuGroupProps[] +} interface DataTableProps { data?: TData[] columns: ColumnDef[] filters?: DataTableFilter[] commands?: DataTableCommand[] + action?: DataTableActionProps + actionMenu?: DataTableActionMenuProps rowCount?: number getRowId: (row: TData) => string enablePagination?: boolean @@ -36,6 +74,8 @@ export const DataTable = ({ columns, filters, commands, + action, + actionMenu, getRowId, rowCount = 0, enablePagination = true, @@ -135,6 +175,9 @@ export const DataTable = ({ }) } + const { pagination: paginationTranslations, toolbar: toolbarTranslations } = + useDataTableTranslations() + const instance = useDataTable({ data, columns, @@ -171,7 +214,10 @@ export const DataTable = ({ return ( - + {heading}
{enableSearch && ( @@ -182,13 +228,14 @@ export const DataTable = ({ )} {enableFiltering && } - + {actionMenu && } + {action && }
- {enablePagination && } + {enablePagination && ( + + )} {enableCommands && ( `${count} selected`} /> )} @@ -242,3 +289,51 @@ function parseFilterState( return filters } + +const useDataTableTranslations = () => { + const { t } = useTranslation() + + const paginationTranslations = { + of: t("general.of"), + results: t("general.results"), + pages: t("general.pages"), + prev: t("general.prev"), + next: t("general.next"), + } + + const toolbarTranslations = { + clearAll: t("actions.clearAll"), + } + + return { + pagination: paginationTranslations, + toolbar: toolbarTranslations, + } +} + +const DataTableAction = ({ + label, + disabled, + ...props +}: DataTableActionProps) => { + const buttonProps = { + size: "small" as const, + disabled: disabled ?? false, + type: "button" as const, + variant: "secondary" as const, + } + + if ("to" in props) { + return ( + + ) + } + + return ( + + ) +} diff --git a/packages/admin/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx b/packages/admin/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx index 3be55a83718a4..3a6ef2a59d0df 100644 --- a/packages/admin/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx +++ b/packages/admin/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx @@ -176,7 +176,7 @@ export const DataTableRoot = ({ : undefined, }} className={clx({ - "bg-ui-bg-base sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']": + "bg-ui-bg-subtle 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, diff --git a/packages/admin/dashboard/src/components/table/data-table/data-table.tsx b/packages/admin/dashboard/src/components/table/data-table/data-table.tsx index e4719ba01ea75..9c741d0812e86 100644 --- a/packages/admin/dashboard/src/components/table/data-table/data-table.tsx +++ b/packages/admin/dashboard/src/components/table/data-table/data-table.tsx @@ -18,7 +18,10 @@ interface DataTableProps // const MemoizedDataTableRoot = memo(DataTableRoot) as typeof DataTableRoot const MemoizedDataTableQuery = memo(DataTableQuery) as typeof DataTableQuery -export const DataTable = ({ +/** + * @deprecated Use the DataTable component from "/components/data-table" instead + */ +export const _DataTable = ({ table, columns, pagination, diff --git a/packages/admin/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-sales-channel-section/api-key-sales-channel-section.tsx b/packages/admin/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-sales-channel-section/api-key-sales-channel-section.tsx index d7b958811ad22..cda422b2ba411 100644 --- a/packages/admin/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-sales-channel-section/api-key-sales-channel-section.tsx +++ b/packages/admin/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-sales-channel-section/api-key-sales-channel-section.tsx @@ -6,7 +6,7 @@ import { RowSelectionState, createColumnHelper } from "@tanstack/react-table" import { useMemo, useState } from "react" import { useTranslation } from "react-i18next" import { ActionMenu } from "../../../../../components/common/action-menu" -import { DataTable } from "../../../../../components/table/data-table" +import { _DataTable } from "../../../../../components/table/data-table" import { useBatchRemoveSalesChannelsFromApiKey } from "../../../../../hooks/api/api-keys" import { useSalesChannels } from "../../../../../hooks/api/sales-channels" import { useSalesChannelTableColumns } from "../../../../../hooks/table/columns/use-sales-channel-table-columns" @@ -109,7 +109,7 @@ export const ApiKeySalesChannelSection = ({ ]} />
-
- - -
- {
- - { - - - - { - - - { - { onRowClick={(row) => { navigate(`/customer-groups/${row.id}`) }} + action={{ + label: t("actions.create"), + to: "/customer-groups/create", + }} emptyState={{ empty: { heading: "No customer groups", @@ -164,7 +168,7 @@ const useDateFilterOptions = () => { { label: "Yesterday", value: { - $lt: new Date( + $gte: new Date( startOfDay.getTime() - 24 * 60 * 60 * 1000 ).toISOString(), }, @@ -172,7 +176,7 @@ const useDateFilterOptions = () => { { label: "Last 7 days", value: { - $lt: new Date( + $gte: new Date( startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000 ).toISOString(), }, @@ -180,7 +184,7 @@ const useDateFilterOptions = () => { { label: "Last 30 days", value: { - $lt: new Date( + $gte: new Date( startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000 ).toISOString(), }, @@ -188,7 +192,7 @@ const useDateFilterOptions = () => { { label: "Last 90 days", value: { - $lt: new Date( + $gte: new Date( startOfDay.getTime() - 90 * 24 * 60 * 60 * 1000 ).toISOString(), }, diff --git a/packages/admin/dashboard/src/routes/customers/customer-detail/components/customer-group-section/customer-group-section.tsx b/packages/admin/dashboard/src/routes/customers/customer-detail/components/customer-group-section/customer-group-section.tsx index 649d0b8112f67..c5bd25b6951a7 100644 --- a/packages/admin/dashboard/src/routes/customers/customer-detail/components/customer-group-section/customer-group-section.tsx +++ b/packages/admin/dashboard/src/routes/customers/customer-detail/components/customer-group-section/customer-group-section.tsx @@ -16,9 +16,9 @@ import { keepPreviousData } from "@tanstack/react-query" import { useTranslation } from "react-i18next" import { Link } from "react-router-dom" import { ActionMenu } from "../../../../../components/common/action-menu/index.ts" -import { DataTable } from "../../../../../components/table/data-table/index.ts" +import { _DataTable } from "../../../../../components/table/data-table/index.ts" +import { useBatchCustomerCustomerGroups } from "../../../../../hooks/api" import { - customerGroupsQueryKeys, useCustomerGroups, useRemoveCustomersFromGroup, } from "../../../../../hooks/api/customer-groups.tsx" @@ -26,9 +26,6 @@ import { useCustomerGroupTableColumns } from "../../../../../hooks/table/columns import { useCustomerGroupTableFilters } from "../../../../../hooks/table/filters/use-customer-group-table-filters.tsx" import { useCustomerGroupTableQuery } from "../../../../../hooks/table/query/use-customer-group-table-query.tsx" import { useDataTable } from "../../../../../hooks/use-data-table.tsx" -import { sdk } from "../../../../../lib/client/index.ts" -import { queryClient } from "../../../../../lib/query-client.ts" -import { useBatchCustomerCustomerGroups } from "../../../../../hooks/api" type CustomerGroupSectionProps = { customer: HttpTypes.AdminCustomer @@ -129,7 +126,7 @@ export const CustomerGroupSection = ({ - */} {/**/} - { - - { {t("actions.create")} - >({ -
- - - - - - - - {
{t("orders.domain")}
- - { return (
-
- { {t("actions.create")}
- - {t("products.domain")} - { {t("actions.create")} - {t("products.domain")} - { {t("actions.create")} - - - - { - - { - { - - { ]} /> - { - { {t("actions.create")} - { {t("actions.create")} - - - { - { - - { ]} /> - {
{t("users.pendingInvites")} - { {t("users.invite")}
- { - { +interface DataTableFilterBarProps { + clearAllFiltersLabel?: string +} + +const DataTableFilterBar = ({ + clearAllFiltersLabel = "Clear all", +}: DataTableFilterBarProps) => { const { instance } = useDataTableContext() const filterState = instance.getFiltering() @@ -27,10 +34,16 @@ const DataTableFilterBar = () => { instance.clearFilters() }, [instance]) - if (Object.keys(filterState).length === 0) { + const filterCount = Object.keys(filterState).length + + if (filterCount === 0) { return null } + if (instance.showSkeleton) { + return + } + return (
{Object.values(filterState).map((filter) => ( @@ -40,7 +53,7 @@ const DataTableFilterBar = () => { label={getFilterLabel(filter)} /> ))} - {Object.keys(filterState).length > 0 ? ( + {filterCount > 0 ? ( ) : null}
) } +const DataTableFilterBarSkeleton = ({ + filterCount, +}: { + filterCount: number +}) => { + return ( +
+ {Array.from({ length: filterCount }).map((_, index) => ( + + ))} + {filterCount > 0 ? : null} +
+ ) +} + export { DataTableFilterBar } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx index f3a92e5d8e6d8..945c43d11425c 100644 --- a/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-filter-menu.tsx @@ -3,6 +3,7 @@ import * as React from "react" import { Funnel } from "@medusajs/icons" import { DropdownMenu } from "../../../components/dropdown-menu" import { IconButton } from "../../../components/icon-button" +import { Skeleton } from "../../../components/skeleton" import { Tooltip } from "../../../components/tooltip" import { useDataTableContext } from "../context/use-data-table-context" @@ -21,6 +22,10 @@ const DataTableFilterMenu = ({ tooltip }: DataTableFilterMenuProps) => { const Wrapper = tooltip ? Tooltip : React.Fragment + if (instance.showSkeleton) { + return + } + return (