Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 36 additions & 19 deletions frontend/src/components/DetailView/common/EditableTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -34,6 +34,7 @@ export const EditableTable = <
url,
getDetailPath,
checkRowRestriction,
renderReadRowActions,
kmlExport,
svgExport,
}: {
Expand All @@ -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>
}) => {
Expand Down Expand Up @@ -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
}
Expand All @@ -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"
Expand All @@ -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}
/>
)
}
166 changes: 156 additions & 10 deletions frontend/src/components/DetailView/common/FieldUpdateHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> =>
Expand All @@ -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

Comment on lines 32 to +35
const isUpdateContainer = (value: unknown): value is UpdateContainer =>
isObject(value) && Array.isArray(value.updates) && value.updates.every(isUpdateLog)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand All @@ -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}
Expand All @@ -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>
Expand All @@ -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>
Expand All @@ -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}
/>
Comment on lines +323 to +331
)
}
16 changes: 15 additions & 1 deletion frontend/src/components/Locality/Tabs/LithologyTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>()
Expand Down Expand Up @@ -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>
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/components/Locality/Tabs/LocalityTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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>
</>
)
Expand Down
Loading
Loading