foreman_leapp 3.2.1 → 3.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbb9af7bc42545f2e0eecc31e462afef8b356f170e29d1b754281f071b0b2948
4
- data.tar.gz: 8f424144f2ef5886a204aa85399fe37d7d8bfbe42249566c3fec8bd5c811ab79
3
+ metadata.gz: a7e5030bef562d53e8f4212fa76732f0901ec4b827620a5f07028fd11bcf36d4
4
+ data.tar.gz: eac05d03ce3b746a996751d2b5affb5ea1fabea4d74dd8899f5771b5bc69f8ec
5
5
  SHA512:
6
- metadata.gz: cada565d0df1f5578f334aa03d5207c91e2f8f89fba58d45a29cf3bf699e89df4aa163068485f6a2e7bd5133c9a36a173fb92445d6bdcefd2301b01d3b772f1c
7
- data.tar.gz: 8d8787b1f57a45e88f3f00bec04e9998e71957d37349f54afc883908908d76f9ba20ff2c446db0da82d547d84217c606e28a69392450420e1e05ebf851c661d8
6
+ metadata.gz: 77e129433f0175d3951ccc870e66bda4f37930d888a83704142e80d95490e7a9c76b134ab3337c293e03c15bc9c770e8835ba9bec8e11e32d66087ff0a23a5a4
7
+ data.tar.gz: 29c9135b5faf4f6564c71d24cc989a6adc3d4a67c731a5bfca38d3618ec75745f9f1492295356386a7204971d53853558bf5a6d335f5252f55bc0d557bb42f7d
data/Rakefile CHANGED
@@ -6,21 +6,6 @@ begin
6
6
  rescue LoadError
7
7
  puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
8
8
  end
9
- begin
10
- require 'rdoc/task'
11
- rescue LoadError
12
- require 'rdoc/rdoc'
13
- require 'rake/rdoctask'
14
- RDoc::Task = Rake::RDocTask
15
- end
16
-
17
- RDoc::Task.new(:rdoc) do |rdoc|
18
- rdoc.rdoc_dir = 'rdoc'
19
- rdoc.title = 'ForemanLeapp'
20
- rdoc.options << '--line-numbers'
21
- rdoc.rdoc_files.include('README.rdoc')
22
- rdoc.rdoc_files.include('lib/**/*.rb')
23
- end
24
9
 
25
10
  APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
26
11
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ForemanLeapp
4
- VERSION = '3.2.1'
4
+ VERSION = '3.4.0'
5
5
  end
