mirror of
https://github.com/ansible/awx.git
synced 2026-01-19 05:31:22 -03:30
Add subscription wizard and redirect logic
This commit is contained in:
parent
868f68035f
commit
440bdee56d
@ -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 |
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}/`);
|
||||
}
|
||||
|
||||
@ -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`)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -1 +0,0 @@
|
||||
export { default } from './LicenseDetail';
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -1 +0,0 @@
|
||||
export { default } from './LicenseEdit';
|
||||
@ -1 +0,0 @@
|
||||
export { default } from './License';
|
||||
@ -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 = {
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './SubscriptionDetail';
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
26
awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/bootstrapPendo.js
vendored
Normal file
26
awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/bootstrapPendo.js
vendored
Normal 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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './SubscriptionEdit';
|
||||
@ -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;
|
||||
1
awx/ui_next/src/screens/Setting/Subscription/index.js
Normal file
1
awx/ui_next/src/screens/Setting/Subscription/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Subscription';
|
||||
@ -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}
|
||||
|
||||
@ -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(),
|
||||
}));
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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_: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user