diff --git a/frontend/src/components/DetailView/common/FieldUpdateHistory.tsx b/frontend/src/components/DetailView/common/FieldUpdateHistory.tsx new file mode 100644 index 00000000..838e2768 --- /dev/null +++ b/frontend/src/components/DetailView/common/FieldUpdateHistory.tsx @@ -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 & { + updates: UpdateLog[] +} + +type FieldUpdate = { + log: UpdateLog + container: UpdateContainer +} + +const SafeDetailContext = DetailContext as unknown as Context> | null> + +const isObject = (value: unknown): value is Record => + 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()): 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()): 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(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) => { + setAnchorElement(event.currentTarget) + } + + const handleClose = () => { + setAnchorElement(null) + } + + return ( + <> + + + + + + + + + {label} update history + + {updates.map(({ log, container }, index) => { + const references = collectReferences(container) + return ( + + + Date: {formatDate(getDate(container))} + + + Editor: {formatValue(getEditor(container))} + + + Coordinator: {formatValue(getCoordinator(container))} + + + Action: {formatAction(log.log_action)} + + + Before: {log.old_data ?? ''} + + + After: {log.new_data ?? ''} + + {getComment(container) ? ( + + Comment: {formatValue(getComment(container))} + + ) : null} + + {references.length === 0 ? ( + No references. + ) : ( + + )} + + ) + })} + + + + ) +} diff --git a/frontend/src/components/DetailView/common/tabLayoutHelpers.tsx b/frontend/src/components/DetailView/common/tabLayoutHelpers.tsx index f5f09de3..77884134 100755 --- a/frontend/src/components/DetailView/common/tabLayoutHelpers.tsx +++ b/frontend/src/components/DetailView/common/tabLayoutHelpers.tsx @@ -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 @@ -31,6 +32,7 @@ const FieldLabel = ({ label, field }: { label: string; field?: string }) => { )} + {field && } ) } diff --git a/frontend/src/tests/components/DetailView/FieldUpdateHistory.test.tsx b/frontend/src/tests/components/DetailView/FieldUpdateHistory.test.tsx new file mode 100644 index 00000000..1a0a5c22 --- /dev/null +++ b/frontend/src/tests/components/DetailView/FieldUpdateHistory.test.tsx @@ -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 }) => field value + +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 + +describe('FieldUpdateHistory', () => { + beforeEach(() => { + jest.restoreAllMocks() + }) + + it('shows field-specific update history from detail data', async () => { + const user = userEvent.setup() + + render( + + + ]]} /> + + + ) + + 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( + + + ]]} /> + + + ) + + expect(screen.queryByLabelText('Show update history for Diet')).not.toBeInTheDocument() + }) + + it('does not show the history icon in edit mode', () => { + render( + + + ]]} /> + + + ) + + 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 + + render( + + + ]]} /> + + + ) + + 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')) + }) +}) diff --git a/frontend/src/tests/components/SpeciesTaxonomyTab.test.tsx b/frontend/src/tests/components/SpeciesTaxonomyTab.test.tsx index 3bc0cb19..7ba9a004 100644 --- a/frontend/src/tests/components/SpeciesTaxonomyTab.test.tsx +++ b/frontend/src/tests/components/SpeciesTaxonomyTab.test.tsx @@ -11,6 +11,7 @@ import { taxonStatusOptions } from '@/shared/taxonStatusOptions' import { useGetAllSpeciesQuery } from '@/redux/speciesReducer' jest.mock('@/components/DetailView/Context/DetailContext', () => ({ + DetailContext: jest.requireActual('react').createContext(null), useDetailContext: jest.fn(), modeOptionToMode: { new: { read: false, staging: false, new: true, option: 'new' },