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();
});