diff --git a/src/views/workflow-history-v2/helpers/__tests__/generate-history-group-details.test.ts b/src/views/workflow-history-v2/helpers/__tests__/generate-history-group-details.test.ts new file mode 100644 index 000000000..a10e5c651 --- /dev/null +++ b/src/views/workflow-history-v2/helpers/__tests__/generate-history-group-details.test.ts @@ -0,0 +1,468 @@ +import formatPendingWorkflowHistoryEvent from '@/utils/data-formatters/format-pending-workflow-history-event'; +import formatWorkflowHistoryEvent from '@/utils/data-formatters/format-workflow-history-event'; +import { + scheduleActivityTaskEvent, + startActivityTaskEvent, + completeActivityTaskEvent, +} from '@/views/workflow-history/__fixtures__/workflow-history-activity-events'; +import { mockActivityEventGroup } from '@/views/workflow-history/__fixtures__/workflow-history-event-groups'; +import { pendingActivityTaskStartEvent } from '@/views/workflow-history/__fixtures__/workflow-history-pending-events'; +import isPendingHistoryEvent from '@/views/workflow-history/workflow-history-event-details/helpers/is-pending-history-event'; +import { type HistoryEventsGroup } from '@/views/workflow-history/workflow-history.types'; + +import generateHistoryEventDetails from '../generate-history-event-details'; +import generateHistoryGroupDetails from '../generate-history-group-details'; + +jest.mock('@/utils/data-formatters/format-pending-workflow-history-event'); +jest.mock('@/utils/data-formatters/format-workflow-history-event'); +jest.mock( + '@/views/workflow-history/workflow-history-event-details/helpers/is-pending-history-event' +); +jest.mock('../generate-history-event-details'); + +const mockedFormatPendingWorkflowHistoryEvent = + formatPendingWorkflowHistoryEvent as jest.Mock; +const mockedFormatWorkflowHistoryEvent = + formatWorkflowHistoryEvent as jest.Mock; +const mockedIsPendingHistoryEvent = + isPendingHistoryEvent as unknown as jest.Mock; +const mockedGenerateHistoryEventDetails = + generateHistoryEventDetails as jest.Mock; + +describe(generateHistoryGroupDetails.name, () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedIsPendingHistoryEvent.mockReturnValue(false); + mockedFormatWorkflowHistoryEvent.mockReturnValue({ + activityId: '0', + activityType: 'TestActivity', + input: 'test input', + }); + mockedFormatPendingWorkflowHistoryEvent.mockReturnValue({ + activityId: '0', + activityType: 'TestActivity', + }); + mockedGenerateHistoryEventDetails.mockReturnValue([ + { + key: 'activityId', + path: 'activityId', + value: '0', + isGroup: false, + renderConfig: null, + }, + { + key: 'activityType', + path: 'activityType', + value: 'TestActivity', + isGroup: false, + renderConfig: null, + }, + ]); + }); + + it('should return empty arrays when eventGroup has no events', () => { + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroup, + events: [], + eventsMetadata: [], + }; + + const result = generateHistoryGroupDetails(eventGroup); + + expect(result.groupDetailsEntries).toEqual([]); + expect(result.summaryDetailsEntries).toEqual([]); + }); + + it('should generate group details entries for events with eventId and metadata', () => { + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroup, + events: [ + scheduleActivityTaskEvent, + { ...startActivityTaskEvent, eventId: '8' }, + ], + eventsMetadata: [ + { + label: 'Scheduled', + status: 'COMPLETED', + timeMs: 1725747370599, + timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', + }, + { + label: 'Started', + status: 'COMPLETED', + timeMs: 1725747370612, + timeLabel: 'Started at 07 Sep, 22:16:10 UTC', + }, + ], + }; + + const result = generateHistoryGroupDetails(eventGroup); + + expect(result.groupDetailsEntries).toHaveLength(2); + expect(result.groupDetailsEntries[0][0]).toBe('7'); + expect(result.groupDetailsEntries[0][1].eventLabel).toBe('Scheduled'); + expect(result.groupDetailsEntries[1][0]).toBe('8'); + expect(result.groupDetailsEntries[1][1].eventLabel).toBe('Started'); + expect(mockedFormatWorkflowHistoryEvent).toHaveBeenCalledTimes(2); + expect(mockedGenerateHistoryEventDetails).toHaveBeenCalledTimes(2); + }); + + it('should skip events without corresponding metadata', () => { + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroup, + events: [ + scheduleActivityTaskEvent, + { ...startActivityTaskEvent, eventId: '8' }, + ], + eventsMetadata: [ + { + label: 'Scheduled', + status: 'COMPLETED', + timeMs: 1725747370599, + timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', + }, + ], + }; + + const result = generateHistoryGroupDetails(eventGroup); + + expect(result.groupDetailsEntries).toHaveLength(1); + expect(result.groupDetailsEntries[0][0]).toBe('7'); + expect(mockedFormatWorkflowHistoryEvent).toHaveBeenCalledTimes(1); + }); + + it('should use formatPendingWorkflowHistoryEvent for pending events', () => { + mockedIsPendingHistoryEvent + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroup, + events: [scheduleActivityTaskEvent, pendingActivityTaskStartEvent], + eventsMetadata: [ + { + label: 'Scheduled', + status: 'COMPLETED', + timeMs: 1725747370599, + timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', + }, + { + label: 'Pending', + status: 'WAITING', + timeMs: null, + timeLabel: 'Pending', + }, + ], + }; + + const result = generateHistoryGroupDetails(eventGroup); + + expect(result.groupDetailsEntries).toHaveLength(2); + expect(mockedIsPendingHistoryEvent).toHaveBeenCalled(); + expect(mockedFormatPendingWorkflowHistoryEvent).toHaveBeenCalledTimes(1); + expect(mockedFormatWorkflowHistoryEvent).toHaveBeenCalledTimes(1); + }); + + it('should use formatWorkflowHistoryEvent for non-pending events', () => { + mockedIsPendingHistoryEvent.mockReturnValue(false); + + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroup, + events: [scheduleActivityTaskEvent], + eventsMetadata: [ + { + label: 'Scheduled', + status: 'COMPLETED', + timeMs: 1725747370599, + timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', + }, + ], + }; + + const result = generateHistoryGroupDetails(eventGroup); + + expect(result.groupDetailsEntries).toHaveLength(1); + expect(mockedIsPendingHistoryEvent).toHaveBeenCalled(); + expect(mockedFormatWorkflowHistoryEvent).toHaveBeenCalledTimes(1); + expect(mockedFormatPendingWorkflowHistoryEvent).not.toHaveBeenCalled(); + }); + + it('should merge additionalDetails from metadata into formatted event details', () => { + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroup, + events: [scheduleActivityTaskEvent], + eventsMetadata: [ + { + label: 'Scheduled', + status: 'COMPLETED', + timeMs: 1725747370599, + timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', + additionalDetails: { + customField: 'custom value', + }, + }, + ], + }; + + generateHistoryGroupDetails(eventGroup); + + expect(mockedGenerateHistoryEventDetails).toHaveBeenCalledWith({ + details: { + activityId: '0', + activityType: 'TestActivity', + input: 'test input', + customField: 'custom value', + }, + negativeFields: undefined, + }); + }); + + it('should pass negativeFields from metadata to generateHistoryEventDetails', () => { + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroup, + events: [scheduleActivityTaskEvent], + eventsMetadata: [ + { + label: 'Scheduled', + status: 'COMPLETED', + timeMs: 1725747370599, + timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', + negativeFields: ['error', 'failureReason'], + }, + ], + }; + + generateHistoryGroupDetails(eventGroup); + + expect(mockedGenerateHistoryEventDetails).toHaveBeenCalledWith({ + details: { + activityId: '0', + activityType: 'TestActivity', + input: 'test input', + }, + negativeFields: ['error', 'failureReason'], + }); + }); + + it('should generate summary details entries when summaryFields match event details', () => { + mockedGenerateHistoryEventDetails.mockReturnValue([ + { + key: 'activityId', + path: 'activityId', + value: '0', + isGroup: false, + renderConfig: null, + }, + { + key: 'activityType', + path: 'activityType', + value: 'TestActivity', + isGroup: false, + renderConfig: null, + }, + { + key: 'input', + path: 'input', + value: 'test input', + isGroup: false, + renderConfig: null, + }, + ]); + + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroup, + events: [scheduleActivityTaskEvent], + eventsMetadata: [ + { + label: 'Scheduled', + status: 'COMPLETED', + timeMs: 1725747370599, + timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', + summaryFields: ['activityId', 'activityType'], + }, + ], + }; + + const result = generateHistoryGroupDetails(eventGroup); + + expect(result.summaryDetailsEntries).toHaveLength(1); + expect(result.summaryDetailsEntries[0][0]).toBe('7'); + expect(result.summaryDetailsEntries[0][1].eventLabel).toBe('Scheduled'); + expect(result.summaryDetailsEntries[0][1].eventDetails).toHaveLength(2); + expect(result.summaryDetailsEntries[0][1].eventDetails[0].path).toBe( + 'activityId' + ); + expect(result.summaryDetailsEntries[0][1].eventDetails[1].path).toBe( + 'activityType' + ); + }); + + it('should not generate summary details entries when summaryFields do not match any event details', () => { + mockedGenerateHistoryEventDetails.mockReturnValue([ + { + key: 'activityId', + path: 'activityId', + value: '0', + isGroup: false, + renderConfig: null, + }, + ]); + + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroup, + events: [scheduleActivityTaskEvent], + eventsMetadata: [ + { + label: 'Scheduled', + status: 'COMPLETED', + timeMs: 1725747370599, + timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', + summaryFields: ['nonExistentField'], + }, + ], + }; + + const result = generateHistoryGroupDetails(eventGroup); + + expect(result.summaryDetailsEntries).toHaveLength(0); + }); + + it('should not generate summary details entries when summaryFields is not provided', () => { + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroup, + events: [scheduleActivityTaskEvent], + eventsMetadata: [ + { + label: 'Scheduled', + status: 'COMPLETED', + timeMs: 1725747370599, + timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', + }, + ], + }; + + const result = generateHistoryGroupDetails(eventGroup); + + expect(result.summaryDetailsEntries).toHaveLength(0); + }); + + it('should handle events where formatWorkflowHistoryEvent returns null', () => { + mockedFormatWorkflowHistoryEvent.mockReturnValue(null); + + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroup, + events: [scheduleActivityTaskEvent], + eventsMetadata: [ + { + label: 'Scheduled', + status: 'COMPLETED', + timeMs: 1725747370599, + timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', + }, + ], + }; + + const result = generateHistoryGroupDetails(eventGroup); + + expect(result.groupDetailsEntries).toHaveLength(1); + expect(result.groupDetailsEntries[0][1].eventDetails).toEqual([]); + expect(mockedGenerateHistoryEventDetails).not.toHaveBeenCalled(); + }); + + it('should handle multiple events with mixed summaryFields', () => { + mockedGenerateHistoryEventDetails.mockReturnValue([ + { + key: 'activityId', + path: 'activityId', + value: '0', + isGroup: false, + renderConfig: null, + }, + { + key: 'activityType', + path: 'activityType', + value: 'TestActivity', + isGroup: false, + renderConfig: null, + }, + { + key: 'result', + path: 'result', + value: 'success', + isGroup: false, + renderConfig: null, + }, + ]); + + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroup, + events: [ + scheduleActivityTaskEvent, + { ...startActivityTaskEvent, eventId: '8' }, + { ...completeActivityTaskEvent, eventId: '9' }, + ], + eventsMetadata: [ + { + label: 'Scheduled', + status: 'COMPLETED', + timeMs: 1725747370599, + timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', + summaryFields: ['activityId'], + }, + { + label: 'Started', + status: 'COMPLETED', + timeMs: 1725747370612, + timeLabel: 'Started at 07 Sep, 22:16:10 UTC', + summaryFields: ['activityType'], + }, + { + label: 'Completed', + status: 'COMPLETED', + timeMs: 1725747370632, + timeLabel: 'Completed at 07 Sep, 22:16:10 UTC', + summaryFields: ['result'], + }, + ], + }; + + const result = generateHistoryGroupDetails(eventGroup); + + expect(result.groupDetailsEntries).toHaveLength(3); + expect(result.summaryDetailsEntries).toHaveLength(3); + expect(result.summaryDetailsEntries[0][0]).toBe('7'); + expect(result.summaryDetailsEntries[1][0]).toBe('8'); + expect(result.summaryDetailsEntries[2][0]).toBe('9'); + }); + + it('should handle events with both additionalDetails and negativeFields', () => { + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroup, + events: [scheduleActivityTaskEvent], + eventsMetadata: [ + { + label: 'Scheduled', + status: 'COMPLETED', + timeMs: 1725747370599, + timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', + additionalDetails: { + customField: 'custom value', + }, + negativeFields: ['error'], + }, + ], + }; + + generateHistoryGroupDetails(eventGroup); + + expect(mockedGenerateHistoryEventDetails).toHaveBeenCalledWith({ + details: { + activityId: '0', + activityType: 'TestActivity', + input: 'test input', + customField: 'custom value', + }, + negativeFields: ['error'], + }); + }); +}); diff --git a/src/views/workflow-history-v2/helpers/generate-history-group-details.ts b/src/views/workflow-history-v2/helpers/generate-history-group-details.ts new file mode 100644 index 000000000..1e58324f5 --- /dev/null +++ b/src/views/workflow-history-v2/helpers/generate-history-group-details.ts @@ -0,0 +1,62 @@ +import formatPendingWorkflowHistoryEvent from '@/utils/data-formatters/format-pending-workflow-history-event'; +import formatWorkflowHistoryEvent from '@/utils/data-formatters/format-workflow-history-event'; +import isPendingHistoryEvent from '@/views/workflow-history/workflow-history-event-details/helpers/is-pending-history-event'; +import { type HistoryEventsGroup } from '@/views/workflow-history/workflow-history.types'; + +import generateHistoryEventDetails from '../helpers/generate-history-event-details'; +import { type EventDetailsTabContent } from '../workflow-history-group-details/workflow-history-group-details.types'; + +export default function generateHistoryGroupDetails( + eventGroup: HistoryEventsGroup +) { + const groupDetailsEntries: Array<[string, EventDetailsTabContent]> = [], + summaryDetailsEntries: Array<[string, EventDetailsTabContent]> = []; + + eventGroup.events.forEach((event, index) => { + const eventId = event.eventId ?? event.computedEventId; + + const eventMetadata = eventGroup.eventsMetadata[index]; + if (!eventMetadata) return; + + const result = isPendingHistoryEvent(event) + ? formatPendingWorkflowHistoryEvent(event) + : formatWorkflowHistoryEvent(event); + + const eventDetails = result + ? generateHistoryEventDetails({ + details: { + ...result, + ...eventMetadata.additionalDetails, + }, + negativeFields: eventMetadata.negativeFields, + }) + : []; + + groupDetailsEntries.push([ + eventId, + { + eventLabel: eventMetadata.label, + eventDetails, + } satisfies EventDetailsTabContent, + ]); + + const eventSummaryDetails = eventDetails.filter((detail) => + eventMetadata.summaryFields?.includes(detail.path) + ); + + if (eventSummaryDetails.length > 0) { + summaryDetailsEntries.push([ + eventId, + { + eventLabel: eventMetadata.label, + eventDetails: eventSummaryDetails, + } satisfies EventDetailsTabContent, + ]); + } + }); + + return { + groupDetailsEntries, + summaryDetailsEntries, + }; +} diff --git a/src/views/workflow-history-v2/workflow-history-event-details/__tests__/workflow-history-event-details.test.tsx b/src/views/workflow-history-v2/workflow-history-event-details/__tests__/workflow-history-event-details.test.tsx new file mode 100644 index 000000000..db0c18231 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-event-details/__tests__/workflow-history-event-details.test.tsx @@ -0,0 +1,202 @@ +import { render, screen } from '@/test-utils/rtl'; + +import { type WorkflowPageParams } from '@/views/workflow-page/workflow-page.types'; + +import type WorkflowHistoryPanelDetailsEntry from '../../workflow-history-panel-details-entry/workflow-history-panel-details-entry'; +import WorkflowHistoryEventDetails from '../workflow-history-event-details'; +import { type EventDetailsEntries } from '../workflow-history-event-details.types'; + +jest.mock( + '../../workflow-history-panel-details-entry/workflow-history-panel-details-entry', + () => + jest.fn(({ detail }) => ( +
+ Panel Entry: {detail.path} ={' '} + {JSON.stringify(detail.isGroup ? detail.groupEntries : detail.value)} + {detail.isNegative && ' (negative)'} +
+ )) +); + +jest.mock( + '@/views/workflow-history/workflow-history-event-details-group/workflow-history-event-details-group', + () => + jest.fn(({ entries }: { entries: EventDetailsEntries }) => ( +
+ Event Details Group ({entries.length} entries) +
+ )) +); + +describe(WorkflowHistoryEventDetails.name, () => { + it('renders "No Details" when eventDetails is empty', () => { + setup({ eventDetails: [] }); + + expect(screen.getByText('No Details')).toBeInTheDocument(); + expect(screen.queryByTestId('panel-details-entry')).not.toBeInTheDocument(); + expect(screen.queryByTestId('event-details-group')).not.toBeInTheDocument(); + }); + + it('renders only rest details when no entries have showInPanels', () => { + const eventDetails: EventDetailsEntries = [ + { + key: 'key1', + path: 'path1', + value: 'value1', + isGroup: false, + renderConfig: { + name: 'Test Config', + key: 'key1', + }, + }, + { + key: 'key2', + path: 'path2', + value: 'value2', + isGroup: false, + renderConfig: null, + }, + ]; + + setup({ eventDetails }); + + expect(screen.queryByTestId('panel-details-entry')).not.toBeInTheDocument(); + expect(screen.getByTestId('event-details-group')).toBeInTheDocument(); + expect( + screen.getByText('Event Details Group (2 entries)') + ).toBeInTheDocument(); + }); + + it('renders panel details when entries have showInPanels flag', () => { + const eventDetails: EventDetailsEntries = [ + { + key: 'key1', + path: 'path1', + value: 'value1', + isGroup: false, + renderConfig: { + name: 'Panel Config', + key: 'key1', + showInPanels: true, + }, + }, + { + key: 'key2', + path: 'path2', + value: 'value2', + isGroup: false, + renderConfig: { + name: 'Rest Config', + key: 'key2', + }, + }, + ]; + + setup({ eventDetails }); + + expect(screen.getByTestId('panel-details-entry')).toBeInTheDocument(); + expect( + screen.getByText(/Panel Entry: path1 = "value1"/) + ).toBeInTheDocument(); + expect(screen.getByTestId('event-details-group')).toBeInTheDocument(); + expect( + screen.getByText('Event Details Group (1 entries)') + ).toBeInTheDocument(); + }); + + it('renders multiple panel details', () => { + const eventDetails: EventDetailsEntries = [ + { + key: 'key1', + path: 'path1', + value: 'value1', + isGroup: false, + renderConfig: { + name: 'Panel Config 1', + key: 'key1', + showInPanels: true, + }, + }, + { + key: 'key2', + path: 'path2', + value: { nested: 'value2' }, + isGroup: false, + renderConfig: { + name: 'Panel Config 2', + key: 'key2', + showInPanels: true, + }, + }, + { + key: 'key3', + path: 'path3', + value: 'value3', + isGroup: false, + renderConfig: { + name: 'Rest Config', + key: 'key3', + }, + }, + ]; + + setup({ eventDetails }); + + const panelEntries = screen.getAllByTestId('panel-details-entry'); + expect(panelEntries).toHaveLength(2); + expect( + screen.getByText(/Panel Entry: path1 = "value1"/) + ).toBeInTheDocument(); + expect( + screen.getByText(/Panel Entry: path2 = \{"nested":"value2"\}/) + ).toBeInTheDocument(); + expect(screen.getByTestId('event-details-group')).toBeInTheDocument(); + expect( + screen.getByText('Event Details Group (1 entries)') + ).toBeInTheDocument(); + }); + + it('correctly renders panel details entry', () => { + const eventDetails: EventDetailsEntries = [ + { + key: 'key1', + path: 'path1', + value: 'value1', + isGroup: false, + isNegative: true, + renderConfig: { + name: 'Panel Config', + key: 'key1', + showInPanels: true, + }, + }, + ]; + + setup({ eventDetails }); + + expect( + screen.getByText(/Panel Entry: path1 = "value1"/) + ).toBeInTheDocument(); + expect(screen.getByText(/\(negative\)/)).toBeInTheDocument(); + }); +}); + +function setup({ + eventDetails, + workflowPageParams = { + domain: 'test-domain', + cluster: 'test-cluster', + workflowId: 'test-workflow-id', + runId: 'test-run-id', + }, +}: { + eventDetails: EventDetailsEntries; + workflowPageParams?: WorkflowPageParams; +}) { + render( + + ); +} diff --git a/src/views/workflow-history-v2/workflow-history-event-details/workflow-history-event-details.styles.ts b/src/views/workflow-history-v2/workflow-history-event-details/workflow-history-event-details.styles.ts new file mode 100644 index 000000000..5d86f6247 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-event-details/workflow-history-event-details.styles.ts @@ -0,0 +1,31 @@ +import { styled as createStyled, type Theme } from 'baseui'; + +export const styled = { + EmptyDetails: createStyled('div', ({ $theme }: { $theme: Theme }) => ({ + ...$theme.typography.LabelXSmall, + color: $theme.colors.contentTertiary, + textAlign: 'center', + padding: `${$theme.sizing.scale700} 0`, + })), + EventDetailsContainer: createStyled('div', { + display: 'flex', + flexDirection: 'column', + }), + PanelDetails: createStyled('div', ({ $theme }: { $theme: Theme }) => ({ + display: 'flex', + flexDirection: 'column', + [$theme.mediaQuery.medium]: { + flexDirection: 'row', + }, + gap: $theme.sizing.scale500, + paddingBottom: $theme.sizing.scale500, + alignItems: 'stretch', + })), + PanelContainer: createStyled('div', { + flex: 1, + }), + RestDetails: createStyled('div', ({ $theme }: { $theme: Theme }) => ({ + paddingLeft: $theme.sizing.scale100, + paddingRight: $theme.sizing.scale100, + })), +}; diff --git a/src/views/workflow-history-v2/workflow-history-event-details/workflow-history-event-details.tsx b/src/views/workflow-history-v2/workflow-history-event-details/workflow-history-event-details.tsx new file mode 100644 index 000000000..68bd7c155 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-event-details/workflow-history-event-details.tsx @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; + +import partition from 'lodash/partition'; + +import WorkflowHistoryEventDetailsGroup from '@/views/workflow-history/workflow-history-event-details-group/workflow-history-event-details-group'; + +import WorkflowHistoryPanelDetailsEntry from '../workflow-history-panel-details-entry/workflow-history-panel-details-entry'; + +import { styled } from './workflow-history-event-details.styles'; +import { type Props } from './workflow-history-event-details.types'; + +export default function WorkflowHistoryEventDetails({ + eventDetails, + workflowPageParams, +}: Props) { + const [panelDetails, restDetails] = useMemo( + () => + partition(eventDetails, (detail) => detail.renderConfig?.showInPanels), + [eventDetails] + ); + + if (eventDetails.length === 0) { + return No Details; + } + + return ( + + {panelDetails.length > 0 && ( + + {panelDetails.map((detail) => { + return ( + + + + ); + })} + + )} + + + + + ); +} diff --git a/src/views/workflow-history-v2/workflow-history-event-details/workflow-history-event-details.types.ts b/src/views/workflow-history-v2/workflow-history-event-details/workflow-history-event-details.types.ts index 883c5d233..1929ad4ad 100644 --- a/src/views/workflow-history-v2/workflow-history-event-details/workflow-history-event-details.types.ts +++ b/src/views/workflow-history-v2/workflow-history-event-details/workflow-history-event-details.types.ts @@ -1,5 +1,10 @@ import { type WorkflowPageParams } from '@/views/workflow-page/workflow-page.types'; +export type Props = { + eventDetails: EventDetailsEntries; + workflowPageParams: WorkflowPageParams; +}; + export type EventDetailsFuncArgs = { path: string; key: string; diff --git a/src/views/workflow-history-v2/workflow-history-event-group/__tests__/workflow-history-event-group.test.tsx b/src/views/workflow-history-v2/workflow-history-event-group/__tests__/workflow-history-event-group.test.tsx index 9bac73118..fa732b165 100644 --- a/src/views/workflow-history-v2/workflow-history-event-group/__tests__/workflow-history-event-group.test.tsx +++ b/src/views/workflow-history-v2/workflow-history-event-group/__tests__/workflow-history-event-group.test.tsx @@ -3,6 +3,7 @@ import { render, screen, userEvent } from '@/test-utils/rtl'; import { completedActivityTaskEvents, scheduleActivityTaskEvent, + startActivityTaskEvent, } from '@/views/workflow-history/__fixtures__/workflow-history-activity-events'; import { mockActivityEventGroup, @@ -13,6 +14,10 @@ import type WorkflowHistoryGroupLabel from '@/views/workflow-history/workflow-hi import type WorkflowHistoryTimelineResetButton from '@/views/workflow-history/workflow-history-timeline-reset-button/workflow-history-timeline-reset-button'; import { type HistoryEventsGroup } from '@/views/workflow-history/workflow-history.types'; +import * as generateHistoryGroupDetailsModule from '../../helpers/generate-history-group-details'; +import type { EventDetailsEntries } from '../../workflow-history-event-details/workflow-history-event-details.types'; +import type WorkflowHistoryGroupDetails from '../../workflow-history-group-details/workflow-history-group-details'; +import type { GroupDetailsEntries } from '../../workflow-history-group-details/workflow-history-group-details.types'; import WorkflowHistoryEventGroup from '../workflow-history-event-group'; import type { Props } from '../workflow-history-event-group.types'; @@ -20,6 +25,33 @@ jest.mock('@/utils/data-formatters/format-date', () => jest.fn((timeMs: number) => `Formatted: ${timeMs}`) ); +jest.mock('../../helpers/generate-history-group-details', () => jest.fn()); + +jest.mock( + '../../workflow-history-group-details/workflow-history-group-details', + () => + jest.fn(({ groupDetailsEntries, initialEventId, onClose }) => ( +
+
+ {groupDetailsEntries.length} events +
+ {groupDetailsEntries.map(([eventId, { eventLabel }]) => ( +
+ {eventLabel} +
+ ))} + {initialEventId && ( +
{initialEventId}
+ )} + {onClose && ( + + )} +
+ )) +); + jest.mock( '@/views/workflow-history/workflow-history-event-status-badge/workflow-history-event-status-badge', () => @@ -54,20 +86,6 @@ jest.mock('../helpers/get-event-group-filtering-type', () => jest.fn(() => 'ACTIVITY') ); -jest.mock( - '../../config/workflow-history-event-filtering-type-colors.config', - () => ({ - __esModule: true, - default: { - ACTIVITY: { - content: '#FF5733', - background: '#FFE5E0', - backgroundHighlighted: '#FFD4CC', - }, - }, - }) -); - const mockActivityEventGroupWithMetadata: HistoryEventsGroup = { ...mockActivityEventGroup, eventsMetadata: [ @@ -77,6 +95,12 @@ const mockActivityEventGroupWithMetadata: HistoryEventsGroup = { timeMs: 1725747370599, timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', }, + { + label: 'Started', + status: 'COMPLETED', + timeMs: 1725747370612, + timeLabel: 'Started at 07 Sep, 22:16:10 UTC', + }, { label: 'Completed', status: 'COMPLETED', @@ -111,6 +135,10 @@ const mockDecisionEventGroupWithMetadata: HistoryEventsGroup = { }; describe(WorkflowHistoryEventGroup.name, () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + it('renders group correctly', () => { setup({ eventGroup: mockActivityEventGroupWithMetadata }); @@ -162,14 +190,19 @@ describe(WorkflowHistoryEventGroup.name, () => { setup({ eventGroup, getIsEventExpanded }); - // Panel should be expanded if any event is expanded, showing content - expect(screen.getByText('TODO: Full event details')).toBeInTheDocument(); + // Panel should be expanded if any event is expanded, showing WorkflowHistoryGroupDetails + expect( + screen.getByTestId('workflow-history-group-details') + ).toBeInTheDocument(); + expect(screen.getByTestId('group-details-count')).toHaveTextContent( + `${completedActivityTaskEvents.length} events` + ); }); - it('calls toggleIsEventExpanded when panel is toggled', async () => { + it('calls toggleIsEventExpanded for each event when panel is toggled', async () => { const eventGroup: HistoryEventsGroup = { ...mockActivityEventGroupWithMetadata, - events: [scheduleActivityTaskEvent], + events: [scheduleActivityTaskEvent, startActivityTaskEvent], }; const toggleIsEventExpanded = jest.fn(); @@ -179,9 +212,7 @@ describe(WorkflowHistoryEventGroup.name, () => { const headerLabel = screen.getByText('Mock event'); await user.click(headerLabel); - expect(toggleIsEventExpanded).toHaveBeenCalledWith( - scheduleActivityTaskEvent.eventId - ); + expect(toggleIsEventExpanded).toHaveBeenCalledTimes(2); }); it('handles missing event group time gracefully', () => { @@ -217,6 +248,276 @@ describe(WorkflowHistoryEventGroup.name, () => { expect(screen.getByText('Loading')).toBeInTheDocument(); expect(screen.queryByText('COMPLETED')).not.toBeInTheDocument(); }); + + it('calls toggleIsEventExpanded when WorkflowHistoryGroupDetails onClose is called', async () => { + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroupWithMetadata, + events: completedActivityTaskEvents, + }; + + const toggleIsEventExpanded = jest.fn(); + const getIsEventExpanded = jest.fn( + (eventId: string) => eventId === completedActivityTaskEvents[0].eventId + ); + + const { user } = setup({ + eventGroup, + getIsEventExpanded, + toggleIsEventExpanded, + }); + + const closeButton = screen.getByTestId('group-details-close'); + await user.click(closeButton); + + // Should call toggleIsEventExpanded for each expanded event + completedActivityTaskEvents.forEach((event) => { + if (event.eventId && getIsEventExpanded(event.eventId)) { + expect(toggleIsEventExpanded).toHaveBeenCalledWith(event.eventId); + } + }); + }); + + it('shows summary tab when summaryDetailsEntries are available', () => { + const mockEventDetails: EventDetailsEntries = [ + { + key: 'input', + path: 'input', + value: 'test input value', + isGroup: false, + renderConfig: null, + }, + { + key: 'activityType', + path: 'activityType', + value: 'TestActivity', + isGroup: false, + renderConfig: null, + }, + ]; + + const mockSummaryDetails: EventDetailsEntries = [ + { + key: 'input', + path: 'input', + value: 'test input value', + isGroup: false, + renderConfig: null, + }, + { + key: 'activityType', + path: 'activityType', + value: 'TestActivity', + isGroup: false, + renderConfig: null, + }, + ]; + + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroupWithMetadata, + events: completedActivityTaskEvents, + eventsMetadata: [ + { + label: 'Scheduled', + status: 'COMPLETED', + timeMs: 1725747370599, + timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', + summaryFields: ['input', 'activityType'], + }, + { + label: 'Started', + status: 'COMPLETED', + timeMs: 1725747370612, + timeLabel: 'Started at 07 Sep, 22:16:10 UTC', + summaryFields: ['activityType'], + }, + { + label: 'Completed', + status: 'COMPLETED', + timeMs: 1725747370632, + timeLabel: 'Completed at 07 Sep, 22:16:10 UTC', + summaryFields: ['result'], + }, + ], + }; + + const getIsEventExpanded = jest.fn( + (eventId: string) => eventId === completedActivityTaskEvents[0].eventId + ); + + setup({ + eventGroup, + getIsEventExpanded, + mockGroupDetails: { + groupDetailsEntries: [ + [ + completedActivityTaskEvents[0].eventId!, + { + eventLabel: 'Scheduled', + eventDetails: mockEventDetails, + }, + ], + [ + completedActivityTaskEvents[1].eventId!, + { + eventLabel: 'Started', + eventDetails: mockEventDetails, + }, + ], + [ + completedActivityTaskEvents[2].eventId!, + { + eventLabel: 'Completed', + eventDetails: mockEventDetails, + }, + ], + ], + summaryDetailsEntries: [ + [ + completedActivityTaskEvents[0].eventId!, + { + eventLabel: 'Scheduled', + eventDetails: mockSummaryDetails, + }, + ], + [ + completedActivityTaskEvents[1].eventId!, + { + eventLabel: 'Started', + eventDetails: mockSummaryDetails, + }, + ], + ], + }, + }); + + // Summary tab should appear in groupDetailsEntries when there are summary details + expect(screen.getByText('Summary')).toBeInTheDocument(); + }); + + it('does not show summary tab when only one event is present in the group', () => { + const mockEventDetails: EventDetailsEntries = [ + { + key: 'input', + path: 'input', + value: 'test input value', + isGroup: false, + renderConfig: null, + }, + ]; + + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroupWithMetadata, + events: [scheduleActivityTaskEvent], + eventsMetadata: [ + { + label: 'Scheduled', + status: 'COMPLETED', + timeMs: 1725747370599, + timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', + summaryFields: ['input'], + }, + ], + }; + + setup({ + eventGroup, + mockGroupDetails: { + groupDetailsEntries: [ + [ + scheduleActivityTaskEvent.eventId!, + { + eventLabel: 'Scheduled', + eventDetails: mockEventDetails, + }, + ], + ], + summaryDetailsEntries: [ + [ + scheduleActivityTaskEvent.eventId!, + { + eventLabel: 'Scheduled', + eventDetails: mockEventDetails, + }, + ], + ], + }, + }); + + // Summary tab should not appear when there is only one event + expect(screen.queryByText('Summary')).not.toBeInTheDocument(); + }); + + it('does not show summary tab when summaryDetailsEntries is empty', () => { + const mockEventDetails: EventDetailsEntries = [ + { + key: 'input', + path: 'input', + value: 'test input value', + isGroup: false, + renderConfig: null, + }, + ]; + + const eventGroup: HistoryEventsGroup = { + ...mockActivityEventGroupWithMetadata, + events: completedActivityTaskEvents, + eventsMetadata: [ + { + label: 'Scheduled', + status: 'COMPLETED', + timeMs: 1725747370599, + timeLabel: 'Scheduled at 07 Sep, 22:16:10 UTC', + summaryFields: ['nonExistentField'], + }, + { + label: 'Started', + status: 'COMPLETED', + timeMs: 1725747370612, + timeLabel: 'Started at 07 Sep, 22:16:10 UTC', + summaryFields: ['anotherNonExistentField'], + }, + { + label: 'Completed', + status: 'COMPLETED', + timeMs: 1725747370632, + timeLabel: 'Completed at 07 Sep, 22:16:10 UTC', + }, + ], + }; + + setup({ + eventGroup, + mockGroupDetails: { + groupDetailsEntries: [ + [ + completedActivityTaskEvents[0].eventId!, + { + eventLabel: 'Scheduled', + eventDetails: mockEventDetails, + }, + ], + [ + completedActivityTaskEvents[1].eventId!, + { + eventLabel: 'Started', + eventDetails: mockEventDetails, + }, + ], + [ + completedActivityTaskEvents[2].eventId!, + { + eventLabel: 'Completed', + eventDetails: mockEventDetails, + }, + ], + ], + summaryDetailsEntries: [], + }, + }); + + // Summary tab should not appear when summaryDetailsEntries is empty + expect(screen.queryByText('Summary')).not.toBeInTheDocument(); + }); }); function setup({ @@ -236,7 +537,45 @@ function setup({ onReset = jest.fn(), getIsEventExpanded = jest.fn(() => false), toggleIsEventExpanded = jest.fn(), -}: Partial = {}) { + mockGroupDetails, +}: Partial & { + mockGroupDetails?: { + groupDetailsEntries: GroupDetailsEntries; + summaryDetailsEntries: GroupDetailsEntries; + }; +} = {}) { + const mockGenerateHistoryGroupDetails = jest.spyOn( + generateHistoryGroupDetailsModule, + 'default' + ); + + if (mockGroupDetails) { + mockGenerateHistoryGroupDetails.mockReturnValue(mockGroupDetails); + } else { + const defaultMockEventDetails: EventDetailsEntries = [ + { + key: 'testKey', + path: 'testPath', + value: 'testValue', + isGroup: false, + renderConfig: null, + }, + ]; + + mockGenerateHistoryGroupDetails.mockReturnValue({ + groupDetailsEntries: eventGroup.events + .filter((event) => event.eventId) + .map((event, index) => [ + event.eventId!, + { + eventLabel: eventGroup.eventsMetadata[index]?.label ?? 'Unknown', + eventDetails: defaultMockEventDetails, + }, + ]), + summaryDetailsEntries: [], + }); + } + const mockOnReset = onReset || jest.fn(); const user = userEvent.setup(); diff --git a/src/views/workflow-history-v2/workflow-history-event-group/helpers/get-summary-tab-content-entry.ts b/src/views/workflow-history-v2/workflow-history-event-group/helpers/get-summary-tab-content-entry.ts new file mode 100644 index 000000000..4f3bc812f --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-event-group/helpers/get-summary-tab-content-entry.ts @@ -0,0 +1,18 @@ +import { type EventDetailsEntries } from '../../workflow-history-event-details/workflow-history-event-details.types'; +import { type EventDetailsTabContent } from '../../workflow-history-group-details/workflow-history-group-details.types'; + +export default function getSummaryTabContentEntry({ + groupId, + summaryDetails, +}: { + groupId: string; + summaryDetails: EventDetailsEntries; +}): [string, EventDetailsTabContent] { + return [ + `summary_${groupId}`, + { + eventDetails: summaryDetails, + eventLabel: 'Summary', + }, + ]; +} diff --git a/src/views/workflow-history-v2/workflow-history-event-group/workflow-history-event-group.styles.ts b/src/views/workflow-history-v2/workflow-history-event-group/workflow-history-event-group.styles.ts index 093a6f283..cd7bb060a 100644 --- a/src/views/workflow-history-v2/workflow-history-event-group/workflow-history-event-group.styles.ts +++ b/src/views/workflow-history-v2/workflow-history-event-group/workflow-history-event-group.styles.ts @@ -44,6 +44,32 @@ export const styled = { alignItems: 'center', margin: `-${$theme.sizing.scale200} 0`, })), + GroupDetailsGridContainer: createStyled('div', { + display: 'grid', + gridTemplateColumns: WORKFLOW_HISTORY_GROUPED_GRID_TEMPLATE_COLUMNS, + }), + GroupDetailsNameSpacer: createStyled( + 'div', + ({ $theme }: { $theme: Theme }) => ({ + display: 'none', + [$theme.mediaQuery.medium]: { + gridColumn: '1 / span 2', + }, + }) + ), + GroupDetailsContainer: createStyled( + 'div', + ({ $theme }: { $theme: Theme }) => ({ + gridColumn: '1 / -1', + [$theme.mediaQuery.medium]: { + gridColumn: '3 / -1', + }, + border: `2px solid ${$theme.colors.borderOpaque}`, + borderRadius: $theme.borders.radius400, + padding: $theme.sizing.scale500, + backgroundColor: $theme.colors.backgroundPrimary, + }) + ), }; export const overrides = ( @@ -103,8 +129,12 @@ export const overrides = ( // Since the original Panel uses longhand properties, we need to use longhand in overrides paddingTop: 0, paddingBottom: $theme.sizing.scale600, - paddingLeft: $theme.sizing.scale700, - paddingRight: $theme.sizing.scale700, + paddingLeft: 0, + paddingRight: 0, + [$theme.mediaQuery.medium]: { + paddingLeft: $theme.sizing.scale700, + paddingRight: $theme.sizing.scale700, + }, backgroundColor: 'inherit', }), }, diff --git a/src/views/workflow-history-v2/workflow-history-event-group/workflow-history-event-group.tsx b/src/views/workflow-history-v2/workflow-history-event-group/workflow-history-event-group.tsx index 904137bf4..4ccd94c13 100644 --- a/src/views/workflow-history-v2/workflow-history-event-group/workflow-history-event-group.tsx +++ b/src/views/workflow-history-v2/workflow-history-event-group/workflow-history-event-group.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { Panel } from 'baseui/accordion'; import { MdCircle } from 'react-icons/md'; @@ -9,9 +9,12 @@ import WorkflowHistoryGroupLabel from '@/views/workflow-history/workflow-history import WorkflowHistoryTimelineResetButton from '@/views/workflow-history/workflow-history-timeline-reset-button/workflow-history-timeline-reset-button'; import workflowHistoryEventFilteringTypeColorsConfig from '../config/workflow-history-event-filtering-type-colors.config'; +import generateHistoryGroupDetails from '../helpers/generate-history-group-details'; import WorkflowHistoryEventGroupDuration from '../workflow-history-event-group-duration/workflow-history-event-group-duration'; +import WorkflowHistoryGroupDetails from '../workflow-history-group-details/workflow-history-group-details'; import getEventGroupFilteringType from './helpers/get-event-group-filtering-type'; +import getSummaryTabContentEntry from './helpers/get-summary-tab-content-entry'; import { overrides as getOverrides, styled, @@ -34,13 +37,13 @@ export default function WorkflowHistoryEventGroup({ status, label, shortLabel, + timeMs, startTimeMs, closeTimeMs, // expectedEndTimeInfo, events, eventsMetadata, hasMissingEvents, - // badges, resetToDecisionEventId, } = eventGroup; @@ -54,6 +57,38 @@ export default function WorkflowHistoryEventGroup({ } }, [onReset]); + const { groupDetailsEntries, summaryDetailsEntries } = useMemo( + () => generateHistoryGroupDetails(eventGroup), + [eventGroup] + ); + + const handleGroupExpansionStateChange = useCallback( + (newExpanded: boolean) => { + events.forEach(({ eventId }) => { + if (eventId && getIsEventExpanded(eventId) !== newExpanded) + toggleIsEventExpanded(eventId); + }); + }, + [events, getIsEventExpanded, toggleIsEventExpanded] + ); + + const groupDetailsEntriesWithSummary = useMemo( + () => [ + ...(summaryDetailsEntries.length > 0 + ? [ + getSummaryTabContentEntry({ + groupId: eventGroup.firstEventId ?? 'unknown', + summaryDetails: summaryDetailsEntries.flatMap( + ([_eventId, { eventDetails }]) => eventDetails + ), + }), + ] + : []), + ...groupDetailsEntries, + ], + [eventGroup.firstEventId, groupDetailsEntries, summaryDetailsEntries] + ); + return ( {eventsMetadata.at(-1)?.label} -
{eventGroup.timeMs ? formatDate(eventGroup.timeMs) : null}
- - {/* TODO: add as event details: - - Existing event details - - Badges - - Expected end time info - */} +
{timeMs ? formatDate(timeMs) : null}
+
+ +
Placeholder for event details @@ -110,13 +142,21 @@ export default function WorkflowHistoryEventGroup({ expanded={events.some( ({ eventId }) => eventId && getIsEventExpanded(eventId) )} - onChange={() => { - if (events.length > 0 && events[0].eventId) - toggleIsEventExpanded(events[0].eventId); - }} + onChange={({ expanded }) => handleGroupExpansionStateChange(expanded)} overrides={overrides.panel} > -
TODO: Full event details
+ + + + handleGroupExpansionStateChange(false)} + /> + +
); } diff --git a/src/views/workflow-history-v2/workflow-history-group-details/__tests__/workflow-history-group-details.test.tsx b/src/views/workflow-history-v2/workflow-history-group-details/__tests__/workflow-history-group-details.test.tsx new file mode 100644 index 000000000..6551067aa --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-group-details/__tests__/workflow-history-group-details.test.tsx @@ -0,0 +1,220 @@ +import { render, screen, userEvent } from '@/test-utils/rtl'; + +import { type WorkflowPageParams } from '@/views/workflow-page/workflow-page.types'; + +import WorkflowHistoryGroupDetails from '../workflow-history-group-details'; +import { type GroupDetailsEntries } from '../workflow-history-group-details.types'; + +jest.mock( + '../../workflow-history-event-details/workflow-history-event-details', + () => + jest.fn( + ({ + eventDetails, + }: { + eventDetails: Array; + workflowPageParams: WorkflowPageParams; + }) => ( +
+ Event Details ({eventDetails.length} entries) +
+ ) + ) +); + +describe(WorkflowHistoryGroupDetails.name, () => { + const mockGroupDetails: GroupDetailsEntries = [ + [ + 'event-1', + { + eventLabel: 'Event 1 Label', + eventDetails: [ + { + key: 'key1', + path: 'path1', + value: 'value1', + isGroup: false, + renderConfig: null, + }, + ], + }, + ], + [ + 'event-2', + { + eventLabel: 'Event 2 Label', + eventDetails: [ + { + key: 'key2', + path: 'path2', + value: 'value2', + isGroup: false, + renderConfig: null, + }, + ], + }, + ], + [ + 'event-3', + { + eventLabel: 'Event 3 Label', + eventDetails: [ + { + key: 'key3', + path: 'path3', + value: 'value3', + isGroup: false, + renderConfig: null, + }, + ], + }, + ], + ]; + + it('renders all event labels as buttons', () => { + setup({ groupDetailsEntries: mockGroupDetails }); + + expect(screen.getByText('Event 1 Label')).toBeInTheDocument(); + expect(screen.getByText('Event 2 Label')).toBeInTheDocument(); + expect(screen.getByText('Event 3 Label')).toBeInTheDocument(); + }); + + it('renders WorkflowHistoryEventDetails with first event details by default', () => { + setup({ groupDetailsEntries: mockGroupDetails }); + + expect( + screen.getByLabelText('Workflow history event details') + ).toBeInTheDocument(); + expect(screen.getByText('Event Details (1 entries)')).toBeInTheDocument(); + }); + + it('selects the event matching initialEventId', () => { + setup({ + groupDetailsEntries: mockGroupDetails, + initialEventId: 'event-2', + }); + + const eventDetails = screen.getByLabelText( + 'Workflow history event details' + ); + expect(eventDetails).toBeInTheDocument(); + expect(screen.getByText('Event Details (1 entries)')).toBeInTheDocument(); + }); + + it('defaults to first event when initialEventId is not provided', () => { + setup({ groupDetailsEntries: mockGroupDetails }); + + const eventDetails = screen.getByLabelText( + 'Workflow history event details' + ); + expect(eventDetails).toBeInTheDocument(); + }); + + it('defaults to first event when initialEventId is not found', () => { + setup({ + groupDetailsEntries: mockGroupDetails, + initialEventId: 'non-existent-event', + }); + + const eventDetails = screen.getByLabelText( + 'Workflow history event details' + ); + expect(eventDetails).toBeInTheDocument(); + }); + + it('changes selected event when a button is clicked', async () => { + const { user } = setup({ groupDetailsEntries: mockGroupDetails }); + + // Initially should show first event + expect(screen.getByText('Event Details (1 entries)')).toBeInTheDocument(); + + // Click on second event button + const event2Button = screen.getByText('Event 2 Label'); + await user.click(event2Button); + + // Should still show event details (the mock returns same format) + expect( + screen.getByLabelText('Workflow history event details') + ).toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', async () => { + const mockOnClose = jest.fn(); + const { user } = setup({ + groupDetailsEntries: mockGroupDetails, + onClose: mockOnClose, + }); + + const closeButton = screen.getByLabelText('Close event details'); + await user.click(closeButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('does not render the close button when onClose is not provided', () => { + setup({ + groupDetailsEntries: mockGroupDetails, + onClose: undefined, + }); + + const closeButton = screen.queryByLabelText('Close event details'); + expect(closeButton).not.toBeInTheDocument(); + }); + + it('handles single event in groupDetails', () => { + const singleEventGroupDetails: GroupDetailsEntries = [ + [ + 'event-1', + { + eventLabel: 'Single Event', + eventDetails: [ + { + key: 'key1', + path: 'path1', + value: 'value1', + isGroup: false, + renderConfig: null, + }, + ], + }, + ], + ]; + + setup({ groupDetailsEntries: singleEventGroupDetails }); + + expect(screen.getByText('Single Event')).toBeInTheDocument(); + expect( + screen.getByLabelText('Workflow history event details') + ).toBeInTheDocument(); + }); +}); + +function setup({ + groupDetailsEntries, + initialEventId, + workflowPageParams = { + domain: 'test-domain', + cluster: 'test-cluster', + workflowId: 'test-workflow-id', + runId: 'test-run-id', + }, + onClose, +}: { + groupDetailsEntries: GroupDetailsEntries; + initialEventId?: string; + workflowPageParams?: WorkflowPageParams; + onClose?: () => void; +}) { + const user = userEvent.setup(); + + render( + + ); + + return { user }; +} diff --git a/src/views/workflow-history-v2/workflow-history-group-details/workflow-history-group-details.styles.ts b/src/views/workflow-history-v2/workflow-history-group-details/workflow-history-group-details.styles.ts new file mode 100644 index 000000000..54e8f0354 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-group-details/workflow-history-group-details.styles.ts @@ -0,0 +1,36 @@ +import { styled as createStyled, type Theme } from 'baseui'; +import { type ButtonGroupProps } from 'baseui/button-group'; +import { type StyleObject } from 'styletron-react'; + +export const styled = { + GroupDetailsContainer: createStyled( + 'div', + ({ $theme }: { $theme: Theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: $theme.sizing.scale500, + alignItems: 'stretch', + }) + ), + ActionsRow: createStyled('div', ({ $theme }: { $theme: Theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: $theme.sizing.scale400, + })), + ExtraActions: createStyled('div', ({ $theme }: { $theme: Theme }) => ({ + display: 'flex', + gap: $theme.sizing.scale100, + alignItems: 'center', + })), +}; + +export const overrides = { + buttonGroup: { + Root: { + style: ({ $theme }: { $theme: Theme }): StyleObject => ({ + gap: $theme.sizing.scale0, + }), + }, + } satisfies ButtonGroupProps['overrides'], +}; diff --git a/src/views/workflow-history-v2/workflow-history-group-details/workflow-history-group-details.tsx b/src/views/workflow-history-v2/workflow-history-group-details/workflow-history-group-details.tsx new file mode 100644 index 000000000..f79fd544c --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-group-details/workflow-history-group-details.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; + +import { Button } from 'baseui/button'; +import { ButtonGroup } from 'baseui/button-group'; +import { MdClose } from 'react-icons/md'; + +import WorkflowHistoryEventDetails from '../workflow-history-event-details/workflow-history-event-details'; + +import { overrides, styled } from './workflow-history-group-details.styles'; +import { type Props } from './workflow-history-group-details.types'; + +export default function WorkflowHistoryGroupDetails({ + groupDetailsEntries, + initialEventId, + workflowPageParams, + onClose, +}: Props) { + const [selectedIndex, setSelectedIndex] = useState( + (() => { + const selectedIdx = groupDetailsEntries.findIndex( + ([eventId]) => eventId === initialEventId + ); + return selectedIdx >= 0 ? selectedIdx : 0; + })() + ); + + return ( + + + { + setSelectedIndex(index); + }} + overrides={overrides.buttonGroup} + > + {groupDetailsEntries.map(([eventId, eventDetailsTabContent]) => ( + + ))} + + + {/* Copy Link Button */} + {onClose && ( + + )} + + + + + ); +} diff --git a/src/views/workflow-history-v2/workflow-history-group-details/workflow-history-group-details.types.ts b/src/views/workflow-history-v2/workflow-history-group-details/workflow-history-group-details.types.ts new file mode 100644 index 000000000..507fa3b6f --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-group-details/workflow-history-group-details.types.ts @@ -0,0 +1,17 @@ +import { type WorkflowPageParams } from '@/views/workflow-page/workflow-page.types'; + +import { type EventDetailsEntries } from '../workflow-history-event-details/workflow-history-event-details.types'; + +export type EventDetailsTabContent = { + eventDetails: EventDetailsEntries; + eventLabel: string; +}; + +export type GroupDetailsEntries = Array<[string, EventDetailsTabContent]>; + +export type Props = { + groupDetailsEntries: GroupDetailsEntries; + initialEventId: string | undefined; + workflowPageParams: WorkflowPageParams; + onClose?: () => void; +}; diff --git a/src/views/workflow-history-v2/workflow-history-panel-details-entry/__tests__/workflow-history-panel-details-entry.test.tsx b/src/views/workflow-history-v2/workflow-history-panel-details-entry/__tests__/workflow-history-panel-details-entry.test.tsx new file mode 100644 index 000000000..abc9f95b6 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-panel-details-entry/__tests__/workflow-history-panel-details-entry.test.tsx @@ -0,0 +1,176 @@ +import { render, screen } from '@/test-utils/rtl'; + +import type WorkflowHistoryEventDetailsGroup from '@/views/workflow-history/workflow-history-event-details-group/workflow-history-event-details-group'; +import { type WorkflowPageParams } from '@/views/workflow-page/workflow-page.types'; + +import { + type EventDetailsGroupEntry, + type EventDetailsSingleEntry, +} from '../../workflow-history-event-details/workflow-history-event-details.types'; +import WorkflowHistoryPanelDetailsEntry from '../workflow-history-panel-details-entry'; + +jest.mock( + '@/views/workflow-history/workflow-history-event-details-group/workflow-history-event-details-group', + () => + jest.fn(({ entries, parentGroupPath }) => ( +
+
{`Event Details Group (${entries.length} entries)`}
+
{parentGroupPath && ` - Parent: ${parentGroupPath}`}
+
+ )) +); + +describe(WorkflowHistoryPanelDetailsEntry.name, () => { + it('renders a single entry with value', () => { + const detail: EventDetailsSingleEntry = { + key: 'test-key', + path: 'test.path', + value: 'test-value', + isGroup: false, + renderConfig: null, + }; + + setup({ detail }); + + expect(screen.getByText('test.path')).toBeInTheDocument(); + expect(screen.getByText('test-value')).toBeInTheDocument(); + }); + + it('renders a group entry with WorkflowHistoryEventDetailsGroup', () => { + const detail: EventDetailsGroupEntry = { + key: 'test-key', + path: 'test.group.path', + isGroup: true, + groupEntries: [ + { + key: 'entry1', + path: 'test.group.path.entry1', + value: 'value1', + isGroup: false, + renderConfig: null, + }, + { + key: 'entry2', + path: 'test.group.path.entry2', + value: 'value2', + isGroup: false, + renderConfig: null, + }, + ], + renderConfig: null, + }; + + setup({ detail }); + + expect(screen.getByText('test.group.path')).toBeInTheDocument(); + expect(screen.getByTestId('event-details-group')).toBeInTheDocument(); + expect( + screen.getByText('Event Details Group (2 entries)') + ).toBeInTheDocument(); + expect(screen.getByText(/Parent: test\.group\.path/)).toBeInTheDocument(); + }); + + it('renders a custom ValueComponent when provided for a single entry', () => { + const MockValueComponent = jest.fn( + ({ + entryKey, + entryPath, + entryValue, + isNegative, + domain, + cluster, + }: { + entryKey: string; + entryPath: string; + entryValue: any; + isNegative?: boolean; + domain: string; + cluster: string; + }) => ( +
+ Custom: {entryKey} - {entryPath} - {JSON.stringify(entryValue)} + {isNegative && ' (negative)'} + {domain} - {cluster} +
+ ) + ); + + const detail: EventDetailsSingleEntry = { + key: 'test-key', + path: 'test.path', + value: 'test-value', + isGroup: false, + renderConfig: { + name: 'Test Config', + key: 'test-key', + valueComponent: MockValueComponent, + }, + }; + + setup({ + detail, + workflowPageParams: { + domain: 'test-domain', + cluster: 'test-cluster', + workflowId: 'test-workflow-id', + runId: 'test-run-id', + }, + }); + + expect(screen.getByTestId('custom-value-component')).toBeInTheDocument(); + expect( + screen.getByText(/Custom: test-key - test\.path - "test-value"/) + ).toBeInTheDocument(); + expect(screen.getByText(/test-domain - test-cluster/)).toBeInTheDocument(); + }); + + it('does not render ValueComponent for group entries', () => { + const MockValueComponent = jest.fn(() => ( +
Custom Component
+ )); + + const detail: EventDetailsGroupEntry = { + key: 'test-key', + path: 'test.group.path', + isGroup: true, + groupEntries: [ + { + key: 'entry1', + path: 'test.group.path.entry1', + value: 'value1', + isGroup: false, + renderConfig: null, + }, + ], + renderConfig: { + name: 'Test Config', + key: 'test-key', + valueComponent: MockValueComponent, + }, + }; + + setup({ detail }); + + expect( + screen.queryByTestId('custom-value-component') + ).not.toBeInTheDocument(); + expect(screen.getByTestId('event-details-group')).toBeInTheDocument(); + }); +}); + +function setup({ + detail, + workflowPageParams = { + domain: 'test-domain', + cluster: 'test-cluster', + workflowId: 'test-workflow-id', + runId: 'test-run-id', + }, +}: { + detail: EventDetailsSingleEntry | EventDetailsGroupEntry; + workflowPageParams?: WorkflowPageParams; +}) { + render( + + ); +} diff --git a/src/views/workflow-history-v2/workflow-history-panel-details-entry/workflow-history-panel-details-entry.styles.ts b/src/views/workflow-history-v2/workflow-history-panel-details-entry/workflow-history-panel-details-entry.styles.ts new file mode 100644 index 000000000..41d815466 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-panel-details-entry/workflow-history-panel-details-entry.styles.ts @@ -0,0 +1,33 @@ +import { styled as createStyled, type Theme } from 'baseui'; + +export const styled = { + PanelContainer: createStyled<'div', { $isNegative?: boolean }>( + 'div', + ({ $theme, $isNegative }: { $theme: Theme; $isNegative?: boolean }) => ({ + padding: $theme.sizing.scale600, + backgroundColor: $isNegative + ? $theme.colors.backgroundNegativeLight + : $theme.colors.backgroundSecondary, + borderRadius: $theme.borders.radius300, + height: '100%', + }) + ), + PanelLabel: createStyled<'div', { $isNegative?: boolean }>( + 'div', + ({ $theme, $isNegative }: { $theme: Theme; $isNegative?: boolean }) => ({ + color: $isNegative + ? $theme.colors.contentNegative + : $theme.colors.contentPrimary, + ...$theme.typography.LabelSmall, + }) + ), + PanelValue: createStyled<'div', { $isNegative?: boolean }>( + 'div', + ({ $theme, $isNegative }: { $theme: Theme; $isNegative?: boolean }) => ({ + padding: `${$theme.sizing.scale500} 0`, + color: $isNegative + ? $theme.colors.contentNegative + : $theme.colors.contentPrimary, + }) + ), +}; diff --git a/src/views/workflow-history-v2/workflow-history-panel-details-entry/workflow-history-panel-details-entry.tsx b/src/views/workflow-history-v2/workflow-history-panel-details-entry/workflow-history-panel-details-entry.tsx new file mode 100644 index 000000000..4f4edd376 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-panel-details-entry/workflow-history-panel-details-entry.tsx @@ -0,0 +1,42 @@ +import WorkflowHistoryEventDetailsGroup from '@/views/workflow-history/workflow-history-event-details-group/workflow-history-event-details-group'; + +import { styled } from './workflow-history-panel-details-entry.styles'; +import { type Props } from './workflow-history-panel-details-entry.types'; + +export default function WorkflowHistoryPanelDetailsEntry({ + detail, + ...workflowPageParams +}: Props) { + const ValueComponent = detail.renderConfig?.valueComponent; + + if (ValueComponent !== undefined && !detail.isGroup) { + return ( + + ); + } + + return ( + + + {detail.path} + + + {detail.isGroup ? ( + + ) : ( + detail.value + )} + + + ); +} diff --git a/src/views/workflow-history-v2/workflow-history-panel-details-entry/workflow-history-panel-details-entry.types.ts b/src/views/workflow-history-v2/workflow-history-panel-details-entry/workflow-history-panel-details-entry.types.ts new file mode 100644 index 000000000..75190199f --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-panel-details-entry/workflow-history-panel-details-entry.types.ts @@ -0,0 +1,10 @@ +import { type WorkflowPageParams } from '@/views/workflow-page/workflow-page.types'; + +import { + type EventDetailsGroupEntry, + type EventDetailsSingleEntry, +} from '../workflow-history-event-details/workflow-history-event-details.types'; + +export type Props = { + detail: EventDetailsSingleEntry | EventDetailsGroupEntry; +} & WorkflowPageParams; diff --git a/src/views/workflow-history/workflow-history-event-details-group/workflow-history-event-details-group.types.ts b/src/views/workflow-history/workflow-history-event-details-group/workflow-history-event-details-group.types.ts index 02ba5f193..084024717 100644 --- a/src/views/workflow-history/workflow-history-event-details-group/workflow-history-event-details-group.types.ts +++ b/src/views/workflow-history/workflow-history-event-details-group/workflow-history-event-details-group.types.ts @@ -1,11 +1,11 @@ -import { type WorkflowPageTabsParams } from '@/views/workflow-page/workflow-page-tabs/workflow-page-tabs.types'; +import { type WorkflowPageParams } from '@/views/workflow-page/workflow-page.types'; import { type WorkflowHistoryEventDetailsEntries } from '../workflow-history-event-details/workflow-history-event-details.types'; export type Props = { entries: WorkflowHistoryEventDetailsEntries; parentGroupPath?: string; - decodedPageUrlParams: WorkflowPageTabsParams; + decodedPageUrlParams: WorkflowPageParams; }; export type EventDetailsLabelKind = 'regular' | 'group' | 'negative';