From 146aeeb5d767ed43387a650b7b5d621536be7760 Mon Sep 17 00:00:00 2001 From: karilint Date: Wed, 3 Jun 2026 15:26:45 +0300 Subject: [PATCH] Add locality relation entry history --- .../DetailView/common/EditableTable.tsx | 55 ++++-- .../DetailView/common/FieldUpdateHistory.tsx | 166 ++++++++++++++++-- .../components/Locality/Tabs/LithologyTab.tsx | 16 +- .../components/Locality/Tabs/LocalityTab.tsx | 15 +- .../components/Locality/Tabs/MuseumTab.tsx | 12 ++ .../components/Locality/Tabs/ProjectTab.tsx | 12 ++ .../components/Locality/Tabs/TaphonomyTab.tsx | 15 +- .../components/Species/Tabs/SynonymTab.tsx | 17 +- .../DetailView/FieldUpdateHistory.test.tsx | 127 ++++++++++++++ 9 files changed, 402 insertions(+), 33 deletions(-) diff --git a/frontend/src/components/DetailView/common/EditableTable.tsx b/frontend/src/components/DetailView/common/EditableTable.tsx index c17fc0faa..02031b60f 100755 --- a/frontend/src/components/DetailView/common/EditableTable.tsx +++ b/frontend/src/components/DetailView/common/EditableTable.tsx @@ -5,7 +5,7 @@ import { useDetailContext } from '../Context/DetailContext' import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline' import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline' import PolicyIcon from '@mui/icons-material/Policy' -import { useEffect } from 'react' +import { useEffect, type ReactNode } from 'react' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { usePageContext } from '@/components/Page' import { checkFieldErrors } from './checkFieldErrors' @@ -34,6 +34,7 @@ export const EditableTable = < url, getDetailPath, checkRowRestriction, + renderReadRowActions, kmlExport, svgExport, }: { @@ -49,6 +50,7 @@ export const EditableTable = < url?: string getDetailPath?: (row: T) => string checkRowRestriction?: (row: T) => boolean + renderReadRowActions?: (props: { row: MRT_Row }) => ReactNode kmlExport?: (table: MRT_TableInstance) => void | Promise svgExport?: (table: MRT_TableInstance) => void | Promise }) => { @@ -134,13 +136,26 @@ export const EditableTable = < return ( - + event.stopPropagation()} sx={{ display: 'inline-flex' }}> + + ) } + const readRowActions = ({ row }: { row: MRT_Row }) => ( + + {renderReadRowActions?.({ row })} + {restrictionIndicator({ row })} + + ) + const resolveRenderRowActions = () => { - if (mode.read) return checkRowRestriction ? restrictionIndicator : undefined + if (mode.read) { + if (!checkRowRestriction && !renderReadRowActions) return undefined + + return readRowActions + } return actionRow } @@ -166,6 +181,22 @@ export const EditableTable = < return tableData } + const muiTableBodyRowProps = ({ row }: { row: MRT_Row }) => ({ + 'data-cy': idFieldName ? `table-row-${String(row.original[idFieldName])}` : undefined, + onClick: () => { + if (mode.read && idFieldName && url) { + setPreviousTableUrls([...previousTableUrls, `${location.pathname}?tab=${searchParams.get('tab')}`]) + navigate(resolveDetailPath(row.original), { + state: { returnTo: `${location.pathname}${location.search}` }, + }) + } + }, + sx: { + backgroundColor: rowStateToColor(row.original.rowState), + cursor: mode.read && idFieldName && url ? 'pointer' : undefined, + }, + }) + return ( mode="edit" @@ -174,25 +205,11 @@ export const EditableTable = < enableTopToolbar={enableAdvancedTableControls} enableColumnActions={enableAdvancedTableControls} enableSorting={enableAdvancedTableControls} - enableRowActions={!mode.read || Boolean(checkRowRestriction)} + enableRowActions={!mode.read || Boolean(checkRowRestriction) || Boolean(renderReadRowActions)} renderRowActions={resolveRenderRowActions()} kmlExport={kmlExport} svgExport={svgExport} - muiTableBodyRowProps={({ row }: { row: MRT_Row }) => ({ - 'data-cy': idFieldName ? `table-row-${String(row.original[idFieldName])}` : undefined, - onClick: () => { - if (mode.read && idFieldName && url) { - setPreviousTableUrls([...previousTableUrls, `${location.pathname}?tab=${searchParams.get('tab')}`]) - navigate(resolveDetailPath(row.original), { - state: { returnTo: `${location.pathname}${location.search}` }, - }) - } - }, - sx: { - backgroundColor: rowStateToColor(row.original.rowState), - cursor: mode.read && idFieldName && url ? 'pointer' : undefined, - }, - })} + muiTableBodyRowProps={muiTableBodyRowProps} /> ) } diff --git a/frontend/src/components/DetailView/common/FieldUpdateHistory.tsx b/frontend/src/components/DetailView/common/FieldUpdateHistory.tsx index 838e27687..ce55cc1bf 100644 --- a/frontend/src/components/DetailView/common/FieldUpdateHistory.tsx +++ b/frontend/src/components/DetailView/common/FieldUpdateHistory.tsx @@ -14,6 +14,15 @@ type FieldUpdate = { container: UpdateContainer } +type EntryUpdateHistoryProps = { + row: TRow + label: string + tableName: string + columnName?: string + getRowValue: (row: TRow) => unknown + getPkValues?: (row: TRow) => unknown[] +} + const SafeDetailContext = DetailContext as unknown as Context> | null> const isObject = (value: unknown): value is Record => @@ -22,6 +31,8 @@ const isObject = (value: unknown): value is Record => const isUpdateLog = (value: unknown): value is UpdateLog => isObject(value) && 'column_name' in value && 'log_action' in value +const getPkData = (log: UpdateLog & { pk_data?: unknown }) => log.pk_data + const isUpdateContainer = (value: unknown): value is UpdateContainer => isObject(value) && Array.isArray(value.updates) && value.updates.every(isUpdateLog) @@ -51,6 +62,76 @@ const getFieldUpdates = (data: unknown, field: string): FieldUpdate[] => })) ) +const stringifyComparableValue = (value: unknown): string | undefined => { + if (value === null || value === undefined || value === '') return undefined + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value) + if (value instanceof Date) return value.toISOString() + return undefined +} + +const getEncodedPkSegment = (value: unknown) => { + const text = stringifyComparableValue(value) + if (!text) return undefined + return `${text.length}.${text};` +} + +const isString = (value: unknown): value is string => typeof value === 'string' + +const getEncodedPkSegments = (values: unknown[]) => values.map(getEncodedPkSegment).filter(isString) + +const toSafeDomIdPart = (value: unknown): string => { + const text = stringifyComparableValue(value) ?? formatValue(value) + const sanitized = text.trim().replace(/[^A-Za-z0-9_-]+/g, '-') + return sanitized.replace(/^-+|-+$/g, '') || 'entry' +} + +const valuesMatch = (left: unknown, right: unknown) => { + const leftText = stringifyComparableValue(left) + const rightText = stringifyComparableValue(right) + return Boolean(leftText && rightText && leftText === rightText) +} + +const getEntryUpdates = ( + data: unknown, + tableName: string, + columnName: string | undefined, + rowValue: unknown, + pkValues: unknown[] +): FieldUpdate[] => { + const encodedPkSegments = getEncodedPkSegments(pkValues) + const fallbackPkSegment = getEncodedPkSegment(rowValue) + + return collectUpdateContainers(data).flatMap(container => + container.updates + .filter(log => { + if (log.table_name !== tableName) return false + + const pkData = getPkData(log) + const pkDataMatches = + typeof pkData === 'string' && encodedPkSegments.length > 0 + ? encodedPkSegments.every(segment => pkData.includes(segment)) + : false + if (pkDataMatches) return true + if (typeof pkData === 'string' && encodedPkSegments.length > 0) return false + + const fallbackPkDataMatches = + typeof pkData === 'string' && encodedPkSegments.length === 0 && fallbackPkSegment + ? pkData.includes(fallbackPkSegment) + : false + if (fallbackPkDataMatches) return true + + if (columnName && log.column_name !== columnName) return false + + return valuesMatch(log.new_data, rowValue) || valuesMatch(log.old_data, rowValue) + }) + .map(log => ({ + log, + container, + })) + ) +} + const formatValue = (value: unknown): string => { if (value === null || value === undefined) return '' if (typeof value === 'string') return value @@ -101,17 +182,28 @@ const formatAction = (action: number | null | undefined) => { return 'Add' } -export const FieldUpdateHistory = ({ field, label }: { field: string; label: string }) => { - const detailContext = useContext(SafeDetailContext) +const UpdateHistoryPopover = ({ + idPrefix, + title, + tooltip, + ariaLabel, + updates, + onOpen, +}: { + idPrefix: string + title: string + tooltip: string + ariaLabel: string + updates: FieldUpdate[] + onOpen?: (event: MouseEvent) => void +}) => { const [anchorElement, setAnchorElement] = useState(null) - const updates = useMemo(() => getFieldUpdates(detailContext?.data, field), [detailContext?.data, field]) - - if (!detailContext?.mode.read || updates.length === 0) return null const open = Boolean(anchorElement) - const id = open ? `${field}-update-history-popover` : undefined + const id = open ? `${idPrefix}-update-history-popover` : undefined const handleOpen = (event: MouseEvent) => { + onOpen?.(event) setAnchorElement(event.currentTarget) } @@ -121,9 +213,9 @@ export const FieldUpdateHistory = ({ field, label }: { field: string; label: str return ( <> - + - {label} update history + {title} {updates.map(({ log, container }, index) => { const references = collectReferences(container) return ( - + Date: {formatDate(getDate(container))} @@ -160,6 +252,9 @@ export const FieldUpdateHistory = ({ field, label }: { field: string; label: str Action: {formatAction(log.log_action)} + + Table: {formatValue(log.table_name)} + Before: {log.old_data ?? ''} @@ -185,3 +280,54 @@ export const FieldUpdateHistory = ({ field, label }: { field: string; label: str ) } + +export const FieldUpdateHistory = ({ field, label }: { field: string; label: string }) => { + const detailContext = useContext(SafeDetailContext) + const updates = useMemo(() => getFieldUpdates(detailContext?.data, field), [detailContext?.data, field]) + + if (!detailContext?.mode.read || updates.length === 0) return null + + return ( + + ) +} + +export const EntryUpdateHistory = ({ + row, + label, + tableName, + columnName, + getRowValue, + getPkValues, +}: EntryUpdateHistoryProps) => { + const detailContext = useContext(SafeDetailContext) + const rowValue = getRowValue(row) + const pkValues = useMemo(() => getPkValues?.(row) ?? [rowValue], [getPkValues, row, rowValue]) + const updates = useMemo( + () => getEntryUpdates(detailContext?.data, tableName, columnName, rowValue, pkValues), + [columnName, detailContext?.data, pkValues, rowValue, tableName] + ) + + if (!detailContext?.mode.read || updates.length === 0) return null + + const handleOpen = (event: MouseEvent) => { + event.stopPropagation() + } + + return ( + + ) +} diff --git a/frontend/src/components/Locality/Tabs/LithologyTab.tsx b/frontend/src/components/Locality/Tabs/LithologyTab.tsx index ed9a658de..96a5ee4e7 100755 --- a/frontend/src/components/Locality/Tabs/LithologyTab.tsx +++ b/frontend/src/components/Locality/Tabs/LithologyTab.tsx @@ -7,6 +7,7 @@ import { useDetailContext } from '@/components/DetailView/Context/DetailContext' import { useGetAllSedimentaryStructuresQuery } from '@/redux/sedimentaryStructureReducer' import { skipToken } from '@reduxjs/toolkit/query' import { MRT_ColumnDef } from 'material-react-table' +import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory' export const LithologyTab = () => { const { mode, textField, dropdown, editData, bigTextField } = useDetailContext() @@ -202,7 +203,20 @@ export const LithologyTab = () => { selectedValues={editData.now_ss.map(ss => ss.sed_struct ?? '')} // TODO ss.sed_struct may be null. is empty string ok default? /> )} - , LocalityDetailsType> columns={columns} field="now_ss" /> + , LocalityDetailsType> + columns={columns} + field="now_ss" + renderReadRowActions={({ row }) => ( + sedimentaryStructure.sed_struct} + getPkValues={sedimentaryStructure => [sedimentaryStructure.lid, sedimentaryStructure.sed_struct]} + /> + )} + /> diff --git a/frontend/src/components/Locality/Tabs/LocalityTab.tsx b/frontend/src/components/Locality/Tabs/LocalityTab.tsx index b9d11d088..8d878ea16 100755 --- a/frontend/src/components/Locality/Tabs/LocalityTab.tsx +++ b/frontend/src/components/Locality/Tabs/LocalityTab.tsx @@ -11,6 +11,7 @@ import { emptyOption } from '@/components/DetailView/common/misc' import { convertDmsToDec, convertDecToDms } from '@/util/coordinateConversion' import { validCountries } from '@/shared/validators/countryList' import { useNotify } from '@/hooks/notification' +import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory' const CoordinateSelectionMap = lazy(async () => { const module = await import('@/components/Map/CoordinateSelectionMap') @@ -274,7 +275,19 @@ export const LocalityTab = () => { {!mode.read && editingModal} - , LocalityDetailsType> columns={columns} field="now_syn_loc" /> + , LocalityDetailsType> + columns={columns} + field="now_syn_loc" + renderReadRowActions={({ row }) => ( + synonym.syn_id} + getPkValues={synonym => [synonym.lid, synonym.syn_id]} + /> + )} + /> ) diff --git a/frontend/src/components/Locality/Tabs/MuseumTab.tsx b/frontend/src/components/Locality/Tabs/MuseumTab.tsx index 91f8bf817..8dadd01d7 100755 --- a/frontend/src/components/Locality/Tabs/MuseumTab.tsx +++ b/frontend/src/components/Locality/Tabs/MuseumTab.tsx @@ -11,6 +11,9 @@ import { EditingModal } from '@/components/DetailView/common/EditingModal' import { useForm } from 'react-hook-form' import { useNotify } from '@/hooks/notification' import { validCountries } from '@/shared/validators/countryList' +import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory' + +const getRelationLid = (row: unknown) => (row as { lid?: unknown }).lid export const MuseumTab = () => { const { mode, editData, setEditData } = useDetailContext() @@ -242,6 +245,15 @@ export const MuseumTab = () => { enableAdvancedTableControls={true} idFieldName="museum" url="museum" + renderReadRowActions={({ row }) => ( + museum.museum} + getPkValues={museum => [getRelationLid(museum), museum.museum]} + /> + )} /> ) diff --git a/frontend/src/components/Locality/Tabs/ProjectTab.tsx b/frontend/src/components/Locality/Tabs/ProjectTab.tsx index 8c9de09cf..762b1d9e0 100755 --- a/frontend/src/components/Locality/Tabs/ProjectTab.tsx +++ b/frontend/src/components/Locality/Tabs/ProjectTab.tsx @@ -10,6 +10,9 @@ import { SelectingTable } from '@/components/DetailView/common/SelectingTable' import { useDetailContext } from '@/components/DetailView/Context/DetailContext' import { useGetAllProjectsQuery } from '@/redux/projectReducer' import { usePageContext } from '@/components/Page' +import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory' + +const getRelationLid = (row: unknown) => (row as { lid?: unknown }).lid export const ProjectTab = () => { const { mode, editData, setEditData } = useDetailContext() @@ -89,6 +92,15 @@ export const ProjectTab = () => { enableAdvancedTableControls={true} idFieldName="pid" url="project" + renderReadRowActions={({ row }) => ( + project.pid} + getPkValues={project => [getRelationLid(project), project.pid]} + /> + )} /> ) diff --git a/frontend/src/components/Locality/Tabs/TaphonomyTab.tsx b/frontend/src/components/Locality/Tabs/TaphonomyTab.tsx index 192dbed6b..98884a7af 100755 --- a/frontend/src/components/Locality/Tabs/TaphonomyTab.tsx +++ b/frontend/src/components/Locality/Tabs/TaphonomyTab.tsx @@ -7,6 +7,7 @@ import { emptyOption } from '@/components/DetailView/common/misc' import { LookupSelectingTable } from '@/components/shared/LookupSelectingTable' import { useGetAllCollectingMethodValuesQuery } from '@/redux/collectingMethodValuesReducer' import { skipToken } from '@reduxjs/toolkit/query' +import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory' export const TaphonomyTab = () => { const { textField, dropdown, mode, editData } = useDetailContext() @@ -165,7 +166,19 @@ export const TaphonomyTab = () => { selectedValues={editData.now_coll_meth.map(method => method.coll_meth ?? '')} /> )} - , LocalityDetailsType> columns={columns} field="now_coll_meth" /> + , LocalityDetailsType> + columns={columns} + field="now_coll_meth" + renderReadRowActions={({ row }) => ( + collectingMethod.coll_meth} + getPkValues={collectingMethod => [collectingMethod.lid, collectingMethod.coll_meth]} + /> + )} + /> <> diff --git a/frontend/src/components/Species/Tabs/SynonymTab.tsx b/frontend/src/components/Species/Tabs/SynonymTab.tsx index 8e8cd9efd..94b606161 100755 --- a/frontend/src/components/Species/Tabs/SynonymTab.tsx +++ b/frontend/src/components/Species/Tabs/SynonymTab.tsx @@ -10,6 +10,7 @@ import { convertSynonymTaxonomyFields } from '@/util/taxonomyUtilities' import { useGetAllSpeciesQuery } from '@/redux/speciesReducer' import { skipToken } from '@reduxjs/toolkit/query' import { CircularProgress } from '@mui/material' +import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory' export const SynonymTab = () => { const { mode, setEditData, editData } = useDetailContext() @@ -113,7 +114,21 @@ export const SynonymTab = () => { <> {!mode.read && editingForm} - , SpeciesDetailsType> columns={columns} field="com_taxa_synonym" /> + , SpeciesDetailsType> + columns={columns} + field="com_taxa_synonym" + renderReadRowActions={({ row }) => ( + synonym.synonym_id} + getPkValues={synonym => [synonym.synonym_id, synonym.species_id]} + /> + )} + /> ) diff --git a/frontend/src/tests/components/DetailView/FieldUpdateHistory.test.tsx b/frontend/src/tests/components/DetailView/FieldUpdateHistory.test.tsx index 1a0a5c227..4986c5a86 100644 --- a/frontend/src/tests/components/DetailView/FieldUpdateHistory.test.tsx +++ b/frontend/src/tests/components/DetailView/FieldUpdateHistory.test.tsx @@ -4,6 +4,7 @@ import type { ContextType } from 'react' import { MemoryRouter } from 'react-router-dom' import { ArrayToTable } from '@/components/DetailView/common/tabLayoutHelpers' import { DetailContext } from '@/components/DetailView/Context/DetailContext' +import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory' const FieldElement = (_props: { field: string }) => field value @@ -20,6 +21,54 @@ const reference = { const detailData = { body_mass: 10, + now_ss: [{ lid: 100, sed_struct: 'cross-bedding' }], + now_lau: [ + { + lau_date: '2026-06-03', + lau_authorizer: 'ED', + lau_coordinator: 'CO', + lau_comment: 'Added sedimentary structure', + now_lr: [reference], + updates: [ + { + log_id: 2, + table_name: 'now_ss', + pk_data: '3.100;13.cross-bedding;', + column_name: 'sed_struct', + log_action: 2, + old_data: null, + new_data: 'cross-bedding', + }, + { + log_id: 3, + table_name: 'now_ss', + pk_data: '3.100;13.cross-bedding;', + column_name: 'ss_comment', + log_action: 3, + old_data: 'old row comment', + new_data: 'new row comment', + }, + { + log_id: 4, + table_name: 'now_ss', + pk_data: '3.101;13.cross-bedding;', + column_name: 'sed_struct', + log_action: 2, + old_data: null, + new_data: 'cross-bedding', + }, + { + log_id: 5, + table_name: 'now_ss', + pk_data: '3.100;19.cross bedding/layer;', + column_name: 'sed_struct', + log_action: 2, + old_data: null, + new_data: 'cross bedding/layer', + }, + ], + }, + ], now_sau: [ { sau_date: '2026-05-29', @@ -98,6 +147,84 @@ describe('FieldUpdateHistory', () => { expect(screen.queryByLabelText('Show update history for Body mass')).not.toBeInTheDocument() }) + it('shows entry-specific update history for a matching relation row', async () => { + const user = userEvent.setup() + + render( + + + row.sed_struct} + getPkValues={row => [row.lid, row.sed_struct]} + /> + + + ) + + await user.click(screen.getByLabelText('Show entry history for sedimentary structure cross-bedding')) + + expect(screen.getByRole('heading', { name: 'sedimentary structure cross-bedding entry history' })).toBeTruthy() + expect(screen.getAllByText('Added sedimentary structure')).toHaveLength(2) + expect(screen.getAllByText(/Field update reference/)).toHaveLength(2) + const tableRow = screen.getAllByText('Table:')[0].closest('p') + expect(tableRow).toBeTruthy() + expect(within(tableRow as HTMLElement).getByText('now_ss')).toBeInTheDocument() + expect(screen.getByText('old row comment')).toBeInTheDocument() + expect(screen.getByText('new row comment')).toBeInTheDocument() + expect(screen.queryByText('101')).not.toBeInTheDocument() + }) + + it('uses a safe popover id for entry values with spaces or punctuation', async () => { + const user = userEvent.setup() + + render( + + + row.sed_struct} + getPkValues={row => [row.lid, row.sed_struct]} + /> + + + ) + + const button = screen.getByLabelText('Show entry history for sedimentary structure cross bedding/layer') + await user.click(button) + + expect(button).toHaveAttribute('aria-describedby', 'now_ss-cross-bedding-layer-update-history-popover') + }) + + it('does not bubble entry history clicks to the relation row', async () => { + const user = userEvent.setup() + const handleRowClick = jest.fn() + + render( + + +
undefined}> + row.sed_struct} + getPkValues={row => [row.lid, row.sed_struct]} + /> +
+
+
+ ) + + await user.click(screen.getByLabelText('Show entry history for sedimentary structure cross-bedding')) + + expect(handleRowClick).not.toHaveBeenCalled() + }) + it('renders multiple same-field updates without duplicate key warnings', async () => { const user = userEvent.setup() const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)