Merge pull request #9496 from marshmalien/settings-subscription-wizard

Subscription wizard

SUMMARY

Adds subscriptions routes
Extends Config context to return an array with two items: config state and config setter

Update components that use Config with the new return pattern


Move mock config into setupTests.js
Return only the routes the user is "authorized" (valid license key) to view

Subscription Details view: /settings/subscription/details

This is our standard details view
Clicking Edit will send the user to subscription wizard view /settings/subscription/edit
Route is not accessible when license type is OPEN

Subscription Add wizard view: /settings/subscription_management
Step 1 - Subscription:

If a user does not have a Red Hat Ansible Automation Platform subscription, they can request a trial subscription via the link
Toggle between uploading a subscription manifest .zip file or retrieving subscriptions using Red Hat credentials (username and password)
Get Subscriptions button fetches subscriptions and displays them in a modal



Step 2 - Tracking and analytics:

Shows two checkboxes to enable User analytics and Automation analytics
If the user has previously selected the RH subscription manifest flow, checking the Automation Analytics box will display required RH username and password fields
If the user has previously selected the RH username/password flow, they will not see this additional username/password field if Automation Analytics is checked


Step 3 - EULA: https://tower-mockups.testing.ansible.com/patternfly/settings/settings-license-step-03/

Submission should show a success message and navigate user to dashboard if this is the initial launch and to the subscription detail view if they are editing the subscription
Failed submission should show a wizard form error

ISSUE TYPE

Feature

COMPONENT NAME

UI

ADDITIONAL INFORMATION

Reviewed-by: Michael Abashian <None>
Reviewed-by: Kersom <None>
Reviewed-by: Tiago Góes <tiago.goes2009@gmail.com>
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-04-07 18:13:48 +00:00 committed by GitHub
commit f9981c0825
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 2495 additions and 279 deletions

View File

@ -79,7 +79,8 @@
"theme",
"gridColumns",
"rows",
"href"
"href",
"modifier"
],
"ignore": ["Ansible", "Tower", "JSON", "YAML", "lg"],
"ignoreComponent": [

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -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 (
<Switch>
<ProtectedRoute
key="/subscription_management"
path="/subscription_management"
>
<PageSection>
<Card>
<SubscriptionEdit />
</Card>
</PageSection>
</ProtectedRoute>
<Route path="*">
<Redirect to="/subscription_management" />
</Route>
</Switch>
);
}
return (
<Switch>
{routeConfig
.flatMap(({ routes }) => routes)
.map(({ path, screen: Screen }) => (
<ProtectedRoute key={path} path={path}>
<Screen match={match} />
</ProtectedRoute>
))
.concat(
<ProtectedRoute key="not-found" path="*">
<NotFound />
</ProtectedRoute>
)}
</Switch>
);
};
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() {
<Redirect to="/home" />
</Route>
<ProtectedRoute>
<AppContainer navRouteConfig={getRouteConfig(i18n)}>
<Switch>
{getRouteConfig(i18n)
.flatMap(({ routes }) => routes)
.map(({ path, screen: Screen }) => (
<ProtectedRoute key={path} path={path}>
<Screen match={match} />
</ProtectedRoute>
))
.concat(
<ProtectedRoute key="not-found" path="*">
<NotFound />
</ProtectedRoute>
)}
</Switch>
</AppContainer>
<ConfigProvider>
<AppContainer navRouteConfig={getRouteConfig(i18n)}>
<AuthorizedRoutes routeConfig={getRouteConfig(i18n)} />
</AppContainer>
</ConfigProvider>
</ProtectedRoute>
</Switch>
</Background>

View File

@ -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;

View File

@ -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}/`);
}

View File

@ -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 = (
<PageHeader
showNavToggle
@ -178,6 +154,23 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
/>
);
const simpleHeader = config.isLoading ? null : (
<PageHeader
logo={<BrandLogo />}
headerTools={
<PageHeaderTools>
<PageHeaderToolsGroup>
<PageHeaderToolsItem>
<Button onClick={handleLogout} variant="tertiary" ouiaId="logout">
{i18n._(t`Logout`)}
</Button>
</PageHeaderToolsItem>
</PageHeaderToolsGroup>
</PageHeaderTools>
}
/>
);
const sidebar = (
<PageSidebar
theme="dark"
@ -200,23 +193,18 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
return (
<>
<Page isManagedSidebar header={header} sidebar={sidebar}>
{isReady && <ConfigProvider value={config}>{children}</ConfigProvider>}
<Page
isManagedSidebar={isSidebarVisible}
header={isSidebarVisible ? header : simpleHeader}
sidebar={isSidebarVisible && sidebar}
>
{isReady ? children : null}
</Page>
<About
version={config?.version}
isOpen={isAboutModalOpen}
onClose={handleAboutModalClose}
/>
<AlertModal
isOpen={configError}
variant="error"
title={i18n._(t`Error!`)}
onClose={handleConfigErrorClose}
>
{i18n._(t`Failed to retrieve configuration.`)}
<ErrorDetail error={configError} />
</AlertModal>
<AlertModal
ouiaId="session-expiration-modal"
title={i18n._(t`Your session is about to expire`)}

View File

@ -5,6 +5,7 @@ import {
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import { ConfigAPI, MeAPI, RootAPI } from '../../api';
import { useAuthorizedPath } from '../../contexts/Config';
import AppContainer from './AppContainer';
jest.mock('../../api');
@ -19,10 +20,12 @@ describe('<AppContainer />', () => {
},
});
MeAPI.read.mockResolvedValue({ data: { results: [{}] } });
useAuthorizedPath.mockImplementation(() => true);
});
afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
test('expected content is rendered', async () => {
@ -77,7 +80,9 @@ describe('<AppContainer />', () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<AppContainer />);
wrapper = mountWithContexts(<AppContainer />, {
context: { config: { version } },
});
});
// open about dropdown menu

View File

@ -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 (
<ConfigContext.Provider value={value}>
{error && (
<AlertModal
isOpen={error}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissError}
ouiaId="config-error-modal"
>
{i18n._(t`Failed to retrieve configuration.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
{children}
</ConfigContext.Provider>
);
});
export const useAuthorizedPath = () => {
const config = useConfig();
const subscriptionMgmtRoute = useRouteMatch({
path: '/subscription_management',
});
return !!config.license_info?.valid_key && !subscriptionMgmtRoute;
};

