mirror of
https://github.com/ansible/awx.git
synced 2026-05-15 21:37:42 -02:30
Add subscription wizard and redirect logic
This commit is contained in:
@@ -79,7 +79,8 @@
|
|||||||
"theme",
|
"theme",
|
||||||
"gridColumns",
|
"gridColumns",
|
||||||
"rows",
|
"rows",
|
||||||
"href"
|
"href",
|
||||||
|
"modifier"
|
||||||
],
|
],
|
||||||
"ignore": ["Ansible", "Tower", "JSON", "YAML", "lg"],
|
"ignore": ["Ansible", "Tower", "JSON", "YAML", "lg"],
|
||||||
"ignoreComponent": [
|
"ignoreComponent": [
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
@@ -8,7 +8,9 @@ import {
|
|||||||
Redirect,
|
Redirect,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import { I18n, I18nProvider } from '@lingui/react';
|
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 AppContainer from './components/AppContainer';
|
||||||
import Background from './components/Background';
|
import Background from './components/Background';
|
||||||
import NotFound from './screens/NotFound';
|
import NotFound from './screens/NotFound';
|
||||||
@@ -20,6 +22,49 @@ import { isAuthenticated } from './util/auth';
|
|||||||
import { getLanguageWithoutRegionCode } from './util/language';
|
import { getLanguageWithoutRegionCode } from './util/language';
|
||||||
|
|
||||||
import getRouteConfig from './routeConfig';
|
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 }) =>
|
const ProtectedRoute = ({ children, ...rest }) =>
|
||||||
isAuthenticated(document.cookie) ? (
|
isAuthenticated(document.cookie) ? (
|
||||||
@@ -36,7 +81,6 @@ function App() {
|
|||||||
// preferred language, default to one that has strings.
|
// preferred language, default to one that has strings.
|
||||||
language = 'en';
|
language = 'en';
|
||||||
}
|
}
|
||||||
const match = useRouteMatch();
|
|
||||||
const { hash, search, pathname } = useLocation();
|
const { hash, search, pathname } = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -55,22 +99,11 @@ function App() {
|
|||||||
<Redirect to="/home" />
|
<Redirect to="/home" />
|
||||||
</Route>
|
</Route>
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<AppContainer navRouteConfig={getRouteConfig(i18n)}>
|
<ConfigProvider>
|
||||||
<Switch>
|
<AppContainer navRouteConfig={getRouteConfig(i18n)}>
|
||||||
{getRouteConfig(i18n)
|
<AuthorizedRoutes routeConfig={getRouteConfig(i18n)} />
|
||||||
.flatMap(({ routes }) => routes)
|
</AppContainer>
|
||||||
.map(({ path, screen: Screen }) => (
|
</ConfigProvider>
|
||||||
<ProtectedRoute key={path} path={path}>
|
|
||||||
<Screen match={match} />
|
|
||||||
</ProtectedRoute>
|
|
||||||
))
|
|
||||||
.concat(
|
|
||||||
<ProtectedRoute key="not-found" path="*">
|
|
||||||
<NotFound />
|
|
||||||
</ProtectedRoute>
|
|
||||||
)}
|
|
||||||
</Switch>
|
|
||||||
</AppContainer>
|
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Background>
|
</Background>
|
||||||
|
|||||||
@@ -6,6 +6,17 @@ class Config extends Base {
|
|||||||
this.baseUrl = '/api/v2/config/';
|
this.baseUrl = '/api/v2/config/';
|
||||||
this.read = this.read.bind(this);
|
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;
|
export default Config;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ class Settings extends Base {
|
|||||||
return this.http.patch(`${this.baseUrl}all/`, data);
|
return this.http.patch(`${this.baseUrl}all/`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateCategory(category, data) {
|
||||||
|
return this.http.patch(`${this.baseUrl}${category}/`, data);
|
||||||
|
}
|
||||||
|
|
||||||
readCategory(category) {
|
readCategory(category) {
|
||||||
return this.http.get(`${this.baseUrl}${category}/`);
|
return this.http.get(`${this.baseUrl}${category}/`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { useHistory, useLocation, withRouter } from 'react-router-dom';
|
import { useHistory, withRouter } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Nav,
|
Nav,
|
||||||
NavList,
|
NavList,
|
||||||
Page,
|
Page,
|
||||||
PageHeader as PFPageHeader,
|
PageHeader as PFPageHeader,
|
||||||
|
PageHeaderTools,
|
||||||
|
PageHeaderToolsGroup,
|
||||||
|
PageHeaderToolsItem,
|
||||||
PageSidebar,
|
PageSidebar,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
import { ConfigAPI, MeAPI, RootAPI } from '../../api';
|
import { MeAPI, RootAPI } from '../../api';
|
||||||
import { ConfigProvider } from '../../contexts/Config';
|
import { useConfig, useAuthorizedPath } from '../../contexts/Config';
|
||||||
import { SESSION_TIMEOUT_KEY } from '../../constants';
|
import { SESSION_TIMEOUT_KEY } from '../../constants';
|
||||||
import { isAuthenticated } from '../../util/auth';
|
import { isAuthenticated } from '../../util/auth';
|
||||||
import About from '../About';
|
import About from '../About';
|
||||||
import AlertModal from '../AlertModal';
|
import AlertModal from '../AlertModal';
|
||||||
import ErrorDetail from '../ErrorDetail';
|
|
||||||
import BrandLogo from './BrandLogo';
|
import BrandLogo from './BrandLogo';
|
||||||
import NavExpandableGroup from './NavExpandableGroup';
|
import NavExpandableGroup from './NavExpandableGroup';
|
||||||
import PageHeaderToolbar from './PageHeaderToolbar';
|
import PageHeaderToolbar from './PageHeaderToolbar';
|
||||||
@@ -85,11 +87,11 @@ function useStorage(key) {
|
|||||||
|
|
||||||
function AppContainer({ i18n, navRouteConfig = [], children }) {
|
function AppContainer({ i18n, navRouteConfig = [], children }) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { pathname } = useLocation();
|
const config = useConfig();
|
||||||
const [config, setConfig] = useState({});
|
|
||||||
const [configError, setConfigError] = useState(null);
|
const isReady = !!config.license_info;
|
||||||
|
const isSidebarVisible = useAuthorizedPath();
|
||||||
const [isAboutModalOpen, setIsAboutModalOpen] = useState(false);
|
const [isAboutModalOpen, setIsAboutModalOpen] = useState(false);
|
||||||
const [isReady, setIsReady] = useState(false);
|
|
||||||
|
|
||||||
const sessionTimeoutId = useRef();
|
const sessionTimeoutId = useRef();
|
||||||
const sessionIntervalId = useRef();
|
const sessionIntervalId = useRef();
|
||||||
@@ -99,7 +101,6 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
|
|||||||
|
|
||||||
const handleAboutModalOpen = () => setIsAboutModalOpen(true);
|
const handleAboutModalOpen = () => setIsAboutModalOpen(true);
|
||||||
const handleAboutModalClose = () => setIsAboutModalOpen(false);
|
const handleAboutModalClose = () => setIsAboutModalOpen(false);
|
||||||
const handleConfigErrorClose = () => setConfigError(null);
|
|
||||||
const handleSessionTimeout = () => setTimeoutWarning(true);
|
const handleSessionTimeout = () => setTimeoutWarning(true);
|
||||||
|
|
||||||
const handleLogout = useCallback(async () => {
|
const handleLogout = useCallback(async () => {
|
||||||
@@ -137,31 +138,6 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
|
|||||||
}
|
}
|
||||||
}, [handleLogout, timeRemaining]);
|
}, [handleLogout, timeRemaining]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadConfig = async () => {
|
|
||||||
if (config?.version) return;
|
|
||||||
try {
|
|
||||||
const [
|
|
||||||
{ data },
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
results: [me],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
] = await Promise.all([ConfigAPI.read(), MeAPI.read()]);
|
|
||||||
setConfig({ ...data, me });
|
|
||||||
setIsReady(true);
|
|
||||||
} catch (err) {
|
|
||||||
if (err.response.status === 401) {
|
|
||||||
handleLogout();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setConfigError(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadConfig();
|
|
||||||
}, [config, pathname, handleLogout]);
|
|
||||||
|
|
||||||
const header = (
|
const header = (
|
||||||
<PageHeader
|
<PageHeader
|
||||||
showNavToggle
|
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 = (
|
const sidebar = (
|
||||||
<PageSidebar
|
<PageSidebar
|
||||||
theme="dark"
|
theme="dark"
|
||||||
@@ -200,23 +193,18 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Page isManagedSidebar header={header} sidebar={sidebar}>
|
<Page
|
||||||
{isReady && <ConfigProvider value={config}>{children}</ConfigProvider>}
|
isManagedSidebar={isSidebarVisible}
|
||||||
|
header={isSidebarVisible ? header : simpleHeader}
|
||||||
|
sidebar={isSidebarVisible && sidebar}
|
||||||
|
>
|
||||||
|
{isReady ? children : null}
|
||||||
</Page>
|
</Page>
|
||||||
<About
|
<About
|
||||||
version={config?.version}
|
version={config?.version}
|
||||||
isOpen={isAboutModalOpen}
|
isOpen={isAboutModalOpen}
|
||||||
onClose={handleAboutModalClose}
|
onClose={handleAboutModalClose}
|
||||||
/>
|
/>
|
||||||
<AlertModal
|
|
||||||
isOpen={configError}
|
|
||||||
variant="error"
|
|
||||||
title={i18n._(t`Error!`)}
|
|
||||||
onClose={handleConfigErrorClose}
|
|
||||||
>
|
|
||||||
{i18n._(t`Failed to retrieve configuration.`)}
|
|
||||||
<ErrorDetail error={configError} />
|
|
||||||
</AlertModal>
|
|
||||||
<AlertModal
|
<AlertModal
|
||||||
ouiaId="session-expiration-modal"
|
ouiaId="session-expiration-modal"
|
||||||
title={i18n._(t`Your session is about to expire`)}
|
title={i18n._(t`Your session is about to expire`)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
waitForElement,
|
waitForElement,
|
||||||
} from '../../../testUtils/enzymeHelpers';
|
} from '../../../testUtils/enzymeHelpers';
|
||||||
import { ConfigAPI, MeAPI, RootAPI } from '../../api';
|
import { ConfigAPI, MeAPI, RootAPI } from '../../api';
|
||||||
|
import { useAuthorizedPath } from '../../contexts/Config';
|
||||||
import AppContainer from './AppContainer';
|
import AppContainer from './AppContainer';
|
||||||
|
|
||||||
jest.mock('../../api');
|
jest.mock('../../api');
|
||||||
@@ -19,10 +20,12 @@ describe('<AppContainer />', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
MeAPI.read.mockResolvedValue({ data: { results: [{}] } });
|
MeAPI.read.mockResolvedValue({ data: { results: [{}] } });
|
||||||
|
useAuthorizedPath.mockImplementation(() => true);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('expected content is rendered', async () => {
|
test('expected content is rendered', async () => {
|
||||||
@@ -77,7 +80,9 @@ describe('<AppContainer />', () => {
|
|||||||
|
|
||||||
let wrapper;
|
let wrapper;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<AppContainer />);
|
wrapper = mountWithContexts(<AppContainer />, {
|
||||||
|
context: { config: { version } },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// open about dropdown menu
|
// 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
|
// 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 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 { t } from '@lingui/macro';
|
||||||
import { Formik, useField, useFormikContext } from 'formik';
|
import { Formik, useField, useFormikContext } from 'formik';
|
||||||
import { Form, FormGroup, Title } from '@patternfly/react-core';
|
import { Form, FormGroup, Title } from '@patternfly/react-core';
|
||||||
import { Config } from '../../../contexts/Config';
|
import { useConfig } from '../../../contexts/Config';
|
||||||
import AnsibleSelect from '../../../components/AnsibleSelect';
|
import AnsibleSelect from '../../../components/AnsibleSelect';
|
||||||
import ContentError from '../../../components/ContentError';
|
import ContentError from '../../../components/ContentError';
|
||||||
import ContentLoading from '../../../components/ContentLoading';
|
import ContentLoading from '../../../components/ContentLoading';
|
||||||
@@ -298,6 +298,7 @@ function ProjectFormFields({
|
|||||||
function ProjectForm({ i18n, project, submitError, ...props }) {
|
function ProjectForm({ i18n, project, submitError, ...props }) {
|
||||||
const { handleCancel, handleSubmit } = props;
|
const { handleCancel, handleSubmit } = props;
|
||||||
const { summary_fields = {} } = project;
|
const { summary_fields = {} } = project;
|
||||||
|
const { project_base_dir, project_local_paths } = useConfig();
|
||||||
const [contentError, setContentError] = useState(null);
|
const [contentError, setContentError] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [scmSubFormState, setScmSubFormState] = useState({
|
const [scmSubFormState, setScmSubFormState] = useState({
|
||||||
@@ -352,61 +353,57 @@ function ProjectForm({ i18n, project, submitError, ...props }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Config>
|
<Formik
|
||||||
{({ project_base_dir, project_local_paths }) => (
|
initialValues={{
|
||||||
<Formik
|
allow_override: project.allow_override || false,
|
||||||
initialValues={{
|
base_dir: project_base_dir || '',
|
||||||
allow_override: project.allow_override || false,
|
credential: project.credential || '',
|
||||||
base_dir: project_base_dir || '',
|
description: project.description || '',
|
||||||
credential: project.credential || '',
|
local_path: project.local_path || '',
|
||||||
description: project.description || '',
|
name: project.name || '',
|
||||||
local_path: project.local_path || '',
|
organization: project.summary_fields?.organization || null,
|
||||||
name: project.name || '',
|
scm_branch: project.scm_branch || '',
|
||||||
organization: project.summary_fields?.organization || null,
|
scm_clean: project.scm_clean || false,
|
||||||
scm_branch: project.scm_branch || '',
|
scm_delete_on_update: project.scm_delete_on_update || false,
|
||||||
scm_clean: project.scm_clean || false,
|
scm_refspec: project.scm_refspec || '',
|
||||||
scm_delete_on_update: project.scm_delete_on_update || false,
|
scm_type:
|
||||||
scm_refspec: project.scm_refspec || '',
|
project.scm_type === ''
|
||||||
scm_type:
|
? 'manual'
|
||||||
project.scm_type === ''
|
: project.scm_type === undefined
|
||||||
? 'manual'
|
? ''
|
||||||
: project.scm_type === undefined
|
: project.scm_type,
|
||||||
? ''
|
scm_update_cache_timeout: project.scm_update_cache_timeout || 0,
|
||||||
: project.scm_type,
|
scm_update_on_launch: project.scm_update_on_launch || false,
|
||||||
scm_update_cache_timeout: project.scm_update_cache_timeout || 0,
|
scm_url: project.scm_url || '',
|
||||||
scm_update_on_launch: project.scm_update_on_launch || false,
|
default_environment:
|
||||||
scm_url: project.scm_url || '',
|
project.summary_fields?.default_environment || null,
|
||||||
default_environment:
|
}}
|
||||||
project.summary_fields?.default_environment || null,
|
onSubmit={handleSubmit}
|
||||||
}}
|
>
|
||||||
onSubmit={handleSubmit}
|
{formik => (
|
||||||
>
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
{formik => (
|
<FormColumnLayout>
|
||||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
<ProjectFormFields
|
||||||
<FormColumnLayout>
|
project={project}
|
||||||
<ProjectFormFields
|
project_base_dir={project_base_dir}
|
||||||
project={project}
|
project_local_paths={project_local_paths}
|
||||||
project_base_dir={project_base_dir}
|
formik={formik}
|
||||||
project_local_paths={project_local_paths}
|
i18n={i18n}
|
||||||
formik={formik}
|
setCredentials={setCredentials}
|
||||||
i18n={i18n}
|
credentials={credentials}
|
||||||
setCredentials={setCredentials}
|
scmTypeOptions={scmTypeOptions}
|
||||||
credentials={credentials}
|
setScmSubFormState={setScmSubFormState}
|
||||||
scmTypeOptions={scmTypeOptions}
|
scmSubFormState={scmSubFormState}
|
||||||
setScmSubFormState={setScmSubFormState}
|
/>
|
||||||
scmSubFormState={scmSubFormState}
|
<FormSubmitError error={submitError} />
|
||||||
/>
|
<FormActionGroup
|
||||||
<FormSubmitError error={submitError} />
|
onCancel={handleCancel}
|
||||||
<FormActionGroup
|
onSubmit={formik.handleSubmit}
|
||||||
onCancel={handleCancel}
|
/>
|
||||||
onSubmit={formik.handleSubmit}
|
</FormColumnLayout>
|
||||||
/>
|
</Form>
|
||||||
</FormColumnLayout>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
)}
|
)}
|
||||||
</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',
|
'REMOTE_HOST_HEADERS',
|
||||||
'SESSIONS_PER_USER',
|
'SESSIONS_PER_USER',
|
||||||
'SESSION_COOKIE_AGE',
|
'SESSION_COOKIE_AGE',
|
||||||
|
'SUBSCRIPTIONS_USERNAME',
|
||||||
|
'SUBSCRIPTIONS_PASSWORD',
|
||||||
'TOWER_URL_BASE'
|
'TOWER_URL_BASE'
|
||||||
);
|
);
|
||||||
const systemData = {
|
const systemData = {
|
||||||
|
|||||||
@@ -32,15 +32,15 @@ const SplitLayout = styled(PageSection)`
|
|||||||
`;
|
`;
|
||||||
const Card = styled(_Card)`
|
const Card = styled(_Card)`
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
break-inside: avoid;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
const CardHeader = styled(_CardHeader)`
|
const CardHeader = styled(_CardHeader)`
|
||||||
align-items: flex-start;
|
&& {
|
||||||
display: flex;
|
align-items: flex-start;
|
||||||
flex-flow: column nowrap;
|
display: flex;
|
||||||
&& > * {
|
flex-flow: column nowrap;
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const CardDescription = styled.div`
|
const CardDescription = styled.div`
|
||||||
@@ -134,13 +134,13 @@ function SettingList({ i18n }) {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: i18n._(t`License`),
|
header: i18n._(t`Subscription`),
|
||||||
description: i18n._(t`View and edit your license information`),
|
description: i18n._(t`View and edit your subscription information`),
|
||||||
id: 'license',
|
id: 'subscription',
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
title: i18n._(t`License settings`),
|
title: i18n._(t`Subscription settings`),
|
||||||
path: '/settings/license',
|
path: '/settings/subscription',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -159,7 +159,10 @@ function SettingList({ i18n }) {
|
|||||||
return (
|
return (
|
||||||
<SplitLayout>
|
<SplitLayout>
|
||||||
{settingRoutes.map(({ description, header, id, routes }) => {
|
{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 null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import GitHub from './GitHub';
|
|||||||
import GoogleOAuth2 from './GoogleOAuth2';
|
import GoogleOAuth2 from './GoogleOAuth2';
|
||||||
import Jobs from './Jobs';
|
import Jobs from './Jobs';
|
||||||
import LDAP from './LDAP';
|
import LDAP from './LDAP';
|
||||||
import License from './License';
|
import Subscription from './Subscription';
|
||||||
import Logging from './Logging';
|
import Logging from './Logging';
|
||||||
import MiscSystem from './MiscSystem';
|
import MiscSystem from './MiscSystem';
|
||||||
import RADIUS from './RADIUS';
|
import RADIUS from './RADIUS';
|
||||||
@@ -93,7 +93,6 @@ function Settings({ i18n }) {
|
|||||||
'/settings/ldap/3/edit': i18n._(t`Edit Details`),
|
'/settings/ldap/3/edit': i18n._(t`Edit Details`),
|
||||||
'/settings/ldap/4/edit': i18n._(t`Edit Details`),
|
'/settings/ldap/4/edit': i18n._(t`Edit Details`),
|
||||||
'/settings/ldap/5/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': i18n._(t`Logging`),
|
||||||
'/settings/logging/details': i18n._(t`Details`),
|
'/settings/logging/details': i18n._(t`Details`),
|
||||||
'/settings/logging/edit': i18n._(t`Edit Details`),
|
'/settings/logging/edit': i18n._(t`Edit Details`),
|
||||||
@@ -106,6 +105,9 @@ function Settings({ i18n }) {
|
|||||||
'/settings/saml': i18n._(t`SAML`),
|
'/settings/saml': i18n._(t`SAML`),
|
||||||
'/settings/saml/details': i18n._(t`Details`),
|
'/settings/saml/details': i18n._(t`Details`),
|
||||||
'/settings/saml/edit': i18n._(t`Edit 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': i18n._(t`TACACS+`),
|
||||||
'/settings/tacacs/details': i18n._(t`Details`),
|
'/settings/tacacs/details': i18n._(t`Details`),
|
||||||
'/settings/tacacs/edit': i18n._(t`Edit Details`),
|
'/settings/tacacs/edit': i18n._(t`Edit Details`),
|
||||||
@@ -160,11 +162,11 @@ function Settings({ i18n }) {
|
|||||||
<Route path="/settings/ldap">
|
<Route path="/settings/ldap">
|
||||||
<LDAP />
|
<LDAP />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/settings/license">
|
<Route path="/settings/subscription">
|
||||||
{license_info?.license_type === 'open' ? (
|
{license_info?.license_type === 'open' ? (
|
||||||
<License />
|
|
||||||
) : (
|
|
||||||
<Redirect to="/settings" />
|
<Redirect to="/settings" />
|
||||||
|
) : (
|
||||||
|
<Subscription />
|
||||||
)}
|
)}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/settings/logging">
|
<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 { FormSubmitError } from '../../../../components/FormField';
|
||||||
import { FormColumnLayout } from '../../../../components/FormLayout';
|
import { FormColumnLayout } from '../../../../components/FormLayout';
|
||||||
import { useSettings } from '../../../../contexts/Settings';
|
import { useSettings } from '../../../../contexts/Settings';
|
||||||
|
import { useConfig } from '../../../../contexts/Config';
|
||||||
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
|
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
|
||||||
import {
|
import {
|
||||||
ChoiceField,
|
ChoiceField,
|
||||||
@@ -22,6 +23,7 @@ function UIEdit() {
|
|||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { isModalOpen, toggleModal, closeModal } = useModal();
|
const { isModalOpen, toggleModal, closeModal } = useModal();
|
||||||
const { PUT: options } = useSettings();
|
const { PUT: options } = useSettings();
|
||||||
|
const { license_info } = useConfig();
|
||||||
|
|
||||||
const { isLoading, error, request: fetchUI, result: uiData } = useRequest(
|
const { isLoading, error, request: fetchUI, result: uiData } = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -88,13 +90,12 @@ function UIEdit() {
|
|||||||
{formik => (
|
{formik => (
|
||||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
<FormColumnLayout>
|
<FormColumnLayout>
|
||||||
{uiData?.PENDO_TRACKING_STATE?.value !== 'off' && (
|
<ChoiceField
|
||||||
<ChoiceField
|
name="PENDO_TRACKING_STATE"
|
||||||
name="PENDO_TRACKING_STATE"
|
config={uiData.PENDO_TRACKING_STATE}
|
||||||
config={uiData.PENDO_TRACKING_STATE}
|
isDisabled={license_info?.license_type === 'open'}
|
||||||
isRequired
|
isRequired
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<TextAreaField
|
<TextAreaField
|
||||||
name="CUSTOM_LOGIN_INFO"
|
name="CUSTOM_LOGIN_INFO"
|
||||||
config={uiData.CUSTOM_LOGIN_INFO}
|
config={uiData.CUSTOM_LOGIN_INFO}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from 'react';
|
||||||
import { configure } from 'enzyme';
|
import { configure } from 'enzyme';
|
||||||
import Adapter from 'enzyme-adapter-react-16';
|
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
|
// and so this mock ensures that we don't encounter a reference error
|
||||||
// when running the tests
|
// when running the tests
|
||||||
global.__webpack_nonce__ = null;
|
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);
|
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() {
|
export function timeOfDay() {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
const hour = date.getHours();
|
const hour = date.getHours();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
formatDateString,
|
formatDateString,
|
||||||
formatDateStringUTC,
|
formatDateStringUTC,
|
||||||
getRRuleDayConstants,
|
getRRuleDayConstants,
|
||||||
|
secondsToDays,
|
||||||
secondsToHHMMSS,
|
secondsToHHMMSS,
|
||||||
} from './dates';
|
} 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', () => {
|
describe('secondsToHHMMSS', () => {
|
||||||
test('it returns the expected value', () => {
|
test('it returns the expected value', () => {
|
||||||
expect(secondsToHHMMSS(50000)).toEqual('13:53:20');
|
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 { mount, shallow } from 'enzyme';
|
||||||
import { MemoryRouter, Router } from 'react-router-dom';
|
import { MemoryRouter, Router } from 'react-router-dom';
|
||||||
import { I18nProvider } from '@lingui/react';
|
import { I18nProvider } from '@lingui/react';
|
||||||
import { ConfigProvider } from '../src/contexts/Config';
|
import { ConfigProvider } from '../src/contexts/Config'
|
||||||
|
|
||||||
const language = 'en-US';
|
const language = 'en-US';
|
||||||
const intlProvider = new I18nProvider(
|
const intlProvider = new I18nProvider(
|
||||||
@@ -44,6 +44,9 @@ const defaultContexts = {
|
|||||||
version: null,
|
version: null,
|
||||||
me: { is_superuser: true },
|
me: { is_superuser: true },
|
||||||
toJSON: () => '/config/',
|
toJSON: () => '/config/',
|
||||||
|
license_info: {
|
||||||
|
valid_key: true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
router: {
|
router: {
|
||||||
history_: {
|
history_: {
|
||||||
|
|||||||
Reference in New Issue
Block a user