mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Add locality relation entry history
This commit is contained in:
@@ -5,7 +5,7 @@ import { useDetailContext } from '../Context/DetailContext'
|
||||
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'
|
||||
import PolicyIcon from '@mui/icons-material/Policy'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { usePageContext } from '@/components/Page'
|
||||
import { checkFieldErrors } from './checkFieldErrors'
|
||||
@@ -34,6 +34,7 @@ export const EditableTable = <
|
||||
url,
|
||||
getDetailPath,
|
||||
checkRowRestriction,
|
||||
renderReadRowActions,
|
||||
kmlExport,
|
||||
svgExport,
|
||||
}: {
|
||||
@@ -49,6 +50,7 @@ export const EditableTable = <
|
||||
url?: string
|
||||
getDetailPath?: (row: T) => string
|
||||
checkRowRestriction?: (row: T) => boolean
|
||||
renderReadRowActions?: (props: { row: MRT_Row<T> }) => ReactNode
|
||||
kmlExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
||||
svgExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
||||
}) => {
|
||||
@@ -134,13 +136,26 @@ export const EditableTable = <
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title="This item has restricted visibility">
|
||||
<PolicyIcon aria-label="Restricted visibility indicator" color="primary" fontSize="medium" />
|
||||
<Box component="span" onClick={event => event.stopPropagation()} sx={{ display: 'inline-flex' }}>
|
||||
<PolicyIcon aria-label="Restricted visibility indicator" color="primary" fontSize="medium" />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const readRowActions = ({ row }: { row: MRT_Row<T> }) => (
|
||||
<Box className="row-actions-column">
|
||||
{renderReadRowActions?.({ row })}
|
||||
{restrictionIndicator({ row })}
|
||||
</Box>
|
||||
)
|
||||
|
||||
const resolveRenderRowActions = () => {
|
||||
if (mode.read) return checkRowRestriction ? restrictionIndicator : undefined
|
||||
if (mode.read) {
|
||||
if (!checkRowRestriction && !renderReadRowActions) return undefined
|
||||
|
||||
return readRowActions
|
||||
}
|
||||
|
||||
return actionRow
|
||||
}
|
||||
@@ -166,6 +181,22 @@ export const EditableTable = <
|
||||
return tableData
|
||||
}
|
||||
|
||||
const muiTableBodyRowProps = ({ row }: { row: MRT_Row<T> }) => ({
|
||||
'data-cy': idFieldName ? `table-row-${String(row.original[idFieldName])}` : undefined,
|
||||
onClick: () => {
|
||||
if (mode.read && idFieldName && url) {
|
||||
setPreviousTableUrls([...previousTableUrls, `${location.pathname}?tab=${searchParams.get('tab')}`])
|
||||
navigate(resolveDetailPath(row.original), {
|
||||
state: { returnTo: `${location.pathname}${location.search}` },
|
||||
})
|
||||
}
|
||||
},
|
||||
sx: {
|
||||
backgroundColor: rowStateToColor(row.original.rowState),
|
||||
cursor: mode.read && idFieldName && url ? 'pointer' : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<DetailTabTable<T>
|
||||
mode="edit"
|
||||
@@ -174,25 +205,11 @@ export const EditableTable = <
|
||||
enableTopToolbar={enableAdvancedTableControls}
|
||||
enableColumnActions={enableAdvancedTableControls}
|
||||
enableSorting={enableAdvancedTableControls}
|
||||
enableRowActions={!mode.read || Boolean(checkRowRestriction)}
|
||||
enableRowActions={!mode.read || Boolean(checkRowRestriction) || Boolean(renderReadRowActions)}
|
||||
renderRowActions={resolveRenderRowActions()}
|
||||
kmlExport={kmlExport}
|
||||
svgExport={svgExport}
|
||||
muiTableBodyRowProps={({ row }: { row: MRT_Row<T> }) => ({
|
||||
'data-cy': idFieldName ? `table-row-${String(row.original[idFieldName])}` : undefined,
|
||||
onClick: () => {
|
||||
if (mode.read && idFieldName && url) {
|
||||
setPreviousTableUrls([...previousTableUrls, `${location.pathname}?tab=${searchParams.get('tab')}`])
|
||||
navigate(resolveDetailPath(row.original), {
|
||||
state: { returnTo: `${location.pathname}${location.search}` },
|
||||
})
|
||||
}
|
||||
},
|
||||
sx: {
|
||||
backgroundColor: rowStateToColor(row.original.rowState),
|
||||
cursor: mode.read && idFieldName && url ? 'pointer' : undefined,
|
||||
},
|
||||
})}
|
||||
muiTableBodyRowProps={muiTableBodyRowProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,15 @@ type FieldUpdate = {
|
||||
container: UpdateContainer
|
||||
}
|
||||
|
||||
type EntryUpdateHistoryProps<TRow> = {
|
||||
row: TRow
|
||||
label: string
|
||||
tableName: string
|
||||
columnName?: string
|
||||
getRowValue: (row: TRow) => unknown
|
||||
getPkValues?: (row: TRow) => unknown[]
|
||||
}
|
||||
|
||||
const SafeDetailContext = DetailContext as unknown as Context<DetailContextType<Record<string, unknown>> | null>
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -22,6 +31,8 @@ const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
const isUpdateLog = (value: unknown): value is UpdateLog =>
|
||||
isObject(value) && 'column_name' in value && 'log_action' in value
|
||||
|
||||
const getPkData = (log: UpdateLog & { pk_data?: unknown }) => log.pk_data
|
||||
|
||||
const isUpdateContainer = (value: unknown): value is UpdateContainer =>
|
||||
isObject(value) && Array.isArray(value.updates) && value.updates.every(isUpdateLog)
|
||||
|
||||
@@ -51,6 +62,76 @@ const getFieldUpdates = (data: unknown, field: string): FieldUpdate[] =>
|
||||
}))
|
||||
)
|
||||
|
||||
const stringifyComparableValue = (value: unknown): string | undefined => {
|
||||
if (value === null || value === undefined || value === '') return undefined
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value)
|
||||
if (value instanceof Date) return value.toISOString()
|
||||
return undefined
|
||||
}
|
||||
|
||||
const getEncodedPkSegment = (value: unknown) => {
|
||||
const text = stringifyComparableValue(value)
|
||||
if (!text) return undefined
|
||||
return `${text.length}.${text};`
|
||||
}
|
||||
|
||||
const isString = (value: unknown): value is string => typeof value === 'string'
|
||||
|
||||
const getEncodedPkSegments = (values: unknown[]) => values.map(getEncodedPkSegment).filter(isString)
|
||||
|
||||
const toSafeDomIdPart = (value: unknown): string => {
|
||||
const text = stringifyComparableValue(value) ?? formatValue(value)
|
||||
const sanitized = text.trim().replace(/[^A-Za-z0-9_-]+/g, '-')
|
||||
return sanitized.replace(/^-+|-+$/g, '') || 'entry'
|
||||
}
|
||||
|
||||
const valuesMatch = (left: unknown, right: unknown) => {
|
||||
const leftText = stringifyComparableValue(left)
|
||||
const rightText = stringifyComparableValue(right)
|
||||
return Boolean(leftText && rightText && leftText === rightText)
|
||||
}
|
||||
|
||||
const getEntryUpdates = (
|
||||
data: unknown,
|
||||
tableName: string,
|
||||
columnName: string | undefined,
|
||||
rowValue: unknown,
|
||||
pkValues: unknown[]
|
||||
): FieldUpdate[] => {
|
||||
const encodedPkSegments = getEncodedPkSegments(pkValues)
|
||||
const fallbackPkSegment = getEncodedPkSegment(rowValue)
|
||||
|
||||
return collectUpdateContainers(data).flatMap(container =>
|
||||
container.updates
|
||||
.filter(log => {
|
||||
if (log.table_name !== tableName) return false
|
||||
|
||||
const pkData = getPkData(log)
|
||||
const pkDataMatches =
|
||||
typeof pkData === 'string' && encodedPkSegments.length > 0
|
||||
? encodedPkSegments.every(segment => pkData.includes(segment))
|
||||
: false
|
||||
if (pkDataMatches) return true
|
||||
if (typeof pkData === 'string' && encodedPkSegments.length > 0) return false
|
||||
|
||||
const fallbackPkDataMatches =
|
||||
typeof pkData === 'string' && encodedPkSegments.length === 0 && fallbackPkSegment
|
||||
? pkData.includes(fallbackPkSegment)
|
||||
: false
|
||||
if (fallbackPkDataMatches) return true
|
||||
|
||||
if (columnName && log.column_name !== columnName) return false
|
||||
|
||||
return valuesMatch(log.new_data, rowValue) || valuesMatch(log.old_data, rowValue)
|
||||
})
|
||||
.map(log => ({
|
||||
log,
|
||||
container,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'string') return value
|
||||
@@ -101,17 +182,28 @@ const formatAction = (action: number | null | undefined) => {
|
||||
return 'Add'
|
||||
}
|
||||
|
||||
export const FieldUpdateHistory = ({ field, label }: { field: string; label: string }) => {
|
||||
const detailContext = useContext(SafeDetailContext)
|
||||
const UpdateHistoryPopover = ({
|
||||
idPrefix,
|
||||
title,
|
||||
tooltip,
|
||||
ariaLabel,
|
||||
updates,
|
||||
onOpen,
|
||||
}: {
|
||||
idPrefix: string
|
||||
title: string
|
||||
tooltip: string
|
||||
ariaLabel: string
|
||||
updates: FieldUpdate[]
|
||||
onOpen?: (event: MouseEvent<HTMLElement>) => void
|
||||
}) => {
|
||||
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 id = open ? `${idPrefix}-update-history-popover` : undefined
|
||||
|
||||
const handleOpen = (event: MouseEvent<HTMLElement>) => {
|
||||
onOpen?.(event)
|
||||
setAnchorElement(event.currentTarget)
|
||||
}
|
||||
|
||||
@@ -121,9 +213,9 @@ export const FieldUpdateHistory = ({ field, label }: { field: string; label: str
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={`Show update history for ${label}`}>
|
||||
<Tooltip title={tooltip}>
|
||||
<IconButton
|
||||
aria-label={`Show update history for ${label}`}
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby={id}
|
||||
size="small"
|
||||
onClick={handleOpen}
|
||||
@@ -142,12 +234,12 @@ export const FieldUpdateHistory = ({ field, label }: { field: string; label: str
|
||||
>
|
||||
<Box p={2} maxWidth={560} display="flex" flexDirection="column" gap={1.5}>
|
||||
<Typography variant="subtitle1" component="h2">
|
||||
{label} update history
|
||||
{title}
|
||||
</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 }}>
|
||||
<Card key={`${index}-${log.log_id ?? ''}-${log.column_name ?? idPrefix}`} sx={{ p: 1.5 }}>
|
||||
<Typography variant="body2">
|
||||
<b>Date:</b> {formatDate(getDate(container))}
|
||||
</Typography>
|
||||
@@ -160,6 +252,9 @@ export const FieldUpdateHistory = ({ field, label }: { field: string; label: str
|
||||
<Typography variant="body2">
|
||||
<b>Action:</b> {formatAction(log.log_action)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<b>Table:</b> {formatValue(log.table_name)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<b>Before:</b> {log.old_data ?? ''}
|
||||
</Typography>
|
||||
@@ -185,3 +280,54 @@ export const FieldUpdateHistory = ({ field, label }: { field: string; label: str
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const FieldUpdateHistory = ({ field, label }: { field: string; label: string }) => {
|
||||
const detailContext = useContext(SafeDetailContext)
|
||||
const updates = useMemo(() => getFieldUpdates(detailContext?.data, field), [detailContext?.data, field])
|
||||
|
||||
if (!detailContext?.mode.read || updates.length === 0) return null
|
||||
|
||||
return (
|
||||
<UpdateHistoryPopover
|
||||
idPrefix={field}
|
||||
title={`${label} update history`}
|
||||
tooltip={`Show update history for ${label}`}
|
||||
ariaLabel={`Show update history for ${label}`}
|
||||
updates={updates}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const EntryUpdateHistory = <TRow,>({
|
||||
row,
|
||||
label,
|
||||
tableName,
|
||||
columnName,
|
||||
getRowValue,
|
||||
getPkValues,
|
||||
}: EntryUpdateHistoryProps<TRow>) => {
|
||||
const detailContext = useContext(SafeDetailContext)
|
||||
const rowValue = getRowValue(row)
|
||||
const pkValues = useMemo(() => getPkValues?.(row) ?? [rowValue], [getPkValues, row, rowValue])
|
||||
const updates = useMemo(
|
||||
() => getEntryUpdates(detailContext?.data, tableName, columnName, rowValue, pkValues),
|
||||
[columnName, detailContext?.data, pkValues, rowValue, tableName]
|
||||
)
|
||||
|
||||
if (!detailContext?.mode.read || updates.length === 0) return null
|
||||
|
||||
const handleOpen = (event: MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
<UpdateHistoryPopover
|
||||
idPrefix={[tableName, columnName, rowValue].filter(Boolean).map(toSafeDomIdPart).join('-')}
|
||||
title={`${label} entry history`}
|
||||
tooltip={`Show entry history for ${label}`}
|
||||
ariaLabel={`Show entry history for ${label}`}
|
||||
updates={updates}
|
||||
onOpen={handleOpen}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
|
||||
import { useGetAllSedimentaryStructuresQuery } from '@/redux/sedimentaryStructureReducer'
|
||||
import { skipToken } from '@reduxjs/toolkit/query'
|
||||
import { MRT_ColumnDef } from 'material-react-table'
|
||||
import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory'
|
||||
|
||||
export const LithologyTab = () => {
|
||||
const { mode, textField, dropdown, editData, bigTextField } = useDetailContext<LocalityDetailsType>()
|
||||
@@ -202,7 +203,20 @@ export const LithologyTab = () => {
|
||||
selectedValues={editData.now_ss.map(ss => ss.sed_struct ?? '')} // TODO ss.sed_struct may be null. is empty string ok default?
|
||||
/>
|
||||
)}
|
||||
<EditableTable<Editable<SedimentaryStructure>, LocalityDetailsType> columns={columns} field="now_ss" />
|
||||
<EditableTable<Editable<SedimentaryStructure>, LocalityDetailsType>
|
||||
columns={columns}
|
||||
field="now_ss"
|
||||
renderReadRowActions={({ row }) => (
|
||||
<EntryUpdateHistory
|
||||
row={row.original}
|
||||
label={`sedimentary structure ${row.original.sed_struct ?? ''}`.trim()}
|
||||
tableName="now_ss"
|
||||
columnName="sed_struct"
|
||||
getRowValue={sedimentaryStructure => sedimentaryStructure.sed_struct}
|
||||
getPkValues={sedimentaryStructure => [sedimentaryStructure.lid, sedimentaryStructure.sed_struct]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grouped>
|
||||
<ArrayFrame half array={depositionalContext} title="Depositional Context" />
|
||||
</HalfFrames>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { emptyOption } from '@/components/DetailView/common/misc'
|
||||
import { convertDmsToDec, convertDecToDms } from '@/util/coordinateConversion'
|
||||
import { validCountries } from '@/shared/validators/countryList'
|
||||
import { useNotify } from '@/hooks/notification'
|
||||
import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory'
|
||||
|
||||
const CoordinateSelectionMap = lazy(async () => {
|
||||
const module = await import('@/components/Map/CoordinateSelectionMap')
|
||||
@@ -274,7 +275,19 @@ export const LocalityTab = () => {
|
||||
|
||||
<Grouped title="Synonyms">
|
||||
{!mode.read && editingModal}
|
||||
<EditableTable<Editable<LocalitySynonym>, LocalityDetailsType> columns={columns} field="now_syn_loc" />
|
||||
<EditableTable<Editable<LocalitySynonym>, LocalityDetailsType>
|
||||
columns={columns}
|
||||
field="now_syn_loc"
|
||||
renderReadRowActions={({ row }) => (
|
||||
<EntryUpdateHistory
|
||||
row={row.original}
|
||||
label={`locality synonym ${row.original.synonym ?? ''}`.trim()}
|
||||
tableName="now_syn_loc"
|
||||
getRowValue={synonym => synonym.syn_id}
|
||||
getPkValues={synonym => [synonym.lid, synonym.syn_id]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grouped>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -11,6 +11,9 @@ import { EditingModal } from '@/components/DetailView/common/EditingModal'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useNotify } from '@/hooks/notification'
|
||||
import { validCountries } from '@/shared/validators/countryList'
|
||||
import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory'
|
||||
|
||||
const getRelationLid = (row: unknown) => (row as { lid?: unknown }).lid
|
||||
|
||||
export const MuseumTab = () => {
|
||||
const { mode, editData, setEditData } = useDetailContext<LocalityDetailsType>()
|
||||
@@ -242,6 +245,15 @@ export const MuseumTab = () => {
|
||||
enableAdvancedTableControls={true}
|
||||
idFieldName="museum"
|
||||
url="museum"
|
||||
renderReadRowActions={({ row }) => (
|
||||
<EntryUpdateHistory
|
||||
row={row.original}
|
||||
label={`museum ${row.original.museum ?? ''}`.trim()}
|
||||
tableName="now_mus"
|
||||
getRowValue={museum => museum.museum}
|
||||
getPkValues={museum => [getRelationLid(museum), museum.museum]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grouped>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,9 @@ import { SelectingTable } from '@/components/DetailView/common/SelectingTable'
|
||||
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
|
||||
import { useGetAllProjectsQuery } from '@/redux/projectReducer'
|
||||
import { usePageContext } from '@/components/Page'
|
||||
import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory'
|
||||
|
||||
const getRelationLid = (row: unknown) => (row as { lid?: unknown }).lid
|
||||
|
||||
export const ProjectTab = () => {
|
||||
const { mode, editData, setEditData } = useDetailContext<LocalityDetailsType>()
|
||||
@@ -89,6 +92,15 @@ export const ProjectTab = () => {
|
||||
enableAdvancedTableControls={true}
|
||||
idFieldName="pid"
|
||||
url="project"
|
||||
renderReadRowActions={({ row }) => (
|
||||
<EntryUpdateHistory
|
||||
row={row.original}
|
||||
label={`project ${row.original.pid ?? ''}`.trim()}
|
||||
tableName="now_plr"
|
||||
getRowValue={project => project.pid}
|
||||
getPkValues={project => [getRelationLid(project), project.pid]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grouped>
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { emptyOption } from '@/components/DetailView/common/misc'
|
||||
import { LookupSelectingTable } from '@/components/shared/LookupSelectingTable'
|
||||
import { useGetAllCollectingMethodValuesQuery } from '@/redux/collectingMethodValuesReducer'
|
||||
import { skipToken } from '@reduxjs/toolkit/query'
|
||||
import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory'
|
||||
|
||||
export const TaphonomyTab = () => {
|
||||
const { textField, dropdown, mode, editData } = useDetailContext<LocalityDetailsType>()
|
||||
@@ -165,7 +166,19 @@ export const TaphonomyTab = () => {
|
||||
selectedValues={editData.now_coll_meth.map(method => method.coll_meth ?? '')}
|
||||
/>
|
||||
)}
|
||||
<EditableTable<Editable<CollectingMethod>, LocalityDetailsType> columns={columns} field="now_coll_meth" />
|
||||
<EditableTable<Editable<CollectingMethod>, LocalityDetailsType>
|
||||
columns={columns}
|
||||
field="now_coll_meth"
|
||||
renderReadRowActions={({ row }) => (
|
||||
<EntryUpdateHistory
|
||||
row={row.original}
|
||||
label={`collecting method ${row.original.coll_meth ?? ''}`.trim()}
|
||||
tableName="now_coll_meth"
|
||||
getRowValue={collectingMethod => collectingMethod.coll_meth}
|
||||
getPkValues={collectingMethod => [collectingMethod.lid, collectingMethod.coll_meth]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grouped>
|
||||
<></>
|
||||
</HalfFrames>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { convertSynonymTaxonomyFields } from '@/util/taxonomyUtilities'
|
||||
import { useGetAllSpeciesQuery } from '@/redux/speciesReducer'
|
||||
import { skipToken } from '@reduxjs/toolkit/query'
|
||||
import { CircularProgress } from '@mui/material'
|
||||
import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory'
|
||||
|
||||
export const SynonymTab = () => {
|
||||
const { mode, setEditData, editData } = useDetailContext<SpeciesDetailsType>()
|
||||
@@ -113,7 +114,21 @@ export const SynonymTab = () => {
|
||||
<>
|
||||
<Grouped title="Synonyms">
|
||||
{!mode.read && editingForm}
|
||||
<EditableTable<Editable<SpeciesSynonym>, SpeciesDetailsType> columns={columns} field="com_taxa_synonym" />
|
||||
<EditableTable<Editable<SpeciesSynonym>, SpeciesDetailsType>
|
||||
columns={columns}
|
||||
field="com_taxa_synonym"
|
||||
renderReadRowActions={({ row }) => (
|
||||
<EntryUpdateHistory
|
||||
row={row.original}
|
||||
label={`species synonym ${row.original.syn_genus_name ?? ''} ${
|
||||
row.original.syn_species_name ?? ''
|
||||
}`.trim()}
|
||||
tableName="com_taxa_synonym"
|
||||
getRowValue={synonym => synonym.synonym_id}
|
||||
getPkValues={synonym => [synonym.synonym_id, synonym.species_id]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grouped>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory'
|
||||
|
||||
const FieldElement = (_props: { field: string }) => <span>field value</span>
|
||||
|
||||
@@ -20,6 +21,54 @@ const reference = {
|
||||
|
||||
const detailData = {
|
||||
body_mass: 10,
|
||||
now_ss: [{ lid: 100, sed_struct: 'cross-bedding' }],
|
||||
now_lau: [
|
||||
{
|
||||
lau_date: '2026-06-03',
|
||||
lau_authorizer: 'ED',
|
||||
lau_coordinator: 'CO',
|
||||
lau_comment: 'Added sedimentary structure',
|
||||
now_lr: [reference],
|
||||
updates: [
|
||||
{
|
||||
log_id: 2,
|
||||
table_name: 'now_ss',
|
||||
pk_data: '3.100;13.cross-bedding;',
|
||||
column_name: 'sed_struct',
|
||||
log_action: 2,
|
||||
old_data: null,
|
||||
new_data: 'cross-bedding',
|
||||
},
|
||||
{
|
||||
log_id: 3,
|
||||
table_name: 'now_ss',
|
||||
pk_data: '3.100;13.cross-bedding;',
|
||||
column_name: 'ss_comment',
|
||||
log_action: 3,
|
||||
old_data: 'old row comment',
|
||||
new_data: 'new row comment',
|
||||
},
|
||||
{
|
||||
log_id: 4,
|
||||
table_name: 'now_ss',
|
||||
pk_data: '3.101;13.cross-bedding;',
|
||||
column_name: 'sed_struct',
|
||||
log_action: 2,
|
||||
old_data: null,
|
||||
new_data: 'cross-bedding',
|
||||
},
|
||||
{
|
||||
log_id: 5,
|
||||
table_name: 'now_ss',
|
||||
pk_data: '3.100;19.cross bedding/layer;',
|
||||
column_name: 'sed_struct',
|
||||
log_action: 2,
|
||||
old_data: null,
|
||||
new_data: 'cross bedding/layer',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
now_sau: [
|
||||
{
|
||||
sau_date: '2026-05-29',
|
||||
@@ -98,6 +147,84 @@ describe('FieldUpdateHistory', () => {
|
||||
expect(screen.queryByLabelText('Show update history for Body mass')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows entry-specific update history for a matching relation row', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DetailContext.Provider value={contextValue}>
|
||||
<EntryUpdateHistory
|
||||
row={{ lid: 100, sed_struct: 'cross-bedding' }}
|
||||
label="sedimentary structure cross-bedding"
|
||||
tableName="now_ss"
|
||||
getRowValue={row => row.sed_struct}
|
||||
getPkValues={row => [row.lid, row.sed_struct]}
|
||||
/>
|
||||
</DetailContext.Provider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
await user.click(screen.getByLabelText('Show entry history for sedimentary structure cross-bedding'))
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'sedimentary structure cross-bedding entry history' })).toBeTruthy()
|
||||
expect(screen.getAllByText('Added sedimentary structure')).toHaveLength(2)
|
||||
expect(screen.getAllByText(/Field update reference/)).toHaveLength(2)
|
||||
const tableRow = screen.getAllByText('Table:')[0].closest('p')
|
||||
expect(tableRow).toBeTruthy()
|
||||
expect(within(tableRow as HTMLElement).getByText('now_ss')).toBeInTheDocument()
|
||||
expect(screen.getByText('old row comment')).toBeInTheDocument()
|
||||
expect(screen.getByText('new row comment')).toBeInTheDocument()
|
||||
expect(screen.queryByText('101')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses a safe popover id for entry values with spaces or punctuation', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DetailContext.Provider value={contextValue}>
|
||||
<EntryUpdateHistory
|
||||
row={{ lid: 100, sed_struct: 'cross bedding/layer' }}
|
||||
label="sedimentary structure cross bedding/layer"
|
||||
tableName="now_ss"
|
||||
getRowValue={row => row.sed_struct}
|
||||
getPkValues={row => [row.lid, row.sed_struct]}
|
||||
/>
|
||||
</DetailContext.Provider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
const button = screen.getByLabelText('Show entry history for sedimentary structure cross bedding/layer')
|
||||
await user.click(button)
|
||||
|
||||
expect(button).toHaveAttribute('aria-describedby', 'now_ss-cross-bedding-layer-update-history-popover')
|
||||
})
|
||||
|
||||
it('does not bubble entry history clicks to the relation row', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleRowClick = jest.fn()
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DetailContext.Provider value={contextValue}>
|
||||
<div role="button" tabIndex={0} onClick={handleRowClick} onKeyDown={() => undefined}>
|
||||
<EntryUpdateHistory
|
||||
row={{ lid: 100, sed_struct: 'cross-bedding' }}
|
||||
label="sedimentary structure cross-bedding"
|
||||
tableName="now_ss"
|
||||
getRowValue={row => row.sed_struct}
|
||||
getPkValues={row => [row.lid, row.sed_struct]}
|
||||
/>
|
||||
</div>
|
||||
</DetailContext.Provider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
await user.click(screen.getByLabelText('Show entry history for sedimentary structure cross-bedding'))
|
||||
|
||||
expect(handleRowClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders multiple same-field updates without duplicate key warnings', async () => {
|
||||
const user = userEvent.setup()
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
Reference in New Issue
Block a user