View File

@ -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 (
<Config>
{({ project_base_dir, project_local_paths }) => (
<Formik
initialValues={{
allow_override: project.allow_override || false,
base_dir: project_base_dir || '',
credential: project.credential || '',
description: project.description || '',
local_path: project.local_path || '',
name: project.name || '',
organization: project.summary_fields?.organization || null,
scm_branch: project.scm_branch || '',
scm_clean: project.scm_clean || false,
scm_delete_on_update: project.scm_delete_on_update || false,
scm_refspec: project.scm_refspec || '',
scm_type:
project.scm_type === ''
? 'manual'
: project.scm_type === undefined
? ''
: project.scm_type,
scm_update_cache_timeout: project.scm_update_cache_timeout || 0,
scm_update_on_launch: project.scm_update_on_launch || false,
scm_url: project.scm_url || '',
default_environment:
project.summary_fields?.default_environment || null,
}}
onSubmit={handleSubmit}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<ProjectFormFields
project={project}
project_base_dir={project_base_dir}
project_local_paths={project_local_paths}
formik={formik}
i18n={i18n}
setCredentials={setCredentials}
credentials={credentials}
scmTypeOptions={scmTypeOptions}
setScmSubFormState={setScmSubFormState}
scmSubFormState={scmSubFormState}
/>
<FormSubmitError error={submitError} />
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
/>
</FormColumnLayout>
</Form>
)}
</Formik>
<Formik
initialValues={{
allow_override: project.allow_override || false,
base_dir: project_base_dir || '',
credential: project.credential || '',
description: project.description || '',
local_path: project.local_path || '',
name: project.name || '',
organization: project.summary_fields?.organization || null,
scm_branch: project.scm_branch || '',
scm_clean: project.scm_clean || false,
scm_delete_on_update: project.scm_delete_on_update || false,
scm_refspec: project.scm_refspec || '',
scm_type:
project.scm_type === ''
? 'manual'
: project.scm_type === undefined
? ''
: project.scm_type,
scm_update_cache_timeout: project.scm_update_cache_timeout || 0,
scm_update_on_launch: project.scm_update_on_launch || false,
scm_url: project.scm_url || '',
default_environment:
project.summary_fields?.default_environment || null,
}}
onSubmit={handleSubmit}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<ProjectFormFields
project={project}
project_base_dir={project_base_dir}
project_local_paths={project_local_paths}
formik={formik}
i18n={i18n}
setCredentials={setCredentials}
credentials={credentials}
scmTypeOptions={scmTypeOptions}
setScmSubFormState={setScmSubFormState}
scmSubFormState={scmSubFormState}
/>
<FormSubmitError error={submitError} />
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
/>
</FormColumnLayout>
</Form>
)}
</Config>
</Formik>
);
}

