diff --git a/webpack/components/PreupgradeReportsTable/PreupgradeReportsTable.scss b/webpack/components/PreupgradeReportsTable/PreupgradeReportsTable.scss new file mode 100644 index 0000000..20abd21 --- /dev/null +++ b/webpack/components/PreupgradeReportsTable/PreupgradeReportsTable.scss @@ -0,0 +1,5 @@ +.leapp-report-details { + dd { + white-space: pre-wrap; + } +} diff --git a/webpack/components/PreupgradeReportsTable/ReportDetails.js b/webpack/components/PreupgradeReportsTable/ReportDetails.js new file mode 100644 index 0000000..0c38b88 --- /dev/null +++ b/webpack/components/PreupgradeReportsTable/ReportDetails.js @@ -0,0 +1,134 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, + Label, + LabelGroup, +} from '@patternfly/react-core'; +import { translate as __ } from 'foremanReact/common/I18n'; +import './PreupgradeReportsTable.scss'; + +export const renderSeverityLabel = severity => { + switch (severity) { + case 'high': + return ; + case 'medium': + return ; + case 'low': + return ; + case 'info': + return ; + default: + return ; + } +}; + +const ReportDetails = ({ entry }) => ( + + {entry.title && ( + + {__('Title')} + {entry.title} + + )} + + {entry.severity && ( + + {__('Risk Factor')} + + {renderSeverityLabel(entry.severity)} + + + )} + + {entry.summary && ( + + {__('Summary')} + {entry.summary} + + )} + + {entry.tags && entry.tags.length > 0 && ( + + {__('Tags')} + + + {entry.tags.map(tag => ( + + ))} + + + + )} + + {entry.detail?.external?.filter(link => link.url).length > 0 && ( + + {__('Links')} + + {entry.detail.external + .filter(link => link.url) + .map((item, i) => ( +
+ + {item.title || item.url} + +
+ ))} +
+
+ )} + + {entry.detail?.remediations?.length > 0 && + entry.detail.remediations.map((item, i) => ( + + + {item.type === 'command' ? __('Command') : __('Hint')} + + + {item.type === 'command' ? ( + + {Array.isArray(item.context) + ? item.context.join(' ') + : item.context} + + ) : ( + item.context + )} + + + ))} +
+); + +ReportDetails.propTypes = { + entry: PropTypes.shape({ + title: PropTypes.string, + severity: PropTypes.string, + summary: PropTypes.string, + tags: PropTypes.arrayOf(PropTypes.string), + detail: PropTypes.shape({ + external: PropTypes.arrayOf( + PropTypes.shape({ + url: PropTypes.string, + title: PropTypes.string, + }) + ), + remediations: PropTypes.arrayOf( + PropTypes.shape({ + type: PropTypes.string, + context: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + ]), + }) + ), + }), + }).isRequired, +}; + +export default ReportDetails; diff --git a/webpack/components/PreupgradeReportsTable/__tests__/PreupgradeReportsTable.test.js b/webpack/components/PreupgradeReportsTable/__tests__/PreupgradeReportsTable.test.js index ad33b0b..3b3a995 100644 --- a/webpack/components/PreupgradeReportsTable/__tests__/PreupgradeReportsTable.test.js +++ b/webpack/components/PreupgradeReportsTable/__tests__/PreupgradeReportsTable.test.js @@ -1,5 +1,11 @@ import React from 'react'; -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { + render, + screen, + waitFor, + fireEvent, + within, +} from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { Provider } from 'react-redux'; import configureMockStore from 'redux-mock-store'; @@ -23,8 +29,15 @@ const mockEntries = Array.from({ length: 12 }, (_, i) => ({ title: `Report Entry ${i + 1}`, hostname: 'example.com', severity: i === 0 ? 'high' : 'low', + summary: `Summary for report entry ${i + 1}`, + tags: i === 0 ? ['security', 'network'] : [], flags: i === 0 ? ['inhibitor'] : [], - detail: { remediations: i === 0 ? [{ type: 'cmd' }] : [] }, + detail: { + remediations: + i === 0 ? [{ type: 'command', context: ['echo', 'fix_command'] }] : [], + external: + i === 0 ? [{ url: 'http://example.com', title: 'External Link' }] : [], + }, })); describe('PreupgradeReportsTable', () => { @@ -48,10 +61,10 @@ describe('PreupgradeReportsTable', () => { }); }); - const renderComponent = () => + const renderComponent = (data = mockJobData) => render( - + ); @@ -62,37 +75,67 @@ describe('PreupgradeReportsTable', () => { it('renders data', async () => { renderComponent(); expandSection(); + await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' })); + expect( + screen.getByText('Report Entry 1', { selector: 'td' }) + ).toBeInTheDocument(); + }); + + it('expands a row and shows details', async () => { + renderComponent(); + expandSection(); + await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' })); + + const rowExpandButtons = screen.getAllByLabelText('Details'); + fireEvent.click(rowExpandButtons[0]); + + expect(await screen.findByText('Summary')).toBeInTheDocument(); + expect( + await screen.findByText('Summary for report entry 1') + ).toBeInTheDocument(); + }); + + it('expands all rows', async () => { + renderComponent(); + expandSection(); + await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' })); - await waitFor(() => screen.getByText('Report Entry 1')); + const expandAllButton = screen.getByLabelText('Expand all rows'); + fireEvent.click(expandAllButton); - expect(screen.getByText('Report Entry 1')).toBeInTheDocument(); - expect(screen.getByText('Report Entry 5')).toBeInTheDocument(); - expect(screen.queryByText('Report Entry 6')).not.toBeInTheDocument(); + expect( + await screen.findByText('Summary for report entry 1') + ).toBeInTheDocument(); + expect( + await screen.findByText('Summary for report entry 5') + ).toBeInTheDocument(); }); it('paginates to the next page', async () => { renderComponent(); expandSection(); - await waitFor(() => screen.getByText('Report Entry 1')); + await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' })); fireEvent.click(screen.getAllByLabelText('Go to next page')[0]); + await waitFor(() => screen.getByText('Report Entry 6', { selector: 'td' })); - await waitFor(() => screen.getByText('Report Entry 6')); - expect(screen.getByText('Report Entry 10')).toBeInTheDocument(); - expect(screen.queryByText('Report Entry 1')).not.toBeInTheDocument(); + expect( + screen.getByText('Report Entry 10', { selector: 'td' }) + ).toBeInTheDocument(); }); it('changes perPage limit to 10', async () => { renderComponent(); expandSection(); - await waitFor(() => screen.getByText('Report Entry 1')); + await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' })); fireEvent.click(screen.getAllByLabelText('Items per page')[0]); fireEvent.click(screen.getAllByText('10 per page')[0]); await waitFor(() => { - expect(screen.getByText('Report Entry 10')).toBeInTheDocument(); - expect(screen.queryByText('Report Entry 11')).not.toBeInTheDocument(); + expect( + screen.getByText('Report Entry 10', { selector: 'td' }) + ).toBeInTheDocument(); }); }); @@ -106,12 +149,38 @@ describe('PreupgradeReportsTable', () => { return { type: 'EMPTY' }; }; }); - renderComponent(); expandSection(); - await waitFor(() => { - expect(screen.getByText('The preupgrade report shows no issues.')).toBeInTheDocument(); + expect( + screen.getByText('The preupgrade report shows no issues.') + ).toBeInTheDocument(); }); }); + + it('does not render anything for non-Leapp jobs', () => { + const nonLeappData = { id: 55, template_name: 'Standard RHEL Update' }; + renderComponent(nonLeappData); + expect( + screen.queryByText('Leapp preupgrade report') + ).not.toBeInTheDocument(); + }); + + it('displays correct inhibitor status based on flags', async () => { + renderComponent(); + expandSection(); + await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' })); + + const row1 = screen + .getByText('Report Entry 1', { selector: 'td' }) + .closest('tr'); + const inhibitorCell1 = row1.querySelector('td[data-label="Inhibitor?"]'); + expect(within(inhibitorCell1).getByText('Yes')).toBeInTheDocument(); + + const row2 = screen + .getByText('Report Entry 2', { selector: 'td' }) + .closest('tr'); + const inhibitorCell2 = row2.querySelector('td[data-label="Inhibitor?"]'); + expect(within(inhibitorCell2).getByText('No')).toBeInTheDocument(); + }); }); diff --git a/webpack/components/PreupgradeReportsTable/index.js b/webpack/components/PreupgradeReportsTable/index.js index 2d81e64..a732e0f 100644 --- a/webpack/components/PreupgradeReportsTable/index.js +++ b/webpack/components/PreupgradeReportsTable/index.js @@ -1,35 +1,25 @@ import PropTypes from 'prop-types'; import React, { useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; -import { ExpandableSection, Label, Tooltip } from '@patternfly/react-core'; +import { ExpandableSection, Tooltip } from '@patternfly/react-core'; +import { ExpandableRowContent, Tbody, Td, Tr } from '@patternfly/react-table'; import { translate as __ } from 'foremanReact/common/I18n'; import { Table } from 'foremanReact/components/PF4/TableIndexPage/Table/Table'; +import { getColumnHelpers } from 'foremanReact/components/PF4/TableIndexPage/Table/helpers'; import { APIActions } from 'foremanReact/redux/API'; import { STATUS } from 'foremanReact/constants'; - import { entriesPage } from '../PreupgradeReports/PreupgradeReportsHelpers'; - -const renderSeverityLabel = severity => { - switch (severity) { - case 'high': - return ; - case 'medium': - return ; - case 'low': - return ; - case 'info': - return ; - default: - return ; - } -}; +import ReportDetails, { renderSeverityLabel } from './ReportDetails'; const PreupgradeReportsTable = ({ data = {} }) => { const [error, setError] = useState(null); - const [isExpanded, setIsExpanded] = useState(false); + + const [isReportExpanded, setIsReportExpanded] = useState(false); // Outer expansion state (Leapp Report Section) const [pagination, setPagination] = useState({ page: 1, perPage: 5 }); const [reportData, setReportData] = useState(null); const [status, setStatus] = useState(STATUS.RESOLVED); + const [expandedRowIds, setExpandedRowIds] = useState(new Set()); // Inner table expansion state (Rows) + const dispatch = useDispatch(); // eslint-disable-next-line camelcase const isLeappJob = data?.template_name?.includes('Run preupgrade via Leapp'); @@ -67,7 +57,7 @@ const PreupgradeReportsTable = ({ data = {} }) => { useEffect(() => { let isMounted = true; - if (!isLeappJob || !isExpanded || reportData) { + if (!isLeappJob || !isReportExpanded || reportData) { return undefined; } setStatus(STATUS.PENDING); @@ -117,7 +107,7 @@ const PreupgradeReportsTable = ({ data = {} }) => { return () => { isMounted = false; }; - }, [isExpanded, data.id, isLeappJob, reportData, dispatch]); + }, [isReportExpanded, data.id, isLeappJob, reportData, dispatch]); // eslint-disable-next-line camelcase const entries = reportData?.preupgrade_report_entries || []; @@ -129,15 +119,43 @@ const PreupgradeReportsTable = ({ data = {} }) => { page: newParams.page || prev.page, perPage: newParams.per_page || prev.perPage, })); + setExpandedRowIds(new Set()); }; + const toggleRowExpansion = (id, isExpanding) => { + setExpandedRowIds(prev => { + const newSet = new Set(prev); + if (isExpanding) { + newSet.add(id); + } else { + newSet.delete(id); + } + return newSet; + }); + }; + + const areAllRowsExpanded = + pagedEntries.length > 0 && + pagedEntries.every(entry => expandedRowIds.has(entry.id)); + + const onExpandAll = () => { + setExpandedRowIds(() => { + if (areAllRowsExpanded) { + return new Set(); + } + return new Set(pagedEntries.map(e => e.id)); + }); + }; + + const [columnKeys, keysToColumnNames] = getColumnHelpers(columns); + if (!isLeappJob) return null; return ( setIsExpanded(val)} + isExpanded={isReportExpanded} + onToggle={(_event, val) => setIsReportExpanded(val)} toggleText={__('Leapp preupgrade report')} > { isDeleteable={false} emptyMessage={__('The preupgrade report shows no issues.')} setParams={handleParamsChange} - /> + childrenOutsideTbody + onExpandAll={onExpandAll} + // Inverted per PatternFly implementation to ensure correct toggle icon state + areAllRowsExpanded={!areAllRowsExpanded} + > + {pagedEntries.map((entry, rowIndex) => { + const isRowExpanded = expandedRowIds.has(entry.id); + return ( + + + + ))} + + + + + + ); + })} +
+ toggleRowExpansion(entry.id, isOpen), + }} + /> + {columnKeys.map(key => ( + + {columns[key].wrapper + ? columns[key].wrapper(entry) + : entry[key]} +
+ + {isRowExpanded && } + +
); }; PreupgradeReportsTable.propTypes = { data: PropTypes.shape({ - id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + id: PropTypes.number, template_name: PropTypes.string, }), };