diff --git a/babel.config.js b/babel.config.js index f053ebf7..35039952 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1 +1,11 @@ -module.exports = {}; + +module.exports = (api) => { + api.cache(true) + const presets = ["@babel/preset-react", "@babel/preset-env"]; + const plugins = ["@babel/plugin-transform-modules-commonjs"]; + + return { + presets, + plugins + }; +} diff --git a/config/cspell.config.json b/config/cspell.config.json index 2ce33a73..2f91aa89 100644 --- a/config/cspell.config.json +++ b/config/cspell.config.json @@ -15,6 +15,7 @@ "elit", "fadein", "generatedid", + "haspopup", "labelledby", "mturley", "nocolor", @@ -30,6 +31,7 @@ "rescan", "rhacs", "rowgroup", + "rowindex", "runas", "scanjob", "srcset", diff --git a/jest.config.js b/jest.config.js index 7bdae6c7..60d96dd5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -30,13 +30,16 @@ module.exports = { testMatch: ['/**/__tests__/**/*.{ts,tsx}', '/**/*.{spec,test}.{ts,tsx}'], testEnvironment: 'jsdom', transform: { - '^.+\\.(js|jsx|ts|tsx)$': '/node_modules/ts-jest', + '^.+\\.(jsx|ts|tsx)$': '/node_modules/ts-jest', + // @mturley-latest/react-table-batteries lib proved to be challenging to properly transform + // using ts-jest; for this reason, js files are transformed with babel for now; + '^.+\\.js$': '/node_modules/babel-jest', '^.+\\.css$': '/config/jest.transform.style.js', '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '/config/jest.transform.file.js' }, transformIgnorePatterns: [ - '[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$', + '[/\\\\]node_modules[/\\\\][/\\.+\\\\]*.+\\.(jsx|mjs|cjs|ts|tsx)$', '^.+\\.module\\.(css|sass|scss)$' ], - watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'] + watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'], }; diff --git a/public/locales/en.json b/public/locales/en.json index 506c1a81..d6e6c34c 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -173,6 +173,7 @@ "label_satellite": "Satellite", "label_rhacs": "RHACS", "label_rescan": "Rescan", + "label_download": "Download", "label_scan": "Scan", "label_source": "Source", "label_source_other": "Sources", diff --git a/src/components/actionMenu/actionMenu.tsx b/src/components/actionMenu/actionMenu.tsx index ba28f49a..448b9c96 100644 --- a/src/components/actionMenu/actionMenu.tsx +++ b/src/components/actionMenu/actionMenu.tsx @@ -11,7 +11,7 @@ import { EllipsisVIcon } from '@patternfly/react-icons'; interface ActionMenuProps { item: T; - actions: { label: string; onClick: (item: T) => void }[]; + actions: { label: string; onClick: (item: T) => void; disabled?: boolean }[]; } const ActionMenu = ({ item, actions }: ActionMenuProps) => { @@ -43,6 +43,7 @@ const ActionMenu = ({ item, actions }: ActionMenuProps) => { onClick={() => { a.onClick(item); }} + isDisabled={a.disabled} > {a.label} diff --git a/src/helpers/helpers.ts b/src/helpers/helpers.ts index 99ab9161..b72e4ca9 100644 --- a/src/helpers/helpers.ts +++ b/src/helpers/helpers.ts @@ -9,7 +9,7 @@ import React from 'react'; import moment, { type MomentInput } from 'moment'; import titleImg from '../images/title.svg'; import titleImgBrand from '../images/titleBrand.svg'; -import { type CredentialType } from '../types/types'; +import { type CredentialType, type MostRecentScan, type scanJob } from '../types/types'; /** * Is dev mode active. @@ -226,8 +226,19 @@ const getCurrentDate = () => (TEST_MODE && moment.utc('20241001').toDate()) || m */ const getTitleImg = (isBrand = UI_BRAND) => ((isBrand && titleImgBrand) || titleImg) as string; +/** + * Return if a report associated with given ScanJob can be downloaded + * + * @param {scanJob | MostRecentScan} job + * @returns {boolean} + */ +const canDownloadReport = (job?: scanJob | MostRecentScan) => { + return job?.status === 'completed' && job?.report_id !== undefined; +}; + const helpers = { authType, + canDownloadReport, downloadData, noopTranslate, generateId, diff --git a/src/types/types.ts b/src/types/types.ts index 1bbf1828..e0c52a57 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -174,12 +174,16 @@ export type ScanOptions = { export type scanJob = { id: number; report_id: number; + status: string; }; export type StatusDetails = { job_status_message: string; }; +// TODO: Considerations for a future refactor: merge and/or rename scanJob and MostRecentScan. +// Reason: the object returned from the api representing scanJob and MostRecentScan are the same; +// also, in quipucords jargon, MostRecentScan is a ScanJob, not a Scan. export type MostRecentScan = { id: number; report_id: number; diff --git a/src/views/scans/__tests__/__snapshots__/viewScansList.test.tsx.snap b/src/views/scans/__tests__/__snapshots__/viewScansList.test.tsx.snap new file mode 100644 index 00000000..406378f3 --- /dev/null +++ b/src/views/scans/__tests__/__snapshots__/viewScansList.test.tsx.snap @@ -0,0 +1,1653 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`view Scan List sanity check kebab menu: expanded kebab menu 1`] = ` + + + + + + downloadable scan + + + + + + + + + +
+
+ +
+
+ + +`; + +exports[`view Scan List sanity check kebab menu: kebab menu hidden 1`] = ` + + + + + + downloadable scan + + + + + + + + + + + +`; + +exports[`view Scan List sanity check rendered scanViewList: scanViewList 1`] = ` + +
+
+
+
+
+
+ +
+
+ +
+
+
+ + + + +
+
+
+
+ +
+
+ +
+
+
+
+ + 0 - 0 + + of + + 0 + + +
+
+ +
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + t([ + "table.header", + { + "context": "sources" + } +]) + + +
+ + + downloadable scan + + + + + + +
+ + + not downloadable 1 + + + + + + +
+ + + not downloadable 2 + + + + + + +
+ + + not downloadable 3 + + + + + + +
+ + + not downloadable 4 + + + + + + +
+
+
+ +
+ +
+
+
+`; diff --git a/src/views/scans/__tests__/showScansModal.test.tsx b/src/views/scans/__tests__/showScansModal.test.tsx index 10beaaf4..7bae02bf 100644 --- a/src/views/scans/__tests__/showScansModal.test.tsx +++ b/src/views/scans/__tests__/showScansModal.test.tsx @@ -16,7 +16,7 @@ describe('ShowScansModal', () => { scanJobs={[ { id: 12345, - status: 'DOLOR SIT', + status: 'completed', start_time: new Date('2024-09-06'), end_time: new Date('2024-09-06'), report_id: 67890 diff --git a/src/views/scans/__tests__/viewScansList.test.tsx b/src/views/scans/__tests__/viewScansList.test.tsx new file mode 100644 index 00000000..dfff26f7 --- /dev/null +++ b/src/views/scans/__tests__/viewScansList.test.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor, within, fireEvent, act } from '@testing-library/react'; +import axios from 'axios'; +import moment from 'moment'; +import helpers from '../../../helpers'; +import ScansListView from '../viewScansList'; + +/** + * ScanListView wrapped with QueryClientProvider + * + * @returns React.Component + */ +const wrappedScanViewList = () => { + // ScansListView requires a QueryClient and to be wrapped by QueryClientProvider in order + // to be properly rendered; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + + + ); +}; + +/** + * Returns the downloadButton of given scanName. + * + * @param {string} scanName + * @returns {object} (scanRow, downloadBtn, kebabMenu) + */ +const clickKebabMenu = scanName => { + const scanRow = screen.getByText(scanName).parentElement as HTMLElement; + const kebabBtn = within(scanRow).getByRole('button', { + name: /action menu toggle/i + }); + act(() => { + // click the kebab to make the menu visible + fireEvent.click(kebabBtn); + }); + // finally grab the download button. It should be the 3rd item on the menu + const downloadBtn = within(scanRow).getAllByRole('menuitem')[2]; + return { + scanRow, + downloadBtn, + kebabBtn + }; +}; + +const downloadableScanName = 'downloadable scan'; +const downloadableReportId = 42; + +const dummyScanList = [ + { + id: 1, + name: downloadableScanName, + sources: [1], + most_recent: { + report_id: downloadableReportId, + status: 'completed' + } + }, + { + id: 2, + name: 'not downloadable 1', + sources: [1], + most_recent: { + report_id: 2, + status: 'pending' + } + }, + { + id: 3, + name: 'not downloadable 2', + sources: [1], + most_recent: { + status: 'completed' + } + }, + { + id: 4, + sources: [1], + name: 'not downloadable 3', + most_recent: { + report_id: 4 + } + }, + { + id: 5, + sources: [1], + name: 'not downloadable 4' + } +]; + +describe('view Scan List', () => { + let asFragment; + const mockedDownloadReport = jest.fn(); + + beforeEach(async () => { + // we don't care about properly rendering timestamps in this test; mocking this + // helper makes our mock data simpler + jest.spyOn(helpers, 'getTimeDisplayHowLongAgo').mockImplementation(() => moment().from(moment())); + // we don't need to actually download anything either, so let's mock the downloader + // (this is a bit trickier because downloadReport is nested in another function) + const useScanApiModule = require('../../../hooks/useScanApi'); + jest.spyOn(useScanApiModule, 'useDownloadReportApi').mockImplementation(() => { + return { + downloadReport: mockedDownloadReport + }; + }); + // mock axios to return dummyScanList + jest.spyOn(axios, 'get').mockResolvedValue({ data: { results: dummyScanList } }); + // now let's render the ScanViewList... + const rendered = render(wrappedScanViewList()); + asFragment = rendered.asFragment; + // ...wait until data is loaded + await waitFor(() => { + rendered.getByText('downloadable scan'); + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('sanity check rendered scanViewList', () => { + // ...ensure axios was called + expect(axios.get).toHaveBeenCalledTimes(1); + // ...and make sure it matches our snapshot + expect(asFragment()).toMatchSnapshot('scanViewList'); + }); + + it('sanity check kebab menu', () => { + const { scanRow, kebabBtn } = clickKebabMenu(downloadableScanName); + expect(kebabBtn.getAttribute('aria-expanded')).toBe('true'); + expect(scanRow).toMatchSnapshot('expanded kebab menu'); + // clicking on it again should make it go away + act(() => { + fireEvent.click(kebabBtn); + }); + expect(kebabBtn.getAttribute('aria-expanded')).toBe('false'); + expect(scanRow).toMatchSnapshot('kebab menu hidden'); + }); + + it('ensure only the first scan has a downloadable report', async () => { + // see dummyScanList; only the first scan should have a downloadable report + // grab the download button for the first scan + const { downloadBtn } = clickKebabMenu(downloadableScanName); + // the button should not be disabled + expect(downloadBtn.hasAttribute('disabled')).toBeFalsy(); + // and clicking on it should... + act(() => { + fireEvent.click(downloadBtn); + }); + // ...download the report + expect(mockedDownloadReport).toHaveBeenCalledWith(downloadableReportId); + mockedDownloadReport.mockReset(); + // repeat the process for other scans, which should not be downloadable + const otherScans = [1, 2, 3, 4]; + otherScans.forEach(n => { + const { downloadBtn: button } = clickKebabMenu(`not downloadable ${n}`); + expect(button.hasAttribute('disabled')).toBeTruthy(); + act(() => { + fireEvent.click(downloadBtn); + }); + expect(mockedDownloadReport).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/views/scans/showScansModal.tsx b/src/views/scans/showScansModal.tsx index 2f9624a7..2f78437a 100644 --- a/src/views/scans/showScansModal.tsx +++ b/src/views/scans/showScansModal.tsx @@ -131,7 +131,7 @@ const ShowScansModal: React.FC = ({ {helpers.formatDate(job.end_time || job.start_time)} {job.status} - {job.report_id && job.end_time && ( + {helpers.canDownloadReport(job) && ( diff --git a/src/views/scans/viewScansList.tsx b/src/views/scans/viewScansList.tsx index 6b9097f0..8d5ba89c 100644 --- a/src/views/scans/viewScansList.tsx +++ b/src/views/scans/viewScansList.tsx @@ -250,6 +250,15 @@ const ScansListView: React.FunctionComponent = () => { setScanSelected(undefined); }); } + }, + { + label: t('table.label', { context: 'download' }), + disabled: !helpers.canDownloadReport(scan?.most_recent), + onClick: () => { + if (scan?.most_recent) { + downloadReport(scan.most_recent.report_id); + } + } } ]} />