"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; /** * Title typography, opt-in and independent of `fullWidth`. Defaults to the * `fullWidth ? lg : sm` behaviour, so every existing caller is unchanged; pass * it only for the exception — a full-width list nested inside another surface * (the document browser's right pane), which needs `fullWidth` to drop the card * border but must keep the card-sized title of the panels around it. */ titleSize?: "sm" | "lg"; groupBy?: string; groupLabel?: (key: string) => ReactNode; groupOrder?: string[]; hideHeader?: boolean; emptyState?: ReactNode; /** * Suppress the prev/next `TableFooter` even when the API returned * `links.next`. For capped lists (a dashboard block showing the first five * rows) whose header link goes to the full page instead. */ hidePagination?: boolean; 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 = !props.hidePagination && !!(data.next || data.previous); // The icon follows the title, not `fullWidth` on its own: a 24px icon beside a // 12px title is the mismatch the opt-in exists to avoid. const titleIsLarge = (props.titleSize ?? (fullWidth ? `lg` : `sm`)) === `lg`; 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}` : ""}`} )}
)}
); });