From 440bdee56ddd2553a8699c1274a41dbdb289cad1 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 5 Mar 2021 12:07:28 -0500 Subject: [PATCH] Add subscription wizard and redirect logic --- awx/ui_next/.eslintrc | 3 +- .../media/insights-analytics-dashboard.jpeg | Bin 0 -> 77861 bytes awx/ui_next/src/App.jsx | 67 ++- awx/ui_next/src/api/models/Config.js | 11 + awx/ui_next/src/api/models/Settings.js | 4 + .../components/AppContainer/AppContainer.jsx | 78 ++- .../AppContainer/AppContainer.test.jsx | 7 +- awx/ui_next/src/contexts/Config.jsx | 93 +++- .../screens/Project/shared/ProjectForm.jsx | 107 ++-- .../src/screens/Setting/License/License.jsx | 30 -- .../screens/Setting/License/License.test.jsx | 16 - .../License/LicenseDetail/LicenseDetail.jsx | 26 - .../LicenseDetail/LicenseDetail.test.jsx | 16 - .../Setting/License/LicenseDetail/index.js | 1 - .../License/LicenseEdit/LicenseEdit.jsx | 25 - .../License/LicenseEdit/LicenseEdit.test.jsx | 16 - .../Setting/License/LicenseEdit/index.js | 1 - .../src/screens/Setting/License/index.js | 1 - .../MiscSystemDetail/MiscSystemDetail.jsx | 2 + .../src/screens/Setting/SettingList.jsx | 25 +- awx/ui_next/src/screens/Setting/Settings.jsx | 12 +- .../Setting/Subscription/Subscription.jsx | 39 ++ .../Subscription/Subscription.test.jsx | 51 ++ .../SubscriptionDetail/SubscriptionDetail.jsx | 166 +++++++ .../SubscriptionDetail.test.jsx | 73 +++ .../Subscription/SubscriptionDetail/index.js | 1 + .../SubscriptionEdit/AnalyticsStep.jsx | 134 +++++ .../SubscriptionEdit/AnalyticsStep.test.jsx | 38 ++ .../SubscriptionEdit/EulaStep.jsx | 54 +++ .../SubscriptionEdit/EulaStep.test.jsx | 38 ++ .../SubscriptionEdit/SubscriptionEdit.jsx | 292 +++++++++++ .../SubscriptionEdit.test.jsx | 459 ++++++++++++++++++ .../SubscriptionEdit/SubscriptionModal.jsx | 184 +++++++ .../SubscriptionModal.test.jsx | 158 ++++++ .../SubscriptionEdit/SubscriptionStep.jsx | 280 +++++++++++ .../SubscriptionStep.test.jsx | 127 +++++ .../SubscriptionEdit/bootstrapPendo.js | 26 + .../Subscription/SubscriptionEdit/index.js | 1 + .../SubscriptionEdit/pendoUtils.js | 64 +++ .../src/screens/Setting/Subscription/index.js | 1 + .../src/screens/Setting/UI/UIEdit/UIEdit.jsx | 15 +- awx/ui_next/src/setupTests.js | 11 + awx/ui_next/src/util/dates.jsx | 8 + awx/ui_next/src/util/dates.test.jsx | 8 + awx/ui_next/testUtils/enzymeHelpers.jsx | 5 +- 45 files changed, 2495 insertions(+), 279 deletions(-) create mode 100644 awx/ui_next/public/static/media/insights-analytics-dashboard.jpeg delete mode 100644 awx/ui_next/src/screens/Setting/License/License.jsx delete mode 100644 awx/ui_next/src/screens/Setting/License/License.test.jsx delete mode 100644 awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx delete mode 100644 awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.test.jsx delete mode 100644 awx/ui_next/src/screens/Setting/License/LicenseDetail/index.js delete mode 100644 awx/ui_next/src/screens/Setting/License/LicenseEdit/LicenseEdit.jsx delete mode 100644 awx/ui_next/src/screens/Setting/License/LicenseEdit/LicenseEdit.test.jsx delete mode 100644 awx/ui_next/src/screens/Setting/License/LicenseEdit/index.js delete mode 100644 awx/ui_next/src/screens/Setting/License/index.js create mode 100644 awx/ui_next/src/screens/Setting/Subscription/Subscription.jsx create mode 100644 awx/ui_next/src/screens/Setting/Subscription/Subscription.test.jsx create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionDetail/SubscriptionDetail.jsx create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionDetail/SubscriptionDetail.test.jsx create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionDetail/index.js create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/AnalyticsStep.jsx create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/AnalyticsStep.test.jsx create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/EulaStep.jsx create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/EulaStep.test.jsx create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionEdit.jsx create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionEdit.test.jsx create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionModal.jsx create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionModal.test.jsx create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionStep.jsx create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionStep.test.jsx create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/bootstrapPendo.js create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/index.js create mode 100644 awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/pendoUtils.js create mode 100644 awx/ui_next/src/screens/Setting/Subscription/index.js diff --git a/awx/ui_next/.eslintrc b/awx/ui_next/.eslintrc index 350f50379b..fc3fa694fd 100644 --- a/awx/ui_next/.eslintrc +++ b/awx/ui_next/.eslintrc @@ -79,7 +79,8 @@ "theme", "gridColumns", "rows", - "href" + "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 0000000000000000000000000000000000000000..f93808edbd6d6d6d5af47fda84b563eb6080ae22 GIT binary patch literal 77861 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 Wf@9z1MM}z2VXo1Q@=w6e<^KzHNg!(g literal 0 HcmV?d00001 diff --git a/awx/ui_next/src/App.jsx b/awx/ui_next/src/App.jsx index 5833714d89..c054bd0dca 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 878ddfad70..704bb518ed 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 3c85f68da6..55babf213d 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 6c4016ac9b..4c290adb41 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 e8674c955c..1511b236e8 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 7b110ba3ce..9ce8aa555b 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 1d92df41dc..0000000000 --- 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 17388ebd2d..0000000000 --- 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 73efe6d31a..0000000000 --- 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 f744cab073..0000000000 --- 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 efe2514fed..0000000000 --- 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 38e4eca014..0000000000 --- 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 f1e6163948..0000000000 --- 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 04c3fcfb24..0000000000 --- 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 1bf99773e6..0000000000 --- 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 54eac90e9f..6b91aaa921 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 3284d48a7f..c72e1a3714 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 8b9c2db334..f359bbce12 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 0000000000..e7838927a7 --- /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 0000000000..ac46977f96 --- /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 0000000000..600f9103d7 --- /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 0000000000..c693eb5354 --- /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 0000000000..9f45dc3c26 --- /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 0000000000..b33c38fd38 --- /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 0000000000..039bea87fb --- /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 0000000000..06c8ee1ef5 --- /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 0000000000..ebb2370dd6 --- /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 0000000000..68e4c48b6f --- /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 0000000000..84b2bb84a3 --- /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 0000000000..c68c158a6a --- /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 0000000000..4e74044f0b --- /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 0000000000..48aa5b15b4 --- /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 0000000000..ab9ad2a289 --- /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 0000000000..871a7834aa --- /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 0000000000..1b9aeadaec --- /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 0000000000..e03cb4c53c --- /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 0000000000..41a92af34f --- /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 937c0c3e66..42a85f57cb 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 02251a8e78..f86f423eef 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 5f6162ce8c..d5dfb559aa 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 1e950dc251..fe9982dc45 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_: {