"use client"; import "../../client"; import { ExpandedState, flexRender, getCoreRowModel, getExpandedRowModel, useReactTable } from "@tanstack/react-table"; import { cn } from "@/index"; import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; import React, { ReactNode, memo, useMemo, useState } from "react"; import { DataListRetriever, TableContent, useTableGenerator } from "../../hooks"; import { ModuleWithPermissions } from "../../permissions"; import { Button, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "../../shadcnui"; import { MicroLabel } from "../typography"; import { ContentTableSearch } from "./ContentTableSearch"; const EMPTY_ARRAY: any[] = []; function getGroupKeys(item: TableContent, field: string): string[] { const value = item.jsonApiData[field]; if (Array.isArray(value)) { return value.filter((v: any) => v && typeof v === "object" && "name" in v).map((v: any) => v.name ?? String(v.id)); } if (value && typeof value === "object" && "name" in value) { return [value.name ?? String(value.id)]; } return [String(value ?? "")]; } export type GenerateTableStructureParams = { data: any[]; toggleValueToFormIdsId: (id: string, name: string) => void; isSelected: (id: string) => boolean; }; type ContentListTableProps = { title?: string; titleActions?: ReactNode; data: DataListRetriever; tableGenerator?: never; tableGeneratorType: ModuleWithPermissions; fields: any[]; checkedIds?: string[]; toggleId?: (id: string) => void; functions?: ReactNode; filters?: ReactNode; allowSearch?: boolean; context?: Record; expandable?: boolean; getSubRows?: (row: any) => any[]; defaultExpanded?: boolean | ExpandedState; fullWidth?: boolean; groupBy?: string; groupLabel?: (key: string) => ReactNode; groupOrder?: string[]; hideHeader?: boolean; emptyState?: ReactNode; onRowClick?: (rowData: any) => void; }; export const ContentListTable = memo(function ContentListTable(props: ContentListTableProps) { const { data, fields, checkedIds, toggleId, allowSearch, filters: _filters, fullWidth, onRowClick } = props; const t = useTranslations(); const [expanded, setExpanded] = useState( props.defaultExpanded === true ? true : typeof props.defaultExpanded === "object" ? props.defaultExpanded : {}, ); // Track which pagination direction is loading so we can show a spinner on that button const [pendingDirection, setPendingDirection] = useState<"prev" | "next" | null>(null); React.useEffect(() => { if (data.isLoaded) setPendingDirection(null); }, [data.isLoaded]); const { data: tableData, columns: tableColumns } = useTableGenerator(props.tableGeneratorType, { data: data?.data ?? EMPTY_ARRAY, fields: fields, checkedIds: checkedIds, toggleId: toggleId, dataRetriever: data, context: props.context, }); const columnVisibility = useMemo( () => fields.reduce( (acc, columnId) => { acc[columnId] = true; return acc; }, {} as Record, ), [fields], ); const table = useReactTable({ data: tableData, columns: tableColumns, getCoreRowModel: getCoreRowModel(), ...(props.expandable && { getExpandedRowModel: getExpandedRowModel(), getSubRows: props.getSubRows, onExpandedChange: setExpanded, state: { expanded }, }), initialState: { columnVisibility, }, }); // if (!data.isLoaded || !data.data) { // return ; // } const rowModel = tableData ? table.getRowModel() : null; const groupedRows = useMemo(() => { if (!props.groupBy || !rowModel?.rows?.length) return null; const groupMap = new Map(); for (const row of rowModel.rows) { const keys = getGroupKeys(row.original, props.groupBy!); for (const key of keys) { let list = groupMap.get(key); if (!list) { list = []; groupMap.set(key, list); } list.push(row); } } const order = props.groupOrder; const sortedKeys = [...groupMap.keys()].sort((a, b) => { if (order) { const ia = order.indexOf(a); const ib = order.indexOf(b); // keys absent from groupOrder sort last, alphabetically among themselves if (ia === -1 && ib === -1) return a.localeCompare(b); if (ia === -1) return 1; if (ib === -1) return -1; return ia - ib; } return a.localeCompare(b); }); return sortedKeys.map((groupKey) => ({ groupKey, rows: groupMap.get(groupKey)!, })); }, [props.groupBy, props.groupOrder, rowModel]); const showFooter = !!(data.next || data.previous); return (
{/*
*/}
{props.title && (
{/*
{fullWidth ? `` : props.title}
*/}
{props.titleActions} {props.tableGeneratorType.icon && ( )} {props.title}
{(props.functions || props.filters || allowSearch) && ( <> {props.filters} {props.functions} {/* `allowSearch !== false`, not `allowSearch`: the search box has always rendered here whenever `functions`/`filters` were set, and most callers rely on that without passing the flag. Gating on the flag alone (as the sibling ContentListGrid does) would silently remove search from every such list. This only lets a caller whose retriever ignores `search` — e.g. notifications, whose repository has no search param and no fulltext index — opt out of a control that would do nothing. */} {allowSearch !== false && } )}
)} {!props.hideHeader && table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const meta = header.column.columnDef.meta as { className?: string } | undefined; return ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ); })} ))}
{rowModel && rowModel.rows?.length ? ( groupedRows ? ( groupedRows.map((group) => ( {props.groupLabel?.(group.groupKey) ?? group.groupKey} {group.rows.map((row) => ( onRowClick?.(row.original.jsonApiData)} className={`group ${onRowClick ? "hover:bg-muted/50 cursor-pointer" : ""}`} > {row.getVisibleCells().map((cell) => { const meta = cell.column.columnDef.meta as { className?: string } | undefined; return ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ); })} ))} )) ) : ( rowModel.rows.map((row) => ( onRowClick?.(row.original.jsonApiData)} className={`group ${onRowClick ? "hover:bg-muted/50 cursor-pointer" : ""}`} > {row.getVisibleCells().map((cell) => { const meta = cell.column.columnDef.meta as { className?: string } | undefined; return ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ); })} )) ) ) : ( {props.emptyState ?? (t.has("ui.empty_states.no_results") ? t("ui.empty_states.no_results") : "No results.")} )} {showFooter && (
{data.pageInfo && ( {`${data.pageInfo.startItem}-${data.pageInfo.endItem}${data.total ? ` of ${data.total}` : ""}`} )}
)}
); });