diff --git a/public/locales/en.json b/public/locales/en.json index 3c723272..bb60b2fc 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -1,4 +1,12 @@ { + "about": { + "browser-os": "Browser OS", + "browser-version": "Browser Version", + "copyright": "Copyright (c) {{year}} Red Hat Inc.", + "server-version": "Server Version", + "ui-version": "UI Version", + "username": "Username" + }, "form-dialog": { "confirmation_heading_delete-scan": "Are you sure you want to delete the scan <0>{{name}}?", "confirmation_heading_delete-credential": "Are you sure you want to delete the credential {{name}}?", diff --git a/src/components/aboutModal/__tests__/__snapshots__/aboutModal.test.tsx.snap b/src/components/aboutModal/__tests__/__snapshots__/aboutModal.test.tsx.snap new file mode 100644 index 00000000..e14e180a --- /dev/null +++ b/src/components/aboutModal/__tests__/__snapshots__/aboutModal.test.tsx.snap @@ -0,0 +1,111 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AboutModal Component should attempt to display username, status data: username and status 1`] = ` + + + + + + t([ + "about.username" +]) + + + lorem ipsum + + + + + t([ + "about.ui-version" +]) + + + 0.0.0.0000000 + + + + + t([ + "about.server-version" +]) + + + 0.0.0.12345678 + + + + + +`; + +exports[`AboutModal Component should render a basic component: basic 1`] = ` + + + + + + t([ + "about.ui-version" +]) + + + 0.0.0.0000000 + + + + + +`; diff --git a/src/components/aboutModal/__tests__/aboutModal.test.tsx b/src/components/aboutModal/__tests__/aboutModal.test.tsx new file mode 100644 index 00000000..5d688460 --- /dev/null +++ b/src/components/aboutModal/__tests__/aboutModal.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { shallowComponent } from '../../../../config/jest.setupTests'; +import { AboutModal } from '../aboutModal'; + +describe('AboutModal Component', () => { + it('should render a basic component', async () => { + const props = {}; + const component = await shallowComponent(); + expect(component).toMatchSnapshot('basic'); + }); + + it('should attempt to display username, status data', async () => { + const mockGetStatus = jest.fn().mockResolvedValue({ server_version: '0.0.0.12345678' }); + const mockUseStatusApi = jest.fn().mockReturnValue({ getStatus: mockGetStatus }); + const mockGetUser = jest.fn().mockResolvedValue('lorem ipsum'); + const mockUseUserApi = jest.fn().mockReturnValue({ getUser: mockGetUser }); + const props = { + isOpen: true, + useUser: mockUseUserApi, + useStatus: mockUseStatusApi + }; + + const component = await shallowComponent(); + expect(component).toMatchSnapshot('username and status'); + }); + + it('should call onClose', async () => { + const mockOnClose = jest.fn(); + const props = { + isOpen: true, + useUser: jest.fn().mockReturnValue({ getUser: jest.fn().mockResolvedValue('lorem ipsum') }), + useStatus: jest.fn().mockReturnValue({ getStatus: jest.fn().mockResolvedValue({}) }), + onClose: mockOnClose + }; + + render(); + + const user = userEvent.setup(); + await user.click(screen.getByLabelText('Close Dialog')); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/aboutModal/aboutModal.tsx b/src/components/aboutModal/aboutModal.tsx new file mode 100644 index 00000000..3f14b224 --- /dev/null +++ b/src/components/aboutModal/aboutModal.tsx @@ -0,0 +1,102 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AboutModal as PfAboutModal, TextContent, TextList, TextListItem } from '@patternfly/react-core'; +import { detect } from 'detect-browser'; +import moment from 'moment/moment'; +import { helpers } from '../../helpers'; +import { useUserApi } from '../../hooks/useLoginApi'; +import { useStatusApi, type ApiStatusSuccessType } from '../../hooks/useStatusApi'; +import backgroundImageSrc from '../../images/aboutBg.png'; + +interface AboutModalProps { + currentYear?: string; + isOpen?: boolean; + onClose?: () => void; + titleImg?: string; + uiName?: string; + uiVersion?: string; + useStatus?: typeof useStatusApi; + useUser?: typeof useUserApi; +} + +const AboutModal: React.FC = ({ + currentYear = moment.utc(helpers.getCurrentDate()).format('YYYY'), + isOpen = false, + onClose = Function.prototype, + titleImg = helpers.getTitleImg(), + uiName = helpers.UI_NAME, + uiVersion = helpers.UI_VERSION, + useStatus = useStatusApi, + useUser = useUserApi +}) => { + const { t } = useTranslation(); + const { getStatus } = useStatus(); + const { getUser } = useUser(); + const [userName, setUserName] = useState(); + const [stats, setStats] = useState(); + const browser = detect(); + const loadingClassName = (!stats && 'fadein') || ''; + + useEffect(() => { + if (isOpen && !stats) { + getUser().then(username => setUserName(username)); + getStatus().then( + data => setStats(data), + error => console.error(`About status error: ${error} `) + ); + } + }, [isOpen, getStatus, getUser, stats]); + + return ( + onClose()} + trademark={t('about.copyright', { year: currentYear })} + > + + + {userName && ( + + {t('about.username')} + {userName} + + )} + {browser && ( + + {t('about.browser-version')} + {`${browser.name} ${browser.version}`} + + )} + {browser && ( + + {t('about.browser-os')} + {browser.os || ''} + + )} + {uiVersion && ( + + {t('about.ui-version')} + {uiVersion} + + )} + {stats?.server_version && ( + + + {t('about.server-version')} + + + {stats.server_version} + + + )} + + + + ); +}; + +export { AboutModal as default, AboutModal, type AboutModalProps }; diff --git a/src/components/viewLayout/__tests__/__snapshots__/viewLayoutToolbar.test.tsx.snap b/src/components/viewLayout/__tests__/__snapshots__/viewLayoutToolbar.test.tsx.snap index 310d0abb..fc731fc7 100644 --- a/src/components/viewLayout/__tests__/__snapshots__/viewLayoutToolbar.test.tsx.snap +++ b/src/components/viewLayout/__tests__/__snapshots__/viewLayoutToolbar.test.tsx.snap @@ -12,70 +12,100 @@ exports[`ViewToolbar should attempt to load and display a username: user 1`] = ` `; exports[`ViewToolbar should render a basic component: basic 1`] = ` - - + - - - - - - - } - isSelected={true} - onChange={[Function]} - /> - - - + + + + + + + } + isSelected={true} + onChange={[Function]} + /> + + + + } + onChange={[Function]} + /> + + + + - - - + toggle={[Function]} + > + + About + + + + + - About + Logout @@ -97,21 +128,15 @@ exports[`ViewToolbar should render a basic component: basic 1`] = ` - - - - - Logout - - - - - + + + + `; diff --git a/src/components/viewLayout/__tests__/viewLayout.test.tsx b/src/components/viewLayout/__tests__/viewLayout.test.tsx index 534ed7b8..d29f8687 100644 --- a/src/components/viewLayout/__tests__/viewLayout.test.tsx +++ b/src/components/viewLayout/__tests__/viewLayout.test.tsx @@ -14,7 +14,7 @@ describe('ViewLayout', () => { it('should render a brand component', async () => { const props = { children: 'Lorem ipsum', - isBrand: true, + titleImg: 'titleBrand.svg', uiName: 'Discovery' }; const component = await shallowComponent(); diff --git a/src/components/viewLayout/viewLayout.tsx b/src/components/viewLayout/viewLayout.tsx index 0c401529..8346a4f3 100644 --- a/src/components/viewLayout/viewLayout.tsx +++ b/src/components/viewLayout/viewLayout.tsx @@ -28,18 +28,20 @@ import { } from '@patternfly/react-core'; import { BarsIcon } from '@patternfly/react-icons'; import { helpers } from '../../helpers'; -import titleImg from '../../images/title.svg'; -import titleImgBrand from '../../images/titleBrand.svg'; import { IAppRoute, IAppRouteGroup, routes } from '../../routes'; import { AppToolbar } from './viewLayoutToolbar'; interface AppLayoutProps { children: React.ReactNode; - isBrand?: boolean; + titleImg?: string; uiName?: string; } -const AppLayout: React.FC = ({ children, isBrand = helpers.UI_BRAND, uiName = helpers.UI_NAME }) => { +const AppLayout: React.FC = ({ + children, + titleImg = helpers.getTitleImg(), + uiName = helpers.UI_NAME +}) => { const { t } = useTranslation(); const [sidebarOpen, setSidebarOpen] = React.useState(true); @@ -53,7 +55,7 @@ const AppLayout: React.FC = ({ children, isBrand = helpers.UI_BR - + diff --git a/src/components/viewLayout/viewLayoutToolbar.tsx b/src/components/viewLayout/viewLayoutToolbar.tsx index f306e55d..db725df3 100644 --- a/src/components/viewLayout/viewLayoutToolbar.tsx +++ b/src/components/viewLayout/viewLayoutToolbar.tsx @@ -22,6 +22,7 @@ import { EllipsisVIcon, MoonIcon, QuestionCircleIcon, SunIcon } from '@patternfl import { useLogoutApi, useUserApi } from '../../hooks/useLoginApi'; import '@patternfly/react-styles/css/components/Avatar/avatar.css'; import './viewLayoutToolbar.css'; +import AboutModal from '../aboutModal/aboutModal'; interface AppToolbarProps { useLogout?: typeof useLogoutApi; @@ -33,6 +34,7 @@ const AppToolbar: React.FC = ({ useLogout = useLogoutApi, useUs const { getUser } = useUser(); const [userName, setUserName] = useState(); const [helpOpen, setHelpOpen] = useState(false); + const [aboutOpen, setAboutOpen] = useState(false); const [userDropdownOpen, setUserDropdownOpen] = useState(false); const [kebabDropdownOpen, setKebabDropdownOpen] = useState(false); const [isDarkTheme, setIsDarkTheme] = useState( @@ -56,7 +58,9 @@ const AppToolbar: React.FC = ({ useLogout = useLogoutApi, useUs }; applyTheme(isDarkTheme); - const onAbout = () => {}; + const onAbout = () => setAboutOpen(true); + + const onAboutClose = () => setAboutOpen(false); const onHelpSelect = ( _event: React.MouseEvent | undefined, @@ -75,86 +79,114 @@ const AppToolbar: React.FC = ({ useLogout = useLogoutApi, useUs }; return ( - - - - - - - - - - } - isSelected={!isDarkTheme} - onChange={() => { - setIsDarkTheme(false); - applyTheme(false); - }} - /> - - - - } - isSelected={isDarkTheme} - onChange={() => { - setIsDarkTheme(true); - applyTheme(true); - }} - /> - - - + + + + + + + + + + + } + isSelected={!isDarkTheme} + onChange={() => { + setIsDarkTheme(false); + applyTheme(false); + }} + /> + + + + } + isSelected={isDarkTheme} + onChange={() => { + setIsDarkTheme(true); + applyTheme(true); + }} + /> + + + + setHelpOpen(isOpen)} + isOpen={helpOpen} + toggle={toggleRef => ( + setHelpOpen(prev => !prev)} + isExpanded={helpOpen} + > + + + )} + > + + About + + + + + setHelpOpen(isOpen)} - isOpen={helpOpen} + onSelect={onUserDropdownSelect} + onOpenChange={(isOpen: boolean) => setKebabDropdownOpen(isOpen)} + isOpen={kebabDropdownOpen} toggle={toggleRef => ( setHelpOpen(prev => !prev)} - isExpanded={helpOpen} + onClick={() => setKebabDropdownOpen(prev => !prev)} + isExpanded={kebabDropdownOpen} + style={{ width: 'auto' }} + data-ouia-component-id="user_dropdown_button" > - + )} > - - About + + Logout - + setKebabDropdownOpen(isOpen)} - isOpen={kebabDropdownOpen} + onOpenChange={(isOpen: boolean) => setUserDropdownOpen(isOpen)} + isOpen={userDropdownOpen} toggle={toggleRef => ( setKebabDropdownOpen(prev => !prev)} - isExpanded={kebabDropdownOpen} - style={{ width: 'auto' }} + onClick={() => setUserDropdownOpen(prev => !prev)} + isExpanded={userDropdownOpen} data-ouia-component-id="user_dropdown_button" > - +
+ + {userName} +
)} > @@ -163,35 +195,10 @@ const AppToolbar: React.FC = ({ useLogout = useLogoutApi, useUs
-
- - setUserDropdownOpen(isOpen)} - isOpen={userDropdownOpen} - toggle={toggleRef => ( - setUserDropdownOpen(prev => !prev)} - isExpanded={userDropdownOpen} - data-ouia-component-id="user_dropdown_button" - > -
- - {userName} -
-
- )} - > - - Logout - -
-
-
-
+ + + + ); }; diff --git a/src/helpers/__tests__/__snapshots__/helpers.test.ts.snap b/src/helpers/__tests__/__snapshots__/helpers.test.ts.snap index bfc708c4..90c65678 100644 --- a/src/helpers/__tests__/__snapshots__/helpers.test.ts.snap +++ b/src/helpers/__tests__/__snapshots__/helpers.test.ts.snap @@ -11,6 +11,12 @@ exports[`getAuthType should return a credential type: credentialTypes 1`] = ` ] `; +exports[`getCurrentDate should return a predictable current date: current date 1`] = ` +{ + "currentDate": 2024-10-01T00:00:00.000Z, +} +`; + exports[`getTimeDisplayHowLongAgo should return a timestamp estimate: timestamps 1`] = ` [ "a few seconds ago", @@ -18,3 +24,15 @@ exports[`getTimeDisplayHowLongAgo should return a timestamp estimate: timestamps "a day ago", ] `; + +exports[`getTitleImg should return a brand title image: brand title image 1`] = ` +{ + "titleImg": "titleBrand.svg", +} +`; + +exports[`getTitleImg should return a title image: title image 1`] = ` +{ + "titleImg": "title.svg", +} +`; diff --git a/src/helpers/__tests__/helpers.test.ts b/src/helpers/__tests__/helpers.test.ts index 7a2766a9..d47f984a 100644 --- a/src/helpers/__tests__/helpers.test.ts +++ b/src/helpers/__tests__/helpers.test.ts @@ -64,6 +64,25 @@ describe('getAuthType', () => { }); }); +describe('getCurrentDate', () => { + it('should return a predictable current date', () => { + const currentDate = helpers.getCurrentDate(); + expect({ currentDate }).toMatchSnapshot('current date'); + }); +}); + +describe('getTitleImg', () => { + it('should return a title image', () => { + const titleImg = helpers.getTitleImg(); + expect({ titleImg }).toMatchSnapshot('title image'); + }); + + it('should return a brand title image', () => { + const titleImg = helpers.getTitleImg(true); + expect({ titleImg }).toMatchSnapshot('brand title image'); + }); +}); + describe('noopTranslate', () => { it('should format key, value, and components into a string', () => { const key = 'testKey'; diff --git a/src/helpers/helpers.ts b/src/helpers/helpers.ts index 473e3d23..9c59461e 100644 --- a/src/helpers/helpers.ts +++ b/src/helpers/helpers.ts @@ -7,6 +7,8 @@ */ 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'; /** @@ -39,6 +41,12 @@ const UI_BRAND = process.env.REACT_APP_UI_BRAND === 'true'; */ const UI_NAME = (UI_BRAND && process.env.REACT_APP_UI_BRAND_NAME) || `${process.env.REACT_APP_UI_NAME}`; +/** + * UI packaged application version, with generated hash. + * See dotenv config files for updating. See build scripts for generated hash. + */ +const UI_VERSION = process.env.REACT_APP_UI_VERSION; + /** * Generates a translation key for internationalization. * @@ -189,20 +197,38 @@ const downloadData = (data: string | ArrayBuffer | ArrayBufferView | Blob, fileN const generateId = (prefix = 'generatedid') => `${prefix}-${(process.env.REACT_APP_ENV !== 'test' && Math.ceil(1e5 * Math.random())) || ''}`; +/** + * Return a consistent current date + * + * @returns {string|Date} + */ +const getCurrentDate = () => (TEST_MODE && moment.utc('20241001').toDate()) || moment.utc().toDate(); + +/** + * Return a consistent title image + * + * @param {boolean} isBrand + * @returns {string} + */ +const getTitleImg = (isBrand = UI_BRAND) => ((isBrand && titleImgBrand) || titleImg) as string; + const helpers = { authType, downloadData, noopTranslate, generateId, getAuthType, + getCurrentDate, getTimeDisplayHowLongAgo, + getTitleImg, formatDate, normalizeTotal, DEV_MODE, PROD_MODE, TEST_MODE, UI_BRAND, - UI_NAME + UI_NAME, + UI_VERSION }; export { helpers as default, helpers }; diff --git a/src/hooks/__tests__/__snapshots__/useStatusApi.test.ts.snap b/src/hooks/__tests__/__snapshots__/useStatusApi.test.ts.snap new file mode 100644 index 00000000..bba30b6b --- /dev/null +++ b/src/hooks/__tests__/__snapshots__/useStatusApi.test.ts.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useStatusApi should attempt an api call to get a status: apiCall 1`] = ` +[ + [ + "/api/v1/status/", + ], +] +`; + +exports[`useStatusApi should handle errors while attempting to get a status: getStatus, error 1`] = ` +{ + "isAxiosError": true, + "message": "Mock error", +} +`; + +exports[`useStatusApi should handle success while attempting to get a status: getStatus, success 1`] = `undefined`; + +exports[`useStatusApi should process an API error response: callbackError 1`] = ` +{ + "response": { + "data": { + "message": "Dolor sit", + }, + }, +} +`; + +exports[`useStatusApi should process an API success response: callbackSuccess 1`] = ` +{ + "api_version": "lorem ipsum", + "server_version": "dolor sit", +} +`; diff --git a/src/hooks/__tests__/useStatusApi.test.ts b/src/hooks/__tests__/useStatusApi.test.ts new file mode 100644 index 00000000..be093f44 --- /dev/null +++ b/src/hooks/__tests__/useStatusApi.test.ts @@ -0,0 +1,60 @@ +import { renderHook } from '@testing-library/react'; +import axios from 'axios'; +import { useStatusApi } from '../useStatusApi'; + +describe('useStatusApi', () => { + let hookResult; + + beforeEach(() => { + const hook = renderHook(() => useStatusApi()); + hookResult = hook?.result?.current; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should attempt an api call to get a status', () => { + const { apiCall } = hookResult; + const spyAxios = jest.spyOn(axios, 'get'); + + apiCall(); + expect(spyAxios.mock.calls).toMatchSnapshot('apiCall'); + }); + + it('should handle success while attempting to get a status', async () => { + const { getStatus } = hookResult; + jest.spyOn(axios, 'get').mockImplementation(() => Promise.resolve({})); + + await expect(getStatus()).resolves.toMatchSnapshot('getStatus, success'); + }); + + it('should handle errors while attempting to get a status', async () => { + const { getStatus } = hookResult; + jest.spyOn(axios, 'get').mockImplementation(() => Promise.reject({ isAxiosError: true, message: 'Mock error' })); + + await expect(getStatus()).rejects.toMatchSnapshot('getStatus, error'); + }); + + it('should process an API success response', async () => { + const { callbackSuccess } = hookResult; + + expect(callbackSuccess({ data: { api_version: 'lorem ipsum', server_version: 'dolor sit' } })).toMatchSnapshot( + 'callbackSuccess' + ); + }); + + it('should process an API error response', async () => { + const { callbackError } = hookResult; + + await expect( + callbackError({ + response: { + data: { + message: 'Dolor sit' + } + } + }) + ).rejects.toMatchSnapshot('callbackError'); + }); +}); diff --git a/src/hooks/useStatusApi.ts b/src/hooks/useStatusApi.ts new file mode 100644 index 00000000..4729a75d --- /dev/null +++ b/src/hooks/useStatusApi.ts @@ -0,0 +1,52 @@ +import { useCallback } from 'react'; +import axios, { type AxiosError, type AxiosResponse, isAxiosError } from 'axios'; +import helpers from '../helpers'; + +type ApiStatusSuccessType = { + server_version: string; +}; + +type ApiStatusErrorType = { + detail?: string; + message: string; +}; + +/** + * A status API call + */ +const useStatusApi = () => { + const apiCall = useCallback( + (): Promise> => axios.get(`${process.env.REACT_APP_STATUS_SERVICE}`), + [] + ); + + const callbackSuccess = useCallback((response: AxiosResponse) => response?.data, []); + + const callbackError = useCallback((error: AxiosError) => { + return Promise.reject(error); + }, []); + + const getStatus = useCallback(async () => { + let response; + try { + response = await apiCall(); + } catch (error) { + if (isAxiosError(error)) { + return callbackError(error); + } + if (!helpers.TEST_MODE) { + console.error(error); + } + } + return callbackSuccess(response); + }, [apiCall, callbackSuccess, callbackError]); + + return { + apiCall, + callbackError, + callbackSuccess, + getStatus + }; +}; + +export { useStatusApi, type ApiStatusSuccessType }; diff --git a/tests/__snapshots__/code.test.ts.snap b/tests/__snapshots__/code.test.ts.snap index 80ec3335..fbb536dd 100644 --- a/tests/__snapshots__/code.test.ts.snap +++ b/tests/__snapshots__/code.test.ts.snap @@ -2,8 +2,9 @@ exports[`General code checks should only have specific console.[warn|log|info|error] methods: console methods 1`] = ` [ - "components/viewLayout/viewLayoutToolbar.tsx:65: console.log('selected', value);", - "components/viewLayout/viewLayoutToolbar.tsx:73: console.log('selected', value);", + "components/aboutModal/aboutModal.tsx:45: error => console.error(\`About status error: \${error} \`)", + "components/viewLayout/viewLayoutToolbar.tsx:69: console.log('selected', value);", + "components/viewLayout/viewLayoutToolbar.tsx:77: console.log('selected', value);", "helpers/queryHelpers.ts:70: console.log(\`Query: \`, query);", "helpers/queryHelpers.ts:75: console.error(error);", "hooks/useCredentialApi.ts:81: console.log(missingCredsMsg);", @@ -28,6 +29,7 @@ exports[`General code checks should only have specific console.[warn|log|info|er "hooks/useSourceApi.ts:127: console.error(error);", "hooks/useSourceApi.ts:191: console.error(error);", "hooks/useSourceApi.ts:255: console.error(error);", + "hooks/useStatusApi.ts:38: console.error(error);", "views/scans/showScansModal.tsx:79: console.log({ aValue, bValue });", "views/sources/addSourceModal.tsx:106: console.error(err);", ]