diff --git a/packages/core/js-sdk/src/admin/views.ts b/packages/core/js-sdk/src/admin/views.ts index 85e0be57a9..7d1d2b860c 100644 --- a/packages/core/js-sdk/src/admin/views.ts +++ b/packages/core/js-sdk/src/admin/views.ts @@ -19,7 +19,6 @@ export class Views { }) } - // View configurations async listConfigurations( entity: string, query?: HttpTypes.AdminGetViewConfigurationsParams, diff --git a/packages/design-system/ui/package.json b/packages/design-system/ui/package.json index 768ca47524..ed5b47aa34 100644 --- a/packages/design-system/ui/package.json +++ b/packages/design-system/ui/package.json @@ -81,6 +81,9 @@ "vitest": "^3.0.5" }, "dependencies": { + "@dnd-kit/core": "^6.0.0", + "@dnd-kit/sortable": "^7.0.0", + "@dnd-kit/utilities": "^3.2.0", "@medusajs/icons": "2.10.1", "@tanstack/react-table": "8.20.5", "clsx": "^1.2.1", diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-column-visibility-menu.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-column-visibility-menu.tsx new file mode 100644 index 0000000000..337cd32001 --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-column-visibility-menu.tsx @@ -0,0 +1,102 @@ +import React from "react" +import { Column } from "@tanstack/react-table" + +import { Checkbox } from "@/components/checkbox" +import { DropdownMenu } from "@/components/dropdown-menu" +import { IconButton } from "@/components/icon-button" +import { Tooltip } from "@/components/tooltip" +import { Adjustments } from "@medusajs/icons" +import { clx } from "@/utils/clx" + +import { useDataTableContext } from "../context/use-data-table-context" + +interface DataTableColumnVisibilityMenuProps { + className?: string + tooltip?: string +} + +const DataTableColumnVisibilityMenu = ({ + className, + tooltip, +}: DataTableColumnVisibilityMenuProps) => { + const { instance, enableColumnVisibility } = useDataTableContext() + + if (!enableColumnVisibility) { + return null + } + + const columns = instance + .getAllColumns() + .filter((column) => column.getCanHide()) + + const handleToggleColumn = (column: Column) => { + column.toggleVisibility() + } + + const handleToggleAll = (value: boolean) => { + instance.setColumnVisibility( + Object.fromEntries( + columns.map((column: Column) => [column.id, value]) + ) + ) + } + + const allColumnsVisible = columns.every((column: Column) => column.getIsVisible()) + const someColumnsVisible = columns.some((column: Column) => column.getIsVisible()) + + const Wrapper = tooltip ? Tooltip : React.Fragment + const wrapperProps = tooltip ? { content: tooltip } : ({} as any) + + return ( + + + + + + + + + + Toggle columns + + { + e.preventDefault() + handleToggleAll(!allColumnsVisible) + }} + > +
+ + Toggle all +
+
+ +
+ {columns.map((column: Column) => { + return ( + { + e.preventDefault() + handleToggleColumn(column) + }} + > +
+ + + {(column.columnDef.meta as any)?.name || column.id} + +
+
+ ) + })} +
+
+
+ ) +} + +export { DataTableColumnVisibilityMenu } +export type { DataTableColumnVisibilityMenuProps } 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 9540b80c90..b87a2f653c 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 @@ -3,28 +3,114 @@ import * as React from "react" import { DataTableFilter } from "@/blocks/data-table/components/data-table-filter" +import { DataTableFilterMenu } from "@/blocks/data-table/components/data-table-filter-menu" +import { DataTableSortingMenu } from "@/blocks/data-table/components/data-table-sorting-menu" +import { DataTableColumnVisibilityMenu } from "@/blocks/data-table/components/data-table-column-visibility-menu" import { useDataTableContext } from "@/blocks/data-table/context/use-data-table-context" import { Button } from "@/components/button" import { Skeleton } from "@/components/skeleton" interface DataTableFilterBarProps { clearAllFiltersLabel?: string + alwaysShow?: boolean + sortingTooltip?: string + columnsTooltip?: string + children?: React.ReactNode +} + +interface LocalFilter { + id: string + value: unknown + isNew: boolean } const DataTableFilterBar = ({ clearAllFiltersLabel = "Clear all", + alwaysShow = false, + sortingTooltip, + columnsTooltip, + children, }: DataTableFilterBarProps) => { - const { instance } = useDataTableContext() - - const filterState = instance.getFiltering() + const { instance, enableColumnVisibility } = useDataTableContext() + + // Local state for managing intermediate filters + const [localFilters, setLocalFilters] = React.useState([]) + + const parentFilterState = instance.getFiltering() + const availableFilters = instance.getFilters() + + // Sync parent filters with local state + React.useEffect(() => { + setLocalFilters(prevLocalFilters => { + const parentIds = Object.keys(parentFilterState) + const localIds = prevLocalFilters.map(f => f.id) + + // Remove local filters that have been removed from parent + const updatedLocalFilters = prevLocalFilters.filter(f => + parentIds.includes(f.id) || f.isNew + ) + + // Add parent filters that don't exist locally + parentIds.forEach(id => { + if (!localIds.includes(id)) { + updatedLocalFilters.push({ + id, + value: parentFilterState[id], + isNew: false + }) + } + }) + + // Only update if there's an actual change + if (updatedLocalFilters.length !== prevLocalFilters.length || + updatedLocalFilters.some((f, i) => f.id !== prevLocalFilters[i]?.id)) { + return updatedLocalFilters + } + return prevLocalFilters + }) + }, [parentFilterState]) + + // Add a new filter locally + const addLocalFilter = React.useCallback((id: string, value: unknown) => { + setLocalFilters(prev => [...prev, { id, value, isNew: true }]) + }, []) + + // Update a local filter's value + const updateLocalFilter = React.useCallback((id: string, value: unknown) => { + setLocalFilters(prev => prev.map(f => + f.id === id ? { ...f, value, isNew: false } : f + )) + + // If the filter has a meaningful value, propagate to parent + if (value !== undefined && value !== null && value !== '' && + !(Array.isArray(value) && value.length === 0)) { + instance.updateFilter({ id, value }) + } + }, [instance]) + + // Remove a local filter + const removeLocalFilter = React.useCallback((id: string) => { + setLocalFilters(prev => prev.filter(f => f.id !== id)) + // Also remove from parent if it exists there + if (parentFilterState[id] !== undefined) { + instance.removeFilter(id) + } + }, [instance, parentFilterState]) const clearFilters = React.useCallback(() => { + setLocalFilters([]) instance.clearFilters() }, [instance]) - const filterCount = Object.keys(filterState).length + const filterCount = localFilters.length + const hasAvailableFilters = availableFilters.length > 0 + + // Check if sorting is enabled + const sortableColumns = instance.getAllColumns().filter((column) => column.getCanSort()) + const hasSorting = instance.enableSorting && sortableColumns.length > 0 - if (filterCount === 0) { + // Always show the filter bar when there are available filters, sorting, column visibility, or when forced + if (filterCount === 0 && !hasAvailableFilters && !hasSorting && !enableColumnVisibility && !alwaysShow && !children) { return null } @@ -33,21 +119,27 @@ const DataTableFilterBar = ({ } return ( -
- {Object.entries(filterState).map(([id, filter]) => ( - - ))} - {filterCount > 0 ? ( - - ) : null} +
+
+ {localFilters.map((localFilter) => ( + updateLocalFilter(localFilter.id, value)} + onRemove={() => removeLocalFilter(localFilter.id)} + /> + ))} + {hasAvailableFilters && ( + + )} +
+
+ {hasSorting && } + {enableColumnVisibility && } + {children} +
) } 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 9a9c8507fd..b47f90ad03 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 @@ -12,13 +12,17 @@ interface DataTableFilterMenuProps { * The tooltip to show when hovering over the filter menu. */ tooltip?: string + /** + * Callback when a filter is added + */ + onAddFilter?: (id: string, value: unknown) => void } /** * This component adds a filter menu to the data table, allowing users * to filter the table's data. */ -const DataTableFilterMenu = (props: DataTableFilterMenuProps) => { +const DataTableFilterMenu = ({ tooltip, onAddFilter }: DataTableFilterMenuProps) => { const { instance } = useDataTableContext() const enabledFilters = Object.keys(instance.getFiltering()) @@ -33,9 +37,9 @@ const DataTableFilterMenu = (props: DataTableFilterMenuProps) => { ) } - const Wrapper = props.tooltip ? Tooltip : React.Fragment - const wrapperProps = props.tooltip - ? { content: props.tooltip, hidden: filterOptions.length === 0 } + const Wrapper = tooltip ? Tooltip : React.Fragment + const wrapperProps = tooltip + ? { content: tooltip, hidden: filterOptions.length === 0 } : ({} as any) if (instance.showSkeleton) { @@ -51,17 +55,45 @@ const DataTableFilterMenu = (props: DataTableFilterMenuProps) => { - - {filterOptions.map((filter) => ( - { - instance.addFilter({ id: filter.id, value: undefined }) - }} - > - {filter.label} - - ))} + + {filterOptions.map((filter) => { + const getDefaultValue = () => { + switch (filter.type) { + case "select": + case "multiselect": + return [] + case "string": + return "" + case "number": + return null + case "date": + return null + case "radio": + return null + case "custom": + return null + default: + return null + } + } + + return ( + { + // Prevent any bubbling that might interfere + e.stopPropagation() + if (onAddFilter) { + onAddFilter(filter.id, getDefaultValue()) + } else { + instance.addFilter({ id: filter.id, value: getDefaultValue() }) + } + }} + > + {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 1632a9d479..7accde300a 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,23 +1,32 @@ -"use client" - -import { CheckMini, EllipseMiniSolid, XMark } from "@medusajs/icons" +import { CheckMini, EllipseMiniSolid, XMark, XMarkMini, MagnifyingGlass } from "@medusajs/icons" import * as React from "react" import { useDataTableContext } from "@/blocks/data-table/context/use-data-table-context" import type { DataTableDateComparisonOperator, + DataTableNumberComparisonOperator, DataTableDateFilterProps, + DataTableMultiselectFilterProps, + DataTableStringFilterProps, + DataTableNumberFilterProps, + DataTableCustomFilterProps, DataTableFilterOption, } from "@/blocks/data-table/types" import { isDateComparisonOperator } from "@/blocks/data-table/utils/is-date-comparison-operator" import { DatePicker } from "@/components/date-picker" import { Label } from "@/components/label" import { Popover } from "@/components/popover" +import { Input } from "@/components/input" +import { Select } from "@/components/select" +import { Checkbox } from "@/components/checkbox" import { clx } from "@/utils/clx" interface DataTableFilterProps { id: string filter: unknown + isNew?: boolean + onUpdate?: (value: unknown) => void + onRemove?: () => void } const DEFAULT_FORMAT_DATE_VALUE = (d: Date) => @@ -30,56 +39,106 @@ const DEFAULT_RANGE_OPTION_LABEL = "Custom" const DEFAULT_RANGE_OPTION_START_LABEL = "Starting" const DEFAULT_RANGE_OPTION_END_LABEL = "Ending" -const DataTableFilter = ({ id, filter }: DataTableFilterProps) => { +const DataTableFilter = ({ id, filter, isNew = false, onUpdate, onRemove }: DataTableFilterProps) => { const { instance } = useDataTableContext() - const [open, setOpen] = React.useState(filter === undefined) - const [isCustom, setIsCustom] = React.useState(false) + + // Initialize open state based on isNew prop + const [open, setOpen] = React.useState(isNew) + const [hasInteracted, setHasInteracted] = React.useState(false) + + const meta = instance.getFilterMeta(id) + + if (!meta) { + return null + } + + const { type, label, ...rest } = meta + const options = (meta as any).options + + // Helper to check if filter has a meaningful value + const hasValue = React.useMemo(() => { + if (filter === null || filter === undefined) return false + if (typeof filter === "string" && filter === "") return false + if (Array.isArray(filter) && filter.length === 0) return false + if (typeof filter === "number") return true + if (isDateComparisonOperator(filter)) { + return !!(filter.$gte || filter.$lte || filter.$gt || filter.$lt) + } + if (typeof filter === "object" && filter !== null) { + // For number comparison operators + const keys = Object.keys(filter) + return keys.length > 0 && (filter as any)[keys[0]] !== null && (filter as any)[keys[0]] !== undefined + } + return true + }, [filter]) const onOpenChange = React.useCallback( - (open: boolean) => { - if ( - !open && - (!filter || (Array.isArray(filter) && filter.length === 0)) - ) { - instance.removeFilter(id) + (newOpen: boolean) => { + setOpen(newOpen) + + // Mark as interacted when user closes + if (!newOpen && open) { + setHasInteracted(true) } - setOpen(open) + // If closing without a value, remove filter + // For new filters that haven't been interacted with, remove immediately + if (!newOpen && !hasValue) { + // Only remove if it's a new filter being closed without interaction, + // or if it's an existing filter with no value + if ((isNew && !hasInteracted) || !isNew) { + if (onRemove) { + onRemove() + } else { + instance.removeFilter(id) + } + } + } }, - [instance, id, filter] + [instance, id, open, hasInteracted, isNew, hasValue, onRemove] ) const removeFilter = React.useCallback(() => { - instance.removeFilter(id) - }, [instance, id]) - - const meta = instance.getFilterMeta(id) - const { type, options, label, ...rest } = meta ?? {} + if (onRemove) { + onRemove() + } else { + instance.removeFilter(id) + } + }, [instance, id, onRemove]) const { displayValue, isCustomRange } = React.useMemo(() => { let displayValue: string | null = null let isCustomRange = false if (typeof filter === "string") { - displayValue = options?.find((o) => o.value === filter)?.label ?? null + // For string filters without options, just show the value + if (!options || options.length === 0) { + displayValue = filter + } else { + displayValue = options?.find((o: any) => o.value === filter)?.label ?? null + } + } + + if (typeof filter === "number") { + displayValue = String(filter) } if (Array.isArray(filter)) { displayValue = filter - .map((v) => options?.find((o) => o.value === v)?.label) + .map((v) => options?.find((o: any) => o.value === v)?.label) .join(", ") ?? null } if (isDateComparisonOperator(filter)) { + // First check if it matches a predefined option displayValue = - options?.find((o) => { + options?.find((o: any) => { if (!isDateComparisonOperator(o.value)) { return false } return ( - !isCustom && (filter.$gte === o.value.$gte || (!filter.$gte && !o.value.$gte)) && (filter.$lte === o.value.$lte || (!filter.$lte && !o.value.$lte)) && (filter.$gt === o.value.$gt || (!filter.$gt && !o.value.$gt)) && @@ -87,27 +146,24 @@ const DataTableFilter = ({ id, filter }: DataTableFilterProps) => { ) })?.label ?? null + // If no match found, it's a custom range if (!displayValue && isDateFilterProps(meta)) { + isCustomRange = true const formatDateValue = meta.formatDateValue ? meta.formatDateValue : DEFAULT_FORMAT_DATE_VALUE if (filter.$gte && !filter.$lte) { - isCustomRange = true - displayValue = `${ - meta.rangeOptionStartLabel || DEFAULT_RANGE_OPTION_START_LABEL - } ${formatDateValue(new Date(filter.$gte))}` + displayValue = `${meta.rangeOptionStartLabel || DEFAULT_RANGE_OPTION_START_LABEL + } ${formatDateValue(new Date(filter.$gte))}` } if (filter.$lte && !filter.$gte) { - isCustomRange = true - displayValue = `${ - meta.rangeOptionEndLabel || DEFAULT_RANGE_OPTION_END_LABEL - } ${formatDateValue(new Date(filter.$lte))}` + displayValue = `${meta.rangeOptionEndLabel || DEFAULT_RANGE_OPTION_END_LABEL + } ${formatDateValue(new Date(filter.$lte))}` } if (filter.$gte && filter.$lte) { - isCustomRange = true displayValue = `${formatDateValue( new Date(filter.$gte) )} - ${formatDateValue(new Date(filter.$lte))}` @@ -115,62 +171,109 @@ const DataTableFilter = ({ id, filter }: DataTableFilterProps) => { } } - return { displayValue, isCustomRange } - }, [filter, options]) + // Handle number comparison operators + if (typeof filter === "object" && filter !== null && !Array.isArray(filter) && !isDateComparisonOperator(filter)) { + const operators: Record = { + $eq: "=", + $gt: ">", + $gte: "≥", + $lt: "<", + $lte: "≤", + } - React.useEffect(() => { - if (isCustomRange && !isCustom) { - setIsCustom(true) + const op = Object.keys(filter)[0] + const opLabel = operators[op] || op + const value = (filter as any)[op] + + if (typeof value === "number") { + displayValue = `${opLabel} ${value}` + } } - }, [isCustomRange, isCustom]) - if (!meta) { - return null - } + return { displayValue, isCustomRange } + }, [filter, options, meta]) return ( - +
+ {!hasValue && isNew && }
*]:txt-compact-small-plus [&>*]:flex [&>*]:items-center [&>*]:justify-center", + "flex items-center px-2 py-1 text-ui-fg-muted", { - "shadow-borders-base divide-x": displayValue, - "border border-dashed": !displayValue, + "border-r": hasValue } )} > - {displayValue && ( -
- {label || id} -
- )} - + {hasValue && ( + <> + {(type === "select" || type === "multiselect" || type === "radio") && ( +
+ is +
)} - > - {displayValue || label || id} -
- - {displayValue && ( + + + - )} -
-
+ + )} +
{ + if (isNew) { + // For new filters, ensure the first input gets focus + const target = e.currentTarget as HTMLElement + if (target) { + const firstInput = target.querySelector( + 'input:not([type="hidden"]), [role="list"][tabindex="0"]' + ) as HTMLElement | null + firstInput?.focus() + } + } + }} + onCloseAutoFocus={(e) => { + // Prevent focus from going to the trigger when closing + e.preventDefault() + }} + onInteractOutside={(e) => { + // Check if the click is on a filter menu item + const target = e.target as HTMLElement + if (target.closest('[role="menuitem"]')) { + e.preventDefault() + } + }} > {(() => { switch (type) { @@ -180,6 +283,8 @@ const DataTableFilter = ({ id, filter }: DataTableFilterProps) => { id={id} filter={filter as string[] | undefined} options={options as DataTableFilterOption[]} + isNew={isNew} + onUpdate={onUpdate} /> ) case "radio": @@ -188,9 +293,11 @@ const DataTableFilter = ({ id, filter }: DataTableFilterProps) => { id={id} filter={filter} options={options as DataTableFilterOption[]} + onUpdate={onUpdate} /> ) case "date": + const dateRest = rest as Omit return ( { options={ options as DataTableFilterOption[] } - isCustom={isCustom} - setIsCustom={setIsCustom} - {...rest} + isCustomRange={isCustomRange} + format={dateRest.format} + rangeOptionLabel={dateRest.rangeOptionLabel} + disableRangeOption={dateRest.disableRangeOption} + rangeOptionStartLabel={dateRest.rangeOptionStartLabel} + rangeOptionEndLabel={dateRest.rangeOptionEndLabel} + onUpdate={onUpdate} + /> + ) + case "multiselect": + const multiselectRest = rest as Omit + return ( + []} + searchable={multiselectRest.searchable} + onUpdate={onUpdate} + /> + ) + case "string": + const stringRest = rest as Omit + return ( + + ) + case "number": + const numberRest = rest as Omit + return ( + + ) + case "custom": + const customRest = rest as Omit + return ( + ) default: @@ -217,8 +371,8 @@ type DataTableFilterDateContentProps = { id: string filter: unknown options: DataTableFilterOption[] - isCustom: boolean - setIsCustom: (isCustom: boolean) => void + isCustomRange: boolean + onUpdate?: (value: unknown) => void } & Pick< DataTableDateFilterProps, | "format" @@ -237,11 +391,17 @@ const DataTableFilterDateContent = ({ rangeOptionStartLabel = DEFAULT_RANGE_OPTION_START_LABEL, rangeOptionEndLabel = DEFAULT_RANGE_OPTION_END_LABEL, disableRangeOption = false, - isCustom, - setIsCustom, + isCustomRange, + onUpdate, }: DataTableFilterDateContentProps) => { const currentValue = filter as DataTableDateComparisonOperator | undefined const { instance } = useDataTableContext() + const [isCustom, setIsCustom] = React.useState(isCustomRange) + + // Sync isCustom state when isCustomRange changes + React.useEffect(() => { + setIsCustom(isCustomRange) + }, [isCustomRange]) const selectedValue = React.useMemo(() => { if (!currentValue || isCustom) { @@ -256,23 +416,31 @@ const DataTableFilterDateContent = ({ setIsCustom(false) const value = JSON.parse(valueStr) as DataTableDateComparisonOperator - instance.updateFilter({ id, value }) + if (onUpdate) { + onUpdate(value) + } else { + instance.updateFilter({ id, value }) + } }, - [instance, id] + [instance, id, onUpdate] ) const onSelectCustom = React.useCallback(() => { setIsCustom(true) - instance.updateFilter({ id, value: undefined }) - }, [instance, id]) + // Don't clear the value when selecting custom - keep the current value + }, []) const onCustomValueChange = React.useCallback( (input: "$gte" | "$lte", value: Date | null) => { const newCurrentValue = { ...currentValue } newCurrentValue[input] = value ? value.toISOString() : undefined - instance.updateFilter({ id, value: newCurrentValue }) + if (onUpdate) { + onUpdate(newCurrentValue) + } else { + instance.updateFilter({ id, value: newCurrentValue }) + } }, - [instance, id] + [instance, id, currentValue, onUpdate] ) const { focusedIndex, setFocusedIndex } = useKeyboardNavigation( @@ -392,68 +560,105 @@ type DataTableFilterSelectContentProps = { id: string filter?: string[] options: DataTableFilterOption[] + isNew?: boolean + onUpdate?: (value: unknown) => void } const DataTableFilterSelectContent = ({ id, filter = [], options, + isNew = false, + onUpdate, }: DataTableFilterSelectContentProps) => { const { instance } = useDataTableContext() + const [search, setSearch] = React.useState("") + + const filteredOptions = React.useMemo(() => { + if (!search) return options + + const searchLower = search.toLowerCase() + return options.filter(opt => + opt.label.toLowerCase().includes(searchLower) + ) + }, [options, search]) const onValueChange = React.useCallback( (value: string) => { if (filter?.includes(value)) { const newValues = filter?.filter((v) => v !== value) - instance.updateFilter({ - id, - value: newValues, - }) + const newValue = newValues.length > 0 ? newValues : undefined + if (onUpdate) { + onUpdate(newValue) + } else { + instance.updateFilter({ + id, + value: newValue, + }) + } } else { - instance.updateFilter({ - id, - value: [...(filter ?? []), value], - }) + const newValue = [...(filter ?? []), value] + if (onUpdate) { + onUpdate(newValue) + } else { + instance.updateFilter({ + id, + value: newValue, + }) + } } }, - [instance, id, filter] + [instance, id, filter, onUpdate] ) - const { focusedIndex, setFocusedIndex } = useKeyboardNavigation( - options, - (index) => onValueChange(options[index].value) - ) - - const onListFocus = React.useCallback(() => { - if (focusedIndex === -1) { - setFocusedIndex(0) - } - }, [focusedIndex]) - return ( -
- {options.map((option, idx) => { - const isSelected = !!filter?.includes(option.value) +
+
+ + setSearch(e.target.value)} + placeholder="Search..." + className="h-8 flex-1 bg-transparent text-sm outline-none placeholder:text-ui-fg-muted" + autoFocus + /> + {search && ( + + )} +
- return ( - onValueChange(option.value)} - onMouseEvent={setFocusedIndex} - icon={CheckMini} - /> - ) - })} +
+ {filteredOptions.length === 0 && ( +
+ No results found +
+ )} + + {filteredOptions.map(option => { + const isSelected = filter?.includes(option.value) + + return ( + + ) + })} +
) } @@ -462,20 +667,26 @@ type DataTableFilterRadioContentProps = { id: string filter: unknown options: DataTableFilterOption[] + onUpdate?: (value: unknown) => void } const DataTableFilterRadioContent = ({ id, filter, options, + onUpdate, }: DataTableFilterRadioContentProps) => { const { instance } = useDataTableContext() const onValueChange = React.useCallback( (value: string) => { - instance.updateFilter({ id, value }) + if (onUpdate) { + onUpdate(value) + } else { + instance.updateFilter({ id, value }) + } }, - [instance, id] + [instance, id, onUpdate] ) const { focusedIndex, setFocusedIndex } = useKeyboardNavigation( @@ -525,6 +736,38 @@ function isDateFilterProps(props?: unknown | null): props is DataTableDateFilter return (props as DataTableDateFilterProps).type === "date" } +function isMultiselectFilterProps(props?: unknown | null): props is DataTableMultiselectFilterProps { + if (!props) { + return false + } + + return (props as DataTableMultiselectFilterProps).type === "multiselect" +} + +function isStringFilterProps(props?: unknown | null): props is DataTableStringFilterProps { + if (!props) { + return false + } + + return (props as DataTableStringFilterProps).type === "string" +} + +function isNumberFilterProps(props?: unknown | null): props is DataTableNumberFilterProps { + if (!props) { + return false + } + + return (props as DataTableNumberFilterProps).type === "number" +} + +function isCustomFilterProps(props?: unknown | null): props is DataTableCustomFilterProps { + if (!props) { + return false + } + + return (props as DataTableCustomFilterProps).type === "custom" +} + type OptionButtonProps = { index: number option: DataTableFilterOption @@ -612,6 +855,426 @@ function useKeyboardNavigation( return { focusedIndex, setFocusedIndex } } +type DataTableFilterMultiselectContentProps = { + id: string + filter?: string[] + options: DataTableFilterOption[] + searchable?: boolean + onUpdate?: (value: unknown) => void +} + +const DataTableFilterMultiselectContent = ({ + id, + filter = [], + options, + searchable = true, + onUpdate, +}: DataTableFilterMultiselectContentProps) => { + const { instance } = useDataTableContext() + const [search, setSearch] = React.useState("") + + const filteredOptions = React.useMemo(() => { + if (!searchable || !search) return options + + const searchLower = search.toLowerCase() + return options.filter(opt => + opt.label.toLowerCase().includes(searchLower) + ) + }, [options, search, searchable]) + + const onValueChange = React.useCallback( + (value: string) => { + if (filter?.includes(value)) { + const newValues = filter?.filter((v) => v !== value) + const newValue = newValues.length > 0 ? newValues : undefined + if (onUpdate) { + onUpdate(newValue) + } else { + instance.updateFilter({ + id, + value: newValue, + }) + } + } else { + const newValue = [...(filter ?? []), value] + if (onUpdate) { + onUpdate(newValue) + } else { + instance.updateFilter({ + id, + value: newValue, + }) + } + } + }, + [instance, id, filter, onUpdate] + ) + + if (!searchable) { + return ( +
+
+ {options.map(option => { + const isSelected = filter?.includes(option.value) + + return ( + + ) + })} +
+
+ ) + } + + return ( +
+
+ + setSearch(e.target.value)} + placeholder="Search..." + className="h-8 flex-1 bg-transparent text-sm outline-none placeholder:text-ui-fg-muted" + autoFocus + /> + {search && ( + + )} +
+ +
+ {filteredOptions.length === 0 && ( +
+ No results found +
+ )} + + {filteredOptions.map(option => { + const isSelected = filter?.includes(option.value) + + return ( + + ) + })} +
+
+ ) +} + +type DataTableFilterStringContentProps = { + id: string + filter?: string + placeholder?: string + onUpdate?: (value: unknown) => void +} + +const DataTableFilterStringContent = ({ + id, + filter, + placeholder = "Enter value...", + onUpdate, +}: DataTableFilterStringContentProps) => { + const { instance } = useDataTableContext() + const [value, setValue] = React.useState(filter || "") + const timeoutRef = React.useRef | null>(null) + + const handleChange = React.useCallback((newValue: string) => { + setValue(newValue) + + // Clear existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + + // Debounce the update + timeoutRef.current = setTimeout(() => { + const updateValue = newValue.trim() || undefined + if (onUpdate) { + onUpdate(updateValue) + } else { + instance.updateFilter({ + id, + value: updateValue, + }) + } + }, 500) + }, [instance, id, onUpdate]) + + // Cleanup timeout on unmount + React.useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + } + }, []) + + const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter") { + // Clear timeout and apply immediately + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + const updateValue = value.trim() || undefined + if (onUpdate) { + onUpdate(updateValue) + } else { + instance.updateFilter({ + id, + value: updateValue, + }) + } + } + }, [instance, id, value, onUpdate]) + + return ( +
+ handleChange(e.target.value)} + onKeyDown={handleKeyDown} + autoFocus + /> +
+ ) +} + +type DataTableFilterNumberContentProps = { + id: string + filter: any + placeholder?: string + includeOperators?: boolean + onUpdate?: (value: unknown) => void +} + +const DataTableFilterNumberContent = ({ + id, + filter, + placeholder = "Enter number...", + includeOperators = true, + onUpdate, +}: DataTableFilterNumberContentProps) => { + const { instance } = useDataTableContext() + const [operator, setOperator] = React.useState("eq") + const [value, setValue] = React.useState("") + const timeoutRef = React.useRef | null>(null) + + React.useEffect(() => { + if (filter) { + if (typeof filter === "number") { + setOperator("eq") + setValue(String(filter)) + } else if (typeof filter === "object") { + const op = Object.keys(filter)[0] as string + setOperator(op.replace("$", "")) + setValue(String(filter[op])) + } + } + }, [filter]) + + const handleValueChange = React.useCallback((newValue: string) => { + setValue(newValue) + + // Clear existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + + // Debounce the update + timeoutRef.current = setTimeout(() => { + const num = parseFloat(newValue) + if (!isNaN(num)) { + const filterValue = includeOperators && operator !== "eq" + ? { [`$${operator}`]: num } + : num + + if (onUpdate) { + onUpdate(filterValue) + } else { + instance.updateFilter({ + id, + value: filterValue, + }) + } + } else if (newValue === "") { + if (onUpdate) { + onUpdate(undefined) + } else { + instance.updateFilter({ + id, + value: undefined, + }) + } + } + }, 500) + }, [instance, id, operator, includeOperators, onUpdate]) + + const handleOperatorChange = React.useCallback((newOperator: string) => { + setOperator(newOperator) + + // If we have a value, update immediately with new operator + const num = parseFloat(value) + if (!isNaN(num)) { + const filterValue = includeOperators && newOperator !== "eq" + ? { [`$${newOperator}`]: num } + : num + + if (onUpdate) { + onUpdate(filterValue) + } else { + instance.updateFilter({ + id, + value: filterValue, + }) + } + } + }, [instance, id, value, includeOperators, onUpdate]) + + // Cleanup timeout on unmount + React.useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + } + }, []) + + const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter") { + // Clear timeout and apply immediately + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + const num = parseFloat(value) + if (!isNaN(num)) { + const filterValue = includeOperators && operator !== "eq" + ? { [`$${operator}`]: num } + : num + + if (onUpdate) { + onUpdate(filterValue) + } else { + instance.updateFilter({ + id, + value: filterValue, + }) + } + } + } + }, [instance, id, value, operator, includeOperators, onUpdate]) + + const operators = [ + { value: "eq", label: "Equals" }, + { value: "gt", label: "Greater than" }, + { value: "gte", label: "Greater than or equal" }, + { value: "lt", label: "Less than" }, + { value: "lte", label: "Less than or equal" }, + ] + + return ( +
+ {includeOperators && ( + + )} + + handleValueChange(e.target.value)} + onKeyDown={handleKeyDown} + autoFocus={!includeOperators} + /> +
+ ) +} + +type DataTableFilterCustomContentProps = { + id: string + filter: any + onRemove: () => void + render: (props: { + value: any + onChange: (value: any) => void + onRemove: () => void + }) => React.ReactNode + onUpdate?: (value: unknown) => void +} + +const DataTableFilterCustomContent = ({ + id, + filter, + onRemove, + render, + onUpdate, +}: DataTableFilterCustomContentProps) => { + const { instance } = useDataTableContext() + + const handleChange = React.useCallback((value: any) => { + if (onUpdate) { + onUpdate(value) + } else { + instance.updateFilter({ + id, + value, + }) + } + }, [instance, id, onUpdate]) + + return ( + <> + {render({ + value: filter, + onChange: handleChange, + onRemove, + })} + + ) +} + export { DataTableFilter } export type { DataTableFilterProps } diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-non-sortable-header-cell.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-non-sortable-header-cell.tsx new file mode 100644 index 0000000000..0ca323c1ff --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-non-sortable-header-cell.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import { useSortable } from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { Table } from "@/components/table" + +interface DataTableNonSortableHeaderCellProps extends React.HTMLAttributes { + id: string + children: React.ReactNode + isFirstColumn?: boolean +} + +export const DataTableNonSortableHeaderCell = React.forwardRef< + HTMLTableCellElement, + DataTableNonSortableHeaderCellProps +>(({ id, children, className, style: propStyle, isFirstColumn, ...props }, ref) => { + // Still use sortable hook but without listeners + const { + setNodeRef, + transform, + transition, + } = useSortable({ + id, + disabled: true, // Disable dragging + }) + + // Only apply horizontal transform for smooth shifting + const transformStyle = transform ? { + x: transform.x, + y: 0, + scaleX: transform.scaleX, + scaleY: transform.scaleY, + } : null + + const style: React.CSSProperties = { + ...propStyle, + transform: transformStyle ? CSS.Transform.toString(transformStyle) : undefined, + transition, + position: 'relative' as const, + } + + const combineRefs = (element: HTMLTableCellElement | null) => { + setNodeRef(element) + if (ref) { + if (typeof ref === 'function') { + ref(element) + } else { + ref.current = element + } + } + } + + return ( + + {children} + + ) +}) + +DataTableNonSortableHeaderCell.displayName = "DataTableNonSortableHeaderCell" \ No newline at end of file diff --git a/packages/design-system/ui/src/blocks/data-table/components/data-table-sortable-header-cell.tsx b/packages/design-system/ui/src/blocks/data-table/components/data-table-sortable-header-cell.tsx new file mode 100644 index 0000000000..e194a5f620 --- /dev/null +++ b/packages/design-system/ui/src/blocks/data-table/components/data-table-sortable-header-cell.tsx @@ -0,0 +1,71 @@ +import * as React from "react" +import { useSortable } from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { clx } from "@/utils/clx" +import { Table } from "@/components/table" + +interface DataTableSortableHeaderCellProps extends React.HTMLAttributes { + id: string + children: React.ReactNode + isFirstColumn?: boolean +} + +export const DataTableSortableHeaderCell = React.forwardRef< + HTMLTableCellElement, + DataTableSortableHeaderCellProps +>(({ id, children, className, style: propStyle, isFirstColumn, ...props }, ref) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id, + }) + + // Only apply horizontal transform, ignore vertical movement + const transformStyle = transform ? { + x: transform.x, + y: 0, + scaleX: 1, + scaleY: 1 + } : null + + const style: React.CSSProperties = { + ...propStyle, + transform: transformStyle ? CSS.Transform.toString(transformStyle) : undefined, + transition, + opacity: isDragging ? 0.8 : 1, + zIndex: isDragging ? 50 : undefined, + backgroundColor: "white", + position: 'relative' as const, + } + + const combineRefs = (element: HTMLTableCellElement | null) => { + setNodeRef(element) + if (ref) { + if (typeof ref === 'function') { + ref(element) + } else { + ref.current = element + } + } + } + + return ( + + {children} + + ) +}) + +DataTableSortableHeaderCell.displayName = "DataTableSortableHeaderCell" 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 66a95f0a04..f40d3f56f6 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,22 @@ import * as React from "react" import { Table } from "@/components/table" import { flexRender } from "@tanstack/react-table" +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, + DragStartEvent, +} from "@dnd-kit/core" +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + horizontalListSortingStrategy, +} from "@dnd-kit/sortable" import { useDataTableContext } from "@/blocks/data-table/context/use-data-table-context" import { Skeleton } from "@/components/skeleton" @@ -15,6 +31,8 @@ import { DataTableEmptyStateProps, } from "../types" import { DataTableSortingIcon } from "./data-table-sorting-icon" +import { DataTableSortableHeaderCell } from "./data-table-sortable-header-cell" +import { DataTableNonSortableHeaderCell } from "./data-table-non-sortable-header-cell" interface DataTableTableProps { /** @@ -42,6 +60,59 @@ const DataTableTable = (props: DataTableTableProps) => { const hasSelect = columns.find((c) => c.id === "select") const hasActions = columns.find((c) => c.id === "action") + // Create list of all column IDs for SortableContext + // Use current order if available, otherwise use default order + const sortableItems = React.useMemo(() => { + if (instance.columnOrder && instance.columnOrder.length > 0) { + return instance.columnOrder + } + return columns.map(col => col.id) + }, [columns, instance.columnOrder]) + + // Setup drag-and-drop sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + + if (active.id !== over?.id && over?.id) { + const activeId = active.id as string + const overId = over.id as string + + // Don't allow dragging fixed columns + if (activeId === "select" || activeId === "action") { + return + } + + // Don't allow dropping on fixed columns + if (overId === "select" || overId === "action") { + return + } + + // Use the current column order from the instance + const currentOrder = instance.columnOrder && instance.columnOrder.length > 0 + ? instance.columnOrder + : columns.map(col => col.id) + + const oldIndex = currentOrder.indexOf(activeId) + const newIndex = currentOrder.indexOf(overId) + + if (oldIndex !== -1 && newIndex !== -1) { + const newOrder = arrayMove(currentOrder, oldIndex, newIndex) + instance.setColumnOrderFromArray(newOrder) + } + } + } + React.useEffect(() => { const onKeyDownHandler = (event: KeyboardEvent) => { // If an editable element is focused, we don't want to select a row @@ -100,155 +171,349 @@ const DataTableTable = (props: DataTableTableProps) => { return (
{instance.emptyState === DataTableEmptyState.POPULATED && ( -
- - +
- {instance.getHeaderGroups().map((headerGroup) => ( - + - {headerGroup.headers.map((header, idx) => { - const canSort = header.column.getCanSort() - const sortDirection = header.column.getIsSorted() - const sortHandler = header.column.getToggleSortingHandler() + {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 isActionHeader = header.id === "action" + const isSelectHeader = header.id === "select" + const isSpecialHeader = isActionHeader || isSelectHeader + const isDraggable = !isSpecialHeader - const Wrapper = canSort ? "button" : "div" - const isFirstColumn = hasSelect ? idx === 1 : idx === 0 + const Wrapper = canSort ? "button" : "div" + const isFirstColumn = hasSelect ? idx === 1 : idx === 0 - return ( - + e.stopPropagation() : undefined} + className={clx( + "group flex cursor-default items-center gap-2", + { + "cursor-pointer": canSort, + "w-full": isRightAligned || isCenterAligned, + "w-fit": !isRightAligned && !isCenterAligned, + "justify-end": isRightAligned, + "justify-center": isCenterAligned, + } + )} + > + {canSort && isRightAligned && ( + + )} + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {canSort && !isRightAligned && ( + + )} + + + ) })} - style={ - !isSpecialHeader - ? { + + + ))} + + + {instance.getRowModel().rows.map((row) => { + return ( + (hoveredRowId.current = row.id)} + onMouseLeave={() => (hoveredRowId.current = null)} + onClick={(e) => instance.onRowClick?.(e, row)} + className={clx("group/row last-of-type:border-b-0", { + "cursor-pointer": !!instance.onRowClick, + })} + > + {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() + )} + + ) + })} + + ) + })} + +
+
+ + ) : ( +
+ + + {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 + + // Get header alignment from column metadata + const headerAlign = (header.column.columnDef.meta as any)?.___alignMetaData?.headerAlign || 'left' + const isRightAligned = headerAlign === 'right' + const isCenterAligned = headerAlign === 'center' + + return ( + - - {flexRender( - header.column.columnDef.header, - header.getContext() - )} - {canSort && ( - - )} - - - ) - })} - - ))} - - - {instance.getRowModel().rows.map((row) => { - return ( - (hoveredRowId.current = row.id)} - onMouseLeave={() => (hoveredRowId.current = null)} - onClick={(e) => instance.onRowClick?.(e, row)} - className={clx("group/row last-of-type:border-b-0", { - "cursor-pointer": !!instance.onRowClick, + e.stopPropagation() : undefined} + className={clx( + "group flex cursor-default items-center gap-2", + { + "cursor-pointer": canSort, + "w-full": isRightAligned || isCenterAligned, + "w-fit": !isRightAligned && !isCenterAligned, + "justify-end": isRightAligned, + "justify-center": isCenterAligned, + } + )} + > + {canSort && isRightAligned && ( + + )} + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {canSort && !isRightAligned && ( + + )} + + + ) })} - > - {row.getVisibleCells().map((cell, idx) => { - const isSelectCell = cell.column.id === "select" - const isActionCell = cell.column.id === "action" - const isSpecialCell = isSelectCell || isActionCell + + ))} + + + {instance.getRowModel().rows.map((row) => { + return ( + (hoveredRowId.current = row.id)} + onMouseLeave={() => (hoveredRowId.current = null)} + onClick={(e) => instance.onRowClick?.(e, row)} + className={clx("group/row last-of-type:border-b-0", { + "cursor-pointer": !!instance.onRowClick, + })} + > + {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 + const isFirstColumn = hasSelect ? idx === 1 : idx === 0 - return ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ) - })} - - ) - })} - -
-
+ : undefined + } + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ) + })} + + ) + })} + + +
+ ) )} { + const { instance } = useDataTableContext() + const hasFilters = instance.getFilters().length > 0 + return (
@@ -36,7 +52,12 @@ const DataTableToolbar = (props: DataTableToolbarProps) => {
+ alwaysShow={hasFilters} + sortingTooltip={props.translations?.sort} + columnsTooltip={props.translations?.columns} + > + {props.filterBarContent} +
) } 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 index bce96b20c6..75b1cd55ab 100644 --- 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 @@ -15,7 +15,13 @@ const DataTableContextProvider = ({ children, }: DataTableContextProviderProps) => { return ( - + {children} ) 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 index 51a6b3aabd..d3fc777641 100644 --- 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 @@ -3,6 +3,8 @@ import { UseDataTableReturn } from "../use-data-table" export interface DataTableContextValue { instance: UseDataTableReturn + enableColumnVisibility: boolean + enableColumnOrder: boolean } export const DataTableContext = 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 b50e69f46e..57764afbb5 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 @@ -248,24 +248,24 @@ const columns = [ [ { label: "Edit", - onClick: () => {}, + onClick: () => { }, icon: , }, { label: "Edit", - onClick: () => {}, + onClick: () => { }, icon: , }, { label: "Edit", - onClick: () => {}, + onClick: () => { }, icon: , }, ], [ { label: "Delete", - onClick: () => {}, + onClick: () => { }, icon: , }, ], @@ -385,6 +385,13 @@ const KitchenSinkDemo = () => { }, }) + const handleFilteringChange = ( + state: DataTableFilteringState, + ) => { + console.log("Filtering changed:", state) + setFiltering(state) + } + const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: 10, @@ -414,7 +421,7 @@ const KitchenSinkDemo = () => { }, filtering: { state: filtering, - onFilteringChange: setFiltering, + onFilteringChange: handleFilteringChange, }, rowSelection: { state: rowSelection, 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 d44e2ad4a2..711fed7a5a 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 @@ -5,6 +5,8 @@ import * as React from "react" import { clx } from "@/utils/clx" import { DataTableCommandBar } from "./components/data-table-command-bar" +import { DataTableColumnVisibilityMenu } from "./components/data-table-column-visibility-menu" +import { DataTableFilterBar } from "./components/data-table-filter-bar" import { DataTableFilterMenu } from "./components/data-table-filter-menu" import { DataTablePagination } from "./components/data-table-pagination" import { DataTableSearch } from "./components/data-table-search" @@ -58,6 +60,8 @@ const DataTable = Object.assign(Root, { Search: DataTableSearch, SortingMenu: DataTableSortingMenu, FilterMenu: DataTableFilterMenu, + FilterBar: DataTableFilterBar, + ColumnVisibilityMenu: DataTableColumnVisibilityMenu, Pagination: DataTablePagination, CommandBar: DataTableCommandBar, }) 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 e2b3212dd4..e6cd49c820 100644 --- a/packages/design-system/ui/src/blocks/data-table/index.ts +++ b/packages/design-system/ui/src/blocks/data-table/index.ts @@ -12,6 +12,7 @@ export type { DataTableColumnFilter, DataTableCommand, DataTableDateComparisonOperator, + DataTableNumberComparisonOperator, DataTableEmptyState, DataTableEmptyStateContent, DataTableEmptyStateProps, @@ -25,3 +26,6 @@ export type { DataTableSortDirection, DataTableSortingState, } from "./types" + +// Re-export types from @tanstack/react-table that are used in the public API +export type { VisibilityState, ColumnOrderState } from "@tanstack/react-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 index 68806e0b55..7c315f09b2 100644 --- a/packages/design-system/ui/src/blocks/data-table/types.ts +++ b/packages/design-system/ui/src/blocks/data-table/types.ts @@ -73,10 +73,24 @@ export type DataTableSortableColumnDef = { enableSorting?: boolean } +export type DataTableHeaderAlignment = 'left' | 'center' | 'right' + +export type DataTableAlignableColumnDef = { + /** + * The alignment of the header content. + * @default 'left' + */ + headerAlign?: DataTableHeaderAlignment +} + export type DataTableSortableColumnDefMeta = { ___sortMetaData?: DataTableSortableColumnDef } +export type DataTableAlignableColumnDefMeta = { + ___alignMetaData?: DataTableAlignableColumnDef +} + export type DataTableActionColumnDefMeta = { ___actions?: | DataTableAction[] @@ -151,8 +165,8 @@ export interface DataTableColumnHelper { >( accessor: TAccessor, column: TAccessor extends AccessorFn - ? DataTableDisplayColumnDef & DataTableSortableColumnDef - : DataTableIdentifiedColumnDef & DataTableSortableColumnDef + ? DataTableDisplayColumnDef & DataTableSortableColumnDef & DataTableAlignableColumnDef + : DataTableIdentifiedColumnDef & DataTableSortableColumnDef & DataTableAlignableColumnDef ) => TAccessor extends AccessorFn ? AccessorFnColumnDef : AccessorKeyColumnDef @@ -192,7 +206,7 @@ export type DataTableFilteringState< [K in keyof T]: T[K] } -export type DataTableFilterType = "radio" | "select" | "date" +export type DataTableFilterType = "radio" | "select" | "date" | "multiselect" | "string" | "number" | "custom" export type DataTableFilterOption = { label: string value: T @@ -259,10 +273,57 @@ export interface DataTableDateFilterProps extends DataTableBaseFilterProps { options: DataTableFilterOption[] } +export interface DataTableMultiselectFilterProps extends DataTableBaseFilterProps { + type: "multiselect" + options: DataTableFilterOption[] + /** + * Whether to show a search input for the options. + * @default true + */ + searchable?: boolean +} + +export interface DataTableStringFilterProps extends DataTableBaseFilterProps { + type: "string" + /** + * Placeholder text for the input. + */ + placeholder?: string +} + +export interface DataTableNumberFilterProps extends DataTableBaseFilterProps { + type: "number" + /** + * Placeholder text for the input. + */ + placeholder?: string + /** + * Whether to include comparison operators. + * @default true + */ + includeOperators?: boolean +} + +export interface DataTableCustomFilterProps extends DataTableBaseFilterProps { + type: "custom" + /** + * Custom render function for the filter. + */ + render: (props: { + value: any + onChange: (value: any) => void + onRemove: () => void + }) => React.ReactNode +} + export type DataTableFilterProps = | DataTableRadioFilterProps | DataTableSelectFilterProps | DataTableDateFilterProps + | DataTableMultiselectFilterProps + | DataTableStringFilterProps + | DataTableNumberFilterProps + | DataTableCustomFilterProps export type DataTableFilter< T extends DataTableFilterProps = DataTableFilterProps @@ -295,6 +356,29 @@ export type DataTableDateComparisonOperator = { $gt?: string } +export type DataTableNumberComparisonOperator = { + /** + * The filtered number must be greater than or equal to this value. + */ + $gte?: number + /** + * The filtered number must be less than or equal to this value. + */ + $lte?: number + /** + * The filtered number must be less than this value. + */ + $lt?: number + /** + * The filtered number must be greater than this value. + */ + $gt?: number + /** + * The filtered number must be equal to this value. + */ + $eq?: number +} + type DataTableCommandAction = ( selection: DataTableRowSelectionState ) => void | Promise 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 487396f684..c73ee92fba 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 @@ -2,6 +2,7 @@ import { ColumnFilter, ColumnFiltersState, type ColumnSort, + type ColumnOrderState, getCoreRowModel, PaginationState, type RowSelectionState, @@ -9,6 +10,7 @@ import { type TableOptions, type Updater, useReactTable, + type VisibilityState, } from "@tanstack/react-table" import * as React from "react" import { @@ -106,6 +108,20 @@ interface DataTableOptions * @default true */ autoResetPageIndex?: boolean + /** + * The state and callback for the column visibility. + */ + columnVisibility?: { + state: VisibilityState + onColumnVisibilityChange: (state: VisibilityState) => void + } + /** + * The state and callback for the column order. + */ + columnOrder?: { + state: ColumnOrderState + onColumnOrderChange: (state: ColumnOrderState) => void + } } interface UseDataTableReturn @@ -119,6 +135,8 @@ interface UseDataTableReturn | "previousPage" | "getPageCount" | "getAllColumns" + | "setColumnVisibility" + | "setColumnOrder" > { getSorting: () => DataTableSortingState | null setSorting: ( @@ -156,6 +174,10 @@ interface UseDataTableReturn enableFiltering: boolean enableSorting: boolean enableSearch: boolean + enableColumnVisibility: boolean + enableColumnOrder: boolean + columnOrder: ColumnOrderState + setColumnOrderFromArray: (order: string[]) => void } const useDataTable = ({ @@ -170,6 +192,8 @@ const useDataTable = ({ onRowClick, autoResetPageIndex = true, isLoading = false, + columnVisibility, + columnOrder, ...options }: DataTableOptions): UseDataTableReturn => { const { state: sortingState, onSortingChange } = sorting ?? {} @@ -180,6 +204,10 @@ const useDataTable = ({ onRowSelectionChange, enableRowSelection, } = rowSelection ?? {} + const { state: columnVisibilityState, onColumnVisibilityChange } = columnVisibility ?? {} + const { state: columnOrderState, onColumnOrderChange } = columnOrder ?? {} + + // Store filter metadata like openOnMount const autoResetPageIndexHandler = React.useCallback(() => { return autoResetPageIndex @@ -230,6 +258,32 @@ const useDataTable = ({ : undefined }, [onPaginationChange, paginationState]) + const columnVisibilityStateHandler = React.useCallback(() => { + return onColumnVisibilityChange + ? (updaterOrValue: Updater) => { + const value = + typeof updaterOrValue === "function" + ? updaterOrValue(columnVisibilityState ?? {}) + : updaterOrValue + + onColumnVisibilityChange(value) + } + : undefined + }, [onColumnVisibilityChange, columnVisibilityState]) + + const columnOrderStateHandler = React.useCallback(() => { + return onColumnOrderChange + ? (updaterOrValue: Updater) => { + const value = + typeof updaterOrValue === "function" + ? updaterOrValue(columnOrderState ?? []) + : updaterOrValue + + onColumnOrderChange(value) + } + : undefined + }, [onColumnOrderChange, columnOrderState]) + const instance = useReactTable({ ...options, getCoreRowModel: getCoreRowModel(), @@ -243,6 +297,8 @@ const useDataTable = ({ }) ), pagination: paginationState, + columnVisibility: columnVisibilityState ?? {}, + columnOrder: columnOrderState ?? [], }, enableRowSelection, rowCount, @@ -250,6 +306,8 @@ const useDataTable = ({ onRowSelectionChange: rowSelectionStateHandler(), onSortingChange: sortingStateHandler(), onPaginationChange: paginationStateHandler(), + onColumnVisibilityChange: columnVisibilityStateHandler(), + onColumnOrderChange: columnOrderStateHandler(), manualSorting: true, manualPagination: true, manualFiltering: true, @@ -289,7 +347,7 @@ const useDataTable = ({ return null } - return filter.options as DataTableFilterOption[] + return ((filter as any).options as DataTableFilterOption[]) || null }, [getFilters] ) @@ -307,11 +365,11 @@ const useDataTable = ({ }, [instance]) const addFilter = React.useCallback( - (filter: ColumnFilter) => { - if (filter.value) { - autoResetPageIndexHandler()?.() - } - onFilteringChange?.({ ...getFiltering(), [filter.id]: filter.value }) + (filter: DataTableColumnFilter) => { + const currentFilters = getFiltering() + const newFilters = { ...currentFilters, [filter.id]: filter.value } + autoResetPageIndexHandler()?.() + onFilteringChange?.(newFilters) }, [onFilteringChange, getFiltering, autoResetPageIndexHandler] ) @@ -424,12 +482,20 @@ const useDataTable = ({ const enableFiltering: boolean = !!filtering const enableSorting: boolean = !!sorting const enableSearch: boolean = !!search + const enableColumnVisibility: boolean = !!columnVisibility + const enableColumnOrder: boolean = !!columnOrder + + const setColumnOrderFromArray = React.useCallback((order: string[]) => { + instance.setColumnOrder(order) + }, [instance]) return { // Table getHeaderGroups: instance.getHeaderGroups, getRowModel: instance.getRowModel, getAllColumns: instance.getAllColumns, + setColumnVisibility: instance.setColumnVisibility, + setColumnOrder: instance.setColumnOrder, // Pagination enablePagination, getCanNextPage: instance.getCanNextPage, @@ -468,6 +534,12 @@ const useDataTable = ({ // Loading isLoading, showSkeleton, + // Column Visibility + enableColumnVisibility, + // Column Order + enableColumnOrder, + columnOrder: instance.getState().columnOrder, + setColumnOrderFromArray, } } @@ -516,7 +588,7 @@ function onFilteringChangeTransformer( : updaterOrValue const transformedValue = Object.fromEntries( - value.map((filter) => [filter.id, filter]) + value.map((filter) => [filter.id, filter.value]) ) onFilteringChange(transformedValue) 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 860a7e7790..26ce1d454c 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 @@ -13,6 +13,8 @@ import { DataTableSelectColumnDef, DataTableSortableColumnDef, DataTableSortableColumnDefMeta, + DataTableAlignableColumnDef, + DataTableAlignableColumnDefMeta, } from "../types" const createDataTableColumnHelper = < @@ -27,13 +29,15 @@ const createDataTableColumnHelper = < sortLabel, sortAscLabel, sortDescLabel, + headerAlign, meta, enableSorting, ...rest - } = column as any & DataTableSortableColumnDef + } = column as any & DataTableSortableColumnDef & DataTableAlignableColumnDef - const extendedMeta: DataTableSortableColumnDefMeta = { + const extendedMeta: DataTableSortableColumnDefMeta & DataTableAlignableColumnDefMeta = { ___sortMetaData: { sortLabel, sortAscLabel, sortDescLabel }, + ___alignMetaData: { headerAlign }, ...(meta || {}), } diff --git a/yarn.lock b/yarn.lock index 2615dc121f..423329ca3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3753,6 +3753,31 @@ __metadata: languageName: node linkType: hard +"@dnd-kit/accessibility@npm:^3.1.1": + version: 3.1.1 + resolution: "@dnd-kit/accessibility@npm:3.1.1" + dependencies: + tslib: ^2.0.0 + peerDependencies: + react: ">=16.8.0" + checksum: be0bf41716dc58f9386bc36906ec1ce72b7b42b6d1d0e631d347afe9bd8714a829bd6f58a346dd089b1519e93918ae2f94497411a61a4f5e4d9247c6cfd1fef8 + languageName: node + linkType: hard + +"@dnd-kit/core@npm:^6.0.0": + version: 6.3.1 + resolution: "@dnd-kit/core@npm:6.3.1" + dependencies: + "@dnd-kit/accessibility": ^3.1.1 + "@dnd-kit/utilities": ^3.2.2 + tslib: ^2.0.0 + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 196db95d81096d9dc248983533eab91ba83591770fa5c894b1ac776f42af0d99522b3fd5bb3923411470e4733fcfa103e6ee17adc17b9b7eb54c7fbec5ff7c52 + languageName: node + linkType: hard + "@dnd-kit/core@npm:^6.1.0": version: 6.1.0 resolution: "@dnd-kit/core@npm:6.1.0" @@ -3767,6 +3792,19 @@ __metadata: languageName: node linkType: hard +"@dnd-kit/sortable@npm:^7.0.0": + version: 7.0.2 + resolution: "@dnd-kit/sortable@npm:7.0.2" + dependencies: + "@dnd-kit/utilities": ^3.2.0 + tslib: ^2.0.0 + peerDependencies: + "@dnd-kit/core": ^6.0.7 + react: ">=16.8.0" + checksum: 06aeb113eeeb470bb2443bf1c48d597157bb3a1caa9740e60c2fa73a3076e753cd083a2d381f0556bd7e9873e851a49ce8ea14796ac02e2d796eabea4e27196d + languageName: node + linkType: hard + "@dnd-kit/sortable@npm:^8.0.0": version: 8.0.0 resolution: "@dnd-kit/sortable@npm:8.0.0" @@ -3780,7 +3818,7 @@ __metadata: languageName: node linkType: hard -"@dnd-kit/utilities@npm:^3.2.2": +"@dnd-kit/utilities@npm:^3.2.0, @dnd-kit/utilities@npm:^3.2.2": version: 3.2.2 resolution: "@dnd-kit/utilities@npm:3.2.2" dependencies: @@ -7533,6 +7571,9 @@ __metadata: version: 0.0.0-use.local resolution: "@medusajs/ui@workspace:packages/design-system/ui" dependencies: + "@dnd-kit/core": ^6.0.0 + "@dnd-kit/sortable": ^7.0.0 + "@dnd-kit/utilities": ^3.2.0 "@faker-js/faker": ^9.2.0 "@medusajs/icons": 2.10.1 "@medusajs/ui-preset": 2.10.1