diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/TaskHelper.js b/webpack/ForemanTasks/Components/TaskDetails/Components/TaskHelper.js index 156d52f6e..f6f6b2166 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/Components/TaskHelper.js +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/TaskHelper.js @@ -22,3 +22,57 @@ export const durationInWords = ( const numberWithDelimiter = x => x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + +export const isDelayed = ({ startAt, startedAt }) => { + if ( + startAt == null || + startedAt == null || + startAt === '' || + startedAt === '' + ) { + return false; + } + + const a = new Date(startAt); + const b = new Date(startedAt); + + if (Number.isNaN(a.getTime()) || Number.isNaN(b.getTime())) { + return false; + } + + a.setMilliseconds(0); + b.setMilliseconds(0); + + return a.getTime() !== b.getTime(); +}; + +export const parseUsernameLinkHref = usernamePath => { + if (!usernamePath || typeof usernamePath !== 'string') { + return null; + } + + const match = usernamePath.match(/href=(["'])(.*?)\1/i); + + return match ? match[2] : null; +}; + +export const formatTaskDuration = (startedAtStr, endedAtStr, state) => { + if (state !== 'stopped' || !startedAtStr || !endedAtStr) { + return __('N/A'); + } + + const startMs = new Date(startedAtStr).getTime(); + const endMs = new Date(endedAtStr).getTime(); + + if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs < startMs) { + return __('N/A'); + } + + const duration = durationInWords(startedAtStr, endedAtStr); + + if (typeof duration === 'string') { + return duration; + } + + return duration.text; +}; diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/TaskInfo.js b/webpack/ForemanTasks/Components/TaskDetails/Components/TaskInfo.js index affab7a53..9bf000779 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/Components/TaskInfo.js +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/TaskInfo.js @@ -1,36 +1,39 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { + ClipboardCopy, + ExpandableSection, + ExpandableSectionToggle, + Flex, + FlexItem, Grid, GridItem, Progress, ProgressVariant, + Split, + SplitItem, + Text, + TextVariants, } from '@patternfly/react-core'; -import { translate as __ } from 'foremanReact/common/I18n'; +import { translate as __, sprintf } from 'foremanReact/common/I18n'; import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime'; import { taskResultIconEl } from '../../common/taskResultIcon'; +import { + formatTaskDuration, + isDelayed, + parseUsernameLinkHref, +} from './TaskHelper'; -const isDelayed = ({ startAt, startedAt }) => { - if ( - startAt == null || - startedAt == null || - startAt === '' || - startedAt === '' - ) { - return false; - } - const a = new Date(startAt); - const b = new Date(startedAt); - if (Number.isNaN(a.getTime()) || Number.isNaN(b.getTime())) { - return false; +const formatResultLabel = result => { + if (!result || typeof result !== 'string') { + return __('N/A'); } - a.setMilliseconds(0); - b.setMilliseconds(0); - return a.getTime() !== b.getTime(); + + return result.charAt(0).toUpperCase() + result.slice(1).toLowerCase(); }; -const progressVariantForResult = result => { +const progressVariant = result => { switch (result) { case 'error': return ProgressVariant.danger; @@ -43,127 +46,233 @@ const progressVariantForResult = result => { } }; +const MetadataField = ({ title, value, className, labelOuiaId, span }) => ( + + + + + {title} + + + {value} + + +); + +MetadataField.propTypes = { + title: PropTypes.node.isRequired, + value: PropTypes.node.isRequired, + className: PropTypes.string, + labelOuiaId: PropTypes.string.isRequired, + span: PropTypes.number, +}; + +MetadataField.defaultProps = { + className: undefined, + span: undefined, +}; + +const copyableText = text => + text ? ( + + {text} + + ) : ( + __('N/A') + ); + const TaskInfo = props => { const { action, endedAt, + externalId, + id, + label, result, startAt, - startBefore, startedAt, + startBefore, state, help, - output, progress, username, usernamePath, } = props; - const details = [ - [ - { - title: 'Name', - value: action || __('N/A'), - className: 'details-name', - }, - { - title: 'Start at', - value: , - }, - ], - [ - { - title: 'Result', - value: ( - - {taskResultIconEl(state, result)} {result} - - ), - }, - { - title: 'Started at', - value: , - }, - ], - [ - { - title: 'Triggered by', - value: (usernamePath || '').includes('href') ? ( - {username} - ) : ( - username || '' - ), - }, - { - title: 'Ended at', - value: , - }, - ], - [ - { - title: 'Execution type', - value: __(isDelayed(props) ? 'Delayed' : 'Immediate'), - }, - { - title: 'Start before', - value: startBefore ? ( - - ) : ( - '-' - ), - }, - ], - ]; - - const progVariant = progressVariantForResult(result); + const [isExpanded, setIsExpanded] = useState(false); + const baseId = id || 'task-info'; + const contentId = `${baseId}-more-details-content`; + const toggleId = `${baseId}-more-details-toggle`; + + const usernameHref = parseUsernameLinkHref(usernamePath); + const triggeredBy = usernameHref ? ( + {username} + ) : ( + username || '' + ); + + const progVariant = progressVariant(result); return ( - - {details.map((items, key) => ( - - - - {__(items[0].title)}: - - - - {items[0].value} - - - - {__(items[1].title)}: - - - - {items[1].value} - - - ))} - + + + {taskResultIconEl(state, result)} + {formatResultLabel(result)} + + } + /> + + } + /> + + + + - {__('State')}: - {state} + + {isExpanded ? __('Show less details') : __('Show more details')} + + - + + + + + + + + + {startBefore && ( + + ({__('before')} + + ) + + )} + + } + /> + + } + /> + + {help && ( + } + /> + )} + + - - {help && ( - - {__('Troubleshooting')} -

- - )} - {output && output.length > 0 && ( + + {state !== 'stopped' && ( - {__('Output:')} -

{output}
+ + + + + + {`${__('State')}: `} + {state} + + + + + {sprintf(__('%s%% %s'), progress, __('Complete'))} + + + + + + + +
)}
@@ -173,6 +282,9 @@ const TaskInfo = props => { TaskInfo.propTypes = { action: PropTypes.string, endedAt: PropTypes.string, + externalId: PropTypes.string, + id: PropTypes.string, + label: PropTypes.string, result: PropTypes.string, startAt: PropTypes.string, startBefore: PropTypes.string, @@ -182,12 +294,14 @@ TaskInfo.propTypes = { progress: PropTypes.number, username: PropTypes.string, usernamePath: PropTypes.string, - output: PropTypes.oneOfType([PropTypes.string, PropTypes.shape({})]), }; TaskInfo.defaultProps = { action: '', endedAt: '', + externalId: '', + id: '', + label: '', result: 'error', startAt: '', startBefore: '', @@ -197,7 +311,6 @@ TaskInfo.defaultProps = { progress: 0, username: '', usernamePath: '', - output: '', }; export default TaskInfo; diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Task.test.js b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Task.test.js index d21ff7b67..bb996eaf2 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Task.test.js +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Task.test.js @@ -1,5 +1,6 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import Task from '../Task'; @@ -13,12 +14,20 @@ jest.mock('foremanReact/components/common/dates/RelativeDateTime', () => { }); describe('Task', () => { - it('renders task overview metadata via TaskInfo', () => { + it('renders task overview metadata via TaskInfo', async () => { render(); - expect(screen.getByText(/name:/i)).toBeInTheDocument(); + expect(screen.getByText('Result')).toBeInTheDocument(); + expect(screen.getByText('State')).toBeInTheDocument(); + expect(screen.getByText('test')).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole('button', { name: /show more details/i }) + ); + expect(screen.getByText('Refresh hosts')).toBeInTheDocument(); - expect(screen.getByText(/result:/i)).toBeInTheDocument(); + expect(screen.getByText('Label')).toBeInTheDocument(); + expect(screen.getByText('Execution type')).toBeInTheDocument(); expect( screen.queryByRole('button', { name: /cancel/i }) ).not.toBeInTheDocument(); diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskHelper.test.js b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskHelper.test.js index db3e12ca2..da0afe47b 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskHelper.test.js +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskHelper.test.js @@ -1,4 +1,9 @@ -import { durationInWords } from '../TaskHelper'; +import { + durationInWords, + formatTaskDuration, + isDelayed, + parseUsernameLinkHref, +} from '../TaskHelper'; describe('durationInWords', () => { it('should work for seconds', () => { @@ -20,3 +25,115 @@ describe('durationInWords', () => { }); }); }); + +describe('isDelayed', () => { + it('returns false when startAt is missing', () => { + expect(isDelayed({ startAt: null, startedAt: '2020-01-02' })).toBe(false); + expect(isDelayed({ startAt: '', startedAt: '2020-01-02' })).toBe(false); + expect(isDelayed({ startedAt: '2020-01-02' })).toBe(false); + }); + + it('returns false when startedAt is missing', () => { + expect(isDelayed({ startAt: '2020-01-01', startedAt: null })).toBe(false); + expect(isDelayed({ startAt: '2020-01-01', startedAt: '' })).toBe(false); + expect(isDelayed({ startAt: '2020-01-01' })).toBe(false); + }); + + it('returns false when either date is invalid', () => { + expect( + isDelayed({ startAt: 'not-a-date', startedAt: '2020-01-02' }) + ).toBe(false); + expect( + isDelayed({ startAt: '2020-01-01', startedAt: 'not-a-date' }) + ).toBe(false); + }); + + it('returns false when startAt and startedAt are the same instant', () => { + expect( + isDelayed({ startAt: '2020-01-01T10:00:00', startedAt: '2020-01-01T10:00:00' }) + ).toBe(false); + }); + + it('ignores millisecond differences when comparing startAt and startedAt', () => { + expect( + isDelayed({ + startAt: '2020-01-01T10:00:00.123', + startedAt: '2020-01-01T10:00:00.456', + }) + ).toBe(false); + }); + + it('returns true when startAt and startedAt differ', () => { + expect( + isDelayed({ startAt: '2020-01-01', startedAt: '2020-01-02' }) + ).toBe(true); + }); +}); + +describe('parseUsernameLinkHref', () => { + it('returns null for empty or non-string input', () => { + expect(parseUsernameLinkHref(null)).toBeNull(); + expect(parseUsernameLinkHref(undefined)).toBeNull(); + expect(parseUsernameLinkHref('')).toBeNull(); + expect(parseUsernameLinkHref(123)).toBeNull(); + }); + + it('extracts href from double-quoted attribute values', () => { + expect( + parseUsernameLinkHref('Bob') + ).toBe('/users/bob'); + }); + + it('extracts href from single-quoted attribute values', () => { + expect( + parseUsernameLinkHref("Bob") + ).toBe('/users/bob'); + }); + + it('returns null when no href attribute is present', () => { + expect(parseUsernameLinkHref('Bob')).toBeNull(); + }); +}); + +describe('formatTaskDuration', () => { + it('returns N/A when the task is not stopped', () => { + expect( + formatTaskDuration('2020-01-01T10:00:00', '2020-01-01T10:00:01', 'running') + ).toBe('N/A'); + }); + + it('returns N/A when start or end time is missing', () => { + expect(formatTaskDuration('', '2020-01-01T10:00:01', 'stopped')).toBe( + 'N/A' + ); + expect(formatTaskDuration('2020-01-01T10:00:00', '', 'stopped')).toBe( + 'N/A' + ); + }); + + it('returns N/A when dates are invalid or end precedes start', () => { + expect( + formatTaskDuration('not-a-date', '2020-01-01T10:00:01', 'stopped') + ).toBe('N/A'); + expect( + formatTaskDuration('2020-01-01T10:00:00', 'not-a-date', 'stopped') + ).toBe('N/A'); + expect( + formatTaskDuration( + '2020-01-01T10:00:01', + '2020-01-01T10:00:00', + 'stopped' + ) + ).toBe('N/A'); + }); + + it('returns a humanized duration for stopped tasks with valid timestamps', () => { + expect( + formatTaskDuration( + '1/1/1 10:00:00', + '1/1/1 10:00:01', + 'stopped' + ) + ).toBe('1 second'); + }); +}); diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskInfo.test.js b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskInfo.test.js index 821d71621..b146f6cbb 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskInfo.test.js +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskInfo.test.js @@ -1,5 +1,6 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; jest.mock('foremanReact/components/common/dates/RelativeDateTime', () => { @@ -10,41 +11,209 @@ jest.mock('foremanReact/components/common/dates/RelativeDateTime', () => { }); import TaskInfo from '../TaskInfo'; +import { durationInWords } from '../TaskHelper'; describe('TaskInfo', () => { - it('renders default metadata labels when given minimal props', () => { - render(); - expect(screen.getByText(/name:/i)).toBeInTheDocument(); - expect(screen.getByText(/start at:/i)).toBeInTheDocument(); - expect(screen.getByText(/result:/i)).toBeInTheDocument(); + afterEach(() => { + jest.clearAllMocks(); }); - it('renders task fields and troubleshooting when full props are provided', () => { + it('renders overview metadata headings for minimal props', () => { + render(); + + expect(screen.getByText('Result')).toBeInTheDocument(); + expect(screen.getByText('State')).toBeInTheDocument(); + expect(screen.getByText('Started at')).toBeInTheDocument(); + expect(screen.getByText('Duration')).toBeInTheDocument(); + expect(screen.getByText('Triggered by')).toBeInTheDocument(); + expect(screen.getByText('Id')).toBeInTheDocument(); + + expect( + screen.getByRole('button', { name: /show more details/i }) + ).toBeInTheDocument(); + expect(screen.getByText('tid')).toBeInTheDocument(); + expect(screen.getByText(/^Error$/)).toBeInTheDocument(); + }); + + it('shows progress only while task state is not stopped', () => { + const props = { + id: 'r1', + state: 'running', + result: 'pending', + progress: 42, + startedAt: '', + usernamePath: '', + }; + + const { rerender } = render(); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '42' + ); + + rerender(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + it('expands to show label, execution type, start at, ended at, and external id', async () => { render( ); - expect(screen.getByText('Monitor Event Queue')).toBeInTheDocument(); - expect(screen.getByText(/troubleshooting/i)).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole('button', { name: /show more details/i }) + ); + + expect(screen.getByText('Execution type')).toBeInTheDocument(); + expect(screen.getByText('Label')).toBeInTheDocument(); + expect(screen.getByText('Start at')).toBeInTheDocument(); + expect(screen.getByText('Ended at')).toBeInTheDocument(); + expect(screen.getByText('External Id')).toBeInTheDocument(); + expect( + screen.getByText('Actions::Katello::EventQueue::Monitor') + ).toBeInTheDocument(); + expect(screen.getByText('ext-42')).toBeInTheDocument(); + expect(screen.getByText(/^Error$/)).toBeInTheDocument(); + expect(screen.getAllByText(/^paused$/).length).toBeGreaterThanOrEqual(1); + }); + + it('reports delayed execution type when startAt and startedAt differ', async () => { + render( + + ); + + await userEvent.click( + screen.getByRole('button', { name: /show more details/i }) + ); + + expect(screen.getByText('Delayed')).toBeInTheDocument(); + }); + + it('reports immediate execution type when startAt is missing', async () => { + render(); + + await userEvent.click( + screen.getByRole('button', { name: /show more details/i }) + ); + + expect(screen.getByText('Immediate')).toBeInTheDocument(); + }); + + it('renders triggered-by link when usernamePath embeds an href', () => { + render( + + ); + + const link = screen.getByRole('link', { name: 'bob' }); + expect(link).toHaveAttribute('href', '/users/bob'); + }); + + it('renders triggered-by link when usernamePath uses single-quoted href', () => { + render( + + ); + + const link = screen.getByRole('link', { name: 'bob' }); + expect(link).toHaveAttribute('href', '/users/bob'); + }); + + it('formats stop duration when started and ended are valid', () => { + const startedAt = '2020-01-01T10:00:00.000Z'; + const endedAt = '2020-01-01T10:00:45.000Z'; + + render(); + expect( - screen.getByText(/a paused task represents a process/i) + screen.getByText(durationInWords(startedAt, endedAt).text) ).toBeInTheDocument(); - expect(screen.getByText(/^paused$/)).toBeInTheDocument(); - expect(screen.getByText(/^error$/)).toBeInTheDocument(); + }); + + it('shows start before deadline when startBefore is set', async () => { + render( + + ); + + await userEvent.click( + screen.getByRole('button', { name: /show more details/i }) + ); + + expect(screen.getByText('2020-01-01')).toBeInTheDocument(); + expect(screen.getByText(/before/i)).toBeInTheDocument(); + expect(screen.getByText('2020-01-05')).toBeInTheDocument(); + }); + + it('does not show start before text when startBefore is missing', async () => { + render(); + + await userEvent.click( + screen.getByRole('button', { name: /show more details/i }) + ); + + expect(screen.getByText('2020-01-01')).toBeInTheDocument(); + expect(screen.queryByText(/before/i)).not.toBeInTheDocument(); + }); + + it('shows troubleshooting help when expanded and help is provided', async () => { + render( + + ); + + await userEvent.click( + screen.getByRole('button', { name: /show more details/i }) + ); + + expect(screen.getByText('Troubleshooting')).toBeInTheDocument(); + expect(screen.getByText(/Investigate the error/i)).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'troubleshooting documentation' }) + ).toHaveAttribute('href', '/docs'); + }); + + it('does not show troubleshooting help when help is empty', async () => { + render(); + + await userEvent.click( + screen.getByRole('button', { name: /show more details/i }) + ); + + expect(screen.queryByText('Troubleshooting')).not.toBeInTheDocument(); }); }); diff --git a/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.test.js b/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.test.js index b046ad0d1..4b8df535e 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.test.js +++ b/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.test.js @@ -221,10 +221,14 @@ describe('TaskDetails', () => { expect(screen.getByRole('tab', { name: /raw/i })).toBeDisabled(); }); - it('renders troubleshooting help in the overview section', () => { + it('renders troubleshooting help in overview after expanding details', async () => { renderTaskDetails({ ...fixtureWithOverviewMessages }); - expect(screen.getByText(/troubleshooting/i)).toBeInTheDocument(); + await userEvent.click( + screen.getByRole('button', { name: /show more details/i }) + ); + + expect(screen.getByText('Troubleshooting')).toBeInTheDocument(); expect(screen.getByText('See logs')).toBeInTheDocument(); });