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 { useDetailContext } from '../Context/DetailContext'
|
||||||
import { EditDataType } from '@/shared/types'
|
import { EditDataType } from '@/shared/types'
|
||||||
import { getFieldInfoText } from '@/shared/fieldInfo'
|
import { getFieldInfoText } from '@/shared/fieldInfo'
|
||||||
|
import { FieldUpdateHistory } from './FieldUpdateHistory'
|
||||||
|
|
||||||
const getFieldFromNode = (node: ReactNode): string | undefined => {
|
const getFieldFromNode = (node: ReactNode): string | undefined => {
|
||||||
if (!isValidElement(node)) return undefined
|
if (!isValidElement(node)) return undefined
|
||||||
@@ -31,6 +32,7 @@ const FieldLabel = ({ label, field }: { label: string; field?: string }) => {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{field && <FieldUpdateHistory field={field} label={label} />}
|
||||||
</Box>
|
</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'
|
import { useGetAllSpeciesQuery } from '@/redux/speciesReducer'
|
||||||
|
|
||||||
jest.mock('@/components/DetailView/Context/DetailContext', () => ({
|
jest.mock('@/components/DetailView/Context/DetailContext', () => ({
|
||||||
|
DetailContext: jest.requireActual<typeof import('react')>('react').createContext(null),
|
||||||
useDetailContext: jest.fn(),
|
useDetailContext: jest.fn(),
|
||||||
modeOptionToMode: {
|
modeOptionToMode: {
|
||||||
new: { read: false, staging: false, new: true, option: 'new' },
|
new: { read: false, staging: false, new: true, option: 'new' },
|
||||||
|
|||||||
Reference in New Issue
Block a user