foreman-tasks 12.2.4 → 13.1.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/app/services/foreman_tasks/troubleshooting_help_generator.rb +1 -1
- 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/Task.js +8 -70
- data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskButtons.js +115 -104
- data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskButtons.scss +45 -0
- data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskHelper.js +54 -0
- data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskInfo.js +237 -177
- data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskSkeleton.js +1 -6
- data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Task.test.js +27 -35
- data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskButtons.test.js +83 -69
- data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskHelper.test.js +118 -1
- data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskInfo.test.js +190 -20
- data/webpack/ForemanTasks/Components/TaskDetails/ExecutionDetails.js +65 -0
- data/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.js +76 -65
- data/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.scss +1 -44
- data/webpack/ForemanTasks/Components/TaskDetails/TaskDetailsConstants.js +2 -1
- data/webpack/ForemanTasks/Components/TaskDetails/TaskDetailsSelectors.js +20 -10
- data/webpack/ForemanTasks/Components/TaskDetails/__tests__/ExecutionDetails.test.js +194 -0
- data/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.fixtures.js +100 -1
- data/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.test.js +226 -18
- data/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetailsSelectors.test.js +81 -0
- data/webpack/ForemanTasks/Components/common/taskResultIcon.js +53 -0
- data/webpack/ForemanTasks/Routes/ShowTaskDetails/TaskDetailsHeader.js +174 -0
- data/webpack/ForemanTasks/Routes/ShowTaskDetails/TaskDetailsHeader.scss +5 -0
- data/webpack/ForemanTasks/Routes/ShowTaskDetails/TaskDetailsPage.js +51 -0
- data/webpack/ForemanTasks/Routes/ShowTaskDetails/__tests__/TaskDetailsHeader.test.js +142 -0
- data/webpack/ForemanTasks/Routes/ShowTaskDetails/__tests__/TaskDetailsPage.test.js +277 -0
- data/webpack/ForemanTasks/{Components/TaskDetails → Routes/ShowTaskDetails}/index.js +13 -12
- data/webpack/Routes/routes.js +6 -0
- data/webpack/Routes/routes.test.js +16 -5
- data/webpack/index.js +0 -5
- metadata +16 -3
- data/app/views/foreman_tasks/tasks/show.html.erb +0 -18
|
@@ -1,8 +1,107 @@
|
|
|
1
1
|
export const minProps = {
|
|
2
|
+
id: 'a15dd820-32f1-4ced-9ab7-c0fab8234c47',
|
|
2
3
|
cancelStep: jest.fn(),
|
|
3
4
|
taskReload: false,
|
|
4
5
|
taskReloadStop: jest.fn(),
|
|
5
6
|
taskReloadStart: jest.fn(),
|
|
6
7
|
taskProgressToggle: jest.fn(),
|
|
7
|
-
|
|
8
|
+
apiStatus: 'RESOLVED',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Props for snapshots that exercise the Execution details tab alongside the overview. */
|
|
12
|
+
export const taskDetailsWithExecutionTabDefaults = {
|
|
13
|
+
...minProps,
|
|
14
|
+
locks: [
|
|
15
|
+
{
|
|
16
|
+
exclusive: false,
|
|
17
|
+
resource_type: 'User',
|
|
18
|
+
resource_id: 4,
|
|
19
|
+
link: null,
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
links: [],
|
|
23
|
+
executionPlan: { state: 'stopped', cancellable: false },
|
|
24
|
+
failedSteps: [],
|
|
25
|
+
runningSteps: [],
|
|
26
|
+
dependsOn: [],
|
|
27
|
+
blocks: [],
|
|
28
|
+
action: 'Refresh foo',
|
|
29
|
+
state: 'stopped',
|
|
30
|
+
result: 'success',
|
|
31
|
+
progress: 100,
|
|
32
|
+
startAt: '',
|
|
33
|
+
startedAt: '',
|
|
34
|
+
endedAt: '',
|
|
35
|
+
startBefore: '',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const SAMPLE_FAILED_STEP = {
|
|
39
|
+
error: {
|
|
40
|
+
exception_class: 'RuntimeError',
|
|
41
|
+
message:
|
|
42
|
+
'Action Actions::Katello::EventQueue::Monitor is already active',
|
|
43
|
+
backtrace: [
|
|
44
|
+
"/home/example/gems/dynflow-1.2.3/lib/dynflow/action/singleton.rb:15:in `rescue in singleton_lock!'",
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
action_class: 'Actions::Katello::EventQueue::Monitor',
|
|
48
|
+
state: 'error',
|
|
49
|
+
input:
|
|
50
|
+
'{"locale"=>"en",\n "current_request_id"=>nil,\n "current_user_id"=>4}\n',
|
|
51
|
+
output: '{}\n',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const SAMPLE_RUNNING_STEP = {
|
|
55
|
+
cancellable: false,
|
|
56
|
+
id: 1,
|
|
57
|
+
action_class: 'Actions::DynflowExample',
|
|
58
|
+
state: 'running',
|
|
59
|
+
input: '{}',
|
|
60
|
+
output: '{}',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const SAMPLE_DEPENDS_ON = [
|
|
64
|
+
{
|
|
65
|
+
id: '123',
|
|
66
|
+
action: 'Actions::FooBar',
|
|
67
|
+
humanized: 'Foo Bar Action',
|
|
68
|
+
state: 'stopped',
|
|
69
|
+
result: 'success',
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export const fixtureFailedExecutionDetail = {
|
|
74
|
+
...taskDetailsWithExecutionTabDefaults,
|
|
75
|
+
result: 'error',
|
|
76
|
+
state: 'stopped',
|
|
77
|
+
failedSteps: [SAMPLE_FAILED_STEP],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const fixtureRunningExecutionDetail = {
|
|
81
|
+
...taskDetailsWithExecutionTabDefaults,
|
|
82
|
+
state: 'running',
|
|
83
|
+
runningSteps: [SAMPLE_RUNNING_STEP],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const fixtureWithOverviewMessages = {
|
|
87
|
+
...taskDetailsWithExecutionTabDefaults,
|
|
88
|
+
help: '<strong>See logs</strong> for more.',
|
|
89
|
+
output: {
|
|
90
|
+
messages: ['partial output'],
|
|
91
|
+
result: 'error',
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const fixtureWithDependencies = {
|
|
96
|
+
...taskDetailsWithExecutionTabDefaults,
|
|
97
|
+
dependsOn: SAMPLE_DEPENDS_ON,
|
|
98
|
+
blocks: [
|
|
99
|
+
{
|
|
100
|
+
id: '789',
|
|
101
|
+
action: 'Actions::Test',
|
|
102
|
+
humanized: 'Test Action',
|
|
103
|
+
state: 'paused',
|
|
104
|
+
result: 'warning',
|
|
105
|
+
},
|
|
106
|
+
],
|
|
8
107
|
};
|
|
@@ -1,48 +1,256 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
2
3
|
import { render, screen } from '@testing-library/react';
|
|
3
4
|
import '@testing-library/jest-dom';
|
|
5
|
+
import { configureStore } from '@reduxjs/toolkit';
|
|
6
|
+
import { Provider } from 'react-redux';
|
|
7
|
+
import { createMemoryHistory } from 'history';
|
|
8
|
+
import { Router } from 'react-router-dom';
|
|
4
9
|
|
|
5
10
|
import TaskDetails from '../TaskDetails';
|
|
6
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
fixtureFailedExecutionDetail,
|
|
13
|
+
fixtureWithDependencies,
|
|
14
|
+
fixtureWithOverviewMessages,
|
|
15
|
+
minProps,
|
|
16
|
+
taskDetailsWithExecutionTabDefaults,
|
|
17
|
+
} from './TaskDetails.fixtures';
|
|
18
|
+
import { VIEW_FOREMAN_TASKS } from '../TaskDetailsConstants';
|
|
19
|
+
|
|
20
|
+
const mockUseForemanPermissions = jest.fn(
|
|
21
|
+
() => new Set(['view_foreman_tasks'])
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
jest.mock('foremanReact/Root/Context/ForemanContext', () => ({
|
|
25
|
+
...jest.requireActual('foremanReact/Root/Context/ForemanContext'),
|
|
26
|
+
useForemanPermissions: (...args) => mockUseForemanPermissions(...args),
|
|
27
|
+
}));
|
|
7
28
|
|
|
8
29
|
delete window.location;
|
|
9
30
|
window.location = new URL(
|
|
10
31
|
'https://foreman.com/foreman_tasks/tasks/a15dd820-32f1-4ced-9ab7-c0fab8234c47/'
|
|
11
32
|
);
|
|
12
33
|
|
|
34
|
+
const store = configureStore({ reducer: state => state || {} });
|
|
35
|
+
|
|
36
|
+
function renderTaskDetails(props = {}) {
|
|
37
|
+
const history = createMemoryHistory();
|
|
38
|
+
|
|
39
|
+
return render(
|
|
40
|
+
<Router history={history}>
|
|
41
|
+
<Provider store={store}>
|
|
42
|
+
<TaskDetails {...minProps} {...props} />
|
|
43
|
+
</Provider>
|
|
44
|
+
</Router>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
13
48
|
describe('TaskDetails', () => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
{...minProps}
|
|
18
|
-
status="ERROR"
|
|
19
|
-
APIerror={{ message: 'some-error' }}
|
|
20
|
-
/>
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
mockUseForemanPermissions.mockImplementation(
|
|
51
|
+
() => new Set(['view_foreman_tasks'])
|
|
21
52
|
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
jest.clearAllMocks();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it(`shows ResourceLoadFailedEmptyState when ${VIEW_FOREMAN_TASKS} is absent`, () => {
|
|
60
|
+
mockUseForemanPermissions.mockImplementation(() => new Set());
|
|
61
|
+
|
|
62
|
+
renderTaskDetails();
|
|
63
|
+
|
|
64
|
+
expect(
|
|
65
|
+
screen.getByRole('heading', { name: /permission denied/i })
|
|
66
|
+
).toBeInTheDocument();
|
|
67
|
+
expect(screen.getByText(VIEW_FOREMAN_TASKS)).toBeInTheDocument();
|
|
68
|
+
expect(
|
|
69
|
+
screen.getByRole('button', { name: /back to tasks/i })
|
|
70
|
+
).toBeInTheDocument();
|
|
71
|
+
expect(screen.queryByRole('tab', { name: /^task$/i })).not.toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('shows ResourceLoadFailedEmptyState when apiStatus is ERROR', () => {
|
|
75
|
+
renderTaskDetails({
|
|
76
|
+
apiStatus: 'ERROR',
|
|
77
|
+
apiErrorMessage: 'some-error',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(
|
|
81
|
+
screen.getByRole('heading', { name: /unable to load task/i })
|
|
82
|
+
).toBeInTheDocument();
|
|
83
|
+
expect(
|
|
84
|
+
screen.getByText('Server returned: some-error')
|
|
85
|
+
).toBeInTheDocument();
|
|
86
|
+
expect(screen.queryByRole('tab', { name: /^task$/i })).not.toBeInTheDocument();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('shows permission denied when apiErrorCode is 403', () => {
|
|
90
|
+
renderTaskDetails({
|
|
91
|
+
apiStatus: 'ERROR',
|
|
92
|
+
apiErrorCode: 403,
|
|
93
|
+
apiErrorMessage: 'Forbidden',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(
|
|
97
|
+
screen.getByRole('heading', { name: /permission denied/i })
|
|
98
|
+
).toBeInTheDocument();
|
|
99
|
+
expect(screen.getByText('view_foreman_tasks')).toBeInTheDocument();
|
|
100
|
+
expect(
|
|
101
|
+
screen.getByText('Server returned: Forbidden')
|
|
102
|
+
).toBeInTheDocument();
|
|
103
|
+
expect(screen.queryByRole('tab', { name: /^task$/i })).not.toBeInTheDocument();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('shows not found messaging when apiErrorCode is 404', () => {
|
|
107
|
+
renderTaskDetails({
|
|
108
|
+
apiStatus: 'ERROR',
|
|
109
|
+
apiErrorCode: 404,
|
|
110
|
+
apiErrorMessage: 'Task missing',
|
|
111
|
+
});
|
|
112
|
+
|
|
22
113
|
expect(
|
|
23
|
-
screen.
|
|
114
|
+
screen.getByRole('heading', { name: /unable to load task/i })
|
|
24
115
|
).toBeInTheDocument();
|
|
116
|
+
expect(
|
|
117
|
+
screen.getByText(/could not be found/i)
|
|
118
|
+
).toBeInTheDocument();
|
|
119
|
+
expect(
|
|
120
|
+
screen.getByText('Server returned: Task missing')
|
|
121
|
+
).toBeInTheDocument();
|
|
122
|
+
expect(screen.queryByRole('tab', { name: /^task$/i })).not.toBeInTheDocument();
|
|
25
123
|
});
|
|
26
124
|
|
|
27
|
-
it('shows skeleton
|
|
28
|
-
const { container } =
|
|
125
|
+
it('shows skeleton in the overview while loading', () => {
|
|
126
|
+
const { container } = renderTaskDetails({ isLoading: true });
|
|
29
127
|
expect(
|
|
30
128
|
container.querySelector('.react-loading-skeleton')
|
|
31
129
|
).toBeInTheDocument();
|
|
32
130
|
});
|
|
33
131
|
|
|
34
|
-
it('renders
|
|
35
|
-
|
|
36
|
-
expect(document.getElementById('task-details-tabs')).toBeInTheDocument();
|
|
37
|
-
expect(screen.getByRole('tab', { name: /^task$/i })).toBeInTheDocument();
|
|
132
|
+
it('renders four tabs with expected labels', () => {
|
|
133
|
+
renderTaskDetails();
|
|
38
134
|
expect(
|
|
39
|
-
screen.getByRole('tab', { name: /
|
|
135
|
+
screen.getByRole('tab', { name: /execution details/i })
|
|
40
136
|
).toBeInTheDocument();
|
|
41
|
-
expect(screen.getByRole('tab', { name: /errors/i })).toBeInTheDocument();
|
|
42
|
-
expect(screen.getByRole('tab', { name: /locks/i })).toBeInTheDocument();
|
|
43
137
|
expect(
|
|
44
138
|
screen.getByRole('tab', { name: /dependencies/i })
|
|
45
139
|
).toBeInTheDocument();
|
|
140
|
+
expect(screen.getByRole('tab', { name: /locks/i })).toBeInTheDocument();
|
|
46
141
|
expect(screen.getByRole('tab', { name: /raw/i })).toBeInTheDocument();
|
|
47
142
|
});
|
|
143
|
+
|
|
144
|
+
it('exposes stable ouiaIds on tabs for automation', () => {
|
|
145
|
+
renderTaskDetails();
|
|
146
|
+
|
|
147
|
+
expect(
|
|
148
|
+
document.querySelector(
|
|
149
|
+
'[data-ouia-component-id="task-details-tab-execution-details"]'
|
|
150
|
+
)
|
|
151
|
+
).toBeInTheDocument();
|
|
152
|
+
expect(
|
|
153
|
+
document.querySelector(
|
|
154
|
+
'[data-ouia-component-id="task-details-tab-dependencies"]'
|
|
155
|
+
)
|
|
156
|
+
).toBeInTheDocument();
|
|
157
|
+
expect(
|
|
158
|
+
document.querySelector(
|
|
159
|
+
'[data-ouia-component-id="task-details-tab-locks"]'
|
|
160
|
+
)
|
|
161
|
+
).toBeInTheDocument();
|
|
162
|
+
expect(
|
|
163
|
+
document.querySelector('[data-ouia-component-id="task-details-tab-raw"]')
|
|
164
|
+
).toBeInTheDocument();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('shows failed step errors on the default execution details tab', () => {
|
|
168
|
+
renderTaskDetails({
|
|
169
|
+
...fixtureFailedExecutionDetail,
|
|
170
|
+
executionPlan: fixtureFailedExecutionDetail.executionPlan,
|
|
171
|
+
failedSteps: fixtureFailedExecutionDetail.failedSteps,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(
|
|
175
|
+
screen.getByRole('tab', {
|
|
176
|
+
name: /action actions::katello::eventqueue::monitor is already active/i,
|
|
177
|
+
})
|
|
178
|
+
).toBeInTheDocument();
|
|
179
|
+
expect(screen.getByLabelText(/failed task errors/i)).toBeInTheDocument();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('starts task reload on mount and stops on unmount', () => {
|
|
183
|
+
const taskReloadStart = jest.fn();
|
|
184
|
+
const taskReloadStop = jest.fn();
|
|
185
|
+
|
|
186
|
+
const { unmount } = renderTaskDetails({
|
|
187
|
+
...taskDetailsWithExecutionTabDefaults,
|
|
188
|
+
taskReloadStart,
|
|
189
|
+
taskReloadStop,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(taskReloadStart).toHaveBeenCalledWith(
|
|
193
|
+
'a15dd820-32f1-4ced-9ab7-c0fab8234c47'
|
|
194
|
+
);
|
|
195
|
+
unmount();
|
|
196
|
+
expect(taskReloadStop).toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('shows execution details panel on the default tab when loaded', () => {
|
|
200
|
+
renderTaskDetails({ ...taskDetailsWithExecutionTabDefaults });
|
|
201
|
+
|
|
202
|
+
expect(
|
|
203
|
+
screen.getByRole('heading', { name: /^no errors found$/i })
|
|
204
|
+
).toBeInTheDocument();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('disables tabs and hides execution panel while loading', () => {
|
|
208
|
+
renderTaskDetails({
|
|
209
|
+
...taskDetailsWithExecutionTabDefaults,
|
|
210
|
+
isLoading: true,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(
|
|
214
|
+
screen.queryByRole('heading', { name: /^no errors found$/i })
|
|
215
|
+
).not.toBeInTheDocument();
|
|
216
|
+
expect(
|
|
217
|
+
screen.getByRole('tab', { name: /execution details/i })
|
|
218
|
+
).toBeDisabled();
|
|
219
|
+
expect(screen.getByRole('tab', { name: /dependencies/i })).toBeDisabled();
|
|
220
|
+
expect(screen.getByRole('tab', { name: /locks/i })).toBeDisabled();
|
|
221
|
+
expect(screen.getByRole('tab', { name: /raw/i })).toBeDisabled();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('renders troubleshooting help in overview after expanding details', async () => {
|
|
225
|
+
renderTaskDetails({ ...fixtureWithOverviewMessages });
|
|
226
|
+
|
|
227
|
+
await userEvent.click(
|
|
228
|
+
screen.getByRole('button', { name: /show more details/i })
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(screen.getByText('Troubleshooting')).toBeInTheDocument();
|
|
232
|
+
expect(screen.getByText('See logs')).toBeInTheDocument();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('renders raw output when the Raw tab is selected', async () => {
|
|
236
|
+
renderTaskDetails({ ...fixtureWithOverviewMessages });
|
|
237
|
+
|
|
238
|
+
await userEvent.click(screen.getByRole('tab', { name: /raw/i }));
|
|
239
|
+
|
|
240
|
+
expect(screen.getByText(/raw output/i)).toBeInTheDocument();
|
|
241
|
+
expect(screen.getByText(/partial output/i)).toBeInTheDocument();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('renders dependency tables when the Dependencies tab is selected', async () => {
|
|
245
|
+
renderTaskDetails({ ...fixtureWithDependencies });
|
|
246
|
+
|
|
247
|
+
await userEvent.click(screen.getByRole('tab', { name: /dependencies/i }));
|
|
248
|
+
|
|
249
|
+
expect(
|
|
250
|
+
screen.getByRole('link', { name: 'Foo Bar Action' })
|
|
251
|
+
).toBeInTheDocument();
|
|
252
|
+
expect(
|
|
253
|
+
screen.getByRole('link', { name: 'Test Action' })
|
|
254
|
+
).toBeInTheDocument();
|
|
255
|
+
});
|
|
48
256
|
});
|
|
@@ -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
|
+
});
|
|
@@ -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,174 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import PropTypes from 'prop-types';
|
|
3
|
+
import {
|
|
4
|
+
Flex,
|
|
5
|
+
FlexItem,
|
|
6
|
+
Split,
|
|
7
|
+
SplitItem,
|
|
8
|
+
Title,
|
|
9
|
+
} from '@patternfly/react-core';
|
|
10
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
11
|
+
import { STATUS } from 'foremanReact/constants';
|
|
12
|
+
import { usePermissions } from 'foremanReact/common/hooks/Permissions/permissionHooks';
|
|
13
|
+
import { TaskButtons } from '../../Components/TaskDetails/Components/TaskButtons';
|
|
14
|
+
import { VIEW_FOREMAN_TASKS } from '../../Components/TaskDetails/TaskDetailsConstants';
|
|
15
|
+
import {
|
|
16
|
+
ForceUnlockConfirmationModal,
|
|
17
|
+
UnlockConfirmationModal,
|
|
18
|
+
} from '../../Components/common/ClickConfirmation';
|
|
19
|
+
import { taskResultIconEl } from '../../Components/common/taskResultIcon';
|
|
20
|
+
import './TaskDetailsHeader.scss';
|
|
21
|
+
|
|
22
|
+
const TitleComponent = ({ action, state, result }) => (
|
|
23
|
+
<Flex
|
|
24
|
+
alignItems={{ default: 'alignItemsCenter' }}
|
|
25
|
+
spaceItems={{ default: 'spaceItemsXs' }}
|
|
26
|
+
>
|
|
27
|
+
<FlexItem>
|
|
28
|
+
<Title
|
|
29
|
+
headingLevel="h1"
|
|
30
|
+
className="foreman-tasks-task-details-header__title"
|
|
31
|
+
ouiaId="task-overview-title"
|
|
32
|
+
>
|
|
33
|
+
{action || __('Task Details')}
|
|
34
|
+
</Title>
|
|
35
|
+
</FlexItem>
|
|
36
|
+
<FlexItem>{taskResultIconEl(state, result)}</FlexItem>
|
|
37
|
+
</Flex>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
TitleComponent.propTypes = {
|
|
41
|
+
action: PropTypes.string,
|
|
42
|
+
state: PropTypes.string,
|
|
43
|
+
result: PropTypes.string,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
TitleComponent.defaultProps = {
|
|
47
|
+
action: '',
|
|
48
|
+
state: '',
|
|
49
|
+
result: '',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const TaskDetailsHeader = props => {
|
|
53
|
+
const {
|
|
54
|
+
taskReload,
|
|
55
|
+
id,
|
|
56
|
+
forceCancelTaskRequest,
|
|
57
|
+
unlockTaskRequest,
|
|
58
|
+
action,
|
|
59
|
+
taskReloadStart,
|
|
60
|
+
taskReloadStop,
|
|
61
|
+
executionPlan,
|
|
62
|
+
apiStatus,
|
|
63
|
+
} = props;
|
|
64
|
+
const hasViewPermission = usePermissions([VIEW_FOREMAN_TASKS]);
|
|
65
|
+
const showActions = hasViewPermission && apiStatus !== STATUS.ERROR;
|
|
66
|
+
const resumable = executionPlan ? executionPlan.state === 'paused' : false;
|
|
67
|
+
const cancellable = executionPlan ? executionPlan.cancellable : false;
|
|
68
|
+
|
|
69
|
+
const forceUnlock = () => {
|
|
70
|
+
if (!taskReload) {
|
|
71
|
+
taskReloadStart(id);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
forceCancelTaskRequest(id, action);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const unlock = () => {
|
|
78
|
+
if (!taskReload) {
|
|
79
|
+
taskReloadStart(id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
unlockTaskRequest(id, action);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const taskProgressToggle = () => {
|
|
86
|
+
if (taskReload) {
|
|
87
|
+
taskReloadStop();
|
|
88
|
+
} else {
|
|
89
|
+
taskReloadStart(id);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const [unlockModalOpen, setUnlockModalOpen] = useState(false);
|
|
94
|
+
const [forceUnlockModalOpen, setForceUnlockModalOpen] = useState(false);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<React.Fragment>
|
|
98
|
+
{showActions && (
|
|
99
|
+
<>
|
|
100
|
+
<UnlockConfirmationModal
|
|
101
|
+
onClick={unlock}
|
|
102
|
+
isOpen={unlockModalOpen}
|
|
103
|
+
setModalClosed={() => setUnlockModalOpen(false)}
|
|
104
|
+
/>
|
|
105
|
+
<ForceUnlockConfirmationModal
|
|
106
|
+
onClick={forceUnlock}
|
|
107
|
+
isOpen={forceUnlockModalOpen}
|
|
108
|
+
setModalClosed={() => setForceUnlockModalOpen(false)}
|
|
109
|
+
/>
|
|
110
|
+
</>
|
|
111
|
+
)}
|
|
112
|
+
<Split
|
|
113
|
+
className="foreman-tasks-task-details-header"
|
|
114
|
+
hasGutter
|
|
115
|
+
isWrappable
|
|
116
|
+
data-ouia-component-id="foreman-tasks-task-details-title-row"
|
|
117
|
+
>
|
|
118
|
+
<SplitItem isFilled>
|
|
119
|
+
<TitleComponent
|
|
120
|
+
action={props.action}
|
|
121
|
+
state={props.state}
|
|
122
|
+
result={props.result}
|
|
123
|
+
/>
|
|
124
|
+
</SplitItem>
|
|
125
|
+
{showActions && (
|
|
126
|
+
<SplitItem>
|
|
127
|
+
<TaskButtons
|
|
128
|
+
{...props}
|
|
129
|
+
taskReloadStart={taskReloadStart}
|
|
130
|
+
taskProgressToggle={taskProgressToggle}
|
|
131
|
+
setUnlockModalOpen={setUnlockModalOpen}
|
|
132
|
+
setForceUnlockModalOpen={setForceUnlockModalOpen}
|
|
133
|
+
resumable={resumable}
|
|
134
|
+
cancellable={cancellable}
|
|
135
|
+
/>
|
|
136
|
+
</SplitItem>
|
|
137
|
+
)}
|
|
138
|
+
</Split>
|
|
139
|
+
</React.Fragment>
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
TaskDetailsHeader.propTypes = {
|
|
144
|
+
taskReload: PropTypes.bool,
|
|
145
|
+
id: PropTypes.string,
|
|
146
|
+
forceCancelTaskRequest: PropTypes.func,
|
|
147
|
+
unlockTaskRequest: PropTypes.func,
|
|
148
|
+
action: PropTypes.string,
|
|
149
|
+
state: PropTypes.string,
|
|
150
|
+
result: PropTypes.string,
|
|
151
|
+
taskReloadStart: PropTypes.func,
|
|
152
|
+
taskReloadStop: PropTypes.func,
|
|
153
|
+
executionPlan: PropTypes.shape({
|
|
154
|
+
state: PropTypes.string,
|
|
155
|
+
cancellable: PropTypes.bool,
|
|
156
|
+
}),
|
|
157
|
+
apiStatus: PropTypes.oneOf(Object.keys(STATUS)),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
TaskDetailsHeader.defaultProps = {
|
|
161
|
+
taskReload: false,
|
|
162
|
+
id: '',
|
|
163
|
+
forceCancelTaskRequest: () => null,
|
|
164
|
+
unlockTaskRequest: () => null,
|
|
165
|
+
action: '',
|
|
166
|
+
state: '',
|
|
167
|
+
result: '',
|
|
168
|
+
taskReloadStart: () => null,
|
|
169
|
+
taskReloadStop: () => null,
|
|
170
|
+
executionPlan: {},
|
|
171
|
+
apiStatus: STATUS.RESOLVED,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export default TaskDetailsHeader;
|