diff --git a/app/controllers/foreman_tasks/tasks_controller.rb b/app/controllers/foreman_tasks/tasks_controller.rb index 678495a8b..59c001304 100644 --- a/app/controllers/foreman_tasks/tasks_controller.rb +++ b/app/controllers/foreman_tasks/tasks_controller.rb @@ -7,8 +7,9 @@ class TasksController < ::ApplicationController before_action :find_dynflow_task, only: [:unlock, :force_unlock, :cancel, :abort, :cancel_step, :resume] def show - @task = resource_base.find(params[:id]) - render :layout => !request.xhr? + @task = resource_scope.find(params[:id]) + + render('react/index', :layout => 'layouts/react_application') end def index diff --git a/app/views/foreman_tasks/tasks/show.html.erb b/app/views/foreman_tasks/tasks/show.html.erb deleted file mode 100644 index ad454da05..000000000 --- a/app/views/foreman_tasks/tasks/show.html.erb +++ /dev/null @@ -1,18 +0,0 @@ -<% stylesheet 'foreman_tasks/foreman_tasks' %> -<% content_for(:javascripts) do %> - <%= webpacked_plugins_js_for :'foreman-tasks' %> -<% end %> -<% content_for(:stylesheets) do %> - <%= webpacked_plugins_css_for :'foreman-tasks' %> -<% end %> - -<% title _("Details of %s task") % @task.to_s %> - -<%= breadcrumbs( - items: breadcrumb_items, - name_field: 'action', - resource_url: foreman_tasks_api_tasks_path, - switcher_item_url: foreman_tasks_task_path(:id => ':id') -) %> - -<%= react_component('TaskDetails') %> diff --git a/config/routes.rb b/config/routes.rb index 212a05e4e..49979c41c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,6 +35,7 @@ resources :tasks, :only => [:index], constraints: ->(req) { req.format == :csv } match '/tasks', to: '/react#index', via: :get + get '/tasks/:id', :to => 'tasks#show' match '/tasks/:id/sub_tasks', to: '/react#index', via: :get match '/ex_tasks/:id', to: '/react#index', via: :get diff --git a/test/controllers/tasks_controller_test.rb b/test/controllers/tasks_controller_test.rb index dd3c684ba..1e80e957e 100644 --- a/test/controllers/tasks_controller_test.rb +++ b/test/controllers/tasks_controller_test.rb @@ -83,12 +83,20 @@ def in_taxonomy_scope(organization, location = nil) end describe 'show' do - it 'does not allow user without permissions to see task details' do + it 'does not allow user without permissions to see task details page' do setup_user('view', 'foreman_tasks', 'owner.id = current_user') get :show, params: { id: FactoryBot.create(:some_task).id }, session: set_session_user(User.current) assert_response :not_found end + + it 'serves react shell when user may view the task' do + task = FactoryBot.create(:some_task) + get :show, params: { id: task.id }, session: set_session_user + + assert_response :success + assert_includes @response.body, '/webpack/' + end end describe 'index' do diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/Errors.js b/webpack/ForemanTasks/Components/TaskDetails/Components/Errors.js index 7cd5ac2ef..7469e44f2 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/Components/Errors.js +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/Errors.js @@ -1,69 +1,212 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { Alert } from '@patternfly/react-core'; -import { translate as __ } from 'foremanReact/common/I18n'; +import { + Alert, + AlertVariant, + CodeBlock, + CodeBlockCode, + EmptyState, + EmptyStateBody, + EmptyStateHeader, + Icon, + EmptyStateVariant, + Flex, + FlexItem, + Grid, + GridItem, + Split, + SplitItem, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { CheckCircleIcon } from '@patternfly/react-icons'; +import { translate as __, sprintf } from 'foremanReact/common/I18n'; -const Errors = ({ ...props }) => { - const { failedSteps, executionPlan } = props; - if (!executionPlan) +const TRANSPARENT_CODE_BLOCK_STYLE = { + '--pf-v5-c-code-block--BackgroundColor': 'transparent', + backgroundColor: 'transparent', +}; + +const ErrorDetailSection = ({ label, children }) => ( + + + + {label} + + + + {children} + + + + +); + +ErrorDetailSection.propTypes = { + label: PropTypes.node.isRequired, + children: PropTypes.node.isRequired, +}; + +const ErrorDetailsPane = ({ step }) => { + if (!step) { + return null; + } + + return ( + + {step.input} + + {step.output} + + {step.error && ( + <> + + {step.error.exception_class}: {step.error.message} + + + {(step.error.backtrace || []).join('\n')} + + + )} + + ); +}; + +ErrorDetailsPane.propTypes = { + step: PropTypes.shape({ + input: PropTypes.node, + output: PropTypes.node, + error: PropTypes.shape({ + exception_class: PropTypes.string, + message: PropTypes.string, + backtrace: PropTypes.array, + }), + }), +}; + +ErrorDetailsPane.defaultProps = { + step: null, +}; + +const Errors = ({ executionPlan, failedSteps }) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + useEffect(() => { + setSelectedIndex(idx => { + if (idx >= failedSteps.length) { + return Math.max(0, failedSteps.length - 1); + } + + return idx; + }); + }, [failedSteps.length]); + + if (!executionPlan) { return ( - + {__('Execution plan data not available ')} ); - if (!failedSteps.length) + } + + if (!failedSteps.length) { return ( - + + + + + + + + + } + /> + + {__('The task finished with no errors or warnings.')} + + + + + + ); + } + + const selectedStep = failedSteps[selectedIndex]; + return ( -
- {failedSteps.map((step, i) => ( - + + - {__('Action')}: - -
{step.action_class}
-
- {__('Input')}: - -
{step.input}
-
- {__('Output')}: - -
{step.output}
-
- {step.error && ( - - {__('Exception')}: - -
-                  {step.error.exception_class}: {step.error.message}
-                
-
- {__('Backtrace')}: - -
{(step.error.backtrace || []).join('\n')}
-
-
- )} -
- ))} -
+ + {failedSteps.map((step, i) => { + const summary = + step.error?.message || step.action_class || __('Unknown error'); + const isStoppedStep = ['skipped', 'skipping'].includes( + String(step.state ?? '') + ); + const variant = isStoppedStep + ? AlertVariant.warning + : AlertVariant.danger; + const titleKey = isStoppedStep + ? __('Stopped task: %s') + : __('Failed task: %s'); + const selected = i === selectedIndex; + + return ( + + setSelectedIndex(i)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setSelectedIndex(i); + } + }} + style={{ + cursor: 'pointer', + }} + > + + + + ); + })} + + + + + + + ); }; diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/RunningSteps.js b/webpack/ForemanTasks/Components/TaskDetails/Components/RunningSteps.js index 0db0a862f..2f68a9dc3 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/Components/RunningSteps.js +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/RunningSteps.js @@ -1,68 +1,174 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Alert, AlertVariant, Button } from '@patternfly/react-core'; +import { + Alert, + AlertVariant, + Button, + EmptyState, + EmptyStateBody, + EmptyStateHeader, + EmptyStateIcon, + EmptyStateVariant, + Flex, + FlexItem, + Grid, + GridItem, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { HourglassStartIcon } from '@patternfly/react-icons'; import { translate as __, sprintf } from 'foremanReact/common/I18n'; +const RunningStepDetailBlock = ({ label, children }) => ( + + + + {label} + + {children} + + +); + +RunningStepDetailBlock.propTypes = { + label: PropTypes.node.isRequired, + children: PropTypes.node.isRequired, +}; + const RunningSteps = ({ + executionPlan, + result, runningSteps, id, cancelStep, taskReload, taskReloadStart, }) => { - if (!runningSteps.length) return {__('No running steps')}; - return ( -
- {runningSteps.map((step, i) => ( + const planState = executionPlan?.state; + const resultIsPending = String(result) === 'pending'; + + if (!runningSteps.length) { + if (planState === 'running' && resultIsPending) { + return ( - {step.cancellable && ( -

- -

- )} - -

- {__('Action')}: - -

-
{step.action_class}
-

- {__('State')}: - {step.state} -

- {__('Input')}: - -
{step.input}
-
- {__('Output')}: - -
{step.output}
-
+ {__('The task is still being processed. Please wait.')}
+ ); + } + + if (planState === 'planned' && resultIsPending) { + return ( + + + + + + } + /> + + {__('The task has not started yet.')} + + + + + + + ); + } + + return {__('No running steps')}; + } + + return ( + + {runningSteps.map((step, i) => ( + + + + {step.cancellable && ( + + + + + + + + )} + + + + {`${__('Action')}:`} + + {step.action_class} + + + + + + {`${__('State')}:`} + + {step.state} + + + +
{step.input}
+
+ +
{step.output}
+
+
+
+
))} -
+ ); }; RunningSteps.propTypes = { + executionPlan: PropTypes.shape({ state: PropTypes.string }), + result: PropTypes.string, runningSteps: PropTypes.array, id: PropTypes.string.isRequired, cancelStep: PropTypes.func.isRequired, @@ -72,6 +178,8 @@ RunningSteps.propTypes = { RunningSteps.defaultProps = { runningSteps: [], + executionPlan: {}, + result: undefined, }; export default RunningSteps; diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/Task.js b/webpack/ForemanTasks/Components/TaskDetails/Components/Task.js index 75b00d851..453f22843 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/Components/Task.js +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/Task.js @@ -1,5 +1,19 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; +import { + Flex, + FlexItem, + Split, + SplitItem, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { + CheckCircleIcon, + ExclamationTriangleIcon, + InProgressIcon, +} from '@patternfly/react-icons'; import TaskInfo from './TaskInfo'; import { ForceUnlockConfirmationModal, @@ -7,6 +21,64 @@ import { } from '../../common/ClickConfirmation'; import { TaskButtons } from './TaskButtons'; +const TitleIcon = ({ state, result }) => { + if (state === 'running') { + return ; + } + + switch (result) { + case 'error': + return ( + + ); + case 'warning': + return ( + + ); + case 'success': + return ; + default: + return null; + } +}; + +TitleIcon.propTypes = { + state: PropTypes.string, + result: PropTypes.string, +}; + +TitleIcon.defaultProps = { + state: '', + result: '', +}; + +const TitleComponent = ({ action, state, result }) => ( + + + + {action} + + + + + + +); + +TitleComponent.propTypes = { + action: PropTypes.string.isRequired, + state: PropTypes.string, + result: PropTypes.string, +}; + +TitleComponent.defaultProps = { + state: '', + result: '', +}; + const Task = props => { const { taskReload, @@ -43,13 +115,30 @@ const Task = props => { isOpen={forceUnlockModalOpen} setModalClosed={() => setForceUnlockModalOpen(false)} /> - - + + + + + + + + + + + + + + + ); }; @@ -60,6 +149,8 @@ Task.propTypes = { forceCancelTaskRequest: PropTypes.func, unlockTaskRequest: PropTypes.func, action: PropTypes.string, + state: PropTypes.string, + result: PropTypes.string, taskReloadStart: PropTypes.func, }; @@ -69,6 +160,8 @@ Task.defaultProps = { forceCancelTaskRequest: () => null, unlockTaskRequest: () => null, action: '', + state: '', + result: '', taskReloadStart: () => null, }; diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/TaskButtons.js b/webpack/ForemanTasks/Components/TaskDetails/Components/TaskButtons.js index b1c425e42..28047c15a 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/Components/TaskButtons.js +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/TaskButtons.js @@ -1,7 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Button, Icon } from '@patternfly/react-core'; -import { SyncAltIcon } from '@patternfly/react-icons'; +import { Button, Flex, FlexItem, Icon } from '@patternfly/react-core'; +import { SimpleDropdown } from '@patternfly/react-templates'; +import { EllipsisVIcon, SyncAltIcon } from '@patternfly/react-icons'; import { translate as __ } from 'foremanReact/common/I18n'; export const TaskButtons = ({ @@ -30,120 +31,126 @@ export const TaskButtons = ({ ? undefined : `dynflow_enable_console ${__('Setting is off')}`; + const overflowItems = [ + { + value: 'reload', + content: ( + <> + + + +   + {taskReload ? __('Stop auto-reloading') : __('Start auto-reloading')} + + ), + onClick: taskProgressToggle, + }, + { + value: 'resume', + content: __('Resume'), + onClick: () => { + if (!taskReload) { + taskReloadStart(id); + } + + resumeTaskRequest(id, action); + }, + isDisabled: !canEdit || !resumable, + tooltip: editActionsTitle, + }, + ...(parentTask + ? [ + { + value: 'parent', + content: __('Parent task'), + onClick: () => { + window.location.assign(`/foreman_tasks/tasks/${parentTask}`); + }, + }, + ] + : []), + ...(hasSubTasks + ? [ + { + value: 'subtasks', + content: __('Sub tasks'), + onClick: () => { + window.location.assign(`/foreman_tasks/tasks/${id}/sub_tasks`); + }, + }, + ] + : []), + { + value: 'unlock', + content: __('Unlock'), + onClick: () => setUnlockModalOpen(true), + isDisabled: !canEdit || state !== 'paused', + tooltip: editActionsTitle, + }, + { + value: 'force-unlock', + content: __('Force Unlock'), + onClick: () => setForceUnlockModalOpen(true), + isDisabled: !canEdit || state === 'stopped', + tooltip: editActionsTitle, + }, + ]; + return ( - <> - - - - - {parentTask && ( + + - )} - {hasSubTasks && ( + + - )} - - - + + + + ); }; diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/TaskInfo.js b/webpack/ForemanTasks/Components/TaskDetails/Components/TaskInfo.js index a206c1043..0f0469e97 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/Components/TaskInfo.js +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/TaskInfo.js @@ -5,17 +5,12 @@ import { GridItem, Progress, ProgressVariant, - Icon, } from '@patternfly/react-core'; -import { - CheckCircleIcon, - ExclamationCircleIcon, - ExclamationTriangleIcon, - QuestionCircleIcon, -} from '@patternfly/react-icons'; import { translate as __ } from 'foremanReact/common/I18n'; import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime'; +import { taskResultIconEl } from '../../common/taskResultIcon'; + const isDelayed = ({ startAt, startedAt }) => { if ( startAt == null || @@ -35,41 +30,6 @@ const isDelayed = ({ startAt, startedAt }) => { return a.getTime() !== b.getTime(); }; -const resultIconEl = (state, result) => { - if (state !== 'stopped') - return ( - - - - ); - switch (result) { - case 'success': - return ( - - - - ); - case 'error': - return ( - - - - ); - case 'warning': - return ( - - - - ); - default: - return ( - - - - ); - } -}; - const progressVariantForResult = result => { switch (result) { case 'error': @@ -94,7 +54,6 @@ const TaskInfo = props => { state, help, output, - errors, progress, username, usernamePath, @@ -117,7 +76,7 @@ const TaskInfo = props => { title: 'Result', value: ( - {resultIconEl(state, result)} {result} + {taskResultIconEl(state, result)} {result} ), }, @@ -213,12 +172,6 @@ const TaskInfo = props => {
{output}
)} - {errors && errors.length > 0 && ( - - {__('Errors:')} -
{errors}
-
- )} ); }; diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Errors.test.js b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Errors.test.js index b760f8ae0..9c41ce5e3 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Errors.test.js +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Errors.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; import Errors from '../Errors'; @@ -20,6 +20,8 @@ const failedStepFixture = { output: '{}\n', }; +const executionPlan = { state: 'paused', cancellable: false }; + describe('Errors', () => { it('renders warning when execution plan is missing', () => { render(); @@ -30,14 +32,18 @@ describe('Errors', () => { it('renders success state when there are no failed steps', () => { render(); - const noErrors = screen.getAllByText(/^no errors$/i); - expect(noErrors.length).toBeGreaterThanOrEqual(1); + expect( + screen.getByRole('heading', { level: 2, name: /no errors found/i }) + ).toBeInTheDocument(); + expect( + screen.getByText(/the task finished with no errors or warnings/i) + ).toBeInTheDocument(); }); it('renders failed step details when failedSteps is non-empty', () => { const { container } = render( ); @@ -47,13 +53,199 @@ describe('Errors', () => { expect(stepAlert).toBeInTheDocument(); expect(stepAlert).toHaveClass('pf-m-inline'); expect( - screen.getByText('Actions::Katello::EventQueue::Monitor') + screen.getByRole('heading', { + level: 4, + name: /failed task: action actions::katello::eventqueue::monitor is already active/i, + }) + ).toBeInTheDocument(); + expect( + screen.getByRole('listbox', { name: /failed task errors/i }) ).toBeInTheDocument(); + expect(screen.getByText('Input')).toBeInTheDocument(); + expect(screen.getByText('Output')).toBeInTheDocument(); + expect(screen.getByText('Exception:')).toBeInTheDocument(); + expect(screen.getByText('Backtrace')).toBeInTheDocument(); expect(screen.getByText(/runtimeerror/i)).toBeInTheDocument(); + expect(screen.getByText(/singleton_lock/i)).toBeInTheDocument(); + }); + + it('switches detail pane when a different error option is clicked', () => { + const firstStep = { + ...failedStepFixture, + action_class: 'Action::First', + input: 'INPUT_FROM_FIRST_STEP', + output: '{}', + }; + const secondStep = { + ...failedStepFixture, + action_class: 'Action::Second', + error: { + exception_class: 'StandardError', + message: 'second step failure', + backtrace: [], + }, + input: 'INPUT_FROM_SECOND_STEP', + output: 'OUTPUT_SECOND', + state: 'error', + }; + + render( + + ); + + expect(screen.getByText('INPUT_FROM_FIRST_STEP')).toBeInTheDocument(); + expect(screen.queryByText('INPUT_FROM_SECOND_STEP')).not.toBeInTheDocument(); + + const options = screen.getAllByRole('option'); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + expect(options[1]).toHaveAttribute('aria-selected', 'false'); + + fireEvent.click(options[1]); + + expect(screen.getByText('INPUT_FROM_SECOND_STEP')).toBeInTheDocument(); + expect(screen.queryByText('INPUT_FROM_FIRST_STEP')).not.toBeInTheDocument(); + expect(options[0]).toHaveAttribute('aria-selected', 'false'); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('OUTPUT_SECOND')).toBeInTheDocument(); + }); + + it('selects an error option with Enter', () => { + const firstStep = { + ...failedStepFixture, + action_class: 'Action::First', + input: 'ONLY_FIRST', + output: '{}', + }; + const secondStep = { + ...failedStepFixture, + action_class: 'Action::Second', + error: { + ...failedStepFixture.error, + message: 'other', + }, + input: 'ONLY_SECOND', + output: '{}', + state: 'error', + }; + + render( + + ); + + const options = screen.getAllByRole('option'); + fireEvent.keyDown(options[1], { key: 'Enter', preventDefault: jest.fn() }); + + expect(screen.getByText('ONLY_SECOND')).toBeInTheDocument(); + expect(screen.queryByText('ONLY_FIRST')).not.toBeInTheDocument(); + }); + + it('selects an error option with Space', () => { + const firstStep = { + ...failedStepFixture, + action_class: 'Action::A', + input: 'INPUT_A_UNIQUE', + output: '{}', + }; + const secondStep = { + ...failedStepFixture, + action_class: 'Action::B', + input: 'INPUT_B_UNIQUE', + output: '{}', + state: 'error', + }; + + render( + + ); + + fireEvent.keyDown(screen.getAllByRole('option')[1], { + key: ' ', + preventDefault: jest.fn(), + }); + + expect(screen.getByText('INPUT_B_UNIQUE')).toBeInTheDocument(); + }); + + it('clamps selection when failedSteps shrinks', () => { + const firstStep = { + ...failedStepFixture, + action_class: 'Action::First', + input: 'AFTER_CLAMP', + output: '{}', + }; + const secondStep = { + ...failedStepFixture, + action_class: 'Action::Second', + input: 'REMOVED', + output: '{}', + state: 'error', + }; + + const { rerender } = render( + + ); + + fireEvent.click(screen.getAllByRole('option')[1]); + expect(screen.getByText('REMOVED')).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.getAllByRole('option')).toHaveLength(1); + expect(screen.getByText('AFTER_CLAMP')).toBeInTheDocument(); + }); + + it('uses stopped task title and warning for skipped steps', () => { + const skippedStep = { + action_class: 'Actions::Example', + state: 'skipped', + input: '{}', + output: '{}', + }; + + const { container } = render( + + ); + expect( - screen.getByText( - /action actions::katello::eventqueue::monitor is already active/i - ) + screen.getByRole('heading', { + level: 4, + name: /stopped task: actions::example/i, + }) ).toBeInTheDocument(); + + const alert = container.querySelector('[data-ouia-component-id="task-error-0"]'); + expect(alert).toHaveClass('pf-m-warning'); + }); + + it('omits exception and backtrace when step has no error object', () => { + const stepWithoutError = { + action_class: 'Actions::NoError', + state: 'error', + input: 'plain-input', + output: 'plain-output', + }; + + render( + + ); + + expect(screen.getByText('plain-input')).toBeInTheDocument(); + expect(screen.getByText('plain-output')).toBeInTheDocument(); + expect(screen.queryByText('Exception:')).not.toBeInTheDocument(); + expect(screen.queryByText('Backtrace')).not.toBeInTheDocument(); }); }); diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/RunningSteps.test.js b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/RunningSteps.test.js index 30e2ee97d..ff4b40747 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/RunningSteps.test.js +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/RunningSteps.test.js @@ -27,6 +27,45 @@ describe('RunningSteps', () => { expect(screen.getByText(/no running steps/i)).toBeInTheDocument(); }); + it('shows suspended warning when plan is running, result pending, no steps', () => { + render( + + ); + + expect( + screen.getByRole('heading', { + level: 4, + name: /temporarily suspended step/i, + }) + ).toBeInTheDocument(); + expect( + screen.getByText(/the task is still being processed/i) + ).toBeInTheDocument(); + }); + + it('shows planned empty state when plan is planned, result pending, no steps', () => { + render( + + ); + + expect( + screen.getByRole('heading', { level: 2, name: /planned task/i }) + ).toBeInTheDocument(); + expect( + screen.getByText(/the task has not started yet/i) + ).toBeInTheDocument(); + }); + it('renders running step fields and Cancel when step is cancellable', () => { const cancelStep = jest.fn(); render( diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Task.test.js b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Task.test.js index 4ef7727d2..8805d282e 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Task.test.js +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Task.test.js @@ -1,46 +1,113 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { STATUS } from 'foremanReact/constants'; import Task from '../Task'; +jest.mock('foremanReact/components/common/dates/RelativeDateTime', () => { + const RelativeDateTime = ({ date, defaultValue }) => ( + {date || defaultValue} + ); + return RelativeDateTime; +}); + +const baseTaskProps = { + id: 'test', + taskReloadStart: jest.fn(), + taskProgressToggle: jest.fn(), + cancelTaskRequest: jest.fn(), + resumeTaskRequest: jest.fn(), +}; + +const openTaskActionsMenu = async () => { + fireEvent.click(screen.getByRole('button', { name: /^task actions$/i })); + + await waitFor(() => { + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); +}; + describe('Task', () => { - it('renders task controls from TaskButtons with minimal props', () => { + it('renders action heading and exposes cancel, dynflow link, and task actions toggle', () => { render( ); + + expect( + screen.getByRole('heading', { level: 4, name: 'Refresh hosts' }) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /dynflow console/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /^task actions$/i }) + ).toBeInTheDocument(); + }); + + it('puts reload controls in the overflow menu instead of standalone buttons', async () => { + render(); expect( - screen.getByRole('button', { name: /start auto-reloading/i }) + screen.queryByRole('button', { name: /start auto-reloading/i }) + ).not.toBeInTheDocument(); + + await openTaskActionsMenu(); + expect( + screen.getByRole('menuitem', { name: /start auto-reloading/i }) ).toBeInTheDocument(); }); - it('renders parent task and sub tasks links when provided', () => { + it('navigates parent and subtask targets from overflow items', async () => { + const assign = jest.fn(); + delete window.location; + window.location = { ...window.location, assign }; + render( ); - expect(screen.getByRole('link', { name: /parent task/i })).toHaveAttribute( - 'href', - '/foreman_tasks/tasks/parent-id' - ); - expect(screen.getByRole('link', { name: /sub tasks/i })).toHaveAttribute( - 'href', - '/foreman_tasks/tasks/test/sub_tasks' + + await openTaskActionsMenu(); + fireEvent.click(screen.getByRole('menuitem', { name: /parent task/i })); + expect(assign).toHaveBeenCalledWith('/foreman_tasks/tasks/parent-id'); + + fireEvent.click(screen.getByRole('button', { name: /^task actions$/i })); + + await waitFor(() => { + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('menuitem', { name: /sub tasks/i })); + expect(assign).toHaveBeenCalledWith('/foreman_tasks/tasks/test/sub_tasks'); + }); + + it('shows an icon next to the title when task state is running', () => { + render( + ); + + const heading = screen.getByRole('heading', { level: 4 }); + expect( + heading.parentElement?.nextElementSibling?.querySelector('svg') + ).toBeTruthy(); }); }); diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskButtons.test.js b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskButtons.test.js index ad3acd5d3..cafd564d0 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskButtons.test.js +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskButtons.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { STATUS } from 'foremanReact/constants'; import { TaskButtons } from '../TaskButtons'; @@ -17,29 +17,39 @@ const defaultProps = { setForceUnlockModalOpen, }; +const openTaskActionsMenu = async () => { + fireEvent.click(screen.getByRole('button', { name: /^task actions$/i })); + + await waitFor(() => { + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); +}; + describe('TaskButtons', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('rendering', () => { - it('renders reload button with correct text when taskReload is false', () => { + it('shows start auto-reloading in overflow when taskReload is false', async () => { render(); + await openTaskActionsMenu(); expect( - screen.getByRole('button', { name: /start auto-reloading/i }) + screen.getByRole('menuitem', { name: /start auto-reloading/i }) ).toBeInTheDocument(); expect( - screen.queryByRole('button', { name: /stop auto-reloading/i }) + screen.queryByRole('menuitem', { name: /stop auto-reloading/i }) ).not.toBeInTheDocument(); }); - it('renders reload button with correct text when taskReload is true', () => { + it('shows stop auto-reloading in overflow when taskReload is true', async () => { render(); + await openTaskActionsMenu(); expect( - screen.getByRole('button', { name: /stop auto-reloading/i }) + screen.getByRole('menuitem', { name: /stop auto-reloading/i }) ).toBeInTheDocument(); expect( - screen.queryByRole('button', { name: /start auto-reloading/i }) + screen.queryByRole('menuitem', { name: /start auto-reloading/i }) ).not.toBeInTheDocument(); }); @@ -55,15 +65,21 @@ describe('TaskButtons', () => { expect(dynflowLink).toHaveAttribute('target', '_blank'); }); - it('disables dynflow console link when dynflowEnableConsole is false', () => { - render(); + it('marks dynflow console link disabled when dynflowEnableConsole is false', () => { + render( + + ); const dynflowLink = screen.getByRole('link', { name: /dynflow console/i, }); - expect(dynflowLink).not.toBeDisabled(); + expect(dynflowLink).toHaveAttribute('aria-disabled', 'true'); }); - it('enables dynflow console link when dynflowEnableConsole is true', () => { + it('does not disable dynflow console link when dynflowEnableConsole is true', () => { render( { const dynflowLink = screen.getByRole('link', { name: /dynflow console/i, }); - expect(dynflowLink).not.toBeDisabled(); + expect(dynflowLink.getAttribute('aria-disabled')).not.toBe('true'); }); - it('disables resume and cancel buttons when canEdit is false', () => { + it('disables resume overflow item and cancel button when canEdit is false', async () => { render(); - expect(screen.getByRole('button', { name: /resume/i })).toBeDisabled(); expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled(); + await openTaskActionsMenu(); + expect(screen.getByRole('menuitem', { name: /^resume$/i })).toBeDisabled(); }); - it('disables resume button when resumable is false', () => { + it('disables resume overflow item when resumable is false', async () => { render(); - expect(screen.getByRole('button', { name: /resume/i })).toBeDisabled(); + await openTaskActionsMenu(); + expect(screen.getByRole('menuitem', { name: /^resume$/i })).toBeDisabled(); }); it('disables cancel button when cancellable is false', () => { @@ -93,66 +111,63 @@ describe('TaskButtons', () => { expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled(); }); - it('disables unlock button when state is not paused', () => { + it('disables unlock overflow item when state is not paused', async () => { render(); - expect(screen.getByRole('button', { name: /^unlock$/i })).toBeDisabled(); + await openTaskActionsMenu(); + expect(screen.getByRole('menuitem', { name: /^unlock$/i })).toBeDisabled(); }); - it('enables unlock button when state is paused and canEdit is true', () => { + it('enables unlock overflow item when state is paused and canEdit is true', async () => { render(); - expect( - screen.getByRole('button', { name: /^unlock$/i }) - ).not.toBeDisabled(); + await openTaskActionsMenu(); + expect(screen.getByRole('menuitem', { name: /^unlock$/i })).not.toBeDisabled(); }); - it('disables force unlock button when state is stopped', () => { + it('disables force unlock overflow item when state is stopped', async () => { render(); - expect( - screen.getByRole('button', { name: /force unlock/i }) - ).toBeDisabled(); + await openTaskActionsMenu(); + expect(screen.getByRole('menuitem', { name: /force unlock/i })).toBeDisabled(); }); - it('enables force unlock button when state is not stopped and canEdit is true', () => { + it('enables force unlock overflow item when state is not stopped and canEdit is true', async () => { render(); - expect( - screen.getByRole('button', { name: /force unlock/i }) - ).not.toBeDisabled(); + await openTaskActionsMenu(); + expect(screen.getByRole('menuitem', { name: /force unlock/i })).not.toBeDisabled(); }); - it('renders parent task button when parentTask is provided', () => { + it('includes parent task overflow item when parentTask is provided', async () => { render(); - const parentButton = screen.getByRole('link', { name: /parent task/i }); - expect(parentButton).toBeInTheDocument(); - expect(parentButton).toHaveAttribute( - 'href', - '/foreman_tasks/tasks/parent-123' - ); + await openTaskActionsMenu(); + expect( + screen.getByRole('menuitem', { name: /parent task/i }) + ).toBeInTheDocument(); }); - it('does not render parent task button when parentTask is not provided', () => { + it('does not include parent overflow item when parentTask is not provided', async () => { render(); + await openTaskActionsMenu(); expect( - screen.queryByRole('link', { name: /parent task/i }) + screen.queryByRole('menuitem', { name: /parent task/i }) ).not.toBeInTheDocument(); }); - it('renders sub tasks button when hasSubTasks is true', () => { + it('includes sub tasks overflow item when hasSubTasks is true', async () => { render(); - const subTasksButton = screen.getByRole('link', { name: /sub tasks/i }); - expect(subTasksButton).toBeInTheDocument(); - expect(subTasksButton).toHaveAttribute( - 'href', - '/foreman_tasks/tasks/task-123/sub_tasks' - ); + await openTaskActionsMenu(); + expect( + screen.getByRole('menuitem', { name: /sub tasks/i }) + ).toBeInTheDocument(); }); - it('does not render sub tasks button when hasSubTasks is false', () => { + it('does not include sub tasks overflow item when hasSubTasks is false', async () => { render(); + await openTaskActionsMenu(); expect( - screen.queryByRole('link', { name: /sub tasks/i }) + screen.queryByRole('menuitem', { name: /sub tasks/i }) ).not.toBeInTheDocument(); }); }); + describe('user interactions', () => { const cancelTaskRequest = jest.fn(); const resumeTaskRequest = jest.fn(); @@ -176,45 +191,66 @@ describe('TaskButtons', () => { state: 'paused', }; - it('calls taskProgressToggle when reload button is clicked', () => { + it('calls taskProgressToggle when overflow reload item is clicked', async () => { render(); - const reloadButton = screen.getByRole('button', { - name: /start auto-reloading/i, - }); - fireEvent.click(reloadButton); + await openTaskActionsMenu(); + fireEvent.click( + screen.getByRole('menuitem', { name: /start auto-reloading/i }) + ); expect(taskProgressToggle).toHaveBeenCalled(); }); - it('calls taskReloadStart and resumeTaskRequest when resume button is clicked', () => { + it('calls taskReloadStart and resumeTaskRequest when resume menu item is clicked', async () => { render(); - const resumeButton = screen.getByRole('button', { name: /resume/i }); - fireEvent.click(resumeButton); + await openTaskActionsMenu(); + fireEvent.click(screen.getByRole('menuitem', { name: /^resume$/i })); expect(taskReloadStart).toHaveBeenCalledWith(id); expect(resumeTaskRequest).toHaveBeenCalledWith(id, action); }); it('calls taskReloadStart and cancelTaskRequest when cancel button is clicked', () => { render(); - const cancelButton = screen.getByRole('button', { name: /cancel/i }); - fireEvent.click(cancelButton); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); expect(taskReloadStart).toHaveBeenCalledWith(id); expect(cancelTaskRequest).toHaveBeenCalledWith(id, action); }); - it('calls setUnlockModalOpen when unlock button is clicked', () => { + it('calls setUnlockModalOpen when unlock menu item is clicked', async () => { render(); - const unlockButton = screen.getByRole('button', { name: /^unlock$/i }); - fireEvent.click(unlockButton); + await openTaskActionsMenu(); + fireEvent.click(screen.getByRole('menuitem', { name: /^unlock$/i })); expect(setUnlockModalOpen).toHaveBeenCalledWith(true); }); - it('calls setForceUnlockModalOpen when force unlock button is clicked', () => { + it('calls setForceUnlockModalOpen when force unlock menu item is clicked', async () => { render(); - const forceUnlockButton = screen.getByRole('button', { - name: /force unlock/i, - }); - fireEvent.click(forceUnlockButton); + await openTaskActionsMenu(); + fireEvent.click(screen.getByRole('menuitem', { name: /force unlock/i })); expect(setForceUnlockModalOpen).toHaveBeenCalledWith(true); }); + + it('assigns window location for parent task overflow item', async () => { + const assign = jest.fn(); + delete window.location; + window.location = { ...window.location, assign }; + + render(); + await openTaskActionsMenu(); + fireEvent.click(screen.getByRole('menuitem', { name: /parent task/i })); + expect(assign).toHaveBeenCalledWith('/foreman_tasks/tasks/parent-xyz'); + }); + + it('assigns window location for sub tasks overflow item', async () => { + const assign = jest.fn(); + delete window.location; + window.location = { ...window.location, assign }; + + render(); + await openTaskActionsMenu(); + fireEvent.click(screen.getByRole('menuitem', { name: /sub tasks/i })); + expect(assign).toHaveBeenCalledWith( + `/foreman_tasks/tasks/${id}/sub_tasks` + ); + }); }); }); diff --git a/webpack/ForemanTasks/Components/TaskDetails/ExecutionDetails.js b/webpack/ForemanTasks/Components/TaskDetails/ExecutionDetails.js new file mode 100644 index 000000000..ca18f3ff8 --- /dev/null +++ b/webpack/ForemanTasks/Components/TaskDetails/ExecutionDetails.js @@ -0,0 +1,62 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import RunningSteps from './Components/RunningSteps'; +import Errors from './Components/Errors'; + +const ExecutionDetails = ({ + state, + runningSteps, + cancelStep, + id, + taskReload, + taskReloadStart, + executionPlan, + failedSteps, + result, +}) => { + const showingRunningSteps = + state === 'running' || state === 'pending' || runningSteps.length > 0; + + return ( +
+ {showingRunningSteps ? ( + + ) : ( + + )} +
+ ); +}; + +ExecutionDetails.propTypes = { + state: PropTypes.string, + result: PropTypes.string, + runningSteps: PropTypes.array, + cancelStep: PropTypes.func.isRequired, + id: PropTypes.string.isRequired, + taskReload: PropTypes.bool.isRequired, + taskReloadStart: PropTypes.func.isRequired, + executionPlan: PropTypes.shape({}), + failedSteps: PropTypes.array, +}; + +ExecutionDetails.defaultProps = { + state: '', + result: undefined, + runningSteps: [], + executionPlan: {}, + failedSteps: [], +}; + +export default ExecutionDetails; diff --git a/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.js b/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.js index 3d0ef51ed..abbe5f8f7 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.js +++ b/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.js @@ -5,16 +5,21 @@ import { translate as __, sprintf } from 'foremanReact/common/I18n'; import { STATUS } from 'foremanReact/constants'; import MessageBox from 'foremanReact/components/common/MessageBox'; import Task from './Components/Task'; -import RunningSteps from './Components/RunningSteps'; -import Errors from './Components/Errors'; import Locks from './Components/Locks'; import Raw from './Components/Raw'; +import ExecutionDetails from './ExecutionDetails'; import Dependencies from './Components/Dependencies'; import { getTaskID } from './TasksDetailsHelper'; import { TaskSkeleton } from './Components/TaskSkeleton'; import './TaskDetails.scss'; +export const TASK_DETAILS_TAB_KEYS = Object.freeze({ + EXECUTION: 'execution', + LOCKS: 'locks', + RAW: 'raw', +}); + const TaskDetails = ({ executionPlan, failedSteps, @@ -31,7 +36,7 @@ const TaskDetails = ({ }) => { const id = getTaskID(); const { taskReload, status, isLoading } = props; - const [activeTabKey, setActiveTabKey] = useState(1); + const [activeTab, setActiveTab] = useState(TASK_DETAILS_TAB_KEYS.EXECUTION); useEffect(() => { taskReloadStart(id); @@ -61,7 +66,7 @@ const TaskDetails = ({ const cancellable = executionPlan ? executionPlan.cancellable : false; const lockRecords = locks.concat(links); - const taskComponentProps = { + const taskProps = { ...props, cancellable, resumable, @@ -72,70 +77,68 @@ const TaskDetails = ({ }; return ( -
+
+
+ {isLoading ? : } +
setActiveTabKey(tabKey)} + className="pf-u-mt-xl" + activeKey={activeTab} + variant="secondary" mountOnEnter + onSelect={(_e, tabKey) => setActiveTab(tabKey)} > {__('Task')}} - aria-label={__('Task')} - ouiaId="task-details-tab-task" + eventKey={TASK_DETAILS_TAB_KEYS.EXECUTION} + ouiaId="task-details-tab-execution" + title={{__('Execution details')}} + aria-label={__('Execution details')} > - {isLoading ? : } + {!isLoading && ( + + )} {__('Running Steps')}} - isDisabled={isLoading} - aria-label={__('Running Steps')} - ouiaId="task-details-tab-running-steps" - > - - - {__('Errors')}} - isDisabled={isLoading} - aria-label={__('Errors')} - ouiaId="task-details-tab-errors" + ouiaId="task-details-tab-dependencies" + eventKey={5} + disabled={isLoading} + title={__('Dependencies')} > - + {__('Locks')}} isDisabled={isLoading} aria-label={__('Locks')} - ouiaId="task-details-tab-locks" > {__('Dependencies')}} - isDisabled={isLoading} - aria-label={__('Dependencies')} - ouiaId="task-details-tab-dependencies" - > - - - {__('Raw')}} isDisabled={isLoading} aria-label={__('Raw')} - ouiaId="task-details-tab-raw" > { + it('renders execution details panel with OUIA id', () => { + render( + + ); + + expect( + document.querySelector('[data-ouia-component-id="execution-details-panel"]') + ).toBeInTheDocument(); + expect(document.getElementById('execution-details-panel')).toBeInTheDocument(); + }); + + it('shows Errors pane when stopped with no running steps', () => { + render( + + ); + + expect(screen.getByRole('heading', { name: /^no errors found$/i })).toBeInTheDocument(); + expect( + screen.getByText(/the task finished with no errors or warnings/i) + ).toBeInTheDocument(); + }); + + it('shows RunningSteps when stopped but runningSteps list is non-empty', () => { + render( + + ); + + expect( + screen.getByRole('heading', { name: 'Warning alert: Running step 1' }) + ).toBeInTheDocument(); + }); + + it('forwards executionPlan and result to RunningSteps when state is pending without steps', () => { + render( + + ); + + expect( + screen.getByRole('heading', { level: 2, name: /planned task/i }) + ).toBeInTheDocument(); + }); + + it('shows temporarily suspended messaging when pending, plan running, result pending', () => { + render( + + ); + + expect( + screen.getByRole('heading', { + level: 4, + name: /temporarily suspended step/i, + }) + ).toBeInTheDocument(); + }); + + it('routes failed steps through Errors pane when stopped', () => { + render( + + ); + + expect( + screen.getByRole('heading', { + level: 4, + name: /failed task/i, + }) + ).toBeInTheDocument(); + }); + + it('renders RunningSteps with cancel when task is running', () => { + const cancelStep = jest.fn(); + + render( + + ); + + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); +}); diff --git a/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.fixtures.js b/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.fixtures.js index 84bb4c07b..2d549b92b 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.fixtures.js +++ b/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.fixtures.js @@ -6,3 +6,103 @@ export const minProps = { taskProgressToggle: jest.fn(), status: 'RESOLVED', }; + +/** Props for snapshots that exercise the Execution details tab alongside the overview. */ +export const taskDetailsWithExecutionTabDefaults = { + ...minProps, + locks: [ + { + exclusive: false, + resource_type: 'User', + resource_id: 4, + link: null, + }, + ], + links: [], + executionPlan: { state: 'stopped', cancellable: false }, + failedSteps: [], + runningSteps: [], + dependsOn: [], + blocks: [], + action: 'Refresh foo', + state: 'stopped', + result: 'success', + progress: 100, + startAt: '', + startedAt: '', + endedAt: '', + startBefore: '', +}; + +const SAMPLE_FAILED_STEP = { + error: { + exception_class: 'RuntimeError', + message: + 'Action Actions::Katello::EventQueue::Monitor is already active', + backtrace: [ + "/home/example/gems/dynflow-1.2.3/lib/dynflow/action/singleton.rb:15:in `rescue in singleton_lock!'", + ], + }, + action_class: 'Actions::Katello::EventQueue::Monitor', + state: 'error', + input: + '{"locale"=>"en",\n "current_request_id"=>nil,\n "current_user_id"=>4}\n', + output: '{}\n', +}; + +const SAMPLE_RUNNING_STEP = { + cancellable: false, + id: 1, + action_class: 'Actions::DynflowExample', + state: 'running', + input: '{}', + output: '{}', +}; + +const SAMPLE_DEPENDS_ON = [ + { + id: '123', + action: 'Actions::FooBar', + humanized: 'Foo Bar Action', + state: 'stopped', + result: 'success', + }, +]; + +export const fixtureFailedExecutionDetail = { + ...taskDetailsWithExecutionTabDefaults, + result: 'error', + state: 'stopped', + failedSteps: [SAMPLE_FAILED_STEP], +}; + +export const fixtureRunningExecutionDetail = { + ...taskDetailsWithExecutionTabDefaults, + state: 'running', + runningSteps: [SAMPLE_RUNNING_STEP], +}; + +export const fixtureStoppedWithTaskMessages = { + ...taskDetailsWithExecutionTabDefaults, + help: 'See logs for more.', + output: { + messages: ['partial output'], + result: 'error', + failedModules: {}, + }, + errors: ['Validation failed'], +}; + +export const fixtureWithDependenciesTables = { + ...taskDetailsWithExecutionTabDefaults, + dependsOn: SAMPLE_DEPENDS_ON, + blocks: [ + { + id: '789', + action: 'Actions::Test', + humanized: 'Test Action', + state: 'paused', + result: 'warning', + }, + ], +}; diff --git a/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.test.js b/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.test.js index 8e8cad961..07e9d43bc 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.test.js +++ b/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.test.js @@ -24,25 +24,23 @@ describe('TaskDetails', () => { ).toBeInTheDocument(); }); - it('shows skeleton while loading on the Task tab', () => { + it('shows skeleton in the overview while loading', () => { const { container } = render(); expect( container.querySelector('.react-loading-skeleton') ).toBeInTheDocument(); }); - it('renders six tabs with expected labels', () => { + it('renders four tabs with expected labels', () => { render(); expect(document.getElementById('task-details-tabs')).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: /^task$/i })).toBeInTheDocument(); expect( - screen.getByRole('tab', { name: /running steps/i }) + screen.getByRole('tab', { name: /execution details/i }) ).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: /errors/i })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: /locks/i })).toBeInTheDocument(); expect( screen.getByRole('tab', { name: /dependencies/i }) ).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /locks/i })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /raw/i })).toBeInTheDocument(); }); }); diff --git a/webpack/ForemanTasks/Components/common/taskResultIcon.js b/webpack/ForemanTasks/Components/common/taskResultIcon.js new file mode 100644 index 000000000..6698160cc --- /dev/null +++ b/webpack/ForemanTasks/Components/common/taskResultIcon.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { Icon } from '@patternfly/react-core'; +import { + CheckCircleIcon, + ExclamationCircleIcon, + ExclamationTriangleIcon, + QuestionCircleIcon, +} from '@patternfly/react-icons'; + +/** + * Icon reflecting task state/result (aligned with TaskInfo / tasks table). + * + * @param {string} state Dynflow task state (e.g. stopped, running). + * @param {string} [result] Result when stopped (success, error, warning, …). + * @returns {React.ReactElement} + */ +export const taskResultIconEl = (state, result) => { + if (state !== 'stopped') { + return ( + + + + ); + } + + switch (result) { + case 'success': + return ( + + + + ); + case 'error': + return ( + + + + ); + case 'warning': + return ( + + + + ); + default: + return ( + + + + ); + } +}; diff --git a/webpack/ForemanTasks/Routes/ShowTaskDetails/TaskDetailsPage.js b/webpack/ForemanTasks/Routes/ShowTaskDetails/TaskDetailsPage.js new file mode 100644 index 000000000..2317b90b5 --- /dev/null +++ b/webpack/ForemanTasks/Routes/ShowTaskDetails/TaskDetailsPage.js @@ -0,0 +1,68 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { Flex, FlexItem, TextContent, Text } from '@patternfly/react-core'; +import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout'; +import { translate as __, sprintf } from 'foremanReact/common/I18n'; +import TaskDetails from '../../Components/TaskDetails'; +import { + selectAction, + selectResult, + selectState, +} from '../../Components/TaskDetails/TaskDetailsSelectors'; +import { taskResultIconEl } from '../../Components/common/taskResultIcon'; + +const TaskDetailsPage = props => { + const { id } = props.match.params; + const action = useSelector(selectAction); + const taskState = useSelector(selectState); + const taskResult = useSelector(selectResult); + const headerText = action + ? sprintf(__('Details of %s task'), action) + : __('Task details'); + + const header = ( + + + + {headerText} + + + + {taskResultIconEl(taskState, taskResult)} + + + ); + + return ( + + + + ); +}; + +TaskDetailsPage.propTypes = { + match: PropTypes.shape({ + params: PropTypes.shape({ + id: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, +}; + +export default TaskDetailsPage; diff --git a/webpack/ForemanTasks/Routes/ShowTaskDetails/__tests__/TaskDetailsPage.test.js b/webpack/ForemanTasks/Routes/ShowTaskDetails/__tests__/TaskDetailsPage.test.js new file mode 100644 index 000000000..5015c257b --- /dev/null +++ b/webpack/ForemanTasks/Routes/ShowTaskDetails/__tests__/TaskDetailsPage.test.js @@ -0,0 +1,186 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import { IntlProvider } from 'react-intl'; +import { Provider } from 'react-redux'; + +import breadcrumbBarReducer from 'foremanReact/components/BreadcrumbBar/BreadcrumbBarReducer'; +import { STATUS } from 'foremanReact/constants'; +import intervalsReducer from 'foremanReact/redux/middlewares/IntervalMiddleware/IntervalReducer'; + +import { FOREMAN_TASK_DETAILS } from '../../../Components/TaskDetails/TaskDetailsConstants'; +import TaskDetailsPage from '../TaskDetailsPage'; + +const TASK_DETAILS_TITLE_ROW_OUIA_ID = 'foreman-tasks-task-details-title-row'; + +const routerPropsBase = { + history: { push: jest.fn(), replace: jest.fn(), go: jest.fn() }, + location: { + pathname: '/foreman_tasks/tasks/task-route-id', + search: '', + hash: '', + state: undefined, + }, +}; + +const matchDefault = { + params: { id: 'task-route-id' }, + path: '/foreman_tasks/tasks/:id', + url: '/foreman_tasks/tasks/task-route-id', + isExact: true, +}; + +const baseTaskPayload = { + action: '', + input: [], + output: {}, + locks: [], + links: [], + depends_on: [], + blocks: [], + failed_steps: [], + running_steps: [], + execution_plan: {}, + state: 'running', + result: '', +}; + +const createStoreForTaskPayload = overrides => ({ + API: { + [FOREMAN_TASK_DETAILS]: { + response: { ...baseTaskPayload, ...overrides }, + status: STATUS.RESOLVED, + payload: {}, + }, + }, +}); + +const rootReducer = combineReducers({ + API: (state = {}, action) => state, + intervals: intervalsReducer, + breadcrumbBar: breadcrumbBarReducer, + foremanTasks: (state = {}, action) => state, +}); + +function renderPage(apiPayloadOverrides = {}, propsOverrides = {}) { + const store = configureStore({ + reducer: rootReducer, + preloadedState: createStoreForTaskPayload(apiPayloadOverrides), + middleware: getDefaultMiddleware => + getDefaultMiddleware({ + immutableCheck: false, + serializableCheck: false, + }), + }); + + window.history.pushState( + {}, + '', + `/foreman_tasks/tasks/${matchDefault.params.id}` + ); + + return render( + + + + + + ); +} + +function breadcrumbTitleHeadings() { + return screen.getAllByRole('heading', { level: 1 }).filter( + heading => heading.getAttribute('data-ouia-component-id') === 'breadcrumb_title' + ); +} + +/** + * Title row (`customHeader` root `Flex`): same OUIA pattern as `Locks.test.js`. + */ +function taskDetailsTitleRegion(container) { + const el = container.querySelector( + `[data-ouia-component-id="${TASK_DETAILS_TITLE_ROW_OUIA_ID}"]` + ); + + expect(el).toBeTruthy(); + + return el; +} + +describe('TaskDetailsPage', () => { + afterEach(() => { + window.history.pushState({}, '', '/'); + }); + + const expectToolbarHeadingText = substring => { + const headings = breadcrumbTitleHeadings(); + + expect(headings.length).toBeGreaterThan(0); + + headings.forEach(heading => { + expect(heading).toHaveTextContent(substring); + }); + }; + + it('shows generic title and breadcrumb from route id when action is unset', () => { + const page = renderPage({}); + expectToolbarHeadingText('Task details'); + + expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent( + 'Tasks' + ); + expect( + within(screen.getByRole('navigation', { name: 'Breadcrumb' })).getByText( + /task-route-id/ + ) + ).toBeInTheDocument(); + + const titleRegion = taskDetailsTitleRegion(page.container); + + expect( + within(titleRegion).getAllByRole('img', { hidden: true }).length + ).toBeGreaterThan(0); + expect(titleRegion.querySelector('[class*="danger"]')).toBeNull(); + }); + + it('uses task action for title and breadcrumb when loaded', () => { + renderPage({ action: 'Refresh hosts' }); + + expectToolbarHeadingText('Details of Refresh hosts task'); + + expect(screen.getByRole('link', { name: /^Tasks$/ })).toHaveAttribute( + 'href', + '/foreman_tasks/tasks' + ); + expect( + within(screen.getByRole('navigation', { name: 'Breadcrumb' })).getByText( + 'Refresh hosts' + ) + ).toBeInTheDocument(); + }); + + it('shows error status styling in the heading when task is stopped with error result', () => { + const page = renderPage({ + action: 'Some action', + state: 'stopped', + result: 'error', + }); + + expectToolbarHeadingText('Details of Some action task'); + + expect( + taskDetailsTitleRegion(page.container).querySelector('[class*="danger"]') + ).toBeTruthy(); + + expect( + within(screen.getByRole('navigation', { name: 'Breadcrumb' })).getByText( + 'Some action' + ) + ).toBeInTheDocument(); + }); +}); diff --git a/webpack/Routes/routes.js b/webpack/Routes/routes.js index 4fe83f36b..dc99e67cc 100644 --- a/webpack/Routes/routes.js +++ b/webpack/Routes/routes.js @@ -1,6 +1,7 @@ import React from 'react'; import TasksTableIndexPage from '../ForemanTasks/Components/TasksTable/TasksIndexPage'; import ShowTask from '../ForemanTasks/Routes/ShowTask/ShowTask'; +import TaskDetailsPage from '../ForemanTasks/Routes/ShowTaskDetails/TaskDetailsPage'; const ForemanTasksRoutes = [ { @@ -8,6 +9,11 @@ const ForemanTasksRoutes = [ exact: true, render: props => , }, + { + path: '/foreman_tasks/tasks/:id', + exact: true, + render: props => , + }, { path: '/foreman_tasks/tasks/:id/sub_tasks', exact: true, diff --git a/webpack/Routes/routes.test.js b/webpack/Routes/routes.test.js index a21aebef7..dbadc7cd4 100644 --- a/webpack/Routes/routes.test.js +++ b/webpack/Routes/routes.test.js @@ -20,6 +20,14 @@ jest.mock( } ); +jest.mock( + '../ForemanTasks/Routes/ShowTaskDetails/TaskDetailsPage', + () => + function TaskDetailsPageStub() { + return
; + } +); + const routerProps = { history: { push: jest.fn(), replace: jest.fn(), go: jest.fn() }, location: { @@ -42,6 +50,7 @@ describe('ForemanTasks routes', () => { ForemanTasksRoutes.map(({ path, exact }) => ({ path, exact })) ).toEqual([ { path: '/foreman_tasks/tasks', exact: true }, + { path: '/foreman_tasks/tasks/:id', exact: true }, { path: '/foreman_tasks/tasks/:id/sub_tasks', exact: true }, { path: '/foreman_tasks/ex_tasks/:id', exact: undefined }, ]); @@ -58,6 +67,15 @@ describe('ForemanTasks routes', () => { url: '/foreman_tasks/tasks', }, }, + { + ...routerProps, + match: { + ...routerProps.match, + params: { id: '99' }, + path: '/foreman_tasks/tasks/:id', + url: '/foreman_tasks/tasks/99', + }, + }, { ...routerProps, match: { @@ -81,7 +99,11 @@ describe('ForemanTasks routes', () => { ForemanTasksRoutes.forEach((route, index) => { const { unmount } = render(route.render(propsByIndex[index])); - if (index === 2) { + if (index === 1) { + expect( + screen.getByTestId('task-details-page-stub') + ).toBeInTheDocument(); + } else if (index === 3) { expect(screen.getByTestId('show-task-stub')).toBeInTheDocument(); } else { expect(