Add field update history popover

This commit is contained in:
karilint
2026-05-29 18:02:10 +03:00
parent f819ce6909
commit 3a6e14d3a4
3 changed files with 271 additions and 0 deletions
@@ -0,0 +1,189 @@
import HistoryIcon from '@mui/icons-material/History'
import { Box, Card, Divider, IconButton, Popover, Tooltip, Typography } from '@mui/material'
import { createContext, 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> | undefined) ??
createContext<DetailContextType<Record<string, unknown>> | null>(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 || 'old_data' in value || 'new_data' 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 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 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 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={`${log.log_id ?? index}-${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>
</>
)
}
@@ -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 }) => {
</IconButton>
</Tooltip>
)}
{field && <FieldUpdateHistory field={field} label={label} />}
</Box>
)
}
@@ -0,0 +1,80 @@
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 contextValue = {
data: {
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',
},
],
},
],
},
mode: { read: true, staging: false, new: false, option: 'read' },
} as unknown as ContextType<typeof DetailContext>
describe('FieldUpdateHistory', () => {
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()
})
})