Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions frontend/src/components/DetailView/common/FieldUpdateHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import HistoryIcon from '@mui/icons-material/History'
import { Box, Card, Divider, IconButton, Popover, Tooltip, Typography } from '@mui/material'
import { useContext, useMemo, useState, type Context, type MouseEvent } from 'react'
import { DetailContext, type DetailContextType } from '@/components/DetailView/Context/DetailContext'
import type { AnyReference, UpdateLog } from '@/shared/types'
import { ReferenceList } from './ReferenceList'

type UpdateContainer = Record<string, unknown> & {
updates: UpdateLog[]
}

type FieldUpdate = {
log: UpdateLog
container: UpdateContainer
}

const SafeDetailContext = DetailContext as unknown as Context<DetailContextType<Record<string, unknown>> | null>

const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)

const isUpdateLog = (value: unknown): value is UpdateLog =>
isObject(value) && 'column_name' in value && 'log_action' in value

const isUpdateContainer = (value: unknown): value is UpdateContainer =>
isObject(value) && Array.isArray(value.updates) && value.updates.every(isUpdateLog)

const collectUpdateContainers = (value: unknown, seen = new Set<unknown>()): UpdateContainer[] => {
if (!isObject(value) && !Array.isArray(value)) return []
if (seen.has(value)) return []
seen.add(value)

const containers: UpdateContainer[] = []
if (isUpdateContainer(value)) containers.push(value)

const nestedValues = Array.isArray(value) ? value : Object.values(value)
nestedValues.forEach(nestedValue => {
containers.push(...collectUpdateContainers(nestedValue, seen))
})

return containers
}

const getFieldUpdates = (data: unknown, field: string): FieldUpdate[] =>
collectUpdateContainers(data).flatMap(container =>
container.updates
.filter(log => log.column_name === field)
.map(log => ({
log,
container,
}))
)

const formatValue = (value: unknown): string => {
if (value === null || value === undefined) return ''
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
if (value instanceof Date) return value.toISOString()
return JSON.stringify(value)
}

const formatDate = (value: unknown): string => {
if (!value) return 'No date'
const date = new Date(value as string | number | Date)
if (Number.isNaN(date.getTime())) return formatValue(value)
return date.toISOString().split('T')[0]
}

const findFirstBySuffix = (container: UpdateContainer, suffix: string): unknown => {
const entry = Object.entries(container).find(([key]) => key.endsWith(suffix))
return entry?.[1]
}

const getDate = (container: UpdateContainer) => container.date ?? findFirstBySuffix(container, '_date')
const getEditor = (container: UpdateContainer) => container.authorizer ?? findFirstBySuffix(container, '_authorizer')
const getCoordinator = (container: UpdateContainer) =>
container.coordinator ?? findFirstBySuffix(container, '_coordinator')
const getComment = (container: UpdateContainer) => container.comment ?? findFirstBySuffix(container, '_comment')

const isAnyReference = (value: unknown): value is AnyReference =>
isObject(value) && typeof value.rid === 'number' && isObject(value.ref_ref)

const collectReferences = (value: unknown, seen = new Set<unknown>()): AnyReference[] => {
if (!isObject(value) && !Array.isArray(value)) return []
if (seen.has(value)) return []
seen.add(value)

if (Array.isArray(value)) {
if (value.every(isAnyReference)) return value
return value.flatMap(item => collectReferences(item, seen))
}

return Object.entries(value)
.filter(([key]) => key !== 'updates')
.flatMap(([, nestedValue]) => collectReferences(nestedValue, seen))
}

const formatAction = (action: number | null | undefined) => {
if (action === 1) return 'Delete'
if (action === 3) return 'Update'
return 'Add'
}

export const FieldUpdateHistory = ({ field, label }: { field: string; label: string }) => {
const detailContext = useContext(SafeDetailContext)
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 handleOpen = (event: MouseEvent<HTMLElement>) => {
setAnchorElement(event.currentTarget)
}

const handleClose = () => {
setAnchorElement(null)
}

return (
<>
<Tooltip title={`Show update history for ${label}`}>
<IconButton
aria-label={`Show update history for ${label}`}
aria-describedby={id}
size="small"
onClick={handleOpen}
sx={{ ml: 0.5, p: 0.25, color: 'text.secondary', flexShrink: 0 }}
>
<HistoryIcon fontSize="inherit" />
</IconButton>
</Tooltip>
<Popover
id={id}
open={open}
anchorEl={anchorElement}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
>
<Box p={2} maxWidth={560} display="flex" flexDirection="column" gap={1.5}>
<Typography variant="subtitle1" component="h2">
{label} update history
</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 }}>
<Typography variant="body2">
<b>Date:</b> {formatDate(getDate(container))}
</Typography>
<Typography variant="body2">
<b>Editor:</b> {formatValue(getEditor(container))}
</Typography>
<Typography variant="body2">
<b>Coordinator:</b> {formatValue(getCoordinator(container))}
</Typography>
<Typography variant="body2">
<b>Action:</b> {formatAction(log.log_action)}
</Typography>
<Typography variant="body2">
<b>Before:</b> {log.old_data ?? ''}
</Typography>
<Typography variant="body2">
<b>After:</b> {log.new_data ?? ''}
</Typography>
{getComment(container) ? (
<Typography variant="body2">
<b>Comment:</b> {formatValue(getComment(container))}
</Typography>
) : null}
<Divider sx={{ my: 1 }} />
{references.length === 0 ? (
<Typography variant="body2">No references.</Typography>
) : (
<ReferenceList references={references} big={false} />
)}
</Card>
)
})}
</Box>
</Popover>
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { isValidElement, ReactNode } from 'react'
import { useDetailContext } from '../Context/DetailContext'
import { EditDataType } from '@/shared/types'
import { getFieldInfoText } from '@/shared/fieldInfo'
import { FieldUpdateHistory } from './FieldUpdateHistory'

