Add subscription wizard and redirect logic

This commit is contained in:
Marliana Lara 2021-03-05 12:07:28 -05:00
parent 868f68035f
commit 440bdee56d
No known key found for this signature in database
GPG Key ID: 38C73B40DFA809EE
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_: {