Add locality relation entry history

This commit is contained in:
karilint
2026-06-03 17:26:55 +03:00
parent 69387fad37
commit 146aeeb5d7
9 changed files with 402 additions and 33 deletions
@@ -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<T> }) => ReactNode
kmlExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
svgExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
}) => {
@@ -134,13 +136,26 @@ export const EditableTable = <
return (
<Tooltip placement="top" title="This item has restricted visibility">
<PolicyIcon aria-label="Restricted visibility indicator" color="primary" fontSize="medium" />
<Box component="span" onClick={event => event.stopPropagation()} sx={{ display: 'inline-flex' }}>
<PolicyIcon aria-label="Restricted visibility indicator" color="primary" fontSize="medium" />
</Box>
</Tooltip>
)
}
const readRowActions = ({ row }: { row: MRT_Row<T> }) => (
<Box className="row-actions-column">
{renderReadRowActions?.({ row })}
{restrictionIndicator({ row })}
</Box>
)
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<T> }) => ({
'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 (
<DetailTabTable<T>
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<T> }) => ({
'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}
/>
)
}
@@ -14,6 +14,15 @@ type FieldUpdate = {
container: UpdateContainer
}
type EntryUpdateHistoryProps<TRow> = {
row: TRow
label: string
tableName: string
columnName?: string
getRowValue: (row: TRow) => unknown
getPkValues?: (row: TRow) => unknown[]
}
const SafeDetailContext = DetailContext as unknown as Context<DetailContextType<Record<string, unknown>> | null>
const isObject = (value: unknown): value is Record<string, unknown> =>
@@ -22,6 +31,8 @@ const isObject = (value: unknown): value is Record<string, unknown> =>
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<HTMLElement>) => void
}) => {
const [anchorElement, setAnchorElement] = useState<HTMLElement | null>(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<HTMLElement>) => {
onOpen?.(event)
setAnchorElement(event.currentTarget)
}
@@ -121,9 +213,9 @@ export const FieldUpdateHistory = ({ field, label }: { field: string; label: str
return (
<>
<Tooltip title={`Show update history for ${label}`}>
<Tooltip title={tooltip}>
<IconButton
aria-label={`Show update history for ${label}`}
aria-label={ariaLabel}
aria-describedby={id}
size="small"
onClick={handleOpen}
@@ -142,12 +234,12 @@ export const FieldUpdateHistory = ({ field, label }: { field: string; label: str
>
<Box p={2} maxWidth={560} display="flex" flexDirection="column" gap={1.5}>
<Typography variant="subtitle1" component="h2">
{label} update history
{title}
</Typography>
{updates.map(({ log, container }, index) => {
const references = collectReferences(container)
return (
<Card key={`${index}-${log.log_id ?? ''}-${log.column_name ?? field}`} sx={{ p: 1.5 }}>
<Card key={`${index}-${log.log_id ?? ''}-${log.column_name ?? idPrefix}`} sx={{ p: 1.5 }}>
<Typography variant="body2">
<b>Date:</b> {formatDate(getDate(container))}
</Typography>
@@ -160,6 +252,9 @@ export const FieldUpdateHistory = ({ field, label }: { field: string; label: str
<Typography variant="body2">
<b>Action:</b> {formatAction(log.log_action)}
</Typography>
<Typography variant="body2">
<b>Table:</b> {formatValue(log.table_name)}
</Typography>
<Typography variant="body2">
<b>Before:</b> {log.old_data ?? ''}
</Typography>
@@ -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 (
<UpdateHistoryPopover
idPrefix={field}
title={`${label} update history`}
tooltip={`Show update history for ${label}`}
ariaLabel={`Show update history for ${label}`}
updates={updates}
/>
)
}
export const EntryUpdateHistory = <TRow,>({
row,
label,
tableName,
columnName,
getRowValue,
getPkValues,
}: EntryUpdateHistoryProps<TRow>) => {
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<HTMLElement>) => {
event.stopPropagation()
}
return (
<UpdateHistoryPopover
idPrefix={[tableName, columnName, rowValue].filter(Boolean).map(toSafeDomIdPart).join('-')}
title={`${label} entry history`}
tooltip={`Show entry history for ${label}`}
ariaLabel={`Show entry history for ${label}`}
updates={updates}
onOpen={handleOpen}
/>
)
}
@@ -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<LocalityDetailsType>()
@@ -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?
/>
)}
<EditableTable<Editable<SedimentaryStructure>, LocalityDetailsType> columns={columns} field="now_ss" />
<EditableTable<Editable<SedimentaryStructure>, LocalityDetailsType>
columns={columns}
field="now_ss"
renderReadRowActions={({ row }) => (
<EntryUpdateHistory
row={row.original}
label={`sedimentary structure ${row.original.sed_struct ?? ''}`.trim()}
tableName="now_ss"
columnName="sed_struct"
getRowValue={sedimentaryStructure => sedimentaryStructure.sed_struct}
getPkValues={sedimentaryStructure => [sedimentaryStructure.lid, sedimentaryStructure.sed_struct]}
/>
)}
/>
</Grouped>
<ArrayFrame half array={depositionalContext} title="Depositional Context" />
</HalfFrames>
@@ -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 = () => {
<Grouped title="Synonyms">
{!mode.read && editingModal}
<EditableTable<Editable<LocalitySynonym>, LocalityDetailsType> columns={columns} field="now_syn_loc" />
<EditableTable<Editable<LocalitySynonym>, LocalityDetailsType>
columns={columns}
field="now_syn_loc"
renderReadRowActions={({ row }) => (
<EntryUpdateHistory
row={row.original}
label={`locality synonym ${row.original.synonym ?? ''}`.trim()}
tableName="now_syn_loc"
getRowValue={synonym => synonym.syn_id}
getPkValues={synonym => [synonym.lid, synonym.syn_id]}
/>
)}
/>
</Grouped>
</>
)
@@ -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<LocalityDetailsType>()
@@ -242,6 +245,15 @@ export const MuseumTab = () => {
enableAdvancedTableControls={true}
idFieldName="museum"
url="museum"
renderReadRowActions={({ row }) => (
<EntryUpdateHistory
row={row.original}
label={`museum ${row.original.museum ?? ''}`.trim()}
tableName="now_mus"
getRowValue={museum => museum.museum}
getPkValues={museum => [getRelationLid(museum), museum.museum]}
/>
)}
/>
</Grouped>
)
@@ -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<LocalityDetailsType>()
@@ -89,6 +92,15 @@ export const ProjectTab = () => {
enableAdvancedTableControls={true}
idFieldName="pid"
url="project"
renderReadRowActions={({ row }) => (
<EntryUpdateHistory
row={row.original}
label={`project ${row.original.pid ?? ''}`.trim()}
tableName="now_plr"
getRowValue={project => project.pid}
getPkValues={project => [getRelationLid(project), project.pid]}
/>
)}
/>
</Grouped>
)
@@ -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<LocalityDetailsType>()
@@ -165,7 +166,19 @@ export const TaphonomyTab = () => {
selectedValues={editData.now_coll_meth.map(method => method.coll_meth ?? '')}
/>
)}
<EditableTable<Editable<CollectingMethod>, LocalityDetailsType> columns={columns} field="now_coll_meth" />
<EditableTable<Editable<CollectingMethod>, LocalityDetailsType>
columns={columns}
field="now_coll_meth"
renderReadRowActions={({ row }) => (
<EntryUpdateHistory
row={row.original}
label={`collecting method ${row.original.coll_meth ?? ''}`.trim()}
tableName="now_coll_meth"
getRowValue={collectingMethod => collectingMethod.coll_meth}
getPkValues={collectingMethod => [collectingMethod.lid, collectingMethod.coll_meth]}
/>
)}
/>
</Grouped>
<></>
</HalfFrames>
@@ -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<SpeciesDetailsType>()
@@ -113,7 +114,21 @@ export const SynonymTab = () => {
<>
<Grouped title="Synonyms">
{!mode.read && editingForm}
<EditableTable<Editable<SpeciesSynonym>, SpeciesDetailsType> columns={columns} field="com_taxa_synonym" />
<EditableTable<Editable<SpeciesSynonym>, SpeciesDetailsType>
columns={columns}
field="com_taxa_synonym"
renderReadRowActions={({ row }) => (
<EntryUpdateHistory
row={row.original}
label={`species synonym ${row.original.syn_genus_name ?? ''} ${
row.original.syn_species_name ?? ''
}`.trim()}
tableName="com_taxa_synonym"
getRowValue={synonym => synonym.synonym_id}
getPkValues={synonym => [synonym.synonym_id, synonym.species_id]}
/>
)}
/>
</Grouped>
</>
)
@@ -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 }) => <span>field value</span>
@@ -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(
<MemoryRouter>
<DetailContext.Provider value={contextValue}>
<EntryUpdateHistory
row={{ lid: 100, sed_struct: 'cross-bedding' }}
label="sedimentary structure cross-bedding"
tableName="now_ss"
getRowValue={row => row.sed_struct}
getPkValues={row => [row.lid, row.sed_struct]}
/>
</DetailContext.Provider>
</MemoryRouter>
)
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(
<MemoryRouter>
<DetailContext.Provider value={contextValue}>
<EntryUpdateHistory
row={{ lid: 100, sed_struct: 'cross bedding/layer' }}
label="sedimentary structure cross bedding/layer"
tableName="now_ss"
getRowValue={row => row.sed_struct}
getPkValues={row => [row.lid, row.sed_struct]}
/>
</DetailContext.Provider>
</MemoryRouter>
)
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(
<MemoryRouter>
<DetailContext.Provider value={contextValue}>
<div role="button" tabIndex={0} onClick={handleRowClick} onKeyDown={() => undefined}>
<EntryUpdateHistory
row={{ lid: 100, sed_struct: 'cross-bedding' }}
label="sedimentary structure cross-bedding"
tableName="now_ss"
getRowValue={row => row.sed_struct}
getPkValues={row => [row.lid, row.sed_struct]}
/>
</div>
</DetailContext.Provider>
</MemoryRouter>
)
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)