From da61ce10cc5fc71d35f9ea00addc16d369e04a8e Mon Sep 17 00:00:00 2001 From: Kari Lintulaakso Date: Tue, 17 Feb 2026 13:48:22 +0200 Subject: [PATCH 1/9] docs: audit detail tab table implementations --- documentation/frontend/tables.md | 53 ++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/documentation/frontend/tables.md b/documentation/frontend/tables.md index 428c4e24..14bbfb58 100644 --- a/documentation/frontend/tables.md +++ b/documentation/frontend/tables.md @@ -2,6 +2,59 @@ This guide explains how to build paginated tables in the NOW Database frontend, especially when you need to transform API rows with a `selectorFn` before rendering. +## Detail tab table inventory (audit baseline) + +The matrix below inventories current detail-tab table/list implementations under: + +- `frontend/src/components/Locality/Tabs/*` +- `frontend/src/components/Species/Tabs/*` +- `frontend/src/components/Reference/Tabs/*` +- `frontend/src/components/TimeUnit/Tabs/*` +- `frontend/src/components/TimeBound/Tabs/*` +- `frontend/src/components/Museum/Tabs/*` + +Legend: + +- **Primitive**: `SimpleTable`, `EditableTable`, `SelectingTable`, `LookupSelectingTable`, or direct `TableView` usage. +- **Data source**: + - `API-driven`: tab issues a query hook in the tab component. + - `Context-driven`: table rows come from detail context payload (`data`/`editData`) and are rendered client-side. + - `Mixed`: selection is API-driven while edited/read table rows are context-driven. +- **Edit-mode actions** summarize current mutation UX. + +### User-requested URLs (explicit coverage) + +| URL | Tab component | Primitive(s) | Data source | Edit-mode actions | +| --- | --- | --- | --- | --- | +| `/locality/10003?tab=2` (Locality/Species) | `Locality/Tabs/SpeciesTab.tsx` | `SelectingTable` + `EditableTable` | Mixed | Add new species form, copy taxonomy selector, select existing species, remove/re-add linked rows via `rowState` in editable table. | +| `/locality/10006?tab=8` (Locality/Museums) | `Locality/Tabs/MuseumTab.tsx` | `SelectingTable` + `EditableTable` | Mixed | Select museum from lookup list, add link to `now_mus`, remove/re-add linked rows via editable table actions. | +| `/locality/10003?tab=9` (Locality/Projects) | `Locality/Tabs/ProjectTab.tsx` | `SelectingTable` + `EditableTable` | Mixed | Select project from API list, duplicate guard message, remove/re-add project links via editable table actions. | +| `/species/10001?tab=6` (Species/Localities) | `Species/Tabs/LocalityTab.tsx` | `SelectingTable` + `EditableTable` | Mixed | Select locality and append to `now_ls`, remove/re-add links via editable table actions. | +| `/species/10001?tab=7` (Species/Locality Species) | `Species/Tabs/LocalitySpeciesTab.tsx` | `EditableTable` | Context-driven | "Add new Locality Species" modal is present (TODO save path), row remove/re-add handled by editable table actions. | +| `/reference/10029?tab=1` (Reference/Localities) | `Reference/Tabs/LocalityTab.tsx` | `SimpleTable` | API-driven | No edit actions (read-only linked list with row navigation). | +| `/reference/10029?tab=2` (Reference/Species) | `Reference/Tabs/SpeciesTab.tsx` | `SimpleTable` | API-driven | No edit actions (read-only linked list with row navigation). | +| `/time-unit/agenian?tab=1` (Time Unit/Localities) | `TimeUnit/Tabs/LocalityTab.tsx` | `SimpleTable` | API-driven (+ client transform for checkmark/X columns) | No edit actions. | +| `/time-bound/9?tab=1` (Time Bound/Time Units) | `TimeBound/Tabs/TimeUnitTab.tsx` | `SimpleTable` | API-driven | No edit actions. | +| `/museum/APM?tab=0` (Museum/Localities) | `Museum/Tabs/LocalityTab.tsx` | `SimpleTable` | Context-driven (museum details payload) | No edit actions in this tab; guidance text shown for linking via Locality edit flow. | + +### Additional table/list tabs in audited folders + +| Component | Primitive(s) | Data source | Edit-mode actions | +| --- | --- | --- | --- | +| `Locality/Tabs/TaphonomyTab.tsx` | `LookupSelectingTable` + `EditableTable` | Mixed | Select collecting methods from lookup API and manage linked rows through editable row actions. | +| `Locality/Tabs/LithologyTab.tsx` | `LookupSelectingTable` + `EditableTable` | Mixed | Select sedimentary structures from lookup API and manage links via editable row actions. | +| `Locality/Tabs/LocalityTab.tsx` | `EditableTable` | Context-driven | Manage locality synonyms through editable row actions. | +| `Species/Tabs/SynonymTab.tsx` | `EditableTable` | Context-driven | Add/edit/remove synonym rows on species detail payload. | +| `Species/Tabs/TaxonomyTab.tsx` | `SelectingTable` | API-driven | Copy taxonomy from an existing species into taxonomy form fields. | +| `Reference/Tabs/AuthorTab.tsx` | `SelectingTable` + `EditableTable` | Mixed | Select/add authors and manage relation rows in edit mode. | +| `Reference/Tabs/JournalTab.tsx` | `SelectingTable` + `EditableTable` | Mixed | Select/add journals and manage relation rows in edit mode. | + +### Current DRY opportunity notes + +- `SimpleTable` and `EditableTable` currently disable advanced column/table interactions (sorting/column actions/filtering parity with `TableView` is not present). +- The dominant repeating pattern in edit tabs is **selector table + editable linked table**, which is a suitable extraction target for a shared detail-tab table abstraction. +- User-requested parity work should prioritize the ten explicitly listed URLs first, then roll over to additional table tabs listed above. + ## Core building blocks | Concern | Implementation | From 69d2100bec6d1f41b405a516e6fcbe839545a930 Mon Sep 17 00:00:00 2001 From: Kari Lintulaakso Date: Tue, 17 Feb 2026 14:20:48 +0200 Subject: [PATCH 2/9] refactor: add shared detail tab table wrapper --- .../DetailView/common/DetailTabTable.test.tsx | 79 ++++++++++ .../DetailView/common/DetailTabTable.tsx | 145 ++++++++++++++++++ .../DetailView/common/EditableTable.tsx | 39 ++--- .../DetailView/common/SelectingTable.tsx | 5 +- .../DetailView/common/SimpleTable.tsx | 72 +++------ 5 files changed, 261 insertions(+), 79 deletions(-) create mode 100644 frontend/src/components/DetailView/common/DetailTabTable.test.tsx create mode 100644 frontend/src/components/DetailView/common/DetailTabTable.tsx diff --git a/frontend/src/components/DetailView/common/DetailTabTable.test.tsx b/frontend/src/components/DetailView/common/DetailTabTable.test.tsx new file mode 100644 index 00000000..b4dc13f1 --- /dev/null +++ b/frontend/src/components/DetailView/common/DetailTabTable.test.tsx @@ -0,0 +1,79 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import { DetailTabTable } from './DetailTabTable' + +const tableViewMock = jest.fn<(props: Record) => JSX.Element>() +const useMaterialReactTableMock = jest.fn<(options: Record) => Record>() + +jest.mock('@/components/TableView/TableView', () => ({ + TableView: (props: Record) => tableViewMock(props), +})) + +jest.mock('material-react-table', () => ({ + useMaterialReactTable: (options: Record) => useMaterialReactTableMock(options), + MaterialReactTable: () =>
, +})) + +type TestRow = { + id: number + name: string +} + +describe('DetailTabTable', () => { + beforeEach(() => { + tableViewMock.mockReset() + useMaterialReactTableMock.mockReset() + tableViewMock.mockReturnValue(
) + useMaterialReactTableMock.mockReturnValue({}) + }) + + it('maps read mode props to TableView', () => { + render( + + mode="read" + title="Read table" + data={[{ id: 1, name: 'Alpha' }]} + columns={[ + { accessorKey: 'id', header: 'Id' }, + { accessorKey: 'name', header: 'Name' }, + ]} + idFieldName="id" + url="species" + isFetching={false} + enableColumnFilterModes={true} + /> + ) + + expect(screen.getByTestId('table-view')).toBeTruthy() + expect(tableViewMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Read table', + idFieldName: 'id', + url: 'species', + enableColumnFilterModes: true, + }) + ) + }) + + it('renders editable mode via MaterialReactTable setup', () => { + render( + + mode="edit" + data={[{ id: 1, name: 'Alpha' }]} + columns={[{ accessorKey: 'name', header: 'Name' }]} + enableSorting={false} + enableColumnActions={false} + /> + ) + + expect(screen.getByTestId('material-react-table')).toBeTruthy() + expect(useMaterialReactTableMock).toHaveBeenCalledWith( + expect.objectContaining({ + enableSorting: false, + enableColumnActions: false, + enablePagination: true, + }) + ) + }) +}) diff --git a/frontend/src/components/DetailView/common/DetailTabTable.tsx b/frontend/src/components/DetailView/common/DetailTabTable.tsx new file mode 100644 index 00000000..b1bd9e99 --- /dev/null +++ b/frontend/src/components/DetailView/common/DetailTabTable.tsx @@ -0,0 +1,145 @@ +import { useState, type ReactNode } from 'react' +import { + MaterialReactTable, + MRT_PaginationState, + MRT_Row, + MRT_RowData, + MRT_TableOptions, + MRT_VisibilityState, + type MRT_ColumnDef, + useMaterialReactTable, +} from 'material-react-table' +import { TableView } from '@/components/TableView/TableView' + +const defaultEditPagination: MRT_PaginationState = { pageIndex: 0, pageSize: 15 } + +type ReadOrSelectMode = 'read' | 'select' + +type DetailTabTableReadSelectProps = { + mode: ReadOrSelectMode + data?: T[] | null + columns: MRT_ColumnDef[] + title: string + visibleColumns?: MRT_VisibilityState + idFieldName: keyof T + isFetching?: boolean + isError?: boolean + checkRowRestriction?: (row: T) => boolean + selectorFn?: (id: T) => void + tableRowAction?: (row: T) => void + url?: string + clickableRows?: boolean + enableColumnFilterModes?: boolean + paginationPlacement?: 'top' | 'bottom' | 'both' +} + +type DetailTabTableEditProps = { + mode: 'edit' + data: T[] + columns: MRT_ColumnDef[] + enableSorting?: boolean + enableColumnActions?: boolean + enableTopToolbar?: boolean + positionPagination?: 'top' | 'bottom' | 'both' + paginationState?: MRT_PaginationState + onPaginationChange?: MRT_TableOptions['onPaginationChange'] + enableRowActions?: boolean + renderRowActions?: MRT_TableOptions['renderRowActions'] + muiTableBodyRowProps?: MRT_TableOptions['muiTableBodyRowProps'] +} + +type DetailTabTableProps = DetailTabTableReadSelectProps | DetailTabTableEditProps + +export const DetailTabTable = (props: DetailTabTableProps) => { + if (props.mode === 'edit') { + return + } + + const { + data, + columns, + title, + visibleColumns, + idFieldName, + isFetching = false, + selectorFn, + tableRowAction, + url, + checkRowRestriction, + clickableRows, + enableColumnFilterModes, + paginationPlacement, + isError, + } = props + + const resolvedVisibleColumns: MRT_VisibilityState = visibleColumns + ? visibleColumns + : columns.reduce((acc, column) => { + if (column.accessorKey) { + acc[column.accessorKey.toString()] = true + } + return acc + }, {}) + + return ( + + title={title} + idFieldName={idFieldName} + columns={columns} + visibleColumns={resolvedVisibleColumns} + data={data ?? undefined} + isFetching={isFetching} + selectorFn={selectorFn} + tableRowAction={tableRowAction} + url={url} + checkRowRestriction={checkRowRestriction} + clickableRows={clickableRows} + enableColumnFilterModes={enableColumnFilterModes} + paginationPlacement={paginationPlacement} + isError={isError} + /> + ) +} + +const DetailTabEditableTable = ({ + data, + columns, + enableSorting = false, + enableColumnActions = false, + enableTopToolbar = false, + positionPagination = 'both', + paginationState, + onPaginationChange, + enableRowActions = false, + renderRowActions, + muiTableBodyRowProps, +}: DetailTabTableEditProps) => { + const [pagination, setPagination] = useState(paginationState ?? defaultEditPagination) + + const resolvedPagination = paginationState ?? pagination + const handlePaginationChange: MRT_TableOptions['onPaginationChange'] = onPaginationChange ?? setPagination + + const table = useMaterialReactTable({ + columns, + data, + enableTopToolbar, + enableColumnActions, + enableSorting, + enablePagination: true, + onPaginationChange: handlePaginationChange, + positionPagination, + paginationDisplayMode: 'pages', + state: { density: 'compact', pagination: resolvedPagination }, + enableRowActions, + renderRowActions, + muiTableBodyRowProps, + }) + + return +} + +export type { DetailTabTableProps, DetailTabTableReadSelectProps, DetailTabTableEditProps } +export type DetailTableEditRowRenderer = (props: { + row: MRT_Row + staticRowIndex?: number +}) => ReactNode diff --git a/frontend/src/components/DetailView/common/EditableTable.tsx b/frontend/src/components/DetailView/common/EditableTable.tsx index 3548bf48..411d2f09 100755 --- a/frontend/src/components/DetailView/common/EditableTable.tsx +++ b/frontend/src/components/DetailView/common/EditableTable.tsx @@ -1,20 +1,13 @@ import { EditDataType, RowState } from '@/shared/types' import { CircularProgress, Box, Button } from '@mui/material' -import { - type MRT_ColumnDef, - type MRT_RowData, - MaterialReactTable, - MRT_Row, - MRT_PaginationState, -} from 'material-react-table' +import { type MRT_ColumnDef, type MRT_Row, type MRT_RowData } from 'material-react-table' import { useDetailContext } from '../Context/DetailContext' import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline' import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline' -import { useState, useEffect } from 'react' +import { useEffect } from 'react' import { checkFieldErrors } from './checkFieldErrors' import { ActionComponent } from '@/components/TableView/ActionComponent' - -const defaultPagination: MRT_PaginationState = { pageIndex: 0, pageSize: 15 } +import { DetailTabTable } from './DetailTabTable' const getNewState = (state: RowState): RowState => { if (!state || state === 'clean') return 'removed' @@ -47,7 +40,6 @@ export const EditableTable = < idFieldName?: keyof T url?: string }) => { - const [pagination, setPagination] = useState(defaultPagination) const { editData, setEditData, mode, data, validator, fieldsWithErrors, setFieldsWithErrors } = useDetailContext() const errorObject = validator(editData, field) @@ -100,14 +92,16 @@ export const EditableTable = < return null // code shouldn't get here! } - const actionRowProps = () => { + const resolveRenderRowActions = () => { if (mode.read && (!idFieldName || !url)) { - return {} - } else if (mode.read && idFieldName && url) { - return { enableRowActions: true, renderRowActions: linkToDetails } - } else { - return { enableRowActions: true, renderRowActions: actionRow } + return undefined + } + + if (mode.read && idFieldName && url) { + return linkToDetails } + + return actionRow } const rowStateToColor = (state: RowState | undefined) => { @@ -132,18 +126,15 @@ export const EditableTable = < } return ( - + mode="edit" columns={columns} data={getData()} enableTopToolbar={false} enableColumnActions={false} enableSorting={false} - enablePagination={true} - onPaginationChange={setPagination} - positionPagination="both" - paginationDisplayMode="pages" - state={{ density: 'compact', pagination }} + enableRowActions={Boolean(resolveRenderRowActions())} + renderRowActions={resolveRenderRowActions()} muiTableBodyRowProps={({ row }: { row: MRT_Row }) => ({ sx: { backgroundColor: rowStateToColor(row.original.rowState) }, })} diff --git a/frontend/src/components/DetailView/common/SelectingTable.tsx b/frontend/src/components/DetailView/common/SelectingTable.tsx index dc19cf9a..a8fe3f39 100755 --- a/frontend/src/components/DetailView/common/SelectingTable.tsx +++ b/frontend/src/components/DetailView/common/SelectingTable.tsx @@ -1,6 +1,6 @@ import { MRT_ColumnDef, MRT_RowData } from 'material-react-table' import { EditingModal } from './EditingModal' -import { TableView } from '@/components/TableView/TableView' +import { DetailTabTable } from './DetailTabTable' import { useDetailContext } from '../Context/DetailContext' import { useMemo } from 'react' import { Box, CircularProgress } from '@mui/material' @@ -77,7 +77,8 @@ export const SelectingTable = return ( {data ? ( - + + mode="select" data={filteredData} columns={columns} title={title} diff --git a/frontend/src/components/DetailView/common/SimpleTable.tsx b/frontend/src/components/DetailView/common/SimpleTable.tsx index f3a71523..59c1d608 100755 --- a/frontend/src/components/DetailView/common/SimpleTable.tsx +++ b/frontend/src/components/DetailView/common/SimpleTable.tsx @@ -1,18 +1,7 @@ import { CircularProgress } from '@mui/material' -import { - type MRT_ColumnDef, - type MRT_RowData, - type MRT_PaginationState, - MRT_Row, - MaterialReactTable, - useMaterialReactTable, -} from 'material-react-table' +import { type MRT_ColumnDef, type MRT_RowData } from 'material-react-table' import { useDetailContext } from '../Context/DetailContext' -import { ActionComponent } from '@/components/TableView/ActionComponent' -import { defaultPaginationSmall } from './defaultValues' -import { useState } from 'react' -import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' -import { usePageContext } from '@/components/Page' +import { DetailTabTable } from './DetailTabTable' export const SimpleTable = ({ data, @@ -26,48 +15,25 @@ export const SimpleTable = { const { mode } = useDetailContext() - const [pagination, setPagination] = useState(defaultPaginationSmall) - const navigate = useNavigate() - const location = useLocation() - const { previousTableUrls, setPreviousTableUrls } = usePageContext() - const [searchParams] = useSearchParams() + if (!data) return - const linkToDetails = ({ row }: { row: MRT_Row }) => { - if (mode.read && idFieldName && url) return - return null // code shouldn't get here! + if (!idFieldName) { + return mode="edit" data={data} columns={columns} enableRowActions={false} /> } - const actionRowProps = - mode.read && idFieldName && url - ? { - enableRowActions: true, - renderRowActions: linkToDetails, - muiTableBodyRowProps: ({ row }: { row: MRT_Row }) => ({ - onClick: () => { - setPreviousTableUrls([...previousTableUrls, `${location.pathname}?tab=${searchParams.get('tab')}`]) - navigate(`/${url}/${row.original[idFieldName]}`) - }, - sx: { - cursor: 'pointer', - }, - }), - } - : {} - - const table = useMaterialReactTable({ - columns: columns, - data: data || [], - enableTopToolbar: false, - enableColumnActions: false, - positionPagination: 'bottom', - onPaginationChange: setPagination, - paginationDisplayMode: 'pages', - state: { density: 'compact', pagination }, - ...actionRowProps, - }) - - if (!data) return - - return + return ( + + mode={mode.read ? 'read' : 'select'} + title="Detail List" + data={data} + columns={columns} + idFieldName={idFieldName} + url={url} + isFetching={false} + clickableRows={mode.read} + enableColumnFilterModes={true} + paginationPlacement="bottom" + /> + ) } From c9571032dbd80c0c8620dc1e92e93d8405ea1d27 Mon Sep 17 00:00:00 2001 From: Kari Lintulaakso Date: Tue, 17 Feb 2026 15:29:33 +0200 Subject: [PATCH 3/9] feat: apply default species ordering for tab tables --- .../DetailView/common/DetailTabTable.test.tsx | 34 ++++++++- .../DetailView/common/DetailTabTable.tsx | 75 +++++++++++++++++++ .../components/Locality/Tabs/SpeciesTab.tsx | 23 +++++- .../components/Reference/Tabs/SpeciesTab.tsx | 9 ++- .../Species/Tabs/LocalitySpeciesTab.tsx | 22 +++++- 5 files changed, 156 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/DetailView/common/DetailTabTable.test.tsx b/frontend/src/components/DetailView/common/DetailTabTable.test.tsx index b4dc13f1..370fd980 100644 --- a/frontend/src/components/DetailView/common/DetailTabTable.test.tsx +++ b/frontend/src/components/DetailView/common/DetailTabTable.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, jest, beforeEach } from '@jest/globals' import '@testing-library/jest-dom' import { render, screen } from '@testing-library/react' -import { DetailTabTable } from './DetailTabTable' +import { applyDefaultSpeciesOrdering, DetailTabTable, hasActiveSortingInSearch } from './DetailTabTable' const tableViewMock = jest.fn<(props: Record) => JSX.Element>() const useMaterialReactTableMock = jest.fn<(options: Record) => Record>() @@ -77,3 +77,35 @@ describe('DetailTabTable', () => { ) }) }) + +describe('default species ordering helpers', () => { + it('applies fallback species ordering when sorting is not active', () => { + const rows = [ + { order_name: 'Rodentia', family_name: 'Muridae', genus_name: 'Mus', species_name: 'musculus' }, + { order_name: 'Artiodactyla', family_name: 'Bovidae', genus_name: 'Bos', species_name: 'taurus' }, + { order_name: 'Artiodactyla', family_name: 'Cervidae', genus_name: 'Cervus', species_name: 'elaphus' }, + ] + + const ordered = applyDefaultSpeciesOrdering(rows) + + expect(ordered?.map(row => `${row.order_name}:${row.family_name}:${row.genus_name}:${row.species_name}`)).toEqual([ + 'Artiodactyla:Bovidae:Bos:taurus', + 'Artiodactyla:Cervidae:Cervus:elaphus', + 'Rodentia:Muridae:Mus:musculus', + ]) + }) + + it('does not apply fallback ordering when explicit sorting exists in url', () => { + const search = '?sorting=' + encodeURIComponent(JSON.stringify([{ id: 'species_name', desc: true }])) + expect(hasActiveSortingInSearch(search)).toBe(true) + + const rows = [ + { order_name: 'Rodentia', family_name: 'Muridae', genus_name: 'Mus', species_name: 'musculus' }, + { order_name: 'Artiodactyla', family_name: 'Bovidae', genus_name: 'Bos', species_name: 'taurus' }, + ] + + const ordered = applyDefaultSpeciesOrdering(rows, { skip: hasActiveSortingInSearch(search) }) + + expect(ordered).toBe(rows) + }) +}) diff --git a/frontend/src/components/DetailView/common/DetailTabTable.tsx b/frontend/src/components/DetailView/common/DetailTabTable.tsx index b1bd9e99..4153c49b 100644 --- a/frontend/src/components/DetailView/common/DetailTabTable.tsx +++ b/frontend/src/components/DetailView/common/DetailTabTable.tsx @@ -13,6 +13,81 @@ import { TableView } from '@/components/TableView/TableView' const defaultEditPagination: MRT_PaginationState = { pageIndex: 0, pageSize: 15 } +const speciesDefaultSortFields = ['order_name', 'family_name', 'genus_name', 'species_name'] as const + +const getNestedValue = (row: MRT_RowData, path: string): unknown => { + return path.split('.').reduce((acc, segment) => { + if (typeof acc !== 'object' || acc === null || !(segment in acc)) { + return undefined + } + + return (acc as Record)[segment] + }, row) +} + +const normalizeSortValue = (value: unknown): string => { + if (value === null || value === undefined) { + return '' + } + + if (typeof value === 'string') { + return value.toLocaleLowerCase() + } + + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return `${value}`.toLocaleLowerCase() + } + + return '' +} + +// eslint-disable-next-line react-refresh/only-export-components +export const hasActiveSortingInSearch = (search: string): boolean => { + const params = new URLSearchParams(search) + const rawSorting = params.get('sorting') + + if (!rawSorting) { + return false + } + + try { + const parsed = JSON.parse(rawSorting) as unknown + return Array.isArray(parsed) && parsed.length > 0 + } catch { + return false + } +} + +// eslint-disable-next-line react-refresh/only-export-components +export const applyDefaultSpeciesOrdering = ( + rows: T[] | undefined, + options?: { prefix?: string; skip?: boolean } +): T[] | undefined => { + if (!rows) { + return undefined + } + + if (options?.skip) { + return rows + } + + const fields = speciesDefaultSortFields.map(field => (options?.prefix ? `${options.prefix}.${field}` : field)) + + return [...rows].sort((leftRow, rightRow) => { + for (const field of fields) { + const left = normalizeSortValue(getNestedValue(leftRow as MRT_RowData, field)) + const right = normalizeSortValue(getNestedValue(rightRow as MRT_RowData, field)) + + const compared = left.localeCompare(right) + if (compared !== 0) { + return compared + } + } + + return 0 + }) +} + type ReadOrSelectMode = 'read' | 'select' type DetailTabTableReadSelectProps = { diff --git a/frontend/src/components/Locality/Tabs/SpeciesTab.tsx b/frontend/src/components/Locality/Tabs/SpeciesTab.tsx index 602f181d..a8dd2be1 100755 --- a/frontend/src/components/Locality/Tabs/SpeciesTab.tsx +++ b/frontend/src/components/Locality/Tabs/SpeciesTab.tsx @@ -23,18 +23,34 @@ import { import { useNotify } from '@/hooks/notification' import { validateSpecies } from '@/shared/validators/species' import { smallSpeciesTableColumns } from '@/common' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { SynonymsModal } from '@/components/Species/SynonymsModal' import { taxonStatusSelectOptions } from '@/constants/taxonStatusOptions' +import { applyDefaultSpeciesOrdering, hasActiveSortingInSearch } from '@/components/DetailView/common/DetailTabTable' +import { useLocation } from 'react-router-dom' export const SpeciesTab = () => { const { mode, editData, setEditData } = useDetailContext() + const location = useLocation() const { data: speciesData, isError } = useGetAllSpeciesQuery(mode.read ? skipToken : undefined) const { notify } = useNotify() const [replacedValues, setReplacedValues] = useState | undefined>() const [selectedSpecies, setSelectedSpecies] = useState() const [modalOpen, setModalOpen] = useState(false) + const hasUrlSorting = hasActiveSortingInSearch(location.search) + + const sortedSpeciesData = useMemo(() => { + return applyDefaultSpeciesOrdering(speciesData, { skip: hasUrlSorting }) + }, [hasUrlSorting, speciesData]) + + const sortedLocalitySpeciesData = useMemo(() => { + return applyDefaultSpeciesOrdering(editData.now_ls as unknown as LocalitySpecies[], { + prefix: 'com_species', + skip: hasUrlSorting, + }) + }, [editData.now_ls, hasUrlSorting]) + const handleRowActionClick = (row: Species) => { setSelectedSpecies(row.species_id.toString()) setModalOpen(true) @@ -46,7 +62,7 @@ export const SpeciesTab = () => { dataCy="copy_existing_taxonomy_button" buttonText="Copy existing taxonomy" title="Copy existing taxonomy" - data={speciesData} + data={sortedSpeciesData} isError={isError} columns={smallSpeciesTableColumns} fieldName="order_name" // this doesn't do anything here but is required @@ -223,7 +239,7 @@ export const SpeciesTab = () => { /> buttonText="Select Species" - data={speciesData} + data={sortedSpeciesData} title="Species" isError={isError} columns={speciesColumns} @@ -251,6 +267,7 @@ export const SpeciesTab = () => { columns={localitySpeciesColumns} field="now_ls" + visible_data={sortedLocalitySpeciesData} idFieldName="species_id" url="species" /> diff --git a/frontend/src/components/Reference/Tabs/SpeciesTab.tsx b/frontend/src/components/Reference/Tabs/SpeciesTab.tsx index 537bf13b..48319c2f 100755 --- a/frontend/src/components/Reference/Tabs/SpeciesTab.tsx +++ b/frontend/src/components/Reference/Tabs/SpeciesTab.tsx @@ -3,9 +3,12 @@ import { useGetReferenceSpeciesQuery } from '@/redux/referenceReducer' import { CircularProgress } from '@mui/material' import { useDetailContext } from '@/components/DetailView/Context/DetailContext' import { SimpleTable } from '@/components/DetailView/common/SimpleTable' +import { applyDefaultSpeciesOrdering, hasActiveSortingInSearch } from '@/components/DetailView/common/DetailTabTable' +import { useLocation } from 'react-router-dom' export const SpeciesTab = () => { const { data } = useDetailContext() + const location = useLocation() const { data: speciesData, isError } = useGetReferenceSpeciesQuery(encodeURIComponent(data.rid)) if (isError) return 'Error loading Species.' @@ -46,5 +49,9 @@ export const SpeciesTab = () => { }, ] - return + const sortedSpeciesData = applyDefaultSpeciesOrdering(speciesData, { + skip: hasActiveSortingInSearch(location.search), + }) + + return } diff --git a/frontend/src/components/Species/Tabs/LocalitySpeciesTab.tsx b/frontend/src/components/Species/Tabs/LocalitySpeciesTab.tsx index 2b4ee684..d13b1476 100755 --- a/frontend/src/components/Species/Tabs/LocalitySpeciesTab.tsx +++ b/frontend/src/components/Species/Tabs/LocalitySpeciesTab.tsx @@ -8,6 +8,9 @@ import { MRT_ColumnDef, MRT_Row } from 'material-react-table' import { matchesCountryOrContinent } from '@/shared/validators/countryContinents' import { useForm } from 'react-hook-form' import { calculateNormalizedMesowearScore } from '@/shared/utils/mesowear' +import { applyDefaultSpeciesOrdering, hasActiveSortingInSearch } from '@/components/DetailView/common/DetailTabTable' +import { useLocation } from 'react-router-dom' +import { useMemo } from 'react' const hasMesowearScoreInputs = (row: SpeciesLocality) => { return ( @@ -21,12 +24,23 @@ const hasMesowearScoreInputs = (row: SpeciesLocality) => { } export const LocalitySpeciesTab = () => { - const { mode } = useDetailContext() + const { mode, data, editData } = useDetailContext() + const location = useLocation() const { register, formState: { errors }, } = useForm() + const sortedLocalitySpeciesRows = useMemo(() => { + const sourceRows = (mode.read ? data.now_ls : editData.now_ls) as unknown as Editable[] + + return ( + applyDefaultSpeciesOrdering(sourceRows, { + skip: hasActiveSortingInSearch(location.search), + }) ?? sourceRows + ) + }, [data.now_ls, editData.now_ls, location.search, mode.read]) + const columns: MRT_ColumnDef[] = [ { accessorKey: 'now_loc.loc_name', @@ -200,7 +214,11 @@ export const LocalitySpeciesTab = () => { return ( {!mode.read && editingModal} - , SpeciesDetailsType> columns={columns} field="now_ls" /> + , SpeciesDetailsType> + columns={columns} + field="now_ls" + visible_data={sortedLocalitySpeciesRows} + /> ) } From a17b203831a6957f58c0e89823cd419021146cbe Mon Sep 17 00:00:00 2001 From: Kari Lintulaakso Date: Tue, 17 Feb 2026 15:40:39 +0200 Subject: [PATCH 4/9] refactor: migrate read-only tabs to DetailTabTable --- .../components/Museum/Tabs/LocalityTab.tsx | 17 ++++++++++++-- .../components/Reference/Tabs/LocalityTab.tsx | 17 ++++++++++++-- .../components/Reference/Tabs/SpeciesTab.tsx | 22 ++++++++++++++++--- .../components/TimeBound/Tabs/TimeUnitTab.tsx | 10 +++++++-- .../components/TimeUnit/Tabs/LocalityTab.tsx | 17 ++++++++++++-- 5 files changed, 72 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/Museum/Tabs/LocalityTab.tsx b/frontend/src/components/Museum/Tabs/LocalityTab.tsx index bbf4d4a9..0a461bc6 100644 --- a/frontend/src/components/Museum/Tabs/LocalityTab.tsx +++ b/frontend/src/components/Museum/Tabs/LocalityTab.tsx @@ -1,4 +1,4 @@ -import { SimpleTable } from '@/components/DetailView/common/SimpleTable' +import { DetailTabTable } from '@/components/DetailView/common/DetailTabTable' import { useDetailContext } from '@/components/DetailView/Context/DetailContext' import { MuseumLocalities } from '@/shared/types' @@ -34,5 +34,18 @@ export const LocalityTab = ({ isNew }: { isNew: boolean }) => { return
Link localities to this museum by editing a locality and navigating to the museum tab there.
} - return + return ( + + ) } diff --git a/frontend/src/components/Reference/Tabs/LocalityTab.tsx b/frontend/src/components/Reference/Tabs/LocalityTab.tsx index 66ad82df..d23ee7de 100755 --- a/frontend/src/components/Reference/Tabs/LocalityTab.tsx +++ b/frontend/src/components/Reference/Tabs/LocalityTab.tsx @@ -2,7 +2,7 @@ import { ReferenceDetailsType } from '@/shared/types' import { useGetReferenceLocalitiesQuery } from '@/redux/referenceReducer' import { CircularProgress } from '@mui/material' import { useDetailContext } from '@/components/DetailView/Context/DetailContext' -import { SimpleTable } from '@/components/DetailView/common/SimpleTable' +import { DetailTabTable } from '@/components/DetailView/common/DetailTabTable' export const LocalityTab = () => { const { data } = useDetailContext() @@ -30,5 +30,18 @@ export const LocalityTab = () => { }, ] - return + return ( + + ) } diff --git a/frontend/src/components/Reference/Tabs/SpeciesTab.tsx b/frontend/src/components/Reference/Tabs/SpeciesTab.tsx index 48319c2f..0604b459 100755 --- a/frontend/src/components/Reference/Tabs/SpeciesTab.tsx +++ b/frontend/src/components/Reference/Tabs/SpeciesTab.tsx @@ -2,8 +2,11 @@ import { ReferenceDetailsType } from '@/shared/types' import { useGetReferenceSpeciesQuery } from '@/redux/referenceReducer' import { CircularProgress } from '@mui/material' import { useDetailContext } from '@/components/DetailView/Context/DetailContext' -import { SimpleTable } from '@/components/DetailView/common/SimpleTable' -import { applyDefaultSpeciesOrdering, hasActiveSortingInSearch } from '@/components/DetailView/common/DetailTabTable' +import { + applyDefaultSpeciesOrdering, + DetailTabTable, + hasActiveSortingInSearch, +} from '@/components/DetailView/common/DetailTabTable' import { useLocation } from 'react-router-dom' export const SpeciesTab = () => { @@ -53,5 +56,18 @@ export const SpeciesTab = () => { skip: hasActiveSortingInSearch(location.search), }) - return + return ( + + ) } diff --git a/frontend/src/components/TimeBound/Tabs/TimeUnitTab.tsx b/frontend/src/components/TimeBound/Tabs/TimeUnitTab.tsx index ff1357a1..b19cc0fb 100755 --- a/frontend/src/components/TimeBound/Tabs/TimeUnitTab.tsx +++ b/frontend/src/components/TimeBound/Tabs/TimeUnitTab.tsx @@ -4,7 +4,7 @@ import { Alert, Button, CircularProgress, Stack } from '@mui/material' import { skipToken } from '@reduxjs/toolkit/query' import { useDetailContext } from '@/components/DetailView/Context/DetailContext' import { MRT_ColumnDef } from 'material-react-table' -import { SimpleTable } from '@/components/DetailView/common/SimpleTable' +import { DetailTabTable } from '@/components/DetailView/common/DetailTabTable' export const TimeUnitTab = () => { const { data, mode } = useDetailContext() @@ -62,11 +62,17 @@ export const TimeUnitTab = () => { ] return ( - + + mode="read" + title="Time Units" columns={columns} data={timeUnitsData} idFieldName="tu_name" url="time-unit" + isFetching={false} + enableColumnFilterModes={true} + clickableRows={true} + paginationPlacement="bottom" /> ) } diff --git a/frontend/src/components/TimeUnit/Tabs/LocalityTab.tsx b/frontend/src/components/TimeUnit/Tabs/LocalityTab.tsx index 3d3d6ca7..23f70517 100755 --- a/frontend/src/components/TimeUnit/Tabs/LocalityTab.tsx +++ b/frontend/src/components/TimeUnit/Tabs/LocalityTab.tsx @@ -2,7 +2,7 @@ import { TimeUnitDetailsType } from '@/shared/types' import { useGetTimeUnitLocalitiesQuery } from '@/redux/timeUnitReducer' import { CircularProgress } from '@mui/material' import { useDetailContext } from '@/components/DetailView/Context/DetailContext' -import { SimpleTable } from '@/components/DetailView/common/SimpleTable' +import { DetailTabTable } from '@/components/DetailView/common/DetailTabTable' export const LocalityTab = () => { const { data } = useDetailContext() @@ -39,5 +39,18 @@ export const LocalityTab = () => { }, ] - return + return ( + + ) } From 3a63ff6559337a2d5752250c877df6e607c38d34 Mon Sep 17 00:00:00 2001 From: Kari Lintulaakso Date: Tue, 17 Feb 2026 15:55:39 +0200 Subject: [PATCH 5/9] refactor: enable advanced controls in editable relation tabs --- .../DetailView/common/EditableTable.tsx | 8 +- .../components/Locality/Tabs/MuseumTab.tsx | 1 + .../components/Locality/Tabs/ProjectTab.tsx | 1 + .../components/Locality/Tabs/SpeciesTab.tsx | 1 + .../Tabs/__tests__/ProjectTab.test.tsx | 87 +++++++++++++++++++ .../Species/Tabs/LocalitySpeciesTab.tsx | 1 + .../components/Species/Tabs/LocalityTab.tsx | 1 + .../__tests__/LocalitySpeciesTab.test.tsx | 6 ++ 8 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/Locality/Tabs/__tests__/ProjectTab.test.tsx diff --git a/frontend/src/components/DetailView/common/EditableTable.tsx b/frontend/src/components/DetailView/common/EditableTable.tsx index 411d2f09..71ca6c83 100755 --- a/frontend/src/components/DetailView/common/EditableTable.tsx +++ b/frontend/src/components/DetailView/common/EditableTable.tsx @@ -27,6 +27,7 @@ export const EditableTable = < visible_data, // use some filtered data instead of the actual data. Allows you to hide some rows. But be careful that the data is in the right format useDefinedIndex = false, // Control whether to use the defined index or static index. The index data needs to have a key named 'index'. useObject = false, + enableAdvancedTableControls = false, idFieldName, url, }: { @@ -37,6 +38,7 @@ export const EditableTable = < visible_data?: Array useDefinedIndex?: boolean useObject?: boolean + enableAdvancedTableControls?: boolean idFieldName?: keyof T url?: string }) => { @@ -130,9 +132,9 @@ export const EditableTable = < mode="edit" columns={columns} data={getData()} - enableTopToolbar={false} - enableColumnActions={false} - enableSorting={false} + enableTopToolbar={enableAdvancedTableControls} + enableColumnActions={enableAdvancedTableControls} + enableSorting={enableAdvancedTableControls} enableRowActions={Boolean(resolveRenderRowActions())} renderRowActions={resolveRenderRowActions()} muiTableBodyRowProps={({ row }: { row: MRT_Row }) => ({ diff --git a/frontend/src/components/Locality/Tabs/MuseumTab.tsx b/frontend/src/components/Locality/Tabs/MuseumTab.tsx index 03e9bc23..5919fb86 100755 --- a/frontend/src/components/Locality/Tabs/MuseumTab.tsx +++ b/frontend/src/components/Locality/Tabs/MuseumTab.tsx @@ -58,6 +58,7 @@ export const MuseumTab = () => { , LocalityDetailsType> columns={columns.map(col => ({ ...col, accessorKey: `com_mlist.${col.accessorKey}` }))} field="now_mus" + enableAdvancedTableControls={true} idFieldName="museum" url="museum" /> diff --git a/frontend/src/components/Locality/Tabs/ProjectTab.tsx b/frontend/src/components/Locality/Tabs/ProjectTab.tsx index d4e1d306..8c9de09c 100755 --- a/frontend/src/components/Locality/Tabs/ProjectTab.tsx +++ b/frontend/src/components/Locality/Tabs/ProjectTab.tsx @@ -86,6 +86,7 @@ export const ProjectTab = () => { , LocalityDetailsType> columns={columns.map(c => ({ ...c, accessorKey: `now_proj.${c.accessorKey}` }))} field="now_plr" + enableAdvancedTableControls={true} idFieldName="pid" url="project" /> diff --git a/frontend/src/components/Locality/Tabs/SpeciesTab.tsx b/frontend/src/components/Locality/Tabs/SpeciesTab.tsx index a8dd2be1..c3b05f0e 100755 --- a/frontend/src/components/Locality/Tabs/SpeciesTab.tsx +++ b/frontend/src/components/Locality/Tabs/SpeciesTab.tsx @@ -268,6 +268,7 @@ export const SpeciesTab = () => { columns={localitySpeciesColumns} field="now_ls" visible_data={sortedLocalitySpeciesData} + enableAdvancedTableControls={true} idFieldName="species_id" url="species" /> diff --git a/frontend/src/components/Locality/Tabs/__tests__/ProjectTab.test.tsx b/frontend/src/components/Locality/Tabs/__tests__/ProjectTab.test.tsx new file mode 100644 index 00000000..60dab15a --- /dev/null +++ b/frontend/src/components/Locality/Tabs/__tests__/ProjectTab.test.tsx @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { fireEvent, render, screen } from '@testing-library/react' +import { ProjectTab } from '../ProjectTab' +import { useDetailContext, modeOptionToMode } from '@/components/DetailView/Context/DetailContext' +import { useGetAllProjectsQuery } from '@/redux/projectReducer' +import { usePageContext } from '@/components/Page' + +jest.mock('@/components/DetailView/Context/DetailContext', () => ({ + useDetailContext: jest.fn(), + modeOptionToMode: { + new: { read: false, staging: false, new: true, option: 'new' }, + read: { read: true, staging: false, new: false, option: 'read' }, + edit: { read: false, staging: false, new: false, option: 'edit' }, + 'staging-edit': { read: false, staging: true, new: false, option: 'staging-edit' }, + 'staging-new': { read: false, staging: true, new: true, option: 'staging-new' }, + }, +})) + +jest.mock('@/redux/projectReducer', () => ({ + useGetAllProjectsQuery: jest.fn(), +})) + +jest.mock('@/components/Page', () => ({ + usePageContext: jest.fn(), +})) + +jest.mock('@/components/DetailView/common/tabLayoutHelpers', () => ({ + Grouped: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +jest.mock('@/components/DetailView/common/EditableTable', () => ({ + EditableTable: ({ enableAdvancedTableControls }: { enableAdvancedTableControls?: boolean }) => ( +
+ ), +})) + +jest.mock('@/components/DetailView/common/SelectingTable', () => ({ + SelectingTable: ({ editingAction }: { editingAction: (project: { pid: number; proj_name: string }) => void }) => ( + + ), +})) + +const mockUseDetailContext = useDetailContext as jest.MockedFunction +const mockUseGetAllProjectsQuery = useGetAllProjectsQuery as jest.MockedFunction +const mockUsePageContext = usePageContext as jest.MockedFunction + +describe('ProjectTab selection regression', () => { + const setEditData = jest.fn<(value: unknown) => void>() + + beforeEach(() => { + jest.clearAllMocks() + mockUsePageContext.mockReturnValue({ editRights: { edit: true, new: true } } as never) + mockUseGetAllProjectsQuery.mockReturnValue({ data: [], isError: false } as never) + }) + + it('adds selected projects to pending links', () => { + mockUseDetailContext.mockReturnValue({ + mode: modeOptionToMode.edit, + editData: { lid: 1, now_plr: [] }, + setEditData, + } as never) + + render() + + fireEvent.click(screen.getByRole('button', { name: /select project/i })) + + expect(setEditData).toHaveBeenCalledWith({ + lid: 1, + now_plr: [{ lid: 1, pid: 100, now_proj: { pid: 100, proj_name: 'Project Alpha' }, rowState: 'new' }], + }) + expect(screen.getByTestId('editable-table').getAttribute('data-advanced')).toBe('true') + }) + + it('shows duplicate warning when selected project already exists', () => { + mockUseDetailContext.mockReturnValue({ + mode: modeOptionToMode.edit, + editData: { lid: 1, now_plr: [{ lid: 1, pid: 100, rowState: 'clean' }] }, + setEditData, + } as never) + + render() + + fireEvent.click(screen.getByRole('button', { name: /select project/i })) + + expect(screen.getByTestId('project-selection-error').textContent).toContain('already linked') + }) +}) diff --git a/frontend/src/components/Species/Tabs/LocalitySpeciesTab.tsx b/frontend/src/components/Species/Tabs/LocalitySpeciesTab.tsx index d13b1476..8ff729f3 100755 --- a/frontend/src/components/Species/Tabs/LocalitySpeciesTab.tsx +++ b/frontend/src/components/Species/Tabs/LocalitySpeciesTab.tsx @@ -218,6 +218,7 @@ export const LocalitySpeciesTab = () => { columns={columns} field="now_ls" visible_data={sortedLocalitySpeciesRows} + enableAdvancedTableControls={true} /> ) diff --git a/frontend/src/components/Species/Tabs/LocalityTab.tsx b/frontend/src/components/Species/Tabs/LocalityTab.tsx index e5a8f13b..325d578d 100755 --- a/frontend/src/components/Species/Tabs/LocalityTab.tsx +++ b/frontend/src/components/Species/Tabs/LocalityTab.tsx @@ -82,6 +82,7 @@ export const LocalityTab = () => { , SpeciesDetailsType> columns={columns.map(c => ({ ...c, accessorKey: `now_loc.${c.accessorKey}` }))} field="now_ls" + enableAdvancedTableControls={true} idFieldName="lid" url="locality" /> diff --git a/frontend/src/components/Species/Tabs/__tests__/LocalitySpeciesTab.test.tsx b/frontend/src/components/Species/Tabs/__tests__/LocalitySpeciesTab.test.tsx index 8683c7c7..f01f5d4c 100644 --- a/frontend/src/components/Species/Tabs/__tests__/LocalitySpeciesTab.test.tsx +++ b/frontend/src/components/Species/Tabs/__tests__/LocalitySpeciesTab.test.tsx @@ -20,6 +20,7 @@ jest.mock('@/components/DetailView/Context/DetailContext', () => ({ type EditableTableProps = { columns: MRT_ColumnDef[] field: string + enableAdvancedTableControls?: boolean } const editableTableMock = jest.fn<(props: EditableTableProps) => JSX.Element>() @@ -59,6 +60,11 @@ describe('LocalitySpeciesTab MW Score rendering', () => { render() }) + it('enables advanced controls for editable locality-species rows', () => { + const editableTableProps = editableTableMock.mock.calls[0]?.[0] + expect(editableTableProps?.enableAdvancedTableControls).toBe(true) + }) + it('renders normalized score with 2 decimals for valid inputs', () => { const cellRenderer = getMwScoreCellRenderer() expect(cellRenderer).toBeDefined() From a6f2b8b25580a6cef3566351d09297d59dfbabc5 Mon Sep 17 00:00:00 2001 From: Kari Lintulaakso Date: Tue, 17 Feb 2026 16:08:09 +0200 Subject: [PATCH 6/9] feat: add csv export controls for editable tab tables --- .../DetailView/common/DetailTabTable.tsx | 23 +++++++++++++++++-- .../src/components/TableView/TableToolBar.tsx | 4 +++- .../src/components/TableView/TableView.tsx | 1 + 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/DetailView/common/DetailTabTable.tsx b/frontend/src/components/DetailView/common/DetailTabTable.tsx index 4153c49b..5465282e 100644 --- a/frontend/src/components/DetailView/common/DetailTabTable.tsx +++ b/frontend/src/components/DetailView/common/DetailTabTable.tsx @@ -10,6 +10,10 @@ import { useMaterialReactTable, } from 'material-react-table' import { TableView } from '@/components/TableView/TableView' +import { TableToolBar } from '@/components/TableView/TableToolBar' +import { Box } from '@mui/material' +import { TableHelp } from '@components/Table' +import { useUser } from '@/hooks/user' const defaultEditPagination: MRT_PaginationState = { pageIndex: 0, pageSize: 15 } @@ -121,6 +125,7 @@ type DetailTabTableEditProps = { enableRowActions?: boolean renderRowActions?: MRT_TableOptions['renderRowActions'] muiTableBodyRowProps?: MRT_TableOptions['muiTableBodyRowProps'] + tableName?: string } type DetailTabTableProps = DetailTabTableReadSelectProps | DetailTabTableEditProps @@ -188,8 +193,10 @@ const DetailTabEditableTable = ({ enableRowActions = false, renderRowActions, muiTableBodyRowProps, + tableName = 'table', }: DetailTabTableEditProps) => { const [pagination, setPagination] = useState(paginationState ?? defaultEditPagination) + const user = useUser() const resolvedPagination = paginationState ?? pagination const handlePaginationChange: MRT_TableOptions['onPaginationChange'] = onPaginationChange ?? setPagination @@ -197,7 +204,7 @@ const DetailTabEditableTable = ({ const table = useMaterialReactTable({ columns, data, - enableTopToolbar, + enableTopToolbar: false, enableColumnActions, enableSorting, enablePagination: true, @@ -210,7 +217,19 @@ const DetailTabEditableTable = ({ muiTableBodyRowProps, }) - return + return ( + + {enableTopToolbar && user && ( +
+ + + table={table} tableName={tableName} hideLeftButtons={true} /> + +
+ )} + +
+ ) } export type { DetailTabTableProps, DetailTabTableReadSelectProps, DetailTabTableEditProps } diff --git a/frontend/src/components/TableView/TableToolBar.tsx b/frontend/src/components/TableView/TableToolBar.tsx index cea09b20..a7854273 100644 --- a/frontend/src/components/TableView/TableToolBar.tsx +++ b/frontend/src/components/TableView/TableToolBar.tsx @@ -18,6 +18,7 @@ export const TableToolBar = ({ isCrossSearchTable, selectorFn, showNewButton, + hideLeftButtons, }: { table: MRT_TableInstance tableName: string @@ -26,6 +27,7 @@ export const TableToolBar = ({ isCrossSearchTable?: boolean selectorFn?: (id: T) => void showNewButton?: boolean + hideLeftButtons?: boolean }) => { const { previousTableUrls, setPreviousTableUrls } = usePageContext() const location = useLocation() @@ -40,7 +42,7 @@ export const TableToolBar = ({ return (
- {!selectorFn && ( + {!selectorFn && !hideLeftButtons && ( buttonText="Contact" noContext={true} /> diff --git a/frontend/src/components/TableView/TableView.tsx b/frontend/src/components/TableView/TableView.tsx index 381738bb..fb5c0e9a 100755 --- a/frontend/src/components/TableView/TableView.tsx +++ b/frontend/src/components/TableView/TableView.tsx @@ -424,6 +424,7 @@ export const TableView = ({ showNewButton={editRights.new && !selectorFn && title != 'Locality-Species-Cross-Search'} isCrossSearchTable={isCrossSearchTable} selectorFn={selectorFn} + hideLeftButtons={false} />
From 231e521ed72c9bec9655091f7397b49b7eec1bef Mon Sep 17 00:00:00 2001 From: Kari Lintulaakso Date: Tue, 17 Feb 2026 16:26:52 +0200 Subject: [PATCH 7/9] feat: validate and paginate tab list query parameters --- .../tabLists/queryValidation.test.ts | 60 ++++++ backend/src/routes/museum.ts | 13 +- backend/src/routes/reference.ts | 30 ++- backend/src/routes/timeBound.ts | 17 +- backend/src/routes/timeUnit.ts | 13 +- backend/src/services/museum.ts | 12 +- backend/src/services/reference.ts | 21 +- backend/src/services/tabularQuery.ts | 193 ++++++++++++++++++ backend/src/services/timeBound.ts | 8 +- backend/src/services/timeUnit.ts | 10 +- 10 files changed, 365 insertions(+), 12 deletions(-) create mode 100644 backend/src/api-tests/tabLists/queryValidation.test.ts create mode 100644 backend/src/services/tabularQuery.ts diff --git a/backend/src/api-tests/tabLists/queryValidation.test.ts b/backend/src/api-tests/tabLists/queryValidation.test.ts new file mode 100644 index 00000000..34a49e77 --- /dev/null +++ b/backend/src/api-tests/tabLists/queryValidation.test.ts @@ -0,0 +1,60 @@ +import { afterAll, beforeAll, describe, expect, it } from '@jest/globals' +import { pool } from '../../utils/db' +import { login, resetDatabase, resetDatabaseTimeout, send } from '../utils' + +describe('Tab list query validation and pagination', () => { + beforeAll(async () => { + await resetDatabase() + await login() + }, resetDatabaseTimeout) + + afterAll(async () => { + await pool.end() + }) + + it('rejects invalid sorting for reference species endpoint', async () => { + const { body, status } = await send<{ message: string; errors: string[] }>( + 'reference/species/10029?sorting=%5B%7B%22id%22%3A%22invalid%22%2C%22desc%22%3Afalse%7D%5D', + 'GET' + ) + + expect(status).toBe(400) + expect(body.message).toBe('Invalid query parameters') + expect(body.errors[0]).toContain('sorting.id must be one of') + }) + + it('rejects non-empty server-side column filters for tab list endpoints', async () => { + const { body, status } = await send<{ message: string; errors: string[] }>( + 'time-unit/localities/agenian?columnfilters=%5B%7B%22id%22%3A%22loc_name%22%2C%22value%22%3A%22x%22%7D%5D', + 'GET' + ) + + expect(status).toBe(400) + expect(body.message).toBe('Invalid query parameters') + expect(body.errors).toContain( + 'Server-side columnfilters are not supported for this endpoint. Use client-side filtering.' + ) + }) + + it('applies pagination and sorting to reference species endpoint', async () => { + const { body, status } = await send[]>( + 'reference/species/10029?sorting=%5B%7B%22id%22%3A%22species_name%22%2C%22desc%22%3Atrue%7D%5D&pagination=%7B%22pageIndex%22%3A0%2C%22pageSize%22%3A1%7D', + 'GET' + ) + + expect(status).toBe(200) + expect(Array.isArray(body)).toBe(true) + expect(body.length).toBeLessThanOrEqual(1) + }) + + it('applies pagination to museum localities without changing authorization behavior', async () => { + const { body, status } = await send<{ localities: Record[] }>( + 'museum/APM?limit=1&offset=0&sorting=%5B%7B%22id%22%3A%22loc_name%22%2C%22desc%22%3Afalse%7D%5D', + 'GET' + ) + + expect(status).toBe(200) + expect(Array.isArray(body.localities)).toBe(true) + expect(body.localities.length).toBeLessThanOrEqual(1) + }) +}) diff --git a/backend/src/routes/museum.ts b/backend/src/routes/museum.ts index 8f2581b1..b3c85571 100644 --- a/backend/src/routes/museum.ts +++ b/backend/src/routes/museum.ts @@ -4,6 +4,7 @@ import { fixBigInt } from '../utils/common' import { requireOneOf } from '../middlewares/authorizer' import { Role, EditDataType, EditMetaData, Museum } from '../../../frontend/src/shared/types' import { DuplicateMuseumCodeError, writeMuseum } from '../services/write/museum' +import { parseTabListQuery } from '../services/tabularQuery' const router = Router() @@ -14,7 +15,17 @@ router.get('/all', async (_req, res) => { router.get('/:id', async (req, res) => { const id = req.params.id - const museum = await getMuseumDetails(id) + const parsedQuery = parseTabListQuery({ + query: req.query, + allowedSortingColumns: ['loc_name', 'country', 'max_age', 'min_age', 'lid'], + defaultSorting: [{ id: 'loc_name', desc: false }], + }) + + if (!parsedQuery.ok) { + return res.status(400).send({ message: 'Invalid query parameters', errors: parsedQuery.errors }) + } + + const museum = await getMuseumDetails(id, parsedQuery.options) if (!museum) return res.status(404).send() return res.status(200).send(fixBigInt(museum)) }) diff --git a/backend/src/routes/reference.ts b/backend/src/routes/reference.ts index fb7ee967..d4e1293b 100644 --- a/backend/src/routes/reference.ts +++ b/backend/src/routes/reference.ts @@ -16,6 +16,7 @@ import { requireOneOf } from '../middlewares/authorizer' import { Role, EditMetaData, ReferenceDetailsType, EditDataType } from '../../../frontend/src/shared/types' import { deleteReference, writeReference } from '../services/write/reference' import { fixBigInt } from '../utils/common' +import { parseTabListQuery } from '../services/tabularQuery' const router = Router() @@ -63,13 +64,38 @@ router.get('/:id', async (req, res) => { router.get('/localities/:id', async (req, res) => { const id = req.params.id - const localities = await getReferenceLocalities(id) + const parsedQuery = parseTabListQuery({ + query: req.query, + allowedSortingColumns: ['loc_name', 'country', 'max_age', 'min_age', 'lid'], + defaultSorting: [{ id: 'loc_name', desc: false }], + }) + + if (!parsedQuery.ok) { + return res.status(400).send({ message: 'Invalid query parameters', errors: parsedQuery.errors }) + } + + const localities = await getReferenceLocalities(id, parsedQuery.options) return res.status(200).send(fixBigInt(localities)) }) router.get('/species/:id', async (req, res) => { const id = req.params.id - const species = await getReferenceSpecies(id) + const parsedQuery = parseTabListQuery({ + query: req.query, + allowedSortingColumns: ['order_name', 'family_name', 'genus_name', 'species_name', 'species_id'], + defaultSorting: [ + { id: 'order_name', desc: false }, + { id: 'family_name', desc: false }, + { id: 'genus_name', desc: false }, + { id: 'species_name', desc: false }, + ], + }) + + if (!parsedQuery.ok) { + return res.status(400).send({ message: 'Invalid query parameters', errors: parsedQuery.errors }) + } + + const species = await getReferenceSpecies(id, parsedQuery.options) return res.status(200).send(fixBigInt(species)) }) diff --git a/backend/src/routes/timeBound.ts b/backend/src/routes/timeBound.ts index a3f39c2e..85d69b8c 100644 --- a/backend/src/routes/timeBound.ts +++ b/backend/src/routes/timeBound.ts @@ -9,6 +9,7 @@ import { } from '../services/timeBound' import { deleteTimeBound, writeTimeBound } from '../services/write/timeBound' import { fixBigInt } from '../utils/common' +import { parseTabListQuery } from '../services/tabularQuery' const router = Router() @@ -26,7 +27,21 @@ router.get('/:id', async (req, res) => { router.get('/time-units/:id', async (req, res) => { const id = parseInt(req.params.id) - const timeUnits = await getTimeBoundTimeUnits(id) + const parsedQuery = parseTabListQuery({ + query: req.query, + allowedSortingColumns: ['tu_name', 'tu_display_name', 'rank', 'sequence', 'tu_comment', 'up_bnd', 'low_bnd'], + defaultSorting: [ + { id: 'rank', desc: false }, + { id: 'sequence', desc: false }, + { id: 'tu_name', desc: false }, + ], + }) + + if (!parsedQuery.ok) { + return res.status(400).send({ message: 'Invalid query parameters', errors: parsedQuery.errors }) + } + + const timeUnits = await getTimeBoundTimeUnits(id, parsedQuery.options) return res.status(200).send(fixBigInt(timeUnits)) }) diff --git a/backend/src/routes/timeUnit.ts b/backend/src/routes/timeUnit.ts index 36729670..52612e55 100644 --- a/backend/src/routes/timeUnit.ts +++ b/backend/src/routes/timeUnit.ts @@ -18,6 +18,7 @@ import { getTimeBoundDetails, validateEntireTimeBound } from '../services/timeBo import { ConflictError, DuplicateTimeUnitError, deleteTimeUnit, writeTimeUnit } from '../services/write/timeUnit' import { fixBigInt } from '../utils/common' import { writeTimeBound } from '../services/write/timeBound' +import { parseTabListQuery } from '../services/tabularQuery' const router = Router() @@ -45,7 +46,17 @@ router.get('/:id', async (req, res) => { router.get('/localities/:id', async (req, res) => { const id = req.params.id - const localities = await getTimeUnitLocalities(id) + const parsedQuery = parseTabListQuery({ + query: req.query, + allowedSortingColumns: ['loc_name', 'country', 'max_age', 'min_age', 'lid'], + defaultSorting: [{ id: 'loc_name', desc: false }], + }) + + if (!parsedQuery.ok) { + return res.status(400).send({ message: 'Invalid query parameters', errors: parsedQuery.errors }) + } + + const localities = await getTimeUnitLocalities(id, parsedQuery.options) return res.status(200).send(fixBigInt(localities)) }) diff --git a/backend/src/services/museum.ts b/backend/src/services/museum.ts index fe17278d..c3a47cb7 100644 --- a/backend/src/services/museum.ts +++ b/backend/src/services/museum.ts @@ -1,15 +1,16 @@ import { Locality, Museum, EditDataType, EditMetaData } from '../../../frontend/src/shared/types' import { nowDb } from '../utils/db' import { ValidationObject } from '../../../frontend/src/shared/validators/validator' -import Prisma from '../../prisma/generated/now_test_client' +import type Prisma from '../../prisma/generated/now_test_client' import { validateMuseum } from '../../../frontend/src/shared/validators/museum' +import { TabListQueryOptions } from './tabularQuery' export const getAllMuseums = async () => { const result = await nowDb.com_mlist.findMany({}) return result } -export const getMuseumDetails = async (id: string) => { +export const getMuseumDetails = async (id: string, options?: TabListQueryOptions) => { const museum = await nowDb.com_mlist.findUnique({ where: { museum: id }, }) @@ -23,6 +24,10 @@ export const getMuseumDetails = async (id: string) => { const localityIds = localityLinks.map(link => link.lid) + const orderBy = options?.sorting.map(sort => ({ + [sort.id]: sort.desc ? 'desc' : 'asc', + })) + const localitiesResult = await nowDb.now_loc.findMany({ where: { lid: { in: localityIds } }, select: { @@ -80,6 +85,9 @@ export const getMuseumDetails = async (id: string) => { select: { synonym: true }, }, }, + orderBy, + skip: options?.skip, + take: options?.take, }) const localities: Locality[] = localitiesResult.map(locality => { diff --git a/backend/src/services/reference.ts b/backend/src/services/reference.ts index 38cc10ec..ed0250cc 100644 --- a/backend/src/services/reference.ts +++ b/backend/src/services/reference.ts @@ -6,7 +6,8 @@ import { ReferenceFieldDisplayNames, validateReference, } from '../../../frontend/src/shared/validators/reference' -import type { ref_ref } from '../../prisma/generated/now_test_client' +import { type ref_ref } from '../../prisma/generated/now_test_client' +import { TabListQueryOptions } from './tabularQuery' type ReferenceTypeFieldName = { field_name: string | null; ref_field_name: string | null } @@ -65,8 +66,12 @@ export const getReferenceDetails = async (id: number) => { } // Fetch localities that have been updated by the given reference id -export const getReferenceLocalities = async (id: string) => { +export const getReferenceLocalities = async (id: string, options?: TabListQueryOptions) => { // TODO: Check if user has access + const orderBy = options?.sorting.map(sort => ({ + [sort.id]: sort.desc ? 'desc' : 'asc', + })) + const result = await nowDb.now_loc.findMany({ where: { now_lau: { @@ -77,13 +82,20 @@ export const getReferenceLocalities = async (id: string) => { }, }, }, + orderBy, + skip: options?.skip, + take: options?.take, }) return result } // Fetch species that have been updated by the given reference id -export const getReferenceSpecies = async (id: string) => { +export const getReferenceSpecies = async (id: string, options?: TabListQueryOptions) => { // TODO: Check if user has access + const orderBy = options?.sorting.map(sort => ({ + [sort.id]: sort.desc ? 'desc' : 'asc', + })) + const result = await nowDb.com_species.findMany({ where: { now_sau: { @@ -94,6 +106,9 @@ export const getReferenceSpecies = async (id: string) => { }, }, }, + orderBy, + skip: options?.skip, + take: options?.take, }) return result } diff --git a/backend/src/services/tabularQuery.ts b/backend/src/services/tabularQuery.ts new file mode 100644 index 00000000..49a73e96 --- /dev/null +++ b/backend/src/services/tabularQuery.ts @@ -0,0 +1,193 @@ +import type { Request } from 'express' + +type SortInput = { + id: string + desc: boolean +} + +export type TabListQueryOptions = { + sorting: SortInput[] + skip?: number + take?: number +} + +type ParseTabQuerySuccess = { + ok: true + options: TabListQueryOptions +} + +type ParseTabQueryFailure = { + ok: false + errors: string[] +} + +type ParseTabQueryResult = ParseTabQuerySuccess | ParseTabQueryFailure + +type ParseTabQueryParams = { + query: Request['query'] + allowedSortingColumns: string[] + defaultSorting: SortInput[] + allowServerColumnFilters?: boolean +} + +const getSingleQueryValue = (value: unknown): string | undefined => { + if (typeof value === 'string') { + return value + } + + if (Array.isArray(value) && typeof value[0] === 'string') { + return value[0] + } + + return undefined +} + +const parseSorting = (sortingRaw: string | undefined, allowedSortingColumns: string[]) => { + if (!sortingRaw) { + return { sorting: undefined as SortInput[] | undefined, errors: [] as string[] } + } + + try { + const parsed = JSON.parse(sortingRaw) as unknown + + if (!Array.isArray(parsed)) { + return { sorting: undefined, errors: ['sorting must be a JSON array.'] } + } + + const normalizedSorting: SortInput[] = [] + for (const entry of parsed) { + if (typeof entry !== 'object' || entry === null) { + return { sorting: undefined, errors: ['sorting entries must be objects.'] } + } + + const candidate = entry as { id?: unknown; desc?: unknown } + if (typeof candidate.id !== 'string' || !allowedSortingColumns.includes(candidate.id)) { + return { sorting: undefined, errors: [`sorting.id must be one of: ${allowedSortingColumns.join(', ')}.`] } + } + + if (candidate.desc !== undefined && typeof candidate.desc !== 'boolean') { + return { sorting: undefined, errors: ['sorting.desc must be boolean when provided.'] } + } + + normalizedSorting.push({ id: candidate.id, desc: Boolean(candidate.desc) }) + } + + return { sorting: normalizedSorting, errors: [] as string[] } + } catch { + return { sorting: undefined, errors: ['sorting must be valid JSON.'] } + } +} + +const parseColumnFilters = (columnFiltersRaw: string | undefined, allowServerColumnFilters: boolean): string[] => { + if (!columnFiltersRaw) { + return [] + } + + try { + const parsed = JSON.parse(columnFiltersRaw) as unknown + if (!Array.isArray(parsed)) { + return ['columnfilters must be a JSON array.'] + } + + if (!allowServerColumnFilters && parsed.length > 0) { + return ['Server-side columnfilters are not supported for this endpoint. Use client-side filtering.'] + } + + return [] + } catch { + return ['columnfilters must be valid JSON.'] + } +} + +const parsePagination = (query: Request['query']): { skip?: number; take?: number; errors: string[] } => { + const errors: string[] = [] + + const limitRaw = getSingleQueryValue(query.limit) + const offsetRaw = getSingleQueryValue(query.offset) + + const parseWholeNumber = (value: string, fieldName: 'limit' | 'offset') => { + if (!/^\d+$/.test(value)) { + errors.push(`${fieldName} must be a non-negative integer.`) + return undefined + } + + const parsed = parseInt(value, 10) + if (fieldName === 'limit' && (parsed < 1 || parsed > 500)) { + errors.push('limit must be between 1 and 500.') + return undefined + } + + return parsed + } + + if (limitRaw || offsetRaw) { + const take = limitRaw ? parseWholeNumber(limitRaw, 'limit') : undefined + const skip = offsetRaw ? parseWholeNumber(offsetRaw, 'offset') : undefined + return { take, skip, errors } + } + + const paginationRaw = getSingleQueryValue(query.pagination) + if (!paginationRaw) { + return { errors } + } + + try { + const parsed = JSON.parse(paginationRaw) as unknown + if (typeof parsed !== 'object' || parsed === null) { + errors.push('pagination must be a JSON object.') + return { errors } + } + + const candidate = parsed as { pageIndex?: unknown; pageSize?: unknown } + if (!Number.isInteger(candidate.pageIndex) || (candidate.pageIndex as number) < 0) { + errors.push('pagination.pageIndex must be a non-negative integer.') + return { errors } + } + + if ( + !Number.isInteger(candidate.pageSize) || + (candidate.pageSize as number) < 1 || + (candidate.pageSize as number) > 500 + ) { + errors.push('pagination.pageSize must be an integer between 1 and 500.') + return { errors } + } + + return { + skip: (candidate.pageIndex as number) * (candidate.pageSize as number), + take: candidate.pageSize as number, + errors, + } + } catch { + errors.push('pagination must be valid JSON.') + return { errors } + } +} + +export const parseTabListQuery = ({ + query, + allowedSortingColumns, + defaultSorting, + allowServerColumnFilters = false, +}: ParseTabQueryParams): ParseTabQueryResult => { + const sortingRaw = getSingleQueryValue(query.sorting) + const columnFiltersRaw = getSingleQueryValue(query.columnfilters) ?? getSingleQueryValue(query.columnFilters) + + const sortingResult = parseSorting(sortingRaw, allowedSortingColumns) + const columnFilterErrors = parseColumnFilters(columnFiltersRaw, allowServerColumnFilters) + const paginationResult = parsePagination(query) + + const errors = [...sortingResult.errors, ...columnFilterErrors, ...paginationResult.errors] + if (errors.length > 0) { + return { ok: false, errors } + } + + return { + ok: true, + options: { + sorting: sortingResult.sorting ?? defaultSorting, + skip: paginationResult.skip, + take: paginationResult.take, + }, + } +} diff --git a/backend/src/services/timeBound.ts b/backend/src/services/timeBound.ts index 5b831d9c..5457ee7f 100644 --- a/backend/src/services/timeBound.ts +++ b/backend/src/services/timeBound.ts @@ -5,6 +5,7 @@ import Prisma from '../../prisma/generated/now_test_client' import { ValidationObject, referenceValidator } from '../../../frontend/src/shared/validators/validator' import { getReferenceDetails } from './reference' import { buildPersonLookupByInitials, getPersonDisplayName, getPersonFromLookup } from './utils/person' +import { TabListQueryOptions } from './tabularQuery' export const getAllTimeBounds = async () => { const result = await nowDb.now_tu_bound.findMany({ @@ -67,10 +68,15 @@ export const getTimeBoundDetails = async (id: number) => { return result } -export const getTimeBoundTimeUnits = async (id: number) => { +export const getTimeBoundTimeUnits = async (id: number, options?: TabListQueryOptions) => { // TODO: Check if user has access + const orderBy = options?.sorting.map(sort => ({ [sort.id]: sort.desc ? 'desc' : 'asc' })) + const result = await nowDb.now_time_unit.findMany({ where: { OR: [{ up_bnd: id }, { low_bnd: id }] }, + orderBy, + skip: options?.skip, + take: options?.take, }) return result } diff --git a/backend/src/services/timeUnit.ts b/backend/src/services/timeUnit.ts index feed36e6..98abc544 100644 --- a/backend/src/services/timeUnit.ts +++ b/backend/src/services/timeUnit.ts @@ -4,6 +4,7 @@ import { ValidationObject, referenceValidator } from '../../../frontend/src/shar import { validateTimeUnit } from '../../../frontend/src/shared/validators/timeUnit' import { getReferenceDetails } from './reference' import { buildPersonLookupByInitials, getPersonDisplayName, getPersonFromLookup } from './utils/person' +import { TabListQueryOptions } from './tabularQuery' export const getAllTimeUnits = async () => { const result = await nowDb.now_time_unit.findMany({ @@ -96,10 +97,17 @@ export const getTimeUnitDetails = async (id: string) => { return { ...rest, low_bound, up_bound } } -export const getTimeUnitLocalities = async (id: string) => { +export const getTimeUnitLocalities = async (id: string, options?: TabListQueryOptions) => { // TODO: Check if user has access + const orderBy = options?.sorting.map(sort => ({ + [sort.id]: sort.desc ? 'desc' : 'asc', + })) + const result = await nowDb.now_loc.findMany({ where: { OR: [{ bfa_max: id }, { bfa_min: id }] }, + orderBy, + skip: options?.skip, + take: options?.take, }) return result } From f3a2dc7c66fa20a4deca6262560a13769c30c37a Mon Sep 17 00:00:00 2001 From: Kari Lintulaakso Date: Tue, 17 Feb 2026 16:39:55 +0200 Subject: [PATCH 8/9] docs: add tab-table rollout checklist and read-only regression test --- documentation/CHANGELOG.md | 3 ++ documentation/frontend/tables.md | 39 +++++++++++++++++++ .../Tabs/__tests__/ProjectTab.test.tsx | 15 +++++++ 3 files changed, 57 insertions(+) diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 25910e96..14c02d5e 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -13,3 +13,6 @@ ### Added - Species table Genus and Species filters now also match synonym names (syn_genus_name and syn_species_name) returned with each species record. +- Added backend validation for tab-list query params (`sorting`, `pagination`, `limit/offset`, `columnfilters`) with structured `400` errors on invalid inputs for Reference, Time Unit, Time Bound, and Museum detail-tab endpoints. +- Added tab-list API integration coverage for invalid sorting/filter payloads and pagination behavior, plus read-only Project tab unit coverage that verifies edit-only controls stay hidden without edit rights. +- Added rollout documentation for unified `DetailTabTable` behavior, migration checklist, and known server/client filtering constraints. diff --git a/documentation/frontend/tables.md b/documentation/frontend/tables.md index 14bbfb58..88d43ad3 100644 --- a/documentation/frontend/tables.md +++ b/documentation/frontend/tables.md @@ -139,3 +139,42 @@ For every paginated table with a selector: | Buttons never disable | `isLoading` not forwarded | Pass `isFetching || isLoading` from the query result to the `` component. | Keep this guide updated as new pagination utilities or patterns land in the codebase. + + +## Unified DetailTabTable behavior (post-migration) + +Detail tabs migrated to `DetailTabTable` now share the same interaction baseline: + +- Column sorting via header click (ascending/descending). +- Shift-click multi-column sorting. +- Per-column filter popovers and quick search where column filter variants are enabled. +- Column visibility toggle menu. +- CSV export action for migrated tab tables. + +### Known exceptions and constraints + +- Endpoints listed in backend tab-list validation currently reject non-empty server-side `columnfilters` payloads; filtering remains client-side for those datasets. +- Edit-only mutation actions (select existing, add new, remove/re-add) are only rendered when detail mode and page edit rights allow mutation. +- Read-only viewers retain navigation and table interaction controls but do not see mutation controls. + +## Rollout checklist for tab migrations + +Use this checklist whenever migrating a remaining tab list/table to `DetailTabTable`: + +1. **Primitive migration** + - Replace direct `SimpleTable`/`TableView` usage with `DetailTabTable` in the target tab. + - Preserve row click navigation and return-stack behavior. +2. **Query semantics** + - Ensure endpoint supports validated `sorting` and pagination (`pagination` or `limit/offset`). + - If server-side column filters are unsupported, confirm client-side filtering remains enabled in the table. +3. **Permission parity** + - Verify read-only roles cannot access edit controls. + - Verify editors can still perform select/add/remove/re-add flows. +4. **Regression checks** + - Confirm URL-provided sorting overrides defaults. + - Confirm default species ordering applies when no explicit sorting is set. + - Confirm export reflects current sorted/filtered/visible table state where supported. +5. **QA gates** + - Run lint/typecheck at root. + - Run frontend unit tests for touched tabs. + - Run selected Cypress smoke coverage for navigation and read vs edit visibility. diff --git a/frontend/src/components/Locality/Tabs/__tests__/ProjectTab.test.tsx b/frontend/src/components/Locality/Tabs/__tests__/ProjectTab.test.tsx index 60dab15a..c522089b 100644 --- a/frontend/src/components/Locality/Tabs/__tests__/ProjectTab.test.tsx +++ b/frontend/src/components/Locality/Tabs/__tests__/ProjectTab.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals' import { fireEvent, render, screen } from '@testing-library/react' +import { skipToken } from '@reduxjs/toolkit/query' import { ProjectTab } from '../ProjectTab' import { useDetailContext, modeOptionToMode } from '@/components/DetailView/Context/DetailContext' import { useGetAllProjectsQuery } from '@/redux/projectReducer' @@ -71,6 +72,20 @@ describe('ProjectTab selection regression', () => { expect(screen.getByTestId('editable-table').getAttribute('data-advanced')).toBe('true') }) + it('hides edit-only project selector for read-only users', () => { + mockUsePageContext.mockReturnValue({ editRights: { edit: false, new: false } } as never) + mockUseDetailContext.mockReturnValue({ + mode: modeOptionToMode.read, + editData: { lid: 1, now_plr: [] }, + setEditData, + } as never) + + render() + + expect(screen.queryByRole('button', { name: /select project/i })).toBeNull() + expect(mockUseGetAllProjectsQuery).toHaveBeenCalledWith(skipToken) + }) + it('shows duplicate warning when selected project already exists', () => { mockUseDetailContext.mockReturnValue({ mode: modeOptionToMode.edit, From 4802c4f6583c71d0cd767043e8176fd2c1e05c4e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:12:05 +0000 Subject: [PATCH 9/9] Initial plan