@@ -0,0 +1,14 @@
1
+ .leapp-report-details {
2
+ dd {
3
+ white-space: pre-wrap;
4
+ }
5
+ }
6
+
7
+ .leapp-expanded-tbody {
8
+ border: 1px solid var(--pf-v5-global--BorderColor--100);
9
+ box-shadow: var(--pf-v5-global--BoxShadow--sm);
10
+
11
+ tr:first-child > td {
12
+ background-color: var(--pf-v5-global--primary-color--light-background, #e7f1fa);
13
+ }
14
+ }
@@ -0,0 +1,134 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import {
4
+ DescriptionList,
5
+ DescriptionListGroup,
6
+ DescriptionListTerm,
7
+ DescriptionListDescription,
8
+ Label,
9
+ LabelGroup,
10
+ } from '@patternfly/react-core';
11
+ import { translate as __ } from 'foremanReact/common/I18n';
12
+ import './PreupgradeReportsTable.scss';
13
+
14
+ export const renderSeverityLabel = severity => {
15
+ switch (severity) {
16
+ case 'high':
17
+ return <Label color="red">{__('High')}</Label>;
18
+ case 'medium':
19
+ return <Label color="orange">{__('Medium')}</Label>;
20
+ case 'low':
21
+ return <Label color="blue">{__('Low')}</Label>;
22
+ case 'info':
23
+ return <Label color="grey">{__('Info')}</Label>;
24
+ default:
25
+ return <Label color="grey">{severity || __('Info')}</Label>;
26
+ }
27
+ };
28
+
29
+ const ReportDetails = ({ entry }) => (
30
+ <DescriptionList isHorizontal isCompact className="leapp-report-details">
31
+ {entry.title && (
32
+ <DescriptionListGroup>
33
+ <DescriptionListTerm>{__('Title')}</DescriptionListTerm>
34
+ <DescriptionListDescription>{entry.title}</DescriptionListDescription>
35
+ </DescriptionListGroup>
36
+ )}
37
+
38
+ {entry.severity && (
39
+ <DescriptionListGroup>
40
+ <DescriptionListTerm>{__('Risk Factor')}</DescriptionListTerm>
41
+ <DescriptionListDescription>
42
+ {renderSeverityLabel(entry.severity)}
43
+ </DescriptionListDescription>
44
+ </DescriptionListGroup>
45
+ )}
46
+
47
+ {entry.summary && (
48
+ <DescriptionListGroup>
49
+ <DescriptionListTerm>{__('Summary')}</DescriptionListTerm>
50
+ <DescriptionListDescription>{entry.summary}</DescriptionListDescription>
51
+ </DescriptionListGroup>
52
+ )}
53
+
54
+ {entry.tags && entry.tags.length > 0 && (
55
+ <DescriptionListGroup>
56
+ <DescriptionListTerm>{__('Tags')}</DescriptionListTerm>
57
+ <DescriptionListDescription>
58
+ <LabelGroup>
59
+ {entry.tags.map(tag => (
60
+ <Label key={tag} color="blue">
61
+ {tag}
62
+ </Label>
63
+ ))}
64
+ </LabelGroup>
65
+ </DescriptionListDescription>
66
+ </DescriptionListGroup>
67
+ )}
68
+
69
+ {entry.detail?.external?.filter(link => link.url).length > 0 && (
70
+ <DescriptionListGroup>
71
+ <DescriptionListTerm>{__('Links')}</DescriptionListTerm>
72
+ <DescriptionListDescription>
73
+ {entry.detail.external
74
+ .filter(link => link.url)
75
+ .map((item, i) => (
76
+ <div key={item.url || i}>
77
+ <a href={item.url} target="_blank" rel="noopener noreferrer">
78
+ {item.title || item.url}
79
+ </a>
80
+ </div>
81
+ ))}
82
+ </DescriptionListDescription>
83
+ </DescriptionListGroup>
84
+ )}
85
+
86
+ {entry.detail?.remediations?.length > 0 &&
87
+ entry.detail.remediations.map((item, i) => (
88
+ <DescriptionListGroup key={`remediations-${i}`}>
89
+ <DescriptionListTerm>
90
+ {item.type === 'command' ? __('Command') : __('Hint')}
91
+ </DescriptionListTerm>
92
+ <DescriptionListDescription>
93
+ {item.type === 'command' ? (
94
+ <code>
95
+ {Array.isArray(item.context)
96
+ ? item.context.join(' ')
97
+ : item.context}
98
+ </code>
99
+ ) : (
100
+ item.context
101
+ )}
102
+ </DescriptionListDescription>
103
+ </DescriptionListGroup>
104
+ ))}
105
+ </DescriptionList>
106
+ );
107
+
108
+ ReportDetails.propTypes = {
109
+ entry: PropTypes.shape({
110
+ title: PropTypes.string,
111
+ severity: PropTypes.string,
112
+ summary: PropTypes.string,
113
+ tags: PropTypes.arrayOf(PropTypes.string),
114
+ detail: PropTypes.shape({
115
+ external: PropTypes.arrayOf(
116
+ PropTypes.shape({
117
+ url: PropTypes.string,
118
+ title: PropTypes.string,
119
+ })
120
+ ),
121
+ remediations: PropTypes.arrayOf(
122
+ PropTypes.shape({
123
+ type: PropTypes.string,
124
+ context: PropTypes.oneOfType([
125
+ PropTypes.string,
126
+ PropTypes.arrayOf(PropTypes.string),
127
+ ]),
128
+ })
129
+ ),
130
+ }),
131
+ }).isRequired,
132
+ };
133
+
134
+ export default ReportDetails;
@@ -1,11 +1,19 @@
1
- import React from 'react';
2
- import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3
1
  import '@testing-library/jest-dom/extend-expect';
2
+
3
+ import {
4
+ fireEvent,
5
+ render,
6
+ screen,
7
+ waitFor,
8
+ within,
9
+ } from '@testing-library/react';
10
+
11
+ import { APIActions } from 'foremanReact/redux/API';
12
+ import PreupgradeReportsTable from '../index';
4
13
  import { Provider } from 'react-redux';
14
+ import React from 'react';
5
15
  import configureMockStore from 'redux-mock-store';
6
16
  import thunk from 'redux-thunk';
7
- import { APIActions } from 'foremanReact/redux/API';
8
- import PreupgradeReportsTable from '../index';
9
17
 
10
18
  jest.mock('foremanReact/redux/API');
11
19
 
@@ -18,13 +26,28 @@ const mockJobData = {
18
26
  template_name: 'Run preupgrade via Leapp',
19
27
  };
20
28
 
29
+ // Entry 0 (id=1): command remediation + inhibitor flag → fixable + selectable
30
+ // Entry 1 (id=2): hint-only remediation → has_remediation=Yes, NOT selectable
31
+ // Entries 2-11: no remediations → has_remediation=No, NOT selectable
21
32
  const mockEntries = Array.from({ length: 12 }, (_, i) => ({
22
33
  id: i + 1,
23
34
  title: `Report Entry ${i + 1}`,
24
35
  hostname: 'example.com',
36
+ host_id: 100 + i,
25
37
  severity: i === 0 ? 'high' : 'low',
38
+ summary: `Summary for report entry ${i + 1}`,
39
+ tags: i === 0 ? ['security', 'network'] : [],
26
40
  flags: i === 0 ? ['inhibitor'] : [],
27
- detail: { remediations: i === 0 ? [{ type: 'cmd' }] : [] },
41
+ detail: {
42
+ remediations:
43
+ i === 0
44
+ ? [{ type: 'command', context: ['echo', 'fix_command'] }]
45
+ : i === 1
46
+ ? [{ type: 'hint', context: 'Do something manually' }]
47
+ : null,
48
+ external:
49
+ i === 0 ? [{ url: 'http://example.com', title: 'External Link' }] : [],
50
+ },
28
51
  }));
29
52
 
30
53
  describe('PreupgradeReportsTable', () => {
@@ -46,57 +69,301 @@ describe('PreupgradeReportsTable', () => {
46
69
  return { type: 'MOCK_API_SUCCESS' };
47
70
  };
48
71
  });
72
+
73
+ APIActions.post.mockImplementation(() => () => ({ type: 'MOCK_API_POST' }));
49
74
  });
50
75
 
51
- const renderComponent = () =>
76
+ const renderComponent = (data = mockJobData) =>
52
77
  render(
53
78
  <Provider store={store}>
54
- <PreupgradeReportsTable data={mockJobData} />
79
+ <PreupgradeReportsTable data={data} />
55
80
  </Provider>
56
81
  );
57
82
 
58
- const expandSection = () => {
83
+ const expandSection = () =>
59
84
  fireEvent.click(screen.getByText('Leapp preupgrade report'));
60
- };
85
+
86
+ const waitForTable = () =>
87
+ waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' }));
61
88
 
