Feat/datatable core enhancements (#13193)

**What**

This PR adds core DataTable enhancements to support view configurations in the admin dashboard. This is part of a set of stacked PRs to add the feature to Medusa.

- Puts handlers in place to update the visible columns in a table and the order in which they appear.
- Adds a ViewPills component for displaying and switching between saved view configurations
- Integrated view configuration hooks (useViewConfigurations) with the DataTable

Note: Column drag-and-drop reordering and the column visibility UI controls are not included in this PR as they require additional UI library updates - which will come in the next PR.

Example of what this looks like with the feature flag turned on - note the view pills with "default" in the top. This will expand when the data table behavior adds configuration.
<img width="2492" height="758" alt="CleanShot 2025-08-13 at 2  31 35@2x" src="https://github.com/user-attachments/assets/ee770f1c-dae1-49da-b255-1a6d615789de" />
This commit is contained in:
Sebastian Rindom
2025-09-01 12:30:05 +02:00
committed by GitHub
parent fa10c78ed3
commit 5a46372afd
12 changed files with 1368 additions and 83 deletions

View File

@@ -1,19 +1,19 @@
import {
Button,
clx,
DataTable as UiDataTable,
useDataTable,
DataTableColumnDef,
DataTableCommand,
DataTableEmptyStateProps,
DataTableFilter,
DataTableFilteringState,
DataTablePaginationState,
DataTableRow,
DataTableRowSelectionState,
DataTableSortingState,
Heading,
DataTable as Primitive,
Text,
useDataTable,
Button,
DataTableFilteringState,
DataTablePaginationState,
DataTableSortingState,
clx,
} from "@medusajs/ui"
import React, { ReactNode, useCallback, useMemo } from "react"
import { useTranslation } from "react-i18next"
@@ -21,31 +21,37 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom"
import { useQueryParams } from "../../hooks/use-query-params"
import { ActionMenu } from "../common/action-menu"
import { ViewPills } from "../table/view-selector"
import { useFeatureFlag } from "../../providers/feature-flag-provider"
// Types for column visibility and ordering
type VisibilityState = Record<string, boolean>
type ColumnOrderState = string[]
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[]
@@ -80,6 +86,19 @@ interface DataTableProps<TData> {
enableRowSelection?: boolean | ((row: DataTableRow<TData>) => boolean)
}
layout?: "fill" | "auto"
enableColumnVisibility?: boolean
initialColumnVisibility?: VisibilityState
onColumnVisibilityChange?: (visibility: VisibilityState) => void
columnOrder?: ColumnOrderState
onColumnOrderChange?: (order: ColumnOrderState) => void
enableViewSelector?: boolean
entity?: string
onViewChange?: (view: any) => void
currentColumns?: {
visible: string[]
order: string[]
}
filterBarContent?: React.ReactNode
}
export const DataTable = <TData,>({
@@ -103,13 +122,52 @@ export const DataTable = <TData,>({
rowSelection,
isLoading = false,
layout = "auto",
enableColumnVisibility = false,
initialColumnVisibility = {},
onColumnVisibilityChange,
columnOrder,
onColumnOrderChange,
enableViewSelector = false,
entity,
onViewChange,
currentColumns,
filterBarContent,
}: DataTableProps<TData>) => {
const { t } = useTranslation()
const isViewConfigEnabled = useFeatureFlag("view_configurations")
// If view config is disabled, don't use column visibility features
const effectiveEnableColumnVisibility = isViewConfigEnabled && enableColumnVisibility
const effectiveEnableViewSelector = isViewConfigEnabled && enableViewSelector
const enableFiltering = filters && filters.length > 0
const enableCommands = commands && commands.length > 0
const enableSorting = columns.some((column) => column.enableSorting)
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(initialColumnVisibility)
// Update column visibility when initial visibility changes
React.useEffect(() => {
// Deep compare to check if the visibility has actually changed
const currentKeys = Object.keys(columnVisibility).sort()
const newKeys = Object.keys(initialColumnVisibility).sort()
const hasChanged = currentKeys.length !== newKeys.length ||
currentKeys.some((key, index) => key !== newKeys[index]) ||
Object.entries(initialColumnVisibility).some(([key, value]) => columnVisibility[key] !== value)
if (hasChanged) {
setColumnVisibility(initialColumnVisibility)
}
}, [initialColumnVisibility])
// Wrapper function to handle column visibility changes
const handleColumnVisibilityChange = React.useCallback((visibility: VisibilityState) => {
setColumnVisibility(visibility)
onColumnVisibilityChange?.(visibility)
}, [onColumnVisibilityChange])
// Extract filter IDs for query param management
const filterIds = useMemo(() => filters?.map((f) => f.id) ?? [], [filters])
const prefixedFilterIds = filterIds.map((id) => getQueryParamKey(id, prefix))
@@ -167,18 +225,24 @@ export const DataTable = <TData,>({
const handleFilteringChange = (value: DataTableFilteringState) => {
setSearchParams((prev) => {
// Remove filters that are no longer in the state
Array.from(prev.keys()).forEach((key) => {
if (prefixedFilterIds.includes(key) && !(key in value)) {
prev.delete(key)
if (prefixedFilterIds.includes(key)) {
// Extract the unprefixed key
const unprefixedKey = prefix ? key.replace(`${prefix}_`, '') : key
if (!(unprefixedKey in value)) {
prev.delete(key)
}
}
})
// Add or update filters in the state
Object.entries(value).forEach(([key, filter]) => {
if (
prefixedFilterIds.includes(getQueryParamKey(key, prefix)) &&
filter
) {
prev.set(getQueryParamKey(key, prefix), JSON.stringify(filter))
const prefixedKey = getQueryParamKey(key, prefix)
if (filter !== undefined) {
prev.set(prefixedKey, JSON.stringify(filter))
} else {
prev.delete(prefixedKey)
}
})
@@ -190,6 +254,13 @@ export const DataTable = <TData,>({
return order ? parseSortingState(order) : null
}, [order])
// Memoize current configuration to prevent infinite loops
const currentConfiguration = useMemo(() => ({
filters: filtering,
sorting: sorting,
search: search,
}), [filtering, sorting, search])
const handleSortingChange = (value: DataTableSortingState) => {
setSearchParams((prev) => {
if (value) {
@@ -242,92 +313,99 @@ export const DataTable = <TData,>({
onRowClick: rowHref ? onRowClick : undefined,
pagination: enablePagination
? {
state: pagination,
onPaginationChange: handlePaginationChange,
}
state: pagination,
onPaginationChange: handlePaginationChange,
}
: undefined,
filtering: enableFiltering
? {
state: filtering,
onFilteringChange: handleFilteringChange,
}
state: filtering,
onFilteringChange: handleFilteringChange,
}
: undefined,
sorting: enableSorting
? {
state: sorting,
onSortingChange: handleSortingChange,
}
state: sorting,
onSortingChange: handleSortingChange,
}
: undefined,
search: enableSearch
? {
state: search,
onSearchChange: handleSearchChange,
}
state: search,
onSearchChange: handleSearchChange,
}
: undefined,
rowSelection,
isLoading,
columnVisibility: effectiveEnableColumnVisibility
? {
state: columnVisibility,
onColumnVisibilityChange: handleColumnVisibilityChange,
}
: undefined,
columnOrder: effectiveEnableColumnVisibility && columnOrder && onColumnOrderChange
? {
state: columnOrder,
onColumnOrderChange: onColumnOrderChange,
}
: undefined,
})
const shouldRenderHeading = heading || subHeading
return (
<Primitive
<UiDataTable
instance={instance}
className={clx({
"h-full [&_tr]:last-of-type:!border-b": layout === "fill",
})}
className={layout === "fill" ? "h-full [&_tr]:last-of-type:!border-b" : undefined}
>
<Primitive.Toolbar
<UiDataTable.Toolbar
className="flex flex-col items-start justify-between gap-2 md:flex-row md:items-center"
translations={toolbarTranslations}
filterBarContent={filterBarContent}
>
<div className="flex w-full items-center justify-between gap-2">
{shouldRenderHeading && (
<div>
{heading && <Heading>{heading}</Heading>}
{subHeading && (
<Text size="small" className="text-ui-fg-subtle">
{subHeading}
</Text>
)}
</div>
)}
<div className="flex items-center justify-end gap-x-2 md:hidden">
{enableFiltering && (
<Primitive.FilterMenu tooltip={t("filters.filterLabel")} />
<div className="flex items-center gap-x-4">
{shouldRenderHeading && (
<div>
{heading && <Heading>{heading}</Heading>}
{subHeading && (
<Text size="small" className="text-ui-fg-subtle">
{subHeading}
</Text>
)}
</div>
)}
<Primitive.SortingMenu tooltip={t("filters.sortLabel")} />
{actionMenu && <ActionMenu variant="primary" {...actionMenu} />}
{action && <DataTableAction {...action} />}
</div>
</div>
<div className="flex w-full items-center gap-2 md:justify-end">
{enableSearch && (
<div className="w-full md:w-auto">
<Primitive.Search
placeholder={t("filters.searchLabel")}
autoFocus={autoFocusSearch}
{effectiveEnableViewSelector && entity && (
<ViewPills
entity={entity}
onViewChange={onViewChange}
currentColumns={currentColumns}
currentConfiguration={currentConfiguration}
/>
</div>
)}
<div className="hidden items-center gap-x-2 md:flex">
{enableFiltering && (
<Primitive.FilterMenu tooltip={t("filters.filterLabel")} />
)}
<Primitive.SortingMenu tooltip={t("filters.sortLabel")} />
</div>
<div className="flex items-center gap-x-2">
{enableSearch && (
<div className="w-full md:w-auto">
<UiDataTable.Search
placeholder={t("filters.searchLabel")}
autoFocus={autoFocusSearch}
/>
</div>
)}
{actionMenu && <ActionMenu variant="primary" {...actionMenu} />}
{action && <DataTableAction {...action} />}
</div>
</div>
</Primitive.Toolbar>
<Primitive.Table emptyState={emptyState} />
</UiDataTable.Toolbar>
<UiDataTable.Table emptyState={emptyState} />
{enablePagination && (
<Primitive.Pagination translations={paginationTranslations} />
<UiDataTable.Pagination translations={paginationTranslations} />
)}
{enableCommands && (
<Primitive.CommandBar selectedLabel={(count) => `${count} selected`} />
<UiDataTable.CommandBar selectedLabel={(count) => `${count} selected`} />
)}
</Primitive>
</UiDataTable>
)
}
@@ -367,7 +445,7 @@ function parseFilterState(
for (const id of filterIds) {
const filterValue = value[id]
if (filterValue) {
if (filterValue !== undefined) {
filters[id] = JSON.parse(filterValue)
}
}
@@ -392,6 +470,8 @@ const useDataTableTranslations = () => {
const toolbarTranslations = {
clearAll: t("actions.clearAll"),
sort: t("filters.sortLabel"),
columns: "Columns",
}
return {
@@ -426,3 +506,4 @@ const DataTableAction = ({
</Button>
)
}