diff --git a/app/views/api/v2/preupgrade_report_entries/base.json.rabl b/app/views/api/v2/preupgrade_report_entries/base.json.rabl index fa57ed55..4a8e6959 100644 --- a/app/views/api/v2/preupgrade_report_entries/base.json.rabl +++ b/app/views/api/v2/preupgrade_report_entries/base.json.rabl @@ -1,4 +1,4 @@ object @preupgrade_report_entry -attributes :id, :preupgrade_report_id, :host_id, :hostname, :title, :actor, :audience, +attributes :id, :detail, :preupgrade_report_id, :host_id, :hostname, :title, :actor, :audience, :severity, :leapp_run_id, :summary, :tags, :flags, :created_at, :updated_at diff --git a/lib/foreman_leapp/engine.rb b/lib/foreman_leapp/engine.rb index 3eaac295..8f23ff61 100644 --- a/lib/foreman_leapp/engine.rb +++ b/lib/foreman_leapp/engine.rb @@ -40,6 +40,8 @@ class Engine < ::Rails::Engine :resource_type => 'JobInvocation' end + register_global_js_file 'global' + describe_host do multiple_actions_provider :leapp_hosts_multiple_actions end diff --git a/webpack/__mocks__/foremanReact/common/I18n.js b/webpack/__mocks__/foremanReact/common/I18n.js deleted file mode 100644 index 6f1977b2..00000000 --- a/webpack/__mocks__/foremanReact/common/I18n.js +++ /dev/null @@ -1,2 +0,0 @@ -export const translate = val => val; -export const sprintf = val => val; diff --git a/webpack/__mocks__/foremanReact/components/Pagination.js b/webpack/__mocks__/foremanReact/components/Pagination.js deleted file mode 100644 index 75ee32ca..00000000 --- a/webpack/__mocks__/foremanReact/components/Pagination.js +++ /dev/null @@ -1,2 +0,0 @@ -const Pagination = () => jest.fn(); -export default Pagination; diff --git a/webpack/__mocks__/foremanReact/components/common/EmptyState.js b/webpack/__mocks__/foremanReact/components/common/EmptyState.js deleted file mode 100644 index 7d919622..00000000 --- a/webpack/__mocks__/foremanReact/components/common/EmptyState.js +++ /dev/null @@ -1 +0,0 @@ -export const EmptyStatePattern = () => jest.fn(); diff --git a/webpack/__mocks__/foremanReact/components/common/MessageBox.js b/webpack/__mocks__/foremanReact/components/common/MessageBox.js deleted file mode 100644 index 22cb59e7..00000000 --- a/webpack/__mocks__/foremanReact/components/common/MessageBox.js +++ /dev/null @@ -1,2 +0,0 @@ -const MessageBox = () => jest.fn(); -export default MessageBox; diff --git a/webpack/components/PreupgradeReports/PreupgradeReports.js b/webpack/components/PreupgradeReports/PreupgradeReports.js index 68847ad6..b38ab3a1 100644 --- a/webpack/components/PreupgradeReports/PreupgradeReports.js +++ b/webpack/components/PreupgradeReports/PreupgradeReports.js @@ -119,7 +119,7 @@ const withLoadingState = Component => componentProps => { idsForInvocationFromEntries(flattenEntries(reports)); export const entriesPage = (entries, pagination) => { - const offset = (pagination.page - 1) * pagination.per_page; + const offset = (pagination.page - 1) * pagination.perPage; - return entries.slice(offset, offset + pagination.per_page); + return entries.slice(offset, offset + pagination.perPage); }; export const filterEntries = (attribute, value, entries) => { diff --git a/webpack/components/PreupgradeReports/__tests__/__snapshots__/PreupgradeReports.test.js.snap b/webpack/components/PreupgradeReports/__tests__/__snapshots__/PreupgradeReports.test.js.snap index 2a35658c..e52fc099 100644 --- a/webpack/components/PreupgradeReports/__tests__/__snapshots__/PreupgradeReports.test.js.snap +++ b/webpack/components/PreupgradeReports/__tests__/__snapshots__/PreupgradeReports.test.js.snap @@ -4,7 +4,7 @@ exports[`PreupgradeReports should render error 1`] = ` `; diff --git a/webpack/components/PreupgradeReports/__tests__/__snapshots__/PreupgradeReportsHelpers.test.js.snap b/webpack/components/PreupgradeReports/__tests__/__snapshots__/PreupgradeReportsHelpers.test.js.snap index 333ffea1..0fa68f61 100644 --- a/webpack/components/PreupgradeReports/__tests__/__snapshots__/PreupgradeReportsHelpers.test.js.snap +++ b/webpack/components/PreupgradeReports/__tests__/__snapshots__/PreupgradeReportsHelpers.test.js.snap @@ -131,7 +131,31 @@ Array [ ] `; -exports[`PreupgradeReportsHelpers should return entries page 1`] = `Array []`; +exports[`PreupgradeReportsHelpers should return entries page 1`] = ` +Array [ + Object { + "flags": Array [], + "hostname": "foo.example.com", + "id": 45, + "severity": "low", + "title": "Not enough credits", + }, + Object { + "flags": Array [], + "hostname": "foo.example.com", + "id": 46, + "severity": "medium", + "title": "SELinux is turned off", + }, + Object { + "flags": Array [], + "hostname": "foo.example.com", + "id": 47, + "severity": "medium", + "title": "Root password is too short", + }, +] +`; exports[`PreupgradeReportsHelpers should return fixable entries 1`] = ` Array [ diff --git a/webpack/components/PreupgradeReports/components/__snapshots__/NoReports.test.js.snap b/webpack/components/PreupgradeReports/components/__snapshots__/NoReports.test.js.snap index c70281a2..094bb1e1 100644 --- a/webpack/components/PreupgradeReports/components/__snapshots__/NoReports.test.js.snap +++ b/webpack/components/PreupgradeReports/components/__snapshots__/NoReports.test.js.snap @@ -2,18 +2,24 @@ exports[`NoReports should render when reports expected 1`] = ` `; exports[`NoReports should render when reports not expected 1`] = ` `; diff --git a/webpack/components/PreupgradeReportsList/__tests__/PreupgradeReportsList.test.js b/webpack/components/PreupgradeReportsList/__tests__/PreupgradeReportsList.test.js index 0c803998..1646429f 100644 --- a/webpack/components/PreupgradeReportsList/__tests__/PreupgradeReportsList.test.js +++ b/webpack/components/PreupgradeReportsList/__tests__/PreupgradeReportsList.test.js @@ -1,40 +1,124 @@ -import { testComponentSnapshotsWithFixtures } from '@theforeman/test'; +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; import PreupgradeReportsList from '../index'; -const allEntries = [ - { title: 'Fix me!', severity: 'Too severe to talk about' }, - { title: 'I am broken too', severity: 'medium' }, - { title: 'Octocat is not happy', severity: 'high' }, - { title: 'Not enough credits', severity: 'low' }, -]; - -const isSelected = () => false; -const toggleSelected = () => {}; -const sort = { attribute: '', order: 'asc' }; -const changeSort = () => {}; -const toggleSelectAll = () => {}; - -const fixtures = { - 'should render': { - allEntries, - fixAllWorking: false, - isSelected, - toggleSelected, - sort, - changeSort, - toggleSelectAll, - }, - 'should render when working': { - allEntries, - fixAllWorking: true, - isSelected, - toggleSelected, - sort, - changeSort, - toggleSelectAll, - }, +jest.mock('foremanReact/components/Pagination', () => { + const MockPagination = () =>
Pagination
; + return MockPagination; +}); + +jest.mock('../components/images/i_severity-high.svg', () => 'severity-high.svg'); +jest.mock('../components/images/i_severity-med.svg', () => 'severity-med.svg'); +jest.mock('../components/images/i_severity-low.svg', () => 'severity-low.svg'); + +const createMockEntries = count => + Array.from({ length: count }, (_, i) => ({ + id: i + 1, + preupgradeReportId: 100, + title: `Entry ${i + 1}`, + hostname: `host${i + 1}.example.com`, + severity: i % 3 === 0 ? 'high' : i % 3 === 1 ? 'medium' : 'low', + flags: i === 0 ? ['inhibitor'] : [], + detail: { + remediations: + i < 2 + ? [{ type: 'command', context: ['echo', 'fix', 'command'] }] + : [], + }, + })); + +const defaultProps = { + allEntries: createMockEntries(4), + isSelected: () => false, + toggleSelected: jest.fn(), + sort: { attribute: '', order: 'asc' }, + changeSort: jest.fn(), + toggleSelectAll: jest.fn(), }; -describe('PreupgradeReportsList', () => - testComponentSnapshotsWithFixtures(PreupgradeReportsList, fixtures)); +const renderComponent = (props = {}) => + render(); + +describe('PreupgradeReportsList', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the list header', () => { + renderComponent(); + + expect(screen.getByText('Title')).toBeInTheDocument(); + expect(screen.getByText('Host')).toBeInTheDocument(); + expect(screen.getByText('Risk Factor')).toBeInTheDocument(); + expect(screen.getByText('Has Remediation?')).toBeInTheDocument(); + expect(screen.getByText('Inhibitor?')).toBeInTheDocument(); + }); + + it('renders entry titles', () => { + renderComponent(); + + expect(screen.getByText('Entry 1')).toBeInTheDocument(); + expect(screen.getByText('Entry 2')).toBeInTheDocument(); + expect(screen.getByText('Entry 3')).toBeInTheDocument(); + expect(screen.getByText('Entry 4')).toBeInTheDocument(); + }); + + it('renders entry hostnames', () => { + renderComponent(); + + expect(screen.getByText('host1.example.com')).toBeInTheDocument(); + expect(screen.getByText('host2.example.com')).toBeInTheDocument(); + }); + + it('renders checkboxes for entries', () => { + renderComponent(); + + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBeGreaterThan(0); + }); + + it('calls toggleSelectAll when header checkbox is clicked', () => { + const toggleSelectAll = jest.fn(); + renderComponent({ toggleSelectAll }); + + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + + expect(toggleSelectAll).toHaveBeenCalled(); + }); + + it('renders with empty entries array', () => { + renderComponent({ allEntries: [] }); + + expect(screen.getByText('Title')).toBeInTheDocument(); + expect(screen.queryByText('Entry 1')).not.toBeInTheDocument(); + }); + + it('renders entries with selected state', () => { + const isSelected = entry => entry.id === 1; + renderComponent({ isSelected }); + + const checkboxes = screen.getAllByRole('checkbox'); + const entryCheckboxes = checkboxes.slice(1); + expect(entryCheckboxes[0]).toBeChecked(); + expect(entryCheckboxes[1]).not.toBeChecked(); + }); + + it('calls toggleSelected when entry checkbox is clicked', () => { + const toggleSelected = jest.fn(); + renderComponent({ toggleSelected }); + + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[1]); + + expect(toggleSelected).toHaveBeenCalled(); + }); + + it('renders pagination component', () => { + renderComponent(); + + expect(screen.getByTestId('pagination')).toBeInTheDocument(); + }); +}); diff --git a/webpack/components/PreupgradeReportsList/__tests__/__snapshots__/PreupgradeReportsList.test.js.snap b/webpack/components/PreupgradeReportsList/__tests__/__snapshots__/PreupgradeReportsList.test.js.snap deleted file mode 100644 index f4e79bcf..00000000 --- a/webpack/components/PreupgradeReportsList/__tests__/__snapshots__/PreupgradeReportsList.test.js.snap +++ /dev/null @@ -1,135 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PreupgradeReportsList should render 1`] = ` - - - - - - - - -`; - -exports[`PreupgradeReportsList should render when working 1`] = ` - - - - - - - - -`; diff --git a/webpack/components/PreupgradeReportsList/index.js b/webpack/components/PreupgradeReportsList/index.js index e0785020..28ac5200 100644 --- a/webpack/components/PreupgradeReportsList/index.js +++ b/webpack/components/PreupgradeReportsList/index.js @@ -19,11 +19,10 @@ const PreupgradeReportsList = ({ changeSort, toggleSelectAll, }) => { - const { perPage, perPageOptions } = useForemanSettings(); + const { perPage } = useForemanSettings(); const [pagination, setPagination] = useState({ page: 1, - per_page: perPage, - perPageOptions, + perPage, }); return ( diff --git a/webpack/components/PreupgradeReportsTable/__tests__/PreupgradeReportsTable.test.js b/webpack/components/PreupgradeReportsTable/__tests__/PreupgradeReportsTable.test.js new file mode 100644 index 00000000..ad33b0bd --- /dev/null +++ b/webpack/components/PreupgradeReportsTable/__tests__/PreupgradeReportsTable.test.js @@ -0,0 +1,117 @@ +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { APIActions } from 'foremanReact/redux/API'; +import PreupgradeReportsTable from '../index'; + +jest.mock('foremanReact/redux/API'); + +const mockStore = configureMockStore([thunk]); + +const mockJobId = 42; +const mockReportId = 999; +const mockJobData = { + id: mockJobId, + template_name: 'Run preupgrade via Leapp', +}; + +const mockEntries = Array.from({ length: 12 }, (_, i) => ({ + id: i + 1, + title: `Report Entry ${i + 1}`, + hostname: 'example.com', + severity: i === 0 ? 'high' : 'low', + flags: i === 0 ? ['inhibitor'] : [], + detail: { remediations: i === 0 ? [{ type: 'cmd' }] : [] }, +})); + +describe('PreupgradeReportsTable', () => { + let store; + + beforeEach(() => { + store = mockStore({ API: {} }); + jest.clearAllMocks(); + + APIActions.get.mockImplementation(({ key, handleSuccess }) => { + return dispatch => { + if (key.includes('GET_LEAPP_REPORT_LIST')) + handleSuccess({ results: [{ id: mockReportId }] }); + if (key.includes('GET_LEAPP_REPORT_DETAIL')) + handleSuccess({ + id: mockReportId, + preupgrade_report_entries: mockEntries, + }); + return { type: 'MOCK_API_SUCCESS' }; + }; + }); + }); + + const renderComponent = () => + render( + + + + ); + + const expandSection = () => { + fireEvent.click(screen.getByText('Leapp preupgrade report')); + }; + + it('renders data', async () => { + renderComponent(); + expandSection(); + + await waitFor(() => screen.getByText('Report Entry 1')); + + expect(screen.getByText('Report Entry 1')).toBeInTheDocument(); + expect(screen.getByText('Report Entry 5')).toBeInTheDocument(); + expect(screen.queryByText('Report Entry 6')).not.toBeInTheDocument(); + }); + + it('paginates to the next page', async () => { + renderComponent(); + expandSection(); + await waitFor(() => screen.getByText('Report Entry 1')); + + fireEvent.click(screen.getAllByLabelText('Go to next page')[0]); + + await waitFor(() => screen.getByText('Report Entry 6')); + expect(screen.getByText('Report Entry 10')).toBeInTheDocument(); + expect(screen.queryByText('Report Entry 1')).not.toBeInTheDocument(); + }); + + it('changes perPage limit to 10', async () => { + renderComponent(); + expandSection(); + await waitFor(() => screen.getByText('Report Entry 1')); + + 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(); + }); + }); + + it('renders empty state message when no issues found', async () => { + APIActions.get.mockImplementation(({ key, handleSuccess }) => { + return () => { + if (key.includes('GET_LEAPP_REPORT_LIST')) + handleSuccess({ results: [{ id: mockReportId }] }); + if (key.includes('GET_LEAPP_REPORT_DETAIL')) + handleSuccess({ id: mockReportId, preupgrade_report_entries: [] }); + return { type: 'EMPTY' }; + }; + }); + + renderComponent(); + expandSection(); + + await waitFor(() => { + expect(screen.getByText('The preupgrade report shows no issues.')).toBeInTheDocument(); + }); + }); +}); diff --git a/webpack/components/PreupgradeReportsTable/index.js b/webpack/components/PreupgradeReportsTable/index.js new file mode 100644 index 00000000..2d81e64c --- /dev/null +++ b/webpack/components/PreupgradeReportsTable/index.js @@ -0,0 +1,180 @@ +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 { translate as __ } from 'foremanReact/common/I18n'; +import { Table } from 'foremanReact/components/PF4/TableIndexPage/Table/Table'; +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 ; + } +}; + +const PreupgradeReportsTable = ({ data = {} }) => { + const [error, setError] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); + const [pagination, setPagination] = useState({ page: 1, perPage: 5 }); + const [reportData, setReportData] = useState(null); + const [status, setStatus] = useState(STATUS.RESOLVED); + const dispatch = useDispatch(); + // eslint-disable-next-line camelcase + const isLeappJob = data?.template_name?.includes('Run preupgrade via Leapp'); + + const columns = { + title: { + title: __('Title'), + }, + host: { + title: __('Host'), + wrapper: entry => + entry.hostname || (reportData && reportData.hostname) || '-', + }, + risk_factor: { + title: __('Risk Factor'), + wrapper: ({ severity }) => renderSeverityLabel(severity), + }, + has_remediation: { + title: __('Has Remediation?'), + wrapper: entry => + entry.detail && entry.detail.remediations ? __('Yes') : __('No'), + }, + inhibitor: { + title: __('Inhibitor?'), + wrapper: entry => + entry.flags && entry.flags.some(flag => flag === 'inhibitor') ? ( + + {__('Yes')} + + ) : ( + __('No') + ), + }, + }; + + useEffect(() => { + let isMounted = true; + if (!isLeappJob || !isExpanded || reportData) { + return undefined; + } + setStatus(STATUS.PENDING); + + dispatch( + APIActions.get({ + key: `GET_LEAPP_REPORT_LIST_${data.id}`, + url: `/api/job_invocations/${data.id}/preupgrade_reports`, + handleSuccess: listResponse => { + if (!isMounted) return; + const listPayload = listResponse.data || listResponse; + const summary = listPayload.results?.[0]; + if (summary?.id) { + dispatch( + APIActions.get({ + key: `GET_LEAPP_REPORT_DETAIL_${summary.id}`, + url: `/api/preupgrade_reports/${summary.id}`, + handleSuccess: detailResponse => { + if (isMounted) { + const detailPayload = detailResponse.data || detailResponse; + setReportData(detailPayload); + setStatus(STATUS.RESOLVED); + } + }, + handleError: err => { + if (isMounted) { + setError(err); + setStatus(STATUS.ERROR); + } + }, + }) + ); + } else if (isMounted) { + setReportData({}); + setStatus(STATUS.RESOLVED); + } + }, + handleError: err => { + if (isMounted) { + setError(err); + setStatus(STATUS.ERROR); + } + }, + }) + ); + + return () => { + isMounted = false; + }; + }, [isExpanded, data.id, isLeappJob, reportData, dispatch]); + + // eslint-disable-next-line camelcase + const entries = reportData?.preupgrade_report_entries || []; + const pagedEntries = entriesPage(entries, pagination); + + const handleParamsChange = newParams => { + setPagination(prev => ({ + ...prev, + page: newParams.page || prev.page, + perPage: newParams.per_page || prev.perPage, + })); + }; + + if (!isLeappJob) return null; + + return ( + setIsExpanded(val)} + toggleText={__('Leapp preupgrade report')} + > + {}} + isDeleteable={false} + emptyMessage={__('The preupgrade report shows no issues.')} + setParams={handleParamsChange} + /> + + ); +}; + +PreupgradeReportsTable.propTypes = { + data: PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + template_name: PropTypes.string, + }), +}; + +PreupgradeReportsTable.defaultProps = { + data: {}, +}; + +export default PreupgradeReportsTable; diff --git a/webpack/global_index.js b/webpack/global_index.js new file mode 100644 index 00000000..e3258946 --- /dev/null +++ b/webpack/global_index.js @@ -0,0 +1,10 @@ +import React from 'react'; +import { addGlobalFill } from 'foremanReact/components/common/Fill/GlobalFill'; +import PreupgradeReportsTable from './components/PreupgradeReportsTable'; + +addGlobalFill( + 'job-invocation-additional-info', + 'leapp-preupgrade-report-fill', + , + 100 +);