diff --git a/src/pages/audit-report/components/DynamicDataTable.tsx b/src/pages/audit-report/components/DynamicDataTable.tsx index f0fb95097..661c7f710 100644 --- a/src/pages/audit-report/components/DynamicDataTable.tsx +++ b/src/pages/audit-report/components/DynamicDataTable.tsx @@ -4,6 +4,7 @@ import { formatDate } from "../utils"; import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; import { MRT_ColumnDef } from "mantine-react-table"; +import { SortingState } from "@tanstack/react-table"; import HealthBadge, { HealthType } from "./HealthBadge"; import GaugeCell from "./GaugeCell"; import { Link, useSearchParams } from "react-router-dom"; @@ -29,6 +30,8 @@ interface DynamicDataTableProps { totalRowCount?: number; isLoading?: boolean; tablePrefix?: string; + defaultPageSize?: number; + defaultSorting?: SortingState; } interface RowAttributes { @@ -50,21 +53,99 @@ const DynamicDataTable: React.FC = ({ pageCount, totalRowCount, isLoading, - tablePrefix + tablePrefix, + defaultPageSize, + defaultSorting }) => { - const columnDef: MRT_ColumnDef[] = columns - .filter((col) => !col.hidden && !hiddenColumnTypes.includes(col.type)) - .map((col) => { + const visibleColumnsWithRatio = React.useMemo(() => { + return columns.reduce< + { + column: ViewColumnDef; + width?: string; + }[] + >((acc, col) => { + if (col.hidden || hiddenColumnTypes.includes(col.type)) { + return acc; + } + const width = col.width; + acc.push({ column: col, width }); + return acc; + }, []); + }, [columns]); + + const baseWidth = React.useMemo( + () => Math.max(visibleColumnsWithRatio.length * 120, 120), + [visibleColumnsWithRatio.length] + ); + + const { customWidths, widthError } = React.useMemo(() => { + const widths: Record = {}; + let totalWeight = 0; + const weightColumns: { name: string; weight: number }[] = []; + + for (const { column: col, width } of visibleColumnsWithRatio) { + if (!width) { + continue; + } + const widthStr = String(width).trim(); + const match = widthStr.match(/^([0-9]+(?:\.[0-9]+)?)(px)?$/); + if (!match) { + return { + customWidths: {}, + widthError: `Invalid column width "${widthStr}" for column "${col.name}". Use weight (e.g. "2") or px (e.g. "150px").` + }; + } + const value = parseFloat(match[1]); + const unit = match[2]; + if (Number.isNaN(value) || value <= 0) { + return { + customWidths: {}, + widthError: `Invalid column width "${widthStr}" for column "${col.name}". Use positive values like "2" or "150px".` + }; + } + + if (unit === "px") { + widths[col.name] = Math.max(20, Math.round(value)); + } else { + weightColumns.push({ name: col.name, weight: value }); + totalWeight += value; + } + } + + if (weightColumns.length > 0 && totalWeight > 0) { + weightColumns.forEach(({ name, weight }) => { + widths[name] = Math.max( + 20, + Math.round((weight / totalWeight) * baseWidth) + ); + }); + } + return { + customWidths: widths, + widthError: undefined as string | undefined + }; + }, [baseWidth, visibleColumnsWithRatio]); + + const columnDef: MRT_ColumnDef[] = visibleColumnsWithRatio.map( + ({ column: col }) => { + const calculatedSize = widthError ? undefined : customWidths[col.name]; + return { accessorKey: col.name, - minSize: 15, - maxSize: minWidthForColumnType(col.type), + ...(calculatedSize === undefined + ? { + minSize: 15, + maxSize: minWidthForColumnType(col.type) + } + : {}), + size: calculatedSize, header: formatDisplayLabel(col.name), enableSorting: col.type !== "labels", Cell: ({ cell, row }: { cell: any; row: any }) => renderCellValue(cell.getValue(), col, row.original, tablePrefix) }; - }); + } + ); const adaptedData = rows.map((row) => { const rowObj: { [key: string]: any } = {}; @@ -97,16 +178,26 @@ const DynamicDataTable: React.FC = ({ }); return ( - + <> + {widthError && ( +
+ {widthError} Falling back to default column widths. +
+ )} + + ); }; diff --git a/src/pages/audit-report/components/View/View.tsx b/src/pages/audit-report/components/View/View.tsx index 219451948..008395612 100644 --- a/src/pages/audit-report/components/View/View.tsx +++ b/src/pages/audit-report/components/View/View.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useEffect, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { Box, Table2, LayoutGrid } from "lucide-react"; import { useSearchParams } from "react-router-dom"; @@ -6,6 +6,7 @@ import DynamicDataTable from "../DynamicDataTable"; import { formatDisplayLabel } from "./panels/utils"; import { ColumnFilterOptions, + DisplayTable, PanelResult, ViewColumnDef, ViewRow, @@ -38,6 +39,7 @@ interface ViewProps { name: string; columns?: ViewColumnDef[]; columnOptions?: Record; + table?: DisplayTable; variables?: ViewVariable[]; card?: { columns: number; @@ -55,17 +57,48 @@ const View: React.FC = ({ columns, columnOptions, panels, + table, variables, card, requestFingerprint, currentVariables, hideVariables }) => { - const { pageSize } = useReactTablePaginationState(); + const tablePrefix = `view_${namespace}_${name}`; + + const defaultPageSize = table?.size; + + const defaultSorting = useMemo(() => { + const sort = table?.sort?.trim(); + if (!sort) { + return undefined; + } + + const desc = sort.startsWith("-"); + const id = sort.replace(/^[-+]/, ""); + + if (!id) { + return undefined; + } + + return [ + { + id, + desc + } + ]; + }, [table?.sort]); + + const { pageSize } = useReactTablePaginationState({ + paramPrefix: tablePrefix, + defaultPageSize: defaultPageSize + }); // Create unique prefix for this view's table - const tablePrefix = `view_${namespace}_${name}`; - const [tableSearchParams] = usePrefixedSearchParams(tablePrefix); + const [tableSearchParams, setTableSearchParams] = usePrefixedSearchParams( + tablePrefix, + false + ); // Separate display mode state (frontend only, not sent to backend) const [searchParams, setSearchParams] = useSearchParams(); @@ -118,6 +151,36 @@ const View: React.FC = ({ return columnFilterFields; }, [columnFilterFields]); + useEffect(() => { + setTableSearchParams((current) => { + const updated = new URLSearchParams(current); + let changed = false; + + if (defaultPageSize && defaultPageSize > 0 && !updated.get("pageSize")) { + updated.set("pageSize", defaultPageSize.toString()); + changed = true; + } + + if (!updated.get("pageIndex")) { + updated.set("pageIndex", "0"); + changed = true; + } + + if ( + defaultSorting && + defaultSorting.length > 0 && + !updated.get("sortBy") + ) { + const sort = defaultSorting[0]; + updated.set("sortBy", sort.id); + updated.set("sortOrder", sort.desc ? "desc" : "asc"); + changed = true; + } + + return changed ? updated : current; + }); + }, [defaultPageSize, defaultSorting, setTableSearchParams]); + // Fetch table data with only column filters (no global filters) const { data: tableResponse, @@ -281,6 +344,8 @@ const View: React.FC = ({ pageCount={totalEntries ? Math.ceil(totalEntries / pageSize) : 1} totalRowCount={totalEntries} tablePrefix={tablePrefix} + defaultPageSize={defaultPageSize} + defaultSorting={defaultSorting} /> ))} diff --git a/src/pages/audit-report/types/index.ts b/src/pages/audit-report/types/index.ts index cdadfee0b..614ea8954 100644 --- a/src/pages/audit-report/types/index.ts +++ b/src/pages/audit-report/types/index.ts @@ -295,6 +295,11 @@ export interface ViewColumnDef { */ filter?: ViewColumnDefFilter; + /** + * Width of the column (weight like "2" or fixed like "150px") + */ + width?: string; + /** * The data type of the column */ @@ -396,6 +401,11 @@ export interface DisplayCard { default?: boolean; } +export interface DisplayTable { + sort?: string; + size?: number; +} + /** * Filter options for a column. * For regular columns, list contains distinct values. @@ -430,6 +440,7 @@ export interface ViewResult { columnOptions?: Record; variables?: ViewVariable[]; card?: DisplayCard; + table?: DisplayTable; requestFingerprint: string; sections?: ViewSection[]; } diff --git a/src/pages/config/details/ConfigDetailsViewPage.tsx b/src/pages/config/details/ConfigDetailsViewPage.tsx index f69e2049c..dc19c6e50 100644 --- a/src/pages/config/details/ConfigDetailsViewPage.tsx +++ b/src/pages/config/details/ConfigDetailsViewPage.tsx @@ -98,6 +98,7 @@ export function ConfigDetailsViewPage() { panels={viewResult.panels} columns={viewResult.columns} card={viewResult.card} + table={viewResult.table} requestFingerprint={viewResult.requestFingerprint} columnOptions={viewResult.columnOptions} /> diff --git a/src/pages/views/components/ViewSection.tsx b/src/pages/views/components/ViewSection.tsx index 851952d7b..e7d173ca8 100644 --- a/src/pages/views/components/ViewSection.tsx +++ b/src/pages/views/components/ViewSection.tsx @@ -124,6 +124,7 @@ const ViewSection: React.FC = ({ columns={sectionViewResult?.columns} columnOptions={sectionViewResult?.columnOptions} panels={sectionViewResult?.panels} + table={sectionViewResult?.table} variables={sectionViewResult?.variables} card={sectionViewResult?.card} requestFingerprint={sectionViewResult.requestFingerprint} diff --git a/src/ui/DataTable/Hooks/useReactTablePaginationState.tsx b/src/ui/DataTable/Hooks/useReactTablePaginationState.tsx index d6c7d114f..34cbe7c4c 100644 --- a/src/ui/DataTable/Hooks/useReactTablePaginationState.tsx +++ b/src/ui/DataTable/Hooks/useReactTablePaginationState.tsx @@ -1,30 +1,58 @@ import { OnChangeFn, PaginationState } from "@tanstack/react-table"; -import { useAtom } from "jotai"; -import { atomWithStorage } from "jotai/utils"; import { useCallback } from "react"; import { useSearchParams } from "react-router-dom"; -const persistPageSizeAtom = atomWithStorage( - "persistPageSize", - "50", - undefined, - { - getOnInit: true - } -); +type PaginationStateOptions = { + /** + * Optional prefix to namespace the search params (e.g. view specific tables). + */ + paramPrefix?: string; + + /** + * Custom key name for page index in search params. Defaults to "pageIndex". + */ + pageIndexKey?: string; + + /** + * Custom key name for page size in search params. Defaults to "pageSize". + */ + pageSizeKey?: string; + + /** + * Default page size to use when no query param is present. Defaults to 50. + */ + defaultPageSize?: number; +}; export default function useReactTablePaginationState( - persistToLocalStorage = true + options: PaginationStateOptions = {} ) { - const [pageSizeFromLocalStorage, setPageSize] = useAtom(persistPageSizeAtom); + const { + paramPrefix, + pageIndexKey = "pageIndex", + pageSizeKey = "pageSize", + defaultPageSize = 50 + } = options; + + const pageIndexParamKey = paramPrefix + ? `${paramPrefix}__${pageIndexKey}` + : pageIndexKey; + const pageSizeParamKey = paramPrefix + ? `${paramPrefix}__${pageSizeKey}` + : pageSizeKey; + + const defaultPageSizeValue = defaultPageSize.toString(); const [params, setParams] = useSearchParams({ - pageIndex: "0", - pageSize: persistToLocalStorage ? pageSizeFromLocalStorage : "50" + [pageIndexParamKey]: "0", + [pageSizeParamKey]: defaultPageSizeValue }); - const pageIndex = parseInt(params.get("pageIndex") ?? "0", 10); - const pageSize = parseInt(params.get("pageSize") ?? "50", 10); + const pageIndex = parseInt(params.get(pageIndexParamKey) ?? "0", 10); + const pageSize = parseInt( + params.get(pageSizeParamKey) ?? defaultPageSizeValue, + 10 + ); const setPageIndex: OnChangeFn = useCallback( (param) => { @@ -32,17 +60,23 @@ export default function useReactTablePaginationState( typeof param === "function" ? param({ pageIndex: pageIndex ?? 0, - pageSize: pageSize ?? 50 + pageSize: pageSize ?? defaultPageSize }) : param; - params.set("pageIndex", updated.pageIndex.toString()); - params.set("pageSize", updated.pageSize.toString()); - setParams(params); - if (persistToLocalStorage) { - setPageSize(updated.pageSize.toString()); - } + const newParams = new URLSearchParams(params); + newParams.set(pageIndexParamKey, updated.pageIndex.toString()); + newParams.set(pageSizeParamKey, updated.pageSize.toString()); + setParams(newParams); }, - [pageIndex, pageSize, params, persistToLocalStorage, setPageSize, setParams] + [ + pageIndex, + pageSize, + params, + setParams, + pageIndexParamKey, + pageSizeParamKey, + defaultPageSize + ] ); return { diff --git a/src/ui/DataTable/Hooks/useReactTableSortState.tsx b/src/ui/DataTable/Hooks/useReactTableSortState.tsx index 12100ac45..fa5499829 100644 --- a/src/ui/DataTable/Hooks/useReactTableSortState.tsx +++ b/src/ui/DataTable/Hooks/useReactTableSortState.tsx @@ -1,7 +1,29 @@ import { SortingState, Updater } from "@tanstack/react-table"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; +type SortStateOptions = { + /** + * Optional prefix to namespace the sort search params. + */ + paramPrefix?: string; + + /** + * Custom sortBy key for search params. Defaults to "sortBy". + */ + sortByKey?: string; + + /** + * Custom sortOrder key for search params. Defaults to "sortOrder". + */ + sortOrderKey?: string; + + /** + * Default sorting to apply when no sort is present in the URL. + */ + defaultSorting?: SortingState; +}; + /** * * useReactTableSortState is a custom hook that manages the sorting state of a @@ -10,13 +32,25 @@ import { useSearchParams } from "react-router-dom"; * */ export default function useReactTableSortState( - sortByKey = "sortBy", - sortOrderKey = "sortOrder" + options: SortStateOptions = {} ): [SortingState, (newSortBy: Updater) => void] { + const { + paramPrefix, + sortByKey = "sortBy", + sortOrderKey = "sortOrder", + defaultSorting + } = options; + + const sortByParamKey = paramPrefix + ? `${paramPrefix}__${sortByKey}` + : sortByKey; + const sortOrderParamKey = paramPrefix + ? `${paramPrefix}__${sortOrderKey}` + : sortOrderKey; const [searchParams, setSearchParams] = useSearchParams(); - const sortBy = searchParams.get(sortByKey) || undefined; - const sortOrder = searchParams.get(sortOrderKey) || undefined; + const sortBy = searchParams.get(sortByParamKey) || undefined; + const sortOrder = searchParams.get(sortOrderParamKey) || undefined; const tableSortByState = useMemo(() => { if (!sortBy || !sortOrder) { @@ -30,22 +64,50 @@ export default function useReactTableSortState( ] satisfies SortingState; }, [sortBy, sortOrder]); + useEffect(() => { + if ( + (!sortBy || !sortOrder) && + defaultSorting && + defaultSorting.length > 0 + ) { + const nextParams = new URLSearchParams(window.location.search); + const [firstSort] = defaultSorting; + nextParams.set(sortByParamKey, firstSort.id); + nextParams.set(sortOrderParamKey, firstSort.desc ? "desc" : "asc"); + setSearchParams(nextParams); + } + }, [ + defaultSorting, + setSearchParams, + sortBy, + sortOrder, + sortByParamKey, + sortOrderParamKey + ]); + const updateSortByFn = useCallback( (newSortBy: Updater) => { const sort = typeof newSortBy === "function" ? newSortBy([...tableSortByState]) : newSortBy; + const nextParams = new URLSearchParams(searchParams); if (sort.length === 0) { - searchParams.delete(sortByKey); - searchParams.delete(sortOrderKey); + nextParams.delete(sortByParamKey); + nextParams.delete(sortOrderParamKey); } else { - searchParams.set(sortByKey, sort[0]?.id); - searchParams.set(sortOrderKey, sort[0].desc ? "desc" : "asc"); + nextParams.set(sortByParamKey, sort[0].id); + nextParams.set(sortOrderParamKey, sort[0].desc ? "desc" : "asc"); } - setSearchParams(searchParams); + setSearchParams(nextParams); }, - [searchParams, setSearchParams, sortByKey, sortOrderKey, tableSortByState] + [ + searchParams, + setSearchParams, + sortByParamKey, + sortOrderParamKey, + tableSortByState + ] ); return [tableSortByState, updateSortByFn]; diff --git a/src/ui/MRTDataTable/MRTDataTable.tsx b/src/ui/MRTDataTable/MRTDataTable.tsx index 702b5c56d..ce14c8257 100644 --- a/src/ui/MRTDataTable/MRTDataTable.tsx +++ b/src/ui/MRTDataTable/MRTDataTable.tsx @@ -11,6 +11,7 @@ import { MantineReactTable, MRT_TableOptions } from "mantine-react-table"; +import { SortingState } from "@tanstack/react-table"; import useReactTablePaginationState from "../DataTable/Hooks/useReactTablePaginationState"; import useReactTableSortState from "../DataTable/Hooks/useReactTableSortState"; @@ -46,6 +47,12 @@ type MRTDataTableProps = {}> = { displayColumnDefOptions?: { "mrt-row-expand"?: Partial>; }; + /** Prefix to namespace URL search params for sorting/pagination */ + urlParamPrefix?: string; + /** Default page size for pagination (overrides persisted default when set) */ + defaultPageSize?: number; + /** Default sorting to seed when no sort is present */ + defaultSorting?: SortingState; }; export default function MRTDataTable = {}>({ @@ -68,10 +75,34 @@ export default function MRTDataTable = {}>({ onGroupingChange = () => {}, disableHiding = false, mantineTableBodyRowProps, - displayColumnDefOptions + displayColumnDefOptions, + urlParamPrefix, + defaultPageSize, + defaultSorting }: MRTDataTableProps) { - const { pageIndex, pageSize, setPageIndex } = useReactTablePaginationState(); - const [sortState, setSortState] = useReactTableSortState(); + const { pageIndex, pageSize, setPageIndex } = useReactTablePaginationState({ + paramPrefix: urlParamPrefix, + defaultPageSize + }); + const [sortState, setSortState] = useReactTableSortState({ + paramPrefix: urlParamPrefix, + defaultSorting + }); + + const initialState = { + ...(expandAllRows ? { expanded: true } : {}) + }; + + const rowsPerPageOptions = Array.from( + new Set( + [ + defaultPageSize ? defaultPageSize.toString() : undefined, + "50", + "100", + "200" + ].filter((value): value is string => Boolean(value)) + ) + ).sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); const options = { data: data, @@ -152,13 +183,10 @@ export default function MRTDataTable = {}>({ return acc; }, {}) }, - initialState: expandAllRows - ? { - expanded: true - } - : undefined, + initialState: + Object.keys(initialState).length > 0 ? initialState : undefined, mantinePaginationProps: { - rowsPerPageOptions: ["50", "100", "200"] + rowsPerPageOptions }, mantineExpandButtonProps: { size: "xs"