mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Merge pull request #1190 from nowcommunity/codex/1183-single-field-update-log-ui
Add field update history popover
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,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'))
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user