import { useState, useEffect, Children, type ComponentProps, type ReactNode, } from "react"; import { createPortal } from "react-dom"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import * as diacritic from "diacritic"; import { useDataTableStoreContext, useStore, useTranslate, useResourceContext, useDataTableColumnRankContext, useDataTableColumnFilterContext, useTranslateLabel, DataTableColumnRankContext, DataTableColumnFilterContext, type RaRecord, type Identifier, type SortPayload, type HintedString, type ExtractRecordPaths, } from "ra-core"; import { Columns, Search } from "lucide-react"; import * as PopoverPrimitive from "@radix-ui/react-popover"; import { useIsMobile } from "@/hooks/use-mobile"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { FieldToggle } from "@/components/admin/field-toggle"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Popover, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; /** * Renders a button that lets users show / hide columns in a DataTable * * @see {@link https://marmelab.com/shadcn-admin-kit/docs/columnsbutton/ ColumnsButton documentation} * * @example * import { List, DataTable, EditButton, CreateButton, ExportButton, ColumnsButton } from '@/components/admin'; * * const PostsList = () => ( * * * * * } * > * * * * * * * * ); */ export const ColumnsButton = (props: ColumnsButtonProps) => { const { className, storeKey: _, ...rest } = props; const resource = useResourceContext(props); const storeKey = props.storeKey || `${resource}.datatable`; const [open, setOpen] = useState(false); const isMobile = useIsMobile(); const translate = useTranslate(); const title = translate("ra.action.select_columns", { _: "Columns" }); return ( {isMobile ? ( {title} ) : ( )}
); }; export interface ColumnsButtonProps extends ComponentProps { resource?: string; storeKey?: string; } /** * Render DataTable.Col elements in the ColumnsButton selector using a React Portal. * * @see ColumnsButton */ export const ColumnsSelector = ({ children }: ColumnsSelectorProps) => { const translate = useTranslate(); const { storeKey, defaultHiddenColumns } = useDataTableStoreContext(); const [columnRanks, setColumnRanks] = useStore( `${storeKey}_columnRanks`, ); const [_hiddenColumns, setHiddenColumns] = useStore( storeKey, defaultHiddenColumns, ); const elementId = `${storeKey}-columnsSelector`; const [container, setContainer] = useState(() => typeof document !== "undefined" ? document.getElementById(elementId) : null, ); // on first mount, we don't have the container yet, so we wait for it useEffect(() => { if ( container && typeof document !== "undefined" && document.body.contains(container) ) return; // look for the container in the DOM every 100ms const interval = setInterval(() => { const target = document.getElementById(elementId); if (target) setContainer(target); }, 100); // stop looking after 500ms const timeout = setTimeout(() => clearInterval(interval), 500); return () => { clearInterval(interval); clearTimeout(timeout); }; }, [elementId, container]); const [columnFilter, setColumnFilter] = useState(""); if (!container) return null; const childrenArray = Children.toArray(children); const paddedColumnRanks = padRanks(columnRanks ?? [], childrenArray.length); const shouldDisplaySearchInput = childrenArray.length > 5; return createPortal(
    {shouldDisplaySearchInput ? (
  • ) => { setColumnFilter(e.target.value); }} placeholder={translate("ra.action.search_columns", { _: "Search columns", })} className="pr-8" /> {columnFilter && ( )}
  • ) : null} {paddedColumnRanks.map((position, index) => ( {childrenArray[position]} ))}
, container, ); }; interface ColumnsSelectorProps { children?: React.ReactNode; } export const ColumnsSelectorItem = < RecordType extends RaRecord = RaRecord, >({ source, label, }: ColumnsSelectorItemProps) => { const resource = useResourceContext(); const { storeKey, defaultHiddenColumns } = useDataTableStoreContext(); const [hiddenColumns, setHiddenColumns] = useStore( storeKey, defaultHiddenColumns, ); const columnRank = useDataTableColumnRankContext(); const [columnRanks, setColumnRanks] = useStore( `${storeKey}_columnRanks`, ); const columnFilter = useDataTableColumnFilterContext(); const translateLabel = useTranslateLabel(); if (!source && !label) return null; const fieldLabel = translateLabel({ label: typeof label === "string" ? label : undefined, resource, source, }) as string; const isColumnHidden = hiddenColumns.includes(source!); const isColumnFiltered = fieldLabelMatchesFilter(fieldLabel, columnFilter); const handleMove = ( index1: number | string, index2: number | string | null, ) => { const colRanks = !columnRanks ? padRanks([], Math.max(Number(index1), Number(index2 || 0)) + 1) : Math.max(Number(index1), Number(index2 || 0)) > columnRanks.length - 1 ? padRanks( columnRanks, Math.max(Number(index1), Number(index2 || 0)) + 1, ) : columnRanks; const index1Pos = colRanks.findIndex((index) => index == Number(index1)); const index2Pos = colRanks.findIndex((index) => index == Number(index2)); if (index1Pos === -1 || index2Pos === -1) { return; } let newColumnRanks; if (index1Pos > index2Pos) { newColumnRanks = [ ...colRanks.slice(0, index2Pos), colRanks[index1Pos], ...colRanks.slice(index2Pos, index1Pos), ...colRanks.slice(index1Pos + 1), ]; } else { newColumnRanks = [ ...colRanks.slice(0, index1Pos), ...colRanks.slice(index1Pos + 1, index2Pos + 1), colRanks[index1Pos], ...colRanks.slice(index2Pos + 1), ]; } setColumnRanks(newColumnRanks); }; return isColumnFiltered ? ( isColumnHidden ? setHiddenColumns( hiddenColumns.filter((column) => column !== source!), ) : setHiddenColumns([...hiddenColumns, source!]) } onMove={handleMove} /> ) : null; }; // this is the same interface as DataTableColumnProps // but we copied it here to avoid circular dependencies with data-table export interface ColumnsSelectorItemProps< RecordType extends RaRecord = RaRecord, > { className?: string; cellClassName?: string; headerClassName?: string; conditionalClassName?: (record: RecordType) => string | false | undefined; children?: ReactNode; render?: (record: RecordType) => React.ReactNode; field?: React.ElementType; source?: NoInfer>>; label?: React.ReactNode; disableSort?: boolean; sortByOrder?: SortPayload["order"]; } // Function to help with column ranking const padRanks = (ranks: number[], length: number) => ranks.concat( Array.from({ length: length - ranks.length }, (_, i) => ranks.length + i), ); const fieldLabelMatchesFilter = (fieldLabel: string, columnFilter?: string) => columnFilter ? diacritic .clean(fieldLabel) .toLowerCase() .includes(diacritic.clean(columnFilter).toLowerCase()) : true;