export default function ResultsTable({
getRowCanExpand,
columnFilterFns,
}: ResultsTableProps): ReactElement {
const appContext = useAppContext();
const [showFilters, setShowFilters] = useState(false);
const [columnMenuAnchor, setColumnMenuAnchor] = useState<null | HTMLElement>(null);
const [globalFilter, setGlobalFilter] = useState("");
const globalFilterInputRef = useRef<HTMLInputElement>(null);
// Bridge hotkeys (fired from App.tsx) into local state/refs.
useEffect(() => {
const onFocus = () => {
const el = globalFilterInputRef.current;
if (!el) return;
el.focus();
el.select();
};
const onToggle = () => setShowFilters((v) => !v);
window.addEventListener(FOCUS_GLOBAL_FILTER_EVENT, onFocus);
window.addEventListener(TOGGLE_COLUMN_FILTERS_EVENT, onToggle);
return () => {
window.removeEventListener(FOCUS_GLOBAL_FILTER_EVENT, onFocus);
window.removeEventListener(TOGGLE_COLUMN_FILTERS_EVENT, onToggle);
};
}, []);
// Enhanced search hook that maintains streaming behavior
const {
searchResults,
isLoading,
error,
executeSearch,
handleStopSearch,
excludeProduct,
tableText,
executedQuery,
} = useSearch();
// Watch for pending search queries triggered from HistoryPanel or the drawer.
// The ref guard dedupes against:
// 1. StrictMode double-invoke of effects on mount (dev only)
// 2. The `appContext` dep in the array — context object identity changes on
// every context state update, so this effect re-fires whenever anything
// in AppContext changes, not just `pendingSearchQuery`.
// 3. `executeSearch` / `appContext` not being stable references, which
// would otherwise cause spurious re-runs.
// Without the guard, a single submitted query fires `executeSearch` twice
// (and two sets of supplier HTTP requests).
const lastHandledPendingQueryRef = useRef<string | null>(null);
useEffect(() => {
const pending = appContext?.pendingSearchQuery;
if (!pending) {
// Reset so the same query can be submitted again later.
lastHandledPendingQueryRef.current = null;
return;
}
if (lastHandledPendingQueryRef.current === pending) return;
lastHandledPendingQueryRef.current = pending;
executeSearch(pending);
appContext?.setPendingSearchQuery(null);
}, [appContext?.pendingSearchQuery, executeSearch, appContext]);
// Context menu functionality
const { contextMenu, handleContextMenu, handleCloseContextMenu } = useContextMenu();
const table = useResultsTable({
showSearchResults: searchResults,
columnFilterFns,
globalFilterFns: [globalFilter, setGlobalFilter],
getRowCanExpand,
userSettings: appContext?.userSettings ?? (defaultSettings as UserSettings),
});
// ── Table state persistence ──────────────────────────────────────────
// Uses the TanStack "fully controlled state" pattern: take over the
// table's state via `table.setOptions`, persist the slices we care about
// (sorting, pagination, expanded rows, column visibility) to
// chrome.storage.session, and restore them on mount.
const [tableState, setTableState] = useState<TableState>(table.initialState);
const isStateLoadedRef = useRef(false);
// Collapse all variant rows whenever a new search begins. Without this,
// the persisted `expanded` state from the previous search can leak into
// the new results when row IDs happen to collide (e.g. both searches
// include a product at the same index).
const prevIsLoadingRef = useRef(false);
useEffect(() => {
if (isLoading && !prevIsLoadingRef.current) {
setTableState((prev) => ({ ...prev, expanded: {} }));
}
prevIsLoadingRef.current = isLoading;
}, [isLoading]);
// Load persisted state once on mount
useEffect(() => {
const load = async () => {
try {
const data = await cstorage.session.get([CACHE.TABLE_STATE]);
const stored = data[CACHE.TABLE_STATE] as
| (Partial<TableState> & { globalFilter?: string; showFilters?: boolean })
| undefined;
if (stored && typeof stored === "object") {
if (typeof stored.globalFilter === "string") {
setGlobalFilter(stored.globalFilter);
}
if (typeof stored.showFilters === "boolean") {
setShowFilters(stored.showFilters);
}
if (Array.isArray(stored.columnFilters)) {
columnFilterFns[1](stored.columnFilters);
}
setTableState((prev) => ({ ...prev, ...stored }));
}
} catch (error) {
console.warn("Failed to load table state from session storage:", { error });
}
isStateLoadedRef.current = true;
};
load();
}, []);
// Override state management — the table reads state from our local
// `tableState` and pushes every change back through `setTableState`.
// Column filters and global filter remain externally controlled.
table.setOptions((prev) => ({
...prev,
state: {
...tableState,
columnFilters: columnFilterFns[0],
globalFilter,
},
onStateChange: setTableState,
}));
// Debounced save — only persist the slices we care about restoring
// eslint-disable-next-line react-hooks/exhaustive-deps
const saveTableState = useCallback(
debounce(async (s: TableState & { showFilters?: boolean }) => {
try {
await cstorage.session.set({
[CACHE.TABLE_STATE]: {
sorting: s.sorting,
pagination: s.pagination,
expanded: s.expanded,
columnVisibility: s.columnVisibility,
columnFilters: s.columnFilters,
globalFilter: s.globalFilter,
showFilters: s.showFilters,
},
});
} catch (error) {
console.warn("Failed to persist table state:", { error });
}
}, 300),
[],
);
// Persist whenever controlled state changes (skip until initial load
// completes to avoid overwriting stored state with defaults).
// globalFilter, columnFilters, and showFilters are managed externally
// but still persisted alongside table state.
useEffect(() => {
if (!isStateLoadedRef.current) return;
saveTableState({
...tableState,
globalFilter,
columnFilters: columnFilterFns[0],
showFilters,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableState, globalFilter, columnFilterFns[0], showFilters, saveTableState]);
// Clamp pageSize synchronously so the MUI Select never renders with an
// out-of-range value (e.g. persisted pageSize=36 when valid options are
// [10, 20, 40, 74]). Must happen during render, not in a useEffect,
// because MUI warns before effects run.
const filteredRowCount = table.getRowModel().rows.filter((row) => row.depth === 0).length;
const totalRowCount = table.getFilteredRowModel().rows.length;
const supplierResultsCount = table.getColumn("supplier")?.getFacetedUniqueValues().size ?? 0;
// Emit the filtered row count; the badge controller owns the badge update.
// Suppress leading zeros (the empty table before results populate on open)
// so they don't clear a badge App already set to the restored count.
const hasHadResultsRef = useRef(false);
useEffect(() => {
if (totalRowCount > 0) hasHadResultsRef.current = true;
if (totalRowCount === 0 && !hasHadResultsRef.current) return;
emitSearchEvent(SearchEvent.RESULTS_COUNT, { count: totalRowCount });
}, [filteredRowCount, totalRowCount]);
// Compute valid page sizes from the *filter-applied total* (totalRowCount)
// rather than the page-visible count (filteredRowCount) — feeding the
// page-visible count here creates a self-reinforcing collapse when a prior
// search left pageSize larger than the new search's result count: valid
// sizes would shrink to `[page-visible]`, which forces pageSize down,
// which keeps only that many rows visible, etc. The options Select below
// (line ~572) correctly uses the total as well.
if (totalRowCount > 0) {
const validSizes = generatePageSizes(totalRowCount, 10, 5);
const currentPageSize = tableState.pagination?.pageSize ?? 10;
if (!validSizes.includes(currentPageSize)) {
const best = validSizes.filter((s) => s <= currentPageSize).pop() ?? validSizes.at(-1)!;
table.setPageSize(best);
}
}
// Initialize column visibility - this effect is still needed
useEffect(() => {
if (appContext && !isEmpty(appContext.userSettings.hideColumns)) {
table.getAllLeafColumns().map((column: Column<Product>) => {
if (appContext.userSettings?.hideColumns?.includes(column.id)) {
column.toggleVisibility(false);
}
});
}
}, [appContext?.userSettings.hideColumns, table]);
// Auto column sizing is driven by the raw searchResults (not the filtered
// row model) so that filter input keystrokes don't trigger column remeasuring.
const { getMeasurementTableProps } = useAutoColumnSizing(table, searchResults);
const handleSearch = (query: string) => {
if (query.trim()) {
executeSearch(query.trim());
}
};
const handleKeyPress = (event: KeyboardEvent) => {
if (event.key === "Enter" && isInputElement(event.target)) {
handleSearch(event.target.value);
}
};
const toggleFilters = () => {
setShowFilters(!showFilters);
};
return (
<>
<LoadingBackdrop
open={isLoading}
// Count top-level rows only — `searchResults.length` can double-count
// when suppliers yield variants as flat products rather than nested.
// `getFilteredRowModel().rows.length` is the committed parent-row count
// (sub-rows live on each row's `.subRows`), which is what users see.
resultCount={totalRowCount}
supplierResultsCount={supplierResultsCount}
onClick={handleStopSearch}
/>
<DrawerSystem />
<div className={resultStyles["results-container"]}>
<div className={resultStyles["results-header"]}>
<div className={resultStyles["header-left"]}>
{appContext?.setPanel && (
<BackButton
onClick={() => appContext.setPanel!(0)}
size="small"
aria-label="Back to search home"
>
<ArrowBackIcon />
</BackButton>
)}
{executedQuery && (
<SearchedQueryLabel variant="body2" title={`Searched for: ${executedQuery}`}>
{executedQuery}
</SearchedQueryLabel>
)}
</div>
<div className={resultStyles["header-right"]}>
<FilterIconButton
onClick={toggleFilters}
size="small"
isActive={showFilters}
activeColor="#007bff"
textColor="#666"
>
<FilterListIcon />
</FilterIconButton>
<ColoredIconButton
onClick={(e) => setColumnMenuAnchor(e.currentTarget)}
size="small"
iconColor="#666"
>
<ViewColumnIcon />
</ColoredIconButton>
<ColoredIconButton
onClick={() => appContext?.toggleDrawer()}
size="small"
iconColor="#666"
>
<SettingsIcon />
</ColoredIconButton>
</div>
</div>
{/* <div className="results-title">Search Results ({searchResults.length} found)</div> */}
<ResultsHeaderContainer>
<ResultsCountDisplay>
Results: {filteredRowCount}
{filteredRowCount !== totalRowCount && ` of ${totalRowCount}`}
</ResultsCountDisplay>
{/* Only show the global filter if there are results. Based on
searchResults (not the filtered row model) so the input doesn't
vanish once the user's filter query matches zero rows. */}
{searchResults.length > 0 && (
<GlobalFilterTextField
size="small"
variant="outlined"
placeholder="Filter results..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
inputRef={globalFilterInputRef}
slotProps={{
input: {
onKeyDown: handleKeyPress,
"aria-label": "Filter results",
},
}}
/>
)}
</ResultsHeaderContainer>
<Box
className={`${resultStyles["results-paper"]} ${resultStyles["results-paper-container"]}`}
>
{/* Hidden measurement table for auto-sizing */}
<table
className={resultStyles["hidden-measurement-table"]}
{...getMeasurementTableProps()}
>
<thead className="results-table-column-headers">
<tr>
{table.getAllLeafColumns().map((col) => (
<th key={col.id}>
{typeof col.columnDef.header === "function"
? col.id
: (col.columnDef.header ?? col.id)}
</th>
))}
</tr>
</thead>
<tbody className="results-table-body">
{table
.getRowModel()
.rows.slice(0, 5)
.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{typeof cell.column.columnDef.cell === "function"
? cell.column.columnDef.cell(cell.getContext())
: ""}
</td>
))}
</tr>
))}
</tbody>
</table>
<SearchResultsTable>
{/* Table Head */}
<StyledTableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<StickyHeaderCell
key={header.id}
canSort={header.column.getCanSort()}
cellWidth={header.getSize()}
onClick={header.column.getToggleSortingHandler()}
style={header.column.columnDef.meta?.style}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</StickyHeaderCell>
))}
</TableRow>
))}
{/* Filter Row */}
{showFilters &&
table.getHeaderGroups().map((headerGroup) => {
return (
<TableRow key={`${headerGroup.id}-filters`}>
{headerGroup.headers.map((header) => {
if (header.column.id === "expander") {
return (
<FilterTableCell
key={`${header.id}-filter`}
cellWidth={27.5}
sx={{ flexShrink: 0 }}
>
<Tooltip title="Clear all filters">
<IconButton
size="small"
onClick={() => columnFilterFns[1]([])}
aria-label="Clear all filters"
sx={{ flexShrink: 0 }}
>
<SearchOffIcon fontSize="small" sx={{ flexShrink: 0 }} />
</IconButton>
</Tooltip>
</FilterTableCell>
);
}
return (
<FilterTableCell key={`${header.id}-filter`} cellWidth={header.getSize()}>
{header.column.getCanFilter() ? (
<FilterVariantCell header={header} />
) : null}
</FilterTableCell>
);
})}
</TableRow>
);
})}
</StyledTableHead>
{/* Table Body */}
<StyledTableBody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<SubRowTableRow
key={row.id}
isSubRow={row.depth > 0}
onContextMenu={(e) => handleContextMenu(e, row.original)}
>
{row.getVisibleCells().map((cell) => (
<StyledTableCell
key={cell.id}
className={resultStyles["styled-table-cell"]}
style={{
textAlign: cell.column.columnDef.meta?.style?.textAlign,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</StyledTableCell>
))}
</SubRowTableRow>
))
) : (
<TableRow className={resultStyles["styled-table-row"]}>
<EmptyStateCell colSpan={table.getAllColumns().length}>
{searchResults.length === 0
? isLoading
? "Searching..."
: tableText || "No search query"
: table.getState().columnFilters.length > 0 || table.getState().globalFilter
? "No results matching your filter values"
: "No results found"}
</EmptyStateCell>
</TableRow>
)}
</StyledTableBody>
</SearchResultsTable>
{/* Enhanced error handling */}
{error && (
<ErrorContainer className={resultStyles["error-container"]}>
<p>Error: {error}</p>
<ErrorRetryButton
onClick={() => window.location.reload()}
className={resultStyles["error-retry-button"]}
>
Retry
</ErrorRetryButton>
</ErrorContainer>
)}
{/* Pagination Controls - Only show if more than 1 page */}
{totalRowCount > 10 && (
<PaginationContainer>
{/* Page Size Selector */}
<PageSizeContainer>
<Typography variant="body2">Show:</Typography>
<FormControl size="small">
<PageSizeSelect
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
aria-label="rows per page"
>
{generatePageSizes(totalRowCount, 10, 5).map((pageSize) => (
<MenuItem key={pageSize} value={pageSize}>
{pageSize === totalRowCount ? "All" : pageSize}
</MenuItem>
))}
</PageSizeSelect>
</FormControl>
<Typography variant="body2">rows</Typography>
</PageSizeContainer>
{/* Page Info — "Showing N of M" surfaces the post-filter vs
pre-filter delta when the user narrows the results with a
column / global filter. When no filter is active (filtered
=== total) it collapses back to the plain total form. */}
<Typography variant="body2">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
{filteredRowCount === totalRowCount
? ` (${totalRowCount} total results)`
: ` (Showing ${filteredRowCount} of ${totalRowCount} results)`}
</Typography>
{/* Navigation Buttons */}
<NavigationContainer>
<IconButton
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
size="small"
>
<FirstPageIcon />
</IconButton>
<IconButton
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
size="small"
>
<ChevronLeftIcon />
</IconButton>
<IconButton
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
size="small"
>
<ChevronRightIcon />
</IconButton>
<IconButton
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
size="small"
>
<LastPageIcon />
</IconButton>
</NavigationContainer>
</PaginationContainer>
)}
</Box>
{/* Column Visibility Menu */}
<Menu
anchorEl={columnMenuAnchor}
open={Boolean(columnMenuAnchor)}
onClose={() => setColumnMenuAnchor(null)}
className={styles["column-visibility-menu"]}
>
{table
.getAllLeafColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<ColumnMenuItemContainer key={column.id}>
<FormControlLabel
control={
<Checkbox
checked={column.getIsVisible()}
onChange={column.getToggleVisibilityHandler()}
/>
}
label={<ListItemText primary={String(column.columnDef.header || column.id)} />}
/>
</ColumnMenuItemContainer>
))}
</Menu>
{/* Context Menu */}
{contextMenu && contextMenu.product && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
product={contextMenu.product}
onClose={handleCloseContextMenu}
onExcludeProduct={excludeProduct}
/>
)}
</div>
</>
);
}
Enhanced ResultsTable component using chem-pal styling with local functionality
Features: