From 47e068b45f532375844dc1ee09719a7f9816d018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Tue, 11 Aug 2026 12:02:43 +0100 Subject: [PATCH 01/15] Test shared table behavior Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed Co-authored-by: Amp --- .../dashboard/components/table/Table.test.tsx | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 client/src/webpages/dashboard/components/table/Table.test.tsx diff --git a/client/src/webpages/dashboard/components/table/Table.test.tsx b/client/src/webpages/dashboard/components/table/Table.test.tsx new file mode 100644 index 000000000..6af515674 --- /dev/null +++ b/client/src/webpages/dashboard/components/table/Table.test.tsx @@ -0,0 +1,192 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { Column } from 'react-table'; +import { describe, expect, it, vi } from 'vitest'; + +import { ColumnProps, DefaultColumnFilter } from './filters'; +import { stringSort } from './sort'; +import Table from './Table'; + +type TableRow = { + name: ReactNode; + status: string; + values: { id: string; name: string; status: string }; +}; + +const columns = [ + { Header: 'Name', accessor: 'name', sortType: stringSort }, + { Header: 'Status', accessor: 'status', canSort: false }, +] as unknown as Column[]; + +const data: TableRow[] = [ + { + name: Rendered Zulu, + status: 'Open', + values: { id: 'z', name: 'Zulu', status: 'Open' }, + }, + { + name: Rendered Alpha, + status: 'Closed', + values: { id: 'a', name: 'Alpha', status: 'Closed' }, + }, + { + name: Rendered Alpine, + status: 'Open', + values: { id: 'alpine', name: 'Alpine', status: 'Open' }, + }, +]; + +function renderTable(tableColumns: Column[] = columns, props = {}) { + return render( + + + , + ); +} + +function renderedNames() { + return within(screen.getAllByRole('rowgroup')[1]) + .getAllByRole('row') + .map((row) => within(row).getAllByRole('cell')[0].textContent); +} + +function expectAllRowsVisible() { + expect(screen.getByText('Rendered Zulu')).toBeTruthy(); + expect(screen.getByText('Rendered Alpha')).toBeTruthy(); + expect(screen.getByText('Rendered Alpine')).toBeTruthy(); +} + +const filterFor = + (accessor: 'name' | 'status', placeholder: string) => (props: ColumnProps) => + DefaultColumnFilter({ columnProps: props, accessor, placeholder }); + +describe('Table v7 behavior', () => { + it('renders accessor values and sorts by raw values only on sortable headers', () => { + renderTable(); + + expect(screen.getByRole('columnheader', { name: /Name/ })).toBeTruthy(); + expect(screen.getByRole('columnheader', { name: 'Status' })).toBeTruthy(); + expectAllRowsVisible(); + + fireEvent.click(screen.getByRole('columnheader', { name: /Name/ })); + expect(renderedNames()).toEqual([ + 'Rendered Alpha', + 'Rendered Alpine', + 'Rendered Zulu', + ]); + + fireEvent.click(screen.getByRole('columnheader', { name: /Name/ })); + expect(renderedNames()).toEqual([ + 'Rendered Zulu', + 'Rendered Alpine', + 'Rendered Alpha', + ]); + + fireEvent.click(screen.getByRole('columnheader', { name: 'Status' })); + expect(renderedNames()).toEqual([ + 'Rendered Zulu', + 'Rendered Alpine', + 'Rendered Alpha', + ]); + }); + + it('does not offer filtering unless a column supplies a filter renderer', () => { + renderTable(); + expect(screen.queryByRole('button', { name: /filter/i })).toBeNull(); + }); + + it('stages multiple filters until Save and supports clearing and removing them', () => { + const filterColumns = [ + { + ...columns[0], + Filter: filterFor('name', 'Filter names'), + filter: 'text', + }, + { + ...columns[1], + Filter: filterFor('status', 'Filter statuses'), + filter: 'text', + }, + ] as unknown as Column[]; + renderTable(filterColumns); + + fireEvent.click(screen.getByRole('button', { name: /filter/i })); + fireEvent.click(screen.getAllByText('Name')[0]); + fireEvent.change(screen.getByPlaceholderText('Filter names'), { + target: { value: 'alp' }, + }); + fireEvent.click(screen.getAllByText('Status')[0]); + fireEvent.change(screen.getByPlaceholderText('Filter statuses'), { + target: { value: 'Closed' }, + }); + expectAllRowsVisible(); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(screen.getByText('Rendered Alpha')).toBeTruthy(); + expect(screen.queryByText('Rendered Alpine')).toBeNull(); + expect(screen.queryByText('Rendered Zulu')).toBeNull(); + expect(screen.getByText('Name: alp')).toBeTruthy(); + expect(screen.getByText('Status: Closed')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: /filter/i })); + fireEvent.change(screen.getByPlaceholderText('Filter statuses'), { + target: { value: '' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(screen.getByText('Rendered Alpha')).toBeTruthy(); + expect(screen.getByText('Rendered Alpine')).toBeTruthy(); + expect(screen.queryByText('Rendered Zulu')).toBeNull(); + expect(screen.queryByText('Status: Closed')).toBeNull(); + + const nameChip = screen.getByText('Name: alp'); + const closeIcon = nameChip.parentElement?.querySelector('svg'); + expect(closeIcon).not.toBeNull(); + fireEvent.click(closeIcon!); + expectAllRowsVisible(); + }); + + it('renders links and selects a clicked row using its raw data', () => { + const onSelectRow = vi.fn(); + renderTable(columns, { + rowLinkTo: (row: any) => `/rows/${row.original.values.id}`, + onSelectRow, + }); + + expect( + screen.getAllByRole('link').map((link) => link.getAttribute('href')), + ).toEqual( + expect.arrayContaining([ + '/rows/a', + '/rows/a', + '/rows/alpine', + '/rows/alpine', + '/rows/z', + '/rows/z', + ]), + ); + const alphaRow = screen.getByText('Rendered Alpha').closest('tr')!; + fireEvent.click(alphaRow); + expect(onSelectRow).toHaveBeenCalledTimes(1); + expect(onSelectRow.mock.calls[0][0].original.values.id).toBe('a'); + expect(alphaRow.classList.contains('bg-indigo-100')).toBe(true); + }); + + it('renders one collapsed summary cell per row instead of normal columns', () => { + renderTable(columns, { + isCollapsed: true, + collapsedColumnTitle: 'Summary', + renderCollapsedCell: (row: any) => + `Collapsed ${row.original.values.name}`, + }); + + expect(screen.getAllByRole('columnheader')).toHaveLength(1); + expect(screen.getByRole('columnheader', { name: 'Summary' })).toBeTruthy(); + expect(screen.getAllByRole('cell')).toHaveLength(3); + expect(screen.getByText('Collapsed Zulu')).toBeTruthy(); + expect(screen.getByText('Collapsed Alpha')).toBeTruthy(); + expect(screen.getByText('Collapsed Alpine')).toBeTruthy(); + expect(screen.queryByText('Open')).toBeNull(); + expect(screen.queryByText('Closed')).toBeNull(); + }); +}); From 42ffce678edaf8cc6a418b3672ae82afd9504d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Tue, 11 Aug 2026 12:08:41 +0100 Subject: [PATCH 02/15] Tighten shared table behavior tests Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed Co-authored-by: Amp --- .../dashboard/components/table/Table.test.tsx | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/client/src/webpages/dashboard/components/table/Table.test.tsx b/client/src/webpages/dashboard/components/table/Table.test.tsx index 6af515674..61caacc93 100644 --- a/client/src/webpages/dashboard/components/table/Table.test.tsx +++ b/client/src/webpages/dashboard/components/table/Table.test.tsx @@ -112,11 +112,15 @@ describe('Table v7 behavior', () => { renderTable(filterColumns); fireEvent.click(screen.getByRole('button', { name: /filter/i })); - fireEvent.click(screen.getAllByText('Name')[0]); + const filterMenu = screen + .getByRole('button', { name: 'Save' }) + .closest('.absolute'); + expect(filterMenu).not.toBeNull(); + fireEvent.click(within(filterMenu!).getByText('Name')); fireEvent.change(screen.getByPlaceholderText('Filter names'), { target: { value: 'alp' }, }); - fireEvent.click(screen.getAllByText('Status')[0]); + fireEvent.click(within(filterMenu!).getByText('Status')); fireEvent.change(screen.getByPlaceholderText('Filter statuses'), { target: { value: 'Closed' }, }); @@ -153,18 +157,20 @@ describe('Table v7 behavior', () => { onSelectRow, }); - expect( - screen.getAllByRole('link').map((link) => link.getAttribute('href')), - ).toEqual( - expect.arrayContaining([ - '/rows/a', - '/rows/a', - '/rows/alpine', - '/rows/alpine', - '/rows/z', - '/rows/z', - ]), + const bodyRows = within(screen.getAllByRole('rowgroup')[1]).getAllByRole( + 'row', ); + expect( + bodyRows.map((row) => + within(row) + .getAllByRole('link') + .map((link) => link.getAttribute('href')), + ), + ).toEqual([ + ['/rows/z', '/rows/z'], + ['/rows/a', '/rows/a'], + ['/rows/alpine', '/rows/alpine'], + ]); const alphaRow = screen.getByText('Rendered Alpha').closest('tr')!; fireEvent.click(alphaRow); expect(onSelectRow).toHaveBeenCalledTimes(1); From 1911f86be8a2f197360f527b18481a436660eb08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Tue, 11 Aug 2026 12:21:35 +0100 Subject: [PATCH 03/15] Migrate shared table to TanStack Table v8 Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed Co-authored-by: Amp --- client/package-lock.json | 58 ++-- client/package.json | 3 +- .../dashboard/components/table/Table.test.tsx | 97 +++++- .../dashboard/components/table/Table.tsx | 308 ++++++++---------- .../components/table/TableFilter.tsx | 189 ++++------- .../dashboard/components/table/filters.tsx | 234 +++++-------- .../dashboard/components/table/sort.tsx | 31 +- 7 files changed, 417 insertions(+), 503 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 1ca93bbfe..42e9fdfc5 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -33,6 +33,7 @@ "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.13", + "@tanstack/react-table": "^8.21.3", "@tiptap/react": "^2.0.4", "@tiptap/starter-kit": "^2.0.4", "antd": "^4.10.2", @@ -56,7 +57,6 @@ "react-query": "^3.39.1", "react-router-dom": "^6.30.4", "react-syntax-highlighter": "^16.1.0", - "react-table": "^7.7.0", "recharts": "^2.1.10", "safe-stable-stringify": "^2.4.2", "sonner": "^1.5.0", @@ -83,7 +83,6 @@ "@types/react-csv": "^1.1.10", "@types/react-dom": "^18.0.0", "@types/react-syntax-highlighter": "^13.5.2", - "@types/react-table": "^7.7.20", "@typescript-eslint/eslint-plugin": "^8.64.0", "@typescript-eslint/parser": "^8.57.2", "@vitejs/plugin-react": "^5.2.0", @@ -4422,6 +4421,39 @@ "node": ">=4" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -5308,16 +5340,6 @@ "@types/react": "*" } }, - "node_modules/@types/react-table": { - "version": "7.7.20", - "resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.20.tgz", - "integrity": "sha512-ahMp4pmjVlnExxNwxyaDrFgmKxSbPwU23sGQw2gJK4EhCvnvmib2s/O/+y1dfV57dXOwpr2plfyBol+vEHbi2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/resolve": { "version": "1.20.6", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz", @@ -12292,18 +12314,6 @@ "react": ">= 0.14.0" } }, - "node_modules/react-table": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.8.0.tgz", - "integrity": "sha512-hNaz4ygkZO4bESeFfnfOft73iBUj8K5oKi1EcSHPAibEydfsX2MyU6Z8KCr3mv3C9Kqqh71U+DhZkFvibbnPbA==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.3 || ^17.0.0-0 || ^18.0.0" - } - }, "node_modules/react-transition-group": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", diff --git a/client/package.json b/client/package.json index 5f4899b85..2219d12cf 100644 --- a/client/package.json +++ b/client/package.json @@ -42,6 +42,7 @@ "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.13", + "@tanstack/react-table": "^8.21.3", "@tiptap/react": "^2.0.4", "@tiptap/starter-kit": "^2.0.4", "antd": "^4.10.2", @@ -65,7 +66,6 @@ "react-query": "^3.39.1", "react-router-dom": "^6.30.4", "react-syntax-highlighter": "^16.1.0", - "react-table": "^7.7.0", "recharts": "^2.1.10", "safe-stable-stringify": "^2.4.2", "sonner": "^1.5.0", @@ -92,7 +92,6 @@ "@types/react-csv": "^1.1.10", "@types/react-dom": "^18.0.0", "@types/react-syntax-highlighter": "^13.5.2", - "@types/react-table": "^7.7.20", "@typescript-eslint/eslint-plugin": "^8.64.0", "@typescript-eslint/parser": "^8.57.2", "@vitejs/plugin-react": "^5.2.0", diff --git a/client/src/webpages/dashboard/components/table/Table.test.tsx b/client/src/webpages/dashboard/components/table/Table.test.tsx index 61caacc93..9e419e3bb 100644 --- a/client/src/webpages/dashboard/components/table/Table.test.tsx +++ b/client/src/webpages/dashboard/components/table/Table.test.tsx @@ -1,12 +1,11 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { Column } from 'react-table'; import { describe, expect, it, vi } from 'vitest'; import { ColumnProps, DefaultColumnFilter } from './filters'; import { stringSort } from './sort'; -import Table from './Table'; +import Table, { TableColumnDef } from './Table'; type TableRow = { name: ReactNode; @@ -15,9 +14,15 @@ type TableRow = { }; const columns = [ - { Header: 'Name', accessor: 'name', sortType: stringSort }, - { Header: 'Status', accessor: 'status', canSort: false }, -] as unknown as Column[]; + { + header: 'Name', + accessorKey: 'name', + cell: ({ getValue }) => getValue(), + sortingFn: stringSort, + sortDescFirst: false, + }, + { header: 'Status', accessorKey: 'status', enableSorting: false }, +] satisfies TableColumnDef[]; const data: TableRow[] = [ { @@ -37,7 +42,10 @@ const data: TableRow[] = [ }, ]; -function renderTable(tableColumns: Column[] = columns, props = {}) { +function renderTable( + tableColumns: TableColumnDef[] = columns, + props = {}, +) { return render(
@@ -100,15 +108,15 @@ describe('Table v7 behavior', () => { const filterColumns = [ { ...columns[0], - Filter: filterFor('name', 'Filter names'), - filter: 'text', + meta: { filter: filterFor('name', 'Filter names') }, + filterFn: 'text', }, { ...columns[1], - Filter: filterFor('status', 'Filter statuses'), - filter: 'text', + meta: { filter: filterFor('status', 'Filter statuses') }, + filterFn: 'text', }, - ] as unknown as Column[]; + ] satisfies TableColumnDef[]; renderTable(filterColumns); fireEvent.click(screen.getByRole('button', { name: /filter/i })); @@ -195,4 +203,71 @@ describe('Table v7 behavior', () => { expect(screen.queryByText('Open')).toBeNull(); expect(screen.queryByText('Closed')).toBeNull(); }); + + it('facets raw options by other filters but not the probed column filter', () => { + const FacetFilter = (props: ColumnProps) => ( + <> +
+ {props.preFilteredRows + .map((row) => row.original.values.name) + .sort() + .join(',')} +
+ + + ); + const filterColumns: TableColumnDef[] = [ + { + ...columns[0], + meta: { filter: FacetFilter }, + filterFn: 'text', + }, + { + ...columns[1], + meta: { filter: filterFor('status', 'Filter statuses') }, + filterFn: 'text', + }, + { + header: 'ID', + id: 'id', + accessorFn: (row) => row.values.id, + cell: ({ row }) => `Rendered ${row.original.values.id}`, + meta: { filter: filterFor('id', 'Filter IDs') }, + filterFn: 'text', + }, + ]; + renderTable(filterColumns); + + fireEvent.click(screen.getByRole('button', { name: /filter/i })); + const menu = screen + .getByRole('button', { name: 'Save' }) + .closest('.absolute')!; + fireEvent.click(within(menu).getByText('Status')); + fireEvent.change(screen.getByPlaceholderText('Filter statuses'), { + target: { value: 'Open' }, + }); + fireEvent.click(within(menu).getByText('ID')); + fireEvent.change(screen.getByPlaceholderText('Filter IDs'), { + target: { value: 'z' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + fireEvent.click(screen.getByRole('button', { name: /filter/i })); + fireEvent.click( + within( + screen.getByRole('button', { name: 'Save' }).closest('.absolute')!, + ).getByText('Name'), + ); + expect(screen.getByTestId('facets').textContent).toBe('Zulu'); + + fireEvent.change(screen.getByPlaceholderText('Filter facets'), { + target: { value: 'Zulu' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + fireEvent.click(screen.getByRole('button', { name: /filter/i })); + expect(screen.getByTestId('facets').textContent).toBe('Zulu'); + }); }); diff --git a/client/src/webpages/dashboard/components/table/Table.tsx b/client/src/webpages/dashboard/components/table/Table.tsx index 83c4aefbb..474e637ca 100644 --- a/client/src/webpages/dashboard/components/table/Table.tsx +++ b/client/src/webpages/dashboard/components/table/Table.tsx @@ -1,35 +1,51 @@ -/* eslint-disable react/jsx-key */ import SortAmountAsc from '@/icons/lni/Text editor/sort-amount-asc.svg?react'; import SortAmountDsc from '@/icons/lni/Text editor/sort-amount-dsc.svg?react'; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFilteredRowModel, + getSortedRowModel, + Row, + RowData, + useReactTable, +} from '@tanstack/react-table'; import { ReactNode, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { Column, Row, useFilters, useSortBy, useTable } from 'react-table'; import { getFilterTypes } from './filters'; import TableFilter from './TableFilter'; -export default function Table( - props: { - columns: ReadonlyArray>; - data: readonly object[]; - onSelectRow?: (rowData: Row) => void; - rowLinkTo?: (rowData: Row) => string; - topLeftComponent?: ReactNode; - topRightComponent?: ReactNode; - customMaxHeight?: `max-h-[${number}px]`; - disableFilter?: boolean; - containerClassName?: string; - /** Force the horizontal scrollbar to always render. Opt-in because tables - * that always fit the viewport would otherwise show an unnecessary scrollbar. */ - alwaysShowScrollbar?: boolean; - } & ( - | { - isCollapsed?: boolean; - collapsedColumnTitle?: string; - renderCollapsedCell?: (row: Row) => ReactNode; - } - | {} - ), +export type TableRowData = RowData; +export type TableRow = Row; +export type TableColumnDef = ColumnDef< + TData, + TValue +>; + +type TableProps> = { + columns: TableColumnDef[]; + data: readonly TData[]; + onSelectRow?: (rowData: TableRow) => void; + rowLinkTo?: (rowData: TableRow) => string; + topLeftComponent?: ReactNode; + topRightComponent?: ReactNode; + customMaxHeight?: `max-h-[${number}px]`; + disableFilter?: boolean; + containerClassName?: string; + alwaysShowScrollbar?: boolean; +} & ( + | { + isCollapsed?: boolean; + collapsedColumnTitle?: string; + renderCollapsedCell?: (row: TableRow) => ReactNode; + } + | Record +); + +export default function Table>( + props: TableProps, ) { const { columns, @@ -43,60 +59,57 @@ export default function Table( containerClassName, alwaysShowScrollbar, } = props; - const { - isCollapsed = undefined, - collapsedColumnTitle = undefined, - renderCollapsedCell = undefined, - } = 'isCollapsed' in props ? props : {}; - - const rowsAreSelectable = onSelectRow !== undefined; - - const filterTypes = useMemo(getFilterTypes, []); - - const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = - /* @ts-ignore */ - useTable({ columns, data, filterTypes }, useFilters, useSortBy); - + const { isCollapsed, collapsedColumnTitle, renderCollapsedCell } = + 'isCollapsed' in props ? props : {}; + const filterFns = useMemo(getFilterTypes, []); + const table = useReactTable({ + columns, + data: [...data], + filterFns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + const rows = table.getRowModel().rows; const [selectedRow, setSelectedRow] = useState(null); - - const selectRow = (row: Row, rowIndex: number) => { - if (rowsAreSelectable) { - setSelectedRow(rowIndex); + const rowsAreSelectable = onSelectRow !== undefined; + const selectRow = (row: TableRow, index: number) => { + if (onSelectRow) { + setSelectedRow(index); onSelectRow(row); } }; + const rowClass = (index: number) => + rowsAreSelectable || rowLinkTo + ? selectedRow === index + ? 'cursor-pointer bg-indigo-100 hover:bg-indigo-100 border border-solid border-indigo-200 group' + : `cursor-pointer hover:bg-indigo-100 group ${index % 2 === 0 ? 'bg-white' : 'bg-slate-50'}` + : index % 2 === 0 + ? 'bg-white' + : 'bg-slate-50'; return (
{topLeftComponent} {disableFilter ? null : ( - group.headers, - )} - /> + )} {topRightComponent}
-
+
- {headerGroups.map((headerGroup, _i) => ( - + {table.getHeaderGroups().map((group) => ( + {isCollapsed && collapsedColumnTitle ? ( ) : ( - headerGroup.headers.map((column, index) => { - // For some reason when we pass `columns` into useTable, - // the `headers` prop in headerGroups doesn't receive the - // canSort value from each of the columns (it's overwritten) - // to true always. So we pull the canSort value from the - // `columns` variable instead of the headerGroup.headers variable. - // NB: canSort defaults to true - const canSort = - columns.find( - /* @ts-ignore */ - (col) => col.Header === column.Header, - /* @ts-ignore */ - )!.canSort ?? true; - // If we don't set this on the header's column object directly, - // the user can still click the header to sort the row, even - // though the sort UI is hidden. - if (!canSort) { - /* @ts-ignore */ - column.canSort = false; - } + group.headers.map((header, index) => { + const sorted = header.column.getIsSorted(); return ( ))} - + {rows.map((row, rowIndex) => { - prepareRow(row); - const cell = renderCollapsedCell && renderCollapsedCell(row); - const cellWithWrapper = rowLinkTo ? ( - - {cell} - - ) : ( -
- {cell} -
- ); - return isCollapsed ? ( - selectRow(row, rowIndex)} - > - - - ) : ( + + + ); + } + const cells = row.getVisibleCells(); + return ( selectRow(row, rowIndex)} > - {row.cells.map((cell, index) => { - const cellWithWrapper = rowLinkTo ? ( - - {cell.render('Cell')} - - ) : ( -
- {cell.render('Cell')} -
+ {cells.map((cell, index) => { + const content = flexRender( + cell.column.columnDef.cell, + cell.getContext(), ); - return ( ); })} diff --git a/client/src/webpages/dashboard/components/table/TableFilter.tsx b/client/src/webpages/dashboard/components/table/TableFilter.tsx index e6d2cd6c9..a537feea2 100644 --- a/client/src/webpages/dashboard/components/table/TableFilter.tsx +++ b/client/src/webpages/dashboard/components/table/TableFilter.tsx @@ -1,6 +1,7 @@ import ChevronDown from '@/icons/lni/Direction/chevron-down.svg?react'; import ChevronUp from '@/icons/lni/Direction/chevron-up.svg?react'; import { FilterOutlined } from '@ant-design/icons'; +import { Column, RowData } from '@tanstack/react-table'; import { Button } from 'antd'; import omit from 'lodash/omit'; import without from 'lodash/without'; @@ -10,122 +11,69 @@ import CloseButton from '@/components/common/CloseButton'; import CoopButton from '../CoopButton'; -export default function TableFilter(props: { headers: any[] }) { - const { headers } = props; - - const [menuVisible, setMenuVisible] = useState(false); - const [expandedColumnNames, setExpandedColumnNames] = useState([]); - const [unsavedFilterValues, setUnsavedFilterValues] = useState<{ - [key: string]: any; - }>({}); - const [isButtonFloatedRight, setIsButtonFloatedRight] = useState(false); - +export default function TableFilter({ + columns, +}: { + columns: Column[]; +}) { + const filterColumns = columns.filter( + (column) => column.getCanFilter() && column.columnDef.meta?.filter, + ); + const [menuVisible, setMenuVisible] = useState(false); + const [expanded, setExpanded] = useState([]); + const [pending, setPending] = useState>({}); + const [floatedRight, setFloatedRight] = useState(false); const buttonRef = useRef(null); - - // Determine whether the "Filter" button has floated to the left or right of the screen, - // which helps us display the filter menu properly. useEffect(() => { - const handleButtonPosition = () => { - const button = buttonRef.current; - if (!button) return; - - const buttonRect = button.getBoundingClientRect(); - const canvasCenter = window.innerWidth / 2; - - // Check if the button is placed on the right side - setIsButtonFloatedRight(buttonRect.right > canvasCenter); + const position = () => { + if (buttonRef.current) + setFloatedRight( + buttonRef.current.getBoundingClientRect().right > + window.innerWidth / 2, + ); }; - - handleButtonPosition(); // Initial check - window.addEventListener('resize', handleButtonPosition); - return () => window.removeEventListener('resize', handleButtonPosition); + position(); + window.addEventListener('resize', position); + return () => window.removeEventListener('resize', position); }, [menuVisible]); - - const scrollToButton = () => { - if (buttonRef.current) { - // If the button is in the bottom half of the screen, scroll down to it - // and place it in the middle of the screen. - const buttonPosition = buttonRef.current.getBoundingClientRect().top; - const halfwayPoint = window.innerHeight / 2; - if (buttonPosition > halfwayPoint) { - buttonRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'center', - }); - } - } - }; - - const toggleColumn = (column: string) => { - if (expandedColumnNames.includes(column)) { - setExpandedColumnNames(without(expandedColumnNames, column)); - } else { - setExpandedColumnNames([...expandedColumnNames, column]); - } - }; - const onSave = () => { - for (const [columnId, value] of Object.entries(unsavedFilterValues)) { - headers.find((header) => header.id === columnId)!.setFilter(value); - } + Object.entries(pending).forEach(([id, value]) => + filterColumns.find((c) => c.id === id)?.setFilterValue(value), + ); setMenuVisible(false); }; - - const onSetUnsavedFilterValue = (columnId: string, value: any) => { - setUnsavedFilterValues({ - ...unsavedFilterValues, - [columnId]: value, - }); - }; - - const removeFilter = (columnId: string) => { - setUnsavedFilterValues(omit(unsavedFilterValues, columnId)); - headers.find((header) => header.id === columnId)!.setFilter(undefined); + const remove = (id: string) => { + setPending(omit(pending, id)); + filterColumns.find((c) => c.id === id)?.setFilterValue(undefined); }; - - const activeFilters = headers.filter((header) => header.filterValue); - - return headers.some((header) => header.filter) ? ( + const active = filterColumns.filter((column) => column.getFilterValue()); + if (!filterColumns.length) return null; + return (
- {activeFilters.map((column, i) => ( + {active.map((column) => (
- {`${column.Header}: ${column.filterValue}`} - removeFilter(column.id)} /> + {`${String(column.columnDef.header)}: ${column.getFilterValue()}`} + remove(column.id)} />
))}
{menuVisible && (
Filter
@@ -133,45 +81,46 @@ export default function TableFilter(props: { headers: any[] }) {
- {headers.map((column, index) => { - if (!column || !column.Header.length || !column.Filter) { - return null; - } - const expanded = expandedColumnNames.includes(column.Header); + {filterColumns.map((column) => { + const label = String(column.columnDef.header); + const open = expanded.includes(label); + const Renderer = column.columnDef.meta!.filter!; return (
toggleColumn(column.Header)} - key={`${index}_column_cell`} + onClick={() => + setExpanded( + open ? without(expanded, label) : [...expanded, label], + ) + } > -
- {column.Header} -
- {expanded ? ( +
{label}
+ {open ? ( ) : ( )}
- {expanded && ( -
- {column.Filter({ - ...column, - setUnsavedFilterValue: (value: any) => - onSetUnsavedFilterValue(column.id, value), - unsavedFilterValue: unsavedFilterValues[column.id], - onSave, - })} + {open && ( +
+ + setPending({ + ...pending, + [column.id]: + typeof value === 'function' + ? value(pending[column.id]) + : value, + }) + } + unsavedFilterValue={pending[column.id]} + onSave={onSave} + />
)}
@@ -181,5 +130,5 @@ export default function TableFilter(props: { headers: any[] }) {
)}
- ) : null; + ); } diff --git a/client/src/webpages/dashboard/components/table/filters.tsx b/client/src/webpages/dashboard/components/table/filters.tsx index 09da9f3fb..6d92d2142 100644 --- a/client/src/webpages/dashboard/components/table/filters.tsx +++ b/client/src/webpages/dashboard/components/table/filters.tsx @@ -1,152 +1,106 @@ +import { FilterFn, Row, RowData } from '@tanstack/react-table'; import { DatePicker, Input, Select } from 'antd'; import intersection from 'lodash/intersection'; import uniq from 'lodash/uniq'; -import { MouseEvent } from 'react'; -import { Row, UseFiltersColumnProps } from 'react-table'; +import { MouseEvent, ReactNode } from 'react'; + +declare module '@tanstack/react-table' { + interface FilterFns { + text: FilterFn; + includes: FilterFn; + range: FilterFn; + dateRange: FilterFn; + } + interface ColumnMeta { + filter?: (props: ColumnProps) => ReactNode; + valueType?: TValue; + } +} const { Option } = Select; const { RangePicker } = DatePicker; +type RawRow = { values: Record }; -export type ColumnProps = UseFiltersColumnProps & { +export type ColumnProps> = { + preFilteredRows: readonly Pick, 'original'>[]; setUnsavedFilterValue: (value: any) => void; unsavedFilterValue: any; onSave: () => void; }; - export type FilterProps = { columnProps: ColumnProps; accessor: string; placeholder?: string; }; - -function onClickFilter(event: MouseEvent) { - // This ensures that clicking the filter doesn't trigger a - // 'sort column' event - event.stopPropagation(); -} +const raw = (row: Row, id: string) => (row.original as RawRow).values[id]; export function getFilterTypes() { return { - // Override the default text filter to use "startWith" - text: (rows: Row[], id: string, filterValue: any) => { - if (filterValue == null || filterValue.length === 0) { - return rows; - } - return rows.filter((row) => { - const rowValue = row.original.values[id[0]]; - return rowValue != null - ? String(rowValue) - .toLowerCase() - .includes(String(filterValue).toLowerCase()) - : false; - }); - }, - // Allow for filtering on options in a predetermined list - includes: (rows: Row[], id: string, filterValue: any) => { - if ( - filterValue == null || - (Array.isArray(filterValue) && filterValue.length === 0) - ) { - return rows; - } - return rows.filter((row) => { - const rowValue = row.original.values[id[0]]; - if (rowValue == null) { - return false; - } - if (Array.isArray(rowValue)) { - return intersection(filterValue, rowValue).length > 0; - } - return filterValue.includes(rowValue); - }); - }, - range: (rows: Row[], id: string, filterValue: any) => { - if (filterValue == null) { - return rows; - } - const start = filterValue[0]; - const end = filterValue[1]; - return rows.filter((row) => { - if (start && start > row.original.values[id[0]]) { - return false; - } - if (end && end < row.original.values[id[0]]) { - return false; - } - return true; - }); - }, - dateRange: (rows: Row[], id: string, filterValue: any) => { - if (filterValue == null) { - return rows; - } - let start = filterValue[0]; - let end = filterValue[1]; - if (start) { - start = start.format('YYYY-MM-DD'); - } - if (end) { - end = end.format('YYYY-MM-DD'); - } - return rows.filter((row) => { - if (start && start > row.original.values[id[0]]) { - return false; - } - if (end && end < row.original.values[id[0]]) { - return false; - } - return true; - }); - }, + text: ((row, id, value) => + value == null || + value.length === 0 || + (raw(row, id) != null && + String(raw(row, id)) + .toLowerCase() + .includes(String(value).toLowerCase()))) as FilterFn, + includes: ((row, id, value) => + value == null || + (Array.isArray(value) && value.length === 0) || + (raw(row, id) != null && + (Array.isArray(raw(row, id)) + ? intersection(value, raw(row, id)).length > 0 + : value.includes(raw(row, id))))) as FilterFn, + range: ((row, id, value) => + value == null || + ((!value[0] || value[0] <= raw(row, id)) && + (!value[1] || value[1] >= raw(row, id)))) as FilterFn, + dateRange: ((row, id, value) => { + if (value == null) return true; + const start = value[0]?.format('YYYY-MM-DD'); + const end = value[1]?.format('YYYY-MM-DD'); + return (!start || start <= raw(row, id)) && (!end || end >= raw(row, id)); + }) as FilterFn, }; } - -// Define a default UI for filtering -export function DefaultColumnFilter(props: FilterProps) { - const { columnProps, placeholder } = props; +function onClickFilter(event: MouseEvent) { + event.stopPropagation(); +} +export function DefaultColumnFilter({ columnProps, placeholder }: FilterProps) { const { unsavedFilterValue, setUnsavedFilterValue, onSave } = columnProps; return ( setUnsavedFilterValue(e.target.value || undefined)} - onKeyDown={(event) => { - if (event.key === 'Enter' && unsavedFilterValue?.length) { - onSave(); - } + onKeyDown={(e) => { + if (e.key === 'Enter' && unsavedFilterValue?.length) onSave(); }} onClick={onClickFilter} /> ); } - -// This is a custom filter UI for selecting -// a unique option from a list -export function SelectColumnFilter(props: FilterProps) { - const { columnProps, accessor, placeholder } = props; - const { unsavedFilterValue, setUnsavedFilterValue, preFilteredRows } = - columnProps; - // Calculate the options for filtering - // using the preFilteredRows - const options: (string[] | string)[] = []; - preFilteredRows.forEach((row) => { - options.push((row.original as any).values[accessor]); - }); - const uniqueOptions = uniq(options.flat()); - - // Render a multi-select box +export function SelectColumnFilter({ + columnProps, + accessor, + placeholder, +}: FilterProps) { + const options = uniq( + columnProps.preFilteredRows.flatMap( + (row) => (row.original as RawRow).values[accessor], + ), + ); return ( ); } - -export function NumberRangeColumnFilter(props: FilterProps) { - const { columnProps } = props; - const { setUnsavedFilterValue } = columnProps; - +export function NumberRangeColumnFilter({ columnProps }: FilterProps) { + const set = columnProps.setUnsavedFilterValue; return (
{ - if (!e.target.value) { - setUnsavedFilterValue((old = []) => { - return [undefined, old[1]]; - }); - return; - } - const val = parseFloat(e.target.value); - if (!isNaN(val)) { - setUnsavedFilterValue((old = []) => { - return [val, old[1]]; - }); - } - }} + onChange={(e) => + set((old: any[] = []) => [ + e.target.value ? parseFloat(e.target.value) : undefined, + old[1], + ]) + } onClick={onClickFilter} placeholder="min" /> to { - if (!e.target.value) { - setUnsavedFilterValue((old = []) => { - return [old[0], undefined]; - }); - return; - } - const val = parseFloat(e.target.value); - if (!isNaN(val)) { - setUnsavedFilterValue((old = []) => { - return [old[0], val]; - }); - } - }} + onChange={(e) => + set((old: any[] = []) => [ + old[0], + e.target.value ? parseFloat(e.target.value) : undefined, + ]) + } onClick={onClickFilter} placeholder="max" />
); } - -export function DateRangeColumnFilter(props: FilterProps) { - const { columnProps } = props; - const { unsavedFilterValue, setUnsavedFilterValue } = columnProps; - - // We wrap this in a div because RangePicker's onClick doesn't work +export function DateRangeColumnFilter({ columnProps }: FilterProps) { return (
{ - setUnsavedFilterValue(value); - }} + onChange={(value: any) => columnProps.setUnsavedFilterValue(value)} />
); diff --git a/client/src/webpages/dashboard/components/table/sort.tsx b/client/src/webpages/dashboard/components/table/sort.tsx index 665b97deb..d5c6d3638 100644 --- a/client/src/webpages/dashboard/components/table/sort.tsx +++ b/client/src/webpages/dashboard/components/table/sort.tsx @@ -1,5 +1,5 @@ +import { Row } from '@tanstack/react-table'; import capitalize from 'lodash/capitalize'; -import { IdType, Row } from 'react-table'; import { GQLConditionOutcome, @@ -16,8 +16,7 @@ interface RowWithValues extends Object { export function stringSort( rowA: Row, rowB: Row, - columnId: IdType, - _desc?: boolean, + columnId: string, ) { const s1 = rowA.original.values[columnId]; const s2 = rowB.original.values[columnId]; @@ -36,8 +35,7 @@ export function stringSort( export function integerSort( rowA: Row, rowB: Row, - columnId: IdType, - _desc?: boolean, + columnId: string, ) { // the values come formatted with commas, so we remove all // comma characters before doing any parsing or comparison @@ -49,8 +47,7 @@ export function integerSort( export function boolSort( rowA: Row, rowB: Row, - columnId: IdType, - _desc?: boolean, + columnId: string, ) { const s1 = rowA.original.values[columnId]; const s2 = rowB.original.values[columnId]; @@ -85,7 +82,7 @@ function enumSort( precedence: any[], rowA: Row, rowB: Row, - columnId: IdType, + columnId: string, ) { const s1 = rowA.original.values[columnId]; const s2 = rowB.original.values[columnId]; @@ -99,8 +96,7 @@ function enumSort( export function ruleStatusSort( rowA: Row, rowB: Row, - columnId: IdType, - __?: boolean, + columnId: string, ) { return enumSort( [ @@ -118,8 +114,7 @@ export function ruleStatusSort( export function reportingRuleStatusSort( rowA: Row, rowB: Row, - columnId: IdType, - __?: boolean, + columnId: string, ) { return enumSort( [ @@ -137,8 +132,7 @@ export function reportingRuleStatusSort( export function userRoleSort( rowA: Row, rowB: Row, - columnId: IdType, - __?: boolean, + columnId: string, ) { return enumSort( [GQLUserRole.Admin, GQLUserRole.RulesManager, GQLUserRole.Analyst], @@ -151,8 +145,7 @@ export function userRoleSort( export function conditionOutcomeSort( rowA: Row, rowB: Row, - columnId: IdType, - __?: boolean, + columnId: string, ) { return enumSort( [ @@ -170,8 +163,7 @@ export function conditionOutcomeSort( export function userPenaltySeveritySort( rowA: Row, rowB: Row, - columnId: IdType, - __?: boolean, + columnId: string, ) { return enumSort( [ @@ -196,8 +188,7 @@ export function dateSort(dateKey: string) { return ( rowA: Row, rowB: Row, - _columnId: IdType, - _desc?: boolean, + _columnId: string, ) => { const a = (rowA.original as unknown as Record)[dateKey]; const b = (rowB.original as unknown as Record)[dateKey]; From 3f53c7ab6af289a9aa16c00892aee9c0db24acd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Tue, 11 Aug 2026 12:32:43 +0100 Subject: [PATCH 04/15] Migrate table consumers to TanStack Table v8 Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed Co-authored-by: Amp --- .../dashboard/actions/ActionsDashboard.tsx | 86 ++++++----- .../banks/hash/HashBanksDashboard.tsx | 52 ++++--- .../banks/location/LocationBanksDashboard.tsx | 46 +++--- .../banks/text/TextBanksDashboard.tsx | 66 ++++---- .../dashboard/components/table/Table.test.tsx | 8 +- .../dashboard/components/table/Table.tsx | 3 + .../ItemInvestigationRuleResults.tsx | 144 ++++++++++-------- .../item_types/ItemTypesDashboard.tsx | 66 ++++---- .../dashboard/items/ItemActionHistory.tsx | 30 ++-- .../mrt/ManualReviewDecisionsTable.tsx | 31 ++-- .../mrt/ManualReviewQueueJobsPreview.tsx | 53 ++++--- .../mrt/ManualReviewQueuesDashboard.tsx | 106 +++++++------ .../mrt/ManualReviewRecentDecisions.tsx | 57 +++---- .../MergedReportsComponent.tsx | 8 +- .../ManualReviewJobCurrentJobsComponent.tsx | 12 +- .../dashboard/ncmec/NcmecReportsDashboard.tsx | 140 +++++++++-------- .../dashboard/ReportingRulesDashboard.tsx | 113 +++++++------- .../rules/dashboard/RulesDashboard.tsx | 139 +++++++++-------- .../ReportingRuleInsightsSamplesTable.tsx | 113 +++++++------- .../insights/RuleInsightsSamplesTable.tsx | 113 +++++++------- .../dashboard/userStrikes/PolicyScoresTab.tsx | 18 +-- .../userStrikes/StrikeAnalyticsTab.tsx | 18 +-- .../userStrikes/StrikeEnabledActionsTab.tsx | 52 ++++--- client/src/webpages/settings/ManageUsers.tsx | 116 +++++++------- 24 files changed, 859 insertions(+), 731 deletions(-) diff --git a/client/src/webpages/dashboard/actions/ActionsDashboard.tsx b/client/src/webpages/dashboard/actions/ActionsDashboard.tsx index bb28d962c..1f8482502 100644 --- a/client/src/webpages/dashboard/actions/ActionsDashboard.tsx +++ b/client/src/webpages/dashboard/actions/ActionsDashboard.tsx @@ -153,53 +153,61 @@ export default function ActionsDashboard() { const columns = useMemo( () => [ { - Header: 'Name', - accessor: 'name', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'name', - }), - filter: 'text', - sortType: stringSort, + header: 'Name', + accessorKey: 'name', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'name', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, }, { - Header: 'Description', - accessor: 'description', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'description', - }), - filter: 'text', - sortType: stringSort, + header: 'Description', + accessorKey: 'description', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'description', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, }, { - Header: 'Penalty', - accessor: 'penalty', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'penalty', - }), - filter: 'includes', - sortType: userPenaltySeveritySort, + header: 'Penalty', + accessorKey: 'penalty', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'penalty', + }), + }, + filterFn: 'includes' as const, + sortingFn: userPenaltySeveritySort, }, { - Header: 'ID', - accessor: 'id', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'id', - }), - filter: 'text', - canSort: false, + header: 'ID', + accessorKey: 'id', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'id', + }), + }, + filterFn: 'text' as const, + enableSorting: false, }, { - Header: '', - accessor: 'mutations', // accessor is the "key" in the data - canSort: false, + header: '', + accessorKey: 'mutations', // accessor is the "key" in the data + enableSorting: false, }, ], [], diff --git a/client/src/webpages/dashboard/banks/hash/HashBanksDashboard.tsx b/client/src/webpages/dashboard/banks/hash/HashBanksDashboard.tsx index 96e10840f..ad0318588 100644 --- a/client/src/webpages/dashboard/banks/hash/HashBanksDashboard.tsx +++ b/client/src/webpages/dashboard/banks/hash/HashBanksDashboard.tsx @@ -92,36 +92,40 @@ export default function HashBanksDashboard() { const columns = useMemo( () => [ { - Header: 'Name', - accessor: 'name', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'name', - }), - filter: 'text', - sortType: stringSort, + header: 'Name', + accessorKey: 'name', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'name', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, }, { - Header: 'Description', - accessor: 'description', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'description', - }), - filter: 'text', - sortType: stringSort, + header: 'Description', + accessorKey: 'description', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'description', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, }, { - Header: 'Status', - accessor: 'enabled_ratio', - canSort: false, + header: 'Status', + accessorKey: 'enabled_ratio', + enableSorting: false, }, { - Header: '', - accessor: 'mutations', // accessor is the "key" in the data - canSort: false, + header: '', + accessorKey: 'mutations', // accessor is the "key" in the data + enableSorting: false, }, ], [], diff --git a/client/src/webpages/dashboard/banks/location/LocationBanksDashboard.tsx b/client/src/webpages/dashboard/banks/location/LocationBanksDashboard.tsx index 5968f01ae..2696dfd5b 100644 --- a/client/src/webpages/dashboard/banks/location/LocationBanksDashboard.tsx +++ b/client/src/webpages/dashboard/banks/location/LocationBanksDashboard.tsx @@ -107,31 +107,35 @@ export default function LocationBanksDashboard() { const columns = useMemo( () => [ { - Header: 'Name', - accessor: 'name', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'name', - }), - filter: 'text', - sortType: stringSort, + header: 'Name', + accessorKey: 'name', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'name', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, }, { - Header: 'Description', - accessor: 'description', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'description', - }), - filter: 'text', - sortType: stringSort, + header: 'Description', + accessorKey: 'description', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'description', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, }, { - Header: '', - accessor: 'mutations', // accessor is the "key" in the data - canSort: false, + header: '', + accessorKey: 'mutations', // accessor is the "key" in the data + enableSorting: false, }, ], [], diff --git a/client/src/webpages/dashboard/banks/text/TextBanksDashboard.tsx b/client/src/webpages/dashboard/banks/text/TextBanksDashboard.tsx index 3a3acd365..8679b97c6 100644 --- a/client/src/webpages/dashboard/banks/text/TextBanksDashboard.tsx +++ b/client/src/webpages/dashboard/banks/text/TextBanksDashboard.tsx @@ -112,42 +112,48 @@ export default function TextBanksDashboard() { const columns = useMemo( () => [ { - Header: 'Name', - accessor: 'name', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'name', - }), - filter: 'text', - sortType: stringSort, + header: 'Name', + accessorKey: 'name', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'name', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, }, { - Header: 'Description', - accessor: 'description', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'description', - }), - filter: 'text', - sortType: stringSort, + header: 'Description', + accessorKey: 'description', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'description', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, }, { - Header: 'Type', - accessor: 'type', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'type', - }), - filter: 'includes', - canSort: false, + header: 'Type', + accessorKey: 'type', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'type', + }), + }, + filterFn: 'includes' as const, + enableSorting: false, }, { - Header: '', - accessor: 'mutations', // accessor is the "key" in the data - canSort: false, + header: '', + accessorKey: 'mutations', // accessor is the "key" in the data + enableSorting: false, }, ], [], diff --git a/client/src/webpages/dashboard/components/table/Table.test.tsx b/client/src/webpages/dashboard/components/table/Table.test.tsx index 9e419e3bb..9fe041ce8 100644 --- a/client/src/webpages/dashboard/components/table/Table.test.tsx +++ b/client/src/webpages/dashboard/components/table/Table.test.tsx @@ -66,7 +66,7 @@ function expectAllRowsVisible() { } const filterFor = - (accessor: 'name' | 'status', placeholder: string) => (props: ColumnProps) => + (accessor: string, placeholder: string) => (props: ColumnProps) => DefaultColumnFilter({ columnProps: props, accessor, placeholder }); describe('Table v7 behavior', () => { @@ -245,7 +245,7 @@ describe('Table v7 behavior', () => { fireEvent.click(screen.getByRole('button', { name: /filter/i })); const menu = screen .getByRole('button', { name: 'Save' }) - .closest('.absolute')!; + .closest('.absolute')!; fireEvent.click(within(menu).getByText('Status')); fireEvent.change(screen.getByPlaceholderText('Filter statuses'), { target: { value: 'Open' }, @@ -258,7 +258,9 @@ describe('Table v7 behavior', () => { fireEvent.click(screen.getByRole('button', { name: /filter/i })); fireEvent.click( within( - screen.getByRole('button', { name: 'Save' }).closest('.absolute')!, + screen + .getByRole('button', { name: 'Save' }) + .closest('.absolute')!, ).getByText('Name'), ); expect(screen.getByTestId('facets').textContent).toBe('Zulu'); diff --git a/client/src/webpages/dashboard/components/table/Table.tsx b/client/src/webpages/dashboard/components/table/Table.tsx index 474e637ca..931ef3d37 100644 --- a/client/src/webpages/dashboard/components/table/Table.tsx +++ b/client/src/webpages/dashboard/components/table/Table.tsx @@ -65,6 +65,9 @@ export default function Table>( const table = useReactTable({ columns, data: [...data], + defaultColumn: { + cell: ({ getValue }) => getValue() as ReactNode, + }, filterFns, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), diff --git a/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx b/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx index 9a6cdb919..e0342723c 100644 --- a/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx +++ b/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx @@ -4,7 +4,6 @@ import capitalize from 'lodash/capitalize'; import lowerCase from 'lodash/lowerCase'; import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Row } from 'react-table'; import ComponentLoading from '../../../components/common/ComponentLoading'; import CoopBadge from '../components/CoopBadge'; @@ -20,7 +19,7 @@ import { ruleStatusSort, stringSort, } from '../components/table/sort'; -import Table from '../components/table/Table'; +import Table, { TableRow } from '../components/table/Table'; import { GQLConditionOutcome, @@ -85,76 +84,88 @@ export default function ItemInvestigationRuleResults(props: { const columns = useMemo( () => [ { - Header: 'Rule', - accessor: 'rule', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'rule', - }), - filter: 'text', - sortType: stringSort, + header: 'Rule', + accessorKey: 'rule', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'rule', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, }, { - Header: 'Result', - accessor: 'result', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'result', - }), - filter: 'includes', - sortType: conditionOutcomeSort, + header: 'Result', + accessorKey: 'result', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'result', + }), + }, + filterFn: 'includes' as const, + sortingFn: conditionOutcomeSort, }, { - Header: 'Status', - accessor: 'status', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'status', - }), - filter: 'includes', - sortType: ruleStatusSort, + header: 'Status', + accessorKey: 'status', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'status', + }), + }, + filterFn: 'includes' as const, + sortingFn: ruleStatusSort, }, { - Header: 'Policies', - accessor: 'policies', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'policies', - }), - filter: 'includes', - canSort: false, + header: 'Policies', + accessorKey: 'policies', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'policies', + }), + }, + filterFn: 'includes' as const, + enableSorting: false, }, { - Header: 'Tags', - accessor: 'tags', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'tags', - }), - filter: 'includes', - canSort: false, + header: 'Tags', + accessorKey: 'tags', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'tags', + }), + }, + filterFn: 'includes' as const, + enableSorting: false, }, { - Header: 'Actions', - accessor: 'actions', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'actions', - placeholder: 'Filter by action', - }), - filter: 'includes', - sortType: stringSort, + header: 'Actions', + accessorKey: 'actions', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'actions', + placeholder: 'Filter by action', + }), + }, + filterFn: 'includes' as const, + sortingFn: stringSort, }, { - Header: '', - accessor: 'edit', - canSort: false, + header: '', + accessorKey: 'edit', + enableSorting: false, }, ], [], @@ -226,6 +237,17 @@ export default function ItemInvestigationRuleResults(props: { ), + values: { + rule: ruleResult.ruleName, + result: getDisplayName(outcome), + status: ruleResult.environment, + policies: ruleResult.policies, + tags: ruleResult.tags, + actions: + rules + ?.find((it) => ruleResult.ruleId === it.id) + ?.actions?.map((action) => action.name) ?? [], + }, ruleExecutionResult: ruleResult.result, }; }), @@ -273,7 +295,7 @@ export default function ItemInvestigationRuleResults(props: { ); - const onSelectRow = (rowData: Row) => { + const onSelectRow = (rowData: TableRow) => { const executionResult = ruleExecutionsHistory[rowData.index]; if (executionResult == null) { return; diff --git a/client/src/webpages/dashboard/item_types/ItemTypesDashboard.tsx b/client/src/webpages/dashboard/item_types/ItemTypesDashboard.tsx index cb1913528..3386b4594 100644 --- a/client/src/webpages/dashboard/item_types/ItemTypesDashboard.tsx +++ b/client/src/webpages/dashboard/item_types/ItemTypesDashboard.tsx @@ -262,42 +262,48 @@ export default function ItemTypesDashboard() { const columns = useMemo( () => [ { - Header: 'Name', - accessor: 'name', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'name', - }), - filter: 'text', - sortType: stringSort, + header: 'Name', + accessorKey: 'name', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'name', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, }, { - Header: 'Description', - accessor: 'description', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'description', - }), - filter: 'text', - sortType: stringSort, + header: 'Description', + accessorKey: 'description', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'description', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, }, { - Header: 'ID', - accessor: 'id', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'id', - }), - filter: 'text', - canSort: false, + header: 'ID', + accessorKey: 'id', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'id', + }), + }, + filterFn: 'text' as const, + enableSorting: false, }, { - Header: '', - accessor: 'mutations', // accessor is the "key" in the data - canSort: false, + header: '', + accessorKey: 'mutations', // accessor is the "key" in the data + enableSorting: false, }, ], [], diff --git a/client/src/webpages/dashboard/items/ItemActionHistory.tsx b/client/src/webpages/dashboard/items/ItemActionHistory.tsx index 6d31e5c32..38f31bba9 100644 --- a/client/src/webpages/dashboard/items/ItemActionHistory.tsx +++ b/client/src/webpages/dashboard/items/ItemActionHistory.tsx @@ -179,30 +179,30 @@ export default function ItemActionHistory(props: { const columns = useMemo( () => [ { - Header: 'Actions', - accessor: 'actions', - canSort: false, + header: 'Actions', + accessorKey: 'actions', + enableSorting: false, }, { - Header: 'Policies', - accessor: 'policies', - canSort: false, + header: 'Policies', + accessorKey: 'policies', + enableSorting: false, }, { - Header: 'Decision Time', - accessor: 'ts', + header: 'Decision Time', + accessorKey: 'ts', sortDescFirst: true, - sortType: stringSort, + sortingFn: stringSort, }, { - Header: 'Actor', - accessor: 'actor', - canSort: false, + header: 'Actor', + accessorKey: 'actor', + enableSorting: false, }, { - Header: 'Source(s)', - accessor: 'source', - canSort: false, + header: 'Source(s)', + accessorKey: 'source', + enableSorting: false, }, ], [], diff --git a/client/src/webpages/dashboard/mrt/ManualReviewDecisionsTable.tsx b/client/src/webpages/dashboard/mrt/ManualReviewDecisionsTable.tsx index 5b436656e..ba0ea0f0d 100644 --- a/client/src/webpages/dashboard/mrt/ManualReviewDecisionsTable.tsx +++ b/client/src/webpages/dashboard/mrt/ManualReviewDecisionsTable.tsx @@ -6,9 +6,8 @@ import { assertUnreachable } from '@/utils/misc'; import { DownOutlined, UpOutlined } from '@ant-design/icons'; import { gql } from '@apollo/client/core'; import { useState } from 'react'; -import type { Column } from 'react-table'; -import Table from '../components/table/Table'; +import Table, { TableColumnDef } from '../components/table/Table'; import FullScreenLoading from '@/components/common/FullScreenLoading'; import type { TimeWindow } from '../rules/dashboard/visualization/RulesDashboardInsights'; @@ -150,52 +149,54 @@ export default function ManualReviewDecisionsTable(props: { ); const columns = data?.getDecisionsTable - .reduce[]>( + .reduce< + (TableColumnDef & { accessorKey: string; header: string })[] + >( (acc, { action_id, type }) => { if (action_id !== null && action_id !== undefined) { const action = data?.myOrg?.actions.find((a) => a.id === action_id); if ( action === undefined || - acc.find((it) => it.Header === action.name) + acc.find((it) => it.header === action.name) ) { return acc; } - acc.push({ accessor: action_id, Header: action.name }); + acc.push({ accessorKey: action_id, header: action.name }); } // These should be caught in the previous if with the action if (type === 'CUSTOM_ACTION' || type === 'RELATED_ACTION') { return acc; } const title = getReadableNameFromDecisionType(type); - if (!acc.find((it) => it.Header === title)) { + if (!acc.find((it) => it.header === title)) { acc.push({ - accessor: type, - Header: getReadableNameFromDecisionType(type), + accessorKey: type, + header: getReadableNameFromDecisionType(type), }); } return acc; }, - [{ accessor: 'name', Header: 'Name' }], + [{ accessorKey: 'name', header: 'Name' }], ) .sort((a, b) => { - if (typeof a.Header !== 'string') { + if (typeof a.header !== 'string') { return -1; } - if (typeof b.Header !== 'string') { + if (typeof b.header !== 'string') { return 1; } - return a.Header === 'Name' + return a.header === 'Name' ? -1 - : b.Header === 'Name' + : b.header === 'Name' ? 1 - : a.Header.localeCompare(b.Header); + : a.header.localeCompare(b.header); }); const filledInData = columns ? groupedByKey?.map((it) => { const obj = { ...it }; columns.forEach((col) => { - const accessor = col.accessor as string; + const accessor = col.accessorKey; if (obj[accessor] === undefined) { obj[accessor] = 0; } diff --git a/client/src/webpages/dashboard/mrt/ManualReviewQueueJobsPreview.tsx b/client/src/webpages/dashboard/mrt/ManualReviewQueueJobsPreview.tsx index 0e96d44df..995b68832 100644 --- a/client/src/webpages/dashboard/mrt/ManualReviewQueueJobsPreview.tsx +++ b/client/src/webpages/dashboard/mrt/ManualReviewQueueJobsPreview.tsx @@ -2,7 +2,6 @@ import { safeFormat } from '@/utils/time'; import { gql } from '@apollo/client'; import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; -import { Row } from 'react-table'; import ComponentLoading from '../../../components/common/ComponentLoading'; import DashboardHeader from '../components/DashboardHeader'; @@ -12,7 +11,7 @@ import { SelectColumnFilter, } from '../components/table/filters'; import { stringSort } from '../components/table/sort'; -import Table from '../components/table/Table'; +import Table, { TableRow } from '../components/table/Table'; import { useGQLManualReviewQueueJobsPreviewQuery } from '../../../graphql/generated'; import { filterNullOrUndefined } from '../../../utils/collections'; @@ -116,33 +115,37 @@ export default function ManualReviewQueueJobsPreview() { const columns = useMemo( () => [ { - Header: 'Preview', - accessor: 'preview', - canSort: false, + header: 'Preview', + accessorKey: 'preview', + enableSorting: false, }, { - Header: 'Policies', - accessor: 'policies', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'policies', - }), - filter: 'includes', - canSort: false, + header: 'Policies', + accessorKey: 'policies', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'policies', + }), + }, + filterFn: 'includes' as const, + enableSorting: false, }, { - Header: 'Created At', - accessor: 'createdAt', - Filter: (props: ColumnProps) => - DateRangeColumnFilter({ - columnProps: props, - accessor: 'createdAt', - placeholder: '', - }), - filter: 'dateRange', + header: 'Created At', + accessorKey: 'createdAt', + meta: { + filter: (props: ColumnProps) => + DateRangeColumnFilter({ + columnProps: props, + accessor: 'createdAt', + placeholder: '', + }), + }, + filterFn: 'dateRange' as const, sortDescFirst: true, - sortType: stringSort, + sortingFn: stringSort, }, ], [], @@ -218,7 +221,7 @@ export default function ManualReviewQueueJobsPreview() { throw Error(`Queue not found for ID ${queueId}`); } - const rowLinkTo = (row: Row) => { + const rowLinkTo = (row: TableRow) => { // I don't know why but the jobs do not ever render unless you put a fake lock token // at the end of the URL, so the `/1` is actually necessary here return `/dashboard/manual_review/queues/review/${queueId}/${row.original.jobId}/1`; diff --git a/client/src/webpages/dashboard/mrt/ManualReviewQueuesDashboard.tsx b/client/src/webpages/dashboard/mrt/ManualReviewQueuesDashboard.tsx index 8ac60d345..29bf6de51 100644 --- a/client/src/webpages/dashboard/mrt/ManualReviewQueuesDashboard.tsx +++ b/client/src/webpages/dashboard/mrt/ManualReviewQueuesDashboard.tsx @@ -538,87 +538,93 @@ export default function ManualReviewQueuesDashboard() { filterNullOrUndefined([ columnVisibility.favoriteQueues ? { - Header: '', - accessor: 'favoriteQueues', - canSort: false, + header: '', + accessorKey: 'favoriteQueues', + enableSorting: false, } : undefined, columnVisibility.id ? { - Header: 'ID', - accessor: 'id', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'id', - placeholder: 'Queue ID', - }), - filter: 'text', - sortType: stringSort, + header: 'ID', + accessorKey: 'id', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'id', + placeholder: 'Queue ID', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, } : undefined, columnVisibility.name ? { - Header: 'Name', - accessor: 'name', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'name', - placeholder: 'My Queue', - }), - filter: 'text', - sortType: stringSort, + header: 'Name', + accessorKey: 'name', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'name', + placeholder: 'My Queue', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, } : undefined, columnVisibility.description ? { - Header: 'Description', - accessor: 'description', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'description', - }), - filter: 'text', - sortType: stringSort, + header: 'Description', + accessorKey: 'description', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'description', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, } : undefined, columnVisibility.oldestTaskAge ? { - Header: 'Oldest Task Age', - accessor: 'oldestTaskAge', - sortType: dateSort('oldestJobCreatedAt'), + header: 'Oldest Task Age', + accessorKey: 'oldestTaskAge', + sortingFn: dateSort('oldestJobCreatedAt'), } : undefined, columnVisibility.pendingJobCount ? { - Header: 'Pending Jobs', - accessor: 'pendingJobCount', - sortType: integerSort, + header: 'Pending Jobs', + accessorKey: 'pendingJobCount', + sortingFn: integerSort, } : undefined, columnVisibility.startReviewing ? { - Header: '', - accessor: 'startReviewing', - canSort: false, + header: '', + accessorKey: 'startReviewing', + enableSorting: false, } : undefined, columnVisibility.mutations ? { - Header: '', - accessor: 'mutations', - canSort: false, + header: '', + accessorKey: 'mutations', + enableSorting: false, } : undefined, userHasPermissions(data?.me?.permissions, [ GQLUserPermission.ManageOrg, ]) && columnVisibility.deleteJobs ? { - Header: '', - accessor: 'deleteJobs', - canSort: false, + header: '', + accessorKey: 'deleteJobs', + enableSorting: false, } : undefined, previewJobsViewEnabled && @@ -627,9 +633,9 @@ export default function ManualReviewQueuesDashboard() { ]) && columnVisibility.previewJobs ? { - Header: '', - accessor: 'previewJobs', - canSort: false, + header: '', + accessorKey: 'previewJobs', + enableSorting: false, } : undefined, ]), diff --git a/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx b/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx index 6d2a1695e..23751506b 100644 --- a/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx +++ b/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx @@ -365,45 +365,45 @@ export default function ManualReviewRecentDecisions() { filterNullOrUndefined([ columnVisibility.decisionTime ? { - Header: 'Decision Time', - accessor: 'decisionTime', + header: 'Decision Time', + accessorKey: 'decisionTime', sortDescFirst: true, - sortType: stringSort, + sortingFn: stringSort, } : undefined, columnVisibility.decisions ? { - Header: 'Decisions', - accessor: 'decisions', - canSort: false, + header: 'Decisions', + accessorKey: 'decisions', + enableSorting: false, } : undefined, columnVisibility.decisionReason ? { - Header: 'Decision Reason', - accessor: 'decisionReason', - canSort: false, + header: 'Decision Reason', + accessorKey: 'decisionReason', + enableSorting: false, } : undefined, columnVisibility.policies ? { - Header: 'Policies', - accessor: 'policies', - canSort: false, + header: 'Policies', + accessorKey: 'policies', + enableSorting: false, } : undefined, columnVisibility.reviewer ? { - Header: 'Reviewer', - accessor: 'reviewer', - canSort: false, + header: 'Reviewer', + accessorKey: 'reviewer', + enableSorting: false, } : undefined, columnVisibility.queue ? { - Header: 'Queue', - accessor: 'queue', - canSort: true, + header: 'Queue', + accessorKey: 'queue', + enableSorting: true, } : undefined, ]), @@ -1013,13 +1013,13 @@ export default function ManualReviewRecentDecisions() {
@@ -104,48 +117,25 @@ export default function Table(
- {column.render('Header')} - {/* @ts-ignore */} - {canSort ? ( - /* @ts-ignore */ - column.isSortedDesc ? ( - /* @ts-ignore */ - ) : column.isSorted ? ( + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {header.column.getCanSort() ? ( + sorted === 'desc' ? ( + + ) : sorted === 'asc' ? ( ) : ( @@ -159,92 +149,64 @@ export default function Table(
selectRow(row, rowIndex)} > - {cellWithWrapper} -
+ {rowLinkTo ? ( + + {content} + + ) : ( +
+ {content} +
+ )} +
- {cellWithWrapper} + {rowLinkTo ? ( + + {content} + + ) : ( +
+ {content} +
+ )}
setSelectedDecision( - rowData.original.values.originalDecisionData, + rowData.original.values + .originalDecisionData as GQLManualReviewDecision, ) } topLeftComponent={selectedDecision ? null : tableControls} @@ -1028,7 +1028,10 @@ export default function ManualReviewRecentDecisions() { collapsedColumnTitle="Decisions" renderCollapsedCell={(row) => { const values = row.original.values as { - decisionColorNamePairs: { name: string; colors: string }[]; + decisionColorNamePairs: { + name: string; + colorVariant: BadgeColorVariant; + }[]; reviewerId: string; createdAt: string | Date; }; @@ -1037,13 +1040,13 @@ export default function ManualReviewRecentDecisions() {
{values.decisionColorNamePairs.map( - ({ name, colors }, index) => ( -
( + - {name} -
+ colorVariant={colorVariant} + label={name} + shapeVariant="pill" + /> ), )}
diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/MergedReportsComponent.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/MergedReportsComponent.tsx index 926b511a9..c8d61e9d2 100644 --- a/client/src/webpages/dashboard/mrt/manual_review_job/MergedReportsComponent.tsx +++ b/client/src/webpages/dashboard/mrt/manual_review_job/MergedReportsComponent.tsx @@ -103,10 +103,10 @@ export default function MergedReportsComponent(props: { const columns = useMemo( () => [ - { Header: 'Reported By', accessor: 'reportedBy' }, - { Header: 'Reported For', accessor: 'reportedFor' }, - { Header: 'Reason', accessor: 'reason' }, - { Header: 'Report Time', accessor: 'reportTime' }, + { header: 'Reported By', accessorKey: 'reportedBy' }, + { header: 'Reported For', accessorKey: 'reportedFor' }, + { header: 'Reason', accessorKey: 'reason' }, + { header: 'Report Time', accessorKey: 'reportTime' }, ], [], ); diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/v2/user/ManualReviewJobCurrentJobsComponent.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/v2/user/ManualReviewJobCurrentJobsComponent.tsx index 9e8b93cf4..c0932926b 100644 --- a/client/src/webpages/dashboard/mrt/manual_review_job/v2/user/ManualReviewJobCurrentJobsComponent.tsx +++ b/client/src/webpages/dashboard/mrt/manual_review_job/v2/user/ManualReviewJobCurrentJobsComponent.tsx @@ -47,15 +47,15 @@ export default function ManualReviewCurrentJobsComponent(props: { const columns = useMemo( () => [ { - Header: 'Queue', - accessor: 'queue', - canSort: true, + header: 'Queue', + accessorKey: 'queue', + enableSorting: true, }, { - Header: 'Created At', - accessor: 'createdAt', + header: 'Created At', + accessorKey: 'createdAt', sortDescFirst: true, - sortType: stringSort, + sortingFn: stringSort, }, ], [], diff --git a/client/src/webpages/dashboard/ncmec/NcmecReportsDashboard.tsx b/client/src/webpages/dashboard/ncmec/NcmecReportsDashboard.tsx index 1283f0a5d..570fc7f8c 100644 --- a/client/src/webpages/dashboard/ncmec/NcmecReportsDashboard.tsx +++ b/client/src/webpages/dashboard/ncmec/NcmecReportsDashboard.tsx @@ -326,106 +326,118 @@ export default function NcmecReportsDashboard() { filterNullOrUndefined([ columnVisibility.date ? { - Header: 'Date', - accessor: 'date', - sortType: stringSort, + header: 'Date', + accessorKey: 'date', + sortingFn: stringSort, sortDescFirst: true, - Filter: (props: ColumnProps) => - DateRangeColumnFilter({ - columnProps: props, - accessor: 'date', - placeholder: '', - }), - filter: 'dateRange', + meta: { + filter: (props: ColumnProps) => + DateRangeColumnFilter({ + columnProps: props, + accessor: 'date', + placeholder: '', + }), + }, + filterFn: 'dateRange' as const, } : undefined, columnVisibility.reviewer ? { - Header: 'Reviewer', - accessor: 'reviewer', - filter: 'includes', - sortType: stringSort, - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'reviewer', - }), + header: 'Reviewer', + accessorKey: 'reviewer', + filterFn: 'includes' as const, + sortingFn: stringSort, + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'reviewer', + }), + }, } : undefined, columnVisibility.status ? { // Cell renders the colored Tag from row.status; the filter // reads the plain string from row.original.values.status. - Header: 'Status', - accessor: 'status', - filter: 'includes', - sortType: stringSort, - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'status', - }), + header: 'Status', + accessorKey: 'status', + filterFn: 'includes' as const, + sortingFn: stringSort, + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'status', + }), + }, } : undefined, columnVisibility.reportId ? { - Header: 'Report ID', - accessor: 'reportId', - filter: 'text', - canSort: false, - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'reportId', - placeholder: 'Report ID', - }), + header: 'Report ID', + accessorKey: 'reportId', + filterFn: 'text' as const, + enableSorting: false, + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'reportId', + placeholder: 'Report ID', + }), + }, } : undefined, columnVisibility.userId ? { - Header: 'User ID', - accessor: 'userId', - filter: 'text', - canSort: false, - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'userId', - placeholder: 'User ID', - }), + header: 'User ID', + accessorKey: 'userId', + filterFn: 'text' as const, + enableSorting: false, + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'userId', + placeholder: 'User ID', + }), + }, } : undefined, columnVisibility.userItemType ? { - Header: 'User Item Type', - accessor: 'userItemType', - filter: 'text', - canSort: false, - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'userItemType', - placeholder: 'User Type', - }), + header: 'User Item Type', + accessorKey: 'userItemType', + filterFn: 'text' as const, + enableSorting: false, + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'userItemType', + placeholder: 'User Type', + }), + }, } : undefined, columnVisibility.reportedMedia - ? { Header: 'Reported Media', accessor: 'reportedMedia' } + ? { header: 'Reported Media', accessorKey: 'reportedMedia' } : undefined, columnVisibility.additionalFiles - ? { Header: 'Additional Files', accessor: 'additionalFiles' } + ? { header: 'Additional Files', accessorKey: 'additionalFiles' } : undefined, columnVisibility.reportedMessages - ? { Header: 'Reported Messages', accessor: 'reportedMessages' } + ? { header: 'Reported Messages', accessorKey: 'reportedMessages' } : undefined, columnVisibility.isTest - ? { Header: 'Test Report', accessor: 'isTest' } + ? { header: 'Test Report', accessorKey: 'isTest' } : undefined, columnVisibility.lastError - ? { Header: 'Last Error', accessor: 'lastError' } + ? { header: 'Last Error', accessorKey: 'lastError' } : undefined, columnVisibility.action - ? { Header: 'Action', accessor: 'action' } + ? { header: 'Action', accessorKey: 'action' } : undefined, ]), [columnVisibility], diff --git a/client/src/webpages/dashboard/rules/dashboard/ReportingRulesDashboard.tsx b/client/src/webpages/dashboard/rules/dashboard/ReportingRulesDashboard.tsx index 86b7296f4..3fe173e2d 100644 --- a/client/src/webpages/dashboard/rules/dashboard/ReportingRulesDashboard.tsx +++ b/client/src/webpages/dashboard/rules/dashboard/ReportingRulesDashboard.tsx @@ -5,7 +5,6 @@ import lowerCase from 'lodash/lowerCase'; import { MouseEvent, useCallback, useMemo, useState } from 'react'; import { Helmet } from 'react-helmet-async'; import { useNavigate } from 'react-router-dom'; -import { Column, Row } from 'react-table'; import FullScreenLoading from '../../../../components/common/FullScreenLoading'; import CoopButton from '../../components/CoopButton'; @@ -25,7 +24,7 @@ import { reportingRuleStatusSort, stringSort, } from '../../components/table/sort'; -import Table from '../../components/table/Table'; +import Table, { TableColumnDef, TableRow } from '../../components/table/Table'; import TruncatedListTableCell from '../../components/table/TruncatedListTableCell'; import TruncatedTextTableCell from '../../components/table/TruncatedTextTableCell'; import UserWithAvatar from '../../components/UserWithAvatar'; @@ -144,7 +143,7 @@ export default function ReportingRulesDashboard() { const navigate = useNavigate(); - const rowLinkTo = (row: Row) => { + const rowLinkTo = (row: TableRow) => { return `info/${row.original.values.id}`; }; @@ -212,66 +211,76 @@ export default function ReportingRulesDashboard() { () => [ { - Header: 'Rule', - accessor: 'name', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'name', - }), - filter: 'text', - sortType: stringSort, + header: 'Rule', + accessorKey: 'name', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'name', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, }, { - Header: 'Owner', - accessor: 'owner', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'owner', - }), - filter: 'includes', - canSort: false, + header: 'Owner', + accessorKey: 'owner', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'owner', + }), + }, + filterFn: 'includes' as const, + enableSorting: false, }, { - Header: 'Status', - accessor: 'status', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'status', - }), - filter: 'includes', - sortType: reportingRuleStatusSort, + header: 'Status', + accessorKey: 'status', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'status', + }), + }, + filterFn: 'includes' as const, + sortingFn: reportingRuleStatusSort, }, { - Header: 'Policies', - accessor: 'policies', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'policies', - }), - filter: 'includes', - canSort: false, + header: 'Policies', + accessorKey: 'policies', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'policies', + }), + }, + filterFn: 'includes' as const, + enableSorting: false, }, { - Header: 'Item Types', - accessor: 'itemTypes', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'itemTypes', - }), - filter: 'includes', - canSort: false, + header: 'Item Types', + accessorKey: 'itemTypes', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'itemTypes', + }), + }, + filterFn: 'includes' as const, + enableSorting: false, }, { - Header: '', - accessor: 'mutations', - canSort: false, + header: '', + accessorKey: 'mutations', + enableSorting: false, }, - ] as (Column & { canSort?: boolean })[], + ] as (TableColumnDef & { canSort?: boolean })[], [], ); diff --git a/client/src/webpages/dashboard/rules/dashboard/RulesDashboard.tsx b/client/src/webpages/dashboard/rules/dashboard/RulesDashboard.tsx index 5e42988c2..f92afa7f4 100644 --- a/client/src/webpages/dashboard/rules/dashboard/RulesDashboard.tsx +++ b/client/src/webpages/dashboard/rules/dashboard/RulesDashboard.tsx @@ -14,7 +14,6 @@ import lowerCase from 'lodash/lowerCase'; import { MouseEvent, useCallback, useMemo, useState } from 'react'; import { Helmet } from 'react-helmet-async'; import { useNavigate } from 'react-router-dom'; -import { Column, Row } from 'react-table'; import FullScreenLoading from '../../../../components/common/FullScreenLoading'; import CoopButton from '../../components/CoopButton'; @@ -33,7 +32,7 @@ import { SelectColumnFilter, } from '../../components/table/filters'; import { ruleStatusSort, stringSort } from '../../components/table/sort'; -import Table from '../../components/table/Table'; +import Table, { TableColumnDef, TableRow } from '../../components/table/Table'; import TruncatedListTableCell from '../../components/table/TruncatedListTableCell'; import TruncatedTextTableCell from '../../components/table/TruncatedTextTableCell'; import UserWithAvatar from '../../components/UserWithAvatar'; @@ -191,7 +190,7 @@ export default function RulesDashboard() { const navigate = useNavigate(); const [timeDivision, setTimeDivision] = useState('DAY'); - const rowLinkTo = (row: Row) => { + const rowLinkTo = (row: TableRow) => { return `info/${row.original.values.id}`; }; @@ -284,83 +283,95 @@ export default function RulesDashboard() { () => [ { - Header: '', - accessor: 'favoriteRules', - canSort: false, + header: '', + accessorKey: 'favoriteRules', + enableSorting: false, }, { - Header: 'Created', - accessor: 'dateCreated', - Filter: (props: ColumnProps) => - DateRangeColumnFilter({ - columnProps: props, - accessor: 'dateCreated', - }), - filter: 'dateRange', + header: 'Created', + accessorKey: 'dateCreated', + meta: { + filter: (props: ColumnProps) => + DateRangeColumnFilter({ + columnProps: props, + accessor: 'dateCreated', + }), + }, + filterFn: 'dateRange' as const, sortDescFirst: true, - sortType: stringSort, + sortingFn: stringSort, }, { - Header: 'Rule', - accessor: 'name', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'name', - }), - filter: 'text', - sortType: stringSort, + header: 'Rule', + accessorKey: 'name', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'name', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, }, { - Header: 'Owner', - accessor: 'owner', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'owner', - }), - filter: 'includes', - canSort: false, + header: 'Owner', + accessorKey: 'owner', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'owner', + }), + }, + filterFn: 'includes' as const, + enableSorting: false, }, { - Header: 'Status', - accessor: 'status', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'status', - }), - filter: 'includes', - sortType: ruleStatusSort, + header: 'Status', + accessorKey: 'status', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'status', + }), + }, + filterFn: 'includes' as const, + sortingFn: ruleStatusSort, }, { - Header: 'Policies', - accessor: 'policies', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'policies', - }), - filter: 'includes', - canSort: false, + header: 'Policies', + accessorKey: 'policies', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'policies', + }), + }, + filterFn: 'includes' as const, + enableSorting: false, }, { - Header: 'Item Types', - accessor: 'itemTypes', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'itemTypes', - }), - filter: 'includes', - canSort: false, + header: 'Item Types', + accessorKey: 'itemTypes', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'itemTypes', + }), + }, + filterFn: 'includes' as const, + enableSorting: false, }, { - Header: '', - accessor: 'mutations', - canSort: false, + header: '', + accessorKey: 'mutations', + enableSorting: false, }, - ] as (Column & { canSort?: boolean })[], + ] as (TableColumnDef & { canSort?: boolean })[], [], ); diff --git a/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx b/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx index 0707ace07..e2346d1e9 100644 --- a/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx +++ b/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx @@ -11,7 +11,6 @@ import uniq from 'lodash/uniq'; import { useMemo, useState } from 'react'; import { CSVLink } from 'react-csv'; import { Link } from 'react-router-dom'; -import { Row } from 'react-table'; import ComponentLoading from '../../../../../components/common/ComponentLoading'; import CopyTextComponent from '../../../../../components/common/CopyTextComponent'; @@ -23,7 +22,7 @@ import { SelectColumnFilter, } from '../../../components/table/filters'; import { ruleStatusSort, stringSort } from '../../../components/table/sort'; -import Table from '../../../components/table/Table'; +import Table, { TableRow } from '../../../components/table/Table'; import { GQLFieldType, @@ -235,17 +234,19 @@ export default function ReportingRuleInsightsSamplesTable(props: { ); return distinctSignalNames.map((signalName) => ({ - Header: signalName, - accessor: signalName, - Filter: (props: ColumnProps) => - NumberRangeColumnFilter({ - columnProps: props, - accessor: signalName, - placeholder: '', - }), - filter: 'between', + header: signalName, + accessorKey: signalName, + meta: { + filter: (props: ColumnProps) => + NumberRangeColumnFilter({ + columnProps: props, + accessor: signalName, + placeholder: '', + }), + }, + filterFn: 'between' as const, sortDescFirst: true, - sortType: stringSort, + sortingFn: stringSort, })); }, [allSignals, samples]); @@ -289,55 +290,61 @@ export default function ReportingRuleInsightsSamplesTable(props: { const columns = useMemo(() => { return [ { - Header: 'Timestamp', - accessor: 'time', - Filter: (props: ColumnProps) => - DateRangeColumnFilter({ - columnProps: props, - accessor: 'date', - placeholder: '', - }), - filter: 'dateRange', + header: 'Timestamp', + accessorKey: 'time', + meta: { + filter: (props: ColumnProps) => + DateRangeColumnFilter({ + columnProps: props, + accessor: 'date', + placeholder: '', + }), + }, + filterFn: 'dateRange' as const, sortDescFirst: true, - sortType: stringSort, + sortingFn: stringSort, }, { - Header: 'Status', - accessor: 'status', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'status', - placeholder: 'Live', - }), - filter: 'includes', - sortType: ruleStatusSort, + header: 'Status', + accessorKey: 'status', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'status', + placeholder: 'Live', + }), + }, + filterFn: 'includes' as const, + sortingFn: ruleStatusSort, }, { - Header: 'Item', - accessor: 'item', - canSort: false, + header: 'Item', + accessorKey: 'item', + enableSorting: false, }, { - Header: 'Item Type', - accessor: 'itemTypeName', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'itemTypeName', - }), - filter: 'includes', - sortType: stringSort, + header: 'Item Type', + accessorKey: 'itemTypeName', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'itemTypeName', + }), + }, + filterFn: 'includes' as const, + sortingFn: stringSort, }, { - Header: 'ID', - accessor: 'id', // accessor is the "key" in the data - canSort: false, + header: 'ID', + accessorKey: 'id', // accessor is the "key" in the data + enableSorting: false, }, { - Header: 'Creator ID', - accessor: 'creatorId', - canSort: false, + header: 'Creator ID', + accessorKey: 'creatorId', + enableSorting: false, }, ...extraColumns, ]; @@ -439,8 +446,8 @@ export default function ReportingRuleInsightsSamplesTable(props: { values, ...Object.fromEntries( extraColumns.map((it) => [ - it.accessor, - (values as { [key: string]: any })[it.accessor], + it.accessorKey, + (values as { [key: string]: any })[it.accessorKey], ]), ), }; @@ -452,7 +459,7 @@ export default function ReportingRuleInsightsSamplesTable(props: { throw error ?? priorRuleVersionError ?? signalsError!; } - const onSelectRow = (row: Row) => { + const onSelectRow = (row: TableRow) => { dataValues.length > 0 && setDetailViewData({ visible: true, diff --git a/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx b/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx index a7e4ad59b..5e4c1865e 100644 --- a/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx +++ b/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx @@ -12,7 +12,6 @@ import uniq from 'lodash/uniq'; import { useMemo, useState } from 'react'; import { CSVLink } from 'react-csv'; import { Link } from 'react-router-dom'; -import { Row } from 'react-table'; import ComponentLoading from '../../../../../components/common/ComponentLoading'; import CopyTextComponent from '../../../../../components/common/CopyTextComponent'; @@ -24,7 +23,7 @@ import { SelectColumnFilter, } from '../../../components/table/filters'; import { ruleStatusSort, stringSort } from '../../../components/table/sort'; -import Table from '../../../components/table/Table'; +import Table, { TableRow } from '../../../components/table/Table'; import { GQLField, @@ -400,17 +399,19 @@ export default function RuleInsightsSamplesTable(props: { ruleId: string }) { ); return distinctSignalNames.map((signalName) => ({ - Header: signalName, - accessor: signalName, - Filter: (props: ColumnProps) => - NumberRangeColumnFilter({ - columnProps: props, - accessor: signalName, - placeholder: '', - }), - filter: 'between', + header: signalName, + accessorKey: signalName, + meta: { + filter: (props: ColumnProps) => + NumberRangeColumnFilter({ + columnProps: props, + accessor: signalName, + placeholder: '', + }), + }, + filterFn: 'between' as const, sortDescFirst: true, - sortType: stringSort, + sortingFn: stringSort, })); }, [allSignals, samples]); @@ -455,55 +456,61 @@ export default function RuleInsightsSamplesTable(props: { ruleId: string }) { const columns = useMemo(() => { return [ { - Header: 'Timestamp', - accessor: 'time', - Filter: (props: ColumnProps) => - DateRangeColumnFilter({ - columnProps: props, - accessor: 'date', - placeholder: '', - }), - filter: 'dateRange', + header: 'Timestamp', + accessorKey: 'time', + meta: { + filter: (props: ColumnProps) => + DateRangeColumnFilter({ + columnProps: props, + accessor: 'date', + placeholder: '', + }), + }, + filterFn: 'dateRange' as const, sortDescFirst: true, - sortType: stringSort, + sortingFn: stringSort, }, { - Header: 'Status', - accessor: 'status', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'status', - placeholder: 'Live', - }), - filter: 'includes', - sortType: ruleStatusSort, + header: 'Status', + accessorKey: 'status', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'status', + placeholder: 'Live', + }), + }, + filterFn: 'includes' as const, + sortingFn: ruleStatusSort, }, { - Header: 'Content', - accessor: 'content', - canSort: false, + header: 'Content', + accessorKey: 'content', + enableSorting: false, }, { - Header: 'Item Type', - accessor: 'itemTypeName', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'itemTypeName', - }), - filter: 'includes', - sortType: stringSort, + header: 'Item Type', + accessorKey: 'itemTypeName', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'itemTypeName', + }), + }, + filterFn: 'includes' as const, + sortingFn: stringSort, }, { - Header: 'ID', - accessor: 'id', // accessor is the "key" in the data - canSort: false, + header: 'ID', + accessorKey: 'id', // accessor is the "key" in the data + enableSorting: false, }, { - Header: 'User ID', - accessor: 'userId', - canSort: false, + header: 'User ID', + accessorKey: 'userId', + enableSorting: false, }, ...extraColumns, ]; @@ -608,8 +615,8 @@ export default function RuleInsightsSamplesTable(props: { ruleId: string }) { values, ...Object.fromEntries( extraColumns.map((it) => [ - it.accessor, - (values as any)[it.accessor], + it.accessorKey, + (values as any)[it.accessorKey], ]), ), }; @@ -621,7 +628,7 @@ export default function RuleInsightsSamplesTable(props: { ruleId: string }) { throw error ?? priorRuleVersionError ?? signalsError!; } - const onSelectRow = (row: Row) => { + const onSelectRow = (row: TableRow) => { setDetailViewData({ visible: true, item: (() => { diff --git a/client/src/webpages/dashboard/userStrikes/PolicyScoresTab.tsx b/client/src/webpages/dashboard/userStrikes/PolicyScoresTab.tsx index 2bf54441b..ec287b1d1 100644 --- a/client/src/webpages/dashboard/userStrikes/PolicyScoresTab.tsx +++ b/client/src/webpages/dashboard/userStrikes/PolicyScoresTab.tsx @@ -430,19 +430,19 @@ function ChildPoliciesTable(props: { const columns = useMemo( () => [ { - Header: 'Sub-Policy', - accessor: 'name', - canSort: false, + header: 'Sub-Policy', + accessorKey: 'name', + enableSorting: false, }, { - Header: 'User Strike Score', - accessor: 'userStrikeCount', // accessor is the "key" in the data - canSort: false, + header: 'User Strike Score', + accessorKey: 'userStrikeCount', // accessor is the "key" in the data + enableSorting: false, }, { - Header: 'Apply to sub-policies', - accessor: 'applyUserStrikeCountConfigToChildren', // accessor is the "key" in the data - canSort: false, + header: 'Apply to sub-policies', + accessorKey: 'applyUserStrikeCountConfigToChildren', // accessor is the "key" in the data + enableSorting: false, }, ], [], diff --git a/client/src/webpages/dashboard/userStrikes/StrikeAnalyticsTab.tsx b/client/src/webpages/dashboard/userStrikes/StrikeAnalyticsTab.tsx index feb3c6f78..3ed7e49df 100644 --- a/client/src/webpages/dashboard/userStrikes/StrikeAnalyticsTab.tsx +++ b/client/src/webpages/dashboard/userStrikes/StrikeAnalyticsTab.tsx @@ -191,19 +191,19 @@ function RecentUserStrikeActionsTable() { const columns = useMemo( () => [ { - Header: 'User', - accessor: 'user', - canSort: false, + header: 'User', + accessorKey: 'user', + enableSorting: false, }, { - Header: 'Action Taken', - accessor: 'action', - canSort: false, + header: 'Action Taken', + accessorKey: 'action', + enableSorting: false, }, { - Header: 'Date', - accessor: 'date', - canSort: false, + header: 'Date', + accessorKey: 'date', + enableSorting: false, }, ], [], diff --git a/client/src/webpages/dashboard/userStrikes/StrikeEnabledActionsTab.tsx b/client/src/webpages/dashboard/userStrikes/StrikeEnabledActionsTab.tsx index d01be4540..84e25c6e7 100644 --- a/client/src/webpages/dashboard/userStrikes/StrikeEnabledActionsTab.tsx +++ b/client/src/webpages/dashboard/userStrikes/StrikeEnabledActionsTab.tsx @@ -41,36 +41,40 @@ export default function StrikeEnabledActionsTab() { const columns = useMemo( () => [ { - Header: 'Name', - accessor: 'name', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'name', - }), - filter: 'text', - canSort: false, + header: 'Name', + accessorKey: 'name', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'name', + }), + }, + filterFn: 'text' as const, + enableSorting: false, }, { - Header: 'Description', - accessor: 'description', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'description', - }), - filter: 'text', - canSort: false, + header: 'Description', + accessorKey: 'description', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'description', + }), + }, + filterFn: 'text' as const, + enableSorting: false, }, { - Header: 'Enable Strikes', - accessor: 'enableStrikes', // accessor is the "key" in the data - canSort: false, + header: 'Enable Strikes', + accessorKey: 'enableStrikes', // accessor is the "key" in the data + enableSorting: false, }, { - Header: '', - accessor: 'CustomAction', // accessor is the "key" in the data - canSort: false, + header: '', + accessorKey: 'CustomAction', // accessor is the "key" in the data + enableSorting: false, }, ], [], diff --git a/client/src/webpages/settings/ManageUsers.tsx b/client/src/webpages/settings/ManageUsers.tsx index 5ded20f41..3c5b37d52 100644 --- a/client/src/webpages/settings/ManageUsers.tsx +++ b/client/src/webpages/settings/ManageUsers.tsx @@ -314,70 +314,80 @@ export default function ManageUsers() { const columns = useMemo( () => [ { - Header: 'Name', - accessor: 'name', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'name', - placeholder: 'Jane Smith', - }), - filter: 'text', - sortType: stringSort, + header: 'Name', + accessorKey: 'name', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'name', + placeholder: 'Jane Smith', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, }, { - Header: 'Email', - accessor: 'email', - Filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'email', - placeholder: 'jane@mywebsite.com', - }), - filter: 'text', - sortType: stringSort, + header: 'Email', + accessorKey: 'email', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'email', + placeholder: 'jane@mywebsite.com', + }), + }, + filterFn: 'text' as const, + sortingFn: stringSort, }, { - Header: 'Role', - accessor: 'role', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'role', - placeholder: 'Filter by role', - }), - filter: 'includes', - sortType: userRoleSort, + header: 'Role', + accessorKey: 'role', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'role', + placeholder: 'Filter by role', + }), + }, + filterFn: 'includes' as const, + sortingFn: userRoleSort, }, { - Header: 'Approval Status', - accessor: 'approvalStatus', - Filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'approvalStatus', - placeholder: 'Filter by status', - }), - filter: 'includes', - sortType: boolSort, + header: 'Approval Status', + accessorKey: 'approvalStatus', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'approvalStatus', + placeholder: 'Filter by status', + }), + }, + filterFn: 'includes' as const, + sortingFn: boolSort, }, { - Header: 'Date Created', - accessor: 'dateCreated', - Filter: (props: ColumnProps) => - DateRangeColumnFilter({ - columnProps: props, - accessor: 'dateCreated', - placeholder: '', - }), - filter: 'dateRange', + header: 'Date Created', + accessorKey: 'dateCreated', + meta: { + filter: (props: ColumnProps) => + DateRangeColumnFilter({ + columnProps: props, + accessor: 'dateCreated', + placeholder: '', + }), + }, + filterFn: 'dateRange' as const, sortDescFirst: true, - sortType: stringSort, + sortingFn: stringSort, }, { - Header: '', - accessor: 'mutations', // accessor is the "key" in the data - canSort: false, + header: '', + accessorKey: 'mutations', // accessor is the "key" in the data + enableSorting: false, }, ], [], From 7c54634e7be02782fce6b406e4927969b34d501d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Tue, 11 Aug 2026 12:40:21 +0100 Subject: [PATCH 05/15] Preserve table behavior in v8 migration Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed Co-authored-by: Amp --- .../dashboard/components/table/Table.test.tsx | 59 +++++++++++++++---- .../components/table/TableFilter.tsx | 31 +++++++++- .../ItemInvestigationRuleResults.tsx | 15 +---- 3 files changed, 76 insertions(+), 29 deletions(-) diff --git a/client/src/webpages/dashboard/components/table/Table.test.tsx b/client/src/webpages/dashboard/components/table/Table.test.tsx index 9fe041ce8..e081c7f74 100644 --- a/client/src/webpages/dashboard/components/table/Table.test.tsx +++ b/client/src/webpages/dashboard/components/table/Table.test.tsx @@ -205,6 +205,28 @@ describe('Table v7 behavior', () => { }); it('facets raw options by other filters but not the probed column filter', () => { + const facetData: TableRow[] = [ + { + name: Label One, + status: 'Open', + values: { id: 'group-a-1', name: 'Alpha', status: 'Open' }, + }, + { + name: Label Two, + status: 'Open', + values: { id: 'group-a-2', name: 'Bravo', status: 'Open' }, + }, + { + name: Label Three, + status: 'Open', + values: { id: 'group-b-1', name: 'Charlie', status: 'Open' }, + }, + { + name: Label Four, + status: 'Closed', + values: { id: 'group-a-3', name: 'Delta', status: 'Closed' }, + }, + ]; const FacetFilter = (props: ColumnProps) => ( <>
@@ -240,36 +262,47 @@ describe('Table v7 behavior', () => { filterFn: 'text', }, ]; - renderTable(filterColumns); + render( + +
+ , + ); fireEvent.click(screen.getByRole('button', { name: /filter/i })); - const menu = screen + let menu = screen .getByRole('button', { name: 'Save' }) .closest('.absolute')!; + fireEvent.click(within(menu).getByText('Name')); + expect(screen.getByTestId('facets').textContent).toBe( + 'Alpha,Bravo,Charlie,Delta', + ); fireEvent.click(within(menu).getByText('Status')); fireEvent.change(screen.getByPlaceholderText('Filter statuses'), { target: { value: 'Open' }, }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + fireEvent.click(screen.getByRole('button', { name: /filter/i })); + expect(screen.getByTestId('facets').textContent).toBe( + 'Alpha,Bravo,Charlie', + ); + menu = screen + .getByRole('button', { name: 'Save' }) + .closest('.absolute')!; fireEvent.click(within(menu).getByText('ID')); fireEvent.change(screen.getByPlaceholderText('Filter IDs'), { - target: { value: 'z' }, + target: { value: 'group-a' }, }); fireEvent.click(screen.getByRole('button', { name: 'Save' })); + fireEvent.click(screen.getByRole('button', { name: /filter/i })); - fireEvent.click( - within( - screen - .getByRole('button', { name: 'Save' }) - .closest('.absolute')!, - ).getByText('Name'), - ); - expect(screen.getByTestId('facets').textContent).toBe('Zulu'); + expect(screen.getByTestId('facets').textContent).toBe('Alpha,Bravo'); fireEvent.change(screen.getByPlaceholderText('Filter facets'), { - target: { value: 'Zulu' }, + target: { value: 'Alpha' }, }); fireEvent.click(screen.getByRole('button', { name: 'Save' })); fireEvent.click(screen.getByRole('button', { name: /filter/i })); - expect(screen.getByTestId('facets').textContent).toBe('Zulu'); + expect(screen.getByTestId('facets').textContent).toBe('Alpha,Bravo'); }); }); diff --git a/client/src/webpages/dashboard/components/table/TableFilter.tsx b/client/src/webpages/dashboard/components/table/TableFilter.tsx index a537feea2..b2bdebeb1 100644 --- a/client/src/webpages/dashboard/components/table/TableFilter.tsx +++ b/client/src/webpages/dashboard/components/table/TableFilter.tsx @@ -36,6 +36,18 @@ export default function TableFilter({ window.addEventListener('resize', position); return () => window.removeEventListener('resize', position); }, [menuVisible]); + const scrollToButton = () => { + if (buttonRef.current) { + const buttonPosition = buttonRef.current.getBoundingClientRect().top; + const halfwayPoint = window.innerHeight / 2; + if (buttonPosition > halfwayPoint) { + buttonRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + } + }; const onSave = () => { Object.entries(pending).forEach(([id, value]) => filterColumns.find((c) => c.id === id)?.setFilterValue(value), @@ -53,9 +65,22 @@ export default function TableFilter({
diff --git a/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx b/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx index e0342723c..051417b53 100644 --- a/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx +++ b/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx @@ -19,7 +19,7 @@ import { ruleStatusSort, stringSort, } from '../components/table/sort'; -import Table, { TableRow } from '../components/table/Table'; +import Table, { TableColumnDef, TableRow } from '../components/table/Table'; import { GQLConditionOutcome, @@ -81,7 +81,7 @@ export default function ItemInvestigationRuleResults(props: { [itemHistoryData?.itemWithHistory], ); - const columns = useMemo( + const columns = useMemo[]>( () => [ { header: 'Rule', @@ -237,17 +237,6 @@ export default function ItemInvestigationRuleResults(props: {
), - values: { - rule: ruleResult.ruleName, - result: getDisplayName(outcome), - status: ruleResult.environment, - policies: ruleResult.policies, - tags: ruleResult.tags, - actions: - rules - ?.find((it) => ruleResult.ruleId === it.id) - ?.actions?.map((action) => action.name) ?? [], - }, ruleExecutionResult: ruleResult.result, }; }), From 8b7addb96de6e9a57f5d899a9b45f7707f10e0f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Tue, 11 Aug 2026 12:49:12 +0100 Subject: [PATCH 06/15] Fix numeric table range filters Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed Co-authored-by: Amp --- .../dashboard/components/table/Table.test.tsx | 59 ++++++++++++++++++- .../ReportingRuleInsightsSamplesTable.tsx | 2 +- .../insights/RuleInsightsSamplesTable.tsx | 2 +- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/client/src/webpages/dashboard/components/table/Table.test.tsx b/client/src/webpages/dashboard/components/table/Table.test.tsx index e081c7f74..8e93db1ad 100644 --- a/client/src/webpages/dashboard/components/table/Table.test.tsx +++ b/client/src/webpages/dashboard/components/table/Table.test.tsx @@ -3,7 +3,11 @@ import { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; -import { ColumnProps, DefaultColumnFilter } from './filters'; +import { + ColumnProps, + DefaultColumnFilter, + NumberRangeColumnFilter, +} from './filters'; import { stringSort } from './sort'; import Table, { TableColumnDef } from './Table'; @@ -305,4 +309,57 @@ describe('Table v7 behavior', () => { fireEvent.click(screen.getByRole('button', { name: /filter/i })); expect(screen.getByTestId('facets').textContent).toBe('Alpha,Bravo'); }); + + it('stages an inclusive numeric range and filters raw values on Save', () => { + type NumericRow = { + score: ReactNode; + values: { score: number }; + }; + const numericData: NumericRow[] = [ + { score: Low score, values: { score: 4 } }, + { score: Boundary score, values: { score: 5 } }, + { score: High score, values: { score: 8 } }, + ]; + const numericColumns = [ + { + header: 'Score', + accessorKey: 'score', + cell: ({ getValue }) => getValue(), + meta: { + filter: (props: ColumnProps) => + NumberRangeColumnFilter({ + columnProps: props, + accessor: 'score', + }), + }, + filterFn: 'range', + }, + ] satisfies TableColumnDef[]; + render( + +
+ , + ); + + fireEvent.click(screen.getByRole('button', { name: /filter/i })); + const filterMenu = screen + .getByRole('button', { name: 'Save' }) + .closest('.absolute'); + expect(filterMenu).not.toBeNull(); + fireEvent.click(within(filterMenu!).getByText('Score')); + fireEvent.change(screen.getByPlaceholderText('min'), { + target: { value: '5' }, + }); + fireEvent.change(screen.getByPlaceholderText('max'), { + target: { value: '8' }, + }); + expect(screen.getByText('Low score')).toBeTruthy(); + expect(screen.getByText('Boundary score')).toBeTruthy(); + expect(screen.getByText('High score')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(screen.queryByText('Low score')).toBeNull(); + expect(screen.getByText('Boundary score')).toBeTruthy(); + expect(screen.getByText('High score')).toBeTruthy(); + }); }); diff --git a/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx b/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx index e2346d1e9..70c3e61b1 100644 --- a/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx +++ b/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx @@ -244,7 +244,7 @@ export default function ReportingRuleInsightsSamplesTable(props: { placeholder: '', }), }, - filterFn: 'between' as const, + filterFn: 'range' as const, sortDescFirst: true, sortingFn: stringSort, })); diff --git a/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx b/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx index 5e4c1865e..35abef6cb 100644 --- a/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx +++ b/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx @@ -409,7 +409,7 @@ export default function RuleInsightsSamplesTable(props: { ruleId: string }) { placeholder: '', }), }, - filterFn: 'between' as const, + filterFn: 'range' as const, sortDescFirst: true, sortingFn: stringSort, })); From e3c072b5ba878b2a7056cd5823ecb29de0792042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Tue, 11 Aug 2026 13:04:02 +0100 Subject: [PATCH 07/15] Upgrade TanStack Table to v9 Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed Co-authored-by: Amp --- client/package-lock.json | 64 +++++++++++++++---- client/package.json | 2 +- .../dashboard/actions/ActionsDashboard.tsx | 6 +- .../banks/hash/HashBanksDashboard.tsx | 4 +- .../banks/location/LocationBanksDashboard.tsx | 4 +- .../banks/text/TextBanksDashboard.tsx | 4 +- .../dashboard/components/table/Table.test.tsx | 2 +- .../dashboard/components/table/Table.tsx | 47 +++++--------- .../components/table/TableFilter.tsx | 6 +- .../dashboard/components/table/filters.tsx | 47 ++++++-------- .../dashboard/components/table/sort.tsx | 63 +++++++++--------- .../components/table/tableFeatures.ts | 58 +++++++++++++++++ .../ItemInvestigationRuleResults.tsx | 8 +-- .../item_types/ItemTypesDashboard.tsx | 4 +- .../dashboard/items/ItemActionHistory.tsx | 2 +- .../mrt/ManualReviewDecisionsTable.tsx | 6 +- .../mrt/ManualReviewQueueJobsPreview.tsx | 2 +- .../mrt/ManualReviewQueuesDashboard.tsx | 10 +-- .../mrt/ManualReviewRecentDecisions.tsx | 2 +- .../ManualReviewJobCurrentJobsComponent.tsx | 2 +- .../dashboard/ncmec/NcmecReportsDashboard.tsx | 6 +- .../dashboard/ReportingRulesDashboard.tsx | 4 +- .../rules/dashboard/RulesDashboard.tsx | 6 +- .../ReportingRuleInsightsSamplesTable.tsx | 8 +-- .../insights/RuleInsightsSamplesTable.tsx | 8 +-- client/src/webpages/settings/ManageUsers.tsx | 10 +-- 26 files changed, 231 insertions(+), 154 deletions(-) create mode 100644 client/src/webpages/dashboard/components/table/tableFeatures.ts diff --git a/client/package-lock.json b/client/package-lock.json index 42e9fdfc5..e1034f0ad 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -33,7 +33,7 @@ "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.13", - "@tanstack/react-table": "^8.21.3", + "@tanstack/react-table": "^9.1.2", "@tiptap/react": "^2.0.4", "@tiptap/starter-kit": "^2.0.4", "antd": "^4.10.2", @@ -4422,32 +4422,63 @@ } }, "node_modules/@tanstack/react-table": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", - "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-9.1.2.tgz", + "integrity": "sha512-YQPZFJ1nIi/bjjwsPZVouABgahDcl7Gdm33CdTStUJBn0DjEVJ2uhSTVmIoWt9MVKdQziXGAsXipSzy949Hygg==", "license": "MIT", "dependencies": { - "@tanstack/table-core": "8.21.3" + "@tanstack/react-store": "^0.11.0", + "@tanstack/table-core": "9.1.2" }, "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=18" + } + }, + "node_modules/@tanstack/react-table/node_modules/@tanstack/react-store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz", + "integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz", + "integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/table-core": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", - "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.1.2.tgz", + "integrity": "sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==", "license": "MIT", + "dependencies": { + "@tanstack/store": "^0.11.0" + }, "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { "type": "github", @@ -14374,6 +14405,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/client/package.json b/client/package.json index 2219d12cf..64f1f3529 100644 --- a/client/package.json +++ b/client/package.json @@ -42,7 +42,7 @@ "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.13", - "@tanstack/react-table": "^8.21.3", + "@tanstack/react-table": "^9.1.2", "@tiptap/react": "^2.0.4", "@tiptap/starter-kit": "^2.0.4", "antd": "^4.10.2", diff --git a/client/src/webpages/dashboard/actions/ActionsDashboard.tsx b/client/src/webpages/dashboard/actions/ActionsDashboard.tsx index 1f8482502..8dbffc2a5 100644 --- a/client/src/webpages/dashboard/actions/ActionsDashboard.tsx +++ b/client/src/webpages/dashboard/actions/ActionsDashboard.tsx @@ -163,7 +163,7 @@ export default function ActionsDashboard() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Description', @@ -176,7 +176,7 @@ export default function ActionsDashboard() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Penalty', @@ -189,7 +189,7 @@ export default function ActionsDashboard() { }), }, filterFn: 'includes' as const, - sortingFn: userPenaltySeveritySort, + sortFn: userPenaltySeveritySort, }, { header: 'ID', diff --git a/client/src/webpages/dashboard/banks/hash/HashBanksDashboard.tsx b/client/src/webpages/dashboard/banks/hash/HashBanksDashboard.tsx index ad0318588..ecaf33fea 100644 --- a/client/src/webpages/dashboard/banks/hash/HashBanksDashboard.tsx +++ b/client/src/webpages/dashboard/banks/hash/HashBanksDashboard.tsx @@ -102,7 +102,7 @@ export default function HashBanksDashboard() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Description', @@ -115,7 +115,7 @@ export default function HashBanksDashboard() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Status', diff --git a/client/src/webpages/dashboard/banks/location/LocationBanksDashboard.tsx b/client/src/webpages/dashboard/banks/location/LocationBanksDashboard.tsx index 2696dfd5b..ece0c92bc 100644 --- a/client/src/webpages/dashboard/banks/location/LocationBanksDashboard.tsx +++ b/client/src/webpages/dashboard/banks/location/LocationBanksDashboard.tsx @@ -117,7 +117,7 @@ export default function LocationBanksDashboard() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Description', @@ -130,7 +130,7 @@ export default function LocationBanksDashboard() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: '', diff --git a/client/src/webpages/dashboard/banks/text/TextBanksDashboard.tsx b/client/src/webpages/dashboard/banks/text/TextBanksDashboard.tsx index 8679b97c6..2764ed7cf 100644 --- a/client/src/webpages/dashboard/banks/text/TextBanksDashboard.tsx +++ b/client/src/webpages/dashboard/banks/text/TextBanksDashboard.tsx @@ -122,7 +122,7 @@ export default function TextBanksDashboard() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Description', @@ -135,7 +135,7 @@ export default function TextBanksDashboard() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Type', diff --git a/client/src/webpages/dashboard/components/table/Table.test.tsx b/client/src/webpages/dashboard/components/table/Table.test.tsx index 8e93db1ad..dcfcc2dc9 100644 --- a/client/src/webpages/dashboard/components/table/Table.test.tsx +++ b/client/src/webpages/dashboard/components/table/Table.test.tsx @@ -22,7 +22,7 @@ const columns = [ header: 'Name', accessorKey: 'name', cell: ({ getValue }) => getValue(), - sortingFn: stringSort, + sortFn: stringSort, sortDescFirst: false, }, { header: 'Status', accessorKey: 'status', enableSorting: false }, diff --git a/client/src/webpages/dashboard/components/table/Table.tsx b/client/src/webpages/dashboard/components/table/Table.tsx index 931ef3d37..1c15efa00 100644 --- a/client/src/webpages/dashboard/components/table/Table.tsx +++ b/client/src/webpages/dashboard/components/table/Table.tsx @@ -1,34 +1,24 @@ import SortAmountAsc from '@/icons/lni/Text editor/sort-amount-asc.svg?react'; import SortAmountDsc from '@/icons/lni/Text editor/sort-amount-dsc.svg?react'; -import { - ColumnDef, - flexRender, - getCoreRowModel, - getFacetedRowModel, - getFilteredRowModel, - getSortedRowModel, - Row, - RowData, - useReactTable, -} from '@tanstack/react-table'; -import { ReactNode, useMemo, useState } from 'react'; +import { flexRender, useTable } from '@tanstack/react-table'; +import { ReactNode, useState } from 'react'; import { Link } from 'react-router-dom'; -import { getFilterTypes } from './filters'; +import { features, TableColumnDef, TableData, TableRow } from './tableFeatures'; import TableFilter from './TableFilter'; -export type TableRowData = RowData; -export type TableRow = Row; -export type TableColumnDef = ColumnDef< - TData, - TValue ->; +export type TableRowData = TableData; +export type { TableColumnDef, TableRow } from './tableFeatures'; + +type RowCallback = { + bivarianceHack(row: TableRow): TResult; +}['bivarianceHack']; type TableProps> = { - columns: TableColumnDef[]; + columns: TableColumnDef, any>[]; data: readonly TData[]; - onSelectRow?: (rowData: TableRow) => void; - rowLinkTo?: (rowData: TableRow) => string; + onSelectRow?: RowCallback; + rowLinkTo?: RowCallback; topLeftComponent?: ReactNode; topRightComponent?: ReactNode; customMaxHeight?: `max-h-[${number}px]`; @@ -39,7 +29,7 @@ type TableProps> = { | { isCollapsed?: boolean; collapsedColumnTitle?: string; - renderCollapsedCell?: (row: TableRow) => ReactNode; + renderCollapsedCell?: RowCallback; } | Record ); @@ -61,18 +51,13 @@ export default function Table>( } = props; const { isCollapsed, collapsedColumnTitle, renderCollapsedCell } = 'isCollapsed' in props ? props : {}; - const filterFns = useMemo(getFilterTypes, []); - const table = useReactTable({ + const table = useTable({ + features, columns, data: [...data], defaultColumn: { cell: ({ getValue }) => getValue() as ReactNode, }, - filterFns, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getFacetedRowModel: getFacetedRowModel(), - getSortedRowModel: getSortedRowModel(), }); const rows = table.getRowModel().rows; const [selectedRow, setSelectedRow] = useState(null); @@ -181,7 +166,7 @@ export default function Table>( ); } - const cells = row.getVisibleCells(); + const cells = row.getAllCells(); return ( ({ +export default function TableFilter({ columns, }: { - columns: Column[]; + columns: TableColumn[]; }) { const filterColumns = columns.filter( (column) => column.getCanFilter() && column.columnDef.meta?.filter, diff --git a/client/src/webpages/dashboard/components/table/filters.tsx b/client/src/webpages/dashboard/components/table/filters.tsx index 6d92d2142..b876e20f7 100644 --- a/client/src/webpages/dashboard/components/table/filters.tsx +++ b/client/src/webpages/dashboard/components/table/filters.tsx @@ -1,65 +1,54 @@ -import { FilterFn, Row, RowData } from '@tanstack/react-table'; import { DatePicker, Input, Select } from 'antd'; import intersection from 'lodash/intersection'; import uniq from 'lodash/uniq'; -import { MouseEvent, ReactNode } from 'react'; +import { MouseEvent } from 'react'; -declare module '@tanstack/react-table' { - interface FilterFns { - text: FilterFn; - includes: FilterFn; - range: FilterFn; - dateRange: FilterFn; - } - interface ColumnMeta { - filter?: (props: ColumnProps) => ReactNode; - valueType?: TValue; - } -} +import type { + FacetedRow, + FilterRendererProps, + TableData, +} from './tableFeatures'; const { Option } = Select; const { RangePicker } = DatePicker; type RawRow = { values: Record }; -export type ColumnProps> = { - preFilteredRows: readonly Pick, 'original'>[]; - setUnsavedFilterValue: (value: any) => void; - unsavedFilterValue: any; - onSave: () => void; -}; +export type ColumnProps = + FilterRendererProps; export type FilterProps = { columnProps: ColumnProps; accessor: string; placeholder?: string; }; -const raw = (row: Row, id: string) => (row.original as RawRow).values[id]; +const raw = (row: FacetedRow, id: string) => + (row.original as RawRow).values[id]; export function getFilterTypes() { return { - text: ((row, id, value) => + text: (row: FacetedRow, id: string, value: any) => value == null || value.length === 0 || (raw(row, id) != null && String(raw(row, id)) .toLowerCase() - .includes(String(value).toLowerCase()))) as FilterFn, - includes: ((row, id, value) => + .includes(String(value).toLowerCase())), + includes: (row: FacetedRow, id: string, value: any) => value == null || (Array.isArray(value) && value.length === 0) || (raw(row, id) != null && (Array.isArray(raw(row, id)) ? intersection(value, raw(row, id)).length > 0 - : value.includes(raw(row, id))))) as FilterFn, - range: ((row, id, value) => + : value.includes(raw(row, id)))), + range: (row: FacetedRow, id: string, value: any) => value == null || ((!value[0] || value[0] <= raw(row, id)) && - (!value[1] || value[1] >= raw(row, id)))) as FilterFn, - dateRange: ((row, id, value) => { + (!value[1] || value[1] >= raw(row, id))), + dateRange: (row: FacetedRow, id: string, value: any) => { if (value == null) return true; const start = value[0]?.format('YYYY-MM-DD'); const end = value[1]?.format('YYYY-MM-DD'); return (!start || start <= raw(row, id)) && (!end || end >= raw(row, id)); - }) as FilterFn, + }, }; } function onClickFilter(event: MouseEvent) { diff --git a/client/src/webpages/dashboard/components/table/sort.tsx b/client/src/webpages/dashboard/components/table/sort.tsx index d5c6d3638..9719b5e53 100644 --- a/client/src/webpages/dashboard/components/table/sort.tsx +++ b/client/src/webpages/dashboard/components/table/sort.tsx @@ -1,4 +1,3 @@ -import { Row } from '@tanstack/react-table'; import capitalize from 'lodash/capitalize'; import { @@ -8,14 +7,16 @@ import { GQLUserPenaltySeverity, GQLUserRole, } from '../../../../graphql/generated'; +import { TableRow } from './tableFeatures'; interface RowWithValues extends Object { values: { [key: string]: any }; } +type Row = TableRow; -export function stringSort( - rowA: Row, - rowB: Row, +export function stringSort( + rowA: Row, + rowB: Row, columnId: string, ) { const s1 = rowA.original.values[columnId]; @@ -32,9 +33,9 @@ export function stringSort( return 0; } -export function integerSort( - rowA: Row, - rowB: Row, +export function integerSort( + rowA: Row, + rowB: Row, columnId: string, ) { // the values come formatted with commas, so we remove all @@ -44,9 +45,9 @@ export function integerSort( return s1 > s2 ? 1 : s2 > s1 ? -1 : 0; } -export function boolSort( - rowA: Row, - rowB: Row, +export function boolSort( + rowA: Row, + rowB: Row, columnId: string, ) { const s1 = rowA.original.values[columnId]; @@ -78,10 +79,10 @@ export function boolSort( * @param columnId - the ID (aka the accessor prop) of the column we're sorting * @returns - -1, 0, or 1 corresponding to the standard sorting return value */ -function enumSort( +function enumSort( precedence: any[], - rowA: Row, - rowB: Row, + rowA: Row, + rowB: Row, columnId: string, ) { const s1 = rowA.original.values[columnId]; @@ -93,9 +94,9 @@ function enumSort( return precedence.indexOf(s1) > precedence.indexOf(s2) ? 1 : -1; } -export function ruleStatusSort( - rowA: Row, - rowB: Row, +export function ruleStatusSort( + rowA: Row, + rowB: Row, columnId: string, ) { return enumSort( @@ -111,9 +112,9 @@ export function ruleStatusSort( ); } -export function reportingRuleStatusSort( - rowA: Row, - rowB: Row, +export function reportingRuleStatusSort( + rowA: Row, + rowB: Row, columnId: string, ) { return enumSort( @@ -129,9 +130,9 @@ export function reportingRuleStatusSort( ); } -export function userRoleSort( - rowA: Row, - rowB: Row, +export function userRoleSort( + rowA: Row, + rowB: Row, columnId: string, ) { return enumSort( @@ -142,9 +143,9 @@ export function userRoleSort( ); } -export function conditionOutcomeSort( - rowA: Row, - rowB: Row, +export function conditionOutcomeSort( + rowA: Row, + rowB: Row, columnId: string, ) { return enumSort( @@ -160,9 +161,9 @@ export function conditionOutcomeSort( ); } -export function userPenaltySeveritySort( - rowA: Row, - rowB: Row, +export function userPenaltySeveritySort( + rowA: Row, + rowB: Row, columnId: string, ) { return enumSort( @@ -185,9 +186,9 @@ export function userPenaltySeveritySort( * @returns a sort function compatible with react-table */ export function dateSort(dateKey: string) { - return ( - rowA: Row, - rowB: Row, + return ( + rowA: Row, + rowB: Row, _columnId: string, ) => { const a = (rowA.original as unknown as Record)[dateKey]; diff --git a/client/src/webpages/dashboard/components/table/tableFeatures.ts b/client/src/webpages/dashboard/components/table/tableFeatures.ts new file mode 100644 index 000000000..50585ab79 --- /dev/null +++ b/client/src/webpages/dashboard/components/table/tableFeatures.ts @@ -0,0 +1,58 @@ +import { + columnFacetingFeature, + columnFilteringFeature, + createFacetedRowModel, + createFilteredRowModel, + createSortedRowModel, + metaHelper, + rowSortingFeature, + tableFeatures, + type Column, + type ColumnDef, + type Row, +} from '@tanstack/react-table'; +import type { ReactNode } from 'react'; + +import { getFilterTypes } from './filters'; + +export type TableData = Record; +export type FacetedRow = { + original: TData; +}; +export type FilterRendererProps = { + preFilteredRows: readonly FacetedRow[]; + setUnsavedFilterValue: (value: any) => void; + unsavedFilterValue: any; + onSave: () => void; +}; +type TableColumnMeta = { + filter?: { + bivarianceHack(props: FilterRendererProps): ReactNode; + }['bivarianceHack']; + valueType?: unknown; +}; + +export const features = tableFeatures({ + columnFilteringFeature, + columnFacetingFeature, + rowSortingFeature, + filteredRowModel: createFilteredRowModel(), + facetedRowModel: createFacetedRowModel(), + sortedRowModel: createSortedRowModel(), + filterFns: getFilterTypes(), + columnMeta: metaHelper(), +}); + +export type TableFeatures = typeof features; +export type TableRow = Row< + TableFeatures, + TData +>; +export type TableColumnDef< + TData extends TableData = TableData, + TValue = unknown, +> = ColumnDef; +export type TableColumn< + TData extends TableData = TableData, + TValue = unknown, +> = Column; diff --git a/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx b/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx index 051417b53..486066d55 100644 --- a/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx +++ b/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx @@ -94,7 +94,7 @@ export default function ItemInvestigationRuleResults(props: { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Result', @@ -107,7 +107,7 @@ export default function ItemInvestigationRuleResults(props: { }), }, filterFn: 'includes' as const, - sortingFn: conditionOutcomeSort, + sortFn: conditionOutcomeSort, }, { header: 'Status', @@ -120,7 +120,7 @@ export default function ItemInvestigationRuleResults(props: { }), }, filterFn: 'includes' as const, - sortingFn: ruleStatusSort, + sortFn: ruleStatusSort, }, { header: 'Policies', @@ -160,7 +160,7 @@ export default function ItemInvestigationRuleResults(props: { }), }, filterFn: 'includes' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: '', diff --git a/client/src/webpages/dashboard/item_types/ItemTypesDashboard.tsx b/client/src/webpages/dashboard/item_types/ItemTypesDashboard.tsx index 3386b4594..7595f5940 100644 --- a/client/src/webpages/dashboard/item_types/ItemTypesDashboard.tsx +++ b/client/src/webpages/dashboard/item_types/ItemTypesDashboard.tsx @@ -272,7 +272,7 @@ export default function ItemTypesDashboard() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Description', @@ -285,7 +285,7 @@ export default function ItemTypesDashboard() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'ID', diff --git a/client/src/webpages/dashboard/items/ItemActionHistory.tsx b/client/src/webpages/dashboard/items/ItemActionHistory.tsx index 38f31bba9..6253e555e 100644 --- a/client/src/webpages/dashboard/items/ItemActionHistory.tsx +++ b/client/src/webpages/dashboard/items/ItemActionHistory.tsx @@ -192,7 +192,7 @@ export default function ItemActionHistory(props: { header: 'Decision Time', accessorKey: 'ts', sortDescFirst: true, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Actor', diff --git a/client/src/webpages/dashboard/mrt/ManualReviewDecisionsTable.tsx b/client/src/webpages/dashboard/mrt/ManualReviewDecisionsTable.tsx index ba0ea0f0d..6e6e0e5e5 100644 --- a/client/src/webpages/dashboard/mrt/ManualReviewDecisionsTable.tsx +++ b/client/src/webpages/dashboard/mrt/ManualReviewDecisionsTable.tsx @@ -150,7 +150,11 @@ export default function ManualReviewDecisionsTable(props: { const columns = data?.getDecisionsTable .reduce< - (TableColumnDef & { accessorKey: string; header: string })[] + (TableColumnDef<{ + [key: string]: string | number; + groupedByKey: string; + name: string; + }> & { accessorKey: string; header: string })[] >( (acc, { action_id, type }) => { if (action_id !== null && action_id !== undefined) { diff --git a/client/src/webpages/dashboard/mrt/ManualReviewQueueJobsPreview.tsx b/client/src/webpages/dashboard/mrt/ManualReviewQueueJobsPreview.tsx index 995b68832..c406ee134 100644 --- a/client/src/webpages/dashboard/mrt/ManualReviewQueueJobsPreview.tsx +++ b/client/src/webpages/dashboard/mrt/ManualReviewQueueJobsPreview.tsx @@ -145,7 +145,7 @@ export default function ManualReviewQueueJobsPreview() { }, filterFn: 'dateRange' as const, sortDescFirst: true, - sortingFn: stringSort, + sortFn: stringSort, }, ], [], diff --git a/client/src/webpages/dashboard/mrt/ManualReviewQueuesDashboard.tsx b/client/src/webpages/dashboard/mrt/ManualReviewQueuesDashboard.tsx index 29bf6de51..16d88a6ef 100644 --- a/client/src/webpages/dashboard/mrt/ManualReviewQueuesDashboard.tsx +++ b/client/src/webpages/dashboard/mrt/ManualReviewQueuesDashboard.tsx @@ -556,7 +556,7 @@ export default function ManualReviewQueuesDashboard() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, } : undefined, columnVisibility.name @@ -572,7 +572,7 @@ export default function ManualReviewQueuesDashboard() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, } : undefined, columnVisibility.description @@ -587,21 +587,21 @@ export default function ManualReviewQueuesDashboard() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, } : undefined, columnVisibility.oldestTaskAge ? { header: 'Oldest Task Age', accessorKey: 'oldestTaskAge', - sortingFn: dateSort('oldestJobCreatedAt'), + sortFn: dateSort('oldestJobCreatedAt'), } : undefined, columnVisibility.pendingJobCount ? { header: 'Pending Jobs', accessorKey: 'pendingJobCount', - sortingFn: integerSort, + sortFn: integerSort, } : undefined, columnVisibility.startReviewing diff --git a/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx b/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx index 23751506b..666cf60bc 100644 --- a/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx +++ b/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx @@ -368,7 +368,7 @@ export default function ManualReviewRecentDecisions() { header: 'Decision Time', accessorKey: 'decisionTime', sortDescFirst: true, - sortingFn: stringSort, + sortFn: stringSort, } : undefined, columnVisibility.decisions diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/v2/user/ManualReviewJobCurrentJobsComponent.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/v2/user/ManualReviewJobCurrentJobsComponent.tsx index c0932926b..1d306aeb9 100644 --- a/client/src/webpages/dashboard/mrt/manual_review_job/v2/user/ManualReviewJobCurrentJobsComponent.tsx +++ b/client/src/webpages/dashboard/mrt/manual_review_job/v2/user/ManualReviewJobCurrentJobsComponent.tsx @@ -55,7 +55,7 @@ export default function ManualReviewCurrentJobsComponent(props: { header: 'Created At', accessorKey: 'createdAt', sortDescFirst: true, - sortingFn: stringSort, + sortFn: stringSort, }, ], [], diff --git a/client/src/webpages/dashboard/ncmec/NcmecReportsDashboard.tsx b/client/src/webpages/dashboard/ncmec/NcmecReportsDashboard.tsx index 570fc7f8c..ec2e3f960 100644 --- a/client/src/webpages/dashboard/ncmec/NcmecReportsDashboard.tsx +++ b/client/src/webpages/dashboard/ncmec/NcmecReportsDashboard.tsx @@ -328,7 +328,7 @@ export default function NcmecReportsDashboard() { ? { header: 'Date', accessorKey: 'date', - sortingFn: stringSort, + sortFn: stringSort, sortDescFirst: true, meta: { filter: (props: ColumnProps) => @@ -346,7 +346,7 @@ export default function NcmecReportsDashboard() { header: 'Reviewer', accessorKey: 'reviewer', filterFn: 'includes' as const, - sortingFn: stringSort, + sortFn: stringSort, meta: { filter: (props: ColumnProps) => SelectColumnFilter({ @@ -363,7 +363,7 @@ export default function NcmecReportsDashboard() { header: 'Status', accessorKey: 'status', filterFn: 'includes' as const, - sortingFn: stringSort, + sortFn: stringSort, meta: { filter: (props: ColumnProps) => SelectColumnFilter({ diff --git a/client/src/webpages/dashboard/rules/dashboard/ReportingRulesDashboard.tsx b/client/src/webpages/dashboard/rules/dashboard/ReportingRulesDashboard.tsx index 3fe173e2d..ae43cf77c 100644 --- a/client/src/webpages/dashboard/rules/dashboard/ReportingRulesDashboard.tsx +++ b/client/src/webpages/dashboard/rules/dashboard/ReportingRulesDashboard.tsx @@ -221,7 +221,7 @@ export default function ReportingRulesDashboard() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Owner', @@ -247,7 +247,7 @@ export default function ReportingRulesDashboard() { }), }, filterFn: 'includes' as const, - sortingFn: reportingRuleStatusSort, + sortFn: reportingRuleStatusSort, }, { header: 'Policies', diff --git a/client/src/webpages/dashboard/rules/dashboard/RulesDashboard.tsx b/client/src/webpages/dashboard/rules/dashboard/RulesDashboard.tsx index f92afa7f4..092c53d36 100644 --- a/client/src/webpages/dashboard/rules/dashboard/RulesDashboard.tsx +++ b/client/src/webpages/dashboard/rules/dashboard/RulesDashboard.tsx @@ -299,7 +299,7 @@ export default function RulesDashboard() { }, filterFn: 'dateRange' as const, sortDescFirst: true, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Rule', @@ -312,7 +312,7 @@ export default function RulesDashboard() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Owner', @@ -338,7 +338,7 @@ export default function RulesDashboard() { }), }, filterFn: 'includes' as const, - sortingFn: ruleStatusSort, + sortFn: ruleStatusSort, }, { header: 'Policies', diff --git a/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx b/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx index 70c3e61b1..e1770f1cc 100644 --- a/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx +++ b/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx @@ -246,7 +246,7 @@ export default function ReportingRuleInsightsSamplesTable(props: { }, filterFn: 'range' as const, sortDescFirst: true, - sortingFn: stringSort, + sortFn: stringSort, })); }, [allSignals, samples]); @@ -302,7 +302,7 @@ export default function ReportingRuleInsightsSamplesTable(props: { }, filterFn: 'dateRange' as const, sortDescFirst: true, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Status', @@ -316,7 +316,7 @@ export default function ReportingRuleInsightsSamplesTable(props: { }), }, filterFn: 'includes' as const, - sortingFn: ruleStatusSort, + sortFn: ruleStatusSort, }, { header: 'Item', @@ -334,7 +334,7 @@ export default function ReportingRuleInsightsSamplesTable(props: { }), }, filterFn: 'includes' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'ID', diff --git a/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx b/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx index 35abef6cb..24186addc 100644 --- a/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx +++ b/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx @@ -411,7 +411,7 @@ export default function RuleInsightsSamplesTable(props: { ruleId: string }) { }, filterFn: 'range' as const, sortDescFirst: true, - sortingFn: stringSort, + sortFn: stringSort, })); }, [allSignals, samples]); @@ -468,7 +468,7 @@ export default function RuleInsightsSamplesTable(props: { ruleId: string }) { }, filterFn: 'dateRange' as const, sortDescFirst: true, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Status', @@ -482,7 +482,7 @@ export default function RuleInsightsSamplesTable(props: { ruleId: string }) { }), }, filterFn: 'includes' as const, - sortingFn: ruleStatusSort, + sortFn: ruleStatusSort, }, { header: 'Content', @@ -500,7 +500,7 @@ export default function RuleInsightsSamplesTable(props: { ruleId: string }) { }), }, filterFn: 'includes' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'ID', diff --git a/client/src/webpages/settings/ManageUsers.tsx b/client/src/webpages/settings/ManageUsers.tsx index 3c5b37d52..3e3d4d351 100644 --- a/client/src/webpages/settings/ManageUsers.tsx +++ b/client/src/webpages/settings/ManageUsers.tsx @@ -325,7 +325,7 @@ export default function ManageUsers() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Email', @@ -339,7 +339,7 @@ export default function ManageUsers() { }), }, filterFn: 'text' as const, - sortingFn: stringSort, + sortFn: stringSort, }, { header: 'Role', @@ -353,7 +353,7 @@ export default function ManageUsers() { }), }, filterFn: 'includes' as const, - sortingFn: userRoleSort, + sortFn: userRoleSort, }, { header: 'Approval Status', @@ -367,7 +367,7 @@ export default function ManageUsers() { }), }, filterFn: 'includes' as const, - sortingFn: boolSort, + sortFn: boolSort, }, { header: 'Date Created', @@ -382,7 +382,7 @@ export default function ManageUsers() { }, filterFn: 'dateRange' as const, sortDescFirst: true, - sortingFn: stringSort, + sortFn: stringSort, }, { header: '', From 0a4d98ff93df0a711012321ba262dfc189ccb5a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Tue, 11 Aug 2026 13:17:45 +0100 Subject: [PATCH 08/15] Preserve strict table row typing in v9 Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed Co-authored-by: Amp --- .../dashboard/components/table/Table.tsx | 10 +- .../ItemInvestigationRuleResults.tsx | 7 +- .../mrt/ManualReviewQueueJobsPreview.tsx | 2 +- .../dashboard/ReportingRulesDashboard.tsx | 145 +++++++------- .../rules/dashboard/RulesDashboard.tsx | 181 +++++++++--------- .../ReportingRuleInsightsSamplesTable.tsx | 111 +++++------ .../insights/RuleInsightsSamplesTable.tsx | 115 +++++------ 7 files changed, 287 insertions(+), 284 deletions(-) diff --git a/client/src/webpages/dashboard/components/table/Table.tsx b/client/src/webpages/dashboard/components/table/Table.tsx index 1c15efa00..cd3112ac5 100644 --- a/client/src/webpages/dashboard/components/table/Table.tsx +++ b/client/src/webpages/dashboard/components/table/Table.tsx @@ -10,15 +10,11 @@ import TableFilter from './TableFilter'; export type TableRowData = TableData; export type { TableColumnDef, TableRow } from './tableFeatures'; -type RowCallback = { - bivarianceHack(row: TableRow): TResult; -}['bivarianceHack']; - type TableProps> = { columns: TableColumnDef, any>[]; data: readonly TData[]; - onSelectRow?: RowCallback; - rowLinkTo?: RowCallback; + onSelectRow?: (row: TableRow) => void; + rowLinkTo?: (row: TableRow) => string; topLeftComponent?: ReactNode; topRightComponent?: ReactNode; customMaxHeight?: `max-h-[${number}px]`; @@ -29,7 +25,7 @@ type TableProps> = { | { isCollapsed?: boolean; collapsedColumnTitle?: string; - renderCollapsedCell?: RowCallback; + renderCollapsedCell?: (row: TableRow) => ReactNode; } | Record ); diff --git a/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx b/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx index 486066d55..e0263ae08 100644 --- a/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx +++ b/client/src/webpages/dashboard/investigation/ItemInvestigationRuleResults.tsx @@ -19,7 +19,7 @@ import { ruleStatusSort, stringSort, } from '../components/table/sort'; -import Table, { TableColumnDef, TableRow } from '../components/table/Table'; +import Table, { TableRow } from '../components/table/Table'; import { GQLConditionOutcome, @@ -81,7 +81,7 @@ export default function ItemInvestigationRuleResults(props: { [itemHistoryData?.itemWithHistory], ); - const columns = useMemo[]>( + const columns = useMemo( () => [ { header: 'Rule', @@ -238,6 +238,7 @@ export default function ItemInvestigationRuleResults(props: { ), ruleExecutionResult: ruleResult.result, + values: ruleResult, }; }), [ruleExecutionsHistory, navigate, rules], @@ -284,7 +285,7 @@ export default function ItemInvestigationRuleResults(props: { ); - const onSelectRow = (rowData: TableRow) => { + const onSelectRow = (rowData: TableRow<(typeof tableData)[number]>) => { const executionResult = ruleExecutionsHistory[rowData.index]; if (executionResult == null) { return; diff --git a/client/src/webpages/dashboard/mrt/ManualReviewQueueJobsPreview.tsx b/client/src/webpages/dashboard/mrt/ManualReviewQueueJobsPreview.tsx index c406ee134..e420efc99 100644 --- a/client/src/webpages/dashboard/mrt/ManualReviewQueueJobsPreview.tsx +++ b/client/src/webpages/dashboard/mrt/ManualReviewQueueJobsPreview.tsx @@ -221,7 +221,7 @@ export default function ManualReviewQueueJobsPreview() { throw Error(`Queue not found for ID ${queueId}`); } - const rowLinkTo = (row: TableRow) => { + const rowLinkTo = (row: TableRow<(typeof tableData)[number]>) => { // I don't know why but the jobs do not ever render unless you put a fake lock token // at the end of the URL, so the `/1` is actually necessary here return `/dashboard/manual_review/queues/review/${queueId}/${row.original.jobId}/1`; diff --git a/client/src/webpages/dashboard/rules/dashboard/ReportingRulesDashboard.tsx b/client/src/webpages/dashboard/rules/dashboard/ReportingRulesDashboard.tsx index ae43cf77c..a92ed9c27 100644 --- a/client/src/webpages/dashboard/rules/dashboard/ReportingRulesDashboard.tsx +++ b/client/src/webpages/dashboard/rules/dashboard/ReportingRulesDashboard.tsx @@ -24,7 +24,7 @@ import { reportingRuleStatusSort, stringSort, } from '../../components/table/sort'; -import Table, { TableColumnDef, TableRow } from '../../components/table/Table'; +import Table, { TableRow } from '../../components/table/Table'; import TruncatedListTableCell from '../../components/table/TruncatedListTableCell'; import TruncatedTextTableCell from '../../components/table/TruncatedTextTableCell'; import UserWithAvatar from '../../components/UserWithAvatar'; @@ -143,10 +143,6 @@ export default function ReportingRulesDashboard() { const navigate = useNavigate(); - const rowLinkTo = (row: TableRow) => { - return `info/${row.original.values.id}`; - }; - const permissions = rulesQueryParams.data?.me?.permissions; const canEditLiveRules = userHasPermissions(permissions, [ GQLUserPermission.MutateLiveRules, @@ -208,79 +204,78 @@ export default function ReportingRulesDashboard() { ); const columns = useMemo( - () => - [ - { - header: 'Rule', - accessorKey: 'name', - meta: { - filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'name', - }), - }, - filterFn: 'text' as const, - sortFn: stringSort, + () => [ + { + header: 'Rule', + accessorKey: 'name', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'name', + }), }, - { - header: 'Owner', - accessorKey: 'owner', - meta: { - filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'owner', - }), - }, - filterFn: 'includes' as const, - enableSorting: false, + filterFn: 'text' as const, + sortFn: stringSort, + }, + { + header: 'Owner', + accessorKey: 'owner', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'owner', + }), }, - { - header: 'Status', - accessorKey: 'status', - meta: { - filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'status', - }), - }, - filterFn: 'includes' as const, - sortFn: reportingRuleStatusSort, + filterFn: 'includes' as const, + enableSorting: false, + }, + { + header: 'Status', + accessorKey: 'status', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'status', + }), }, - { - header: 'Policies', - accessorKey: 'policies', - meta: { - filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'policies', - }), - }, - filterFn: 'includes' as const, - enableSorting: false, + filterFn: 'includes' as const, + sortFn: reportingRuleStatusSort, + }, + { + header: 'Policies', + accessorKey: 'policies', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'policies', + }), }, - { - header: 'Item Types', - accessorKey: 'itemTypes', - meta: { - filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'itemTypes', - }), - }, - filterFn: 'includes' as const, - enableSorting: false, + filterFn: 'includes' as const, + enableSorting: false, + }, + { + header: 'Item Types', + accessorKey: 'itemTypes', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'itemTypes', + }), }, - { - header: '', - accessorKey: 'mutations', - enableSorting: false, - }, - ] as (TableColumnDef & { canSort?: boolean })[], + filterFn: 'includes' as const, + enableSorting: false, + }, + { + header: '', + accessorKey: 'mutations', + enableSorting: false, + }, + ], [], ); @@ -339,6 +334,10 @@ export default function ReportingRulesDashboard() { [mutations, dataValues], ); + const rowLinkTo = (row: TableRow<(typeof tableData)[number]>) => { + return `info/${row.original.values.id}`; + }; + if (rulesQueryParams.error) { throw rulesQueryParams.error; } diff --git a/client/src/webpages/dashboard/rules/dashboard/RulesDashboard.tsx b/client/src/webpages/dashboard/rules/dashboard/RulesDashboard.tsx index 092c53d36..efb5aa29e 100644 --- a/client/src/webpages/dashboard/rules/dashboard/RulesDashboard.tsx +++ b/client/src/webpages/dashboard/rules/dashboard/RulesDashboard.tsx @@ -32,7 +32,7 @@ import { SelectColumnFilter, } from '../../components/table/filters'; import { ruleStatusSort, stringSort } from '../../components/table/sort'; -import Table, { TableColumnDef, TableRow } from '../../components/table/Table'; +import Table, { TableRow } from '../../components/table/Table'; import TruncatedListTableCell from '../../components/table/TruncatedListTableCell'; import TruncatedTextTableCell from '../../components/table/TruncatedTextTableCell'; import UserWithAvatar from '../../components/UserWithAvatar'; @@ -190,10 +190,6 @@ export default function RulesDashboard() { const navigate = useNavigate(); const [timeDivision, setTimeDivision] = useState('DAY'); - const rowLinkTo = (row: TableRow) => { - return `info/${row.original.values.id}`; - }; - const permissions = data?.me?.permissions; const favoritedRules = data?.me?.favoriteRules.map((rule) => rule?.id); const canEditLiveRules = userHasPermissions(permissions, [ @@ -280,98 +276,97 @@ export default function RulesDashboard() { ); const columns = useMemo( - () => - [ - { - header: '', - accessorKey: 'favoriteRules', - enableSorting: false, + () => [ + { + header: '', + accessorKey: 'favoriteRules', + enableSorting: false, + }, + { + header: 'Created', + accessorKey: 'dateCreated', + meta: { + filter: (props: ColumnProps) => + DateRangeColumnFilter({ + columnProps: props, + accessor: 'dateCreated', + }), }, - { - header: 'Created', - accessorKey: 'dateCreated', - meta: { - filter: (props: ColumnProps) => - DateRangeColumnFilter({ - columnProps: props, - accessor: 'dateCreated', - }), - }, - filterFn: 'dateRange' as const, - sortDescFirst: true, - sortFn: stringSort, + filterFn: 'dateRange' as const, + sortDescFirst: true, + sortFn: stringSort, + }, + { + header: 'Rule', + accessorKey: 'name', + meta: { + filter: (props: ColumnProps) => + DefaultColumnFilter({ + columnProps: props, + accessor: 'name', + }), }, - { - header: 'Rule', - accessorKey: 'name', - meta: { - filter: (props: ColumnProps) => - DefaultColumnFilter({ - columnProps: props, - accessor: 'name', - }), - }, - filterFn: 'text' as const, - sortFn: stringSort, + filterFn: 'text' as const, + sortFn: stringSort, + }, + { + header: 'Owner', + accessorKey: 'owner', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'owner', + }), }, - { - header: 'Owner', - accessorKey: 'owner', - meta: { - filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'owner', - }), - }, - filterFn: 'includes' as const, - enableSorting: false, + filterFn: 'includes' as const, + enableSorting: false, + }, + { + header: 'Status', + accessorKey: 'status', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'status', + }), }, - { - header: 'Status', - accessorKey: 'status', - meta: { - filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'status', - }), - }, - filterFn: 'includes' as const, - sortFn: ruleStatusSort, - }, - { - header: 'Policies', - accessorKey: 'policies', - meta: { - filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'policies', - }), - }, - filterFn: 'includes' as const, - enableSorting: false, + filterFn: 'includes' as const, + sortFn: ruleStatusSort, + }, + { + header: 'Policies', + accessorKey: 'policies', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'policies', + }), }, - { - header: 'Item Types', - accessorKey: 'itemTypes', - meta: { - filter: (props: ColumnProps) => - SelectColumnFilter({ - columnProps: props, - accessor: 'itemTypes', - }), - }, - filterFn: 'includes' as const, - enableSorting: false, + filterFn: 'includes' as const, + enableSorting: false, + }, + { + header: 'Item Types', + accessorKey: 'itemTypes', + meta: { + filter: (props: ColumnProps) => + SelectColumnFilter({ + columnProps: props, + accessor: 'itemTypes', + }), }, - { - header: '', - accessorKey: 'mutations', - enableSorting: false, - }, - ] as (TableColumnDef & { canSort?: boolean })[], + filterFn: 'includes' as const, + enableSorting: false, + }, + { + header: '', + accessorKey: 'mutations', + enableSorting: false, + }, + ], [], ); @@ -480,6 +475,10 @@ export default function RulesDashboard() { [mutations, dataValues, onAddFavoriteRule, onRemoveFavoriteRule], ); + const rowLinkTo = (row: TableRow<(typeof tableData)[number]>) => { + return `info/${row.original.values.id}`; + }; + if (error) { throw error; } diff --git a/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx b/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx index e1770f1cc..846da6671 100644 --- a/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx +++ b/client/src/webpages/dashboard/rules/info/insights/ReportingRuleInsightsSamplesTable.tsx @@ -351,10 +351,10 @@ export default function ReportingRuleInsightsSamplesTable(props: { }, [extraColumns]); const tableData = useMemo( () => - (dataValues ?? []).map((values) => { + (dataValues ?? []).flatMap((values) => { const parsedItem = JSON.parse(values.itemData); if (itemTypeFields == null) { - return ; + return []; } const fields = itemTypeFields[values.itemTypeName]; if (!fields || fields.length === 0) { @@ -401,56 +401,60 @@ export default function ReportingRuleInsightsSamplesTable(props: { }), ); - return { - id: ( - {values.id}} - /> - ), - itemTypeName: , - creatorId: ( - event.stopPropagation()} - target="_blank" - > - {values.creatorId} - - ), - item: ( - it.length > 0).join('\n')} - displayValue={ -
{item}
- } - footerItems={videoUrls.map((videoUrl) => ( - { - setVideoPlayerUrl(videoUrl); - }} - /> - ))} - /> - ), - time:
{values.time}
, - status: ( -
- {values.id}
} /> - - ), - values, - ...Object.fromEntries( - extraColumns.map((it) => [ - it.accessorKey, - (values as { [key: string]: any })[it.accessorKey], - ]), - ), - }; + ), + itemTypeName: , + creatorId: ( + event.stopPropagation()} + target="_blank" + > + {values.creatorId} + + ), + item: ( + it.length > 0).join('\n')} + displayValue={ +
{item}
+ } + footerItems={videoUrls.map((videoUrl) => ( + { + setVideoPlayerUrl(videoUrl); + }} + /> + ))} + /> + ), + time:
{values.time}
, + status: ( +
+ +
+ ), + values, + ...Object.fromEntries( + extraColumns.map((it) => [ + it.accessorKey, + Object.entries(values).find( + ([key]) => key === it.accessorKey, + )?.[1], + ]), + ), + }, + ]; }), [dataValues, itemTypeFields, extraColumns], ); @@ -459,12 +463,12 @@ export default function ReportingRuleInsightsSamplesTable(props: { throw error ?? priorRuleVersionError ?? signalsError!; } - const onSelectRow = (row: TableRow) => { + const onSelectRow = (row: TableRow<(typeof tableData)[number]>) => { dataValues.length > 0 && setDetailViewData({ visible: true, item: (() => { - const rowData = dataValues[row.index]; + const rowData = row.original.values; return { identifier: { id: rowData.id, typeId: rowData.itemTypeId }, date: rowData.time, @@ -542,7 +546,6 @@ export default function ReportingRuleInsightsSamplesTable(props: {
- dataValues?.map((values) => { + (dataValues ?? []).flatMap((values) => { const parsedContent = JSON.parse(values.content); if (itemTypeFields == null) { - return ; + return []; } const fields = itemTypeFields[values.itemTypeName]; if (!fields || fields.length === 0) { @@ -570,56 +570,62 @@ export default function RuleInsightsSamplesTable(props: { ruleId: string }) { }), ); - return { - id: ( - {values.id}} - /> - ), - itemTypeName: , - userId: ( - event.stopPropagation()} - target="_blank" - > - {values.userId} - - ), - content: ( - it.length > 0).join('\n')} - displayValue={ -
{content}
- } - footerItems={videoUrls.map((videoUrl) => ( - { - setVideoPlayerUrl(videoUrl); - }} - /> - ))} - /> - ), - time:
{values.time}
, - status: ( -
- {values.id}
} /> - - ), - values, - ...Object.fromEntries( - extraColumns.map((it) => [ - it.accessorKey, - (values as any)[it.accessorKey], - ]), - ), - }; + ), + itemTypeName: , + userId: ( + event.stopPropagation()} + target="_blank" + > + {values.userId} + + ), + content: ( + it.length > 0) + .join('\n')} + displayValue={ +
{content}
+ } + footerItems={videoUrls.map((videoUrl) => ( + { + setVideoPlayerUrl(videoUrl); + }} + /> + ))} + /> + ), + time:
{values.time}
, + status: ( +
+ +
+ ), + values, + ...Object.fromEntries( + extraColumns.map((it) => [ + it.accessorKey, + Object.entries(values).find( + ([key]) => key === it.accessorKey, + )?.[1], + ]), + ), + }, + ]; }), [dataValues, itemTypeFields, extraColumns], ); @@ -628,11 +634,11 @@ export default function RuleInsightsSamplesTable(props: { ruleId: string }) { throw error ?? priorRuleVersionError ?? signalsError!; } - const onSelectRow = (row: TableRow) => { + const onSelectRow = (row: TableRow<(typeof tableData)[number]>) => { setDetailViewData({ visible: true, item: (() => { - const rowData = dataValues![row.index]; + const rowData = row.original.values; return { identifier: { id: rowData.id, typeId: rowData.itemTypeId }, date: rowData.time, @@ -710,9 +716,8 @@ export default function RuleInsightsSamplesTable(props: { ruleId: string }) {
From 96e9ec4ade65c03bf0829753b7c2440667dc36b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Wed, 12 Aug 2026 04:34:44 +0100 Subject: [PATCH 09/15] Preserve grouped table header spans Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed --- .../dashboard/components/table/Table.test.tsx | 27 +++++++++++++++++++ .../dashboard/components/table/Table.tsx | 1 + 2 files changed, 28 insertions(+) diff --git a/client/src/webpages/dashboard/components/table/Table.test.tsx b/client/src/webpages/dashboard/components/table/Table.test.tsx index dcfcc2dc9..6f9652fd1 100644 --- a/client/src/webpages/dashboard/components/table/Table.test.tsx +++ b/client/src/webpages/dashboard/components/table/Table.test.tsx @@ -74,6 +74,33 @@ const filterFor = DefaultColumnFilter({ columnProps: props, accessor, placeholder }); describe('Table v7 behavior', () => { + it('preserves grouped header spans above leaf headers', () => { + const groupedColumns = [ + { + header: 'Details', + columns, + }, + ] satisfies TableColumnDef[]; + + renderTable(groupedColumns); + + const headerRows = within(screen.getAllByRole('rowgroup')[0]).getAllByRole( + 'row', + ); + expect(headerRows).toHaveLength(2); + expect( + within(headerRows[0]) + .getByRole('columnheader', { name: 'Details' }) + .getAttribute('colspan'), + ).toBe('2'); + expect( + within(headerRows[1]).getByRole('columnheader', { name: /Name/ }), + ).toBeTruthy(); + expect( + within(headerRows[1]).getByRole('columnheader', { name: 'Status' }), + ).toBeTruthy(); + }); + it('renders accessor values and sorts by raw values only on sortable headers', () => { renderTable(); diff --git a/client/src/webpages/dashboard/components/table/Table.tsx b/client/src/webpages/dashboard/components/table/Table.tsx index cd3112ac5..f63d62511 100644 --- a/client/src/webpages/dashboard/components/table/Table.tsx +++ b/client/src/webpages/dashboard/components/table/Table.tsx @@ -106,6 +106,7 @@ export default function Table>( return (
From 0983a2afdd03ab4ad6b85d0d3119d6430dc69eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Mon, 17 Aug 2026 14:05:03 +0100 Subject: [PATCH 10/15] Clarify shared table compatibility code Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed --- .../dashboard/components/table/filters.tsx | 84 ++++++++++++++----- .../components/table/tableFeatures.ts | 3 + 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/client/src/webpages/dashboard/components/table/filters.tsx b/client/src/webpages/dashboard/components/table/filters.tsx index b876e20f7..0114a217f 100644 --- a/client/src/webpages/dashboard/components/table/filters.tsx +++ b/client/src/webpages/dashboard/components/table/filters.tsx @@ -25,33 +25,70 @@ const raw = (row: FacetedRow, id: string) => export function getFilterTypes() { return { - text: (row: FacetedRow, id: string, value: any) => - value == null || - value.length === 0 || - (raw(row, id) != null && - String(raw(row, id)) - .toLowerCase() - .includes(String(value).toLowerCase())), - includes: (row: FacetedRow, id: string, value: any) => - value == null || - (Array.isArray(value) && value.length === 0) || - (raw(row, id) != null && - (Array.isArray(raw(row, id)) - ? intersection(value, raw(row, id)).length > 0 - : value.includes(raw(row, id)))), - range: (row: FacetedRow, id: string, value: any) => - value == null || - ((!value[0] || value[0] <= raw(row, id)) && - (!value[1] || value[1] >= raw(row, id))), - dateRange: (row: FacetedRow, id: string, value: any) => { - if (value == null) return true; - const start = value[0]?.format('YYYY-MM-DD'); - const end = value[1]?.format('YYYY-MM-DD'); - return (!start || start <= raw(row, id)) && (!end || end >= raw(row, id)); + // Match case-insensitive substrings against the raw column value. + text: (row: FacetedRow, id: string, filterValue: any) => { + if (filterValue == null || filterValue.length === 0) { + return true; + } + const rowValue = raw(row, id); + if (rowValue == null) { + return false; + } + return String(rowValue) + .toLowerCase() + .includes(String(filterValue).toLowerCase()); + }, + // Allow filtering on options in a predetermined list. + includes: (row: FacetedRow, id: string, filterValue: any) => { + if ( + filterValue == null || + (Array.isArray(filterValue) && filterValue.length === 0) + ) { + return true; + } + const rowValue = raw(row, id); + if (rowValue == null) { + return false; + } + if (Array.isArray(rowValue)) { + return intersection(filterValue, rowValue).length > 0; + } + return filterValue.includes(rowValue); + }, + range: (row: FacetedRow, id: string, filterValue: any) => { + if (filterValue == null) { + return true; + } + const start = filterValue[0]; + const end = filterValue[1]; + const rowValue = raw(row, id); + if (start && start > rowValue) { + return false; + } + if (end && end < rowValue) { + return false; + } + return true; + }, + dateRange: (row: FacetedRow, id: string, filterValue: any) => { + if (filterValue == null) { + return true; + } + const start = filterValue[0]?.format('YYYY-MM-DD'); + const end = filterValue[1]?.format('YYYY-MM-DD'); + const rowValue = raw(row, id); + if (start && start > rowValue) { + return false; + } + if (end && end < rowValue) { + return false; + } + return true; }, }; } function onClickFilter(event: MouseEvent) { + // Prevent clicks inside filter controls from sorting the column. event.stopPropagation(); } export function DefaultColumnFilter({ columnProps, placeholder }: FilterProps) { @@ -129,6 +166,7 @@ export function NumberRangeColumnFilter({ columnProps }: FilterProps) { } export function DateRangeColumnFilter({ columnProps }: FilterProps) { return ( + // RangePicker does not forward onClick, so intercept it on a wrapper.
= { onSave: () => void; }; type TableColumnMeta = { + // Use TypeScript's bivariance pattern to accept filter renderers + // typed for a table's specific row shape. + // https://stackoverflow.com/a/52668133 filter?: { bivarianceHack(props: FilterRendererProps): ReactNode; }['bivarianceHack']; From c08fcdd1e700f6e7fb8f920d4d3b57f6c6903acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Mon, 17 Aug 2026 14:21:18 +0100 Subject: [PATCH 11/15] Preserve table filter component structure Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed --- .../dashboard/components/table/filters.tsx | 117 ++++++++++++------ 1 file changed, 77 insertions(+), 40 deletions(-) diff --git a/client/src/webpages/dashboard/components/table/filters.tsx b/client/src/webpages/dashboard/components/table/filters.tsx index 0114a217f..55369ff04 100644 --- a/client/src/webpages/dashboard/components/table/filters.tsx +++ b/client/src/webpages/dashboard/components/table/filters.tsx @@ -23,6 +23,11 @@ export type FilterProps = { const raw = (row: FacetedRow, id: string) => (row.original as RawRow).values[id]; +function onClickFilter(event: MouseEvent) { + // Prevent clicks inside filter controls from sorting the column. + event.stopPropagation(); +} + export function getFilterTypes() { return { // Match case-insensitive substrings against the raw column value. @@ -87,46 +92,53 @@ export function getFilterTypes() { }, }; } -function onClickFilter(event: MouseEvent) { - // Prevent clicks inside filter controls from sorting the column. - event.stopPropagation(); -} -export function DefaultColumnFilter({ columnProps, placeholder }: FilterProps) { + +// Define a default UI for filtering +export function DefaultColumnFilter(props: FilterProps) { + const { columnProps, placeholder } = props; const { unsavedFilterValue, setUnsavedFilterValue, onSave } = columnProps; return ( setUnsavedFilterValue(e.target.value || undefined)} - onKeyDown={(e) => { - if (e.key === 'Enter' && unsavedFilterValue?.length) onSave(); + onKeyDown={(event) => { + if (event.key === 'Enter' && unsavedFilterValue?.length) { + onSave(); + } }} onClick={onClickFilter} /> ); } -export function SelectColumnFilter({ - columnProps, - accessor, - placeholder, -}: FilterProps) { - const options = uniq( - columnProps.preFilteredRows.flatMap( - (row) => (row.original as RawRow).values[accessor], - ), - ); + +// This is a custom filter UI for selecting +// a unique option from a list +export function SelectColumnFilter(props: FilterProps) { + const { columnProps, accessor, placeholder } = props; + const { unsavedFilterValue, setUnsavedFilterValue, preFilteredRows } = + columnProps; + // Calculate the options for filtering + // using the preFilteredRows + const options: (string[] | string)[] = []; + preFilteredRows.forEach((row) => { + options.push((row.original as RawRow).values[accessor]); + }); + const uniqueOptions = uniq(options.flat()); + + // Render a multi-select box return ( ); } -export function NumberRangeColumnFilter({ columnProps }: FilterProps) { - const set = columnProps.setUnsavedFilterValue; + +export function NumberRangeColumnFilter(props: FilterProps) { + const { columnProps } = props; + const { setUnsavedFilterValue } = columnProps; + return (
- set((old: any[] = []) => [ - e.target.value ? parseFloat(e.target.value) : undefined, - old[1], - ]) - } + onChange={(e) => { + if (!e.target.value) { + setUnsavedFilterValue((old: any[] = []) => { + return [undefined, old[1]]; + }); + return; + } + const val = parseFloat(e.target.value); + if (!isNaN(val)) { + setUnsavedFilterValue((old: any[] = []) => { + return [val, old[1]]; + }); + } + }} onClick={onClickFilter} placeholder="min" /> to - set((old: any[] = []) => [ - old[0], - e.target.value ? parseFloat(e.target.value) : undefined, - ]) - } + onChange={(e) => { + if (!e.target.value) { + setUnsavedFilterValue((old: any[] = []) => { + return [old[0], undefined]; + }); + return; + } + const val = parseFloat(e.target.value); + if (!isNaN(val)) { + setUnsavedFilterValue((old: any[] = []) => { + return [old[0], val]; + }); + } + }} onClick={onClickFilter} placeholder="max" />
); } -export function DateRangeColumnFilter({ columnProps }: FilterProps) { + +export function DateRangeColumnFilter(props: FilterProps) { + const { columnProps } = props; + const { unsavedFilterValue, setUnsavedFilterValue } = columnProps; + + // RangePicker does not forward onClick, so intercept it on a wrapper. return ( - // RangePicker does not forward onClick, so intercept it on a wrapper.
columnProps.setUnsavedFilterValue(value)} + onChange={(value: any) => { + setUnsavedFilterValue(value); + }} />
); From 1bdfe35c31c4c6de9a14b53018b1e522f1cbc195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Mon, 17 Aug 2026 14:35:17 +0100 Subject: [PATCH 12/15] Preserve shared table component structure Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed --- .../dashboard/components/table/Table.test.tsx | 2 +- .../dashboard/components/table/Table.tsx | 218 +++++++++++------- 2 files changed, 132 insertions(+), 88 deletions(-) diff --git a/client/src/webpages/dashboard/components/table/Table.test.tsx b/client/src/webpages/dashboard/components/table/Table.test.tsx index 6f9652fd1..eae7063a3 100644 --- a/client/src/webpages/dashboard/components/table/Table.test.tsx +++ b/client/src/webpages/dashboard/components/table/Table.test.tsx @@ -73,7 +73,7 @@ const filterFor = (accessor: string, placeholder: string) => (props: ColumnProps) => DefaultColumnFilter({ columnProps: props, accessor, placeholder }); -describe('Table v7 behavior', () => { +describe('Table behavior', () => { it('preserves grouped header spans above leaf headers', () => { const groupedColumns = [ { diff --git a/client/src/webpages/dashboard/components/table/Table.tsx b/client/src/webpages/dashboard/components/table/Table.tsx index f63d62511..4fa051787 100644 --- a/client/src/webpages/dashboard/components/table/Table.tsx +++ b/client/src/webpages/dashboard/components/table/Table.tsx @@ -10,28 +10,28 @@ import TableFilter from './TableFilter'; export type TableRowData = TableData; export type { TableColumnDef, TableRow } from './tableFeatures'; -type TableProps> = { - columns: TableColumnDef, any>[]; - data: readonly TData[]; - onSelectRow?: (row: TableRow) => void; - rowLinkTo?: (row: TableRow) => string; - topLeftComponent?: ReactNode; - topRightComponent?: ReactNode; - customMaxHeight?: `max-h-[${number}px]`; - disableFilter?: boolean; - containerClassName?: string; - alwaysShowScrollbar?: boolean; -} & ( - | { - isCollapsed?: boolean; - collapsedColumnTitle?: string; - renderCollapsedCell?: (row: TableRow) => ReactNode; - } - | Record -); - export default function Table>( - props: TableProps, + props: { + columns: TableColumnDef, any>[]; + data: readonly TData[]; + onSelectRow?: (row: TableRow) => void; + rowLinkTo?: (row: TableRow) => string; + topLeftComponent?: ReactNode; + topRightComponent?: ReactNode; + customMaxHeight?: `max-h-[${number}px]`; + disableFilter?: boolean; + containerClassName?: string; + /** Force the horizontal scrollbar to always render. Opt-in because tables + * that always fit the viewport would otherwise show an unnecessary scrollbar. */ + alwaysShowScrollbar?: boolean; + } & ( + | { + isCollapsed?: boolean; + collapsedColumnTitle?: string; + renderCollapsedCell?: (row: TableRow) => ReactNode; + } + | Record + ), ) { const { columns, @@ -45,8 +45,14 @@ export default function Table>( containerClassName, alwaysShowScrollbar, } = props; - const { isCollapsed, collapsedColumnTitle, renderCollapsedCell } = - 'isCollapsed' in props ? props : {}; + const { + isCollapsed = undefined, + collapsedColumnTitle = undefined, + renderCollapsedCell = undefined, + } = 'isCollapsed' in props ? props : {}; + + const rowsAreSelectable = onSelectRow !== undefined; + const table = useTable({ features, columns, @@ -57,28 +63,24 @@ export default function Table>( }); const rows = table.getRowModel().rows; const [selectedRow, setSelectedRow] = useState(null); - const rowsAreSelectable = onSelectRow !== undefined; - const selectRow = (row: TableRow, index: number) => { - if (onSelectRow) { - setSelectedRow(index); + + const selectRow = (row: TableRow, rowIndex: number) => { + if (rowsAreSelectable) { + setSelectedRow(rowIndex); onSelectRow(row); } }; - const rowClass = (index: number) => - rowsAreSelectable || rowLinkTo - ? selectedRow === index - ? 'cursor-pointer bg-indigo-100 hover:bg-indigo-100 border border-solid border-indigo-200 group' - : `cursor-pointer hover:bg-indigo-100 group ${index % 2 === 0 ? 'bg-white' : 'bg-slate-50'}` - : index % 2 === 0 - ? 'bg-white' - : 'bg-slate-50'; return (
{topLeftComponent} {disableFilter ? null : ( @@ -88,12 +90,14 @@ export default function Table>(
- {table.getHeaderGroups().map((group) => ( - + {table.getHeaderGroups().map((headerGroup) => ( + {isCollapsed && collapsedColumnTitle ? ( ) : ( - group.headers.map((header, index) => { + headerGroup.headers.map((header, index) => { const sorted = header.column.getIsSorted(); return ( {rows.map((row, rowIndex) => { - if (isCollapsed) { - const content = renderCollapsedCell?.(row); - return ( - selectRow(row, rowIndex)} + const cell = renderCollapsedCell && renderCollapsedCell(row); + const cellWithWrapper = rowLinkTo ? ( + + {cell} + + ) : ( +
+ {cell} +
+ ); + return isCollapsed ? ( + selectRow(row, rowIndex)} + > + - - ); - } - const cells = row.getAllCells(); - return ( + {cellWithWrapper} + + + ) : ( selectRow(row, rowIndex)} > - {cells.map((cell, index) => { - const content = flexRender( - cell.column.columnDef.cell, - cell.getContext(), + {row.getAllCells().map((cell, index, cells) => { + const cellWithWrapper = rowLinkTo ? ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ) : ( +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
); + return ( ); })} From 923176657a2046a9150ed1002c59b4498e5a8ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Mon, 17 Aug 2026 16:41:48 +0100 Subject: [PATCH 13/15] Preserve table filter structure Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed --- .../components/table/TableFilter.tsx | 153 +++++++++++------- 1 file changed, 99 insertions(+), 54 deletions(-) diff --git a/client/src/webpages/dashboard/components/table/TableFilter.tsx b/client/src/webpages/dashboard/components/table/TableFilter.tsx index 2c985069a..9598e62ca 100644 --- a/client/src/webpages/dashboard/components/table/TableFilter.tsx +++ b/client/src/webpages/dashboard/components/table/TableFilter.tsx @@ -11,33 +11,47 @@ import CloseButton from '@/components/common/CloseButton'; import CoopButton from '../CoopButton'; import { TableColumn, TableData } from './tableFeatures'; -export default function TableFilter({ - columns, -}: { +export default function TableFilter(props: { columns: TableColumn[]; }) { + const { columns } = props; + const filterColumns = columns.filter( (column) => column.getCanFilter() && column.columnDef.meta?.filter, ); - const [menuVisible, setMenuVisible] = useState(false); - const [expanded, setExpanded] = useState([]); - const [pending, setPending] = useState>({}); - const [floatedRight, setFloatedRight] = useState(false); + + const [menuVisible, setMenuVisible] = useState(false); + const [expandedColumnNames, setExpandedColumnNames] = useState([]); + const [unsavedFilterValues, setUnsavedFilterValues] = useState<{ + [key: string]: any; + }>({}); + const [isButtonFloatedRight, setIsButtonFloatedRight] = useState(false); + const buttonRef = useRef(null); + + // Determine whether the "Filter" button has floated to the left or right of the screen, + // which helps us display the filter menu properly. useEffect(() => { - const position = () => { - if (buttonRef.current) - setFloatedRight( - buttonRef.current.getBoundingClientRect().right > - window.innerWidth / 2, - ); + const handleButtonPosition = () => { + const button = buttonRef.current; + if (!button) return; + + const buttonRect = button.getBoundingClientRect(); + const canvasCenter = window.innerWidth / 2; + + // Check if the button is placed on the right side + setIsButtonFloatedRight(buttonRect.right > canvasCenter); }; - position(); - window.addEventListener('resize', position); - return () => window.removeEventListener('resize', position); + + handleButtonPosition(); // Initial check + window.addEventListener('resize', handleButtonPosition); + return () => window.removeEventListener('resize', handleButtonPosition); }, [menuVisible]); + const scrollToButton = () => { if (buttonRef.current) { + // If the button is in the bottom half of the screen, scroll down to it + // and place it in the middle of the screen. const buttonPosition = buttonRef.current.getBoundingClientRect().top; const halfwayPoint = window.innerHeight / 2; if (buttonPosition > halfwayPoint) { @@ -48,32 +62,59 @@ export default function TableFilter({ } } }; + + const toggleColumn = (column: string) => { + if (expandedColumnNames.includes(column)) { + setExpandedColumnNames(without(expandedColumnNames, column)); + } else { + setExpandedColumnNames([...expandedColumnNames, column]); + } + }; + const onSave = () => { - Object.entries(pending).forEach(([id, value]) => - filterColumns.find((c) => c.id === id)?.setFilterValue(value), - ); + for (const [columnId, value] of Object.entries(unsavedFilterValues)) { + filterColumns + .find((column) => column.id === columnId)! + .setFilterValue(value); + } setMenuVisible(false); }; - const remove = (id: string) => { - setPending(omit(pending, id)); - filterColumns.find((c) => c.id === id)?.setFilterValue(undefined); + + const onSetUnsavedFilterValue = (columnId: string, value: any) => { + setUnsavedFilterValues({ + ...unsavedFilterValues, + [columnId]: + typeof value === 'function' + ? value(unsavedFilterValues[columnId]) + : value, + }); + }; + + const removeFilter = (columnId: string) => { + setUnsavedFilterValues(omit(unsavedFilterValues, columnId)); + filterColumns + .find((column) => column.id === columnId)! + .setFilterValue(undefined); }; - const active = filterColumns.filter((column) => column.getFilterValue()); - if (!filterColumns.length) return null; - return ( + + const activeFilters = filterColumns.filter((column) => + column.getFilterValue(), + ); + + return filterColumns.length > 0 ? (
- {active.map((column) => ( + {activeFilters.map((column, i) => (
{`${String(column.columnDef.header)}: ${column.getFilterValue()}`} - remove(column.id)} /> + removeFilter(column.id)} />
))}
{menuVisible && (
Filter
@@ -106,44 +149,46 @@ export default function TableFilter({
- {filterColumns.map((column) => { + {filterColumns.map((column, index) => { const label = String(column.columnDef.header); - const open = expanded.includes(label); - const Renderer = column.columnDef.meta!.filter!; + if (!label.length || !column.columnDef.meta?.filter) { + return null; + } + const expanded = expandedColumnNames.includes(label); + const Renderer = column.columnDef.meta.filter; return (
- setExpanded( - open ? without(expanded, label) : [...expanded, label], - ) - } + onClick={(_) => toggleColumn(label)} + key={`${index}_column_cell`} > -
{label}
- {open ? ( +
+ {label} +
+ {expanded ? ( ) : ( )}
- {open && ( -
+ {expanded && ( +
- setPending({ - ...pending, - [column.id]: - typeof value === 'function' - ? value(pending[column.id]) - : value, - }) + onSetUnsavedFilterValue(column.id, value) } - unsavedFilterValue={pending[column.id]} + unsavedFilterValue={unsavedFilterValues[column.id]} onSave={onSave} />
@@ -155,5 +200,5 @@ export default function TableFilter({
)}
- ); + ) : null; } From fda553471776a657f55984c9da9b5fb6d6983247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Tue, 18 Aug 2026 10:25:11 +0100 Subject: [PATCH 14/15] Stabilize table data references Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed --- client/src/webpages/dashboard/components/table/Table.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/webpages/dashboard/components/table/Table.tsx b/client/src/webpages/dashboard/components/table/Table.tsx index 4fa051787..5632edcff 100644 --- a/client/src/webpages/dashboard/components/table/Table.tsx +++ b/client/src/webpages/dashboard/components/table/Table.tsx @@ -1,7 +1,7 @@ import SortAmountAsc from '@/icons/lni/Text editor/sort-amount-asc.svg?react'; import SortAmountDsc from '@/icons/lni/Text editor/sort-amount-dsc.svg?react'; import { flexRender, useTable } from '@tanstack/react-table'; -import { ReactNode, useState } from 'react'; +import { ReactNode, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { features, TableColumnDef, TableData, TableRow } from './tableFeatures'; @@ -52,11 +52,12 @@ export default function Table>( } = 'isCollapsed' in props ? props : {}; const rowsAreSelectable = onSelectRow !== undefined; + const tableData = useMemo(() => [...data], [data]); const table = useTable({ features, columns, - data: [...data], + data: tableData, defaultColumn: { cell: ({ getValue }) => getValue() as ReactNode, }, From 26d78d87023f621982fe064d0aa891e2d6d68150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Tue, 18 Aug 2026 11:00:02 +0100 Subject: [PATCH 15/15] Fix strike action label column Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed --- .../webpages/dashboard/userStrikes/StrikeEnabledActionsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/webpages/dashboard/userStrikes/StrikeEnabledActionsTab.tsx b/client/src/webpages/dashboard/userStrikes/StrikeEnabledActionsTab.tsx index 84e25c6e7..0db141ddc 100644 --- a/client/src/webpages/dashboard/userStrikes/StrikeEnabledActionsTab.tsx +++ b/client/src/webpages/dashboard/userStrikes/StrikeEnabledActionsTab.tsx @@ -73,7 +73,7 @@ export default function StrikeEnabledActionsTab() { }, { header: '', - accessorKey: 'CustomAction', // accessor is the "key" in the data + accessorKey: 'customAction', // accessor is the "key" in the data enableSorting: false, }, ],
@@ -101,14 +105,20 @@ export default function Table>(
{header.isPlaceholder @@ -136,62 +146,96 @@ export default function Table>(
- - {rowLinkTo ? ( - - {content} - - ) : ( -
- {content} -
- )} -
- {rowLinkTo ? ( - - {content} - - ) : ( -
- {content} -
- )} + {cellWithWrapper}