mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 10:00:01 -03:30
Merge pull request #9496 from marshmalien/settings-subscription-wizard
Subscription wizard SUMMARY Adds subscriptions routes Extends Config context to return an array with two items: config state and config setter Update components that use Config with the new return pattern Move mock config into setupTests.js Return only the routes the user is "authorized" (valid license key) to view Subscription Details view: /settings/subscription/details This is our standard details view Clicking Edit will send the user to subscription wizard view /settings/subscription/edit Route is not accessible when license type is OPEN Subscription Add wizard view: /settings/subscription_management Step 1 - Subscription: If a user does not have a Red Hat Ansible Automation Platform subscription, they can request a trial subscription via the link Toggle between uploading a subscription manifest .zip file or retrieving subscriptions using Red Hat credentials (username and password) Get Subscriptions button fetches subscriptions and displays them in a modal Step 2 - Tracking and analytics: Shows two checkboxes to enable User analytics and Automation analytics If the user has previously selected the RH subscription manifest flow, checking the Automation Analytics box will display required RH username and password fields If the user has previously selected the RH username/password flow, they will not see this additional username/password field if Automation Analytics is checked Step 3 - EULA: https://tower-mockups.testing.ansible.com/patternfly/settings/settings-license-step-03/ Submission should show a success message and navigate user to dashboard if this is the initial launch and to the subscription detail view if they are editing the subscription Failed submission should show a wizard form error ISSUE TYPE Feature COMPONENT NAME UI ADDITIONAL INFORMATION Reviewed-by: Michael Abashian <None> Reviewed-by: Kersom <None> Reviewed-by: Tiago Góes <tiago.goes2009@gmail.com>
This commit is contained in:
commit
f9981c0825
@ -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