View File

@ -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 (
<PageSection>
<Card>
{i18n._(t`License settings`)}
<Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
<Route path={`${baseUrl}/details`}>
<LicenseDetail />
</Route>
<Route path={`${baseUrl}/edit`}>
<LicenseEdit />
</Route>
</Switch>
</Card>
</PageSection>
);
}
export default withI18n()(License);

View File

@ -1,16 +0,0 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import License from './License';
describe('<License />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<License />);
});
afterEach(() => {
wrapper.unmount();
});
test('initially renders without crashing', () => {
expect(wrapper.find('Card').text()).toContain('License settings');
});
});

View File

@ -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 (
<CardBody>
{i18n._(t`Detail coming soon :)`)}
<CardActionsRow>
<Button
ouiaId="license-detail-edit-button"
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/license/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
</CardBody>
);
}
export default withI18n()(LicenseDetail);

View File

@ -1,16 +0,0 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import LicenseDetail from './LicenseDetail';
describe('<LicenseDetail />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<LicenseDetail />);
});
afterEach(() => {
wrapper.unmount();
});
test('initially renders without crashing', () => {
expect(wrapper.find('LicenseDetail').length).toBe(1);
});
});

View File

@ -1 +0,0 @@
export { default } from './LicenseDetail';

View File

@ -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 (
<CardBody>
{i18n._(t`Edit form coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Cancel`)}
component={Link}
to="/settings/license/details"
>
{i18n._(t`Cancel`)}
</Button>
</CardActionsRow>
</CardBody>
);
}
export default withI18n()(LicenseEdit);

View File

@ -1,16 +0,0 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import LicenseEdit from './LicenseEdit';
describe('<LicenseEdit />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<LicenseEdit />);
});
afterEach(() => {
wrapper.unmount();
});
test('initially renders without crashing', () => {
expect(wrapper.find('LicenseEdit').length).toBe(1);
});
});

View File

@ -1 +0,0 @@
export { default } from './LicenseEdit';

View File

@ -1 +0,0 @@
export { default } from './License';

View File

@ -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 = {

View File

@ -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 (
<SplitLayout>
{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 (

View File

@ -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 }) {
<Route path="/settings/ldap">
<LDAP />
</Route>
<Route path="/settings/license">
<Route path="/settings/subscription">
{license_info?.license_type === 'open' ? (
<License />
) : (
<Redirect to="/settings" />
) : (
<Subscription />
)}
</Route>
<Route path="/settings/logging">

View File

@ -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 (
<PageSection>
<Card>
{baseRoute && <Redirect to={`${baseURL}/details`} />}
<Switch>
<Route path={`${baseURL}/details`}>
<SubscriptionDetail />
</Route>
<Route path={`${baseURL}/edit`}>
<SubscriptionEdit />
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={baseURL}>{i18n._(t`View Settings`)}</Link>
</ContentError>
</Route>
</Switch>
</Card>
</PageSection>
);
}
export default withI18n()(Subscription);

View File

@ -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('<Subscription />', () => {
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(<Subscription />, {
context: {
router: {
history,
},
config: {
license_info: {
license_type: 'enterprise',
},
},
},
});
});
await waitForElement(wrapper, 'SubscriptionDetail', el => el.length === 1);
});
});

View File

