diff --git a/src/views/workflow-history-v2/config/workflow-history-details-row-parsers.config.ts b/src/views/workflow-history-v2/config/workflow-history-details-row-parsers.config.ts new file mode 100644 index 000000000..3da86085e --- /dev/null +++ b/src/views/workflow-history-v2/config/workflow-history-details-row-parsers.config.ts @@ -0,0 +1,44 @@ +import { + MdHourglassBottom, + MdOutlineMonitorHeart, + MdReplay, +} from 'react-icons/md'; + +import { type DetailsRowItemParser } from '../workflow-history-details-row/workflow-history-details-row.types'; +import WorkflowHistoryDetailsRowJson from '../workflow-history-details-row-json/workflow-history-details-row-json'; +import WorkflowHistoryDetailsRowTooltipJson from '../workflow-history-details-row-tooltip-json/workflow-history-details-row-tooltip-json'; + +const workflowHistoryDetailsRowParsersConfig: Array = [ + { + name: 'Heartbeat time', + matcher: (name) => name === 'lastHeartbeatTime', + icon: MdOutlineMonitorHeart, + }, + { + name: 'Json as PrettyJson', + matcher: (name, value) => + value !== null && + new RegExp( + '(input|result|details|failureDetails|Error|lastCompletionResult|heartbeatDetails|lastFailureDetails)$' + ).test(name), + icon: null, + customRenderValue: WorkflowHistoryDetailsRowJson, + customTooltipContent: WorkflowHistoryDetailsRowTooltipJson, + invertTooltipColors: true, + }, + { + name: 'Timeouts with timer icon', + matcher: (name) => + new RegExp('(TimeoutSeconds|BackoffSeconds|InSeconds)$').test(name), + icon: MdHourglassBottom, + }, + { + name: '"attempt" greater than 0, as "retries"', + matcher: (name) => name === 'attempt', + hide: (_, value) => typeof value === 'number' && value <= 0, + icon: MdReplay, + customTooltipContent: () => 'retries', + }, +]; + +export default workflowHistoryDetailsRowParsersConfig; diff --git a/src/views/workflow-history-v2/workflow-history-details-row-json/__tests__/workflow-history-details-row-json.test.tsx b/src/views/workflow-history-v2/workflow-history-details-row-json/__tests__/workflow-history-details-row-json.test.tsx new file mode 100644 index 000000000..23154f9b1 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-details-row-json/__tests__/workflow-history-details-row-json.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@/test-utils/rtl'; + +import WorkflowHistoryDetailsRowJson from '../workflow-history-details-row-json'; + +describe(WorkflowHistoryDetailsRowJson.name, () => { + it('renders the stringified JSON value', () => { + render( + + ); + + expect( + screen.getByText('{"key":"value","nested":{"number":123}}') + ).toBeInTheDocument(); + }); +}); diff --git a/src/views/workflow-history-v2/workflow-history-details-row-json/workflow-history-details-row-json.styles.ts b/src/views/workflow-history-v2/workflow-history-details-row-json/workflow-history-details-row-json.styles.ts new file mode 100644 index 000000000..66e4201d2 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-details-row-json/workflow-history-details-row-json.styles.ts @@ -0,0 +1,15 @@ +import { styled as createStyled } from 'baseui'; + +export const styled = { + JsonViewContainer: createStyled<'div', { $isNegative: boolean }>( + 'div', + ({ $theme, $isNegative }) => ({ + color: $isNegative ? $theme.colors.contentNegative : '#A964F7', + maxWidth: '360px', + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + ...$theme.typography.MonoParagraphXSmall, + }) + ), +}; diff --git a/src/views/workflow-history-v2/workflow-history-details-row-json/workflow-history-details-row-json.tsx b/src/views/workflow-history-v2/workflow-history-details-row-json/workflow-history-details-row-json.tsx new file mode 100644 index 000000000..620c320c6 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-details-row-json/workflow-history-details-row-json.tsx @@ -0,0 +1,16 @@ +import losslessJsonStringify from '@/utils/lossless-json-stringify'; + +import { type DetailsRowValueComponentProps } from '../workflow-history-details-row/workflow-history-details-row.types'; + +import { styled } from './workflow-history-details-row-json.styles'; + +export default function WorkflowHistoryDetailsRowJson({ + value, + isNegative, +}: DetailsRowValueComponentProps) { + return ( + + {losslessJsonStringify(value)} + + ); +} diff --git a/src/views/workflow-history-v2/workflow-history-details-row-json/workflow-history-details-row-json.types.ts b/src/views/workflow-history-v2/workflow-history-details-row-json/workflow-history-details-row-json.types.ts new file mode 100644 index 000000000..3875f43c1 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-details-row-json/workflow-history-details-row-json.types.ts @@ -0,0 +1,3 @@ +import { type DetailsRowValueComponentProps } from '../workflow-history-details-row/workflow-history-details-row.types'; + +export type Props = DetailsRowValueComponentProps; diff --git a/src/views/workflow-history-v2/workflow-history-details-row-tooltip-json/__tests__/workflow-history-details-row-tooltip-json.test.tsx b/src/views/workflow-history-v2/workflow-history-details-row-tooltip-json/__tests__/workflow-history-details-row-tooltip-json.test.tsx new file mode 100644 index 000000000..cb0d55bc2 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-details-row-tooltip-json/__tests__/workflow-history-details-row-tooltip-json.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@/test-utils/rtl'; + +import WorkflowHistoryDetailsRowTooltipJson from '../workflow-history-details-row-tooltip-json'; + +jest.mock( + '@/views/workflow-history/workflow-history-event-details-json/workflow-history-event-details-json', + () => + jest.fn(({ entryValue, isNegative }) => ( +
+ Event Details Json: {JSON.stringify(entryValue)} + {isNegative && ' (negative)'} +
+ )) +); + +describe(WorkflowHistoryDetailsRowTooltipJson.name, () => { + it('renders the label and passes value to WorkflowHistoryEventDetailsJson', () => { + render( + + ); + + expect(screen.getByText('test-label')).toBeInTheDocument(); + expect(screen.getByTestId('event-details-json')).toBeInTheDocument(); + expect( + screen.getByText( + /Event Details Json: \{"key":"value","nested":\{"number":123\}\}/ + ) + ).toBeInTheDocument(); + }); +}); diff --git a/src/views/workflow-history-v2/workflow-history-details-row-tooltip-json/workflow-history-details-row-tooltip-json.styles.ts b/src/views/workflow-history-v2/workflow-history-details-row-tooltip-json/workflow-history-details-row-tooltip-json.styles.ts new file mode 100644 index 000000000..6506a4c71 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-details-row-tooltip-json/workflow-history-details-row-tooltip-json.styles.ts @@ -0,0 +1,13 @@ +import { styled as createStyled } from 'baseui'; + +export const styled = { + JsonPreviewContainer: createStyled('div', ({ $theme }) => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: $theme.sizing.scale200, + })), + JsonPreviewLabel: createStyled('div', ({ $theme }) => ({ + ...$theme.typography.LabelXSmall, + })), +}; diff --git a/src/views/workflow-history-v2/workflow-history-details-row-tooltip-json/workflow-history-details-row-tooltip-json.tsx b/src/views/workflow-history-v2/workflow-history-details-row-tooltip-json/workflow-history-details-row-tooltip-json.tsx new file mode 100644 index 000000000..f9866b59d --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-details-row-tooltip-json/workflow-history-details-row-tooltip-json.tsx @@ -0,0 +1,21 @@ +import WorkflowHistoryEventDetailsJson from '@/views/workflow-history/workflow-history-event-details-json/workflow-history-event-details-json'; + +import { type DetailsRowValueComponentProps } from '../workflow-history-details-row/workflow-history-details-row.types'; + +import { styled } from './workflow-history-details-row-tooltip-json.styles'; + +export default function WorkflowHistoryDetailsRowTooltipJson({ + value, + label, + isNegative, +}: DetailsRowValueComponentProps) { + return ( + + {label} + + + ); +} diff --git a/src/views/workflow-history-v2/workflow-history-details-row-tooltip-json/workflow-history-details-row-tooltip-json.types.ts b/src/views/workflow-history-v2/workflow-history-details-row-tooltip-json/workflow-history-details-row-tooltip-json.types.ts new file mode 100644 index 000000000..3875f43c1 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-details-row-tooltip-json/workflow-history-details-row-tooltip-json.types.ts @@ -0,0 +1,3 @@ +import { type DetailsRowValueComponentProps } from '../workflow-history-details-row/workflow-history-details-row.types'; + +export type Props = DetailsRowValueComponentProps; diff --git a/src/views/workflow-history-v2/workflow-history-details-row/__tests__/workflow-history-details-row.test.tsx b/src/views/workflow-history-v2/workflow-history-details-row/__tests__/workflow-history-details-row.test.tsx new file mode 100644 index 000000000..1d16a19c4 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-details-row/__tests__/workflow-history-details-row.test.tsx @@ -0,0 +1,136 @@ +import { render, screen, userEvent } from '@/test-utils/rtl'; + +import type { WorkflowPageParams } from '@/views/workflow-page/workflow-page.types'; + +import { type EventDetailsEntries } from '../../workflow-history-event-details/workflow-history-event-details.types'; +import WorkflowHistoryDetailsRow from '../workflow-history-details-row'; +import { type DetailsRowItem } from '../workflow-history-details-row.types'; + +jest.mock('../helpers/get-parsed-details-row-items', () => + jest.fn((detailsEntries: EventDetailsEntries) => + detailsEntries.reduce>((acc, entry) => { + if (!entry.isGroup) { + acc.push({ + path: entry.path, + label: entry.path, + value: entry.value, + icon: ({ size }: any) => ( + + ), + renderValue: ({ value, isNegative }: any) => ( + + {value} + + ), + renderTooltip: ({ label }: any) => ( + {label} + ), + invertTooltipColors: acc.length === 1, // Second item has inverted tooltip + omitWrapping: acc.length === 2, // Third item omits wrapping + }); + } + return acc; + }, []) + ) +); + +const mockWorkflowPageParams: WorkflowPageParams = { + cluster: 'test-cluster', + domain: 'test-domain', + workflowId: 'test-workflow', + runId: 'test-run', +}; + +const mockDetailsEntries: EventDetailsEntries = [ + { + key: 'field1', + path: 'field1', + isGroup: false, + value: 'value1', + isNegative: false, + renderConfig: null, + }, + { + key: 'field2', + path: 'field2', + isGroup: false, + value: 'value2', + isNegative: true, + renderConfig: null, + }, + { + key: 'field3', + path: 'field3', + isGroup: false, + value: 'value3', + isNegative: false, + renderConfig: null, + }, +]; + +describe(WorkflowHistoryDetailsRow.name, () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render details row items when detailsEntries has items', () => { + setup(); + + expect(screen.getByTestId('field-field1')).toBeInTheDocument(); + expect(screen.getByTestId('field-field2')).toBeInTheDocument(); + expect(screen.getByTestId('field-field3')).toBeInTheDocument(); + expect(screen.getByText('value1')).toBeInTheDocument(); + expect(screen.getByText('value2')).toBeInTheDocument(); + expect(screen.getByText('value3')).toBeInTheDocument(); + }); + + it('should mark negative fields correctly', () => { + setup(); + + const negativeField = screen.getByTestId('field-field2'); + expect(negativeField).toHaveAttribute('data-negative', 'true'); + + const positiveField = screen.getByTestId('field-field1'); + expect(positiveField).toHaveAttribute('data-negative', 'false'); + }); + + it('should render icons when provided in item config', () => { + setup(); + + expect(screen.getByTestId('icon-field1')).toBeInTheDocument(); + expect(screen.getByTestId('icon-field2')).toBeInTheDocument(); + expect(screen.getByTestId('icon-field3')).toBeInTheDocument(); + }); + + it('should render tooltip content on hover', async () => { + const { user } = setup(); + + const field1 = screen.getByTestId('field-field1'); + await user.hover(field1); + + expect(await screen.findByTestId('tooltip-field1')).toBeInTheDocument(); + expect(screen.getByText('field1')).toBeInTheDocument(); + }); +}); + +function setup({ + detailsEntries = mockDetailsEntries, + workflowPageParams = mockWorkflowPageParams, +}: { + detailsEntries?: EventDetailsEntries; + workflowPageParams?: WorkflowPageParams; +} = {}) { + const user = userEvent.setup(); + + const renderResult = render( + + ); + + return { user, ...renderResult }; +} diff --git a/src/views/workflow-history-v2/workflow-history-details-row/helpers/__tests__/get-parsed-details-row-items.test.ts b/src/views/workflow-history-v2/workflow-history-details-row/helpers/__tests__/get-parsed-details-row-items.test.ts new file mode 100644 index 000000000..b9ceade3f --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-details-row/helpers/__tests__/get-parsed-details-row-items.test.ts @@ -0,0 +1,357 @@ +import { type EventDetailsEntries } from '@/views/workflow-history-v2/workflow-history-event-details/workflow-history-event-details.types'; + +import * as workflowHistoryDetailsRowParsersConfigModule from '../../../config/workflow-history-details-row-parsers.config'; +import { type DetailsRowItemParser } from '../../workflow-history-details-row.types'; +import getParsedDetailsRowItems from '../get-parsed-details-row-items'; + +// Mock the parser config +jest.mock('../../../config/workflow-history-details-row-parsers.config', () => { + const mockIcon = jest.fn(); + const mockCustomRenderValue = jest.fn(); + const mockCustomTooltipContent = jest.fn(); + + return { + __esModule: true, + default: [ + { + name: 'Json Parser', + matcher: (path: string, _value: unknown) => + path === 'input' || path === 'result', + icon: null, + customRenderValue: mockCustomRenderValue, + customTooltipContent: mockCustomTooltipContent, + invertTooltipColors: true, + }, + { + name: 'Timeout Parser', + matcher: (path: string) => path.endsWith('TimeoutSeconds'), + icon: mockIcon, + }, + { + name: 'Heartbeat Parser', + matcher: (path: string) => path === 'lastHeartbeatTime', + icon: mockIcon, + }, + { + name: 'Hidden Field Parser', + matcher: (path: string) => path === 'hiddenField', + icon: null, + hide: jest.fn((_, value) => value === 'hide-me'), + }, + { + name: 'Attempt Parser', + matcher: (path: string) => path === 'attempt', + icon: mockIcon, + customTooltipContent: jest.fn(() => 'retries'), + omitWrapping: true, + }, + ] satisfies Array, + }; +}); + +describe(getParsedDetailsRowItems.name, () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return empty array when detailsEntries is empty', () => { + const detailsEntries: EventDetailsEntries = []; + + const result = getParsedDetailsRowItems(detailsEntries); + + expect(result).toEqual([]); + }); + + it('should skip group entries', () => { + const detailsEntries: EventDetailsEntries = [ + { + key: 'groupField', + path: 'groupField', + renderConfig: { + name: 'Test Config', + key: 'test', + }, + isGroup: true, + groupEntries: [], + }, + { + key: 'regularField', + path: 'regularField', + value: 'test-value', + renderConfig: { + name: 'Test Config', + key: 'test', + }, + isGroup: false, + }, + ]; + + const result = getParsedDetailsRowItems(detailsEntries); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('regularField'); + }); + + it('should use parser config when matcher matches', () => { + const detailsEntries: EventDetailsEntries = [ + { + key: 'input', + path: 'input', + value: { data: 'test' }, + renderConfig: { + name: 'Test Config', + key: 'test', + }, + isGroup: false, + }, + ]; + + const result = getParsedDetailsRowItems(detailsEntries); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('input'); + expect(result[0].value).toEqual({ data: 'test' }); + }); + + it('should exclude entries when hide function returns true', () => { + const detailsEntries: EventDetailsEntries = [ + { + key: 'hiddenField', + path: 'hiddenField', + value: 'hide-me', + renderConfig: { + name: 'Test Config', + key: 'test', + }, + isGroup: false, + }, + { + key: 'hiddenField', + path: 'hiddenField', + value: 'show-me', + renderConfig: { + name: 'Test Config', + key: 'test', + }, + isGroup: false, + }, + ]; + + const result = getParsedDetailsRowItems(detailsEntries); + + expect(result).toHaveLength(1); + expect(result[0].value).toBe('show-me'); + }); + + it('should use customRenderValue from parser config when available', () => { + const detailsEntries: EventDetailsEntries = [ + { + key: 'input', + path: 'input', + value: { data: 'test' }, + renderConfig: { + name: 'Test Config', + key: 'test', + }, + isGroup: false, + }, + ]; + + const result = getParsedDetailsRowItems(detailsEntries); + + expect(result).toHaveLength(1); + expect(result[0].renderValue).toBe( + workflowHistoryDetailsRowParsersConfigModule.default[0].customRenderValue + ); + }); + + it('should use renderConfig.valueComponent when no customRenderValue is available', () => { + const mockValueComponent = jest.fn(); + const detailsEntries: EventDetailsEntries = [ + { + key: 'regularField', + path: 'regularField', + value: 'test-value', + renderConfig: { + name: 'Test Config', + key: 'test', + valueComponent: mockValueComponent, + }, + isGroup: false, + }, + ]; + + const result = getParsedDetailsRowItems(detailsEntries); + + expect(result).toHaveLength(1); + expect(result[0].renderValue).toBeDefined(); + }); + + it('should use icon from parser config when available', () => { + const detailsEntries: EventDetailsEntries = [ + { + key: 'lastHeartbeatTime', + path: 'lastHeartbeatTime', + value: 1234567890, + renderConfig: { + name: 'Test Config', + key: 'test', + }, + isGroup: false, + }, + ]; + + const result = getParsedDetailsRowItems(detailsEntries); + + expect(result).toHaveLength(1); + expect(result[0].icon).toBe( + workflowHistoryDetailsRowParsersConfigModule.default[2].icon + ); + }); + + it('should use null icon when parser config has no icon', () => { + const detailsEntries: EventDetailsEntries = [ + { + key: 'input', + path: 'input', + value: { data: 'test' }, + renderConfig: { + name: 'Test Config', + key: 'test', + }, + isGroup: false, + }, + ]; + + const result = getParsedDetailsRowItems(detailsEntries); + + expect(result).toHaveLength(1); + expect(result[0].icon).toBeNull(); + }); + + it('should use customTooltipContent from parser config when available', () => { + const detailsEntries: EventDetailsEntries = [ + { + key: 'input', + path: 'input', + value: { data: 'test' }, + renderConfig: { + name: 'Test Config', + key: 'test', + }, + isGroup: false, + }, + ]; + + const result = getParsedDetailsRowItems(detailsEntries); + + expect(result).toHaveLength(1); + expect(result[0].renderTooltip).toBe( + workflowHistoryDetailsRowParsersConfigModule.default[0] + .customTooltipContent + ); + }); + + it('should use default tooltip function that returns label when no customTooltipContent is available', () => { + const detailsEntries: EventDetailsEntries = [ + { + key: 'regularField', + path: 'regularField', + value: 'test-value', + renderConfig: { + name: 'Test Config', + key: 'test', + }, + isGroup: false, + }, + ]; + + const result = getParsedDetailsRowItems(detailsEntries); + + expect(result).toHaveLength(1); + expect(result[0].renderTooltip).toBeDefined(); + // renderTooltip should be a function that returns the label + expect(typeof result[0].renderTooltip).toBe('function'); + }); + + it('should use getLabel from renderConfig when available', () => { + const detailsEntries: EventDetailsEntries = [ + { + key: 'regularField', + path: 'regularField', + value: 'test-value', + renderConfig: { + name: 'Test Config', + key: 'test', + getLabel: ({ path }) => `Label: ${path}`, + }, + isGroup: false, + }, + ]; + + const result = getParsedDetailsRowItems(detailsEntries); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Label: regularField'); + }); + + it('should use path as label when getLabel is not available', () => { + const detailsEntries: EventDetailsEntries = [ + { + key: 'regularField', + path: 'regularField', + value: 'test-value', + renderConfig: { + name: 'Test Config', + key: 'test', + }, + isGroup: false, + }, + ]; + + const result = getParsedDetailsRowItems(detailsEntries); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('regularField'); + }); + + it('should include invertTooltipColors from parser config when available', () => { + const detailsEntries: EventDetailsEntries = [ + { + key: 'input', + path: 'input', + value: { data: 'test' }, + renderConfig: { + name: 'Test Config', + key: 'test', + }, + isGroup: false, + }, + ]; + + const result = getParsedDetailsRowItems(detailsEntries); + + expect(result).toHaveLength(1); + expect(result[0].invertTooltipColors).toBe(true); + }); + + it('should include omitWrapping from parser config when available', () => { + const detailsEntries: EventDetailsEntries = [ + { + key: 'attempt', + path: 'attempt', + value: 2, + renderConfig: { + name: 'Test Config', + key: 'test', + }, + isGroup: false, + }, + ]; + + const result = getParsedDetailsRowItems(detailsEntries); + + expect(result).toHaveLength(1); + expect(result[0].omitWrapping).toBe(true); + }); +}); diff --git a/src/views/workflow-history-v2/workflow-history-details-row/helpers/get-parsed-details-row-items.ts b/src/views/workflow-history-v2/workflow-history-details-row/helpers/get-parsed-details-row-items.ts new file mode 100644 index 000000000..dd5193bfa --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-details-row/helpers/get-parsed-details-row-items.ts @@ -0,0 +1,58 @@ +import { createElement, type ComponentType } from 'react'; + +import workflowHistoryDetailsRowParsersConfig from '../../config/workflow-history-details-row-parsers.config'; +import { type EventDetailsEntries } from '../../workflow-history-event-details/workflow-history-event-details.types'; +import { + type DetailsRowValueComponentProps, + type DetailsRowItem, +} from '../workflow-history-details-row.types'; + +export default function getParsedDetailsRowItems( + detailsEntries: EventDetailsEntries +): Array { + return detailsEntries.reduce>((acc, detailsConfig) => { + if (detailsConfig.isGroup) return acc; + + const { key, path, value, renderConfig } = detailsConfig; + + const parserConfig = workflowHistoryDetailsRowParsersConfig.find((config) => + config.matcher(path, value) + ); + + if (parserConfig?.hide?.(path, value)) { + return acc; + } + + const label = renderConfig?.getLabel?.({ key, path, value }) ?? path; + + let renderValue: ComponentType; + if (parserConfig?.customRenderValue) { + renderValue = parserConfig.customRenderValue; + } else if (renderConfig?.valueComponent) { + const detailsRenderValue = renderConfig.valueComponent; + renderValue = ({ value, label, isNegative, ...workflowPageParams }) => + createElement(detailsRenderValue, { + entryKey: key, + entryPath: path, + entryValue: value, + isNegative, + ...workflowPageParams, + }); + } else { + renderValue = ({ value }) => String(value); + } + + acc.push({ + path, + label, + value, + icon: parserConfig?.icon ?? null, + renderValue, + renderTooltip: parserConfig?.customTooltipContent ?? (() => label), + invertTooltipColors: parserConfig?.invertTooltipColors, + omitWrapping: parserConfig?.omitWrapping, + }); + + return acc; + }, []); +} diff --git a/src/views/workflow-history-v2/workflow-history-details-row/workflow-history-details-row.styles.ts b/src/views/workflow-history-v2/workflow-history-details-row/workflow-history-details-row.styles.ts new file mode 100644 index 000000000..cb9ad43e2 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-details-row/workflow-history-details-row.styles.ts @@ -0,0 +1,69 @@ +import { styled as createStyled, type Theme } from 'baseui'; +import { type PopoverOverrides } from 'baseui/popover'; +import { type StyleObject } from 'styletron-react'; + +export const styled = { + DetailsRowContainer: createStyled('div', ({ $theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: $theme.sizing.scale300, + ...$theme.typography.LabelXSmall, + overflow: 'hidden', + })), + DetailsFieldContainer: createStyled< + 'div', + { $isNegative: boolean; $omitWrapping: boolean } + >( + 'div', + ({ + $theme, + $isNegative, + $omitWrapping, + }: { + $theme: Theme; + $isNegative: boolean; + $omitWrapping: boolean; + }) => ({ + display: 'flex', + alignItems: 'center', + gap: $theme.sizing.scale100, + color: $isNegative + ? $theme.colors.contentNegative + : $theme.colors.contentPrimary, + height: $theme.sizing.scale700, + ...($omitWrapping + ? {} + : { + padding: `${$theme.sizing.scale0} ${$theme.sizing.scale100}`, + backgroundColor: $isNegative + ? $theme.colors.backgroundNegativeLight + : $theme.colors.backgroundSecondary, + borderRadius: $theme.borders.radius200, + }), + }) + ), +}; + +export const overrides = { + popover: { + Inner: { + style: { + maxWidth: '500px', + }, + }, + }, + popoverInverted: { + Arrow: { + style: ({ $theme }: { $theme: Theme }): StyleObject => ({ + backgroundColor: $theme.colors.backgroundPrimary, + }), + }, + Inner: { + style: ({ $theme }: { $theme: Theme }): StyleObject => ({ + backgroundColor: $theme.colors.backgroundPrimary, + color: $theme.colors.contentPrimary, + maxWidth: '500px', + }), + }, + } satisfies PopoverOverrides, +}; diff --git a/src/views/workflow-history-v2/workflow-history-details-row/workflow-history-details-row.tsx b/src/views/workflow-history-v2/workflow-history-details-row/workflow-history-details-row.tsx new file mode 100644 index 000000000..bae6c4654 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-details-row/workflow-history-details-row.tsx @@ -0,0 +1,75 @@ +import React, { useMemo } from 'react'; + +import { StatefulTooltip } from 'baseui/tooltip'; + +import getParsedDetailsRowItems from './helpers/get-parsed-details-row-items'; +import { overrides, styled } from './workflow-history-details-row.styles'; +import { type Props } from './workflow-history-details-row.types'; + +export default function WorkflowHistoryDetailsRow({ + detailsEntries, + ...workflowPageParams +}: Props) { + const rowItems = useMemo(() => { + if (detailsEntries.length === 0) { + return []; + } + + return getParsedDetailsRowItems(detailsEntries); + }, [detailsEntries]); + + const negativePathsSet = useMemo( + () => + new Set( + detailsEntries + .filter((entry) => entry.isNegative) + .map((entry) => entry.path) + ), + [detailsEntries] + ); + + if (rowItems.length === 0) return
; + + return ( + + {rowItems.map((item) => { + const isNegative = negativePathsSet.has(item.path); + + return ( + + } + ignoreBoundary + placement="bottom" + showArrow + overrides={ + item.invertTooltipColors + ? overrides.popoverInverted + : overrides.popover + } + > + + {item.icon && } + + + + ); + })} + + ); +} diff --git a/src/views/workflow-history-v2/workflow-history-details-row/workflow-history-details-row.types.ts b/src/views/workflow-history-v2/workflow-history-details-row/workflow-history-details-row.types.ts new file mode 100644 index 000000000..8ffc313a3 --- /dev/null +++ b/src/views/workflow-history-v2/workflow-history-details-row/workflow-history-details-row.types.ts @@ -0,0 +1,72 @@ +import { type ComponentType } from 'react'; + +import { type IconProps } from 'baseui/icon'; + +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 DetailsRowValueComponentProps = { + label: string; + value: any; + isNegative?: boolean; +} & WorkflowPageParams; + +/** + * Configuration object for parsing and rendering workflow history details row items. + * Parsers are matched against event details entries to determine how they should be displayed. + */ +export type DetailsRowItemParser = { + /** Human-readable name for this parser configuration */ + name: string; + /** + * Function that determines if this parser applies to a given path/value pair. + * Returns true if the parser should handle this entry. + */ + matcher: (path: string, value: unknown) => boolean; + /** + * Optional function that conditionally hides entries even if the matcher matches. + * Returns true to hide the entry, false to show it. + */ + hide?: (path: string, value: unknown) => boolean; + /** + * React component for the icon to display next to the value, or null for no icon. + * The icon component should accept size and color props compatible with BaseUI IconProps. + */ + icon: React.ComponentType<{ + size?: IconProps['size']; + color?: IconProps['color']; + }> | null; + /** + * Optional React component to use for rendering the value instead of the default string conversion. + * Receives DetailsRowValueComponentProps. + */ + customRenderValue?: ComponentType; + /** + * Optional React component to use for rendering tooltip content instead of the default label. + * Receives DetailsRowValueComponentProps. + */ + customTooltipContent?: ComponentType; + /** Optional flag to invert the tooltip color scheme (default: dark tooltip in light mode). */ + invertTooltipColors?: boolean; + /** Optional flag to remove padding and background from the details row item */ + omitWrapping?: boolean; +}; + +export type DetailsRowItem = { + path: string; + label: string; + value: any; + icon: React.ComponentType<{ + size?: IconProps['size']; + color?: IconProps['color']; + }> | null; + renderValue: ComponentType; + renderTooltip: ComponentType; + invertTooltipColors?: boolean; + omitWrapping?: boolean; +}; + +export type Props = { + detailsEntries: EventDetailsEntries; +} & WorkflowPageParams; 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 cd7bb060a..b2d9ce5d6 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 @@ -42,7 +42,9 @@ export const styled = { display: 'flex', gap: $theme.sizing.scale300, alignItems: 'center', - margin: `-${$theme.sizing.scale200} 0`, + [$theme.mediaQuery.medium]: { + margin: `-${$theme.sizing.scale200} 0`, + }, })), GroupDetailsGridContainer: createStyled('div', { display: 'grid', 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 4ccd94c13..192982f8b 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 @@ -10,6 +10,7 @@ import WorkflowHistoryTimelineResetButton from '@/views/workflow-history/workflo import workflowHistoryEventFilteringTypeColorsConfig from '../config/workflow-history-event-filtering-type-colors.config'; import generateHistoryGroupDetails from '../helpers/generate-history-group-details'; +import WorkflowHistoryDetailsRow from '../workflow-history-details-row/workflow-history-details-row'; import WorkflowHistoryEventGroupDuration from '../workflow-history-event-group-duration/workflow-history-event-group-duration'; import WorkflowHistoryGroupDetails from '../workflow-history-group-details/workflow-history-group-details'; @@ -72,21 +73,27 @@ export default function WorkflowHistoryEventGroup({ [events, getIsEventExpanded, toggleIsEventExpanded] ); + const groupSummaryDetails = useMemo( + () => + summaryDetailsEntries.flatMap( + ([_eventId, { eventDetails }]) => eventDetails + ), + [summaryDetailsEntries] + ); + const groupDetailsEntriesWithSummary = useMemo( () => [ - ...(summaryDetailsEntries.length > 0 + ...(groupSummaryDetails.length > 0 && groupDetailsEntries.length > 1 ? [ getSummaryTabContentEntry({ groupId: eventGroup.firstEventId ?? 'unknown', - summaryDetails: summaryDetailsEntries.flatMap( - ([_eventId, { eventDetails }]) => eventDetails - ), + summaryDetails: groupSummaryDetails, }), ] : []), ...groupDetailsEntries, ], - [eventGroup.firstEventId, groupDetailsEntries, summaryDetailsEntries] + [eventGroup.firstEventId, groupDetailsEntries, groupSummaryDetails] ); return ( @@ -124,7 +131,10 @@ export default function WorkflowHistoryEventGroup({ />
- Placeholder for event details + {resetToDecisionEventId && (