const getFieldFromNode = (node: ReactNode): string | undefined => {
if (!isValidElement(node)) return undefined
Expand All @@ -31,6 +32,7 @@ const FieldLabel = ({ label, field }: { label: string; field?: string }) => {
</IconButton>
</Tooltip>
)}
{field && <FieldUpdateHistory field={field} label={label} />}
</Box>
)
}
Expand Down
142 changes: 142 additions & 0 deletions frontend/src/tests/components/DetailView/FieldUpdateHistory.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
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'

const FieldElement = (_props: { field: string }) => <span>field value</span>

const reference = {
rid: 10,
ref_ref: {
rid: 10,
title_primary: 'Field update reference',
date_primary: 2020,
ref_authors: [],
ref_journal: { journal_title: 'Journal' },
},
}

const detailData = {
body_mass: 10,
now_sau: [
{
sau_date: '2026-05-29',
sau_authorizer: 'ED',
sau_coordinator: 'CO',
sau_comment: 'Updated body mass',
now_sr: [reference],
updates: [
{
log_id: 1,
column_name: 'body_mass',
log_action: 3,
old_data: '10',
new_data: '12',
},
],
},
],
}

const contextValue = {
data: detailData,
mode: { read: true, staging: false, new: false, option: 'read' },
} as unknown as ContextType<typeof DetailContext>

describe('FieldUpdateHistory', () => {
beforeEach(() => {
jest.restoreAllMocks()
})

it('shows field-specific update history from detail data', async () => {
const user = userEvent.setup()

render(
<MemoryRouter>
<DetailContext.Provider value={contextValue}>
<ArrayToTable array={[['Body mass', <FieldElement key="body_mass" field="body_mass" />]]} />
</DetailContext.Provider>
</MemoryRouter>
)

await user.click(screen.getByLabelText('Show update history for Body mass'))

const popover = screen.getByRole('heading', { name: 'Body mass update history' }).closest('div')
expect(popover).toBeTruthy()
expect(screen.getByText('Updated body mass')).toBeInTheDocument()
expect(screen.getByText(/Field update reference/)).toBeInTheDocument()
const afterRow = screen.getByText('After:').closest('p')
expect(afterRow).toBeTruthy()
expect(within(afterRow as HTMLElement).getByText('12')).toBeInTheDocument()
})

it('does not show the history icon without matching field updates', () => {
render(
<MemoryRouter>
<DetailContext.Provider value={contextValue}>
<ArrayToTable array={[['Diet', <FieldElement key="diet1" field="diet1" />]]} />
</DetailContext.Provider>
</MemoryRouter>
)

expect(screen.queryByLabelText('Show update history for Diet')).not.toBeInTheDocument()
})

it('does not show the history icon in edit mode', () => {
render(
<MemoryRouter>
<DetailContext.Provider
value={{ ...contextValue, mode: { read: false, staging: false, new: false, option: 'edit' } }}
>
<ArrayToTable array={[['Body mass', <FieldElement key="body_mass" field="body_mass" />]]} />
</DetailContext.Provider>
</MemoryRouter>
)

expect(screen.queryByLabelText('Show update history for Body mass')).not.toBeInTheDocument()
})

it('renders multiple same-field updates without duplicate key warnings', async () => {
const user = userEvent.setup()
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
const duplicateLogContext = {
...contextValue,
data: {
...detailData,
now_sau: [
detailData.now_sau[0],
{
...detailData.now_sau[0],
sau_date: '2026-05-30',
sau_comment: 'Updated body mass again',
updates: [
{
log_id: 1,
column_name: 'body_mass',
log_action: 3,
old_data: '12',
new_data: '14',
},
],
},
],
},
} as unknown as ContextType<typeof DetailContext>

render(
<MemoryRouter>
<DetailContext.Provider value={duplicateLogContext}>
<ArrayToTable array={[['Body mass', <FieldElement key="body_mass" field="body_mass" />]]} />
</DetailContext.Provider>
</MemoryRouter>
)

await user.click(screen.getByLabelText('Show update history for Body mass'))

expect(screen.getByText('Updated body mass')).toBeInTheDocument()
expect(screen.getByText('Updated body mass again')).toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalledWith(expect.stringContaining('Encountered two children with the same key'))
})
})
1 change: 1 addition & 0 deletions frontend/src/tests/components/SpeciesTaxonomyTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { taxonStatusOptions } from '@/shared/taxonStatusOptions'
import { useGetAllSpeciesQuery } from '@/redux/speciesReducer'

jest.mock('@/components/DetailView/Context/DetailContext', () => ({
DetailContext: jest.requireActual<typeof import('react')>('react').createContext(null),
useDetailContext: jest.fn(),
modeOptionToMode: {
new: { read: false, staging: false, new: true, option: 'new' },
Expand Down
Loading