@ -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: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Settings`)}
</>
),
link: '/settings',
id: 99,
},
{
name: i18n._(t`Subscription Details`),
link: `${baseURL}/details`,
id: 0,
},
];
return (
<>
<RoutedTabs tabsArray={tabsArray} />
<CardBody>
<DetailList>
<Detail
dataCy="subscription-status"
label={i18n._(t`Status`)}
value={
license_info.compliant ? (
<Label variant="outline" color="green" icon={<CheckIcon />}>
{i18n._(t`Compliant`)}
</Label>
) : (
<Label
variant="outline"
color="red"
icon={<ExclamationCircleIcon />}
>
{i18n._(t`Out of compliance`)}
</Label>
)
}
/>
<Detail
dataCy="subscription-version"
label={i18n._(t`Version`)}
value={version}
/>
<Detail
dataCy="subscription-type"
label={i18n._(t`Subscription type`)}
value={license_info.license_type}
/>
<Detail
dataCy="subscription-name"
label={i18n._(t`Subscription`)}
value={license_info.subscription_name}
/>
<Detail
dataCy="subscription-trial"
label={i18n._(t`Trial`)}
value={license_info.trial ? i18n._(t`True`) : i18n._(t`False`)}
/>
<Detail
dataCy="subscription-expires-on-date"
label={i18n._(t`Expires on`)}
value={
license_info.license_date &&
formatDateString(
new Date(license_info.license_date * 1000).toISOString()
)
}
/>
<Detail
dataCy="subscription-expires-on-utc-date"
label={i18n._(t`Expires on UTC`)}
value={
license_info.license_date &&
formatDateStringUTC(
new Date(license_info.license_date * 1000).toISOString()
)
}
/>
<Detail
dataCy="subscription-days-remaining"
label={i18n._(t`Days remaining`)}
value={
license_info.time_remaining &&
secondsToDays(license_info.time_remaining)
}
/>
{license_info.instance_count < 9999999 && (
<Detail
dataCy="subscription-hosts-available"
label={i18n._(t`Hosts available`)}
value={license_info.available_instances}
/>
)}
{license_info.instance_count >= 9999999 && (
<Detail
dataCy="subscription-unlimited-hosts-available"
label={i18n._(t`Hosts available`)}
value={i18n._(t`Unlimited`)}
/>
)}
<Detail
dataCy="subscription-hosts-used"
label={i18n._(t`Hosts used`)}
value={license_info.current_instances}
/>
<Detail
dataCy="subscription-hosts-remaining"
label={i18n._(t`Hosts remaining`)}
value={license_info.free_instances}
/>
</DetailList>
<br />
<Trans>
If you are ready to upgrade or renew, please{' '}
<Button
component="a"
href="https://www.redhat.com/contact"
variant="link"
target="_blank"
isInline
>
contact us.
</Button>
</Trans>
<CardActionsRow>
<Button
aria-label={i18n._(t`edit`)}
component={Link}
to="/settings/subscription/edit"
>
<Trans>Edit</Trans>
</Button>
</CardActionsRow>
</CardBody>
</>
);
}
export default withI18n()(SubscriptionDetail);

View File

@ -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('<SubscriptionDetail />', () => {
let wrapper;
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(<SubscriptionDetail />, {
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);
});
});

View File

@ -0,0 +1 @@
export { default } from './SubscriptionDetail';

View File

@ -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 (
<Flex
spaceItems={{ default: 'spaceItemsMd' }}
direction={{ default: 'column' }}
>
<Trans>User and Insights analytics</Trans>
<p>
<Trans>
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{' '}
<Button
component="a"
href="http://docs.ansible.com/ansible-tower/latest/html/installandreference/user-data.html#index-0"
variant="link"
isInline
ouiaId="tower-documentation-link"
target="_blank"
>
this Tower documentation page
</Button>
. Uncheck the following boxes to disable this feature.
</Trans>
</p>
<FormGroup fieldId="pendo">
<CheckboxField
name="pendo"
isDisabled={!config.me.is_superuser}
aria-label={i18n._(t`User analytics`)}
label={i18n._(t`User analytics`)}
id="pendo-field"
description={i18n._(t`This data is used to enhance
future releases of the Tower Software and help
streamline customer experience and success.`)}
/>
</FormGroup>
<FormGroup fieldId="insights">
<CheckboxField
name="insights"
isDisabled={!config.me.is_superuser}
aria-label={i18n._(t`Insights analytics`)}
label={i18n._(t`Insights Analytics`)}
id="insights-field"
description={i18n._(t`This data is used to enhance
future releases of the Tower Software and to provide
Insights Analytics to Tower subscribers.`)}
/>
</FormGroup>
{requireCredentialFields && (
<>
<br />
<p>
<Trans>
Provide your Red Hat or Red Hat Satellite credentials to enable
Insights Analytics.
</Trans>
</p>
<FormField
id="username-field"
isDisabled={!config.me.is_superuser}
isRequired={requireCredentialFields}
label={i18n._(t`Username`)}
name="username"
type="text"
validate={required(null, i18n)}
/>
<PasswordField
id="password-field"
isDisabled={!config.me.is_superuser}
isRequired={requireCredentialFields}
label={i18n._(t`Password`)}
name="password"
validate={required(null, i18n)}
/>
</>
)}
<Flex alignItems={{ default: 'alignItemsCenter' }}>
<img
width="300"
src="/static/media/insights-analytics-dashboard.jpeg"
alt={i18n._(t`Insights Analytics dashboard`)}
/>
<Button
component="a"
href={ANALYTICSLINK}
target="_blank"
variant="secondary"
ouiaId="analytics-link"
>
<Trans>Learn more about Insights Analytics</Trans>
</Button>
</Flex>
</Flex>
);
}
export default withI18n()(AnalyticsStep);

View File

@ -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('<AnalyticsStep />', () => {
let wrapper;
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
insights: false,
manifest_file: null,
manifest_filename: '',
pendo: false,
subscription: null,
password: '',
username: '',
}}
>
<AnalyticsStep />
</Formik>
);
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('initially renders without crashing', async () => {
expect(wrapper.find('AnalyticsStep').length).toBe(1);
});
});

View File

@ -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 (
<Flex
spaceItems={{ default: 'spaceItemsMd' }}
direction={{ default: 'column' }}
>
<b>
<Trans>Agree to the end user license agreement and click submit.</Trans>
</b>
<FormGroup
fieldId="eula"
label={i18n._(t`End User License Agreement`)}
validated={isValid ? 'default' : 'error'}
helperTextInvalid={meta.error}
isRequired
>
<TextArea
id="eula-container"
style={{ minHeight: '200px' }}
resizeOrientation="vertical"
isReadOnly
>
{eula}
</TextArea>
<CheckboxField
name="eula"
aria-label={i18n._(t`Agree to end user license agreement`)}
label={i18n._(t`I agree to the End User License Agreement`)}
id="eula"
isDisabled={!me.is_superuser}
validate={required(
i18n._(
t`Please agree to End User License Agreement before proceeding.`
),
i18n
)}
/>
</FormGroup>
</Flex>
);
}
export default withI18n()(EulaStep);

View File

@ -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('<EulaStep />', () => {
let wrapper;
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
insights: false,
manifest_file: null,
manifest_filename: '',
pendo: false,
subscription: null,
password: '',
username: '',
}}
>
<EulaStep />
</Formik>
);
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('initially renders without crashing', async () => {
expect(wrapper.find('EulaStep').length).toBe(1);
});
});

View File

@ -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 (
<WizardFooter>
<WizardContextConsumer>
{({ activeStep, onNext, onBack }) => (
<>
{activeStep.id === 'eula-step' ? (
<Button
id="subscription-wizard-submit"
aria-label={i18n._(t`Submit`)}
variant="primary"
onClick={onNext}
isDisabled={
(!values.manifest_file && !values.subscription) ||
!me?.is_superuser ||
!values.eula ||
Object.keys(errors).length !== 0
}
type="button"
ouiaId="subscription-wizard-submit"
isLoading={isSubmitLoading}
>
<Trans>Submit</Trans>
</Button>
) : (
<Button
id="subscription-wizard-next"
ouiaId="subscription-wizard-next"
variant="primary"
onClick={onNext}
type="button"
>
<Trans>Next</Trans>
</Button>
)}
<Button
id="subscription-wizard-back"
variant="secondary"
ouiaId="subscription-wizard-back"
onClick={onBack}
isDisabled={activeStep.id === 'subscription-step'}
type="button"
>
<Trans>Back</Trans>
</Button>
{license_info?.valid_key && (
<Button
id="subscription-wizard-cancel"
ouiaId="subscription-wizard-cancel"
variant="link"
aria-label={i18n._(t`Cancel subscription edit`)}
onClick={() => history.push('/settings/subscription/details')}
>
<Trans>Cancel</Trans>
</Button>
)}
</>
)}
</WizardContextConsumer>
</WizardFooter>
);
});
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 <ContentLoading />;
}
if (contentError) {
return <ContentError />;
}
const steps = [
{
name: hasValidKey
? i18n._(t`Subscription Management`)
: `${brandName} ${i18n._(t`Subscription`)}`,
id: 'subscription-step',
component: <SubscriptionStep />,
},
...(!hasValidKey
? [
{
name: i18n._(t`User and Insights analytics`),
id: 'analytics-step',
component: <AnalyticsStep />,
},
]
: []),
{
name: i18n._(t`End user license agreement`),
component: <EulaStep />,
id: 'eula-step',
nextButtonText: i18n._(t`Submit`),
},
];
return (
<>
<Formik
initialValues={{
eula: false,
insights: true,
manifest_file: null,
manifest_filename: '',
pendo: true,
subscription: null,
password: '',
username: '',
}}
onSubmit={handleSubmit}
>
{formik => (
<Form
onSubmit={e => {
e.preventDefault();
}}
>
<Wizard
steps={steps}
onSave={formik.handleSubmit}
footer={<CustomFooter isSubmitLoading={submitLoading} />}
height="fit-content"
/>
{error && (
<div style={{ margin: '0 24px 24px 24px' }}>
<FormSubmitError error={error} />
</div>
)}
</Form>
)}
</Formik>
<AlertGroup isToast>
{submitSuccessful && (
<Alert
variant="success"
title={i18n._(t`Save successful!`)}
ouiaId="success-alert"
>
{subscriptionMgmtRoute ? (
<Link to="/home">
<Trans>Redirecting to dashboard</Trans>
</Link>
) : (
<Link to="/settings/subscription/details">
<Trans>Redirecting to subscription detail</Trans>
</Link>
)}
</Alert>
)}
</AlertGroup>
</>
);
}
export default withI18n()(SubscriptionEdit);

View File

@ -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('<SubscriptionEdit />', () => {
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(<SubscriptionEdit />, {
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(<SubscriptionEdit />, {
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(<SubscriptionEdit />, {
context: {
config: emptyConfig,
},
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
jest.clearAllMocks();
wrapper.unmount();
});
});

View File

@ -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 (
<Modal
aria-label={i18n._(t`Subscription selection modal`)}
isOpen
onClose={onClose}
title={i18n._(t`Select a subscription`)}
width="50%"
actions={[
<Button
aria-label={i18n._(t`Confirm selection`)}
isDisabled={selected.length === 0}
key="confirm"
onClick={handleConfirm}
variant="primary"
ouiaId="subscription-modal-confirm"
>
<Trans>Select</Trans>
</Button>,
<Button
aria-label={i18n._(t`Cancel`)}
key="cancel"
onClick={onClose}
variant="link"
ouiaId="subscription-modal-cancel"
>
<Trans>Cancel</Trans>
</Button>,
]}
>
{isLoading && <ContentLoading />}
{!isLoading && error && (
<>
<EmptyState variant="full">
<EmptyStateIcon icon={ExclamationTriangleIcon} />
<Title size="lg" headingLevel="h3">
<Trans>No subscriptions found</Trans>
</Title>
<EmptyStateBody>
<Trans>
We were unable to locate licenses associated with this account.
</Trans>{' '}
<Button
aria-label={i18n._(t`Close subscription modal`)}
onClick={onClose}
variant="link"
isInline
ouiaId="subscription-modal-close"
>
<Trans>Return to subscription management.</Trans>
</Button>
</EmptyStateBody>
<ErrorDetail error={error} />
</EmptyState>
</>
)}
{!isLoading && !error && subscriptions?.length === 0 && (
<ContentEmpty
title={i18n._(t`No subscriptions found`)}
message={i18n._(
t`We were unable to locate subscriptions associated with this account.`
)}
/>
)}
{!isLoading && !error && subscriptions?.length > 0 && (
<TableComposable
variant="compact"
aria-label={i18n._(t`Subscriptions table`)}
>
<Thead>
<Tr>
<Th />
<Th>{i18n._(t`Name`)}</Th>
<Th modifier="fitContent">{i18n._(t`Managed nodes`)}</Th>
<Th modifier="fitContent">{i18n._(t`Expires`)}</Th>
</Tr>
</Thead>
<Tbody>
{subscriptions.map(subscription => (
<Tr key={`row-${subscription.pool_id}`} id={subscription.pool_id}>
<Td
key={`row-${subscription.pool_id}`}
select={{
onSelect: () => handleSelect(subscription),
isSelected: selected.some(
row => row.pool_id === subscription.pool_id
),
variant: 'radio',
rowIndex: `row-${subscription.pool_id}`,
}}
/>
<Td dataLabel={i18n._(t`Trial`)}>
{subscription.subscription_name}
</Td>
<Td dataLabel={i18n._(t`Managed nodes`)}>
{subscription.instance_count}
</Td>
<Td dataLabel={i18n._(t`Expires`)} modifier="nowrap">
{formatDateStringUTC(
new Date(subscription.license_date * 1000).toISOString()
)}
</Td>
</Tr>
))}
</Tbody>
</TableComposable>
)}
</Modal>
);
}
export default withI18n()(SubscriptionModal);

View File

@ -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('<SubscriptionModal />', () => {
let wrapper;
const onConfirm = jest.fn();
const onClose = jest.fn();
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<SubscriptionModal
subscriptionCreds={{
username: 'admin',
password: '$encrypted',
}}
onConfirm={onConfirm}
onClose={onClose}
/>
);
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(
<SubscriptionModal
subscriptionCreds={{
username: 'admin',
password: '$encrypted',
}}
/>
);
});
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(
<SubscriptionModal
subscriptionCreds={{
username: null,
password: null,
}}
/>
);
await waitForElement(wrapper, 'ContentEmpty', el => el.length === 1);
});
});
test('should auto-select current selected subscription', async () => {
await act(async () => {
wrapper = mountWithContexts(
<SubscriptionModal
subscriptionCreds={{
username: 'admin',
password: '$encrypted',
}}
selectedSubscription={{
pool_id: 8,
}}
/>
);
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);
});
});
});

View File

@ -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 (
<Flex
direction={{ default: 'column' }}
spaceItems={{ default: 'spaceItemsMd' }}
alignItems={{ default: 'alignItemsBaseline' }}
>
{!hasValidKey && (
<>
<b>
{i18n._(t`Welcome to Red Hat Ansible Automation Platform!
Please complete the steps below to activate your subscription.`)}
</b>
<p>
{i18n._(t`If you do not have a subscription, you can visit
Red Hat to obtain a trial subscription.`)}
</p>
<Button
aria-label={i18n._(t`Request subscription`)}
component="a"
href={LICENSELINK}
ouiaId="request-subscription-button"
target="_blank"
variant="secondary"
>
{i18n._(t`Request subscription`)}
</Button>
<Divider />
</>
)}
<p>
{i18n._(
t`Select your Ansible Automation Platform subscription to use.`
)}
</p>
<ToggleGroup>
<ToggleGroupItem
text={i18n._(t`Subscription manifest`)}
isSelected={isSelected === 'uploadManifest'}
onChange={() => setIsSelected('uploadManifest')}
id="subscription-manifest"
/>
<ToggleGroupItem
text={i18n._(t`Username / password`)}
isSelected={isSelected === 'selectSubscription'}
onChange={() => setIsSelected('selectSubscription')}
id="username-password"
/>
</ToggleGroup>
{isSelected === 'uploadManifest' ? (
<>
<p>
<Trans>
Upload a Red Hat Subscription Manifest containing your
subscription. To generate your subscription manifest, go to{' '}
<Button
component="a"
href="https://access.redhat.com/management/subscription_allocations"
variant="link"
target="_blank"
ouiaId="subscription-allocations-link"
isInline
>
subscription allocations
</Button>{' '}
on the Red Hat Customer Portal.
</Trans>
</p>
<FileUploadField
fieldId="subscription-manifest"
validated={manifestMeta.error ? 'error' : 'default'}
helperTextInvalid={i18n._(
t`Invalid file format. Please upload a valid Red Hat Subscription Manifest.`
)}
label={i18n._(t`Red Hat subscription manifest`)}
helperText={i18n._(t`Upload a .zip file`)}
labelIcon={
<Popover
content={
<>
<Trans>
A subscription manifest is an export of a Red Hat
Subscription. To generate a subscription manifest, go to{' '}
<Button
component="a"
href="https://access.redhat.com/management/subscription_allocations"
variant="link"
target="_blank"
isInline
ouiaId="subscription-allocations-link"
>
access.redhat.com
</Button>
. For more information, see the{' '}
<Button
component="a"
href="https://docs.ansible.com/ansible-tower/latest/html/userguide/import_license.html"
variant="link"
target="_blank"
ouiaId="import-license-link"
isInline
>
User Guide
</Button>
.
</Trans>
</>
}
/>
}
>
<FileUpload
id="upload-manifest"
value={manifest.value}
filename={manifestFilename.value}
browseButtonText={i18n._(t`Browse`)}
isDisabled={!config?.me?.is_superuser}
dropzoneProps={{
accept: '.zip',
onDropRejected: () => 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);
}
}}
/>
</FileUploadField>
</>
) : (
<>
<p>
{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.`)}
</p>
<Flex
direction={{ default: 'column', md: 'row' }}
spaceItems={{ default: 'spaceItemsMd' }}
alignItems={{ md: 'alignItemsFlexEnd' }}
fullWidth={{ default: 'fullWidth' }}
>
<FormField
id="username-field"
label={i18n._(t`Username`)}
name="username"
type="text"
isDisabled={!config.me.is_superuser}
/>
<PasswordField
id="password-field"
name="password"
label={i18n._(t`Password`)}
isDisabled={!config.me.is_superuser}
/>
<Button
aria-label={i18n._(t`Get subscriptions`)}
ouiaId="subscription-modal-button"
onClick={toggleModal}
style={{ maxWidth: 'fit-content' }}
isDisabled={!(username.value && password.value)}
>
{i18n._(t`Get subscription`)}
</Button>
{isModalOpen && (
<SubscriptionModal
subscriptionCreds={{
username: username.value,
password: password.value,
}}
selectedSubscripion={subscription?.value}
onClose={closeModal}
onConfirm={value => subscriptionHelpers.setValue(value)}
/>
)}
</Flex>
{subscription.value && (
<Flex
id="selected-subscription"
alignSelf={{ default: 'alignSelfFlexStart' }}
spaceItems={{ default: 'spaceItemsMd' }}
>
<b>{i18n._(t`Selected`)}</b>
<FlexItem>
<i>{subscription?.value?.subscription_name}</i>
<Tooltip
trigger="mouseenter focus click"
content={i18n._(t`Clear subscription`)}
>
<Button
onClick={() => subscriptionHelpers.setValue(null)}
variant="plain"
aria-label={i18n._(t`Clear subscription selection`)}
ouiaId="clear-subscription-selection"
>
<TimesIcon />
</Button>
</Tooltip>
</FlexItem>
</Flex>
)}
</>
)}
</Flex>
);
}
export default withI18n()(SubscriptionStep);

