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 && (
+ <>
+
+
+ }
+ aria-label="Failed events"
+ >
+ {failedEventsMenuItems.length === 1
+ ? '1 failed event'
+ : `${failedEventsMenuItems.length} failed events`}
+
+
+ >
+ )}
+ {pendingEventsMenuItems.length > 0 && (
+ <>
+
+
+ }
+ aria-label="Pending events"
+ >
+ {pendingEventsMenuItems.length === 1
+ ? '1 pending event'
+ : `${pendingEventsMenuItems.length} pending events`}
+
+
+ >
+ )}
);
}
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;