diff --git a/src/views/workflow-history/helpers/workflow-history-grouper.types.ts b/src/views/workflow-history/helpers/workflow-history-grouper.types.ts index 782d5b473..5d384e626 100644 --- a/src/views/workflow-history/helpers/workflow-history-grouper.types.ts +++ b/src/views/workflow-history/helpers/workflow-history-grouper.types.ts @@ -1,5 +1,3 @@ -import { type HistoryEvent } from '@/__generated__/proto-ts/uber/cadence/api/v1/HistoryEvent'; - import type { HistoryEventsGroups, PendingActivityTaskStartEvent, diff --git a/src/views/workflow-history/hooks/__tests__/use-initial-selected-event.test.ts b/src/views/workflow-history/hooks/__tests__/use-initial-selected-event.test.ts index 79f9771b7..7eaf13b5d 100644 --- a/src/views/workflow-history/hooks/__tests__/use-initial-selected-event.test.ts +++ b/src/views/workflow-history/hooks/__tests__/use-initial-selected-event.test.ts @@ -1,21 +1,36 @@ import { renderHook } from '@/test-utils/rtl'; -import { completedDecisionTaskEvents } from '../../__fixtures__/workflow-history-decision-events'; +import { + mockActivityEventGroup, + mockDecisionEventGroup, + mockTimerEventGroup, + mockSingleEventGroup, +} from '../../__fixtures__/workflow-history-event-groups'; +import { type HistoryEventsGroup } from '../../workflow-history.types'; import useInitialSelectedEvent from '../use-initial-selected-event'; -jest.mock('../../helpers/get-history-event-group-id'); - describe('useInitialSelectedEvent', () => { - const events = [...completedDecisionTaskEvents]; - const filteredEventGroupsEntries: [string, any][] = [ - ['group1', completedDecisionTaskEvents], - ]; + // Create a more realistic set of event groups with multiple types + const mockEventGroups: Record = { + '1': mockSingleEventGroup, + '2': mockDecisionEventGroup, + '5': mockActivityEventGroup, + '10': mockTimerEventGroup, + '11': mockDecisionEventGroup, + '12': mockActivityEventGroup, + }; + + it('should return shouldSearchForInitialEvent as true when selectedEventId is defined', () => { + // Filtered entries contain only a subset of all event groups + const filteredEventGroupsEntries: [string, HistoryEventsGroup][] = [ + ['2', mockEventGroups['2']], + ['5', mockEventGroups['5']], + ]; - it('should return shouldSearchForInitialEvent as true when initialEventId is defined', () => { const { result } = renderHook(() => useInitialSelectedEvent({ selectedEventId: '2', - events, + eventGroups: mockEventGroups, filteredEventGroupsEntries, }) ); @@ -23,11 +38,17 @@ describe('useInitialSelectedEvent', () => { expect(result.current.shouldSearchForInitialEvent).toBe(true); }); - it('should return shouldSearchForInitialEvent as false when initialEventId is undefined', () => { + it('should return shouldSearchForInitialEvent as false when selectedEventId is undefined', () => { + const filteredEventGroupsEntries: [string, HistoryEventsGroup][] = [ + ['1', mockEventGroups['1']], + ['2', mockEventGroups['2']], + ['5', mockEventGroups['5']], + ]; + const { result } = renderHook(() => useInitialSelectedEvent({ selectedEventId: undefined, - events, + eventGroups: mockEventGroups, filteredEventGroupsEntries, }) ); @@ -35,27 +56,138 @@ describe('useInitialSelectedEvent', () => { expect(result.current.shouldSearchForInitialEvent).toBe(false); }); - it('should return initialEventGroupIndex as undefined when initialEventId is defined & group is not found', () => { + it('should return initialEventGroupIndex when event is found in a group and group key matches event ID', () => { + // Filtered entries contain only a subset - event '2' is at index 1 + const filteredEventGroupsEntries: [string, HistoryEventsGroup][] = [ + ['1', mockEventGroups['1']], + ['2', mockEventGroups['2']], + ['5', mockEventGroups['5']], + ]; + const { result } = renderHook(() => useInitialSelectedEvent({ - selectedEventId: '500', - events, - filteredEventGroupsEntries: [], + selectedEventId: '2', + eventGroups: mockEventGroups, + filteredEventGroupsEntries, + }) + ); + + expect(result.current.initialEventGroupIndex).toBe(1); + expect(result.current.initialEventFound).toBe(true); + }); + + it('should return initialEventGroupIndex as undefined when selectedEventId is defined & event is not found in filtered entries', () => { + // Group '2' exists in mockEventGroups but is filtered out from the visible list + const filteredEventGroupsEntries: [string, HistoryEventsGroup][] = [ + ['1', mockEventGroups['1']], + ['10', mockEventGroups['10']], + ]; + + const { result } = renderHook(() => + useInitialSelectedEvent({ + selectedEventId: '2', + eventGroups: mockEventGroups, + filteredEventGroupsEntries, }) ); expect(result.current.initialEventGroupIndex).toBe(undefined); }); - it('should return initialEventFound as false when initialEventId is defined & event is not found', () => { + it('should find event when group key does not match event ID but group contains the event', () => { + // Group key is '5' but contains event with ID '7' (activity events) + // The hook should find the event in the group regardless of the group key not matching + // Event '7' is in group '5' which is at index 1 in filtered entries + const filteredEventGroupsEntries: [string, HistoryEventsGroup][] = [ + ['2', mockEventGroups['2']], + ['5', mockEventGroups['5']], + ]; + + const { result } = renderHook(() => + useInitialSelectedEvent({ + selectedEventId: '7', + eventGroups: mockEventGroups, + filteredEventGroupsEntries, + }) + ); + + expect(result.current.initialEventFound).toBe(true); + expect(result.current.initialEventGroupIndex).toBe(1); + }); + + it('should return initialEventFound as false when selectedEventId is defined & event is not found in groups', () => { + // Event ID '500' doesn't exist in any group + const filteredEventGroupsEntries: [string, HistoryEventsGroup][] = [ + ['1', mockEventGroups['1']], + ['2', mockEventGroups['2']], + ['5', mockEventGroups['5']], + ['10', mockEventGroups['10']], + ]; + const { result } = renderHook(() => useInitialSelectedEvent({ selectedEventId: '500', - events, + eventGroups: mockEventGroups, filteredEventGroupsEntries, }) ); expect(result.current.initialEventFound).toBe(false); }); + + it('should return initialEventFound as false when eventGroups is empty', () => { + // Edge case: no event groups available at all + const { result } = renderHook(() => + useInitialSelectedEvent({ + selectedEventId: '2', + eventGroups: {}, + filteredEventGroupsEntries: [], + }) + ); + + expect(result.current.initialEventFound).toBe(false); + expect(result.current.initialEventGroupIndex).toBe(undefined); + }); + + it('should find event at correct index when multiple groups are filtered', () => { + // Realistic scenario: many groups but only some are visible after filtering + // Event '7' is in group '5' which should be at index 2 in the filtered list + const filteredEventGroupsEntries: [string, HistoryEventsGroup][] = [ + ['1', mockEventGroups['1']], + ['2', mockEventGroups['2']], + ['5', mockEventGroups['5']], + ]; + + const { result } = renderHook(() => + useInitialSelectedEvent({ + selectedEventId: '7', + eventGroups: mockEventGroups, + filteredEventGroupsEntries, + }) + ); + + expect(result.current.initialEventFound).toBe(true); + expect(result.current.initialEventGroupIndex).toBe(2); + }); + + it('should handle event at the end of filtered list', () => { + // Event '16' is in group '10' which is at the last position in the filtered list + const filteredEventGroupsEntries: [string, HistoryEventsGroup][] = [ + ['1', mockEventGroups['1']], + ['2', mockEventGroups['2']], + ['5', mockEventGroups['5']], + ['10', mockEventGroups['10']], + ]; + + const { result } = renderHook(() => + useInitialSelectedEvent({ + selectedEventId: '16', + eventGroups: mockEventGroups, + filteredEventGroupsEntries, + }) + ); + + expect(result.current.initialEventFound).toBe(true); + expect(result.current.initialEventGroupIndex).toBe(3); + }); }); diff --git a/src/views/workflow-history/hooks/use-initial-selected-event.ts b/src/views/workflow-history/hooks/use-initial-selected-event.ts index bbe31f9e4..f3f055f5c 100644 --- a/src/views/workflow-history/hooks/use-initial-selected-event.ts +++ b/src/views/workflow-history/hooks/use-initial-selected-event.ts @@ -1,6 +1,4 @@ -import { useMemo, useState } from 'react'; - -import getHistoryEventGroupId from '../helpers/get-history-event-group-id'; +import { useMemo, useRef, useState } from 'react'; import { type UseInitialSelectedEventParams } from './use-initial-selected-event.types'; @@ -12,27 +10,42 @@ import { type UseInitialSelectedEventParams } from './use-initial-selected-event */ export default function useInitialSelectedEvent({ selectedEventId, - events, + eventGroups, filteredEventGroupsEntries, }: UseInitialSelectedEventParams) { + // preserve initial event id even if prop changed. const [initialEventId] = useState(selectedEventId); + const foundGroupIndexRef = useRef(undefined); - const initialEvent = useMemo(() => { + const initialEventGroupEntry = useMemo(() => { if (!initialEventId) return undefined; - return events.find((e) => e.eventId === initialEventId); - }, [events, initialEventId]); + + return Object.entries(eventGroups).find(([_, group]) => + group.events.find((e) => e.eventId === initialEventId) + ); + }, [eventGroups, initialEventId]); const shouldSearchForInitialEvent = initialEventId !== undefined; - const initialEventFound = initialEvent !== undefined; + const initialEventFound = initialEventGroupEntry !== undefined; const initialEventGroupIndex = useMemo(() => { - if (!initialEvent) return undefined; - const groupId = getHistoryEventGroupId(initialEvent); + if (!initialEventGroupEntry) return undefined; + + const groupId = initialEventGroupEntry[0]; + // If group index not change do not search again. + if ( + foundGroupIndexRef.current && + filteredEventGroupsEntries[foundGroupIndexRef.current][0] === groupId + ) + return foundGroupIndexRef.current; + const index = filteredEventGroupsEntries.findIndex( ([id]) => id === groupId ); - return index > -1 ? index : undefined; - }, [initialEvent, filteredEventGroupsEntries]); + const foundGroupIndex = index > -1 ? index : undefined; + foundGroupIndexRef.current = foundGroupIndex; + return foundGroupIndex; + }, [initialEventGroupEntry, filteredEventGroupsEntries]); return { shouldSearchForInitialEvent, diff --git a/src/views/workflow-history/hooks/use-initial-selected-event.types.ts b/src/views/workflow-history/hooks/use-initial-selected-event.types.ts index c8366a006..7e34369a2 100644 --- a/src/views/workflow-history/hooks/use-initial-selected-event.types.ts +++ b/src/views/workflow-history/hooks/use-initial-selected-event.types.ts @@ -1,7 +1,7 @@ -import { type HistoryEvent } from '@/__generated__/proto-ts/uber/cadence/api/v1/HistoryEvent'; +import { type HistoryEventsGroup } from '../workflow-history.types'; export type UseInitialSelectedEventParams = { - events: HistoryEvent[]; + eventGroups: Record; selectedEventId?: string; - filteredEventGroupsEntries: [string, any][]; + filteredEventGroupsEntries: [string, HistoryEventsGroup][]; }; diff --git a/src/views/workflow-history/workflow-history.tsx b/src/views/workflow-history/workflow-history.tsx index 571ed112c..b66076d99 100644 --- a/src/views/workflow-history/workflow-history.tsx +++ b/src/views/workflow-history/workflow-history.tsx @@ -29,12 +29,12 @@ import { WORKFLOW_HISTORY_PAGE_SIZE_CONFIG } from './config/workflow-history-pag import compareUngroupedEvents from './helpers/compare-ungrouped-events'; import getSortableEventId from './helpers/get-sortable-event-id'; import getVisibleGroupsHasMissingEvents from './helpers/get-visible-groups-has-missing-events'; -import { groupHistoryEvents } from './helpers/group-history-events'; import pendingActivitiesInfoToEvents from './helpers/pending-activities-info-to-events'; import pendingDecisionInfoToEvent from './helpers/pending-decision-info-to-event'; import useEventExpansionToggle from './hooks/use-event-expansion-toggle'; import useInitialSelectedEvent from './hooks/use-initial-selected-event'; import useWorkflowHistoryFetcher from './hooks/use-workflow-history-fetcher'; +import useWorkflowHistoryGrouper from './hooks/use-workflow-history-grouper'; import WorkflowHistoryCompactEventCard from './workflow-history-compact-event-card/workflow-history-compact-event-card'; import { WorkflowHistoryContext } from './workflow-history-context-provider/workflow-history-context-provider'; import WorkflowHistoryHeader from './workflow-history-header/workflow-history-header'; @@ -59,6 +59,12 @@ export default function WorkflowHistory({ params }: Props) { waitForNewEvent: true, }; + const { + eventGroups, + updateEvents: updateGrouperEvents, + updatePendingEvents: updateGrouperPendingEvents, + } = useWorkflowHistoryGrouper(); + const { historyQuery, startLoadingHistory, @@ -73,8 +79,7 @@ export default function WorkflowHistory({ params }: Props) { pageSize: wfHistoryRequestArgs.pageSize, waitForNewEvent: wfHistoryRequestArgs.waitForNewEvent, }, - //TODO: @assem.hafez replace this with grouper callback - () => {}, + updateGrouperEvents, 2000 ); @@ -125,7 +130,7 @@ export default function WorkflowHistory({ params }: Props) { [result] ); - const pendingHistoryEvents = useMemo(() => { + useEffect(() => { const pendingStartActivities = pendingActivitiesInfoToEvents( wfExecutionDescription.pendingActivities ); @@ -133,16 +138,11 @@ export default function WorkflowHistory({ params }: Props) { ? pendingDecisionInfoToEvent(wfExecutionDescription.pendingDecision) : null; - return { + updateGrouperPendingEvents({ pendingStartActivities, pendingStartDecision, - }; - }, [wfExecutionDescription]); - - const eventGroups = useMemo( - () => groupHistoryEvents(events, pendingHistoryEvents), - [events, pendingHistoryEvents] - ); + }); + }, [wfExecutionDescription, updateGrouperPendingEvents]); const filteredEventGroupsEntries = useMemo( () => @@ -235,7 +235,7 @@ export default function WorkflowHistory({ params }: Props) { shouldSearchForInitialEvent, } = useInitialSelectedEvent({ selectedEventId: queryParams.historySelectedEventId, - events, + eventGroups, filteredEventGroupsEntries, });