From 22d01f9ab7c99180c2cde14c2b53d2ffdd058ac1 Mon Sep 17 00:00:00 2001 From: karilint Date: Wed, 27 May 2026 14:41:46 +0300 Subject: [PATCH] Show combined time unit update history --- backend/src/api-tests/timeUnit/data.ts | 3 + .../api-tests/timeUnit/timeUnitsFetch.test.ts | 22 +++ backend/src/services/timeUnit.ts | 114 +++++++++++- backend/src/services/write/timeUnit.ts | 9 +- .../DetailView/common/defaultValues.ts | 1 + .../Sequence/SequenceSelect.test.tsx | 1 + .../TimeUnit/Tabs/TimeUnitUpdateTab.tsx | 172 ++++++++++++++++++ .../components/TimeUnit/TimeUnitDetails.tsx | 4 +- frontend/src/shared/types/data.ts | 11 +- .../DetailView/DetailViewCancelEdit.test.tsx | 1 + .../DetailViewUnsavedPrompt.test.tsx | 1 + 11 files changed, 325 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/TimeUnit/Tabs/TimeUnitUpdateTab.tsx diff --git a/backend/src/api-tests/timeUnit/data.ts b/backend/src/api-tests/timeUnit/data.ts index 67f12b9a..1d0c0c5e 100644 --- a/backend/src/api-tests/timeUnit/data.ts +++ b/backend/src/api-tests/timeUnit/data.ts @@ -43,6 +43,7 @@ export const newTimeUnitBasis: EditDataType sequence: 'chlma', tu_comment: 'test comment', now_tau: [], + now_time_update: [], comment: 'test create time unit', references: references, } @@ -56,6 +57,7 @@ export const editedTimeUnit: EditDataType = sequence: 'gcss', tu_comment: 'test comment edited', now_tau: [], + now_time_update: [], comment: 'test updating time unit', references: references, } @@ -69,6 +71,7 @@ export const conflictingTimeUnit: EditDataType { expect(body).toEqual({ message: 'Time unit not found' }) }) + it('returns legacy time update summary rows with linked bound updates', async () => { + const { body, status } = await send<{ + now_time_update: Array<{ + time_update_id: number + tuid: number | null + lower_buid: number | null + lower_bound_update: { buid: number; updates: unknown[] } | null + }> + }>('time-unit/langhian', 'GET') + + expect(status).toBe(200) + + const lowerBoundSummary = body.now_time_update.find(update => update.time_update_id === 460) + + expect(lowerBoundSummary).toMatchObject({ + tuid: null, + lower_buid: 403, + lower_bound_update: { buid: 403 }, + }) + expect(lowerBoundSummary?.lower_bound_update?.updates).toEqual([]) + }) + it('handles unexpected failures gracefully', async () => { const spy = jest.spyOn(timeUnitService, 'getAllTimeUnits').mockRejectedValueOnce(new Error('db unavailable')) diff --git a/backend/src/services/timeUnit.ts b/backend/src/services/timeUnit.ts index f627e132..fdb9bf54 100644 --- a/backend/src/services/timeUnit.ts +++ b/backend/src/services/timeUnit.ts @@ -60,24 +60,80 @@ export const getTimeUnitDetails = async (id: string) => { }, }, }, + now_time_update: { + include: { + now_tau: { + include: { + now_tr: { + include: { + ref_ref: { + select: referenceWithoutExactDateSelect, + }, + }, + }, + }, + }, + now_bau_now_time_update_lower_buidTonow_bau: { + include: { + now_br: { + include: { + ref_ref: { + select: referenceWithoutExactDateSelect, + }, + }, + }, + }, + }, + now_bau_now_time_update_upper_buidTonow_bau: { + include: { + now_br: { + include: { + ref_ref: { + select: referenceWithoutExactDateSelect, + }, + }, + }, + }, + }, + }, + orderBy: [{ date: 'asc' }, { time_update_id: 'asc' }], + }, }, }) if (!result) return null const tuids = result.now_tau.map(tau => tau.tuid) - - const logResult = await logDb.log.findMany({ where: { tuid: { in: tuids } } }) - - const peopleLookup = await buildPersonLookupByInitials( - result.now_tau.flatMap(tau => [tau.tau_coordinator, tau.tau_authorizer]) + const timeUpdateTuids = result.now_time_update.flatMap(update => (update.tuid ? [update.tuid] : [])) + const timeUpdateBuids = result.now_time_update.flatMap(update => + [update.lower_buid, update.upper_buid].filter((buid): buid is number => typeof buid === 'number') ) - result.now_tau = result.now_tau.map(tau => { + const [timeUnitLogRows, timeBoundLogRows] = await Promise.all([ + logDb.log.findMany({ where: { tuid: { in: Array.from(new Set([...tuids, ...timeUpdateTuids])) } } }), + logDb.log.findMany({ where: { buid: { in: Array.from(new Set(timeUpdateBuids)) } } }), + ]) + + const peopleLookup = await buildPersonLookupByInitials( + [ + ...result.now_tau.flatMap(tau => [tau.tau_coordinator, tau.tau_authorizer]), + ...result.now_time_update.flatMap(update => [update.coordinator, update.authorizer]), + ...result.now_time_update.flatMap(update => [ + update.now_tau?.tau_coordinator, + update.now_tau?.tau_authorizer, + update.now_bau_now_time_update_lower_buidTonow_bau?.bau_coordinator, + update.now_bau_now_time_update_lower_buidTonow_bau?.bau_authorizer, + update.now_bau_now_time_update_upper_buidTonow_bau?.bau_coordinator, + update.now_bau_now_time_update_upper_buidTonow_bau?.bau_authorizer, + ]), + ].filter((initials): initials is string => typeof initials === 'string') + ) + + const formatTimeUnitUpdate = (tau: (typeof result.now_tau)[number]) => { const coordinatorPerson = getPersonFromLookup(peopleLookup, tau.tau_coordinator) const authorizerPerson = getPersonFromLookup(peopleLookup, tau.tau_authorizer) - const updates = logResult.filter((logRow: (typeof logResult)[number]) => logRow.tuid === tau.tuid) + const updates = timeUnitLogRows.filter((logRow: (typeof timeUnitLogRows)[number]) => logRow.tuid === tau.tuid) return { ...tau, @@ -86,14 +142,56 @@ export const getTimeUnitDetails = async (id: string) => { now_tr: addNullExactDateToReferenceJoins(tau.now_tr), updates, } + } + + const formatTimeBoundUpdate = ( + bau: + | (typeof result.now_time_update)[number]['now_bau_now_time_update_lower_buidTonow_bau'] + | (typeof result.now_time_update)[number]['now_bau_now_time_update_upper_buidTonow_bau'] + ) => { + if (!bau) return null + + const coordinatorPerson = getPersonFromLookup(peopleLookup, bau.bau_coordinator) + const authorizerPerson = getPersonFromLookup(peopleLookup, bau.bau_authorizer) + const updates = timeBoundLogRows.filter((logRow: (typeof timeBoundLogRows)[number]) => logRow.buid === bau.buid) + + return { + ...bau, + bau_coordinator: getPersonDisplayName(coordinatorPerson, bau.bau_coordinator), + bau_authorizer: getPersonDisplayName(authorizerPerson, bau.bau_authorizer), + now_br: addNullExactDateToReferenceJoins(bau.now_br), + updates, + } + } + + const nowTau = result.now_tau.map(formatTimeUnitUpdate) + const nowTimeUpdate = result.now_time_update.map(update => { + const { + now_bau_now_time_update_lower_buidTonow_bau: lowerBoundUpdate, + now_bau_now_time_update_upper_buidTonow_bau: upperBoundUpdate, + ...timeUpdate + } = update + const coordinatorPerson = getPersonFromLookup(peopleLookup, update.coordinator) + const authorizerPerson = getPersonFromLookup(peopleLookup, update.authorizer) + + return { + ...timeUpdate, + coordinator: getPersonDisplayName(coordinatorPerson, update.coordinator), + authorizer: getPersonDisplayName(authorizerPerson, update.authorizer), + now_tau: update.now_tau ? formatTimeUnitUpdate(update.now_tau) : null, + lower_bound_update: formatTimeBoundUpdate(lowerBoundUpdate), + upper_bound_update: formatTimeBoundUpdate(upperBoundUpdate), + } }) const { + now_tau: _nowTau, + now_time_update: _nowTimeUpdate, now_tu_bound_now_time_unit_low_bndTonow_tu_bound: low_bound, now_tu_bound_now_time_unit_up_bndTonow_tu_bound: up_bound, ...rest } = result - return { ...rest, low_bound, up_bound } + return { ...rest, now_tau: nowTau, now_time_update: nowTimeUpdate, low_bound, up_bound } } export const getTimeUnitLocalities = async (id: string, options?: TabListQueryOptions) => { diff --git a/backend/src/services/write/timeUnit.ts b/backend/src/services/write/timeUnit.ts index 050ae91a..4730c7fa 100644 --- a/backend/src/services/write/timeUnit.ts +++ b/backend/src/services/write/timeUnit.ts @@ -160,12 +160,14 @@ export const writeTimeUnit = async ( low_bound?: unknown references?: unknown comment?: unknown + now_time_update?: unknown } const { up_bound: _upBound, low_bound: _lowBound, references: _references, comment: _comment, + now_time_update: _nowTimeUpdate, ...persistableTimeUnit } = normalizedTimeUnit @@ -177,11 +179,14 @@ export const writeTimeUnit = async ( try { await writeHandler.start() - const timeUnitToPersist: EditDataType = { ...persistableTimeUnit } + const timeUnitToPersist = { ...persistableTimeUnit } if (timeUnitToPersist.tu_name) { await writeHandler.updateObject('now_time_unit', timeUnitToPersist, ['tu_name']) - const { cascadeErrors, calculatorErrors, localitiesToUpdate } = await checkTimeUnitCascade(timeUnitToPersist) + const { cascadeErrors, calculatorErrors, localitiesToUpdate } = await checkTimeUnitCascade({ + ...normalizedTimeUnit, + now_time_update: normalizedTimeUnit.now_time_update ?? [], + } as EditDataType) if (calculatorErrors.length > 0 || cascadeErrors.length > 0) { const calculatorErrorsString = diff --git a/frontend/src/components/DetailView/common/defaultValues.ts b/frontend/src/components/DetailView/common/defaultValues.ts index 60cda3b1..8d62a208 100755 --- a/frontend/src/components/DetailView/common/defaultValues.ts +++ b/frontend/src/components/DetailView/common/defaultValues.ts @@ -206,6 +206,7 @@ export const emptyTimeUnit = { seq_name: '', }, now_tau: [], + now_time_update: [], } as unknown as TimeUnitDetailsType export const emptyTimeBound = { diff --git a/frontend/src/components/Sequence/SequenceSelect.test.tsx b/frontend/src/components/Sequence/SequenceSelect.test.tsx index 9a38f5f6..8003b951 100644 --- a/frontend/src/components/Sequence/SequenceSelect.test.tsx +++ b/frontend/src/components/Sequence/SequenceSelect.test.tsx @@ -51,6 +51,7 @@ const renderWithContext = ({ children, data, mode = 'edit' }: ContextWrapperProp seq_name: 'Central Paratethys', }, now_tau: [], + now_time_update: [], low_bound: { bid: 1, b_name: 'low', diff --git a/frontend/src/components/TimeUnit/Tabs/TimeUnitUpdateTab.tsx b/frontend/src/components/TimeUnit/Tabs/TimeUnitUpdateTab.tsx new file mode 100644 index 00000000..267c475b --- /dev/null +++ b/frontend/src/components/TimeUnit/Tabs/TimeUnitUpdateTab.tsx @@ -0,0 +1,172 @@ +import { Box, Card, Divider, Stack, Typography } from '@mui/material' +import { MRT_ColumnDef, MRT_Row } from 'material-react-table' +import { AnyReference, TimeBoundUpdate, TimeUnitUpdate, TimeUnitUpdateSummary, UpdateLog } from '@/shared/types' +import { useDetailContext } from '@/components/DetailView/Context/DetailContext' +import { EditingModal } from '@/components/DetailView/common/EditingModal' +import { ReferenceList } from '@/components/DetailView/common/ReferenceList' +import { SimpleTable } from '@/components/DetailView/common/SimpleTable' +import { Grouped } from '@/components/DetailView/common/tabLayoutHelpers' + +const formatDate = (date: Date | string | null) => { + if (!date) return 'No date' + return new Date(date).toISOString().split('T')[0] +} + +const getReferences = (update: TimeUnitUpdateSummary): AnyReference[] => [ + ...(update.now_tau?.now_tr ?? []), + ...(update.lower_bound_update?.now_br ?? []), + ...(update.upper_bound_update?.now_br ?? []), +] + +const getLinkedUpdateLabels = (update: TimeUnitUpdateSummary) => { + const labels: string[] = [] + if (update.now_tau) labels.push('Time unit') + if (update.lower_bound_update) labels.push('Lower bound') + if (update.upper_bound_update) labels.push('Upper bound') + return labels.join(', ') || 'No linked update rows' +} + +export const TimeUnitUpdateTab = () => { + const { data } = useDetailContext<{ now_time_update: TimeUnitUpdateSummary[] }>() + const updates = data.now_time_update + + if (!Array.isArray(updates)) { + return ( + + No updates available. + + ) + } + + const columns: MRT_ColumnDef[] = [ + { + accessorKey: 'date', + header: 'Date', + Cell: ({ cell }) => formatDate(cell.getValue() as Date | string | null), + }, + { + accessorKey: 'authorizer', + header: 'Editor', + }, + { + accessorKey: 'coordinator', + header: 'Coordinator', + }, + { + header: 'Linked updates', + accessorFn: getLinkedUpdateLabels, + }, + { + header: 'References', + Cell: ({ row }: { row: MRT_Row }) => ( + + ), + }, + { + header: 'Details', + Cell: ({ row }: { row: MRT_Row }) => , + }, + ] + + return ( + + + + ) +} + +const DetailsModal = ({ update }: { update: TimeUnitUpdateSummary }) => { + const references = getReferences(update) + + return ( + +

Update log

+ + +

+ Date: {formatDate(update.date)} +

+

+ Editor: {update.authorizer} +

+

+ Coordinator: {update.coordinator} +

+

+ Comment: {update.comment ?? ''} +

+
+
+ +

References

+ {references.length === 0 ? Update has no references. : } + +

Changed database values

+ + + + + +
+ ) +} + +const logColumns: MRT_ColumnDef[] = [ + { + header: 'Table', + accessorKey: 'table_name', + }, + { + header: 'Field', + accessorKey: 'column_name', + }, + { + header: 'Action', + accessorFn: ({ log_action }) => (log_action === 1 ? 'Delete' : log_action === 3 ? 'Update' : 'Add'), + }, + { + header: 'Old data', + accessorKey: 'old_data', + }, + { + header: 'New data', + accessorKey: 'new_data', + }, +] + +const ChangeLogTable = ({ updates }: { updates: UpdateLog[] }) => + updates.length === 0 ? ( + No changed database values found for this linked update. + ) : ( + + ) + +const TimeUnitUpdateSection = ({ title, update }: { title: string; update: TimeUnitUpdate | null }) => { + if (!update) return null + + return ( + + {title} + + {formatDate(update.tau_date)} / {update.tau_authorizer} / {update.tau_comment ?? ''} + + + + ) +} + +const TimeBoundUpdateSection = ({ title, update }: { title: string; update: TimeBoundUpdate | null }) => { + if (!update) return null + + return ( + + {title} + + {formatDate(update.bau_date)} / {update.bau_authorizer} / {update.bau_comment ?? ''} + + + + ) +} diff --git a/frontend/src/components/TimeUnit/TimeUnitDetails.tsx b/frontend/src/components/TimeUnit/TimeUnitDetails.tsx index c95f659a..9886e15a 100755 --- a/frontend/src/components/TimeUnit/TimeUnitDetails.tsx +++ b/frontend/src/components/TimeUnit/TimeUnitDetails.tsx @@ -8,10 +8,10 @@ import { useGetTimeUnitDetailsQuery, } from '../../redux/timeUnitReducer' import { emptyTimeUnit } from '../DetailView/common/defaultValues' -import { UpdateTab } from '../DetailView/common/UpdateTab' import { DetailView, TabType } from '../DetailView/DetailView' import { LocalityTab } from './Tabs/LocalityTab' import { TimeUnitTab } from './Tabs/TimeUnitTab' +import { TimeUnitUpdateTab } from './Tabs/TimeUnitUpdateTab' import { validateTimeUnit } from '@/shared/validators/timeUnit' import { makeEditData } from '../DetailView/Context/DetailContext' import { useDeleteTimeUnit } from '@/hooks/useDeleteTimeUnit' @@ -87,7 +87,7 @@ export const TimeUnitDetails = ({ }, { title: 'Updates', - content: , + content: , }, ] diff --git a/frontend/src/shared/types/data.ts b/frontend/src/shared/types/data.ts index 4f728649..dd3e9d41 100755 --- a/frontend/src/shared/types/data.ts +++ b/frontend/src/shared/types/data.ts @@ -489,17 +489,24 @@ export type TimeBound = { export type TimeBoundDetailsType = Prisma.now_tu_bound & { now_bau: TimeBoundUpdate[] } -export type TimeBoundUpdate = Prisma.now_bau & { now_br: Prisma.now_br } & { updates: UpdateLog[] } +export type TimeBoundUpdate = Prisma.now_bau & { now_br: TimeBoundReference[] } & { updates: UpdateLog[] } /* Time Unit */ export type TimeUnitSequence = Prisma.now_tu_sequence export type TimeUnitUpdate = Prisma.now_tau & { - now_tr: Prisma.now_tr & { ref_ref: { ref_authors: string; ref_journal: string }[] } + now_tr: TimeUnitReference[] } & { updates: UpdateLog[] } +export type TimeUnitUpdateSummary = Prisma.now_time_update & { + now_tau: TimeUnitUpdate | null + lower_bound_update: TimeBoundUpdate | null + upper_bound_update: TimeBoundUpdate | null +} + export type TimeUnitDetailsType = Prisma.now_time_unit & { now_tu_sequence: SequenceDetailsType } & { now_tau: Array + now_time_update: Array } & { low_bound: Prisma.now_tu_bound up_bound: Prisma.now_tu_bound diff --git a/frontend/src/tests/components/DetailView/DetailViewCancelEdit.test.tsx b/frontend/src/tests/components/DetailView/DetailViewCancelEdit.test.tsx index 4d51d8f6..498e43be 100644 --- a/frontend/src/tests/components/DetailView/DetailViewCancelEdit.test.tsx +++ b/frontend/src/tests/components/DetailView/DetailViewCancelEdit.test.tsx @@ -25,6 +25,7 @@ const timeUnitData = { tu_comment: '', now_tu_sequence: { sequence: 'seq-1', seq_name: 'Sequence 1' }, now_tau: [], + now_time_update: [], up_bound: { bid: 1, b_name: 'Upper', age: 20, b_comment: '' }, low_bound: { bid: 2, b_name: 'Lower', age: 10, b_comment: '' }, } as unknown as TimeUnitDetailsType diff --git a/frontend/src/tests/components/DetailView/DetailViewUnsavedPrompt.test.tsx b/frontend/src/tests/components/DetailView/DetailViewUnsavedPrompt.test.tsx index 43cf2e8b..ff5ecf6e 100644 --- a/frontend/src/tests/components/DetailView/DetailViewUnsavedPrompt.test.tsx +++ b/frontend/src/tests/components/DetailView/DetailViewUnsavedPrompt.test.tsx @@ -26,6 +26,7 @@ const timeUnitData = { tu_comment: '', now_tu_sequence: { sequence: 'seq-1', seq_name: 'Sequence 1' }, now_tau: [], + now_time_update: [], up_bound: { bid: 1, b_name: 'Upper', age: 20, b_comment: '' }, low_bound: { bid: 2, b_name: 'Lower', age: 10, b_comment: '' }, } as unknown as TimeUnitDetailsType