62
89
  it('renders data', async () => {
63
90
  renderComponent();
64
91
  expandSection();
92
+ await waitForTable();
93
+ expect(
94
+ screen.getByText('Report Entry 1', { selector: 'td' })
95
+ ).toBeInTheDocument();
96
+ });
97
+
98
+ it('does not render anything for non-Leapp jobs', () => {
99
+ renderComponent({ id: 55, template_name: 'Standard RHEL Update' });
100
+ expect(
101
+ screen.queryByText('Leapp preupgrade report')
102
+ ).not.toBeInTheDocument();
103
+ });
104
+
105
+ it('refetches when status_label transitions (e.g. Running → Succeeded)', async () => {
106
+ const { rerender } = render(
107
+ <Provider store={store}>
108
+ <PreupgradeReportsTable
109
+ data={{ ...mockJobData, status_label: 'Running' }}
110
+ />
111
+ </Provider>
112
+ );
113
+ expandSection();
114
+ await waitForTable();
115
+
116
+ const callCountAfterFirstFetch = APIActions.get.mock.calls.length;
65
117
 
66
- await waitFor(() => screen.getByText('Report Entry 1'));
118
+ rerender(
119
+ <Provider store={store}>
120
+ <PreupgradeReportsTable
121
+ data={{ ...mockJobData, status_label: 'Succeeded' }}
122
+ />
123
+ </Provider>
124
+ );
67
125
 
68
- expect(screen.getByText('Report Entry 1')).toBeInTheDocument();
69
- expect(screen.getByText('Report Entry 5')).toBeInTheDocument();
70
- expect(screen.queryByText('Report Entry 6')).not.toBeInTheDocument();
126
+ await waitFor(() =>
127
+ expect(APIActions.get.mock.calls.length).toBeGreaterThan(
128
+ callCountAfterFirstFetch
129
+ )
130
+ );
131
+ expect(
132
+ screen.getByText('Report Entry 1', { selector: 'td' })
133
+ ).toBeInTheDocument();
71
134
  });
72
135
 
