diff --git a/src/views/workflow-history-v2/__tests__/workflow-history-v2.test.tsx b/src/views/workflow-history-v2/__tests__/workflow-history-v2.test.tsx index 9292b2188..16abd9475 100644 --- a/src/views/workflow-history-v2/__tests__/workflow-history-v2.test.tsx +++ b/src/views/workflow-history-v2/__tests__/workflow-history-v2.test.tsx @@ -16,12 +16,29 @@ import { type HistoryEvent } from '@/__generated__/proto-ts/uber/cadence/api/v1/ import * as usePageFiltersModule from '@/components/page-filters/hooks/use-page-filters'; import { type PageQueryParamValues } from '@/hooks/use-page-query-params/use-page-query-params.types'; import { type GetWorkflowHistoryResponse } from '@/route-handlers/get-workflow-history/get-workflow-history.types'; +import { + type PendingActivityTaskStartEvent, + type PendingDecisionTaskStartEvent, +} from '@/views/workflow-history/workflow-history.types'; import { mockDescribeWorkflowResponse } from '@/views/workflow-page/__fixtures__/describe-workflow-response'; import type workflowPageQueryParamsConfig from '@/views/workflow-page/config/workflow-page-query-params.config'; -import { completedActivityTaskEvents } from '../../workflow-history/__fixtures__/workflow-history-activity-events'; -import { completedDecisionTaskEvents } from '../../workflow-history/__fixtures__/workflow-history-decision-events'; +import { + completedActivityTaskEvents, + failedActivityTaskEvents, + scheduleActivityTaskEvent, +} from '../../workflow-history/__fixtures__/workflow-history-activity-events'; +import { + completedDecisionTaskEvents, + failedDecisionTaskEvents, + scheduleDecisionTaskEvent, +} from '../../workflow-history/__fixtures__/workflow-history-decision-events'; +import { + pendingActivityTaskStartEvent, + pendingDecisionTaskStartEvent, +} from '../../workflow-history/__fixtures__/workflow-history-pending-events'; import { WorkflowHistoryContext } from '../../workflow-history/workflow-history-context-provider/workflow-history-context-provider'; +import { type Props as NavbarProps } from '../workflow-history-navigation-bar/workflow-history-navigation-bar.types'; import WorkflowHistoryV2 from '../workflow-history-v2'; jest.mock('@/hooks/use-page-query-params/use-page-query-params', () => @@ -111,9 +128,22 @@ jest.mock( jest.mock( '../workflow-history-navigation-bar/workflow-history-navigation-bar', () => - jest.fn(() => ( -
Navigation Bar
- )) + jest.fn( + ({ failedEventsMenuItems, pendingEventsMenuItems }: NavbarProps) => ( +
+ {failedEventsMenuItems && failedEventsMenuItems.length > 0 && ( +
+ {failedEventsMenuItems.length} failed events +
+ )} + {pendingEventsMenuItems && pendingEventsMenuItems.length > 0 && ( +
+ {pendingEventsMenuItems.length} pending events +
+ )} +
+ ) + ) ); jest.mock('@/utils/decode-url-params', () => jest.fn((params) => params)); @@ -408,6 +438,30 @@ describe(WorkflowHistoryV2.name, () => { await screen.findByTestId('ungrouped-selected-event-id') ).toHaveTextContent('test-event-id'); }); + + it('passes failed events menu items to navigation bar when failed events exist', async () => { + await setup({ + historyEvents: [...failedActivityTaskEvents, ...failedDecisionTaskEvents], + }); + + const failedItemsCounter = await screen.findByTestId( + 'failed-events-menu-items-count' + ); + expect(failedItemsCounter).toHaveTextContent('2 failed events'); + }); + + it('passes pending events menu items to navigation bar when pending events exist', async () => { + await setup({ + historyEvents: [scheduleActivityTaskEvent, scheduleDecisionTaskEvent], + pendingActivities: [pendingActivityTaskStartEvent], + pendingDecision: pendingDecisionTaskStartEvent, + }); + + const pendingItemsCounter = await screen.findByTestId( + 'pending-events-menu-items-count' + ); + expect(pendingItemsCounter).toHaveTextContent('2 pending events'); + }); }); async function setup({ @@ -417,6 +471,9 @@ async function setup({ pageQueryParamsValues = {}, hasNextPage, ungroupedViewPreference, + historyEvents = completedActivityTaskEvents, + pendingActivities, + pendingDecision, }: { error?: boolean; summaryError?: boolean; @@ -426,7 +483,10 @@ async function setup({ >; hasNextPage?: boolean; ungroupedViewPreference?: boolean | null; -}) { + historyEvents?: Array; + pendingActivities?: Array; + pendingDecision?: PendingDecisionTaskStartEvent | null; +} = {}) { const user = userEvent.setup(); const mockSetQueryParams = jest.fn(); @@ -497,12 +557,10 @@ async function setup({ ); } - const events: Array = completedActivityTaskEvents; - return HttpResponse.json( { history: { - events, + events: historyEvents, }, archived: false, nextPageToken: hasNextPage ? 'mock-next-page-token' : '', @@ -526,7 +584,18 @@ async function setup({ }, } : { - jsonResponse: mockDescribeWorkflowResponse, + httpResolver: () => { + const describeResponse = { + ...mockDescribeWorkflowResponse, + ...(pendingActivities && pendingActivities.length > 0 + ? { + pendingActivities, + } + : {}), + ...(pendingDecision ? { pendingDecision } : {}), + }; + return HttpResponse.json(describeResponse, { status: 200 }); + }, }), }, ], diff --git a/src/views/workflow-history-v2/helpers/__tests__/get-navigation-bar-events-menu-items.test.ts b/src/views/workflow-history-v2/helpers/__tests__/get-navigation-bar-events-menu-items.test.ts new file mode 100644 index 000000000..94bd12132 --- /dev/null +++ b/src/views/workflow-history-v2/helpers/__tests__/get-navigation-bar-events-menu-items.test.ts @@ -0,0 +1,135 @@ +import { + mockActivityEventGroup, + mockDecisionEventGroup, +} from '@/views/workflow-history/__fixtures__/workflow-history-event-groups'; +import { type HistoryEventsGroup } from '@/views/workflow-history/workflow-history.types'; + +import { type EventGroupEntry } from '../../workflow-history-v2.types'; +import getNavigationBarEventsMenuItems from '../get-navigation-bar-events-menu-items'; + +jest.mock( + '../../workflow-history-event-group/helpers/get-event-group-filtering-type', + () => + jest.fn((group: HistoryEventsGroup) => { + if (group.groupType === 'Activity') return 'ACTIVITY'; + if (group.groupType === 'Decision') return 'DECISION'; + return 'WORKFLOW'; + }) +); + +describe(getNavigationBarEventsMenuItems.name, () => { + it('should return an empty array when eventGroupsEntries is empty', () => { + const eventGroupsEntries: Array = []; + + const result = getNavigationBarEventsMenuItems( + eventGroupsEntries, + () => true + ); + + expect(result).toEqual([]); + }); + + it('should skip groups with no events', () => { + const groupWithoutEvents: HistoryEventsGroup = { + ...mockActivityEventGroup, + events: [], + }; + const eventGroupsEntries: Array = [ + ['group1', groupWithoutEvents], + ]; + + const result = getNavigationBarEventsMenuItems( + eventGroupsEntries, + () => true + ); + + expect(result).toEqual([]); + }); + + it('should skip groups filtered out by filterFn', () => { + const eventGroupsEntries: Array = [ + ['group1', mockActivityEventGroup], + ['group2', mockDecisionEventGroup], + ]; + const filterFn = jest.fn((group: HistoryEventsGroup) => { + return group.groupType === 'Decision'; + }); + + const result = getNavigationBarEventsMenuItems( + eventGroupsEntries, + filterFn + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + eventId: '4', // last eventId from completedDecisionTaskEvents + label: 'Mock decision', + type: 'DECISION', + }); + }); + + it('should include groups that pass filterFn', () => { + const eventGroupsEntries: Array = [ + ['group1', mockActivityEventGroup], + ['group2', mockDecisionEventGroup], + ]; + + const result = getNavigationBarEventsMenuItems( + eventGroupsEntries, + () => true + ); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + eventId: '10', // last eventId from completedActivityTaskEvents + label: 'Mock event', + type: 'ACTIVITY', + }); + expect(result[1]).toEqual({ + eventId: '4', // last eventId from completedDecisionTaskEvents + label: 'Mock decision', + type: 'DECISION', + }); + }); + + it('should use shortLabel when available, otherwise use label', () => { + const groupWithShortLabel: HistoryEventsGroup = { + ...mockActivityEventGroup, + label: 'Long Label', + shortLabel: 'Short', + }; + const groupWithoutShortLabel: HistoryEventsGroup = { + ...mockDecisionEventGroup, + label: 'Decision Label', + }; + const eventGroupsEntries: Array = [ + ['group1', groupWithShortLabel], + ['group2', groupWithoutShortLabel], + ]; + + const result = getNavigationBarEventsMenuItems( + eventGroupsEntries, + () => true + ); + + expect(result).toHaveLength(2); + expect(result[0].label).toBe('Short'); + expect(result[1].label).toBe('Decision Label'); + }); + + it('should use the last event eventId from each group', () => { + const eventGroupsEntries: Array = [ + ['group1', mockActivityEventGroup], + ]; + + const result = getNavigationBarEventsMenuItems( + eventGroupsEntries, + () => true + ); + + expect(result).toHaveLength(1); + // completedActivityTaskEvents has events with ids: '7', '9', '10' + // Last eventId should be '10' + expect(result[0].eventId).toBe('10'); + }); +}); diff --git a/src/views/workflow-history-v2/helpers/get-navigation-bar-events-menu-items.ts b/src/views/workflow-history-v2/helpers/get-navigation-bar-events-menu-items.ts new file mode 100644 index 000000000..83e3f005c --- /dev/null +++ b/src/views/workflow-history-v2/helpers/get-navigation-bar-events-menu-items.ts @@ -0,0 +1,28 @@ +import { type HistoryEventsGroup } from '@/views/workflow-history/workflow-history.types'; + +import getEventGroupFilteringType from '../workflow-history-event-group/helpers/get-event-group-filtering-type'; +import { type NavigationBarEventsMenuItem } from '../workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu.types'; +import { type EventGroupEntry } from '../workflow-history-v2.types'; + +export default function getNavigationBarEventsMenuItems( + eventGroupsEntries: Array, + filterFn: (group: HistoryEventsGroup) => boolean +): Array { + return eventGroupsEntries.reduce>( + (acc, [_, group]) => { + const lastEventId = group.events.at(-1)?.eventId; + if (!lastEventId) return acc; + + if (!filterFn(group)) return acc; + + acc.push({ + eventId: lastEventId, + label: group.shortLabel ?? group.label, + type: getEventGroupFilteringType(group), + }); + + return acc; + }, + [] + ); +} diff --git a/src/views/workflow-history-v2/workflow-history-navigation-bar-events-menu/__tests__/workflow-history-navigation-bar-events-menu.test.tsx b/src/views/workflow-history-v2/workflow-history-navigation-bar-events-menu/__tests__/workflow-history-navigation-bar-events-menu.test.tsx new file mode 100644 index 000000000..0915e1c99 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-navigation-bar-events-menu/__tests__/workflow-history-navigation-bar-events-menu.test.tsx @@ -0,0 +1,187 @@ +import { useState } from 'react'; + +import { type StatefulPopoverProps } from 'baseui/popover'; + +import { render, screen, userEvent } from '@/test-utils/rtl'; + +import WorkflowHistoryNavigationBarEventsMenu from '../workflow-history-navigation-bar-events-menu'; +import { type Props } from '../workflow-history-navigation-bar-events-menu.types'; + +// Mock StatefulPopover to render content immediately in tests +jest.mock('baseui/popover', () => { + const originalModule = jest.requireActual('baseui/popover'); + return { + ...originalModule, + StatefulPopover: ({ content, children }: StatefulPopoverProps) => { + const [isShown, setIsShown] = useState(false); + + return ( +
+
setIsShown(true)}>{children}
+ {isShown ? ( +
+ {typeof content === 'function' && + content({ + close: () => { + setIsShown(false); + }, + })} +
+ ) : null} +
+ ); + }, + }; +}); + +describe(WorkflowHistoryNavigationBarEventsMenu.name, () => { + it('renders children', () => { + setup(); + + expect(screen.getByText('Open Popover')).toBeInTheDocument(); + }); + + it('does not show popover content initially', () => { + setup(); + + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument(); + }); + + it('renders all menu items when popover is opened', async () => { + const { user } = setup({ + menuItems: [ + { + eventId: 'event-1', + label: 'Event 1', + type: 'ACTIVITY', + }, + { + eventId: 'event-2', + label: 'Event 2', + type: 'DECISION', + }, + ], + }); + + await user.click(screen.getByText('Open Popover')); + + expect(screen.getByTestId('popover-content')).toBeInTheDocument(); + expect(screen.getByText('Event 1')).toBeInTheDocument(); + expect(screen.getByText('Event 2')).toBeInTheDocument(); + }); + + it('calls onClickEvent and closes popover when menu item is clicked', async () => { + const { user, mockOnClickEvent } = setup({ + menuItems: [ + { + eventId: 'event-1', + label: 'Event 1', + type: 'ACTIVITY', + }, + ], + }); + + await user.click(screen.getByText('Open Popover')); + + expect(screen.queryByTestId('popover-content')).toBeInTheDocument(); + const eventButton = screen.getByText('Event 1'); + await user.click(eventButton); + + expect(mockOnClickEvent).toHaveBeenCalledWith('event-1'); + expect(mockOnClickEvent).toHaveBeenCalledTimes(1); + + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument(); + }); + + it('shows pagination when there are more than 10 items', async () => { + const { user } = setup({ + menuItems: Array.from({ length: 15 }, (_, i) => ({ + eventId: `event-${i + 1}`, + label: `Event ${i + 1}`, + type: 'ACTIVITY', + })), + }); + + await user.click(screen.getByText('Open Popover')); + + expect(screen.getByTestId('pagination-container')).toBeInTheDocument(); + }); + + it('does not show pagination when there are 10 or fewer items', async () => { + const { user } = setup({ + menuItems: Array.from({ length: 10 }, (_, i) => ({ + eventId: `event-${i + 1}`, + label: `Event ${i + 1}`, + type: 'ACTIVITY', + })), + }); + + await user.click(screen.getByText('Open Popover')); + + expect( + screen.queryByTestId('pagination-container') + ).not.toBeInTheDocument(); + }); + + it('shows first 10 items on initial page', async () => { + const { user } = setup({ + menuItems: Array.from({ length: 15 }, (_, i) => ({ + eventId: `event-${i + 1}`, + label: `Event ${i + 1}`, + type: 'ACTIVITY', + })), + }); + + await user.click(screen.getByText('Open Popover')); + + expect(screen.getByText('Event 1')).toBeInTheDocument(); + expect(screen.getByText('Event 10')).toBeInTheDocument(); + expect(screen.queryByText('Event 11')).not.toBeInTheDocument(); + }); + + it('shows correct items when pagination page is changed', async () => { + const { user } = setup({ + menuItems: Array.from({ length: 15 }, (_, i) => ({ + eventId: `event-${i + 1}`, + label: `Event ${i + 1}`, + type: 'ACTIVITY', + })), + }); + + await user.click(screen.getByText('Open Popover')); + + const nextPageButton = await screen.findByLabelText(/next page/i); + await user.click(nextPageButton); + + expect(screen.queryByText('Event 1')).not.toBeInTheDocument(); + expect(screen.getByText('Event 11')).toBeInTheDocument(); + expect(screen.getByText('Event 15')).toBeInTheDocument(); + }); +}); + +function setup(overrides: Partial = {}) { + const user = userEvent.setup(); + const mockOnClickEvent = jest.fn(); + + render( + + + + ); + + return { + user, + mockOnClickEvent, + }; +} diff --git a/src/views/workflow-history-v2/workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu.constants.ts b/src/views/workflow-history-v2/workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu.constants.ts new file mode 100644 index 000000000..2fc3e8773 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu.constants.ts @@ -0,0 +1 @@ +export const NAVBAR_MENU_ITEMS_PER_PAGE = 10; diff --git a/src/views/workflow-history-v2/workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu.styles.ts b/src/views/workflow-history-v2/workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu.styles.ts new file mode 100644 index 000000000..4ffd0f993 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu.styles.ts @@ -0,0 +1,48 @@ +import { styled as createStyled, type Theme } from 'baseui'; +import { type PaginationOverrides } from 'baseui/pagination'; +import { type StyleObject } from 'styletron-react'; + +export const styled = { + MenuItemsContainer: createStyled('div', ({ $theme }: { $theme: Theme }) => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + padding: $theme.sizing.scale200, + backgroundColor: $theme.colors.backgroundPrimary, + borderRadius: $theme.borders.radius400, + })), + MenuItemContainer: createStyled('div', ({ $theme }: { $theme: Theme }) => ({ + display: 'flex', + gap: $theme.sizing.scale500, + alignItems: 'flex-start', + })), + PaginationContainer: createStyled('div', ({ $theme }: { $theme: Theme }) => ({ + display: 'flex', + justifyContent: 'center', + marginTop: $theme.sizing.scale300, + paddingTop: $theme.sizing.scale300, + borderTop: `1px solid ${$theme.colors.borderOpaque}`, + })), +}; + +export const overrides = { + pagination: { + Select: { + props: { + overrides: { + SingleValue: { + style: ({ $theme }: { $theme: Theme }): StyleObject => ({ + ...$theme.typography.ParagraphXSmall, + }), + }, + }, + }, + }, + MaxLabel: { + style: ({ $theme }: { $theme: Theme }): StyleObject => ({ + ...$theme.typography.ParagraphXSmall, + marginLeft: $theme.sizing.scale200, + }), + }, + } satisfies PaginationOverrides, +}; diff --git a/src/views/workflow-history-v2/workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu.tsx b/src/views/workflow-history-v2/workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu.tsx new file mode 100644 index 000000000..54569c010 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu.tsx @@ -0,0 +1,87 @@ +import { useState, useMemo, useEffect } from 'react'; + +import { Button } from 'baseui/button'; +import { Pagination } from 'baseui/pagination'; +import { StatefulPopover } from 'baseui/popover'; +import { MdCircle, MdOutlineCircle } from 'react-icons/md'; + +import workflowHistoryEventFilteringTypeColorsConfig from '../config/workflow-history-event-filtering-type-colors.config'; + +import { NAVBAR_MENU_ITEMS_PER_PAGE } from './workflow-history-navigation-bar-events-menu.constants'; +import { + styled, + overrides, +} from './workflow-history-navigation-bar-events-menu.styles'; +import { type Props } from './workflow-history-navigation-bar-events-menu.types'; + +export default function WorkflowHistoryNavigationBarEventsMenu({ + children, + isUngroupedHistoryView, + menuItems, + onClickEvent, +}: Props) { + const [currentPage, setCurrentPage] = useState(1); + + useEffect(() => { + setCurrentPage(1); + }, [menuItems.length]); + + const totalPages = Math.ceil(menuItems.length / NAVBAR_MENU_ITEMS_PER_PAGE); + + const paginatedItems = useMemo(() => { + const startIndex = (currentPage - 1) * NAVBAR_MENU_ITEMS_PER_PAGE; + const endIndex = startIndex + NAVBAR_MENU_ITEMS_PER_PAGE; + return menuItems.slice(startIndex, endIndex); + }, [menuItems, currentPage]); + + const MenuItemIcon = isUngroupedHistoryView ? MdOutlineCircle : MdCircle; + + return ( + ( + + {paginatedItems.map(({ eventId, label, type }) => ( + + + + ))} + {totalPages > 1 && ( + + { + setCurrentPage(nextPage); + }} + size="mini" + overrides={overrides.pagination} + /> + + )} + + )} + autoFocus={false} + placement="auto" + accessibilityType="menu" + > + {children} + + ); +} diff --git a/src/views/workflow-history-v2/workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu.types.ts b/src/views/workflow-history-v2/workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu.types.ts new file mode 100644 index 000000000..103e77532 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu.types.ts @@ -0,0 +1,14 @@ +import { type WorkflowHistoryEventFilteringType } from '@/views/workflow-history/workflow-history-filters-type/workflow-history-filters-type.types'; + +export type Props = { + children: React.ReactNode; + isUngroupedHistoryView: boolean; + menuItems: Array; + onClickEvent: (eventId: string) => void; +}; + +export type NavigationBarEventsMenuItem = { + type: WorkflowHistoryEventFilteringType; + eventId: string; + label: string; +}; diff --git a/src/views/workflow-history-v2/workflow-history-navigation-bar/__tests__/workflow-history-navigation-bar.test.tsx b/src/views/workflow-history-v2/workflow-history-navigation-bar/__tests__/workflow-history-navigation-bar.test.tsx index 709193f51..133725035 100644 --- a/src/views/workflow-history-v2/workflow-history-navigation-bar/__tests__/workflow-history-navigation-bar.test.tsx +++ b/src/views/workflow-history-v2/workflow-history-navigation-bar/__tests__/workflow-history-navigation-bar.test.tsx @@ -3,6 +3,14 @@ import { render, screen, userEvent } from '@/test-utils/rtl'; import WorkflowHistoryNavigationBar from '../workflow-history-navigation-bar'; import { type Props } from '../workflow-history-navigation-bar.types'; +jest.mock( + '../../workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu', + () => + jest.fn(({ children }: { children: React.ReactNode }) => { + return
{children}
; + }) +); + describe(WorkflowHistoryNavigationBar.name, () => { it('renders all navigation buttons', () => { setup(); @@ -54,6 +62,74 @@ describe(WorkflowHistoryNavigationBar.name, () => { expect(mockOnScrollUp).toHaveBeenCalledTimes(1); }); + + it('renders failed events button when failedEventsMenuItems is not empty', () => { + setup({ + failedEventsMenuItems: [ + { + eventId: 'event-1', + label: 'Failed Event 1', + type: 'ACTIVITY', + }, + ], + }); + + expect(screen.getByLabelText('Failed events')).toBeInTheDocument(); + expect(screen.getByText('1 failed event')).toBeInTheDocument(); + }); + + it('renders plural failed events text when multiple failed events exist', () => { + setup({ + failedEventsMenuItems: [ + { + eventId: 'event-1', + label: 'Failed Event 1', + type: 'ACTIVITY', + }, + { + eventId: 'event-2', + label: 'Failed Event 2', + type: 'DECISION', + }, + ], + }); + + expect(screen.getByText('2 failed events')).toBeInTheDocument(); + }); + + it('renders pending events button when pendingEventsMenuItems is not empty', () => { + setup({ + pendingEventsMenuItems: [ + { + eventId: 'event-1', + label: 'Pending Event 1', + type: 'ACTIVITY', + }, + ], + }); + + expect(screen.getByLabelText('Pending events')).toBeInTheDocument(); + expect(screen.getByText('1 pending event')).toBeInTheDocument(); + }); + + it('renders plural pending events text when multiple pending events exist', () => { + setup({ + pendingEventsMenuItems: [ + { + eventId: 'event-1', + label: 'Pending Event 1', + type: 'ACTIVITY', + }, + { + eventId: 'event-2', + label: 'Pending Event 2', + type: 'DECISION', + }, + ], + }); + + expect(screen.getByText('2 pending events')).toBeInTheDocument(); + }); }); function setup(overrides: Partial = {}) { @@ -61,6 +137,7 @@ function setup(overrides: Partial = {}) { const mockOnScrollUp = jest.fn(); const mockOnScrollDown = jest.fn(); const mockOnToggleAllItemsExpanded = jest.fn(); + const mockOnClickEvent = jest.fn(); render( = {}) { onScrollDown={mockOnScrollDown} areAllItemsExpanded={false} onToggleAllItemsExpanded={mockOnToggleAllItemsExpanded} + isUngroupedView={false} + failedEventsMenuItems={[]} + pendingEventsMenuItems={[]} + onClickEvent={mockOnClickEvent} {...overrides} /> ); @@ -77,5 +158,6 @@ function setup(overrides: Partial = {}) { mockOnScrollUp, mockOnScrollDown, mockOnToggleAllItemsExpanded, + mockOnClickEvent, }; } diff --git a/src/views/workflow-history-v2/workflow-history-navigation-bar/workflow-history-navigation-bar.styles.ts b/src/views/workflow-history-v2/workflow-history-navigation-bar/workflow-history-navigation-bar.styles.ts index 8ab30ed35..52fd225d6 100644 --- a/src/views/workflow-history-v2/workflow-history-navigation-bar/workflow-history-navigation-bar.styles.ts +++ b/src/views/workflow-history-v2/workflow-history-navigation-bar/workflow-history-navigation-bar.styles.ts @@ -31,14 +31,38 @@ export const styled = { ), }; +const buttonRootOverrides = { + style: ({ $theme }: { $theme: Theme }): StyleObject => ({ + paddingTop: $theme.sizing.scale200, + paddingBottom: $theme.sizing.scale200, + paddingLeft: $theme.sizing.scale200, + paddingRight: $theme.sizing.scale200, + }), +}; + export const overrides = { navActionButton: { - Root: { + Root: buttonRootOverrides, + } satisfies ButtonOverrides, + failedEventsButton: { + Root: buttonRootOverrides, + BaseButton: { + style: ({ $theme }: { $theme: Theme }): StyleObject => ({ + backgroundColor: $theme.colors.backgroundNegative, + ':hover': { + backgroundColor: $theme.colors.backgroundNegative, + }, + }), + }, + } satisfies ButtonOverrides, + pendingEventsButton: { + Root: buttonRootOverrides, + BaseButton: { style: ({ $theme }: { $theme: Theme }): StyleObject => ({ - paddingTop: $theme.sizing.scale200, - paddingBottom: $theme.sizing.scale200, - paddingLeft: $theme.sizing.scale200, - paddingRight: $theme.sizing.scale200, + backgroundColor: $theme.colors.backgroundAccent, + ':hover': { + backgroundColor: $theme.colors.backgroundAccent, + }, }), }, } satisfies ButtonOverrides, diff --git a/src/views/workflow-history-v2/workflow-history-navigation-bar/workflow-history-navigation-bar.tsx b/src/views/workflow-history-v2/workflow-history-navigation-bar/workflow-history-navigation-bar.tsx index adc4c2f20..6a08d0fe4 100644 --- a/src/views/workflow-history-v2/workflow-history-navigation-bar/workflow-history-navigation-bar.tsx +++ b/src/views/workflow-history-v2/workflow-history-navigation-bar/workflow-history-navigation-bar.tsx @@ -1,12 +1,16 @@ import { MdArrowDownward, MdArrowUpward, + MdErrorOutline, + MdHourglassTop, MdUnfoldLess, MdUnfoldMore, } from 'react-icons/md'; import Button from '@/components/button/button'; +import WorkflowHistoryNavigationBarEventsMenu from '../workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu'; + import { styled, overrides } from './workflow-history-navigation-bar.styles'; import { type Props } from './workflow-history-navigation-bar.types'; @@ -15,6 +19,10 @@ export default function WorkflowHistoryNavigationBar({ onScrollDown, areAllItemsExpanded, onToggleAllItemsExpanded, + isUngroupedView, + failedEventsMenuItems, + pendingEventsMenuItems, + onClickEvent, }: Props) { return ( @@ -53,6 +61,50 @@ export default function WorkflowHistoryNavigationBar({ > + {failedEventsMenuItems.length > 0 && ( + <> + + + + + + )} + {pendingEventsMenuItems.length > 0 && ( + <> + + + + + + )} ); } diff --git a/src/views/workflow-history-v2/workflow-history-navigation-bar/workflow-history-navigation-bar.types.ts b/src/views/workflow-history-v2/workflow-history-navigation-bar/workflow-history-navigation-bar.types.ts index 2e98f9f30..584747598 100644 --- a/src/views/workflow-history-v2/workflow-history-navigation-bar/workflow-history-navigation-bar.types.ts +++ b/src/views/workflow-history-v2/workflow-history-navigation-bar/workflow-history-navigation-bar.types.ts @@ -1,6 +1,12 @@ +import { type NavigationBarEventsMenuItem } from '../workflow-history-navigation-bar-events-menu/workflow-history-navigation-bar-events-menu.types'; + export type Props = { onScrollUp: () => void; onScrollDown: () => void; areAllItemsExpanded: boolean; onToggleAllItemsExpanded: () => void; + isUngroupedView: boolean; + failedEventsMenuItems: Array; + pendingEventsMenuItems: Array; + onClickEvent: (eventId: string) => void; }; diff --git a/src/views/workflow-history-v2/workflow-history-v2.tsx b/src/views/workflow-history-v2/workflow-history-v2.tsx index c1f4d0e17..0e3ef0b22 100644 --- a/src/views/workflow-history-v2/workflow-history-v2.tsx +++ b/src/views/workflow-history-v2/workflow-history-v2.tsx @@ -27,6 +27,7 @@ import useInitialSelectedEvent from '../workflow-history/hooks/use-initial-selec import useWorkflowHistoryFetcher from '../workflow-history/hooks/use-workflow-history-fetcher'; import useWorkflowHistoryGrouper from '../workflow-history/hooks/use-workflow-history-grouper'; import { WorkflowHistoryContext } from '../workflow-history/workflow-history-context-provider/workflow-history-context-provider'; +import filterGroupsByGroupStatus from '../workflow-history/workflow-history-filters-status/helpers/filter-groups-by-group-status'; import workflowPageQueryParamsConfig from '../workflow-page/config/workflow-page-query-params.config'; import { useSuspenseDescribeWorkflow } from '../workflow-page/hooks/use-describe-workflow'; import { type WorkflowPageTabContentParams } from '../workflow-page/workflow-page-tab-content/workflow-page-tab-content.types'; @@ -35,6 +36,7 @@ import WORKFLOW_HISTORY_FETCH_EVENTS_THROTTLE_MS_CONFIG from './config/workflow- import workflowHistoryFiltersConfig from './config/workflow-history-filters.config'; import WORKFLOW_HISTORY_RENDER_FETCHED_EVENTS_THROTTLE_MS_CONFIG from './config/workflow-history-render-fetched-events-throttle-ms.config'; import WORKFLOW_HISTORY_SET_RANGE_THROTTLE_MS_CONFIG from './config/workflow-history-set-range-throttle-ms.config'; +import getNavigationBarEventsMenuItems from './helpers/get-navigation-bar-events-menu-items'; import WorkflowHistoryGroupedTable from './workflow-history-grouped-table/workflow-history-grouped-table'; import WorkflowHistoryHeader from './workflow-history-header/workflow-history-header'; import WorkflowHistoryNavigationBar from './workflow-history-navigation-bar/workflow-history-navigation-bar'; @@ -144,13 +146,19 @@ export default function WorkflowHistoryV2({ params }: Props) { }); }, [wfExecutionDescription, updateGrouperPendingEvents]); - const filteredEventGroupsById = useMemo( + const sortedEventGroupsEntries = useMemo( () => sortBy( Object.entries(eventGroups), ([_, { firstEventId }]) => getSortableEventId(firstEventId), 'ASC' - ).filter(([_, g]) => + ), + [eventGroups] + ); + + const filteredEventGroupsEntries = useMemo( + () => + sortedEventGroupsEntries.filter(([_, g]) => workflowHistoryFiltersConfig.every((f) => f.filterFunc(g, { historyEventTypes: queryParams.historyEventTypes, @@ -159,7 +167,7 @@ export default function WorkflowHistoryV2({ params }: Props) { ) ), [ - eventGroups, + sortedEventGroupsEntries, queryParams.historyEventTypes, queryParams.historyEventStatuses, ] @@ -205,7 +213,7 @@ export default function WorkflowHistoryV2({ params }: Props) { const ungroupedEventsInfo = useMemo>( () => - filteredEventGroupsById + filteredEventGroupsEntries .map(([_, group]) => [ ...group.events.map((event, index) => ({ id: event.eventId ?? event.computedEventId, @@ -219,7 +227,7 @@ export default function WorkflowHistoryV2({ params }: Props) { ]) .flat(1) .sort(compareUngroupedEvents), - [filteredEventGroupsById] + [filteredEventGroupsEntries] ); const [_, setVisibleGroupsRange] = useThrottledState( @@ -251,7 +259,7 @@ export default function WorkflowHistoryV2({ params }: Props) { } = useInitialSelectedEvent({ selectedEventId: selectedEventIdWithinGroup, eventGroups, - filteredEventGroupsEntries: filteredEventGroupsById, + filteredEventGroupsEntries, }); const isLastPageEmpty = @@ -326,6 +334,88 @@ export default function WorkflowHistoryV2({ params }: Props) { }); }, [isUngroupedHistoryViewEnabled]); + const [scrollToEventId, setScrollToEventId] = useState( + undefined + ); + + const scrollToEventIndex = useMemo(() => { + if (!scrollToEventId) return undefined; + + return isUngroupedHistoryViewEnabled + ? ungroupedEventsInfo.findIndex((e) => e.id === scrollToEventId) + : filteredEventGroupsEntries.findIndex(([_, group]) => + group.events.some((e) => e.eventId === scrollToEventId) + ); + }, [ + scrollToEventId, + isUngroupedHistoryViewEnabled, + ungroupedEventsInfo, + filteredEventGroupsEntries, + ]); + + useEffect(() => { + const ref = isUngroupedHistoryViewEnabled + ? ungroupedTableVirtuosoRef + : groupedTableVirtuosoRef; + + if (!ref.current) return; + + if (scrollToEventIndex && scrollToEventIndex !== -1) { + ref.current.scrollToIndex({ + index: scrollToEventIndex, + behavior: 'auto', + align: 'center', + }); + } + + setTimeout(() => setScrollToEventId(undefined), 2000); + }, [scrollToEventIndex, isUngroupedHistoryViewEnabled]); + + const failedEventsMenuItems = useMemo( + () => + getNavigationBarEventsMenuItems(sortedEventGroupsEntries, (group) => + filterGroupsByGroupStatus(group, { historyEventStatuses: ['FAILED'] }) + ), + [sortedEventGroupsEntries] + ); + + const pendingEventsMenuItems = useMemo( + () => + getNavigationBarEventsMenuItems(sortedEventGroupsEntries, (group) => + filterGroupsByGroupStatus(group, { + historyEventStatuses: ['PENDING'], + }) + ), + [sortedEventGroupsEntries] + ); + + const onClickNavMenuEvent = useCallback( + (eventId: string) => { + const isEventVisible = filteredEventGroupsEntries.some( + ([_, eventGroup]) => + eventGroup.events.some( + ({ eventId: eventIdInGroup }) => eventId === eventIdInGroup + ) + ); + + if (!isEventVisible) { + setQueryParams( + { historyEventStatuses: undefined, historyEventTypes: undefined }, + { replace: true } + ); + } + + setScrollToEventId(eventId); + if (!getIsItemExpanded(eventId)) toggleIsItemExpanded(eventId); + }, + [ + filteredEventGroupsEntries, + setQueryParams, + getIsItemExpanded, + toggleIsItemExpanded, + ] + ); + if (contentIsLoading) { return ; } @@ -357,7 +447,7 @@ export default function WorkflowHistoryV2({ params }: Props) { })) } decodedPageUrlParams={decodedParams} - selectedEventId={selectedEventIdWithinGroup} + selectedEventId={scrollToEventId ?? selectedEventIdWithinGroup} resetToDecisionEventId={setResetToDecisionEventId} getIsEventExpanded={getIsItemExpanded} toggleIsEventExpanded={toggleIsItemExpanded} @@ -368,7 +458,7 @@ export default function WorkflowHistoryV2({ params }: Props) { /> ) : ( @@ -383,7 +473,9 @@ export default function WorkflowHistoryV2({ params }: Props) { workflowCloseStatus={workflowExecutionInfo?.closeStatus} workflowIsArchived={workflowExecutionInfo?.isArchived || false} workflowCloseTimeMs={workflowCloseTimeMs} - selectedEventId={queryParams.historySelectedEventId} + selectedEventId={ + scrollToEventId ?? queryParams.historySelectedEventId + } resetToDecisionEventId={setResetToDecisionEventId} getIsEventExpanded={getIsItemExpanded} toggleIsEventExpanded={toggleIsItemExpanded} @@ -411,6 +503,10 @@ export default function WorkflowHistoryV2({ params }: Props) { onScrollDown={handleScrollDown} areAllItemsExpanded={areAllItemsExpanded} onToggleAllItemsExpanded={toggleAreAllItemsExpanded} + isUngroupedView={isUngroupedHistoryViewEnabled} + failedEventsMenuItems={failedEventsMenuItems} + pendingEventsMenuItems={pendingEventsMenuItems} + onClickEvent={onClickNavMenuEvent} /> ); diff --git a/src/views/workflow-history-v2/workflow-history-v2.types.ts b/src/views/workflow-history-v2/workflow-history-v2.types.ts index a96195ffc..180445012 100644 --- a/src/views/workflow-history-v2/workflow-history-v2.types.ts +++ b/src/views/workflow-history-v2/workflow-history-v2.types.ts @@ -1,7 +1,10 @@ +import { type HistoryEventsGroup } from '../workflow-history/workflow-history.types'; import { type WorkflowPageTabContentProps } from '../workflow-page/workflow-page-tab-content/workflow-page-tab-content.types'; export type Props = WorkflowPageTabContentProps; +export type EventGroupEntry = [string, HistoryEventsGroup]; + export type WorkflowHistoryEventFilteringTypeColors = { content: string; background: string;