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 RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'
|
||||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'
|
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'
|
||||||
import PolicyIcon from '@mui/icons-material/Policy'
|
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 { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { usePageContext } from '@/components/Page'
|
import { usePageContext } from '@/components/Page'
|
||||||
import { checkFieldErrors } from './checkFieldErrors'
|
import { checkFieldErrors } from './checkFieldErrors'
|
||||||
@@ -34,6 +34,7 @@ export const EditableTable = <
|
|||||||
url,
|
url,
|
||||||
getDetailPath,
|
getDetailPath,
|
||||||
checkRowRestriction,
|
checkRowRestriction,
|
||||||
|
renderReadRowActions,
|
||||||
kmlExport,
|
kmlExport,
|
||||||
svgExport,
|
svgExport,
|
||||||
}: {
|
}: {
|
||||||
@@ -49,6 +50,7 @@ export const EditableTable = <
|
|||||||
url?: string
|
url?: string
|
||||||
getDetailPath?: (row: T) => string
|
getDetailPath?: (row: T) => string
|
||||||
checkRowRestriction?: (row: T) => boolean
|
checkRowRestriction?: (row: T) => boolean
|
||||||
|
renderReadRowActions?: (props: { row: MRT_Row<T> }) => ReactNode
|
||||||
kmlExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
kmlExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
||||||
svgExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
svgExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
||||||
}) => {
|
}) => {
|
||||||
@@ -134,13 +136,26 @@ export const EditableTable = <
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip placement="top" title="This item has restricted visibility">
|
<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>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const readRowActions = ({ row }: { row: MRT_Row<T> }) => (
|
||||||
|
<Box className="row-actions-column">
|
||||||
|
{renderReadRowActions?.({ row })}
|
||||||
|
{restrictionIndicator({ row })}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
|
||||||
const resolveRenderRowActions = () => {
|
const resolveRenderRowActions = () => {
|
||||||
if (mode.read) return checkRowRestriction ? restrictionIndicator : undefined
|
if (mode.read) {
|
||||||
|
if (!checkRowRestriction && !renderReadRowActions) return undefined
|
||||||
|
|
||||||
|
return readRowActions
|
||||||
|
}
|
||||||
|
|
||||||
return actionRow
|
return actionRow
|
||||||
}
|
}
|
||||||
@@ -166,6 +181,22 @@ export const EditableTable = <
|
|||||||
return tableData
|
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 (
|
return (
|
||||||
<DetailTabTable<T>
|
<DetailTabTable<T>
|
||||||
mode="edit"
|
mode="edit"
|
||||||
@@ -174,25 +205,11 @@ export const EditableTable = <
|
|||||||
enableTopToolbar={enableAdvancedTableControls}
|
enableTopToolbar={enableAdvancedTableControls}
|
||||||
enableColumnActions={enableAdvancedTableControls}
|
enableColumnActions={enableAdvancedTableControls}
|
||||||
enableSorting={enableAdvancedTableControls}
|
enableSorting={enableAdvancedTableControls}
|
||||||
enableRowActions={!mode.read || Boolean(checkRowRestriction)}
|
enableRowActions={!mode.read || Boolean(checkRowRestriction) || Boolean(renderReadRowActions)}
|
||||||
renderRowActions={resolveRenderRowActions()}
|
renderRowActions={resolveRenderRowActions()}
|
||||||
kmlExport={kmlExport}
|
kmlExport={kmlExport}
|
||||||
svgExport={svgExport}
|
svgExport={svgExport}
|
||||||
muiTableBodyRowProps={({ row }: { row: MRT_Row<T> }) => ({
|
muiTableBodyRowProps={muiTableBodyRowProps}
|
||||||
'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,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ type FieldUpdate = {
|
|||||||
container: UpdateContainer
|
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 SafeDetailContext = DetailContext as unknown as Context<DetailContextType<Record<string, unknown>> | null>
|
||||||
|
|
||||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
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 =>
|
const isUpdateLog = (value: unknown): value is UpdateLog =>
|
||||||
isObject(value) && 'column_name' in value && 'log_action' in value
|
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 =>
|
const isUpdateContainer = (value: unknown): value is UpdateContainer =>
|
||||||
isObject(value) && Array.isArray(value.updates) && value.updates.every(isUpdateLog)
|
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 => {
|
const formatValue = (value: unknown): string => {
|
||||||
if (value === null || value === undefined) return ''
|
if (value === null || value === undefined) return ''
|
||||||
if (typeof value === 'string') return value
|
if (typeof value === 'string') return value
|
||||||
@@ -101,17 +182,28 @@ const formatAction = (action: number | null | undefined) => {
|
|||||||
return 'Add'
|
return 'Add'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FieldUpdateHistory = ({ field, label }: { field: string; label: string }) => {
|
const UpdateHistoryPopover = ({
|
||||||
const detailContext = useContext(SafeDetailContext)
|
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 [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 open = Boolean(anchorElement)
|
||||||
const id = open ? `${field}-update-history-popover` : undefined
|
const id = open ? `${idPrefix}-update-history-popover` : undefined
|
||||||
|
|
||||||
const handleOpen = (event: MouseEvent<HTMLElement>) => {
|
const handleOpen = (event: MouseEvent<HTMLElement>) => {
|
||||||
|
onOpen?.(event)
|
||||||
setAnchorElement(event.currentTarget)
|
setAnchorElement(event.currentTarget)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +213,9 @@ export const FieldUpdateHistory = ({ field, label }: { field: string; label: str
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip title={`Show update history for ${label}`}>
|
<Tooltip title={tooltip}>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label={`Show update history for ${label}`}
|
aria-label={ariaLabel}
|
||||||
aria-describedby={id}
|
aria-describedby={id}
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handleOpen}
|
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}>
|
<Box p={2} maxWidth={560} display="flex" flexDirection="column" gap={1.5}>
|
||||||
<Typography variant="subtitle1" component="h2">
|
<Typography variant="subtitle1" component="h2">
|
||||||
{label} update history
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{updates.map(({ log, container }, index) => {
|
{updates.map(({ log, container }, index) => {
|
||||||
const references = collectReferences(container)
|
const references = collectReferences(container)
|
||||||
return (
|
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">
|
<Typography variant="body2">
|
||||||
<b>Date:</b> {formatDate(getDate(container))}
|
<b>Date:</b> {formatDate(getDate(container))}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -160,6 +252,9 @@ export const FieldUpdateHistory = ({ field, label }: { field: string; label: str
|
|||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
<b>Action:</b> {formatAction(log.log_action)}
|
<b>Action:</b> {formatAction(log.log_action)}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<b>Table:</b> {formatValue(log.table_name)}
|
||||||
|
</Typography>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
<b>Before:</b> {log.old_data ?? ''}
|
<b>Before:</b> {log.old_data ?? ''}
|
||||||
</Typography>
|
</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 { useGetAllSedimentaryStructuresQuery } from '@/redux/sedimentaryStructureReducer'
|
||||||
import { skipToken } from '@reduxjs/toolkit/query'
|
import { skipToken } from '@reduxjs/toolkit/query'
|
||||||
import { MRT_ColumnDef } from 'material-react-table'
|
import { MRT_ColumnDef } from 'material-react-table'
|
||||||
|
import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory'
|
||||||
|
|
||||||
export const LithologyTab = () => {
|
export const LithologyTab = () => {
|
||||||
const { mode, textField, dropdown, editData, bigTextField } = useDetailContext<LocalityDetailsType>()
|
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?
|
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>
|
</Grouped>
|
||||||
<ArrayFrame half array={depositionalContext} title="Depositional Context" />
|
<ArrayFrame half array={depositionalContext} title="Depositional Context" />
|
||||||
</HalfFrames>
|
</HalfFrames>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { emptyOption } from '@/components/DetailView/common/misc'
|
|||||||
import { convertDmsToDec, convertDecToDms } from '@/util/coordinateConversion'
|
import { convertDmsToDec, convertDecToDms } from '@/util/coordinateConversion'
|
||||||
import { validCountries } from '@/shared/validators/countryList'
|
import { validCountries } from '@/shared/validators/countryList'
|
||||||
import { useNotify } from '@/hooks/notification'
|
import { useNotify } from '@/hooks/notification'
|
||||||
|
import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory'
|
||||||
|
|
||||||
const CoordinateSelectionMap = lazy(async () => {
|
const CoordinateSelectionMap = lazy(async () => {
|
||||||
const module = await import('@/components/Map/CoordinateSelectionMap')
|
const module = await import('@/components/Map/CoordinateSelectionMap')
|
||||||
@@ -274,7 +275,19 @@ export const LocalityTab = () => {
|
|||||||
|
|
||||||
<Grouped title="Synonyms">
|
<Grouped title="Synonyms">
|
||||||
{!mode.read && editingModal}
|
{!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>
|
</Grouped>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { EditingModal } from '@/components/DetailView/common/EditingModal'
|
|||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { useNotify } from '@/hooks/notification'
|
import { useNotify } from '@/hooks/notification'
|
||||||
import { validCountries } from '@/shared/validators/countryList'
|
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 = () => {
|
export const MuseumTab = () => {
|
||||||
const { mode, editData, setEditData } = useDetailContext<LocalityDetailsType>()
|
const { mode, editData, setEditData } = useDetailContext<LocalityDetailsType>()
|
||||||
@@ -242,6 +245,15 @@ export const MuseumTab = () => {
|
|||||||
enableAdvancedTableControls={true}
|
enableAdvancedTableControls={true}
|
||||||
idFieldName="museum"
|
idFieldName="museum"
|
||||||
url="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>
|
</Grouped>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { SelectingTable } from '@/components/DetailView/common/SelectingTable'
|
|||||||
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
|
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
|
||||||
import { useGetAllProjectsQuery } from '@/redux/projectReducer'
|
import { useGetAllProjectsQuery } from '@/redux/projectReducer'
|
||||||
import { usePageContext } from '@/components/Page'
|
import { usePageContext } from '@/components/Page'
|
||||||
|
import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory'
|
||||||
|
|
||||||
|
const getRelationLid = (row: unknown) => (row as { lid?: unknown }).lid
|
||||||
|
|
||||||
export const ProjectTab = () => {
|
export const ProjectTab = () => {
|
||||||
const { mode, editData, setEditData } = useDetailContext<LocalityDetailsType>()
|
const { mode, editData, setEditData } = useDetailContext<LocalityDetailsType>()
|
||||||
@@ -89,6 +92,15 @@ export const ProjectTab = () => {
|
|||||||
enableAdvancedTableControls={true}
|
enableAdvancedTableControls={true}
|
||||||
idFieldName="pid"
|
idFieldName="pid"
|
||||||
url="project"
|
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>
|
</Grouped>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { emptyOption } from '@/components/DetailView/common/misc'
|
|||||||
import { LookupSelectingTable } from '@/components/shared/LookupSelectingTable'
|
import { LookupSelectingTable } from '@/components/shared/LookupSelectingTable'
|
||||||
import { useGetAllCollectingMethodValuesQuery } from '@/redux/collectingMethodValuesReducer'
|
import { useGetAllCollectingMethodValuesQuery } from '@/redux/collectingMethodValuesReducer'
|
||||||
import { skipToken } from '@reduxjs/toolkit/query'
|
import { skipToken } from '@reduxjs/toolkit/query'
|
||||||
|
import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory'
|
||||||
|
|
||||||
export const TaphonomyTab = () => {
|
export const TaphonomyTab = () => {
|
||||||
const { textField, dropdown, mode, editData } = useDetailContext<LocalityDetailsType>()
|
const { textField, dropdown, mode, editData } = useDetailContext<LocalityDetailsType>()
|
||||||
@@ -165,7 +166,19 @@ export const TaphonomyTab = () => {
|
|||||||
selectedValues={editData.now_coll_meth.map(method => method.coll_meth ?? '')}
|
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>
|
</Grouped>
|
||||||
<></>
|
<></>
|
||||||
</HalfFrames>
|
</HalfFrames>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { convertSynonymTaxonomyFields } from '@/util/taxonomyUtilities'
|
|||||||
import { useGetAllSpeciesQuery } from '@/redux/speciesReducer'
|
import { useGetAllSpeciesQuery } from '@/redux/speciesReducer'
|
||||||
import { skipToken } from '@reduxjs/toolkit/query'
|
import { skipToken } from '@reduxjs/toolkit/query'
|
||||||
import { CircularProgress } from '@mui/material'
|
import { CircularProgress } from '@mui/material'
|
||||||
|
import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory'
|
||||||
|
|
||||||
export const SynonymTab = () => {
|
export const SynonymTab = () => {
|
||||||
const { mode, setEditData, editData } = useDetailContext<SpeciesDetailsType>()
|
const { mode, setEditData, editData } = useDetailContext<SpeciesDetailsType>()
|
||||||
@@ -113,7 +114,21 @@ export const SynonymTab = () => {
|
|||||||
<>
|
<>
|
||||||
<Grouped title="Synonyms">
|
<Grouped title="Synonyms">
|
||||||
{!mode.read && editingForm}
|
{!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>
|
</Grouped>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ContextType } from 'react'
|
|||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import { ArrayToTable } from '@/components/DetailView/common/tabLayoutHelpers'
|
import { ArrayToTable } from '@/components/DetailView/common/tabLayoutHelpers'
|
||||||
import { DetailContext } from '@/components/DetailView/Context/DetailContext'
|
import { DetailContext } from '@/components/DetailView/Context/DetailContext'
|
||||||
|
import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory'
|
||||||
|
|
||||||
const FieldElement = (_props: { field: string }) => <span>field value</span>
|
const FieldElement = (_props: { field: string }) => <span>field value</span>
|
||||||
|
|
||||||
@@ -20,6 +21,54 @@ const reference = {
|
|||||||
|
|
||||||
const detailData = {
|
const detailData = {
|
||||||
body_mass: 10,
|
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: [
|
now_sau: [
|
||||||
{
|
{
|
||||||
sau_date: '2026-05-29',
|
sau_date: '2026-05-29',
|
||||||
@@ -98,6 +147,84 @@ describe('FieldUpdateHistory', () => {
|
|||||||
expect(screen.queryByLabelText('Show update history for Body mass')).not.toBeInTheDocument()
|
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 () => {
|
it('renders multiple same-field updates without duplicate key warnings', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
|||||||
Reference in New Issue
Block a user