foreman-tasks 12.2.4 → 13.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/controllers/foreman_tasks/tasks_controller.rb +0 -5
- data/config/routes.rb +3 -2
- data/lib/foreman_tasks/engine.rb +2 -2
- data/lib/foreman_tasks/version.rb +1 -1
- data/test/controllers/tasks_controller_test.rb +0 -9
- data/test/foreman_tasks_test_helper.rb +2 -2
- data/test/integration/tasks_test.rb +17 -0
- data/test/test_plugin_helper.rb +8 -0
- data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskInfo.js +6 -51
- data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskSkeleton.js +1 -6
- data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Task.test.js +0 -2
- data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskButtons.test.js +0 -2
- data/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.js +29 -15
- data/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.scss +1 -5
- data/webpack/ForemanTasks/Components/TaskDetails/TaskDetailsConstants.js +2 -1
- data/webpack/ForemanTasks/Components/TaskDetails/TaskDetailsSelectors.js +20 -5
- data/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.fixtures.js +1 -1
- data/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.test.js +97 -10
- data/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetailsSelectors.test.js +81 -0
- data/webpack/ForemanTasks/Components/TaskDetails/index.js +6 -4
- data/webpack/ForemanTasks/Components/common/taskResultIcon.js +53 -0
- data/webpack/ForemanTasks/Routes/ShowTaskDetails/TaskDetailsPage.js +74 -0
- data/webpack/ForemanTasks/Routes/ShowTaskDetails/__tests__/TaskDetailsPage.test.js +265 -0
- data/webpack/Routes/routes.js +6 -0
- data/webpack/Routes/routes.test.js +16 -5
- metadata +9 -2
- data/app/views/foreman_tasks/tasks/show.html.erb +0 -18
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9fa716da28a9b6bf38240aa02e47f9ab4a4ab6ba82f98c5d26f7d6066f3149e4
|
|
4
|
+
data.tar.gz: eb7ec125870236abf803a65c132b042fd4360f4de5d7d9a5b2bc718ba8672235
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0547b82e39eabef4f7205100733272e5bdb7b619721ea1a9b94259403c1be370a4ee34564825ccbca83ed16767e72634dd2a070f288ceed28627c3c58671d726
|
|
7
|
+
data.tar.gz: c27122956334571f9b1cc287e8ec6cf322f5f2931be69780539cc58a7f37cbf7f7c4c81800807fc81a79ca8213d1510c127552492ee942c21a1067eb09258a9e
|
|
@@ -6,11 +6,6 @@ module ForemanTasks
|
|
|
6
6
|
|
|
7
7
|
before_action :find_dynflow_task, only: [:unlock, :force_unlock, :cancel, :abort, :cancel_step, :resume]
|
|
8
8
|
|
|
9
|
-
def show
|
|
10
|
-
@task = resource_base.find(params[:id])
|
|
11
|
-
render :layout => !request.xhr?
|
|
12
|
-
end
|
|
13
|
-
|
|
14
9
|
def index
|
|
15
10
|
params[:order] ||= 'started_at DESC'
|
|
16
11
|
respond_with_tasks resource_base
|
data/config/routes.rb
CHANGED
|
@@ -12,7 +12,7 @@ Foreman::Application.routes.draw do
|
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
resources :tasks, :only => [
|
|
15
|
+
resources :tasks, :only => [] do
|
|
16
16
|
collection do
|
|
17
17
|
get 'auto_complete_search'
|
|
18
18
|
get '/summary/:recent_timeframe', action: 'summary'
|
|
@@ -27,7 +27,7 @@ Foreman::Application.routes.draw do
|
|
|
27
27
|
post :cancel_step
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
|
-
resources :tasks, :only => [
|
|
30
|
+
resources :tasks, :only => [], constraints: ->(req) { req.format == :csv } do
|
|
31
31
|
member do
|
|
32
32
|
get :sub_tasks
|
|
33
33
|
end
|
|
@@ -35,6 +35,7 @@ Foreman::Application.routes.draw do
|
|
|
35
35
|
resources :tasks, :only => [:index], constraints: ->(req) { req.format == :csv }
|
|
36
36
|
|
|
37
37
|
match '/tasks', to: '/react#index', via: :get
|
|
38
|
+
match '/tasks/:id', to: '/react#index', via: :get, :as => :task
|
|
38
39
|
match '/tasks/:id/sub_tasks', to: '/react#index', via: :get
|
|
39
40
|
|
|
40
41
|
namespace :api do
|
data/lib/foreman_tasks/engine.rb
CHANGED
|
@@ -20,7 +20,7 @@ module ForemanTasks
|
|
|
20
20
|
require 'foreman/cron'
|
|
21
21
|
|
|
22
22
|
Foreman::Plugin.register :"foreman-tasks" do
|
|
23
|
-
requires_foreman '>=
|
|
23
|
+
requires_foreman '>= 5.0'
|
|
24
24
|
register_global_js_file 'global'
|
|
25
25
|
divider :top_menu, :parent => :monitor_menu, :last => true, :caption => N_('Foreman Tasks')
|
|
26
26
|
menu :top_menu, :tasks,
|
|
@@ -36,7 +36,7 @@ module ForemanTasks
|
|
|
36
36
|
:last => true
|
|
37
37
|
|
|
38
38
|
security_block :foreman_tasks do |_map|
|
|
39
|
-
permission :view_foreman_tasks, { :'foreman_tasks/tasks' => [:auto_complete_search, :sub_tasks, :index, :summary, :summary_sub_tasks
|
|
39
|
+
permission :view_foreman_tasks, { :'foreman_tasks/tasks' => [:auto_complete_search, :sub_tasks, :index, :summary, :summary_sub_tasks],
|
|
40
40
|
:'foreman_tasks/api/tasks' => [:bulk_search, :show, :index, :summary, :summary_sub_tasks, :details, :sub_tasks] }, :resource_type => 'ForemanTasks::Task'
|
|
41
41
|
permission :edit_foreman_tasks, { :'foreman_tasks/tasks' => [:resume, :unlock, :force_unlock, :cancel_step, :cancel, :abort],
|
|
42
42
|
:'foreman_tasks/api/tasks' => [:bulk_resume, :bulk_cancel, :bulk_stop] }, :resource_type => 'ForemanTasks::Task'
|
|
@@ -82,15 +82,6 @@ module ForemanTasks
|
|
|
82
82
|
assert_include response.body.lines[1], 'Some action'
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
-
describe 'show' do
|
|
86
|
-
it 'does not allow user without permissions to see task details' do
|
|
87
|
-
setup_user('view', 'foreman_tasks', 'owner.id = current_user')
|
|
88
|
-
get :show, params: { id: FactoryBot.create(:some_task).id },
|
|
89
|
-
session: set_session_user(User.current)
|
|
90
|
-
assert_response :not_found
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
94
85
|
describe 'index' do
|
|
95
86
|
it 'shows duration column' do
|
|
96
87
|
task = ForemanTasks::Task.select_duration.find(FactoryBot.create(:some_task).id)
|
|
@@ -9,8 +9,8 @@ require_relative './support/history_tasks_builder'
|
|
|
9
9
|
require 'dynflow/testing'
|
|
10
10
|
require 'foreman_tasks/test_helpers'
|
|
11
11
|
|
|
12
|
-
FactoryBot.definition_file_paths
|
|
13
|
-
FactoryBot.
|
|
12
|
+
FactoryBot.definition_file_paths << "#{ForemanTasks::Engine.root}/test/factories"
|
|
13
|
+
FactoryBot.reload
|
|
14
14
|
|
|
15
15
|
ForemanTasks.dynflow.require!
|
|
16
16
|
ForemanTasks.dynflow.config.disable_active_record_actions = true
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../test_plugin_helper'
|
|
4
|
+
require 'integration_test_helper'
|
|
5
|
+
|
|
6
|
+
class TasksIntegrationTest < IntegrationTestWithJavascript
|
|
7
|
+
test 'does not allow user without permissions to see task details' do
|
|
8
|
+
setup_user('view', 'foreman_tasks', 'owner.id = current_user')
|
|
9
|
+
task = FactoryBot.create(:some_task)
|
|
10
|
+
set_request_user(User.current)
|
|
11
|
+
|
|
12
|
+
visit "/foreman_tasks/tasks/#{task.id}"
|
|
13
|
+
|
|
14
|
+
assert_selector 'h5', text: /Unable to load task/i
|
|
15
|
+
assert_no_selector '#task-details-tabs'
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -5,17 +5,12 @@ import {
|
|
|
5
5
|
GridItem,
|
|
6
6
|
Progress,
|
|
7
7
|
ProgressVariant,
|
|
8
|
-
Icon,
|
|
9
8
|
} from '@patternfly/react-core';
|
|
10
|
-
import {
|
|
11
|
-
CheckCircleIcon,
|
|
12
|
-
ExclamationCircleIcon,
|
|
13
|
-
ExclamationTriangleIcon,
|
|
14
|
-
QuestionCircleIcon,
|
|
15
|
-
} from '@patternfly/react-icons';
|
|
16
9
|
import { translate as __ } from 'foremanReact/common/I18n';
|
|
17
10
|
import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime';
|
|
18
11
|
|
|
12
|
+
import { taskResultIconEl } from '../../common/taskResultIcon';
|
|
13
|
+
|
|
19
14
|
const isDelayed = ({ startAt, startedAt }) => {
|
|
20
15
|
if (
|
|
21
16
|
startAt == null ||
|
|
@@ -35,41 +30,6 @@ const isDelayed = ({ startAt, startedAt }) => {
|
|
|
35
30
|
return a.getTime() !== b.getTime();
|
|
36
31
|
};
|
|
37
32
|
|
|
38
|
-
const resultIconEl = (state, result) => {
|
|
39
|
-
if (state !== 'stopped')
|
|
40
|
-
return (
|
|
41
|
-
<Icon>
|
|
42
|
-
<QuestionCircleIcon />
|
|
43
|
-
</Icon>
|
|
44
|
-
);
|
|
45
|
-
switch (result) {
|
|
46
|
-
case 'success':
|
|
47
|
-
return (
|
|
48
|
-
<Icon status="success">
|
|
49
|
-
<CheckCircleIcon />
|
|
50
|
-
</Icon>
|
|
51
|
-
);
|
|
52
|
-
case 'error':
|
|
53
|
-
return (
|
|
54
|
-
<Icon status="danger">
|
|
55
|
-
<ExclamationCircleIcon />
|
|
56
|
-
</Icon>
|
|
57
|
-
);
|
|
58
|
-
case 'warning':
|
|
59
|
-
return (
|
|
60
|
-
<Icon status="warning">
|
|
61
|
-
<ExclamationTriangleIcon />
|
|
62
|
-
</Icon>
|
|
63
|
-
);
|
|
64
|
-
default:
|
|
65
|
-
return (
|
|
66
|
-
<Icon>
|
|
67
|
-
<QuestionCircleIcon />
|
|
68
|
-
</Icon>
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
|
|
73
33
|
const progressVariantForResult = result => {
|
|
74
34
|
switch (result) {
|
|
75
35
|
case 'error':
|
|
@@ -117,7 +77,7 @@ const TaskInfo = props => {
|
|
|
117
77
|
title: 'Result',
|
|
118
78
|
value: (
|
|
119
79
|
<span>
|
|
120
|
-
{
|
|
80
|
+
{taskResultIconEl(state, result)} {result}
|
|
121
81
|
</span>
|
|
122
82
|
),
|
|
123
83
|
},
|
|
@@ -182,14 +142,9 @@ const TaskInfo = props => {
|
|
|
182
142
|
</React.Fragment>
|
|
183
143
|
))}
|
|
184
144
|
<GridItem span={12} className="pf-v5-u-pb-lg" />
|
|
185
|
-
<GridItem span={
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
{state}
|
|
189
|
-
</div>
|
|
190
|
-
</GridItem>
|
|
191
|
-
<GridItem span={6} className="progress-label-top-right">
|
|
192
|
-
<span>{`${progress}% ${__('Complete')}`}</span>
|
|
145
|
+
<GridItem span={12}>
|
|
146
|
+
<span className="list-group-item-heading">{__('State')}: </span>
|
|
147
|
+
{state}
|
|
193
148
|
</GridItem>
|
|
194
149
|
<GridItem span={12}>
|
|
195
150
|
<Progress
|
|
@@ -27,12 +27,7 @@ export const TaskSkeleton = () => {
|
|
|
27
27
|
</React.Fragment>
|
|
28
28
|
))}
|
|
29
29
|
<GridItem span={12} className="pf-v5-u-pb-lg" />
|
|
30
|
-
<GridItem span={
|
|
31
|
-
<div className="progress-description">
|
|
32
|
-
<Skeleton />
|
|
33
|
-
</div>
|
|
34
|
-
</GridItem>
|
|
35
|
-
<GridItem span={6} className="progress-label-top-right">
|
|
30
|
+
<GridItem span={12}>
|
|
36
31
|
<Skeleton />
|
|
37
32
|
</GridItem>
|
|
38
33
|
<GridItem span={12}>
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { render, screen } from '@testing-library/react';
|
|
3
3
|
import '@testing-library/jest-dom';
|
|
4
|
-
import { STATUS } from 'foremanReact/constants';
|
|
5
4
|
|
|
6
5
|
import Task from '../Task';
|
|
7
6
|
|
|
@@ -29,7 +28,6 @@ describe('Task', () => {
|
|
|
29
28
|
parentTask="parent-id"
|
|
30
29
|
taskReload
|
|
31
30
|
canEdit
|
|
32
|
-
status={STATUS.RESOLVED}
|
|
33
31
|
taskProgressToggle={jest.fn()}
|
|
34
32
|
taskReloadStart={jest.fn()}
|
|
35
33
|
/>
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
3
|
import '@testing-library/jest-dom';
|
|
4
|
-
import { STATUS } from 'foremanReact/constants';
|
|
5
4
|
import { TaskButtons } from '../TaskButtons';
|
|
6
5
|
|
|
7
6
|
const setUnlockModalOpen = jest.fn();
|
|
@@ -169,7 +168,6 @@ describe('TaskButtons', () => {
|
|
|
169
168
|
resumeTaskRequest,
|
|
170
169
|
taskProgressToggle,
|
|
171
170
|
taskReloadStart,
|
|
172
|
-
status: STATUS.RESOLVED,
|
|
173
171
|
canEdit: true,
|
|
174
172
|
resumable: true,
|
|
175
173
|
cancellable: true,
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import React, { useEffect, useState } from 'react';
|
|
2
2
|
import PropTypes from 'prop-types';
|
|
3
3
|
import { Tabs, Tab, TabTitleText } from '@patternfly/react-core';
|
|
4
|
-
import { translate as __
|
|
4
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
5
5
|
import { STATUS } from 'foremanReact/constants';
|
|
6
|
-
import
|
|
6
|
+
import { usePermissions } from 'foremanReact/common/hooks/Permissions/permissionHooks';
|
|
7
|
+
import { ResourceLoadFailedEmptyState } from 'foremanReact/components/common/EmptyState';
|
|
7
8
|
import Task from './Components/Task';
|
|
8
9
|
import RunningSteps from './Components/RunningSteps';
|
|
9
10
|
import Errors from './Components/Errors';
|
|
10
11
|
import Locks from './Components/Locks';
|
|
11
12
|
import Raw from './Components/Raw';
|
|
12
13
|
import Dependencies from './Components/Dependencies';
|
|
14
|
+
import { TASKS_PATH, VIEW_FOREMAN_TASKS } from './TaskDetailsConstants';
|
|
13
15
|
import { getTaskID } from './TasksDetailsHelper';
|
|
14
16
|
import { TaskSkeleton } from './Components/TaskSkeleton';
|
|
15
17
|
|
|
@@ -26,12 +28,15 @@ const TaskDetails = ({
|
|
|
26
28
|
cancelStep,
|
|
27
29
|
taskReloadStart,
|
|
28
30
|
taskReloadStop,
|
|
29
|
-
|
|
31
|
+
apiStatus,
|
|
32
|
+
apiErrorMessage,
|
|
33
|
+
apiErrorCode,
|
|
30
34
|
...props
|
|
31
35
|
}) => {
|
|
32
36
|
const id = getTaskID();
|
|
33
|
-
const { taskReload,
|
|
37
|
+
const { taskReload, isLoading, result } = props;
|
|
34
38
|
const [activeTabKey, setActiveTabKey] = useState(1);
|
|
39
|
+
const hasViewPermission = usePermissions([VIEW_FOREMAN_TASKS]);
|
|
35
40
|
|
|
36
41
|
useEffect(() => {
|
|
37
42
|
taskReloadStart(id);
|
|
@@ -48,15 +53,25 @@ const TaskDetails = ({
|
|
|
48
53
|
}
|
|
49
54
|
};
|
|
50
55
|
|
|
51
|
-
if (
|
|
56
|
+
if (apiStatus === STATUS.ERROR || !hasViewPermission) {
|
|
52
57
|
return (
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
<ResourceLoadFailedEmptyState
|
|
59
|
+
resourceLabel={__('task')}
|
|
60
|
+
resourceId={id}
|
|
61
|
+
httpStatus={apiErrorCode}
|
|
62
|
+
errorMessage={apiErrorMessage}
|
|
63
|
+
viewPermissions={['view_foreman_tasks']}
|
|
64
|
+
requiredPermissions={['view_foreman_tasks']}
|
|
65
|
+
ouiaIdPrefix="task-details-empty-state"
|
|
66
|
+
primaryAction={{
|
|
67
|
+
label: __('Back to tasks'),
|
|
68
|
+
url: TASKS_PATH,
|
|
69
|
+
ouiaId: 'task-details-empty-state-tasks-list',
|
|
70
|
+
}}
|
|
57
71
|
/>
|
|
58
72
|
);
|
|
59
73
|
}
|
|
74
|
+
|
|
60
75
|
const resumable = executionPlan ? executionPlan.state === 'paused' : false;
|
|
61
76
|
const cancellable = executionPlan ? executionPlan.cancellable : false;
|
|
62
77
|
const lockRecords = locks.concat(links);
|
|
@@ -66,13 +81,12 @@ const TaskDetails = ({
|
|
|
66
81
|
cancellable,
|
|
67
82
|
resumable,
|
|
68
83
|
id,
|
|
69
|
-
status,
|
|
70
84
|
taskProgressToggle,
|
|
71
85
|
taskReloadStart,
|
|
72
86
|
};
|
|
73
87
|
|
|
74
88
|
return (
|
|
75
|
-
<div className="task-details-react
|
|
89
|
+
<div className="task-details-react">
|
|
76
90
|
<Tabs
|
|
77
91
|
id="task-details-tabs"
|
|
78
92
|
ouiaId="task-details-tabs"
|
|
@@ -159,8 +173,9 @@ TaskDetails.propTypes = {
|
|
|
159
173
|
runningSteps: PropTypes.array,
|
|
160
174
|
cancelStep: PropTypes.func.isRequired,
|
|
161
175
|
taskReload: PropTypes.bool.isRequired,
|
|
162
|
-
|
|
163
|
-
|
|
176
|
+
apiStatus: PropTypes.oneOf(Object.keys(STATUS)),
|
|
177
|
+
apiErrorMessage: PropTypes.string,
|
|
178
|
+
apiErrorCode: PropTypes.number,
|
|
164
179
|
taskReloadStop: PropTypes.func.isRequired,
|
|
165
180
|
taskReloadStart: PropTypes.func.isRequired,
|
|
166
181
|
links: PropTypes.array,
|
|
@@ -175,8 +190,7 @@ TaskDetails.propTypes = {
|
|
|
175
190
|
TaskDetails.defaultProps = {
|
|
176
191
|
label: '',
|
|
177
192
|
runningSteps: [],
|
|
178
|
-
|
|
179
|
-
status: STATUS.PENDING,
|
|
193
|
+
apiErrorMessage: '',
|
|
180
194
|
links: [],
|
|
181
195
|
dependsOn: [],
|
|
182
196
|
blocks: [],
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export const FOREMAN_TASK_DETAILS = 'FOREMAN_TASK_DETAILS';
|
|
2
2
|
export const FOREMAN_TASK_DETAILS_SUCCESS = 'FOREMAN_TASK_DETAILS_SUCCESS';
|
|
3
|
-
|
|
3
|
+
export const TASKS_PATH = '/foreman_tasks/tasks';
|
|
4
4
|
export const TASK_STEP_CANCEL = 'TASK_STEP_CANCEL';
|
|
5
|
+
export const VIEW_FOREMAN_TASKS = 'view_foreman_tasks';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/* eslint-disable camelcase */
|
|
2
2
|
import {
|
|
3
3
|
selectAPIResponse,
|
|
4
|
-
|
|
4
|
+
selectAPIStatus as selectAPIStatusByKey,
|
|
5
|
+
selectAPIError as selectAPIErrorByKey,
|
|
5
6
|
} from 'foremanReact/redux/API/APISelectors';
|
|
6
7
|
import { selectDoesIntervalExist } from 'foremanReact/redux/middlewares/IntervalMiddleware/IntervalSelectors';
|
|
7
8
|
import { STATUS } from 'foremanReact/constants';
|
|
@@ -101,14 +102,28 @@ export const selectDynflowEnableConsole = state =>
|
|
|
101
102
|
export const selectCanEdit = state =>
|
|
102
103
|
selectTaskDetailsResponse(state).can_edit || false;
|
|
103
104
|
|
|
104
|
-
export const
|
|
105
|
+
export const selectAPIStatus = state =>
|
|
106
|
+
selectAPIStatusByKey(state, FOREMAN_TASK_DETAILS);
|
|
105
107
|
|
|
106
108
|
export const selectAPIError = state =>
|
|
107
|
-
|
|
109
|
+
selectAPIErrorByKey(state, FOREMAN_TASK_DETAILS);
|
|
110
|
+
|
|
111
|
+
export const selectAPIErrorMessage = state => {
|
|
112
|
+
const apiError = selectAPIError(state);
|
|
113
|
+
|
|
114
|
+
if (apiError?.response?.data?.error) {
|
|
115
|
+
return apiError.response.data.error.message;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return apiError?.message;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const selectAPIErrorCode = state =>
|
|
122
|
+
selectAPIError(state)?.response?.status;
|
|
108
123
|
|
|
109
124
|
export const selectIsLoading = state =>
|
|
110
|
-
|
|
111
|
-
|
|
125
|
+
!Object.keys(selectTaskDetailsResponse(state) ?? {}).length &&
|
|
126
|
+
selectAPIStatus(state) === STATUS.PENDING;
|
|
112
127
|
|
|
113
128
|
export const selectDependsOn = state =>
|
|
114
129
|
selectTaskDetailsResponse(state).depends_on || [];
|
|
@@ -1,38 +1,125 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { render, screen } from '@testing-library/react';
|
|
3
3
|
import '@testing-library/jest-dom';
|
|
4
|
+
import { configureStore } from '@reduxjs/toolkit';
|
|
5
|
+
import { Provider } from 'react-redux';
|
|
6
|
+
import { createMemoryHistory } from 'history';
|
|
7
|
+
import { Router } from 'react-router-dom';
|
|
4
8
|
|
|
5
9
|
import TaskDetails from '../TaskDetails';
|
|
6
10
|
import { minProps } from './TaskDetails.fixtures';
|
|
11
|
+
import { VIEW_FOREMAN_TASKS } from '../TaskDetailsConstants';
|
|
12
|
+
|
|
13
|
+
const mockUseForemanPermissions = jest.fn(
|
|
14
|
+
() => new Set(['view_foreman_tasks'])
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
jest.mock('foremanReact/Root/Context/ForemanContext', () => ({
|
|
18
|
+
...jest.requireActual('foremanReact/Root/Context/ForemanContext'),
|
|
19
|
+
useForemanPermissions: (...args) => mockUseForemanPermissions(...args),
|
|
20
|
+
}));
|
|
7
21
|
|
|
8
22
|
delete window.location;
|
|
9
23
|
window.location = new URL(
|
|
10
24
|
'https://foreman.com/foreman_tasks/tasks/a15dd820-32f1-4ced-9ab7-c0fab8234c47/'
|
|
11
25
|
);
|
|
12
26
|
|
|
27
|
+
const store = configureStore({ reducer: state => state || {} });
|
|
28
|
+
|
|
29
|
+
function renderTaskDetails(props = {}) {
|
|
30
|
+
const history = createMemoryHistory();
|
|
31
|
+
|
|
32
|
+
return render(
|
|
33
|
+
<Router history={history}>
|
|
34
|
+
<Provider store={store}>
|
|
35
|
+
<TaskDetails {...minProps} {...props} />
|
|
36
|
+
</Provider>
|
|
37
|
+
</Router>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
13
41
|
describe('TaskDetails', () => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
{...minProps}
|
|
18
|
-
status="ERROR"
|
|
19
|
-
APIerror={{ message: 'some-error' }}
|
|
20
|
-
/>
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
mockUseForemanPermissions.mockImplementation(
|
|
44
|
+
() => new Set(['view_foreman_tasks'])
|
|
21
45
|
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it(`shows ResourceLoadFailedEmptyState when ${VIEW_FOREMAN_TASKS} is absent`, () => {
|
|
49
|
+
mockUseForemanPermissions.mockImplementation(() => new Set());
|
|
50
|
+
|
|
51
|
+
renderTaskDetails();
|
|
52
|
+
|
|
53
|
+
expect(
|
|
54
|
+
screen.getByRole('heading', { name: /permission denied/i })
|
|
55
|
+
).toBeInTheDocument();
|
|
56
|
+
expect(screen.getByText(VIEW_FOREMAN_TASKS)).toBeInTheDocument();
|
|
57
|
+
expect(
|
|
58
|
+
screen.getByRole('button', { name: /back to tasks/i })
|
|
59
|
+
).toBeInTheDocument();
|
|
60
|
+
expect(screen.queryByRole('tab', { name: /^task$/i })).not.toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('shows ResourceLoadFailedEmptyState when apiStatus is ERROR', () => {
|
|
64
|
+
renderTaskDetails({
|
|
65
|
+
apiStatus: 'ERROR',
|
|
66
|
+
apiErrorMessage: 'some-error',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(
|
|
70
|
+
screen.getByRole('heading', { name: /unable to load task/i })
|
|
71
|
+
).toBeInTheDocument();
|
|
72
|
+
expect(
|
|
73
|
+
screen.getByText('Server returned: some-error')
|
|
74
|
+
).toBeInTheDocument();
|
|
75
|
+
expect(screen.queryByRole('tab', { name: /^task$/i })).not.toBeInTheDocument();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('shows permission denied when apiErrorCode is 403', () => {
|
|
79
|
+
renderTaskDetails({
|
|
80
|
+
apiStatus: 'ERROR',
|
|
81
|
+
apiErrorCode: 403,
|
|
82
|
+
apiErrorMessage: 'Forbidden',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(
|
|
86
|
+
screen.getByRole('heading', { name: /permission denied/i })
|
|
87
|
+
).toBeInTheDocument();
|
|
88
|
+
expect(screen.getByText('view_foreman_tasks')).toBeInTheDocument();
|
|
89
|
+
expect(
|
|
90
|
+
screen.getByText('Server returned: Forbidden')
|
|
91
|
+
).toBeInTheDocument();
|
|
92
|
+
expect(screen.queryByRole('tab', { name: /^task$/i })).not.toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('shows not found messaging when apiErrorCode is 404', () => {
|
|
96
|
+
renderTaskDetails({
|
|
97
|
+
apiStatus: 'ERROR',
|
|
98
|
+
apiErrorCode: 404,
|
|
99
|
+
apiErrorMessage: 'Task missing',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(
|
|
103
|
+
screen.getByRole('heading', { name: /unable to load task/i })
|
|
104
|
+
).toBeInTheDocument();
|
|
105
|
+
expect(
|
|
106
|
+
screen.getByText(/could not be found/i)
|
|
107
|
+
).toBeInTheDocument();
|
|
22
108
|
expect(
|
|
23
|
-
screen.getByText(
|
|
109
|
+
screen.getByText('Server returned: Task missing')
|
|
24
110
|
).toBeInTheDocument();
|
|
111
|
+
expect(screen.queryByRole('tab', { name: /^task$/i })).not.toBeInTheDocument();
|
|
25
112
|
});
|
|
26
113
|
|
|
27
114
|
it('shows skeleton while loading on the Task tab', () => {
|
|
28
|
-
const { container } =
|
|
115
|
+
const { container } = renderTaskDetails({ isLoading: true });
|
|
29
116
|
expect(
|
|
30
117
|
container.querySelector('.react-loading-skeleton')
|
|
31
118
|
).toBeInTheDocument();
|
|
32
119
|
});
|
|
33
120
|
|
|
34
121
|
it('renders six tabs with expected labels', () => {
|
|
35
|
-
|
|
122
|
+
renderTaskDetails();
|
|
36
123
|
expect(document.getElementById('task-details-tabs')).toBeInTheDocument();
|
|
37
124
|
expect(screen.getByRole('tab', { name: /^task$/i })).toBeInTheDocument();
|
|
38
125
|
expect(
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { STATUS } from 'foremanReact/constants';
|
|
2
|
+
|
|
3
|
+
import { FOREMAN_TASK_DETAILS } from '../TaskDetailsConstants';
|
|
4
|
+
import {
|
|
5
|
+
selectAPIError,
|
|
6
|
+
selectAPIErrorCode,
|
|
7
|
+
selectAPIErrorMessage,
|
|
8
|
+
} from '../TaskDetailsSelectors';
|
|
9
|
+
|
|
10
|
+
const axiosErrorWithApiBody = {
|
|
11
|
+
message: 'Request failed with status code 404',
|
|
12
|
+
response: {
|
|
13
|
+
status: 404,
|
|
14
|
+
data: {
|
|
15
|
+
error: {
|
|
16
|
+
message: 'Task not found',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const plainError = {
|
|
23
|
+
message: 'Network Error',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const forbiddenError = {
|
|
27
|
+
message: 'Request failed with status code 403',
|
|
28
|
+
response: {
|
|
29
|
+
status: 403,
|
|
30
|
+
data: {
|
|
31
|
+
error: {
|
|
32
|
+
message: 'Forbidden',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const buildState = (response, status = STATUS.ERROR) => ({
|
|
39
|
+
API: {
|
|
40
|
+
[FOREMAN_TASK_DETAILS]: {
|
|
41
|
+
payload: {},
|
|
42
|
+
response,
|
|
43
|
+
status,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('TaskDetailsSelectors - API error selectors', () => {
|
|
49
|
+
it('selectAPIError returns null when API status is not ERROR', () => {
|
|
50
|
+
expect(selectAPIError(buildState({}, STATUS.RESOLVED))).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('selectAPIError returns the stored response when API status is ERROR', () => {
|
|
54
|
+
expect(selectAPIError(buildState(plainError))).toEqual(plainError);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('selectAPIErrorMessage prefers the API error body message', () => {
|
|
58
|
+
expect(
|
|
59
|
+
selectAPIErrorMessage(buildState(axiosErrorWithApiBody))
|
|
60
|
+
).toBe('Task not found');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('selectAPIErrorMessage falls back to the top-level error message', () => {
|
|
64
|
+
expect(selectAPIErrorMessage(buildState(plainError))).toBe('Network Error');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('selectAPIErrorMessage returns undefined when there is no API error', () => {
|
|
68
|
+
expect(
|
|
69
|
+
selectAPIErrorMessage(buildState({}, STATUS.RESOLVED))
|
|
70
|
+
).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('selectAPIErrorCode returns the HTTP status from the error response', () => {
|
|
74
|
+
expect(selectAPIErrorCode(buildState(axiosErrorWithApiBody))).toBe(404);
|
|
75
|
+
expect(selectAPIErrorCode(buildState(forbiddenError))).toBe(403);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('selectAPIErrorCode returns undefined when the error response has no status', () => {
|
|
79
|
+
expect(selectAPIErrorCode(buildState(plainError))).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -33,8 +33,9 @@ import {
|
|
|
33
33
|
selectExternalId,
|
|
34
34
|
selectDynflowEnableConsole,
|
|
35
35
|
selectCanEdit,
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
selectAPIStatus,
|
|
37
|
+
selectAPIErrorMessage,
|
|
38
|
+
selectAPIErrorCode,
|
|
38
39
|
selectIsLoading,
|
|
39
40
|
selectDependsOn,
|
|
40
41
|
selectBlocks,
|
|
@@ -70,8 +71,9 @@ const mapStateToProps = state => ({
|
|
|
70
71
|
externalId: selectExternalId(state),
|
|
71
72
|
dynflowEnableConsole: selectDynflowEnableConsole(state),
|
|
72
73
|
canEdit: selectCanEdit(state),
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
apiStatus: selectAPIStatus(state),
|
|
75
|
+
apiErrorMessage: selectAPIErrorMessage(state),
|
|
76
|
+
apiErrorCode: selectAPIErrorCode(state),
|
|
75
77
|
isLoading: selectIsLoading(state),
|
|
76
78
|
dependsOn: selectDependsOn(state),
|
|
77
79
|
blocks: selectBlocks(state),
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Icon } from '@patternfly/react-core';
|
|
3
|
+
import {
|
|
4
|
+
CheckCircleIcon,
|
|
5
|
+
ExclamationCircleIcon,
|
|
6
|
+
ExclamationTriangleIcon,
|
|
7
|
+
QuestionCircleIcon,
|
|
8
|
+
} from '@patternfly/react-icons';
|
|
9
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Icon reflecting task state/result (aligned with TaskInfo / tasks table).
|
|
13
|
+
*
|
|
14
|
+
* @param {string} state Dynflow task state (e.g. stopped, running).
|
|
15
|
+
* @param {string} [result] Result when stopped (success, error, warning, …).
|
|
16
|
+
* @returns {React.ReactElement}
|
|
17
|
+
*/
|
|
18
|
+
export const taskResultIconEl = (state, result) => {
|
|
19
|
+
if (state && state !== 'stopped') {
|
|
20
|
+
return (
|
|
21
|
+
<Icon title={__('Running')}>
|
|
22
|
+
<QuestionCircleIcon />
|
|
23
|
+
</Icon>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
switch (result) {
|
|
28
|
+
case 'success':
|
|
29
|
+
return (
|
|
30
|
+
<Icon status="success" title={__('Success')}>
|
|
31
|
+
<CheckCircleIcon />
|
|
32
|
+
</Icon>
|
|
33
|
+
);
|
|
34
|
+
case 'error':
|
|
35
|
+
return (
|
|
36
|
+
<Icon status="danger" title={__('Error')}>
|
|
37
|
+
<ExclamationCircleIcon />
|
|
38
|
+
</Icon>
|
|
39
|
+
);
|
|
40
|
+
case 'warning':
|
|
41
|
+
return (
|
|
42
|
+
<Icon status="warning" title={__('Warning')}>
|
|
43
|
+
<ExclamationTriangleIcon />
|
|
44
|
+
</Icon>
|
|
45
|
+
);
|
|
46
|
+
default:
|
|
47
|
+
return (
|
|
48
|
+
<Icon title={__('Unknown')}>
|
|
49
|
+
<QuestionCircleIcon />
|
|
50
|
+
</Icon>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import PropTypes from 'prop-types';
|
|
3
|
+
import { useSelector } from 'react-redux';
|
|
4
|
+
import { Flex, FlexItem, TextContent, Text } from '@patternfly/react-core';
|
|
5
|
+
import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout';
|
|
6
|
+
import { translate as __, sprintf } from 'foremanReact/common/I18n';
|
|
7
|
+
import TaskDetails from '../../Components/TaskDetails';
|
|
8
|
+
import {
|
|
9
|
+
selectAction,
|
|
10
|
+
selectResult,
|
|
11
|
+
selectState,
|
|
12
|
+
} from '../../Components/TaskDetails/TaskDetailsSelectors';
|
|
13
|
+
import { taskResultIconEl } from '../../Components/common/taskResultIcon';
|
|
14
|
+
|
|
15
|
+
const TaskDetailsPage = props => {
|
|
16
|
+
const { id } = props.match.params;
|
|
17
|
+
const action = useSelector(selectAction);
|
|
18
|
+
const taskState = useSelector(selectState);
|
|
19
|
+
const taskResult = useSelector(selectResult);
|
|
20
|
+
const headerText = action
|
|
21
|
+
? sprintf(__('Details of %s task'), action)
|
|
22
|
+
: __('Task Details');
|
|
23
|
+
|
|
24
|
+
const header = (
|
|
25
|
+
<Flex
|
|
26
|
+
component="span"
|
|
27
|
+
data-ouia-component-id="foreman-tasks-task-details-title-row"
|
|
28
|
+
display={{ default: 'inlineFlex' }}
|
|
29
|
+
alignItems={{ default: 'alignItemsCenter' }}
|
|
30
|
+
gap={{ default: 'gapSm' }}
|
|
31
|
+
>
|
|
32
|
+
<TextContent>
|
|
33
|
+
<Text ouiaId="breadcrumb_title" component="h1">
|
|
34
|
+
{headerText}
|
|
35
|
+
</Text>
|
|
36
|
+
</TextContent>
|
|
37
|
+
<FlexItem component="span">
|
|
38
|
+
{taskResultIconEl(taskState, taskResult)}
|
|
39
|
+
</FlexItem>
|
|
40
|
+
</Flex>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<PageLayout
|
|
45
|
+
customHeader={header}
|
|
46
|
+
header={headerText}
|
|
47
|
+
searchable={false}
|
|
48
|
+
breadcrumbOptions={{
|
|
49
|
+
breadcrumbItems: [
|
|
50
|
+
{ caption: __('Tasks'), url: '/foreman_tasks/tasks' },
|
|
51
|
+
{ caption: action || id },
|
|
52
|
+
],
|
|
53
|
+
isSwitchable: true,
|
|
54
|
+
resource: {
|
|
55
|
+
nameField: 'action',
|
|
56
|
+
resourceUrl: '/foreman_tasks/api/tasks',
|
|
57
|
+
switcherItemUrl: '/foreman_tasks/tasks/:id',
|
|
58
|
+
},
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
<TaskDetails {...props} />
|
|
62
|
+
</PageLayout>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
TaskDetailsPage.propTypes = {
|
|
67
|
+
match: PropTypes.shape({
|
|
68
|
+
params: PropTypes.shape({
|
|
69
|
+
id: PropTypes.string.isRequired,
|
|
70
|
+
}).isRequired,
|
|
71
|
+
}).isRequired,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export default TaskDetailsPage;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, within } from '@testing-library/react';
|
|
3
|
+
import '@testing-library/jest-dom';
|
|
4
|
+
import { combineReducers, configureStore } from '@reduxjs/toolkit';
|
|
5
|
+
import { createMemoryHistory } from 'history';
|
|
6
|
+
import { Router } from 'react-router-dom';
|
|
7
|
+
import { IntlProvider } from 'react-intl';
|
|
8
|
+
import { Provider } from 'react-redux';
|
|
9
|
+
|
|
10
|
+
import breadcrumbBarReducer from 'foremanReact/components/BreadcrumbBar/BreadcrumbBarReducer';
|
|
11
|
+
import { STATUS } from 'foremanReact/constants';
|
|
12
|
+
import intervalsReducer from 'foremanReact/redux/middlewares/IntervalMiddleware/IntervalReducer';
|
|
13
|
+
|
|
14
|
+
import { FOREMAN_TASK_DETAILS, VIEW_FOREMAN_TASKS } from '../../../Components/TaskDetails/TaskDetailsConstants';
|
|
15
|
+
import TaskDetailsPage from '../TaskDetailsPage';
|
|
16
|
+
|
|
17
|
+
const mockUseForemanPermissions = jest.fn(
|
|
18
|
+
() => new Set(['view_foreman_tasks'])
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
jest.mock('foremanReact/Root/Context/ForemanContext', () => ({
|
|
22
|
+
...jest.requireActual('foremanReact/Root/Context/ForemanContext'),
|
|
23
|
+
useForemanPermissions: (...args) => mockUseForemanPermissions(...args),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const TASK_DETAILS_TITLE_ROW_OUIA_ID = 'foreman-tasks-task-details-title-row';
|
|
27
|
+
|
|
28
|
+
const routerPropsBase = {
|
|
29
|
+
history: { push: jest.fn(), replace: jest.fn(), go: jest.fn() },
|
|
30
|
+
location: {
|
|
31
|
+
pathname: '/foreman_tasks/tasks/task-route-id',
|
|
32
|
+
search: '',
|
|
33
|
+
hash: '',
|
|
34
|
+
state: undefined,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const matchDefault = {
|
|
39
|
+
params: { id: 'task-route-id' },
|
|
40
|
+
path: '/foreman_tasks/tasks/:id',
|
|
41
|
+
url: '/foreman_tasks/tasks/task-route-id',
|
|
42
|
+
isExact: true,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const baseTaskPayload = {
|
|
46
|
+
action: '',
|
|
47
|
+
input: [],
|
|
48
|
+
output: {},
|
|
49
|
+
locks: [],
|
|
50
|
+
links: [],
|
|
51
|
+
depends_on: [],
|
|
52
|
+
blocks: [],
|
|
53
|
+
failed_steps: [],
|
|
54
|
+
running_steps: [],
|
|
55
|
+
execution_plan: {},
|
|
56
|
+
state: 'running',
|
|
57
|
+
result: '',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const createStoreForTaskPayload = overrides => ({
|
|
61
|
+
API: {
|
|
62
|
+
[FOREMAN_TASK_DETAILS]: {
|
|
63
|
+
response: { ...baseTaskPayload, ...overrides },
|
|
64
|
+
status: STATUS.RESOLVED,
|
|
65
|
+
payload: {},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const rootReducer = combineReducers({
|
|
71
|
+
API: (state = {}, action) => state,
|
|
72
|
+
intervals: intervalsReducer,
|
|
73
|
+
breadcrumbBar: breadcrumbBarReducer,
|
|
74
|
+
foremanTasks: (state = {}, action) => state,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const renderPage = (apiPayloadOverrides = {}, propsOverrides = {}) => {
|
|
78
|
+
const history = createMemoryHistory({
|
|
79
|
+
initialEntries: [`/foreman_tasks/tasks/${matchDefault.params.id}`],
|
|
80
|
+
});
|
|
81
|
+
const store = configureStore({
|
|
82
|
+
reducer: rootReducer,
|
|
83
|
+
preloadedState: createStoreForTaskPayload(apiPayloadOverrides),
|
|
84
|
+
middleware: getDefaultMiddleware =>
|
|
85
|
+
getDefaultMiddleware({
|
|
86
|
+
immutableCheck: false,
|
|
87
|
+
serializableCheck: false,
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
window.history.pushState(
|
|
92
|
+
{},
|
|
93
|
+
'',
|
|
94
|
+
`/foreman_tasks/tasks/${matchDefault.params.id}`
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return render(
|
|
98
|
+
<Router history={history}>
|
|
99
|
+
<Provider store={store}>
|
|
100
|
+
<IntlProvider locale="en">
|
|
101
|
+
<TaskDetailsPage
|
|
102
|
+
{...routerPropsBase}
|
|
103
|
+
history={history}
|
|
104
|
+
match={matchDefault}
|
|
105
|
+
{...propsOverrides}
|
|
106
|
+
/>
|
|
107
|
+
</IntlProvider>
|
|
108
|
+
</Provider>
|
|
109
|
+
</Router>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const breadcrumbTitleHeadings = () =>
|
|
114
|
+
screen.getAllByRole('heading', { level: 1 }).filter(
|
|
115
|
+
heading => heading.getAttribute('data-ouia-component-id') === 'breadcrumb_title'
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Title row (`customHeader` root `Flex`): same OUIA pattern as `Locks.test.js`.
|
|
120
|
+
*/
|
|
121
|
+
const taskDetailsTitleRegion = container => {
|
|
122
|
+
const el = container.querySelector(
|
|
123
|
+
`[data-ouia-component-id="${TASK_DETAILS_TITLE_ROW_OUIA_ID}"]`
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(el).toBeTruthy();
|
|
127
|
+
|
|
128
|
+
return el;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
describe('TaskDetailsPage', () => {
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
mockUseForemanPermissions.mockImplementation(() => new Set(['view_foreman_tasks']));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('permissions', () => {
|
|
137
|
+
it(`renders the task details chrome when ${VIEW_FOREMAN_TASKS} is present`, () => {
|
|
138
|
+
mockUseForemanPermissions.mockImplementation(
|
|
139
|
+
() => new Set(['edit_foreman_tasks', VIEW_FOREMAN_TASKS])
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
renderPage({ action: 'Run job' });
|
|
143
|
+
|
|
144
|
+
expect(
|
|
145
|
+
screen.getByRole('navigation', { name: 'Breadcrumb' })
|
|
146
|
+
).toBeInTheDocument();
|
|
147
|
+
expect(
|
|
148
|
+
screen.getByRole('heading', {
|
|
149
|
+
level: 1,
|
|
150
|
+
name: /Details of Run job task/,
|
|
151
|
+
})
|
|
152
|
+
).toBeInTheDocument();
|
|
153
|
+
expect(screen.queryByRole('heading', { name: /Permission denied/i })).not.toBeInTheDocument();
|
|
154
|
+
expect(mockUseForemanPermissions).toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it(`shows ResourceLoadFailedEmptyState and lists ${VIEW_FOREMAN_TASKS} when it is absent`, () => {
|
|
158
|
+
mockUseForemanPermissions.mockImplementation(() => new Set());
|
|
159
|
+
|
|
160
|
+
renderPage({ action: 'Hidden task' });
|
|
161
|
+
|
|
162
|
+
expect(
|
|
163
|
+
screen.getByRole('heading', { name: /Permission denied/i })
|
|
164
|
+
).toBeInTheDocument();
|
|
165
|
+
expect(
|
|
166
|
+
screen.getByText(
|
|
167
|
+
/You do not have permission to view the task with id task-route-id/
|
|
168
|
+
)
|
|
169
|
+
).toBeInTheDocument();
|
|
170
|
+
expect(screen.getByText(VIEW_FOREMAN_TASKS)).toBeInTheDocument();
|
|
171
|
+
expect(
|
|
172
|
+
screen.getByRole('button', { name: /Back to tasks/i })
|
|
173
|
+
).toBeInTheDocument();
|
|
174
|
+
expect(
|
|
175
|
+
screen.getByRole('navigation', { name: 'Breadcrumb' })
|
|
176
|
+
).toBeInTheDocument();
|
|
177
|
+
expect(screen.queryByRole('tab', { name: /^task$/i })).not.toBeInTheDocument();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('denies access when user only has edit_foreman_tasks without view', () => {
|
|
181
|
+
mockUseForemanPermissions.mockImplementation(
|
|
182
|
+
() => new Set(['edit_foreman_tasks'])
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
renderPage({});
|
|
186
|
+
|
|
187
|
+
expect(
|
|
188
|
+
screen.getByRole('heading', { name: /Permission denied/i })
|
|
189
|
+
).toBeInTheDocument();
|
|
190
|
+
expect(screen.getByText(VIEW_FOREMAN_TASKS)).toBeInTheDocument();
|
|
191
|
+
expect(
|
|
192
|
+
screen.getByRole('navigation', { name: 'Breadcrumb' })
|
|
193
|
+
).toBeInTheDocument();
|
|
194
|
+
expect(screen.queryByRole('tab', { name: /^task$/i })).not.toBeInTheDocument();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
const expectToolbarHeadingText = substring => {
|
|
200
|
+
const headings = breadcrumbTitleHeadings();
|
|
201
|
+
|
|
202
|
+
expect(headings.length).toBeGreaterThan(0);
|
|
203
|
+
|
|
204
|
+
headings.forEach(heading => {
|
|
205
|
+
expect(heading).toHaveTextContent(substring);
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
it('shows generic title and breadcrumb from route id when action is unset', () => {
|
|
210
|
+
const page = renderPage({});
|
|
211
|
+
expectToolbarHeadingText('Task Details');
|
|
212
|
+
|
|
213
|
+
expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent(
|
|
214
|
+
'Tasks'
|
|
215
|
+
);
|
|
216
|
+
expect(
|
|
217
|
+
within(screen.getByRole('navigation', { name: 'Breadcrumb' })).getByText(
|
|
218
|
+
/task-route-id/
|
|
219
|
+
)
|
|
220
|
+
).toBeInTheDocument();
|
|
221
|
+
|
|
222
|
+
const titleRegion = taskDetailsTitleRegion(page.container);
|
|
223
|
+
|
|
224
|
+
expect(
|
|
225
|
+
within(titleRegion).getAllByRole('img', { hidden: true }).length
|
|
226
|
+
).toBeGreaterThan(0);
|
|
227
|
+
expect(titleRegion.querySelector('[class*="danger"]')).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('uses task action for title and breadcrumb when loaded', () => {
|
|
231
|
+
renderPage({ action: 'Refresh hosts' });
|
|
232
|
+
|
|
233
|
+
expectToolbarHeadingText('Details of Refresh hosts task');
|
|
234
|
+
|
|
235
|
+
expect(screen.getByRole('link', { name: /^Tasks$/ })).toHaveAttribute(
|
|
236
|
+
'href',
|
|
237
|
+
'/foreman_tasks/tasks'
|
|
238
|
+
);
|
|
239
|
+
expect(
|
|
240
|
+
within(screen.getByRole('navigation', { name: 'Breadcrumb' })).getByText(
|
|
241
|
+
'Refresh hosts'
|
|
242
|
+
)
|
|
243
|
+
).toBeInTheDocument();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('shows error status styling in the heading when task is stopped with error result', () => {
|
|
247
|
+
const page = renderPage({
|
|
248
|
+
action: 'Some action',
|
|
249
|
+
state: 'stopped',
|
|
250
|
+
result: 'error',
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expectToolbarHeadingText('Details of Some action task');
|
|
254
|
+
|
|
255
|
+
expect(
|
|
256
|
+
taskDetailsTitleRegion(page.container).querySelector('[class*="danger"]')
|
|
257
|
+
).toBeTruthy();
|
|
258
|
+
|
|
259
|
+
expect(
|
|
260
|
+
within(screen.getByRole('navigation', { name: 'Breadcrumb' })).getByText(
|
|
261
|
+
'Some action'
|
|
262
|
+
)
|
|
263
|
+
).toBeInTheDocument();
|
|
264
|
+
});
|
|
265
|
+
});
|
data/webpack/Routes/routes.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import TasksTableIndexPage from '../ForemanTasks/Components/TasksTable/TasksIndexPage';
|
|
3
|
+
import TaskDetailsPage from '../ForemanTasks/Routes/ShowTaskDetails/TaskDetailsPage';
|
|
3
4
|
|
|
4
5
|
const ForemanTasksRoutes = [
|
|
5
6
|
{
|
|
@@ -7,6 +8,11 @@ const ForemanTasksRoutes = [
|
|
|
7
8
|
exact: true,
|
|
8
9
|
render: props => <TasksTableIndexPage {...props} />,
|
|
9
10
|
},
|
|
11
|
+
{
|
|
12
|
+
path: '/foreman_tasks/tasks/:id',
|
|
13
|
+
exact: true,
|
|
14
|
+
render: props => <TaskDetailsPage {...props} />,
|
|
15
|
+
},
|
|
10
16
|
{
|
|
11
17
|
path: '/foreman_tasks/tasks/:id/sub_tasks',
|
|
12
18
|
exact: true,
|
|
@@ -12,6 +12,14 @@ jest.mock(
|
|
|
12
12
|
}
|
|
13
13
|
);
|
|
14
14
|
|
|
15
|
+
jest.mock(
|
|
16
|
+
'../ForemanTasks/Routes/ShowTaskDetails/TaskDetailsPage',
|
|
17
|
+
() =>
|
|
18
|
+
function TaskDetailsPageStub() {
|
|
19
|
+
return <div data-testid="task-details-page-stub" />;
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
|
|
15
23
|
const routerProps = {
|
|
16
24
|
history: { push: jest.fn(), replace: jest.fn(), go: jest.fn() },
|
|
17
25
|
location: {
|
|
@@ -34,6 +42,7 @@ describe('ForemanTasks routes', () => {
|
|
|
34
42
|
ForemanTasksRoutes.map(({ path, exact }) => ({ path, exact }))
|
|
35
43
|
).toEqual([
|
|
36
44
|
{ path: '/foreman_tasks/tasks', exact: true },
|
|
45
|
+
{ path: '/foreman_tasks/tasks/:id', exact: true },
|
|
37
46
|
{ path: '/foreman_tasks/tasks/:id/sub_tasks', exact: true },
|
|
38
47
|
]);
|
|
39
48
|
});
|
|
@@ -53,9 +62,9 @@ describe('ForemanTasks routes', () => {
|
|
|
53
62
|
...routerProps,
|
|
54
63
|
match: {
|
|
55
64
|
...routerProps.match,
|
|
56
|
-
params: { id: '
|
|
57
|
-
path: '/foreman_tasks/tasks/:id
|
|
58
|
-
url: '/foreman_tasks/tasks/
|
|
65
|
+
params: { id: '99' },
|
|
66
|
+
path: '/foreman_tasks/tasks/:id',
|
|
67
|
+
url: '/foreman_tasks/tasks/99',
|
|
59
68
|
},
|
|
60
69
|
},
|
|
61
70
|
];
|
|
@@ -63,8 +72,10 @@ describe('ForemanTasks routes', () => {
|
|
|
63
72
|
ForemanTasksRoutes.forEach((route, index) => {
|
|
64
73
|
const { unmount } = render(route.render(propsByIndex[index]));
|
|
65
74
|
|
|
66
|
-
if (index ===
|
|
67
|
-
expect(
|
|
75
|
+
if (index === 1) {
|
|
76
|
+
expect(
|
|
77
|
+
screen.getByTestId('task-details-page-stub')
|
|
78
|
+
).toBeInTheDocument();
|
|
68
79
|
} else {
|
|
69
80
|
expect(
|
|
70
81
|
screen.getByTestId('tasks-table-index-stub')
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: foreman-tasks
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 13.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ivan Nečas
|
|
@@ -219,7 +219,6 @@ files:
|
|
|
219
219
|
- app/views/foreman_tasks/tasks/_lock_card.html.erb
|
|
220
220
|
- app/views/foreman_tasks/tasks/dashboard/_latest_tasks_in_error_warning.html.erb
|
|
221
221
|
- app/views/foreman_tasks/tasks/dashboard/_tasks_status.html.erb
|
|
222
|
-
- app/views/foreman_tasks/tasks/show.html.erb
|
|
223
222
|
- app/views/tasks_mailer/long_tasks.html.erb
|
|
224
223
|
- app/views/tasks_mailer/long_tasks.text.erb
|
|
225
224
|
- babel.config.js
|
|
@@ -331,6 +330,7 @@ files:
|
|
|
331
330
|
- test/graphql/queries/tasks_query_test.rb
|
|
332
331
|
- test/helpers/foreman_tasks/foreman_tasks_helper_test.rb
|
|
333
332
|
- test/helpers/foreman_tasks/tasks_helper_test.rb
|
|
333
|
+
- test/integration/tasks_test.rb
|
|
334
334
|
- test/lib/actions/middleware/keep_current_request_id_test.rb
|
|
335
335
|
- test/lib/actions/middleware/keep_current_taxonomies_test.rb
|
|
336
336
|
- test/lib/actions/middleware/keep_current_timezone_test.rb
|
|
@@ -343,6 +343,7 @@ files:
|
|
|
343
343
|
- test/support/dummy_task_group.rb
|
|
344
344
|
- test/support/history_tasks_builder.rb
|
|
345
345
|
- test/tasks/generate_task_actions_test.rb
|
|
346
|
+
- test/test_plugin_helper.rb
|
|
346
347
|
- test/unit/actions/action_with_sub_plans_test.rb
|
|
347
348
|
- test/unit/actions/bulk_action_test.rb
|
|
348
349
|
- test/unit/actions/proxy_action_test.rb
|
|
@@ -398,6 +399,7 @@ files:
|
|
|
398
399
|
- webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.fixtures.js
|
|
399
400
|
- webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.test.js
|
|
400
401
|
- webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetailsActions.test.js
|
|
402
|
+
- webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetailsSelectors.test.js
|
|
401
403
|
- webpack/ForemanTasks/Components/TaskDetails/index.js
|
|
402
404
|
- webpack/ForemanTasks/Components/TasksDashboard/Components/TasksCardsGrid/Components/PausedTasksCard/PausedTasksCard.js
|
|
403
405
|
- webpack/ForemanTasks/Components/TasksDashboard/Components/TasksCardsGrid/Components/PausedTasksCard/PausedTasksCard.test.js
|
|
@@ -471,9 +473,12 @@ files:
|
|
|
471
473
|
- webpack/ForemanTasks/Components/common/ClickConfirmation/index.js
|
|
472
474
|
- webpack/ForemanTasks/Components/common/ToastsHelpers/ToastTypesConstants.js
|
|
473
475
|
- webpack/ForemanTasks/Components/common/ToastsHelpers/index.js
|
|
476
|
+
- webpack/ForemanTasks/Components/common/taskResultIcon.js
|
|
474
477
|
- webpack/ForemanTasks/Components/common/urlHelpers.js
|
|
475
478
|
- webpack/ForemanTasks/ForemanTasksReducers.js
|
|
476
479
|
- webpack/ForemanTasks/ForemanTasksSelectors.js
|
|
480
|
+
- webpack/ForemanTasks/Routes/ShowTaskDetails/TaskDetailsPage.js
|
|
481
|
+
- webpack/ForemanTasks/Routes/ShowTaskDetails/__tests__/TaskDetailsPage.test.js
|
|
477
482
|
- webpack/Routes/routes.js
|
|
478
483
|
- webpack/Routes/routes.test.js
|
|
479
484
|
- webpack/global_index.js
|
|
@@ -519,6 +524,7 @@ test_files:
|
|
|
519
524
|
- test/graphql/queries/tasks_query_test.rb
|
|
520
525
|
- test/helpers/foreman_tasks/foreman_tasks_helper_test.rb
|
|
521
526
|
- test/helpers/foreman_tasks/tasks_helper_test.rb
|
|
527
|
+
- test/integration/tasks_test.rb
|
|
522
528
|
- test/lib/actions/middleware/keep_current_request_id_test.rb
|
|
523
529
|
- test/lib/actions/middleware/keep_current_taxonomies_test.rb
|
|
524
530
|
- test/lib/actions/middleware/keep_current_timezone_test.rb
|
|
@@ -531,6 +537,7 @@ test_files:
|
|
|
531
537
|
- test/support/dummy_task_group.rb
|
|
532
538
|
- test/support/history_tasks_builder.rb
|
|
533
539
|
- test/tasks/generate_task_actions_test.rb
|
|
540
|
+
- test/test_plugin_helper.rb
|
|
534
541
|
- test/unit/actions/action_with_sub_plans_test.rb
|
|
535
542
|
- test/unit/actions/bulk_action_test.rb
|
|
536
543
|
- test/unit/actions/proxy_action_test.rb
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
<% stylesheet 'foreman_tasks/foreman_tasks' %>
|
|
2
|
-
<% content_for(:javascripts) do %>
|
|
3
|
-
<%= webpacked_plugins_js_for :'foreman-tasks' %>
|
|
4
|
-
<% end %>
|
|
5
|
-
<% content_for(:stylesheets) do %>
|
|
6
|
-
<%= webpacked_plugins_css_for :'foreman-tasks' %>
|
|
7
|
-
<% end %>
|
|
8
|
-
|
|
9
|
-
<% title _("Details of %s task") % @task.to_s %>
|
|
10
|
-
|
|
11
|
-
<%= breadcrumbs(
|
|
12
|
-
items: breadcrumb_items,
|
|
13
|
-
name_field: 'action',
|
|
14
|
-
resource_url: foreman_tasks_api_tasks_path,
|
|
15
|
-
switcher_item_url: foreman_tasks_task_path(:id => ':id')
|
|
16
|
-
) %>
|
|
17
|
-
|
|
18
|
-
<%= react_component('TaskDetails') %>
|