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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/foreman_tasks/tasks_controller.rb +0 -5
  3. data/app/services/foreman_tasks/troubleshooting_help_generator.rb +1 -1
  4. data/config/routes.rb +3 -2
  5. data/lib/foreman_tasks/engine.rb +2 -2
  6. data/lib/foreman_tasks/version.rb +1 -1
  7. data/test/controllers/tasks_controller_test.rb +0 -9
  8. data/test/foreman_tasks_test_helper.rb +2 -2
  9. data/test/integration/tasks_test.rb +17 -0
  10. data/test/test_plugin_helper.rb +8 -0
  11. data/webpack/ForemanTasks/Components/TaskDetails/Components/Task.js +8 -70
  12. data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskButtons.js +115 -104
  13. data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskButtons.scss +45 -0
  14. data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskHelper.js +54 -0
  15. data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskInfo.js +237 -177
  16. data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskSkeleton.js +1 -6
  17. data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Task.test.js +27 -35
  18. data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskButtons.test.js +83 -69
  19. data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskHelper.test.js +118 -1
  20. data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskInfo.test.js +190 -20
  21. data/webpack/ForemanTasks/Components/TaskDetails/ExecutionDetails.js +65 -0
  22. data/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.js +76 -65
  23. data/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.scss +1 -44
  24. data/webpack/ForemanTasks/Components/TaskDetails/TaskDetailsConstants.js +2 -1
  25. data/webpack/ForemanTasks/Components/TaskDetails/TaskDetailsSelectors.js +20 -10
  26. data/webpack/ForemanTasks/Components/TaskDetails/__tests__/ExecutionDetails.test.js +194 -0
  27. data/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.fixtures.js +100 -1
  28. data/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.test.js +226 -18
  29. data/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetailsSelectors.test.js +81 -0
  30. data/webpack/ForemanTasks/Components/common/taskResultIcon.js +53 -0
  31. data/webpack/ForemanTasks/Routes/ShowTaskDetails/TaskDetailsHeader.js +174 -0
  32. data/webpack/ForemanTasks/Routes/ShowTaskDetails/TaskDetailsHeader.scss +5 -0
  33. data/webpack/ForemanTasks/Routes/ShowTaskDetails/TaskDetailsPage.js +51 -0
  34. data/webpack/ForemanTasks/Routes/ShowTaskDetails/__tests__/TaskDetailsHeader.test.js +142 -0
  35. data/webpack/ForemanTasks/Routes/ShowTaskDetails/__tests__/TaskDetailsPage.test.js +277 -0
  36. data/webpack/ForemanTasks/{Components/TaskDetails → Routes/ShowTaskDetails}/index.js +13 -12
  37. data/webpack/Routes/routes.js +6 -0
  38. data/webpack/Routes/routes.test.js +16 -5
  39. data/webpack/index.js +0 -5
  40. metadata +16 -3
  41. data/app/views/foreman_tasks/tasks/show.html.erb +0 -18
@@ -1,76 +1,40 @@
1
- import React from 'react';
1
+ import React, { useState } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import {
4
+ ClipboardCopy,
5
+ ExpandableSection,
6
+ ExpandableSectionToggle,
7
+ Flex,
8
+ FlexItem,
4
9
  Grid,
5
10
  GridItem,
6
11
  Progress,
7
12
  ProgressVariant,
8
- Icon,
13
+ Split,
14
+ SplitItem,
15
+ Text,
16
+ TextVariants,
9
17
  } from '@patternfly/react-core';
10
- import {
11
- CheckCircleIcon,
12
- ExclamationCircleIcon,
13
- ExclamationTriangleIcon,
14
- QuestionCircleIcon,
15
- } from '@patternfly/react-icons';
16
- import { translate as __ } from 'foremanReact/common/I18n';
18
+ import capitalize from 'lodash/capitalize';
19
+ import { translate as __, sprintf } from 'foremanReact/common/I18n';
17
20
  import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime';
18
21
 
