diff --git a/awx/ui_next/.eslintrc b/awx/ui_next/.eslintrc index b7c86c305aaa..fc3fa694fd9e 100644 --- a/awx/ui_next/.eslintrc +++ b/awx/ui_next/.eslintrc @@ -78,7 +78,9 @@ "src", "theme", "gridColumns", - "rows" + "rows", + "href", + "modifier" ], "ignore": ["Ansible", "Tower", "JSON", "YAML", "lg"], "ignoreComponent": [ diff --git a/awx/ui_next/public/static/media/insights-analytics-dashboard.jpeg b/awx/ui_next/public/static/media/insights-analytics-dashboard.jpeg new file mode 100644 index 000000000000..f93808edbd6d Binary files /dev/null and b/awx/ui_next/public/static/media/insights-analytics-dashboard.jpeg differ diff --git a/awx/ui_next/src/App.jsx b/awx/ui_next/src/App.jsx index 5833714d893d..c054bd0dca0c 100644 --- a/awx/ui_next/src/App.jsx +++ b/awx/ui_next/src/App.jsx @@ -8,7 +8,9 @@ import { Redirect, } from 'react-router-dom'; import { I18n, I18nProvider } from '@lingui/react'; +import { Card, PageSection } from '@patternfly/react-core'; +import { ConfigProvider, useAuthorizedPath } from './contexts/Config'; import AppContainer from './components/AppContainer'; import Background from './components/Background'; import NotFound from './screens/NotFound'; @@ -20,6 +22,49 @@ import { isAuthenticated } from './util/auth'; import { getLanguageWithoutRegionCode } from './util/language'; import getRouteConfig from './routeConfig'; +import SubscriptionEdit from './screens/Setting/Subscription/SubscriptionEdit'; + +const AuthorizedRoutes = ({ routeConfig }) => { + const isAuthorized = useAuthorizedPath(); + const match = useRouteMatch(); + + if (!isAuthorized) { + return ( + + + + + + + + + + + + + ); + } + + return ( + + {routeConfig + .flatMap(({ routes }) => routes) + .map(({ path, screen: Screen }) => ( + + + + )) + .concat( + + + + )} + + ); +}; const ProtectedRoute = ({ children, ...rest }) => isAuthenticated(document.cookie) ? ( @@ -36,7 +81,6 @@ function App() { // preferred language, default to one that has strings. language = 'en'; } - const match = useRouteMatch(); const { hash, search, pathname } = useLocation(); return ( @@ -55,22 +99,11 @@ function App() { - - - {getRouteConfig(i18n) - .flatMap(({ routes }) => routes) - .map(({ path, screen: Screen }) => ( - - - - )) - .concat( - - - - )} - - + + + + + diff --git a/awx/ui_next/src/api/models/Config.js b/awx/ui_next/src/api/models/Config.js index 878ddfad70b1..704bb518ed15 100644 --- a/awx/ui_next/src/api/models/Config.js +++ b/awx/ui_next/src/api/models/Config.js @@ -6,6 +6,17 @@ class Config extends Base { this.baseUrl = '/api/v2/config/'; this.read = this.read.bind(this); } + + readSubscriptions(username, password) { + return this.http.post(`${this.baseUrl}subscriptions/`, { + subscriptions_username: username, + subscriptions_password: password, + }); + } + + attach(data) { + return this.http.post(`${this.baseUrl}attach/`, data); + } } export default Config; diff --git a/awx/ui_next/src/api/models/Settings.js b/awx/ui_next/src/api/models/Settings.js index 3c85f68da64c..55babf213d1b 100644 --- a/awx/ui_next/src/api/models/Settings.js +++ b/awx/ui_next/src/api/models/Settings.js @@ -14,6 +14,10 @@ class Settings extends Base { return this.http.patch(`${this.baseUrl}all/`, data); } + updateCategory(category, data) { + return this.http.patch(`${this.baseUrl}${category}/`, data); + } + readCategory(category) { return this.http.get(`${this.baseUrl}${category}/`); } diff --git a/awx/ui_next/src/components/AppContainer/AppContainer.jsx b/awx/ui_next/src/components/AppContainer/AppContainer.jsx index 6c4016ac9bee..4c290adb41fb 100644 --- a/awx/ui_next/src/components/AppContainer/AppContainer.jsx +++ b/awx/ui_next/src/components/AppContainer/AppContainer.jsx @@ -1,24 +1,26 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; -import { useHistory, useLocation, withRouter } from 'react-router-dom'; +import { useHistory, withRouter } from 'react-router-dom'; import { Button, Nav, NavList, Page, PageHeader as PFPageHeader, + PageHeaderTools, + PageHeaderToolsGroup, + PageHeaderToolsItem, PageSidebar, } from '@patternfly/react-core'; import { t } from '@lingui/macro'; import { withI18n } from '@lingui/react'; import styled from 'styled-components'; -import { ConfigAPI, MeAPI, RootAPI } from '../../api'; -import { ConfigProvider } from '../../contexts/Config'; +import { MeAPI, RootAPI } from '../../api'; +import { useConfig, useAuthorizedPath } from '../../contexts/Config'; import { SESSION_TIMEOUT_KEY } from '../../constants'; import { isAuthenticated } from '../../util/auth'; import About from '../About'; import AlertModal from '../AlertModal'; -import ErrorDetail from '../ErrorDetail'; import BrandLogo from './BrandLogo'; import NavExpandableGroup from './NavExpandableGroup'; import PageHeaderToolbar from './PageHeaderToolbar'; @@ -85,11 +87,11 @@ function useStorage(key) { function AppContainer({ i18n, navRouteConfig = [], children }) { const history = useHistory(); - const { pathname } = useLocation(); - const [config, setConfig] = useState({}); - const [configError, setConfigError] = useState(null); + const config = useConfig(); + + const isReady = !!config.license_info; + const isSidebarVisible = useAuthorizedPath(); const [isAboutModalOpen, setIsAboutModalOpen] = useState(false); - const [isReady, setIsReady] = useState(false); const sessionTimeoutId = useRef(); const sessionIntervalId = useRef(); @@ -99,7 +101,6 @@ function AppContainer({ i18n, navRouteConfig = [], children }) { const handleAboutModalOpen = () => setIsAboutModalOpen(true); const handleAboutModalClose = () => setIsAboutModalOpen(false); - const handleConfigErrorClose = () => setConfigError(null); const handleSessionTimeout = () => setTimeoutWarning(true); const handleLogout = useCallback(async () => { @@ -137,31 +138,6 @@ function AppContainer({ i18n, navRouteConfig = [], children }) { } }, [handleLogout, timeRemaining]); - useEffect(() => { - const loadConfig = async () => { - if (config?.version) return; - try { - const [ - { data }, - { - data: { - results: [me], - }, - }, - ] = await Promise.all([ConfigAPI.read(), MeAPI.read()]); - setConfig({ ...data, me }); - setIsReady(true); - } catch (err) { - if (err.response.status === 401) { - handleLogout(); - return; - } - setConfigError(err); - } - }; - loadConfig(); - }, [config, pathname, handleLogout]); - const header = ( ); + const simpleHeader = config.isLoading ? null : ( + } + headerTools={ + + + + + + + + } + /> + ); + const sidebar = ( - - {isReady && {children}} + + {isReady ? children : null} - - {i18n._(t`Failed to retrieve configuration.`)} - - ', () => { }, }); MeAPI.read.mockResolvedValue({ data: { results: [{}] } }); + useAuthorizedPath.mockImplementation(() => true); }); afterEach(() => { jest.clearAllMocks(); + jest.restoreAllMocks(); }); test('expected content is rendered', async () => { @@ -77,7 +80,9 @@ describe('', () => { let wrapper; await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts(, { + context: { config: { version } }, + }); }); // open about dropdown menu diff --git a/awx/ui_next/src/contexts/Config.jsx b/awx/ui_next/src/contexts/Config.jsx index e8674c955c1b..1511b236e879 100644 --- a/awx/ui_next/src/contexts/Config.jsx +++ b/awx/ui_next/src/contexts/Config.jsx @@ -1,8 +1,93 @@ -import React, { useContext } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo } from 'react'; +import { useLocation, useRouteMatch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; + +import { ConfigAPI, MeAPI, RootAPI } from '../api'; +import useRequest, { useDismissableError } from '../util/useRequest'; +import AlertModal from '../components/AlertModal'; +import ErrorDetail from '../components/ErrorDetail'; // eslint-disable-next-line import/prefer-default-export -export const ConfigContext = React.createContext({}); +export const ConfigContext = React.createContext([{}, () => {}]); +ConfigContext.displayName = 'ConfigContext'; -export const ConfigProvider = ConfigContext.Provider; export const Config = ConfigContext.Consumer; -export const useConfig = () => useContext(ConfigContext); +export const useConfig = () => { + const context = useContext(ConfigContext); + if (context === undefined) { + throw new Error('useConfig must be used within a ConfigProvider'); + } + return context; +}; + +export const ConfigProvider = withI18n()(({ i18n, children }) => { + const { pathname } = useLocation(); + + const { + error: configError, + isLoading, + request, + result: config, + setValue: setConfig, + } = useRequest( + useCallback(async () => { + const [ + { data }, + { + data: { + results: [me], + }, + }, + ] = await Promise.all([ConfigAPI.read(), MeAPI.read()]); + return { ...data, me }; + }, []), + {} + ); + + const { error, dismissError } = useDismissableError(configError); + + useEffect(() => { + if (pathname !== '/login') { + request(); + } + }, [request, pathname]); + + useEffect(() => { + if (error?.response?.status === 401) { + RootAPI.logout(); + } + }, [error]); + + const value = useMemo(() => ({ ...config, isLoading, setConfig }), [ + config, + isLoading, + setConfig, + ]); + + return ( + + {error && ( + + {i18n._(t`Failed to retrieve configuration.`)} + + + )} + {children} + + ); +}); + +export const useAuthorizedPath = () => { + const config = useConfig(); + const subscriptionMgmtRoute = useRouteMatch({ + path: '/subscription_management', + }); + return !!config.license_info?.valid_key && !subscriptionMgmtRoute; +}; diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx index 7b110ba3cefc..9ce8aa555ba1 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx @@ -5,7 +5,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Formik, useField, useFormikContext } from 'formik'; import { Form, FormGroup, Title } from '@patternfly/react-core'; -import { Config } from '../../../contexts/Config'; +import { useConfig } from '../../../contexts/Config'; import AnsibleSelect from '../../../components/AnsibleSelect'; import ContentError from '../../../components/ContentError'; import ContentLoading from '../../../components/ContentLoading'; @@ -298,6 +298,7 @@ function ProjectFormFields({ function ProjectForm({ i18n, project, submitError, ...props }) { const { handleCancel, handleSubmit } = props; const { summary_fields = {} } = project; + const { project_base_dir, project_local_paths } = useConfig(); const [contentError, setContentError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [scmSubFormState, setScmSubFormState] = useState({ @@ -352,61 +353,57 @@ function ProjectForm({ i18n, project, submitError, ...props }) { } return ( - - {({ project_base_dir, project_local_paths }) => ( - - {formik => ( -
- - - - - -
- )} -
+ + {formik => ( +
+ + + + + +
)} -
+ ); } diff --git a/awx/ui_next/src/screens/Setting/License/License.jsx b/awx/ui_next/src/screens/Setting/License/License.jsx deleted file mode 100644 index 1d92df41dc24..000000000000 --- a/awx/ui_next/src/screens/Setting/License/License.jsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import { Redirect, Route, Switch } from 'react-router-dom'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { PageSection, Card } from '@patternfly/react-core'; -import LicenseDetail from './LicenseDetail'; -import LicenseEdit from './LicenseEdit'; - -function License({ i18n }) { - const baseUrl = '/settings/license'; - - return ( - - - {i18n._(t`License settings`)} - - - - - - - - - - - - ); -} - -export default withI18n()(License); diff --git a/awx/ui_next/src/screens/Setting/License/License.test.jsx b/awx/ui_next/src/screens/Setting/License/License.test.jsx deleted file mode 100644 index 17388ebd2d6f..000000000000 --- a/awx/ui_next/src/screens/Setting/License/License.test.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import License from './License'; - -describe('', () => { - let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); - afterEach(() => { - wrapper.unmount(); - }); - test('initially renders without crashing', () => { - expect(wrapper.find('Card').text()).toContain('License settings'); - }); -}); diff --git a/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx b/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx deleted file mode 100644 index 73efe6d31a57..000000000000 --- a/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { Button } from '@patternfly/react-core'; -import { CardBody, CardActionsRow } from '../../../../components/Card'; - -function LicenseDetail({ i18n }) { - return ( - - {i18n._(t`Detail coming soon :)`)} - - - - - ); -} - -export default withI18n()(LicenseDetail); diff --git a/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.test.jsx b/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.test.jsx deleted file mode 100644 index f744cab07366..000000000000 --- a/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.test.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; -import LicenseDetail from './LicenseDetail'; - -describe('', () => { - let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); - afterEach(() => { - wrapper.unmount(); - }); - test('initially renders without crashing', () => { - expect(wrapper.find('LicenseDetail').length).toBe(1); - }); -}); diff --git a/awx/ui_next/src/screens/Setting/License/LicenseDetail/index.js b/awx/ui_next/src/screens/Setting/License/LicenseDetail/index.js deleted file mode 100644 index efe2514feda0..000000000000 --- a/awx/ui_next/src/screens/Setting/License/LicenseDetail/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './LicenseDetail'; diff --git a/awx/ui_next/src/screens/Setting/License/LicenseEdit/LicenseEdit.jsx b/awx/ui_next/src/screens/Setting/License/LicenseEdit/LicenseEdit.jsx deleted file mode 100644 index 38e4eca01473..000000000000 --- a/awx/ui_next/src/screens/Setting/License/LicenseEdit/LicenseEdit.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { Button } from '@patternfly/react-core'; -import { CardBody, CardActionsRow } from '../../../../components/Card'; - -function LicenseEdit({ i18n }) { - return ( - - {i18n._(t`Edit form coming soon :)`)} - - - - - ); -} - -export default withI18n()(LicenseEdit); diff --git a/awx/ui_next/src/screens/Setting/License/LicenseEdit/LicenseEdit.test.jsx b/awx/ui_next/src/screens/Setting/License/LicenseEdit/LicenseEdit.test.jsx deleted file mode 100644 index f1e616394874..000000000000 --- a/awx/ui_next/src/screens/Setting/License/LicenseEdit/LicenseEdit.test.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; -import LicenseEdit from './LicenseEdit'; - -describe('', () => { - let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); - afterEach(() => { - wrapper.unmount(); - }); - test('initially renders without crashing', () => { - expect(wrapper.find('LicenseEdit').length).toBe(1); - }); -}); diff --git a/awx/ui_next/src/screens/Setting/License/LicenseEdit/index.js b/awx/ui_next/src/screens/Setting/License/LicenseEdit/index.js deleted file mode 100644 index 04c3fcfb2469..000000000000 --- a/awx/ui_next/src/screens/Setting/License/LicenseEdit/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './LicenseEdit'; diff --git a/awx/ui_next/src/screens/Setting/License/index.js b/awx/ui_next/src/screens/Setting/License/index.js deleted file mode 100644 index 1bf99773e6f2..000000000000 --- a/awx/ui_next/src/screens/Setting/License/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './License'; diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx index 54eac90e9fe1..6b91aaa92197 100644 --- a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx @@ -55,6 +55,8 @@ function MiscSystemDetail({ i18n }) { 'REMOTE_HOST_HEADERS', 'SESSIONS_PER_USER', 'SESSION_COOKIE_AGE', + 'SUBSCRIPTIONS_USERNAME', + 'SUBSCRIPTIONS_PASSWORD', 'TOWER_URL_BASE' ); const systemData = { diff --git a/awx/ui_next/src/screens/Setting/SettingList.jsx b/awx/ui_next/src/screens/Setting/SettingList.jsx index 3284d48a7f8d..c72e1a37142a 100644 --- a/awx/ui_next/src/screens/Setting/SettingList.jsx +++ b/awx/ui_next/src/screens/Setting/SettingList.jsx @@ -32,15 +32,15 @@ const SplitLayout = styled(PageSection)` `; const Card = styled(_Card)` display: inline-block; + break-inside: avoid; margin-bottom: 24px; width: 100%; `; const CardHeader = styled(_CardHeader)` - align-items: flex-start; - display: flex; - flex-flow: column nowrap; - && > * { - padding: 0; + && { + align-items: flex-start; + display: flex; + flex-flow: column nowrap; } `; const CardDescription = styled.div` @@ -134,13 +134,13 @@ function SettingList({ i18n }) { ], }, { - header: i18n._(t`License`), - description: i18n._(t`View and edit your license information`), - id: 'license', + header: i18n._(t`Subscription`), + description: i18n._(t`View and edit your subscription information`), + id: 'subscription', routes: [ { - title: i18n._(t`License settings`), - path: '/settings/license', + title: i18n._(t`Subscription settings`), + path: '/settings/subscription', }, ], }, @@ -159,7 +159,10 @@ function SettingList({ i18n }) { return ( {settingRoutes.map(({ description, header, id, routes }) => { - if (id === 'license' && config?.license_info?.license_type === 'open') { + if ( + id === 'subscription' && + config?.license_info?.license_type === 'open' + ) { return null; } return ( diff --git a/awx/ui_next/src/screens/Setting/Settings.jsx b/awx/ui_next/src/screens/Setting/Settings.jsx index 8b9c2db33446..f359bbce1227 100644 --- a/awx/ui_next/src/screens/Setting/Settings.jsx +++ b/awx/ui_next/src/screens/Setting/Settings.jsx @@ -12,7 +12,7 @@ import GitHub from './GitHub'; import GoogleOAuth2 from './GoogleOAuth2'; import Jobs from './Jobs'; import LDAP from './LDAP'; -import License from './License'; +import Subscription from './Subscription'; import Logging from './Logging'; import MiscSystem from './MiscSystem'; import RADIUS from './RADIUS'; @@ -93,7 +93,6 @@ function Settings({ i18n }) { '/settings/ldap/3/edit': i18n._(t`Edit Details`), '/settings/ldap/4/edit': i18n._(t`Edit Details`), '/settings/ldap/5/edit': i18n._(t`Edit Details`), - '/settings/license': i18n._(t`License`), '/settings/logging': i18n._(t`Logging`), '/settings/logging/details': i18n._(t`Details`), '/settings/logging/edit': i18n._(t`Edit Details`), @@ -106,6 +105,9 @@ function Settings({ i18n }) { '/settings/saml': i18n._(t`SAML`), '/settings/saml/details': i18n._(t`Details`), '/settings/saml/edit': i18n._(t`Edit Details`), + '/settings/subscription': i18n._(t`Subscription`), + '/settings/subscription/details': i18n._(t`Details`), + '/settings/subscription/edit': i18n._(t`Edit Details`), '/settings/tacacs': i18n._(t`TACACS+`), '/settings/tacacs/details': i18n._(t`Details`), '/settings/tacacs/edit': i18n._(t`Edit Details`), @@ -160,11 +162,11 @@ function Settings({ i18n }) { - + {license_info?.license_type === 'open' ? ( - - ) : ( + ) : ( + )} diff --git a/awx/ui_next/src/screens/Setting/Subscription/Subscription.jsx b/awx/ui_next/src/screens/Setting/Subscription/Subscription.jsx new file mode 100644 index 000000000000..e7838927a7bb --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/Subscription.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Link, Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import SubscriptionDetail from './SubscriptionDetail'; +import SubscriptionEdit from './SubscriptionEdit'; +import ContentError from '../../../components/ContentError'; + +function Subscription({ i18n }) { + const baseURL = '/settings/subscription'; + const baseRoute = useRouteMatch({ + path: '/settings/subscription', + exact: true, + }); + + return ( + + + {baseRoute && } + + + + + + + + + + {i18n._(t`View Settings`)} + + + + + + ); +} + +export default withI18n()(Subscription); diff --git a/awx/ui_next/src/screens/Setting/Subscription/Subscription.test.jsx b/awx/ui_next/src/screens/Setting/Subscription/Subscription.test.jsx new file mode 100644 index 000000000000..ac46977f96c4 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/Subscription.test.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import mockAllSettings from '../shared/data.allSettings.json'; +import { SettingsAPI, RootAPI } from '../../../api'; +import Subscription from './Subscription'; + +jest.mock('../../../api'); +SettingsAPI.readCategory.mockResolvedValue({ + data: mockAllSettings, +}); +RootAPI.readAssetVariables.mockResolvedValue({ + data: { + BRAND_NAME: 'AWX', + PENDO_API_KEY: '', + }, +}); + +describe('', () => { + let wrapper; + + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('should redirect to subscription details', async () => { + const history = createMemoryHistory({ + initialEntries: ['/settings/subscription'], + }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { + router: { + history, + }, + config: { + license_info: { + license_type: 'enterprise', + }, + }, + }, + }); + }); + await waitForElement(wrapper, 'SubscriptionDetail', el => el.length === 1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionDetail/SubscriptionDetail.jsx b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionDetail/SubscriptionDetail.jsx new file mode 100644 index 000000000000..600f9103d797 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionDetail/SubscriptionDetail.jsx @@ -0,0 +1,166 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t, Trans } from '@lingui/macro'; +import { Button, Label } from '@patternfly/react-core'; +import { + CaretLeftIcon, + CheckIcon, + ExclamationCircleIcon, +} from '@patternfly/react-icons'; +import RoutedTabs from '../../../../components/RoutedTabs'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; +import { DetailList, Detail } from '../../../../components/DetailList'; +import { useConfig } from '../../../../contexts/Config'; +import { + formatDateString, + formatDateStringUTC, + secondsToDays, +} from '../../../../util/dates'; + +function SubscriptionDetail({ i18n }) { + const { license_info, version } = useConfig(); + const baseURL = '/settings/subscription'; + const tabsArray = [ + { + name: ( + <> + + {i18n._(t`Back to Settings`)} + + ), + link: '/settings', + id: 99, + }, + { + name: i18n._(t`Subscription Details`), + link: `${baseURL}/details`, + id: 0, + }, + ]; + + return ( + <> + + + + }> + {i18n._(t`Compliant`)} + + ) : ( + + ) + } + /> + + + + + + + + {license_info.instance_count < 9999999 && ( + + )} + {license_info.instance_count >= 9999999 && ( + + )} + + + +
+ + If you are ready to upgrade or renew, please{' '} + + + + + +
+ + ); +} + +export default withI18n()(SubscriptionDetail); diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionDetail/SubscriptionDetail.test.jsx b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionDetail/SubscriptionDetail.test.jsx new file mode 100644 index 000000000000..c693eb535480 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionDetail/SubscriptionDetail.test.jsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import SubscriptionDetail from './SubscriptionDetail'; + +const config = { + me: { + is_superuser: false, + }, + version: '1.2.3', + license_info: { + compliant: true, + current_instances: 1, + date_expired: false, + date_warning: true, + free_instances: 1000, + grace_period_remaining: 2904229, + instance_count: 1001, + license_date: '1614401999', + license_type: 'enterprise', + pool_id: '123', + product_name: 'Red Hat Ansible Automation, Standard (5000 Managed Nodes)', + satellite: false, + sku: 'ABC', + subscription_name: + 'Red Hat Ansible Automation, Standard (1001 Managed Nodes)', + support_level: null, + time_remaining: 312229, + trial: false, + valid_key: true, + }, +}; + +describe('', () => { + let wrapper; + + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts(, { + context: { config }, + }); + }); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('initially renders without crashing', () => { + expect(wrapper.find('SubscriptionDetail').length).toBe(1); + }); + + test('should render expected details', () => { + function assertDetail(label, value) { + expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); + expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); + } + assertDetail('Status', 'Compliant'); + assertDetail('Version', '1.2.3'); + assertDetail('Subscription type', 'enterprise'); + assertDetail( + 'Subscription', + 'Red Hat Ansible Automation, Standard (1001 Managed Nodes)' + ); + assertDetail('Trial', 'False'); + assertDetail('Expires on', '2/27/2021, 4:59:59 AM'); + assertDetail('Days remaining', '3'); + assertDetail('Hosts used', '1'); + assertDetail('Hosts remaining', '1000'); + + expect(wrapper.find('Button[aria-label="edit"]').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionDetail/index.js b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionDetail/index.js new file mode 100644 index 000000000000..9f45dc3c264c --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionDetail/index.js @@ -0,0 +1 @@ +export { default } from './SubscriptionDetail'; diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/AnalyticsStep.jsx b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/AnalyticsStep.jsx new file mode 100644 index 000000000000..b33c38fd3886 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/AnalyticsStep.jsx @@ -0,0 +1,134 @@ +import React, { useEffect } from 'react'; +import { withI18n } from '@lingui/react'; +import { Trans, t } from '@lingui/macro'; +import { useField } from 'formik'; +import { Button, Flex, FormGroup } from '@patternfly/react-core'; +import { required } from '../../../../util/validators'; +import FormField, { + CheckboxField, + PasswordField, +} from '../../../../components/FormField'; +import { useConfig } from '../../../../contexts/Config'; + +const ANALYTICSLINK = 'https://www.ansible.com/products/automation-analytics'; + +function AnalyticsStep({ i18n }) { + const config = useConfig(); + const [manifest] = useField({ + name: 'manifest_file', + }); + const [insights] = useField({ + name: 'insights', + }); + const [, , usernameHelpers] = useField({ + name: 'username', + }); + const [, , passwordHelpers] = useField({ + name: 'password', + }); + const requireCredentialFields = manifest.value && insights.value; + + useEffect(() => { + if (!requireCredentialFields) { + usernameHelpers.setValue(''); + passwordHelpers.setValue(''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [requireCredentialFields]); + + return ( + + User and Insights analytics +

+ + By default, Tower collects and transmits analytics data on Tower usage + to Red Hat. There are two categories of data collected by Tower. For + more information, see{' '} + + . Uncheck the following boxes to disable this feature. + +

+ + + + + + + {requireCredentialFields && ( + <> +
+

+ + Provide your Red Hat or Red Hat Satellite credentials to enable + Insights Analytics. + +

+ + + + )} + + {i18n._(t`Insights + + +
+ ); +} +export default withI18n()(AnalyticsStep); diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/AnalyticsStep.test.jsx b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/AnalyticsStep.test.jsx new file mode 100644 index 000000000000..039bea87fb16 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/AnalyticsStep.test.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import AnalyticsStep from './AnalyticsStep'; + +describe('', () => { + let wrapper; + + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('initially renders without crashing', async () => { + expect(wrapper.find('AnalyticsStep').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/EulaStep.jsx b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/EulaStep.jsx new file mode 100644 index 000000000000..06c8ee1ef581 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/EulaStep.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { Trans, t } from '@lingui/macro'; +import { useField } from 'formik'; +import { Flex, FormGroup, TextArea } from '@patternfly/react-core'; +import { required } from '../../../../util/validators'; +import { useConfig } from '../../../../contexts/Config'; +import { CheckboxField } from '../../../../components/FormField'; + +function EulaStep({ i18n }) { + const { eula, me } = useConfig(); + const [, meta] = useField('eula'); + const isValid = !(meta.touched && meta.error); + return ( + + + Agree to the end user license agreement and click submit. + + + + + + + ); +} +export default withI18n()(EulaStep); diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/EulaStep.test.jsx b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/EulaStep.test.jsx new file mode 100644 index 000000000000..ebb2370dd6b7 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/EulaStep.test.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import EulaStep from './EulaStep'; + +describe('', () => { + let wrapper; + + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('initially renders without crashing', async () => { + expect(wrapper.find('EulaStep').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionEdit.jsx b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionEdit.jsx new file mode 100644 index 000000000000..68e4c48b6f38 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionEdit.jsx @@ -0,0 +1,292 @@ +import React, { useCallback, useEffect } from 'react'; +import { useHistory, Link, useRouteMatch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t, Trans } from '@lingui/macro'; +import { Formik, useFormikContext } from 'formik'; +import { + Alert, + AlertGroup, + Button, + Form, + Wizard, + WizardContextConsumer, + WizardFooter, +} from '@patternfly/react-core'; +import { ConfigAPI, SettingsAPI, MeAPI, RootAPI } from '../../../../api'; +import useRequest, { useDismissableError } from '../../../../util/useRequest'; +import ContentLoading from '../../../../components/ContentLoading'; +import ContentError from '../../../../components/ContentError'; +import { FormSubmitError } from '../../../../components/FormField'; +import { useConfig } from '../../../../contexts/Config'; +import issuePendoIdentity from './pendoUtils'; +import SubscriptionStep from './SubscriptionStep'; +import AnalyticsStep from './AnalyticsStep'; +import EulaStep from './EulaStep'; + +const CustomFooter = withI18n()(({ i18n, isSubmitLoading }) => { + const { values, errors } = useFormikContext(); + const { me, license_info } = useConfig(); + const history = useHistory(); + + return ( + + + {({ activeStep, onNext, onBack }) => ( + <> + {activeStep.id === 'eula-step' ? ( + + ) : ( + + )} + + {license_info?.valid_key && ( + + )} + + )} + + + ); +}); + +function SubscriptionEdit({ i18n }) { + const history = useHistory(); + const { license_info, setConfig } = useConfig(); + const hasValidKey = Boolean(license_info?.valid_key); + const subscriptionMgmtRoute = useRouteMatch({ + path: '/subscription_management', + }); + + const { + isLoading: isContentLoading, + error: contentError, + request: fetchContent, + result: { brandName, pendoApiKey }, + } = useRequest( + useCallback(async () => { + const { + data: { BRAND_NAME, PENDO_API_KEY }, + } = await RootAPI.readAssetVariables(); + return { + brandName: BRAND_NAME, + pendoApiKey: PENDO_API_KEY, + }; + }, []), + { + brandName: null, + pendoApiKey: null, + } + ); + + useEffect(() => { + if (subscriptionMgmtRoute && hasValidKey) { + history.push('/settings/subscription/edit'); + } + fetchContent(); + }, [fetchContent]); // eslint-disable-line react-hooks/exhaustive-deps + + const { + error: submitError, + isLoading: submitLoading, + result: submitSuccessful, + request: submitRequest, + } = useRequest( + useCallback(async form => { + if (form.manifest_file) { + await ConfigAPI.create({ + manifest: form.manifest_file, + eula_accepted: form.eula, + }); + } else if (form.subscription) { + await ConfigAPI.attach({ pool_id: form.subscription.pool_id }); + await ConfigAPI.create({ + eula_accepted: form.eula, + }); + } + + const [ + { data }, + { + data: { + results: [me], + }, + }, + ] = await Promise.all([ConfigAPI.read(), MeAPI.read()]); + const newConfig = { ...data, me }; + setConfig(newConfig); + + if (!hasValidKey) { + if (form.pendo) { + await SettingsAPI.updateCategory('ui', { + PENDO_TRACKING_STATE: 'detailed', + }); + await issuePendoIdentity(newConfig, pendoApiKey); + } else { + await SettingsAPI.updateCategory('ui', { + PENDO_TRACKING_STATE: 'off', + }); + } + + if (form.insights) { + await SettingsAPI.updateCategory('system', { + INSIGHTS_TRACKING_STATE: true, + }); + } else { + await SettingsAPI.updateCategory('system', { + INSIGHTS_TRACKING_STATE: false, + }); + } + } + return true; + }, []) // eslint-disable-line react-hooks/exhaustive-deps + ); + + useEffect(() => { + if (submitSuccessful) { + setTimeout(() => { + history.push( + subscriptionMgmtRoute ? '/home' : '/settings/subscription/details' + ); + }, 3000); + } + }, [submitSuccessful, history, subscriptionMgmtRoute]); + + const { error, dismissError } = useDismissableError(submitError); + const handleSubmit = async values => { + dismissError(); + await submitRequest(values); + }; + + if (isContentLoading) { + return ; + } + + if (contentError) { + return ; + } + + const steps = [ + { + name: hasValidKey + ? i18n._(t`Subscription Management`) + : `${brandName} ${i18n._(t`Subscription`)}`, + id: 'subscription-step', + component: , + }, + ...(!hasValidKey + ? [ + { + name: i18n._(t`User and Insights analytics`), + id: 'analytics-step', + component: , + }, + ] + : []), + { + name: i18n._(t`End user license agreement`), + component: , + id: 'eula-step', + nextButtonText: i18n._(t`Submit`), + }, + ]; + + return ( + <> + + {formik => ( +
{ + e.preventDefault(); + }} + > + } + height="fit-content" + /> + {error && ( +
+ +
+ )} + + )} +
+ + {submitSuccessful && ( + + {subscriptionMgmtRoute ? ( + + Redirecting to dashboard + + ) : ( + + Redirecting to subscription detail + + )} + + )} + + + ); +} + +export default withI18n()(SubscriptionEdit); diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionEdit.test.jsx b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionEdit.test.jsx new file mode 100644 index 000000000000..84b2bb84a3bd --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionEdit.test.jsx @@ -0,0 +1,459 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../../testUtils/enzymeHelpers'; +import { + ConfigAPI, + MeAPI, + SettingsAPI, + RootAPI, + UsersAPI, +} from '../../../../api'; +import SubscriptionEdit from './SubscriptionEdit'; + +jest.mock('./bootstrapPendo'); +jest.mock('../../../../api'); +RootAPI.readAssetVariables.mockResolvedValue({ + data: { + BRAND_NAME: 'Mock', + PENDO_API_KEY: '', + }, +}); + +const mockConfig = { + me: { + is_superuser: true, + }, + license_info: { + compliant: true, + current_instances: 1, + date_expired: false, + date_warning: true, + free_instances: 1000, + grace_period_remaining: 2904229, + instance_count: 1001, + license_date: '1614401999', + license_type: 'enterprise', + pool_id: '123', + product_name: 'Red Hat Ansible Automation, Standard (5000 Managed Nodes)', + satellite: false, + sku: 'ABC', + subscription_name: + 'Red Hat Ansible Automation, Standard (1001 Managed Nodes)', + support_level: null, + time_remaining: 312229, + trial: false, + valid_key: true, + }, + analytics_status: 'detailed', + version: '1.2.3', +}; + +const emptyConfig = { + me: { + is_superuser: true, + }, + license_info: { + valid_key: false, + }, +}; + +describe('', () => { + describe('installing a fresh subscription', () => { + let wrapper; + let history; + + beforeAll(async () => { + SettingsAPI.readCategory.mockResolvedValue({ + data: {}, + }); + history = createMemoryHistory({ + initialEntries: ['/settings/subscription_managment'], + }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { + config: emptyConfig, + router: { history }, + }, + }); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('initially renders without crashing', () => { + expect(wrapper.find('SubscriptionEdit').length).toBe(1); + }); + + test('should show all wizard steps when it is a trial or a fresh installation', () => { + expect( + wrapper.find('WizardNavItem[content="Mock Subscription"]').length + ).toBe(1); + expect( + wrapper.find('WizardNavItem[content="User and Insights analytics"]') + .length + ).toBe(1); + expect( + wrapper.find('WizardNavItem[content="End user license agreement"]') + .length + ).toBe(1); + expect( + wrapper.find('button[aria-label="Cancel subscription edit"]').length + ).toBe(0); + }); + + test('subscription selection type toggle should default to manifest', () => { + expect( + wrapper + .find('ToggleGroupItem') + .first() + .text() + ).toBe('Subscription manifest'); + expect( + wrapper + .find('ToggleGroupItem') + .first() + .props().isSelected + ).toBe(true); + expect( + wrapper + .find('ToggleGroupItem') + .last() + .text() + ).toBe('Username / password'); + expect( + wrapper + .find('ToggleGroupItem') + .last() + .props().isSelected + ).toBe(false); + }); + + test('file upload field should upload manifest file', async () => { + expect(wrapper.find('FileUploadField').prop('filename')).toEqual(''); + const mockFile = new Blob(['123'], { type: 'application/zip' }); + mockFile.name = 'mock.zip'; + mockFile.date = new Date(); + await act(async () => { + wrapper.find('FileUpload').invoke('onChange')(mockFile, 'mock.zip'); + }); + await act(async () => { + wrapper.update(); + }); + await act(async () => { + wrapper.update(); + }); + expect(wrapper.find('FileUploadField').prop('filename')).toEqual( + 'mock.zip' + ); + }); + + test('clicking next button should show analytics step', async () => { + await act(async () => { + wrapper.find('Button[children="Next"]').simulate('click'); + }); + wrapper.update(); + expect(wrapper.find('AnalyticsStep').length).toBe(1); + expect(wrapper.find('CheckboxField').length).toBe(2); + expect(wrapper.find('FormField').length).toBe(1); + expect(wrapper.find('PasswordField').length).toBe(1); + }); + + test('deselecting insights checkbox should hide username and password fields', async () => { + expect(wrapper.find('input#username-field')).toHaveLength(1); + expect(wrapper.find('input#password-field')).toHaveLength(1); + await act(async () => { + wrapper.find('Checkbox[name="pendo"] input').simulate('change', { + target: { value: false, name: 'pendo' }, + }); + wrapper.find('Checkbox[name="insights"] input').simulate('change', { + target: { value: false, name: 'insights' }, + }); + }); + wrapper.update(); + expect(wrapper.find('input#username-field')).toHaveLength(0); + expect(wrapper.find('input#password-field')).toHaveLength(0); + }); + + test('clicking next button should show eula step', async () => { + await act(async () => { + wrapper.find('Button[children="Next"]').simulate('click'); + }); + wrapper.update(); + expect(wrapper.find('EulaStep').length).toBe(1); + expect(wrapper.find('CheckboxField').length).toBe(1); + expect(wrapper.find('Button[children="Submit"]').length).toBe(1); + }); + + test('checking EULA agreement should enable Submit button', async () => { + expect(wrapper.find('Button[children="Submit"]').prop('isDisabled')).toBe( + true + ); + await act(async () => { + wrapper.find('Checkbox[name="eula"] input').simulate('change', { + target: { value: true, name: 'eula' }, + }); + }); + wrapper.update(); + expect(wrapper.find('Button[children="Submit"]').prop('isDisabled')).toBe( + false + ); + }); + + test('should successfully save on form submission', async () => { + const { window } = global; + global.window.pendo = { initialize: jest.fn().mockResolvedValue({}) }; + ConfigAPI.read.mockResolvedValue({ + data: mockConfig, + }); + MeAPI.read.mockResolvedValue({ + data: { + results: [ + { + is_superuser: true, + }, + ], + }, + }); + ConfigAPI.attach.mockResolvedValue({}); + ConfigAPI.create.mockResolvedValue({ + data: mockConfig, + }); + UsersAPI.readAdminOfOrganizations({ + data: {}, + }); + expect(wrapper.find('Alert[title="Save successful"]')).toHaveLength(0); + await act(async () => + wrapper.find('button[aria-label="Submit"]').simulate('click') + ); + wrapper.update(); + waitForElement(wrapper, 'Alert[title="Save successful"]'); + global.window = window; + }); + }); + + describe('editing with a valid subscription', () => { + let wrapper; + let history; + + beforeAll(async () => { + SettingsAPI.readCategory.mockResolvedValue({ + data: { + SUBSCRIPTIONS_PASSWORD: 'mock_password', + SUBSCRIPTIONS_USERNAME: 'mock_username', + INSIGHTS_TRACKING_STATE: false, + PENDO: 'off', + }, + }); + ConfigAPI.readSubscriptions.mockResolvedValue({ + data: [ + { + subscription_name: 'mock subscription 50 instances', + instance_count: 50, + license_date: new Date(), + pool_id: 999, + }, + ], + }); + history = createMemoryHistory({ + initialEntries: ['/settings/subscription/edit'], + }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { + config: { + mockConfig, + }, + me: { + is_superuser: true, + }, + router: { history }, + }, + }); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should hide analytics step when editing a current subscription', async () => { + expect( + wrapper.find('WizardNavItem[content="Subscription Management"]').length + ).toBe(1); + expect( + wrapper.find('WizardNavItem[content="User and Insights analytics"]') + .length + ).toBe(0); + expect( + wrapper.find('WizardNavItem[content="End user license agreement"]') + .length + ).toBe(1); + }); + + test('Username/password toggle button should show username credential fields', async () => { + expect( + wrapper + .find('ToggleGroupItem') + .last() + .props().isSelected + ).toBe(false); + wrapper + .find('ToggleGroupItem[text="Username / password"] button') + .simulate('click'); + wrapper.update(); + expect( + wrapper + .find('ToggleGroupItem') + .last() + .props().isSelected + ).toBe(true); + expect(wrapper.find('input#username-field').prop('value')).toEqual(''); + expect(wrapper.find('input#password-field').prop('value')).toEqual(''); + await act(async () => { + wrapper.find('input#username-field').simulate('change', { + target: { value: 'username-cred', name: 'username' }, + }); + wrapper.find('input#password-field').simulate('change', { + target: { value: 'password-cred', name: 'password' }, + }); + }); + wrapper.update(); + expect(wrapper.find('input#username-field').prop('value')).toEqual( + 'username-cred' + ); + expect(wrapper.find('input#password-field').prop('value')).toEqual( + 'password-cred' + ); + }); + + test('should open subscription selection modal', async () => { + expect(wrapper.find('Flex[id="selected-subscription-file"]').length).toBe( + 0 + ); + await act(async () => { + wrapper + .find('SubscriptionStep button[aria-label="Get subscriptions"]') + .simulate('click'); + }); + wrapper.update(); + await waitForElement(wrapper, 'SubscriptionModal'); + await act(async () => { + wrapper + .find('SubscriptionModal SelectColumn') + .first() + .invoke('onSelect')(); + }); + wrapper.update(); + await act(async () => + wrapper.find('Button[aria-label="Confirm selection"]').prop('onClick')() + ); + wrapper.update(); + await waitForElement(wrapper, 'SubscriptionModal', el => el.length === 0); + }); + + test('should show selected subscription name', () => { + expect(wrapper.find('Flex[id="selected-subscription"]').length).toBe(1); + expect(wrapper.find('Flex[id="selected-subscription"] i').text()).toBe( + 'mock subscription 50 instances' + ); + }); + test('next should skip analytics step and navigate to eula step', async () => { + await act(async () => { + wrapper.find('Button[children="Next"]').simulate('click'); + }); + wrapper.update(); + expect(wrapper.find('SubscriptionStep').length).toBe(0); + expect(wrapper.find('AnalyticsStep').length).toBe(0); + expect(wrapper.find('EulaStep').length).toBe(1); + }); + + test('submit should be disabled until EULA agreement checked', async () => { + expect(wrapper.find('Button[children="Submit"]').prop('isDisabled')).toBe( + true + ); + await act(async () => { + wrapper.find('Checkbox[name="eula"] input').simulate('change', { + target: { value: true, name: 'eula' }, + }); + }); + wrapper.update(); + expect(wrapper.find('Button[children="Submit"]').prop('isDisabled')).toBe( + false + ); + }); + + test('should successfully send request to api on form submission', async () => { + expect(wrapper.find('EulaStep').length).toBe(1); + ConfigAPI.read.mockResolvedValue({ + data: { + mockConfig, + }, + }); + MeAPI.read.mockResolvedValue({ + data: { + results: [ + { + is_superuser: true, + }, + ], + }, + }); + ConfigAPI.attach.mockResolvedValue({}); + ConfigAPI.create.mockResolvedValue({}); + UsersAPI.readAdminOfOrganizations({ + data: {}, + }); + waitForElement( + wrapper, + 'Alert[title="Save successful"]', + el => el.length === 0 + ); + await act(async () => + wrapper.find('Button[children="Submit"]').prop('onClick')() + ); + wrapper.update(); + waitForElement(wrapper, 'Alert[title="Save successful"]'); + }); + + test('should navigate to subscription details on cancel', async () => { + expect( + wrapper.find('button[aria-label="Cancel subscription edit"]').length + ).toBe(1); + await act(async () => { + wrapper + .find('button[aria-label="Cancel subscription edit"]') + .invoke('onClick')(); + }); + expect(history.location.pathname).toEqual( + '/settings/subscription/details' + ); + }); + }); + + test.only('should throw a content error', async () => { + RootAPI.readAssetVariables.mockRejectedValueOnce(new Error()); + let wrapper; + await act(async () => { + wrapper = mountWithContexts(, { + context: { + config: emptyConfig, + }, + }); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + jest.clearAllMocks(); + wrapper.unmount(); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionModal.jsx b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionModal.jsx new file mode 100644 index 000000000000..c68c158a6a4a --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionModal.jsx @@ -0,0 +1,184 @@ +import React, { useCallback, useEffect } from 'react'; +import { withI18n } from '@lingui/react'; +import { t, Trans } from '@lingui/macro'; +import { + Button, + EmptyState, + EmptyStateIcon, + EmptyStateBody, + Modal, + Title, +} from '@patternfly/react-core'; +import { + TableComposable, + Tbody, + Td, + Th, + Thead, + Tr, +} from '@patternfly/react-table'; +import { ExclamationTriangleIcon } from '@patternfly/react-icons'; + +import { ConfigAPI } from '../../../../api'; +import { formatDateStringUTC } from '../../../../util/dates'; +import useRequest from '../../../../util/useRequest'; +import useSelected from '../../../../util/useSelected'; +import ErrorDetail from '../../../../components/ErrorDetail'; +import ContentEmpty from '../../../../components/ContentEmpty'; +import ContentLoading from '../../../../components/ContentLoading'; + +function SubscriptionModal({ + i18n, + subscriptionCreds = {}, + selectedSubscription = null, + onClose, + onConfirm, +}) { + const { + isLoading, + error, + request: fetchSubscriptions, + result: subscriptions, + } = useRequest( + useCallback(async () => { + if (!subscriptionCreds.username || !subscriptionCreds.password) { + return []; + } + const { data } = await ConfigAPI.readSubscriptions( + subscriptionCreds.username, + subscriptionCreds.password + ); + return data; + }, []), // eslint-disable-line react-hooks/exhaustive-deps + [] + ); + + const { selected, handleSelect } = useSelected(subscriptions); + + function handleConfirm() { + const [subscription] = selected; + onConfirm(subscription); + onClose(); + } + + useEffect(() => { + fetchSubscriptions(); + }, [fetchSubscriptions]); + + useEffect(() => { + if (selectedSubscription?.pool_id) { + handleSelect({ pool_id: selectedSubscription.pool_id }); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + Select + , + , + ]} + > + {isLoading && } + {!isLoading && error && ( + <> + + + + <Trans>No subscriptions found</Trans> + + + + We were unable to locate licenses associated with this account. + {' '} + + + + + + )} + {!isLoading && !error && subscriptions?.length === 0 && ( + + )} + {!isLoading && !error && subscriptions?.length > 0 && ( + + + + + {i18n._(t`Name`)} + {i18n._(t`Managed nodes`)} + {i18n._(t`Expires`)} + + + + {subscriptions.map(subscription => ( + + handleSelect(subscription), + isSelected: selected.some( + row => row.pool_id === subscription.pool_id + ), + variant: 'radio', + rowIndex: `row-${subscription.pool_id}`, + }} + /> + + {subscription.subscription_name} + + + {subscription.instance_count} + + + {formatDateStringUTC( + new Date(subscription.license_date * 1000).toISOString() + )} + + + ))} + + + )} + + ); +} + +export default withI18n()(SubscriptionModal); diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionModal.test.jsx b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionModal.test.jsx new file mode 100644 index 000000000000..4e74044f0be7 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionModal.test.jsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { + mountWithContexts, + waitForElement, +} from '../../../../../testUtils/enzymeHelpers'; +import { ConfigAPI } from '../../../../api'; +import SubscriptionModal from './SubscriptionModal'; + +jest.mock('../../../../api'); +ConfigAPI.readSubscriptions.mockResolvedValue({ + data: [ + { + subscription_name: 'mock A', + instance_count: 100, + license_date: 1714000271, + pool_id: 7, + }, + { + subscription_name: 'mock B', + instance_count: 200, + license_date: 1714000271, + pool_id: 8, + }, + { + subscription_name: 'mock C', + instance_count: 30, + license_date: 1714000271, + pool_id: 9, + }, + ], +}); + +describe('', () => { + let wrapper; + const onConfirm = jest.fn(); + const onClose = jest.fn(); + + beforeAll(async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('initially renders without crashing', async () => { + expect(wrapper.find('SubscriptionModal').length).toBe(1); + }); + + test('should render header', async () => { + wrapper.update(); + const header = wrapper + .find('tr') + .first() + .find('th'); + expect(header.at(0).text()).toEqual(''); + expect(header.at(1).text()).toEqual('Name'); + expect(header.at(2).text()).toEqual('Managed nodes'); + expect(header.at(3).text()).toEqual('Expires'); + }); + + test('should render subscription rows', async () => { + const rows = wrapper.find('tbody tr'); + expect(rows).toHaveLength(3); + const firstRow = rows.at(0).find('td'); + expect(firstRow.at(0).find('input[type="radio"]')).toHaveLength(1); + expect(firstRow.at(1).text()).toEqual('mock A'); + expect(firstRow.at(2).text()).toEqual('100'); + expect(firstRow.at(3).text()).toEqual('4/24/2024, 11:11:11 PM'); + }); + + test('submit button should call onConfirm', async () => { + expect( + wrapper.find('Button[aria-label="Confirm selection"]').prop('isDisabled') + ).toBe(true); + await act(async () => { + wrapper + .find('SubscriptionModal SelectColumn') + .first() + .invoke('onSelect')(); + }); + wrapper.update(); + expect( + wrapper.find('Button[aria-label="Confirm selection"]').prop('isDisabled') + ).toBe(false); + expect(onConfirm).toHaveBeenCalledTimes(0); + expect(onClose).toHaveBeenCalledTimes(0); + await act(async () => + wrapper.find('Button[aria-label="Confirm selection"]').prop('onClick')() + ); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test('should display error detail message', async () => { + ConfigAPI.readSubscriptions.mockRejectedValueOnce(new Error()); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + await waitForElement(wrapper, 'ErrorDetail', el => el.length === 1); + }); + + test('should show empty content', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + await waitForElement(wrapper, 'ContentEmpty', el => el.length === 1); + }); + }); + + test('should auto-select current selected subscription', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + await waitForElement(wrapper, 'table'); + expect(wrapper.find('tr[id=7] input').prop('checked')).toBe(false); + expect(wrapper.find('tr[id=8] input').prop('checked')).toBe(true); + expect(wrapper.find('tr[id=9] input').prop('checked')).toBe(false); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionStep.jsx b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionStep.jsx new file mode 100644 index 000000000000..48aa5b15b407 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionStep.jsx @@ -0,0 +1,280 @@ +import React, { useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { Trans, t } from '@lingui/macro'; +import { useField, useFormikContext } from 'formik'; +import styled from 'styled-components'; +import { TimesIcon } from '@patternfly/react-icons'; +import { + Button, + Divider, + FileUpload, + Flex, + FlexItem, + FormGroup, + ToggleGroup, + ToggleGroupItem, + Tooltip, +} from '@patternfly/react-core'; +import { useConfig } from '../../../../contexts/Config'; +import useModal from '../../../../util/useModal'; +import FormField, { PasswordField } from '../../../../components/FormField'; +import Popover from '../../../../components/Popover'; +import SubscriptionModal from './SubscriptionModal'; + +const LICENSELINK = 'https://www.ansible.com/license'; +const FileUploadField = styled(FormGroup)` + && { + max-width: 500px; + width: 100%; + } +`; + +function SubscriptionStep({ i18n }) { + const config = useConfig(); + const hasValidKey = Boolean(config?.license_info?.valid_key); + + const { values } = useFormikContext(); + + const [isSelected, setIsSelected] = useState( + values.subscription ? 'selectSubscription' : 'uploadManifest' + ); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const [manifest, manifestMeta, manifestHelpers] = useField({ + name: 'manifest_file', + }); + const [manifestFilename, , manifestFilenameHelpers] = useField({ + name: 'manifest_filename', + }); + const [subscription, , subscriptionHelpers] = useField({ + name: 'subscription', + }); + const [username, usernameMeta, usernameHelpers] = useField({ + name: 'username', + }); + const [password, passwordMeta, passwordHelpers] = useField({ + name: 'password', + }); + + return ( + + {!hasValidKey && ( + <> + + {i18n._(t`Welcome to Red Hat Ansible Automation Platform! + Please complete the steps below to activate your subscription.`)} + +

+ {i18n._(t`If you do not have a subscription, you can visit + Red Hat to obtain a trial subscription.`)} +

+ + + + )} +

+ {i18n._( + t`Select your Ansible Automation Platform subscription to use.` + )} +

+ + setIsSelected('uploadManifest')} + id="subscription-manifest" + /> + setIsSelected('selectSubscription')} + id="username-password" + /> + + {isSelected === 'uploadManifest' ? ( + <> +

+ + Upload a Red Hat Subscription Manifest containing your + subscription. To generate your subscription manifest, go to{' '} + {' '} + on the Red Hat Customer Portal. + +

+ + + A subscription manifest is an export of a Red Hat + Subscription. To generate a subscription manifest, go to{' '} + + . For more information, see the{' '} + + . + + + } + /> + } + > + manifestHelpers.setError(true), + }} + onChange={(value, filename) => { + if (!value) { + manifestHelpers.setValue(null); + manifestFilenameHelpers.setValue(''); + usernameHelpers.setValue(usernameMeta.initialValue); + passwordHelpers.setValue(passwordMeta.initialValue); + return; + } + + try { + const raw = new FileReader(); + raw.readAsBinaryString(value); + raw.onload = () => { + const rawValue = btoa(raw.result); + manifestHelpers.setValue(rawValue); + manifestFilenameHelpers.setValue(filename); + }; + } catch (err) { + manifestHelpers.setError(err); + } + }} + /> + + + ) : ( + <> +

+ {i18n._(t`Provide your Red Hat or Red Hat Satellite credentials + below and you can choose from a list of your available subscriptions. + The credentials you use will be stored for future use in + retrieving renewal or expanded subscriptions.`)} +

+ + + + + {isModalOpen && ( + subscriptionHelpers.setValue(value)} + /> + )} + + {subscription.value && ( + + {i18n._(t`Selected`)} + + {subscription?.value?.subscription_name} + + + + + + )} + + )} +
+ ); +} +export default withI18n()(SubscriptionStep); diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionStep.test.jsx b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionStep.test.jsx new file mode 100644 index 000000000000..ab9ad2a289a8 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionStep.test.jsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import SubscriptionStep from './SubscriptionStep'; + +describe('', () => { + let wrapper; + + beforeAll(async () => { + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('initially renders without crashing', async () => { + expect(wrapper.find('SubscriptionStep').length).toBe(1); + }); + + test('should update filename when a manifest zip file is uploaded', async () => { + expect(wrapper.find('FileUploadField')).toHaveLength(1); + expect(wrapper.find('label').text()).toEqual( + 'Red Hat subscription manifest' + ); + expect(wrapper.find('FileUploadField').prop('value')).toEqual(null); + expect(wrapper.find('FileUploadField').prop('filename')).toEqual(''); + const mockFile = new Blob(['123'], { type: 'application/zip' }); + mockFile.name = 'new file name'; + mockFile.date = new Date(); + await act(async () => { + wrapper.find('FileUpload').invoke('onChange')(mockFile, 'new file name'); + }); + await act(async () => { + wrapper.update(); + }); + await act(async () => { + wrapper.update(); + }); + expect(wrapper.find('FileUploadField').prop('value')).toEqual( + expect.stringMatching(/^[\x00-\x7F]+$/) // eslint-disable-line no-control-regex + ); + expect(wrapper.find('FileUploadField').prop('filename')).toEqual( + 'new file name' + ); + }); + + test('clear button should clear manifest value and filename', async () => { + await act(async () => { + wrapper + .find('FileUpload .pf-c-input-group button') + .last() + .simulate('click'); + }); + wrapper.update(); + expect(wrapper.find('FileUploadField').prop('value')).toEqual(null); + expect(wrapper.find('FileUploadField').prop('filename')).toEqual(''); + }); + + test('FileUpload should throw an error', async () => { + expect( + wrapper.find('div#subscription-manifest-helper.pf-m-error') + ).toHaveLength(0); + await act(async () => { + wrapper.find('FileUpload').invoke('onChange')('✓', 'new file name'); + }); + wrapper.update(); + expect( + wrapper.find('div#subscription-manifest-helper.pf-m-error') + ).toHaveLength(1); + expect(wrapper.find('div#subscription-manifest-helper').text()).toContain( + 'Invalid file format. Please upload a valid Red Hat Subscription Manifest.' + ); + }); + + test('Username/password toggle button should show username credential fields', async () => { + expect( + wrapper + .find('ToggleGroupItem') + .last() + .props().isSelected + ).toBe(false); + wrapper + .find('ToggleGroupItem[text="Username / password"] button') + .simulate('click'); + wrapper.update(); + expect( + wrapper + .find('ToggleGroupItem') + .last() + .props().isSelected + ).toBe(true); + await act(async () => { + wrapper.find('input#username-field').simulate('change', { + target: { value: 'username-cred', name: 'username' }, + }); + wrapper.find('input#password-field').simulate('change', { + target: { value: 'password-cred', name: 'password' }, + }); + }); + wrapper.update(); + expect(wrapper.find('input#username-field').prop('value')).toEqual( + 'username-cred' + ); + expect(wrapper.find('input#password-field').prop('value')).toEqual( + 'password-cred' + ); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/bootstrapPendo.js b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/bootstrapPendo.js new file mode 100644 index 000000000000..871a7834aa48 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/bootstrapPendo.js @@ -0,0 +1,26 @@ +/* eslint-disable */ +function bootstrapPendo(pendoApiKey) { + (function(p, e, n, d, o) { + var v, w, x, y, z; + o = p[d] = p[d] || {}; + o._q = []; + v = ['initialize', 'identify', 'updateOptions', 'pageLoad']; + for (w = 0, x = v.length; w < x; ++w) + (function(m) { + o[m] = + o[m] || + function() { + o._q[m === v[0] ? 'unshift' : 'push']( + [m].concat([].slice.call(arguments, 0)) + ); + }; + })(v[w]); + y = e.createElement(n); + y.async = !0; + y.src = `https://cdn.pendo.io/agent/static/${pendoApiKey}/pendo.js`; + z = e.getElementsByTagName(n)[0]; + z.parentNode.insertBefore(y, z); + })(window, document, 'script', 'pendo'); +} + +export default bootstrapPendo; diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/index.js b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/index.js new file mode 100644 index 000000000000..1b9aeadaec11 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/index.js @@ -0,0 +1 @@ +export { default } from './SubscriptionEdit'; diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/pendoUtils.js b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/pendoUtils.js new file mode 100644 index 000000000000..e03cb4c53ca7 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/pendoUtils.js @@ -0,0 +1,64 @@ +import { UsersAPI } from '../../../../api'; +import bootstrapPendo from './bootstrapPendo'; + +function buildPendoOptions(config, pendoApiKey) { + const tower_version = config.version.split('-')[0]; + const trial = config.trial ? config.trial : false; + const options = { + apiKey: pendoApiKey, + visitor: { + id: null, + role: null, + }, + account: { + id: null, + planLevel: config.license_type, + planPrice: config.instance_count, + creationDate: config.license_date, + trial, + tower_version, + ansible_version: config.ansible_version, + }, + }; + + options.visitor.id = 0; + options.account.id = 'tower.ansible.com'; + + return options; +} + +async function buildPendoOptionsRole(options, config) { + try { + if (config.me.is_superuser) { + options.visitor.role = 'admin'; + } else { + const { data } = await UsersAPI.readAdminOfOrganizations(config.me.id); + if (data.count > 0) { + options.visitor.role = 'orgadmin'; + } else { + options.visitor.role = 'user'; + } + } + return options; + } catch (error) { + throw new Error(error); + } +} + +async function issuePendoIdentity(config, pendoApiKey) { + config.license_info.analytics_status = config.analytics_status; + config.license_info.version = config.version; + config.license_info.ansible_version = config.ansible_version; + + if (config.analytics_status !== 'off') { + bootstrapPendo(pendoApiKey); + const pendoOptions = buildPendoOptions(config, pendoApiKey); + const pendoOptionsWithRole = await buildPendoOptionsRole( + pendoOptions, + config + ); + window.pendo.initialize(pendoOptionsWithRole); + } +} + +export default issuePendoIdentity; diff --git a/awx/ui_next/src/screens/Setting/Subscription/index.js b/awx/ui_next/src/screens/Setting/Subscription/index.js new file mode 100644 index 000000000000..41a92af34fa3 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Subscription/index.js @@ -0,0 +1 @@ +export { default } from './Subscription'; diff --git a/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx index 937c0c3e666d..42a85f57cb8c 100644 --- a/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx +++ b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx @@ -8,6 +8,7 @@ import ContentLoading from '../../../../components/ContentLoading'; import { FormSubmitError } from '../../../../components/FormField'; import { FormColumnLayout } from '../../../../components/FormLayout'; import { useSettings } from '../../../../contexts/Settings'; +import { useConfig } from '../../../../contexts/Config'; import { RevertAllAlert, RevertFormActionGroup } from '../../shared'; import { ChoiceField, @@ -22,6 +23,7 @@ function UIEdit() { const history = useHistory(); const { isModalOpen, toggleModal, closeModal } = useModal(); const { PUT: options } = useSettings(); + const { license_info } = useConfig(); const { isLoading, error, request: fetchUI, result: uiData } = useRequest( useCallback(async () => { @@ -88,13 +90,12 @@ function UIEdit() { {formik => (
- {uiData?.PENDO_TRACKING_STATE?.value !== 'off' && ( - - )} + ({ + __esModule: true, + ConfigContext: MockConfigContext, + ConfigProvider: MockConfigContext.Provider, + Config: MockConfigContext.Consumer, + useConfig: () => React.useContext(MockConfigContext), + useAuthorizedPath: jest.fn(), +})); diff --git a/awx/ui_next/src/util/dates.jsx b/awx/ui_next/src/util/dates.jsx index 02251a8e7835..f86f423eefdd 100644 --- a/awx/ui_next/src/util/dates.jsx +++ b/awx/ui_next/src/util/dates.jsx @@ -23,6 +23,14 @@ export function secondsToHHMMSS(seconds) { return new Date(seconds * 1000).toISOString().substr(11, 8); } +export function secondsToDays(seconds) { + let duration = Math.floor(parseInt(seconds, 10) / 86400); + if (duration < 0) { + duration = 0; + } + return duration.toString(); +} + export function timeOfDay() { const date = new Date(); const hour = date.getHours(); diff --git a/awx/ui_next/src/util/dates.test.jsx b/awx/ui_next/src/util/dates.test.jsx index 5f6162ce8c83..d5dfb559aad9 100644 --- a/awx/ui_next/src/util/dates.test.jsx +++ b/awx/ui_next/src/util/dates.test.jsx @@ -4,6 +4,7 @@ import { formatDateString, formatDateStringUTC, getRRuleDayConstants, + secondsToDays, secondsToHHMMSS, } from './dates'; @@ -52,6 +53,13 @@ describe('formatDateStringUTC', () => { }); }); +describe('secondsToDays', () => { + test('it returns the expected value', () => { + expect(secondsToDays(604800)).toEqual('7'); + expect(secondsToDays(0)).toEqual('0'); + }); +}); + describe('secondsToHHMMSS', () => { test('it returns the expected value', () => { expect(secondsToHHMMSS(50000)).toEqual('13:53:20'); diff --git a/awx/ui_next/testUtils/enzymeHelpers.jsx b/awx/ui_next/testUtils/enzymeHelpers.jsx index 1e950dc251f0..fe9982dc45a8 100644 --- a/awx/ui_next/testUtils/enzymeHelpers.jsx +++ b/awx/ui_next/testUtils/enzymeHelpers.jsx @@ -7,7 +7,7 @@ import { shape, object, string, arrayOf } from 'prop-types'; import { mount, shallow } from 'enzyme'; import { MemoryRouter, Router } from 'react-router-dom'; import { I18nProvider } from '@lingui/react'; -import { ConfigProvider } from '../src/contexts/Config'; +import { ConfigProvider } from '../src/contexts/Config' const language = 'en-US'; const intlProvider = new I18nProvider( @@ -44,6 +44,9 @@ const defaultContexts = { version: null, me: { is_superuser: true }, toJSON: () => '/config/', + license_info: { + valid_key: true + } }, router: { history_: {