From 476cfd70c72d0d9c1023fcdb125ec45b2702142f Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 5 Mar 2021 12:07:28 -0500 Subject: [PATCH] Refactor config context to return an array of two items: state and setter Add subscription wizard and redirect logic sub step stage sub modal analytics step Add ouiaId props, Trans tags, and Insight Analytics dashboard img Pass config to context as an object Add "modifier" to list of rules to ignore --- awx/ui_next/.eslintrc | 4 +- .../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, 2496 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 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 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 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_: {