19
- const isDelayed = ({ startAt, startedAt }) => {
20
- if (
21
- startAt == null ||
22
- startedAt == null ||
23
- startAt === '' ||
24
- startedAt === ''
25
- ) {
26
- return false;
27
- }
28
- const a = new Date(startAt);
29
- const b = new Date(startedAt);
30
- if (Number.isNaN(a.getTime()) || Number.isNaN(b.getTime())) {
31
- return false;
32
- }
33
- a.setMilliseconds(0);
34
- b.setMilliseconds(0);
35
- return a.getTime() !== b.getTime();
36
- };
22
+ import { taskResultIconEl } from '../../common/taskResultIcon';
23
+ import {
24
+ formatTaskDuration,
25
+ isDelayed,
26
+ parseUsernameLinkHref,
27
+ } from './TaskHelper';
37
28
 
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
- );
29
+ const formatResultLabel = result => {
30
+ if (!result || typeof result !== 'string') {
31
+ return __('N/A');
70
32
  }
33
+
34
+ return capitalize(result);
71
35
  };
72
36
 
73
- const progressVariantForResult = result => {
37
+ const progressVariant = result => {
74
38
  switch (result) {
75
39
  case 'error':
76
40
  return ProgressVariant.danger;
@@ -83,140 +47,234 @@ const progressVariantForResult = result => {
83
47
  }
84
48
  };
85
49
 
50
+ const MetadataField = ({ title, value, className, labelOuiaId, span }) => (
51
+ <GridItem
52
+ {...(span ? { span } : { xl: 2, lg: 4, md: 6, sm: 12 })}
53
+ className={className}
54
+ >
55
+ <Flex
56
+ direction={{ default: 'column' }}
57
+ spaceItems={{ default: 'spaceItemsXs' }}
58
+ >
59
+ <FlexItem>
60
+ <Text ouiaId={labelOuiaId} component={TextVariants.small}>
61
+ {title}
62
+ </Text>
63
+ </FlexItem>
64
+ <FlexItem>{value}</FlexItem>
65
+ </Flex>
66
+ </GridItem>
67
+ );
68
+
69
+ MetadataField.propTypes = {
70
+ title: PropTypes.node.isRequired,
71
+ value: PropTypes.node.isRequired,
72
+ className: PropTypes.string,
73
+ labelOuiaId: PropTypes.string.isRequired,
74
+ span: PropTypes.number,
75
+ };
76
+
77
+ MetadataField.defaultProps = {
78
+ className: undefined,
79
+ span: undefined,
80
+ };
81
+
82
+ const copyableText = text =>
83
+ text ? (
84
+ <ClipboardCopy
85
+ variant="inline-compact"
86
+ hoverTip={__('Copy')}
87
+ clickTip={__('Copied')}
88
+ >
89
+ {text}
90
+ </ClipboardCopy>
91
+ ) : (
92
+ __('N/A')
93
+ );
94
+
86
95
  const TaskInfo = props => {
87
96
  const {
88
97
  action,
89
98
  endedAt,
99
+ externalId,
100
+ id,
101
+ label,
90
102
  result,
91
103
  startAt,
92
- startBefore,
93
104
  startedAt,
105
+ startBefore,
94
106
  state,
95
107
  help,
96
- output,
97
- errors,
98
108
  progress,
99
109
  username,
100
110
  usernamePath,
101
111
  } = props;
102
112
 
103
- const details = [
104
- [
105
- {
106
- title: 'Name',
107
- value: action || __('N/A'),
108
- className: 'details-name',
109
- },
110
- {
111
- title: 'Start at',
112
- value: <RelativeDateTime defaultValue={__('N/A')} date={startAt} />,
113
- },
114
- ],
115
- [
116
- {
117
- title: 'Result',
118
- value: (
119
- <span>
120
- {resultIconEl(state, result)} {result}
121
- </span>
122
- ),
123
- },
124
- {
125
- title: 'Started at',
126
- value: <RelativeDateTime defaultValue={__('N/A')} date={startedAt} />,
127
- },
128
- ],
129
- [
130
- {
131
- title: 'Triggered by',
132
- value: (usernamePath || '').includes('href') ? (
133
- <a href={usernamePath.split('"')[1]}>{username}</a>
134
- ) : (
135
- username || ''
136
- ),
137
- },
138
- {
139
- title: 'Ended at',
140
- value: <RelativeDateTime defaultValue={__('N/A')} date={endedAt} />,
141
- },
142
- ],
143
- [
144
- {
145
- title: 'Execution type',
146
- value: __(isDelayed(props) ? 'Delayed' : 'Immediate'),
147
- },
148
- {
149
- title: 'Start before',
150
- value: startBefore ? (
151
- <RelativeDateTime defaultValue={__('N/A')} date={startBefore} />
152
- ) : (
153
- '-'
154
- ),
155
- },
156
- ],
157
- ];
113
+ const [isExpanded, setIsExpanded] = useState(false);
114
+ const baseId = id || 'task-info';
115
+ const contentId = `${baseId}-more-details-content`;
116
+ const toggleId = `${baseId}-more-details-toggle`;
117
+
118
+ const usernameHref = parseUsernameLinkHref(usernamePath)?.startsWith('/')
119
+ ? parseUsernameLinkHref(usernamePath)
120
+ : null;
121
+ const triggeredBy = usernameHref ? (
122
+ <a href={usernameHref}>{username}</a>
123
+ ) : (
124
+ username || ''
125
+ );
158
126
 
159
- const progVariant = progressVariantForResult(result);
127
+ const progVariant = progressVariant(result);
160
128
 
161
129
  return (
162
- <Grid>
163
- <GridItem span={12} className="pf-v5-u-pb-lg" />
164
- {details.map((items, key) => (
165
- <React.Fragment key={key}>
166
- <GridItem md={2} sm={6}>
167
- <span className="list-group-item-heading">
168
- {__(items[0].title)}:
169
- </span>
170
- </GridItem>
171
- <GridItem md={5} sm={6} className={items[0].className}>
172
- <span>{items[0].value}</span>
173
- </GridItem>
174
- <GridItem md={2} sm={6}>
175
- <span className="list-group-item-heading">
176
- {__(items[1].title)}:
177
- </span>
178
- </GridItem>
179
- <GridItem md={3} sm={6} className={items[1].className}>
180
- {items[1].value}
181
- </GridItem>
182
- </React.Fragment>
183
- ))}
184
- <GridItem span={12} className="pf-v5-u-pb-lg" />
185
- <GridItem span={6}>
186
- <div className="progress-description">
187
- <span className="list-group-item-heading">{__('State')}: </span>
188
- {state}
189
- </div>
190
- </GridItem>
191
- <GridItem span={6} className="progress-label-top-right">
192
- <span>{`${progress}% ${__('Complete')}`}</span>
130
+ <Grid className="task-details-overview" hasGutter>
131
+ <MetadataField
132
+ labelOuiaId="task-info-metadata-result-label"
133
+ title={__('Result')}
134
+ value={
135
+ <>
136
+ {taskResultIconEl(state, result)}
137
+ <span> {formatResultLabel(result)}</span>
138
+ </>
139
+ }
140
+ />
141
+ <MetadataField
142
+ labelOuiaId="task-info-metadata-state-label"
143
+ title={__('State')}
144
+ value={capitalize(state) || __('N/A')}
145
+ />
146
+ <MetadataField
147
+ labelOuiaId="task-info-metadata-started-at-label"
148
+ title={__('Started at')}
149
+ value={<RelativeDateTime defaultValue={__('N/A')} date={startedAt} />}
150
+ />
151
+ <MetadataField
152
+ labelOuiaId="task-info-metadata-duration-label"
153
+ title={__('Duration')}
154
+ value={formatTaskDuration(startedAt, endedAt, state)}
155
+ />
156
+ <MetadataField
157
+ labelOuiaId="task-info-metadata-triggered-by-label"
158
+ title={__('Triggered by')}
159
+ value={triggeredBy}
160
+ />
161
+ <MetadataField
162
+ labelOuiaId="task-info-metadata-id-label"
163
+ title={__('Id')}
164
+ value={copyableText(id)}
165
+ />
166
+
167
+ <GridItem span={12}>
168
+ <ExpandableSectionToggle
169
+ toggleId={toggleId}
170
+ contentId={contentId}
171
+ isExpanded={isExpanded}
172
+ onToggle={setIsExpanded}
173
+ >
174
+ {isExpanded ? __('Show less details') : __('Show more details')}
175
+ </ExpandableSectionToggle>
193
176
  </GridItem>
177
+
194
178
  <GridItem span={12}>
195
- <Progress
196
- value={progress}
197
- max={100}
198
- aria-label={`${progress}% ${__('Complete')}`}
199
- measureLocation="outside"
200
- {...(progVariant ? { variant: progVariant } : {})}
201
- />
179
+ <ExpandableSection
180
+ isExpanded={isExpanded}
181
+ isDetached
182
+ toggleId={toggleId}
183
+ contentId={contentId}
184
+ >
185
+ <Grid hasGutter>
186
+ <MetadataField
187
+ labelOuiaId="task-info-metadata-execution-type-label"
188
+ title={__('Execution type')}
189
+ value={__(isDelayed(props) ? 'Delayed' : 'Immediate')}
190
+ />
191
+ <MetadataField
192
+ labelOuiaId="task-info-metadata-label-field-label"
193
+ title={__('Label')}
194
+ value={label || action || __('N/A')}
195
+ className="details-name"
196
+ />
197
+ <MetadataField
198
+ labelOuiaId="task-info-metadata-start-at-label"
199
+ title={__('Start at')}
200
+ value={
201
+ <Flex direction={{ default: 'column' }}>
202
+ <FlexItem>
203
+ <RelativeDateTime defaultValue={__('N/A')} date={startAt} />
204
+ </FlexItem>
205
+ {startBefore && (
206
+ <FlexItem>
207
+ <span>({__('before')} </span>
208
+ <RelativeDateTime
209
+ defaultValue={__('N/A')}
210
+ date={startBefore}
211
+ />
212
+ <span>)</span>
213
+ </FlexItem>
214
+ )}
215
+ </Flex>
216
+ }
217
+ />
218
+ <MetadataField
219
+ labelOuiaId="task-info-metadata-ended-at-label"
220
+ title={__('Ended at')}
221
+ value={
222
+ <RelativeDateTime defaultValue={__('N/A')} date={endedAt} />
223
+ }
224
+ />
225
+ <MetadataField
226
+ labelOuiaId="task-info-metadata-external-id-label"
227
+ title={__('External Id')}
228
+ value={copyableText(externalId)}
229
+ />
230
+ {help && (
231
+ <MetadataField
232
+ labelOuiaId="task-info-metadata-help-text-label"
233
+ title={__('Troubleshooting')}
234
+ span={12}
235
+ value={<p dangerouslySetInnerHTML={{ __html: help }} />}
236
+ />
237
+ )}
238
+ </Grid>
239
+ </ExpandableSection>
202
240
  </GridItem>
203
- <GridItem span={12} className="pf-v5-u-pb-lg" />
204
- {help && (
205
- <GridItem span={12}>
206
- <b>{__('Troubleshooting')}</b>
207
- <p dangerouslySetInnerHTML={{ __html: help }} />
208
- </GridItem>
209
- )}
210
- {output && output.length > 0 && (
211
- <GridItem span={12}>
212
- <b>{__('Output:')}</b>
213
- <pre>{output}</pre>
214
- </GridItem>
215
- )}
216
- {errors && errors.length > 0 && (
241
+
242
+ {state !== 'stopped' && (
217
243
  <GridItem span={12}>
218
- <b>{__('Errors:')}</b>
219
- <pre>{errors}</pre>
244
+ <Flex
245
+ direction={{ default: 'column' }}
246
+ spaceItems={{ default: 'spaceItemsXs' }}
247
+ >
248
+ <FlexItem>
249
+ <Split hasGutter>
250
+ <SplitItem isFilled>
251
+ <Text
252
+ component={TextVariants.p}
253
+ ouiaId="task-info-running-state-summary"
254
+ >
255
+ {capitalize(state)}
256
+ </Text>
257
+ </SplitItem>
258
+ <SplitItem>
259
+ <Text
260
+ component={TextVariants.p}
261
+ ouiaId="task-info-running-progress-complete-text"
262
+ >
263
+ {sprintf(__('%s%% Complete'), progress)}
264
+ </Text>
265
+ </SplitItem>
266
+ </Split>
267
+ </FlexItem>
268
+ <FlexItem>
269
+ <Progress
270
+ value={progress}
271
+ max={100}
272
+ aria-label={sprintf(__('%s%% Complete'), progress)}
273
+ measureLocation="outside"
274
+ {...(progVariant ? { variant: progVariant } : {})}
275
+ />
276
+ </FlexItem>
277
+ </Flex>
220
278
  </GridItem>
221
279
  )}
222
280
  </Grid>
@@ -226,33 +284,35 @@ const TaskInfo = props => {
226
284
  TaskInfo.propTypes = {
227
285
  action: PropTypes.string,
228
286
  endedAt: PropTypes.string,
287
+ externalId: PropTypes.string,
288
+ id: PropTypes.string,
289
+ label: PropTypes.string,
229
290
  result: PropTypes.string,
230
291
  startAt: PropTypes.string,
231
292
  startBefore: PropTypes.string,
232
293
  startedAt: PropTypes.string,
233
294
  state: PropTypes.string,
234
295
  help: PropTypes.string,
235
- errors: PropTypes.array,
236
296
  progress: PropTypes.number,
237
297
  username: PropTypes.string,
238
298
  usernamePath: PropTypes.string,
239
- output: PropTypes.oneOfType([PropTypes.string, PropTypes.shape({})]),
240
299
  };
241
300
 
242
301
  TaskInfo.defaultProps = {
243
302
  action: '',
244
303
  endedAt: '',
304
+ externalId: '',
305
+ id: '',
306
+ label: '',
245
307
  result: 'error',
246
308
  startAt: '',
247
309
  startBefore: '',
248
310
  startedAt: '',
249
311
  state: '',
250
312
  help: '',
251
- errors: [],
252
313
  progress: 0,
253
314
  username: '',
254
315
  usernamePath: '',
255
- output: '',
256
316
  };
257
317
 
258
318
  export default TaskInfo;
@@ -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={6}>
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,46 +1,38 @@
1
1
  import React from 'react';
2
2
  import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
3
4
  import '@testing-library/jest-dom';
4
- import { STATUS } from 'foremanReact/constants';
5
5
 
6
6
  import Task from '../Task';
7
7
 
8
+ jest.mock('foremanReact/components/common/dates/RelativeDateTime', () => {
9
+ const RelativeDateTime = ({ date, defaultValue }) => (
10
+ <span>{date || defaultValue}</span>
11
+ );
12
+
13
+ return RelativeDateTime;
14
+ });
15
+
8
16
  describe('Task', () => {
9
- it('renders task controls from TaskButtons with minimal props', () => {
10
- render(
11
- <Task
12
- id="test"
13
- taskReloadStart={jest.fn()}
14
- taskProgressToggle={jest.fn()}
15
- />
16
- );
17
- expect(
18
- screen.getByRole('button', { name: /start auto-reloading/i })
19
- ).toBeInTheDocument();
20
- });
17
+ it('renders task overview metadata via TaskInfo', async () => {
18
+ render(<Task id="test" action="Refresh hosts" />);
21
19
 
22
- it('renders parent task and sub tasks links when provided', () => {
23
- render(
24
- <Task
25
- id="test"
26
- state="paused"
27
- hasSubTasks
28
- dynflowEnableConsole
29
- parentTask="parent-id"
30
- taskReload
31
- canEdit
32
- status={STATUS.RESOLVED}
33
- taskProgressToggle={jest.fn()}
34
- taskReloadStart={jest.fn()}
35
- />
36
- );
37
- expect(screen.getByRole('link', { name: /parent task/i })).toHaveAttribute(
38
- 'href',
39
- '/foreman_tasks/tasks/parent-id'
40
- );
41
- expect(screen.getByRole('link', { name: /sub tasks/i })).toHaveAttribute(
42
- 'href',
43
- '/foreman_tasks/tasks/test/sub_tasks'
20
+ expect(screen.getByText('Result')).toBeInTheDocument();
21
+ expect(screen.getByText('State')).toBeInTheDocument();
22
+ expect(screen.getByText('test')).toBeInTheDocument();
23
+
24
+ await userEvent.click(
25
+ screen.getByRole('button', { name: /show more details/i })
44
26
  );
27
+
28
+ expect(screen.getByText('Refresh hosts')).toBeInTheDocument();
29
+ expect(screen.getByText('Label')).toBeInTheDocument();
30
+ expect(screen.getByText('Execution type')).toBeInTheDocument();
31
+ expect(
32
+ screen.queryByRole('button', { name: /cancel/i })
33
+ ).not.toBeInTheDocument();
34
+ expect(
35
+ screen.queryByRole('button', { name: /^task actions$/i })
36
+ ).not.toBeInTheDocument();
45
37
  });
46
38
  });