73
- it('paginates to the next page', async () => {
136
+ it('does not refetch on collapse/re-expand when status_label is unchanged', async () => {
137
+ renderComponent({ ...mockJobData, status_label: 'Succeeded' });
138
+ expandSection();
139
+ await waitForTable();
140
+
141
+ const callCountAfterFirstFetch = APIActions.get.mock.calls.length;
142
+
143
+ fireEvent.click(screen.getByText('Leapp preupgrade report')); // collapse
144
+ fireEvent.click(screen.getByText('Leapp preupgrade report')); // re-expand
145
+
146
+ expect(APIActions.get.mock.calls.length).toBe(callCountAfterFirstFetch);
147
+ });
148
+
149
+ it('renders empty state message when no issues found', async () => {
150
+ APIActions.get.mockImplementation(({ key, handleSuccess }) => {
151
+ return () => {
152
+ if (key.includes('GET_LEAPP_REPORT_LIST'))
153
+ handleSuccess({ results: [{ id: mockReportId }] });
154
+ if (key.includes('GET_LEAPP_REPORT_DETAIL'))
155
+ handleSuccess({ id: mockReportId, preupgrade_report_entries: [] });
156
+ return { type: 'EMPTY' };
157
+ };
158
+ });
74
159
  renderComponent();
75
160
  expandSection();
76
- await waitFor(() => screen.getByText('Report Entry 1'));
161
+ await waitFor(() =>
162
+ expect(
163
+ screen.getByText('The preupgrade report shows no issues.')
164
+ ).toBeInTheDocument()
165
+ );
166
+ });
77
167
 
78
- fireEvent.click(screen.getAllByLabelText('Go to next page')[0]);
168
+ it('expands a row and shows details', async () => {
169
+ renderComponent();
170
+ expandSection();
171
+ await waitForTable();
172
+ fireEvent.click(screen.getAllByLabelText('Details')[0]);
173
+ expect(
174
+ await screen.findByText('Summary for report entry 1')
175
+ ).toBeInTheDocument();
176
+ });
79
177
 
80
- await waitFor(() => screen.getByText('Report Entry 6'));
81
- expect(screen.getByText('Report Entry 10')).toBeInTheDocument();
82
- expect(screen.queryByText('Report Entry 1')).not.toBeInTheDocument();
178
+ it('expands all rows', async () => {
179
+ renderComponent();
180
+ expandSection();
181
+ await waitForTable();
182
+ fireEvent.click(screen.getByLabelText('Expand all rows'));
183
+ expect(
184
+ await screen.findByText('Summary for report entry 1')
185
+ ).toBeInTheDocument();
186
+ expect(
187
+ await screen.findByText('Summary for report entry 5')
188
+ ).toBeInTheDocument();
83
189
  });
84
190
 
85
- it('changes perPage limit to 10', async () => {
191
+ it('paginates to the next page', async () => {
86
192
  renderComponent();
87
193
  expandSection();
88
- await waitFor(() => screen.getByText('Report Entry 1'));
194
+ await waitForTable();
195
+ fireEvent.click(screen.getAllByLabelText('Go to next page')[0]);
196
+ await waitFor(() => screen.getByText('Report Entry 6', { selector: 'td' }));
197
+ expect(
198
+ screen.getByText('Report Entry 10', { selector: 'td' })
199
+ ).toBeInTheDocument();
200
+ });
89
201
 
202
+ it('changes perPage limit to 10', async () => {
203
+ renderComponent();
204
+ expandSection();
205
+ await waitForTable();
90
206
  fireEvent.click(screen.getAllByLabelText('Items per page')[0]);
91
207
  fireEvent.click(screen.getAllByText('10 per page')[0]);
208
+ await waitFor(() =>
209
+ expect(
210
+ screen.getByText('Report Entry 10', { selector: 'td' })
211
+ ).toBeInTheDocument()
212
+ );
213
+ });
92
214
 
93
- await waitFor(() => {
94
- expect(screen.getByText('Report Entry 10')).toBeInTheDocument();
95
- expect(screen.queryByText('Report Entry 11')).not.toBeInTheDocument();
96
- });
215
+ it('displays correct inhibitor status based on flags', async () => {
216
+ renderComponent();
217
+ expandSection();
218
+ await waitForTable();
219
+
220
+ const row1 = screen
221
+ .getByText('Report Entry 1', { selector: 'td' })
222
+ .closest('tr');
223
+ expect(
224
+ within(row1.querySelector('td[data-label="Inhibitor?"]')).getByText('Yes')
225
+ ).toBeInTheDocument();
226
+
227
+ const row2 = screen
228
+ .getByText('Report Entry 2', { selector: 'td' })
229
+ .closest('tr');
230
+ expect(
231
+ within(row2.querySelector('td[data-label="Inhibitor?"]')).getByText('No')
232
+ ).toBeInTheDocument();
97
233
  });
98
234
 
99
- it('renders empty state message when no issues found', async () => {
235
+ it('shows Has Remediation? Yes for any remediation type, not only command', async () => {
236
+ renderComponent();
237
+ expandSection();
238
+ await waitForTable();
239
+
240
+ // id=1: command → Yes
241
+ const row1 = screen
242
+ .getByText('Report Entry 1', { selector: 'td' })
243
+ .closest('tr');
244
+ expect(
245
+ within(row1.querySelector('td[data-label="Has Remediation?"]')).getByText(
246
+ 'Yes'
247
+ )
248
+ ).toBeInTheDocument();
249
+
250
+ // id=2: hint-only → still Yes (display column shows any remediations)
251
+ const row2 = screen
252
+ .getByText('Report Entry 2', { selector: 'td' })
253
+ .closest('tr');
254
+ expect(
255
+ within(row2.querySelector('td[data-label="Has Remediation?"]')).getByText(
256
+ 'Yes'
257
+ )
258
+ ).toBeInTheDocument();
259
+
260
+ // id=3: no remediations → No
261
+ const row3 = screen
262
+ .getByText('Report Entry 3', { selector: 'td' })
263
+ .closest('tr');
264
+ expect(
265
+ within(row3.querySelector('td[data-label="Has Remediation?"]')).getByText(
266
+ 'No'
267
+ )
268
+ ).toBeInTheDocument();
269
+ });
270
+
271
+ it('renders Fix Selected button disabled initially', async () => {
272
+ renderComponent();
273
+ expandSection();
274
+ await waitForTable();
275
+ expect(screen.getByRole('button', { name: 'Fix Selected' })).toBeDisabled();
276
+ });
277
+
278
+ it('enables Fix Selected after selecting a fixable (command) row', async () => {
279
+ renderComponent();
280
+ expandSection();
281
+ await waitForTable();
282
+
283
+ // checkboxes[0] = SelectAll, checkboxes[1] = entry id=1 (command remediation)
284
+ fireEvent.click(screen.getAllByRole('checkbox')[1]);
285
+
286
+ await waitFor(() =>
287
+ expect(
288
+ screen.getByRole('button', { name: 'Fix Selected' })
289
+ ).not.toBeDisabled()
290
+ );
291
+ });
292
+
293
+ it('Fix Selected dispatches APIActions.post with correct feature, host_ids and remediation_ids', async () => {
294
+ renderComponent();
295
+ expandSection();
296
+ await waitForTable();
297
+
298
+ fireEvent.click(screen.getAllByRole('checkbox')[1]); // select entry id=1 (host_id=100)
299
+
300
+ await waitFor(() =>
301
+ expect(
302
+ screen.getByRole('button', { name: 'Fix Selected' })
303
+ ).not.toBeDisabled()
304
+ );
305
+
306
+ fireEvent.click(screen.getByRole('button', { name: 'Fix Selected' }));
307
+
308
+ expect(APIActions.post).toHaveBeenCalledWith(
309
+ expect.objectContaining({
310
+ url: expect.stringContaining('/api/job_invocations'),
311
+ params: {
312
+ job_invocation: {
313
+ feature: 'leapp_remediation_plan',
314
+ host_ids: [100],
315
+ inputs: { remediation_ids: '1' },
316
+ },
317
+ },
318
+ })
319
+ );
320
+ });
321
+
322
+ it('hint-only row checkbox is disabled and does not enable Fix Selected', async () => {
323
+ renderComponent();
324
+ expandSection();
325
+ await waitForTable();
326
+
327
+ // checkboxes[2] = entry id=2, which has hint-only remediation — must be disabled
328
+ const hintCheckbox = screen.getAllByRole('checkbox')[2];
329
+ expect(hintCheckbox).toBeDisabled();
330
+
331
+ fireEvent.click(hintCheckbox);
332
+ expect(screen.getByRole('button', { name: 'Fix Selected' })).toBeDisabled();
333
+ });
334
+
335
+ it('renders Run Upgrade button enabled when entries are present', async () => {
336
+ renderComponent();
337
+ expandSection();
338
+ await waitForTable();
339
+ expect(
340
+ screen.getByRole('button', { name: 'Run Upgrade' })
341
+ ).not.toBeDisabled();
342
+ });
343
+
344
+ it('Run Upgrade dispatches APIActions.post with correct feature and all host_ids', async () => {
345
+ renderComponent();
346
+ expandSection();
347
+ await waitForTable();
348
+
349
+ fireEvent.click(screen.getByRole('button', { name: 'Run Upgrade' }));
350
+
351
+ expect(APIActions.post).toHaveBeenCalledWith(
352
+ expect.objectContaining({
353
+ url: expect.stringContaining('/api/job_invocations'),
354
+ params: {
355
+ job_invocation: {
356
+ feature: 'leapp_upgrade',
357
+ host_ids: expect.arrayContaining([100, 101, 102]),
358
+ },
359
+ },
360
+ })
361
+ );
362
+ const callParams = APIActions.post.mock.calls[0][0].params.job_invocation;
363
+ expect(callParams.inputs).toBeUndefined();
364
+ });
365
+
366
+ it('does not render toolbar buttons when report has no entries', async () => {
100
367
  APIActions.get.mockImplementation(({ key, handleSuccess }) => {
101
368
  return () => {
102
369
  if (key.includes('GET_LEAPP_REPORT_LIST'))
@@ -106,12 +373,37 @@ describe('PreupgradeReportsTable', () => {
106
373
  return { type: 'EMPTY' };
107
374
  };
108
375
  });
376
+ renderComponent();
377
+ expandSection();
378
+ await waitFor(() =>
379
+ screen.getByText('The preupgrade report shows no issues.')
380
+ );
381
+ expect(
382
+ screen.queryByRole('button', { name: 'Fix Selected' })
383
+ ).not.toBeInTheDocument();
384
+ expect(
385
+ screen.queryByRole('button', { name: 'Run Upgrade' })
386
+ ).not.toBeInTheDocument();
387
+ });
109
388
 
389
+ it('renders the SelectAll checkbox', async () => {
110
390
  renderComponent();
111
391
  expandSection();
392
+ await waitForTable();
393
+ expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0);
394
+ });
112
395
 
113
- await waitFor(() => {
114
- expect(screen.getByText('The preupgrade report shows no issues.')).toBeInTheDocument();
115
- });
396
+ it('selecting all fixable rows enables Fix Selected', async () => {
397
+ renderComponent();
398
+ expandSection();
399
+ await waitForTable();
400
+
401
+ fireEvent.click(screen.getByLabelText('Select all'));
402
+
403
+ await waitFor(() =>
404
+ expect(
405
+ screen.getByRole('button', { name: 'Fix Selected' })
406
+ ).not.toBeDisabled()
407
+ );
116
408
  });
117
409
  });
@@ -1,145 +1,374 @@
1
+ /* eslint-disable max-lines */
1
2
  import PropTypes from 'prop-types';
2
- import React, { useEffect, useState } from 'react';
3
+ import React, {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
3
10
  import { useDispatch } from 'react-redux';
4
- import { ExpandableSection, Label, Tooltip } from '@patternfly/react-core';
11
+ import {
12
+ Button,
13
+ ExpandableSection,
14
+ Toolbar,
15
+ ToolbarContent,
16
+ ToolbarGroup,
17
+ ToolbarItem,
18
+ Tooltip,
19
+ } from '@patternfly/react-core';
20
+ import { ExpandableRowContent, Tbody, Td, Tr } from '@patternfly/react-table';
5
21
  import { translate as __ } from 'foremanReact/common/I18n';
22
+ import { foremanUrl } from 'foremanReact/common/helpers';
6
23
  import { Table } from 'foremanReact/components/PF4/TableIndexPage/Table/Table';
24
+ import SelectAllCheckbox from 'foremanReact/components/PF4/TableIndexPage/Table/SelectAllCheckbox';
25
+ import { useBulkSelect } from 'foremanReact/components/PF4/TableIndexPage/Table/TableHooks';
26
+ import { RowSelectTd } from 'foremanReact/components/PF4/TableIndexPage/RowSelectTd';
27
+ import { getColumnHelpers } from 'foremanReact/components/PF4/TableIndexPage/Table/helpers';
7
28
  import { APIActions } from 'foremanReact/redux/API';
8
29
  import { STATUS } from 'foremanReact/constants';
30
+ import {
31
+ entriesPage,
32
+ entryFixable,
33
+ } from '../PreupgradeReports/PreupgradeReportsHelpers';
34
+ import ReportDetails, { renderSeverityLabel } from './ReportDetails';
35
+ import './PreupgradeReportsTable.scss';
9
36
 
10
- import { entriesPage } from '../PreupgradeReports/PreupgradeReportsHelpers';
11
-
12
- const renderSeverityLabel = severity => {
13
- switch (severity) {
14
- case 'high':
15
- return <Label color="red">{__('High')}</Label>;
16
- case 'medium':
17
- return <Label color="orange">{__('Medium')}</Label>;
18
- case 'low':
19
- return <Label color="blue">{__('Low')}</Label>;
20
- case 'info':
21
- return <Label color="grey">{__('Info')}</Label>;
22
- default:
23
- return <Label color="grey">{severity || __('Info')}</Label>;
24
- }
37
+ const LEAPP_TEMPLATE_NAME = 'Run preupgrade via Leapp';
38
+
39
+ const isRowFixable = entryFixable;
40
+
41
+ const submitJobInvocation = (
42
+ dispatch,
43
+ setError,
44
+ feature,
45
+ hostIds,
46
+ remediationIds
47
+ ) => {
48
+ const payload = {
49
+ job_invocation: {
50
+ feature,
51
+ host_ids: hostIds,
52
+ ...(remediationIds != null
53
+ ? { inputs: { remediation_ids: remediationIds } }
54
+ : {}),
55
+ },
56
+ };
57
+
58
+ dispatch(
59
+ APIActions.post({
60
+ key: `CREATE_JOB_INVOCATION_${feature}`,
61
+ url: foremanUrl('/api/job_invocations'),
62
+ params: payload,
63
+ handleSuccess: response => {
64
+ const result = response.data || response;
65
+ if (result?.id) {
66
+ window.location.assign(foremanUrl(`/job_invocations/${result.id}`));
67
+ }
68
+ },
69
+ handleError: err => setError(err),
70
+ })
71
+ );
25
72
  };
26
73
 
27
74
  const PreupgradeReportsTable = ({ data = {} }) => {
28
75
  const [error, setError] = useState(null);
29
- const [isExpanded, setIsExpanded] = useState(false);
76
+ const [isReportExpanded, setIsReportExpanded] = useState(false);
30
77
  const [pagination, setPagination] = useState({ page: 1, perPage: 5 });
31
78
  const [reportData, setReportData] = useState(null);
32
79
  const [status, setStatus] = useState(STATUS.RESOLVED);
80
+ const [expandedRowIds, setExpandedRowIds] = useState(new Set());
81
+
33
82
  const dispatch = useDispatch();
34
83
  // eslint-disable-next-line camelcase
35
- const isLeappJob = data?.template_name?.includes('Run preupgrade via Leapp');
84
+ const isLeappJob = data?.template_name?.includes(LEAPP_TEMPLATE_NAME);
36
85
 
37
- const columns = {
38
- title: {
39
- title: __('Title'),
40
- },
41
- host: {
42
- title: __('Host'),
43
- wrapper: entry =>
44
- entry.hostname || (reportData && reportData.hostname) || '-',
45
- },
46
- risk_factor: {
47
- title: __('Risk Factor'),
48
- wrapper: ({ severity }) => renderSeverityLabel(severity),
49
- },
50
- has_remediation: {
51
- title: __('Has Remediation?'),
52
- wrapper: entry =>
53
- entry.detail && entry.detail.remediations ? __('Yes') : __('No'),
54
- },
55
- inhibitor: {
56
- title: __('Inhibitor?'),
57
- wrapper: entry =>
58
- entry.flags && entry.flags.some(flag => flag === 'inhibitor') ? (
59
- <Tooltip content={__('This issue inhibits the upgrade.')}>
60
- <span>{__('Yes')}</span>
61
- </Tooltip>
62
- ) : (
63
- __('No')
64
- ),
65
- },
66
- };
86
+ // eslint-disable-next-line camelcase
87
+ const jobStatusLabel = data?.status_label;
88
+
89
+ const lastFetchedKeyRef = useRef(null);
90
+
91
+ const columns = useMemo(
92
+ () => ({
93
+ title: { title: __('Title') },
94
+ host: {
95
+ title: __('Host'),
96
+ wrapper: entry => entry.hostname || reportData?.hostname || '-',
97
+ },
98
+ risk_factor: {
99
+ title: __('Risk Factor'),
100
+ wrapper: ({ severity }) => renderSeverityLabel(severity),
101
+ },
102
+ has_remediation: {
103
+ title: __('Has Remediation?'),
104
+ wrapper: entry => (entry.detail?.remediations ? __('Yes') : __('No')),
105
+ },
106
+ inhibitor: {
107
+ title: __('Inhibitor?'),
108
+ wrapper: entry =>
109
+ entry.flags?.some(flag => flag === 'inhibitor') ? (
110
+ <Tooltip content={__('This issue inhibits the upgrade.')}>
111
+ <span>{__('Yes')}</span>
112
+ </Tooltip>
113
+ ) : (
114
+ __('No')
115
+ ),
116
+ },
117
+ }),
118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
119
+ [reportData?.hostname]
120
+ );
67
121
 
68
122
  useEffect(() => {
69
123
  let isMounted = true;
70
- if (!isLeappJob || !isExpanded || reportData) {
124
+ const fetchKey = `${data.id}:${jobStatusLabel}`;
125
+
126
+ if (
127
+ !isLeappJob ||
128
+ !isReportExpanded ||
129
+ lastFetchedKeyRef.current === fetchKey
130
+ )
71
131
  return undefined;
72
- }
73
- setStatus(STATUS.PENDING);
74
132
 
133
+ const fail = err => {
134
+ if (!isMounted) return;
135
+ setError(err);
136
+ setStatus(STATUS.ERROR);
137
+ };
138
+
139
+ const succeed = response => {
140
+ if (!isMounted) return;
141
+ lastFetchedKeyRef.current = fetchKey;
142
+ setReportData(response?.data || response || null);
143
+ setStatus(STATUS.RESOLVED);
144
+ };
145
+
146
+ setStatus(STATUS.PENDING);
75
147
  dispatch(
76
148
  APIActions.get({
77
149
  key: `GET_LEAPP_REPORT_LIST_${data.id}`,
78
150
  url: `/api/job_invocations/${data.id}/preupgrade_reports`,
79
151
  handleSuccess: listResponse => {
80
152
  if (!isMounted) return;
81
- const listPayload = listResponse.data || listResponse;
82
- const summary = listPayload.results?.[0];
153
+ const summary = (listResponse.data || listResponse).results?.[0];
83
154
  if (summary?.id) {
84
155
  dispatch(
85
156
  APIActions.get({
86
157
  key: `GET_LEAPP_REPORT_DETAIL_${summary.id}`,
87
158
  url: `/api/preupgrade_reports/${summary.id}`,
88
- handleSuccess: detailResponse => {
89
- if (isMounted) {
90
- const detailPayload = detailResponse.data || detailResponse;
91
- setReportData(detailPayload);
92
- setStatus(STATUS.RESOLVED);
93
- }
94
- },
95
- handleError: err => {
96
- if (isMounted) {
97
- setError(err);
98
- setStatus(STATUS.ERROR);
99
- }
100
- },
159
+ handleSuccess: detailResponse => succeed(detailResponse),
160
+ handleError: err => fail(err),
101
161
  })
102
162
  );
103
- } else if (isMounted) {
104
- setReportData({});
105
- setStatus(STATUS.RESOLVED);
106
- }
107
- },
108
- handleError: err => {
109
- if (isMounted) {
110
- setError(err);
111
- setStatus(STATUS.ERROR);
163
+ return;
112
164
  }
165
+ succeed(null);
113
166
  },
167
+ handleError: err => fail(err),
114
168
  })
115
169
  );
116
170
 
117
171
  return () => {
118
172
  isMounted = false;
119
173
  };
120
- }, [isExpanded, data.id, isLeappJob, reportData, dispatch]);
174
+ }, [isReportExpanded, data.id, isLeappJob, dispatch, jobStatusLabel]);
121
175
 
122
176
  // eslint-disable-next-line camelcase
123
- const entries = reportData?.preupgrade_report_entries || [];
124
- const pagedEntries = entriesPage(entries, pagination);
177
+ const entries = useMemo(() => reportData?.preupgrade_report_entries || [], [
178
+ reportData,
179
+ ]);
180
+
181
+ const pagedEntries = useMemo(
182
+ () => entriesPage(entries, pagination),
183
+ // eslint-disable-next-line react-hooks/exhaustive-deps
184
+ [entries, pagination.page, pagination.perPage]
185
+ );
186
+
187
+ const getHostId = useCallback(
188
+ entry =>
189
+ entry.host_id ||
190
+ entry.hostId ||
191
+ // eslint-disable-next-line camelcase
192
+ reportData?.host_id ||
193
+ reportData?.host?.id ||
194
+ // eslint-disable-next-line camelcase
195
+ data?.targeting?.host_id,
196
+ [reportData, data]
197
+ );
125
198
 
126
- const handleParamsChange = newParams => {
199
+ const handleParamsChange = useCallback(newParams => {
127
200
  setPagination(prev => ({
128
201
  ...prev,
129
202
  page: newParams.page || prev.page,
130
203
  perPage: newParams.per_page || prev.perPage,
131
204
  }));
132
- };
205
+ setExpandedRowIds(new Set());
206
+ }, []);
207
+
208
+ const toggleRowExpansion = useCallback((id, isExpanding) => {
209
+ setExpandedRowIds(prev => {
210
+ const next = new Set(prev);
211
+ if (isExpanding) next.add(id);
212
+ else next.delete(id);
213
+ return next;
214
+ });
215
+ }, []);
216
+
217
+ const { inclusionSet, exclusionSet, ...selectAllOptions } = useBulkSelect({
218
+ results: pagedEntries,
219
+ metadata: {
220
+ total: entries.length,
221
+ page: pagination.page,
222
+ selectable: entries.length,
223
+ },
224
+ initialSearchQuery: '',
225
+ });
226
+
227
+ const {
228
+ selectAll,
229
+ selectPage,
230
+ selectNone,
231
+ selectOne,
232
+ areAllRowsSelected,
233
+ isSelected,
234
+ } = selectAllOptions;
235
+
236
+ const rawSelectedIds =
237
+ areAllRowsSelected() || exclusionSet.size > 0
238
+ ? entries.map(e => e.id).filter(id => !exclusionSet.has(id))
239
+ : Array.from(inclusionSet);
240
+
241
+ const validFixableIds = useMemo(
242
+ () => entries.filter(isRowFixable).map(e => e.id),
243
+ [entries]
244
+ );
245
+
246
+ // eslint-disable-next-line react-hooks/exhaustive-deps
247
+ const selectedIds = useMemo(
248
+ () => rawSelectedIds.filter(id => validFixableIds.includes(id)),
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
+ [rawSelectedIds.join(','), validFixableIds]
251
+ );
252
+
253
+ const pagedFixableEntries = useMemo(() => pagedEntries.filter(isRowFixable), [
254
+ pagedEntries,
255
+ ]);
256
+
257
+ const areAllPageFixableSelected =
258
+ pagedFixableEntries.length > 0 &&
259
+ pagedFixableEntries.every(e => selectedIds.includes(e.id));
260
+
261
+ const areAllFixableSelected =
262
+ validFixableIds.length > 0 &&
263
+ validFixableIds.every(id => selectedIds.includes(id));
264
+
265
+ const areAllRowsExpanded =
266
+ pagedEntries.length > 0 &&
267
+ pagedEntries.every(entry => expandedRowIds.has(entry.id));
268
+
269
+ const onExpandAll = useCallback(() => {
270
+ setExpandedRowIds(
271
+ areAllRowsExpanded ? new Set() : new Set(pagedEntries.map(e => e.id))
272
+ );
273
+ }, [areAllRowsExpanded, pagedEntries]);
274
+
275
+ const [columnKeys, keysToColumnNames] = useMemo(
276
+ () => getColumnHelpers(columns),
277
+ [columns]
278
+ );
279
+
280
+ const hostIdsForSelected = useMemo(
281
+ () =>
282
+ Array.from(
283
+ new Set(
284
+ entries
285
+ .filter(e => selectedIds.includes(e.id))
286
+ .map(getHostId)
287
+ .filter(Boolean)
288
+ )
289
+ ),
290
+ [entries, selectedIds, getHostId]
291
+ );
292
+
293
+ const allHostIds = useMemo(
294
+ () => Array.from(new Set(entries.map(getHostId).filter(Boolean))),
295
+ [entries, getHostId]
296
+ );
133
297
 
134
298
  if (!isLeappJob) return null;
135
299
 
300
+ const isFixSelectedDisabled =
301
+ validFixableIds.length === 0 ||
302
+ selectedIds.length === 0 ||
303
+ hostIdsForSelected.length === 0;
304
+
136
305
  return (
137
306
  <ExpandableSection
138
- className="leapp-report-section"
139
- isExpanded={isExpanded}
140
- onToggle={(_event, val) => setIsExpanded(val)}
307
+ isExpanded={isReportExpanded}
308
+ onToggle={(_event, val) => setIsReportExpanded(val)}
141
309
  toggleText={__('Leapp preupgrade report')}
142
310
  >
311
+ {entries.length > 0 && status === STATUS.RESOLVED && (
312
+ <Toolbar ouiaId="leapp-report-toolbar">
313
+ <ToolbarContent>
314
+ <ToolbarGroup variant="filter-group">
315
+ <ToolbarItem>
316
+ <SelectAllCheckbox
317
+ selectAll={selectAll}
318
+ selectPage={selectPage}
319
+ selectNone={selectNone}
320
+ selectedCount={selectedIds.length}
321
+ pageRowCount={pagedFixableEntries.length}
322
+ totalCount={validFixableIds.length}
323
+ areAllRowsOnPageSelected={areAllPageFixableSelected}
324
+ areAllRowsSelected={areAllFixableSelected}
325
+ />
326
+ </ToolbarItem>
327
+ </ToolbarGroup>
328
+ <ToolbarGroup
329
+ align={{ default: 'alignRight' }}
330
+ variant="button-group"
331
+ >
332
+ <ToolbarItem>
333
+ <Button
334
+ variant="secondary"
335
+ isDisabled={isFixSelectedDisabled}
336
+ onClick={() =>
337
+ submitJobInvocation(
338
+ dispatch,
339
+ setError,
340
+ 'leapp_remediation_plan',
341
+ hostIdsForSelected,
342
+ selectedIds.join(',')
343
+ )
344
+ }
345
+ ouiaId="fix-selected-button"
346
+ >
347
+ {__('Fix Selected')}
348
+ </Button>
349
+ </ToolbarItem>
350
+ <ToolbarItem>
351
+ <Button
352
+ variant="primary"
353
+ isDisabled={allHostIds.length === 0}
354
+ onClick={() =>
355
+ submitJobInvocation(
356
+ dispatch,
357
+ setError,
358
+ 'leapp_upgrade',
359
+ allHostIds
360
+ )
361
+ }
362
+ ouiaId="run-upgrade-button"
363
+ >
364
+ {__('Run Upgrade')}
365
+ </Button>
366
+ </ToolbarItem>
367
+ </ToolbarGroup>
368
+ </ToolbarContent>
369
+ </Toolbar>
370
+ )}
371
+
143
372
  <Table
144
373
  ouiaId="leapp-report-table"
145
374
  columns={columns}
@@ -156,20 +385,75 @@ const PreupgradeReportsTable = ({ data = {} }) => {
156
385
  errorMessage={
157
386
  status === STATUS.ERROR && error?.message ? error.message : null
158
387
  }
159
- showCheckboxes={false}
388
+ showCheckboxes
160
389
  refreshData={() => {}}
161
390
  isDeleteable={false}
162
391
  emptyMessage={__('The preupgrade report shows no issues.')}
163
392
  setParams={handleParamsChange}
164
- />
393
+ childrenOutsideTbody
394
+ onExpandAll={onExpandAll}
395
+ areAllRowsExpanded={!areAllRowsExpanded}
396
+ >
397
+ {pagedEntries.map((entry, rowIndex) => {
398
+ const isRowExpanded = expandedRowIds.has(entry.id);
399
+
400
+ return (
401
+ <Tbody
402
+ key={entry.id}
403
+ isExpanded={isRowExpanded}
404
+ className={isRowExpanded ? 'leapp-expanded-tbody' : ''}
405
+ >
406
+ <Tr ouiaId={`table-row-${rowIndex}`}>
407
+ <Td
408
+ expand={{
409
+ rowIndex,
410
+ isExpanded: isRowExpanded,
411
+ onToggle: (_event, _rowIndex, isOpen) =>
412
+ toggleRowExpansion(entry.id, isOpen),
413
+ }}
414
+ />
415
+ <RowSelectTd
416
+ rowData={entry}
417
+ selectOne={selectOne}
418
+ isSelected={id => isRowFixable(entry) && isSelected(id)}
419
+ isSelectable={isRowFixable}
420
+ />
421
+ {columnKeys.map(key => (
422
+ <Td key={key} dataLabel={keysToColumnNames[key]}>
423
+ {columns[key].wrapper
424
+ ? columns[key].wrapper(entry)
425
+ : entry[key]}
426
+ </Td>
427
+ ))}
428
+ </Tr>
429
+ <Tr
430
+ isExpanded={isRowExpanded}
431
+ ouiaId={`table-row-details-${rowIndex}`}
432
+ >
433
+ <Td colSpan={columnKeys.length + 2}>
434
+ <ExpandableRowContent>
435
+ {isRowExpanded && <ReportDetails entry={entry} />}
436
+ </ExpandableRowContent>
437
+ </Td>
438
+ </Tr>
439
+ </Tbody>
440
+ );
441
+ })}
442
+ </Table>
165
443
  </ExpandableSection>
166
444
  );
167
445
  };
168
446
 
169
447
  PreupgradeReportsTable.propTypes = {
170
448
  data: PropTypes.shape({
171
- id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
449
+ id: PropTypes.number,
450
+ // eslint-disable-next-line camelcase
172
451
  template_name: PropTypes.string,
452
+ // eslint-disable-next-line camelcase
453
+ status_label: PropTypes.string,
454
+ targeting: PropTypes.shape({
455
+ host_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
456
+ }),
173
457
  }),
174
458
  };
175
459
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_leapp
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.1
4
+ version: 3.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foreman Leapp team
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-03-10 00:00:00.000000000 Z
10
+ date: 2026-05-15 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: foreman_remote_execution
@@ -37,20 +37,6 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '5.0'
40
- - !ruby/object:Gem::Dependency
41
- name: rdoc
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
45
- - !ruby/object:Gem::Version
46
- version: '6.2'
47
- type: :development
48
- prerelease: false
49
- version_requirements: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - "~>"
52
- - !ruby/object:Gem::Version
53
- version: '6.2'
54
40
  description: A Foreman plugin to support inplace RHEL upgrades with Leapp utility.
55
41
  email:
56
42
  - foreman-dev@googlegroups.com
@@ -186,6 +172,8 @@ files:
186
172
  - webpack/components/PreupgradeReportsList/components/images/i_severity-low.svg
187
173
  - webpack/components/PreupgradeReportsList/components/images/i_severity-med.svg
188
174
  - webpack/components/PreupgradeReportsList/index.js
175
+ - webpack/components/PreupgradeReportsTable/PreupgradeReportsTable.scss
176
+ - webpack/components/PreupgradeReportsTable/ReportDetails.js
189
177
  - webpack/components/PreupgradeReportsTable/__tests__/PreupgradeReportsTable.test.js
190
178
  - webpack/components/PreupgradeReportsTable/index.js
191
179
  - webpack/consts.js
@@ -212,7 +200,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
212
200
  - !ruby/object:Gem::Version
213
201
  version: '0'
214
202
  requirements: []
215
- rubygems_version: 4.0.3
203
+ rubygems_version: 4.0.10
216
204
  specification_version: 4
217
205
  summary: A Foreman plugin for Leapp utility.
218
206
  test_files: