foreman_leapp 3.2.1 → 3.3.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: 8841245ebb39f45ba92ea5b955a3398434dc103a860c50f2930019c0ee102a4e
4
+ data.tar.gz: 9a13e0df52eb412eecff7dfa1adff514670ada9dec259a9e349eef1b5ec9e092
5
5
  SHA512:
6
- metadata.gz: cada565d0df1f5578f334aa03d5207c91e2f8f89fba58d45a29cf3bf699e89df4aa163068485f6a2e7bd5133c9a36a173fb92445d6bdcefd2301b01d3b772f1c
7
- data.tar.gz: 8d8787b1f57a45e88f3f00bec04e9998e71957d37349f54afc883908908d76f9ba20ff2c446db0da82d547d84217c606e28a69392450420e1e05ebf851c661d8
6
+ metadata.gz: 37b092dbdd94f413763b9301c29c552a55cc44a0a0881462fe228d0f1b3510f750145b20eb55b459bc6acf09212b40381657749eaf0590a49024dbd79c9b097a
7
+ data.tar.gz: f93aca5a49a5c5f76fc36759310c0372f7e56b418ee2bad597461700ba9055544c9bdcb0b5ffab9561eddf7121dc0ad80af0061ee5557944f2d71b2af2927be2
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ForemanLeapp
4
- VERSION = '3.2.1'
4
+ VERSION = '3.3.0'
5
5
  end
@@ -0,0 +1,5 @@
1
+ .leapp-report-details {
2
+ dd {
3
+ white-space: pre-wrap;
4
+ }
5
+ }
@@ -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,5 +1,11 @@
1
1
  import React from 'react';
2
- import { render, screen, waitFor, fireEvent } from '@testing-library/react';
2
+ import {
3
+ render,
4
+ screen,
5
+ waitFor,
6
+ fireEvent,
7
+ within,
8
+ } from '@testing-library/react';
3
9
  import '@testing-library/jest-dom/extend-expect';
4
10
  import { Provider } from 'react-redux';
5
11
  import configureMockStore from 'redux-mock-store';
@@ -23,8 +29,15 @@ const mockEntries = Array.from({ length: 12 }, (_, i) => ({
23
29
  title: `Report Entry ${i + 1}`,
24
30
  hostname: 'example.com',
25
31
  severity: i === 0 ? 'high' : 'low',
32
+ summary: `Summary for report entry ${i + 1}`,
33
+ tags: i === 0 ? ['security', 'network'] : [],
26
34
  flags: i === 0 ? ['inhibitor'] : [],
27
- detail: { remediations: i === 0 ? [{ type: 'cmd' }] : [] },
35
+ detail: {
36
+ remediations:
37
+ i === 0 ? [{ type: 'command', context: ['echo', 'fix_command'] }] : [],
38
+ external:
39
+ i === 0 ? [{ url: 'http://example.com', title: 'External Link' }] : [],
40
+ },
28
41
  }));
29
42
 
30
43
  describe('PreupgradeReportsTable', () => {
@@ -48,10 +61,10 @@ describe('PreupgradeReportsTable', () => {
48
61
  });
49
62
  });
50
63
 
51
- const renderComponent = () =>
64
+ const renderComponent = (data = mockJobData) =>
52
65
  render(
53
66
  <Provider store={store}>
54
- <PreupgradeReportsTable data={mockJobData} />
67
+ <PreupgradeReportsTable data={data} />
55
68
  </Provider>
56
69
  );
57
70
 
@@ -62,37 +75,67 @@ describe('PreupgradeReportsTable', () => {
62
75
  it('renders data', async () => {
63
76
  renderComponent();
64
77
  expandSection();
78
+ await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' }));
79
+ expect(
80
+ screen.getByText('Report Entry 1', { selector: 'td' })
81
+ ).toBeInTheDocument();
82
+ });
83
+
84
+ it('expands a row and shows details', async () => {
85
+ renderComponent();
86
+ expandSection();
87
+ await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' }));
88
+
89
+ const rowExpandButtons = screen.getAllByLabelText('Details');
90
+ fireEvent.click(rowExpandButtons[0]);
91
+
92
+ expect(await screen.findByText('Summary')).toBeInTheDocument();
93
+ expect(
94
+ await screen.findByText('Summary for report entry 1')
95
+ ).toBeInTheDocument();
96
+ });
97
+
98
+ it('expands all rows', async () => {
99
+ renderComponent();
100
+ expandSection();
101
+ await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' }));
65
102
 
66
- await waitFor(() => screen.getByText('Report Entry 1'));
103
+ const expandAllButton = screen.getByLabelText('Expand all rows');
104
+ fireEvent.click(expandAllButton);
67
105
 
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();
106
+ expect(
107
+ await screen.findByText('Summary for report entry 1')
108
+ ).toBeInTheDocument();
109
+ expect(
110
+ await screen.findByText('Summary for report entry 5')
111
+ ).toBeInTheDocument();
71
112
  });
72
113
 
73
114
  it('paginates to the next page', async () => {
74
115
  renderComponent();
75
116
  expandSection();
76
- await waitFor(() => screen.getByText('Report Entry 1'));
117
+ await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' }));
77
118
 
78
119
  fireEvent.click(screen.getAllByLabelText('Go to next page')[0]);
120
+ await waitFor(() => screen.getByText('Report Entry 6', { selector: 'td' }));
79
121
 
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();
122
+ expect(
123
+ screen.getByText('Report Entry 10', { selector: 'td' })
124
+ ).toBeInTheDocument();
83
125
  });
84
126
 
85
127
  it('changes perPage limit to 10', async () => {
86
128
  renderComponent();
87
129
  expandSection();
88
- await waitFor(() => screen.getByText('Report Entry 1'));
130
+ await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' }));
89
131
 
90
132
  fireEvent.click(screen.getAllByLabelText('Items per page')[0]);
91
133
  fireEvent.click(screen.getAllByText('10 per page')[0]);
92
134
 
93
135
  await waitFor(() => {
94
- expect(screen.getByText('Report Entry 10')).toBeInTheDocument();
95
- expect(screen.queryByText('Report Entry 11')).not.toBeInTheDocument();
136
+ expect(
137
+ screen.getByText('Report Entry 10', { selector: 'td' })
138
+ ).toBeInTheDocument();
96
139
  });
97
140
  });
98
141
 
@@ -106,12 +149,38 @@ describe('PreupgradeReportsTable', () => {
106
149
  return { type: 'EMPTY' };
107
150
  };
108
151
  });
109
-
110
152
  renderComponent();
111
153
  expandSection();
112
-
113
154
  await waitFor(() => {
114
- expect(screen.getByText('The preupgrade report shows no issues.')).toBeInTheDocument();
155
+ expect(
156
+ screen.getByText('The preupgrade report shows no issues.')
157
+ ).toBeInTheDocument();
115
158
  });
116
159
  });
160
+
161
+ it('does not render anything for non-Leapp jobs', () => {
162
+ const nonLeappData = { id: 55, template_name: 'Standard RHEL Update' };
163
+ renderComponent(nonLeappData);
164
+ expect(
165
+ screen.queryByText('Leapp preupgrade report')
166
+ ).not.toBeInTheDocument();
167
+ });
168
+
169
+ it('displays correct inhibitor status based on flags', async () => {
170
+ renderComponent();
171
+ expandSection();
172
+ await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' }));
173
+
174
+ const row1 = screen
175
+ .getByText('Report Entry 1', { selector: 'td' })
176
+ .closest('tr');
177
+ const inhibitorCell1 = row1.querySelector('td[data-label="Inhibitor?"]');
178
+ expect(within(inhibitorCell1).getByText('Yes')).toBeInTheDocument();
179
+
180
+ const row2 = screen
181
+ .getByText('Report Entry 2', { selector: 'td' })
182
+ .closest('tr');
183
+ const inhibitorCell2 = row2.querySelector('td[data-label="Inhibitor?"]');
184
+ expect(within(inhibitorCell2).getByText('No')).toBeInTheDocument();
185
+ });
117
186
  });
@@ -1,35 +1,25 @@
1
1
  import PropTypes from 'prop-types';
2
2
  import React, { useEffect, useState } from 'react';
3
3
  import { useDispatch } from 'react-redux';
4
- import { ExpandableSection, Label, Tooltip } from '@patternfly/react-core';
4
+ import { ExpandableSection, Tooltip } from '@patternfly/react-core';
5
+ import { ExpandableRowContent, Tbody, Td, Tr } from '@patternfly/react-table';
5
6
  import { translate as __ } from 'foremanReact/common/I18n';
6
7
  import { Table } from 'foremanReact/components/PF4/TableIndexPage/Table/Table';
8
+ import { getColumnHelpers } from 'foremanReact/components/PF4/TableIndexPage/Table/helpers';
7
9
  import { APIActions } from 'foremanReact/redux/API';
8
10
  import { STATUS } from 'foremanReact/constants';
9
-
10
11
  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
- }
25
- };
12
+ import ReportDetails, { renderSeverityLabel } from './ReportDetails';
26
13
 
27
14
  const PreupgradeReportsTable = ({ data = {} }) => {
28
15
  const [error, setError] = useState(null);
29
- const [isExpanded, setIsExpanded] = useState(false);
16
+
17
+ const [isReportExpanded, setIsReportExpanded] = useState(false); // Outer expansion state (Leapp Report Section)
30
18
  const [pagination, setPagination] = useState({ page: 1, perPage: 5 });
31
19
  const [reportData, setReportData] = useState(null);
32
20
  const [status, setStatus] = useState(STATUS.RESOLVED);
21
+ const [expandedRowIds, setExpandedRowIds] = useState(new Set()); // Inner table expansion state (Rows)
22
+
33
23
  const dispatch = useDispatch();
34
24
  // eslint-disable-next-line camelcase
35
25
  const isLeappJob = data?.template_name?.includes('Run preupgrade via Leapp');
@@ -67,7 +57,7 @@ const PreupgradeReportsTable = ({ data = {} }) => {
67
57
 
68
58
  useEffect(() => {
69
59
  let isMounted = true;
70
- if (!isLeappJob || !isExpanded || reportData) {
60
+ if (!isLeappJob || !isReportExpanded || reportData) {
71
61
  return undefined;
72
62
  }
73
63
  setStatus(STATUS.PENDING);
@@ -117,7 +107,7 @@ const PreupgradeReportsTable = ({ data = {} }) => {
117
107
  return () => {
118
108
  isMounted = false;
119
109
  };
120
- }, [isExpanded, data.id, isLeappJob, reportData, dispatch]);
110
+ }, [isReportExpanded, data.id, isLeappJob, reportData, dispatch]);
121
111
 
122
112
  // eslint-disable-next-line camelcase
123
113
  const entries = reportData?.preupgrade_report_entries || [];
@@ -129,15 +119,43 @@ const PreupgradeReportsTable = ({ data = {} }) => {
129
119
  page: newParams.page || prev.page,
130
120
  perPage: newParams.per_page || prev.perPage,
131
121
  }));
122
+ setExpandedRowIds(new Set());
132
123
  };
133
124
 
125
+ const toggleRowExpansion = (id, isExpanding) => {
126
+ setExpandedRowIds(prev => {
127
+ const newSet = new Set(prev);
128
+ if (isExpanding) {
129
+ newSet.add(id);
130
+ } else {
131
+ newSet.delete(id);
132
+ }
133
+ return newSet;
134
+ });
135
+ };
136
+
137
+ const areAllRowsExpanded =
138
+ pagedEntries.length > 0 &&
139
+ pagedEntries.every(entry => expandedRowIds.has(entry.id));
140
+
141
+ const onExpandAll = () => {
142
+ setExpandedRowIds(() => {
143
+ if (areAllRowsExpanded) {
144
+ return new Set();
145
+ }
146
+ return new Set(pagedEntries.map(e => e.id));
147
+ });
148
+ };
149
+
150
+ const [columnKeys, keysToColumnNames] = getColumnHelpers(columns);
151
+
134
152
  if (!isLeappJob) return null;
135
153
 
136
154
  return (
137
155
  <ExpandableSection
138
156
  className="leapp-report-section"
139
- isExpanded={isExpanded}
140
- onToggle={(_event, val) => setIsExpanded(val)}
157
+ isExpanded={isReportExpanded}
158
+ onToggle={(_event, val) => setIsReportExpanded(val)}
141
159
  toggleText={__('Leapp preupgrade report')}
142
160
  >
143
161
  <Table
@@ -161,14 +179,53 @@ const PreupgradeReportsTable = ({ data = {} }) => {
161
179
  isDeleteable={false}
162
180
  emptyMessage={__('The preupgrade report shows no issues.')}
163
181
  setParams={handleParamsChange}
164
- />
182
+ childrenOutsideTbody
183
+ onExpandAll={onExpandAll}
184
+ // Inverted per PatternFly implementation to ensure correct toggle icon state
185
+ areAllRowsExpanded={!areAllRowsExpanded}
186
+ >
187
+ {pagedEntries.map((entry, rowIndex) => {
188
+ const isRowExpanded = expandedRowIds.has(entry.id);
189
+ return (
190
+ <Tbody key={entry.id} isExpanded={isRowExpanded}>
191
+ <Tr ouiaId={`table-row-${rowIndex}`}>
192
+ <Td
193
+ expand={{
194
+ rowIndex,
195
+ isExpanded: isRowExpanded,
196
+ onToggle: (_event, _rowIndex, isOpen) =>
197
+ toggleRowExpansion(entry.id, isOpen),
198
+ }}
199
+ />
200
+ {columnKeys.map(key => (
201
+ <Td key={key} dataLabel={keysToColumnNames[key]}>
202
+ {columns[key].wrapper
203
+ ? columns[key].wrapper(entry)
204
+ : entry[key]}
205
+ </Td>
206
+ ))}
207
+ </Tr>
208
+ <Tr
209
+ isExpanded={isRowExpanded}
210
+ ouiaId={`table-row-details-${rowIndex}`}
211
+ >
212
+ <Td colSpan={columnKeys.length + 1}>
213
+ <ExpandableRowContent>
214
+ {isRowExpanded && <ReportDetails entry={entry} />}
215
+ </ExpandableRowContent>
216
+ </Td>
217
+ </Tr>
218
+ </Tbody>
219
+ );
220
+ })}
221
+ </Table>
165
222
  </ExpandableSection>
166
223
  );
167
224
  };
168
225
 
169
226
  PreupgradeReportsTable.propTypes = {
170
227
  data: PropTypes.shape({
171
- id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
228
+ id: PropTypes.number,
172
229
  template_name: PropTypes.string,
173
230
  }),
174
231
  };
metadata CHANGED
@@ -1,7 +1,7 @@
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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foreman Leapp team
@@ -186,6 +186,8 @@ files:
186
186
  - webpack/components/PreupgradeReportsList/components/images/i_severity-low.svg
187
187
  - webpack/components/PreupgradeReportsList/components/images/i_severity-med.svg
188
188
  - webpack/components/PreupgradeReportsList/index.js
189
+ - webpack/components/PreupgradeReportsTable/PreupgradeReportsTable.scss
190
+ - webpack/components/PreupgradeReportsTable/ReportDetails.js
189
191
  - webpack/components/PreupgradeReportsTable/__tests__/PreupgradeReportsTable.test.js
190
192
  - webpack/components/PreupgradeReportsTable/index.js
191
193
  - webpack/consts.js