View File

@ -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('<SubscriptionStep />', () => {
let wrapper;
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
insights: false,
manifest_file: null,
manifest_filename: '',
pendo: false,
subscription: null,
password: '',
username: '',
}}
>
<SubscriptionStep />
</Formik>
);
});
});
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'
);
});
});

View File

@ -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;

View File

@ -0,0 +1 @@
export { default } from './SubscriptionEdit';

View File

@ -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;

View File

@ -0,0 +1 @@
export { default } from './Subscription';

View File

@ -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 => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
{uiData?.PENDO_TRACKING_STATE?.value !== 'off' && (
<ChoiceField
name="PENDO_TRACKING_STATE"
config={uiData.PENDO_TRACKING_STATE}
isRequired
/>
)}
<ChoiceField
name="PENDO_TRACKING_STATE"
config={uiData.PENDO_TRACKING_STATE}
isDisabled={license_info?.license_type === 'open'}
isRequired
/>
<TextAreaField
name="CUSTOM_LOGIN_INFO"
config={uiData.CUSTOM_LOGIN_INFO}

View File

@ -1,3 +1,4 @@
import React from 'react';
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
@ -24,3 +25,13 @@ global.console = {
// and so this mock ensures that we don't encounter a reference error
// when running the tests
global.__webpack_nonce__ = null;
const MockConfigContext = React.createContext({});
jest.doMock('./contexts/Config', () => ({
__esModule: true,
ConfigContext: MockConfigContext,
ConfigProvider: MockConfigContext.Provider,
Config: MockConfigContext.Consumer,
useConfig: () => React.useContext(MockConfigContext),
useAuthorizedPath: jest.fn(),
}));

View File

@ -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();

View File

@ -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');

View File

@ -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_: {