mirror of
https://github.com/ansible/awx.git
synced 2026-05-24 17:17:45 -02:30
Merge branch 'devel' into devel
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<version>`.
|
||||
|
||||
## 12.0.0 (TBD)
|
||||
- Moved to a single container image build instead of separate awx_web and awx_task images. The container image is just `awx` (https://github.com/ansible/awx/pull/7228)
|
||||
- Official AWX container image builds now use a two-stage container build process that notably reduces the size of our published images (https://github.com/ansible/awx/pull/7017)
|
||||
- Removed support for HipChat notifications ([EoL announcement](https://www.atlassian.com/partnerships/slack/faq#faq-98b17ca3-247f-423b-9a78-70a91681eff0)); all previously-created HipChat notification templates will be deleted due to this removal.
|
||||
- Fixed a bug which broke AWX installations with oc version 4.3 (https://github.com/ansible/awx/pull/6948/files)
|
||||
@@ -10,11 +11,13 @@ This is a list of high-level changes for each release of AWX. A full list of com
|
||||
- Fixed a bug that caused CyberArk AIM credential plugin looks to hang forever in some environments (https://github.com/ansible/awx/issues/6986)
|
||||
- Fixed a bug that caused ANY/ALL converage settings not to properly save when editing approval nodes in the UI (https://github.com/ansible/awx/issues/6998)
|
||||
- Fixed a bug that broke support for the satellite6_group_prefix source variable (https://github.com/ansible/awx/issues/7031)
|
||||
- Fixed a bug that prevented the usage of the Conjur credential plugin with secrets that contain spaces (https://github.com/ansible/awx/issues/7191)
|
||||
- Fixed a bug in awx-manage run_wsbroadcast --status in kubernetes (https://github.com/ansible/awx/pull/7009)
|
||||
- Fixed a bug that broke notification toggles for system jobs in the UI (https://github.com/ansible/awx/pull/7042)
|
||||
- Fixed a bug that broke local pip installs of awxkit (https://github.com/ansible/awx/issues/7107)
|
||||
- Fixed a bug that prevented PagerDuty notifications from sending for workflow job template approvals (https://github.com/ansible/awx/issues/7094)
|
||||
- Fixed a bug that broke external log aggregation support for URL paths that include the = character (such as the tokens for SumoLogic) (https://github.com/ansible/awx/issues/7139)
|
||||
- Fixed a bug that prevented organization admins from removing labels from workflow job templates (https://github.com/ansible/awx/pull/7143)
|
||||
|
||||
## 11.2.0 (Apr 29, 2020)
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ In the sections below, you'll find deployment details and instructions for each
|
||||
|
||||
### Official vs Building Images
|
||||
|
||||
When installing AWX you have the option of building your own images or using the images provided on DockerHub (see [awx_web](https://hub.docker.com/r/ansible/awx_web/) and [awx_task](https://hub.docker.com/r/ansible/awx_task/))
|
||||
When installing AWX you have the option of building your own image or using the image provided on DockerHub (see [awx](https://hub.docker.com/r/ansible/awx/))
|
||||
|
||||
This is controlled by the following variables in the `inventory` file
|
||||
|
||||
@@ -122,7 +122,7 @@ If these variables are present then all deployments will use these hosted images
|
||||
|
||||
*dockerhub_base*
|
||||
|
||||
> The base location on DockerHub where the images are hosted (by default this pulls container images named `ansible/awx_web:tag` and `ansible/awx_task:tag`)
|
||||
> The base location on DockerHub where the images are hosted (by default this pulls a container image named `ansible/awx:tag`)
|
||||
|
||||
*dockerhub_version*
|
||||
|
||||
|
||||
@@ -495,7 +495,7 @@ class NotificationAttachMixin(BaseAccess):
|
||||
# due to this special case, we use symmetrical logic with attach permission
|
||||
return self._can_attach(notification_template=sub_obj, resource_obj=obj)
|
||||
return super(NotificationAttachMixin, self).can_unattach(
|
||||
obj, sub_obj, relationship, relationship, data=data
|
||||
obj, sub_obj, relationship, data=data
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from .plugin import CredentialPlugin, CertFiles
|
||||
|
||||
import base64
|
||||
from urllib.parse import urljoin, quote_plus
|
||||
from urllib.parse import urljoin, quote
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
import requests
|
||||
@@ -50,9 +50,9 @@ conjur_inputs = {
|
||||
def conjur_backend(**kwargs):
|
||||
url = kwargs['url']
|
||||
api_key = kwargs['api_key']
|
||||
account = quote_plus(kwargs['account'])
|
||||
username = quote_plus(kwargs['username'])
|
||||
secret_path = quote_plus(kwargs['secret_path'])
|
||||
account = quote(kwargs['account'], safe='')
|
||||
username = quote(kwargs['username'], safe='')
|
||||
secret_path = quote(kwargs['secret_path'], safe='')
|
||||
version = kwargs.get('secret_version')
|
||||
cacert = kwargs.get('cacert', None)
|
||||
|
||||
|
||||
37
awx/main/tests/functional/test_labels.py
Normal file
37
awx/main/tests/functional/test_labels.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import pytest
|
||||
|
||||
# awx
|
||||
from awx.main.models import WorkflowJobTemplate
|
||||
from awx.api.versioning import reverse
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_workflow_can_add_label(org_admin,organization, post):
|
||||
# create workflow
|
||||
wfjt = WorkflowJobTemplate.objects.create(name='test-wfjt')
|
||||
wfjt.organization = organization
|
||||
# create label
|
||||
wfjt.admin_role.members.add(org_admin)
|
||||
url = reverse('api:workflow_job_template_label_list', kwargs={'pk': wfjt.pk})
|
||||
data = {'name': 'dev-label', 'organization': organization.id}
|
||||
label = post(url, user=org_admin, data=data, expect=201)
|
||||
assert label.data['name'] == 'dev-label'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_workflow_can_remove_label(org_admin, organization, post, get):
|
||||
# create workflow
|
||||
wfjt = WorkflowJobTemplate.objects.create(name='test-wfjt')
|
||||
wfjt.organization = organization
|
||||
# create label
|
||||
wfjt.admin_role.members.add(org_admin)
|
||||
label = wfjt.labels.create(name='dev-label', organization=organization)
|
||||
# delete label
|
||||
url = reverse('api:workflow_job_template_label_list', kwargs={'pk': wfjt.pk})
|
||||
data = {
|
||||
"id": label.pk,
|
||||
"disassociate": True
|
||||
}
|
||||
post(url, data, org_admin, expect=204)
|
||||
results = get(url, org_admin, expect=200)
|
||||
assert results.data['count'] == 0
|
||||
@@ -6,24 +6,33 @@ Have questions about this document or anything not covered here? Feel free to re
|
||||
|
||||
## Table of contents
|
||||
|
||||
* [Things to know prior to submitting code](#things-to-know-prior-to-submitting-code)
|
||||
* [Setting up your development environment](#setting-up-your-development-environment)
|
||||
* [Prerequisites](#prerequisites)
|
||||
* [Node and npm](#node-and-npm)
|
||||
* [Build the user interface](#build-the-user-interface)
|
||||
* [Accessing the AWX web interface](#accessing-the-awx-web-interface)
|
||||
* [AWX REST API Interaction](#awx-rest-api-interaction)
|
||||
* [Handling API Errors](#handling-api-errors)
|
||||
* [Forms](#forms)
|
||||
* [Working with React](#working-with-react)
|
||||
* [App structure](#app-structure)
|
||||
* [Naming files](#naming-files)
|
||||
* [Class constructors vs Class properties](#class-constructors-vs-class-properties)
|
||||
* [Binding](#binding)
|
||||
* [Typechecking with PropTypes](#typechecking-with-proptypes)
|
||||
* [Naming Functions](#naming-functions)
|
||||
* [Default State Initialization](#default-state-initialization)
|
||||
* [Internationalization](#internationalization)
|
||||
- [Ansible AWX UI With PatternFly](#ansible-awx-ui-with-patternfly)
|
||||
- [Table of contents](#table-of-contents)
|
||||
- [Things to know prior to submitting code](#things-to-know-prior-to-submitting-code)
|
||||
- [Setting up your development environment](#setting-up-your-development-environment)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Node and npm](#node-and-npm)
|
||||
- [Build the User Interface](#build-the-user-interface)
|
||||
- [Accessing the AWX web interface](#accessing-the-awx-web-interface)
|
||||
- [AWX REST API Interaction](#awx-rest-api-interaction)
|
||||
- [Handling API Errors](#handling-api-errors)
|
||||
- [Forms](#forms)
|
||||
- [Working with React](#working-with-react)
|
||||
- [App structure](#app-structure)
|
||||
- [Patterns](#patterns)
|
||||
- [Bootstrapping the application (root src/ files)](#bootstrapping-the-application-root-src-files)
|
||||
- [Naming files](#naming-files)
|
||||
- [Naming components that use the context api](#naming-components-that-use-the-context-api)
|
||||
- [Class constructors vs Class properties](#class-constructors-vs-class-properties)
|
||||
- [Binding](#binding)
|
||||
- [Typechecking with PropTypes](#typechecking-with-proptypes)
|
||||
- [Naming Functions](#naming-functions)
|
||||
- [Default State Initialization](#default-state-initialization)
|
||||
- [Testing components that use contexts](#testing-components-that-use-contexts)
|
||||
- [Internationalization](#internationalization)
|
||||
- [Marking strings for translation and replacement in the UI](#marking-strings-for-translation-and-replacement-in-the-ui)
|
||||
- [Setting up .po files to give to translation team](#setting-up-po-files-to-give-to-translation-team)
|
||||
- [Marking an issue to be translated](#marking-an-issue-to-be-translated)
|
||||
|
||||
|
||||
## Things to know prior to submitting code
|
||||
@@ -35,7 +44,7 @@ Have questions about this document or anything not covered here? Feel free to re
|
||||
- functions should adopt camelCase
|
||||
- constructors/classes should adopt PascalCase
|
||||
- constants to be exported should adopt UPPERCASE
|
||||
- For strings, we adopt the `sentence capitalization` since it is a (patternfly style guide)[https://www.patternfly.org/v4/design-guidelines/content/grammar-and-terminology#capitalization].
|
||||
- For strings, we adopt the `sentence capitalization` since it is a [Patternfly style guide](https://www.patternfly.org/v4/design-guidelines/content/grammar-and-terminology#capitalization).
|
||||
|
||||
## Setting up your development environment
|
||||
|
||||
@@ -238,7 +247,7 @@ About.defaultProps = {
|
||||
Here are the guidelines for how to name functions.
|
||||
|
||||
| Naming Convention | Description |
|
||||
|----------|-------------|
|
||||
| ----------------- | --------------------------------------------------------------------------------- |
|
||||
| `handle<x>` | Use for methods that process events |
|
||||
| `on<x>` | Use for component prop names |
|
||||
| `toggle<x>` | Use for methods that flip one value to the opposite value |
|
||||
@@ -320,3 +329,9 @@ You can learn more about the ways lingui and its React helpers at [this link](ht
|
||||
3) Open up the .po file for the language you want to test and add some translations. In production we would pass this .po file off to the translation team.
|
||||
4) Once you've edited your .po file (or we've gotten a .po file back from the translation team) run `npm run compile-strings`. This command takes the .po files and turns them into a minified JSON object and can be seen in the `messages.js` file in each locale directory. These files get loaded at the App root level (see: App.jsx).
|
||||
5) Change the language in your browser and reload the page. You should see your specified translations in place of English strings.
|
||||
|
||||
### Marking an issue to be translated
|
||||
|
||||
1) Issues marked with `component:I10n` should not be closed after the issue was fixed.
|
||||
2) Remove the label `state:needs_devel`.
|
||||
3) Add the label `state:pending_translations`. At this point, the translations will be batch translated by a maintainer, creating relevant entries in the PO files. Then after those translations have been merged, the issue can be closed.
|
||||
|
||||
12
awx/ui_next/package-lock.json
generated
12
awx/ui_next/package-lock.json
generated
@@ -6690,9 +6690,9 @@
|
||||
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
|
||||
},
|
||||
"eventemitter3": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz",
|
||||
"integrity": "sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg=="
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.4.tgz",
|
||||
"integrity": "sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ=="
|
||||
},
|
||||
"events": {
|
||||
"version": "3.1.0",
|
||||
@@ -7996,9 +7996,9 @@
|
||||
"integrity": "sha1-ksnBN0w1CF912zWexWzCV8u5P6Q="
|
||||
},
|
||||
"http-proxy": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.0.tgz",
|
||||
"integrity": "sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==",
|
||||
"version": "1.18.1",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
|
||||
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
|
||||
"requires": {
|
||||
"eventemitter3": "^4.0.0",
|
||||
"follow-redirects": "^1.0.0",
|
||||
|
||||
@@ -1,221 +1,82 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { global_breakpoint_md } from '@patternfly/react-tokens';
|
||||
import React from 'react';
|
||||
import {
|
||||
Nav,
|
||||
NavList,
|
||||
Page,
|
||||
PageHeader as PFPageHeader,
|
||||
PageSidebar,
|
||||
} from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
import { t } from '@lingui/macro';
|
||||
import { withI18n } from '@lingui/react';
|
||||
useRouteMatch,
|
||||
useLocation,
|
||||
HashRouter,
|
||||
Route,
|
||||
Switch,
|
||||
Redirect,
|
||||
} from 'react-router-dom';
|
||||
import { I18n, I18nProvider } from '@lingui/react';
|
||||
|
||||
import { ConfigAPI, MeAPI, RootAPI } from './api';
|
||||
import About from './components/About';
|
||||
import AlertModal from './components/AlertModal';
|
||||
import NavExpandableGroup from './components/NavExpandableGroup';
|
||||
import BrandLogo from './components/BrandLogo';
|
||||
import PageHeaderToolbar from './components/PageHeaderToolbar';
|
||||
import ErrorDetail from './components/ErrorDetail';
|
||||
import { ConfigProvider } from './contexts/Config';
|
||||
import AppContainer from './components/AppContainer';
|
||||
import Background from './components/Background';
|
||||
import NotFound from './screens/NotFound';
|
||||
import Login from './screens/Login';
|
||||
|
||||
const PageHeader = styled(PFPageHeader)`
|
||||
& .pf-c-page__header-brand-link {
|
||||
color: inherit;
|
||||
import ja from './locales/ja/messages';
|
||||
import en from './locales/en/messages';
|
||||
import { isAuthenticated } from './util/auth';
|
||||
import { getLanguageWithoutRegionCode } from './util/language';
|
||||
|
||||
&:hover {
|
||||
color: inherit;
|
||||
}
|
||||
import getRouteConfig from './routeConfig';
|
||||
|
||||
& svg {
|
||||
height: 76px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
class App extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// initialize with a closed navbar if window size is small
|
||||
const isNavOpen =
|
||||
typeof window !== 'undefined' &&
|
||||
window.innerWidth >= parseInt(global_breakpoint_md.value, 10);
|
||||
|
||||
this.state = {
|
||||
ansible_version: null,
|
||||
custom_virtualenvs: null,
|
||||
me: null,
|
||||
version: null,
|
||||
isAboutModalOpen: false,
|
||||
isNavOpen,
|
||||
configError: null,
|
||||
};
|
||||
|
||||
this.handleLogout = this.handleLogout.bind(this);
|
||||
this.handleAboutClose = this.handleAboutClose.bind(this);
|
||||
this.handleAboutOpen = this.handleAboutOpen.bind(this);
|
||||
this.handleNavToggle = this.handleNavToggle.bind(this);
|
||||
this.handleConfigErrorClose = this.handleConfigErrorClose.bind(this);
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
await this.loadConfig();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
async handleLogout() {
|
||||
const { history } = this.props;
|
||||
await RootAPI.logout();
|
||||
history.replace('/login');
|
||||
}
|
||||
|
||||
handleAboutOpen() {
|
||||
this.setState({ isAboutModalOpen: true });
|
||||
}
|
||||
|
||||
handleAboutClose() {
|
||||
this.setState({ isAboutModalOpen: false });
|
||||
}
|
||||
|
||||
handleNavToggle() {
|
||||
this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen }));
|
||||
}
|
||||
|
||||
handleConfigErrorClose() {
|
||||
this.setState({
|
||||
configError: null,
|
||||
});
|
||||
}
|
||||
|
||||
async loadConfig() {
|
||||
try {
|
||||
const [configRes, meRes] = await Promise.all([
|
||||
ConfigAPI.read(),
|
||||
MeAPI.read(),
|
||||
]);
|
||||
const {
|
||||
data: {
|
||||
ansible_version,
|
||||
custom_virtualenvs,
|
||||
project_base_dir,
|
||||
project_local_paths,
|
||||
version,
|
||||
},
|
||||
} = configRes;
|
||||
const {
|
||||
data: {
|
||||
results: [me],
|
||||
},
|
||||
} = meRes;
|
||||
|
||||
this.setState({
|
||||
ansible_version,
|
||||
custom_virtualenvs,
|
||||
project_base_dir,
|
||||
project_local_paths,
|
||||
version,
|
||||
me,
|
||||
});
|
||||
} catch (err) {
|
||||
this.setState({ configError: err });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
ansible_version,
|
||||
custom_virtualenvs,
|
||||
project_base_dir,
|
||||
project_local_paths,
|
||||
isAboutModalOpen,
|
||||
isNavOpen,
|
||||
me,
|
||||
version,
|
||||
configError,
|
||||
} = this.state;
|
||||
const {
|
||||
i18n,
|
||||
render = () => {},
|
||||
routeGroups = [],
|
||||
navLabel = '',
|
||||
} = this.props;
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
showNavToggle
|
||||
onNavToggle={this.handleNavToggle}
|
||||
logo={<BrandLogo />}
|
||||
logoProps={{ href: '/' }}
|
||||
toolbar={
|
||||
<PageHeaderToolbar
|
||||
loggedInUser={me}
|
||||
isAboutDisabled={!version}
|
||||
onAboutClick={this.handleAboutOpen}
|
||||
onLogoutClick={this.handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
const ProtectedRoute = ({ children, ...rest }) =>
|
||||
isAuthenticated(document.cookie) ? (
|
||||
<Route {...rest}>{children}</Route>
|
||||
) : (
|
||||
<Redirect to="/login" />
|
||||
);
|
||||
|
||||
const sidebar = (
|
||||
<PageSidebar
|
||||
isNavOpen={isNavOpen}
|
||||
theme="dark"
|
||||
nav={
|
||||
<Nav aria-label={navLabel} theme="dark">
|
||||
<NavList>
|
||||
{routeGroups.map(({ groupId, groupTitle, routes }) => (
|
||||
<NavExpandableGroup
|
||||
key={groupId}
|
||||
groupId={groupId}
|
||||
groupTitle={groupTitle}
|
||||
routes={routes}
|
||||
/>
|
||||
))}
|
||||
</NavList>
|
||||
</Nav>
|
||||
}
|
||||
/>
|
||||
);
|
||||
function App() {
|
||||
const catalogs = { en, ja };
|
||||
const language = getLanguageWithoutRegionCode(navigator);
|
||||
const match = useRouteMatch();
|
||||
const { hash, search, pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Page usecondensed="True" header={header} sidebar={sidebar}>
|
||||
<ConfigProvider
|
||||
value={{
|
||||
ansible_version,
|
||||
custom_virtualenvs,
|
||||
project_base_dir,
|
||||
project_local_paths,
|
||||
me,
|
||||
version,
|
||||
}}
|
||||
>
|
||||
{render({ routeGroups })}
|
||||
</ConfigProvider>
|
||||
</Page>
|
||||
<About
|
||||
ansible_version={ansible_version}
|
||||
version={version}
|
||||
isOpen={isAboutModalOpen}
|
||||
onClose={this.handleAboutClose}
|
||||
/>
|
||||
<AlertModal
|
||||
isOpen={configError}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={this.handleConfigErrorClose}
|
||||
>
|
||||
{i18n._(t`Failed to retrieve configuration.`)}
|
||||
<ErrorDetail error={configError} />
|
||||
</AlertModal>
|
||||
</Fragment>
|
||||
<I18nProvider language={language} catalogs={catalogs}>
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<Background>
|
||||
<Switch>
|
||||
<Route exact strict path="/*/">
|
||||
<Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />
|
||||
</Route>
|
||||
<Route path="/login">
|
||||
<Login isAuthenticated={isAuthenticated} />
|
||||
</Route>
|
||||
<Route exact path="/">
|
||||
<Redirect to="/home" />
|
||||
</Route>
|
||||
<ProtectedRoute>
|
||||
<AppContainer navRouteConfig={getRouteConfig(i18n)}>
|
||||
<Switch>
|
||||
{getRouteConfig(i18n)
|
||||
.flatMap(({ routes }) => routes)
|
||||
.map(({ path, screen: Screen }) => (
|
||||
<ProtectedRoute key={path} path={path}>
|
||||
<Screen match={match} />
|
||||
</ProtectedRoute>
|
||||
))
|
||||
.concat(
|
||||
<ProtectedRoute key="not-found" path="*">
|
||||
<NotFound />
|
||||
</ProtectedRoute>
|
||||
)}
|
||||
</Switch>
|
||||
</AppContainer>
|
||||
</ProtectedRoute>
|
||||
</Switch>
|
||||
</Background>
|
||||
)}
|
||||
</I18n>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { App as _App };
|
||||
export default withI18n()(withRouter(App));
|
||||
export default () => (
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
);
|
||||
|
||||
@@ -1,121 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
import { mountWithContexts, waitForElement } from '../testUtils/enzymeHelpers';
|
||||
import { ConfigAPI, MeAPI, RootAPI } from './api';
|
||||
import { asyncFlush } from './setupTests';
|
||||
import { mountWithContexts } from '../testUtils/enzymeHelpers';
|
||||
|
||||
import App from './App';
|
||||
|
||||
jest.mock('./api');
|
||||
|
||||
describe('<App />', () => {
|
||||
const ansible_version = '111';
|
||||
const custom_virtualenvs = [];
|
||||
const version = '222';
|
||||
|
||||
beforeEach(() => {
|
||||
ConfigAPI.read = () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
ansible_version,
|
||||
custom_virtualenvs,
|
||||
version,
|
||||
},
|
||||
});
|
||||
MeAPI.read = () => Promise.resolve({ data: { results: [{}] } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('expected content is rendered', () => {
|
||||
const appWrapper = mountWithContexts(
|
||||
<App
|
||||
routeGroups={[
|
||||
{
|
||||
groupTitle: 'Group One',
|
||||
groupId: 'group_one',
|
||||
routes: [
|
||||
{ title: 'Foo', path: '/foo' },
|
||||
{ title: 'Bar', path: '/bar' },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupTitle: 'Group Two',
|
||||
groupId: 'group_two',
|
||||
routes: [{ title: 'Fiz', path: '/fiz' }],
|
||||
},
|
||||
]}
|
||||
render={({ routeGroups }) =>
|
||||
routeGroups.map(({ groupId }) => <div key={groupId} id={groupId} />)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// page components
|
||||
expect(appWrapper.length).toBe(1);
|
||||
expect(appWrapper.find('PageHeader').length).toBe(1);
|
||||
expect(appWrapper.find('PageSidebar').length).toBe(1);
|
||||
|
||||
// sidebar groups and route links
|
||||
expect(appWrapper.find('NavExpandableGroup').length).toBe(2);
|
||||
expect(appWrapper.find('a[href="/#/foo"]').length).toBe(1);
|
||||
expect(appWrapper.find('a[href="/#/bar"]').length).toBe(1);
|
||||
expect(appWrapper.find('a[href="/#/fiz"]').length).toBe(1);
|
||||
|
||||
// inline render
|
||||
expect(appWrapper.find('#group_one').length).toBe(1);
|
||||
expect(appWrapper.find('#group_two').length).toBe(1);
|
||||
});
|
||||
|
||||
test('opening the about modal renders prefetched config data', async done => {
|
||||
test('renders ok', () => {
|
||||
const wrapper = mountWithContexts(<App />);
|
||||
wrapper.update();
|
||||
|
||||
// open about modal
|
||||
const aboutDropdown = 'Dropdown QuestionCircleIcon';
|
||||
const aboutButton = 'DropdownItem li button';
|
||||
const aboutModalContent = 'AboutModalBoxContent';
|
||||
const aboutModalClose = 'button[aria-label="Close Dialog"]';
|
||||
|
||||
await waitForElement(wrapper, aboutDropdown);
|
||||
wrapper.find(aboutDropdown).simulate('click');
|
||||
|
||||
const button = await waitForElement(
|
||||
wrapper,
|
||||
aboutButton,
|
||||
el => !el.props().disabled
|
||||
);
|
||||
button.simulate('click');
|
||||
|
||||
// check about modal content
|
||||
const content = await waitForElement(wrapper, aboutModalContent);
|
||||
expect(content.find('dd').text()).toContain(ansible_version);
|
||||
expect(content.find('pre').text()).toContain(`< AWX ${version} >`);
|
||||
|
||||
// close about modal
|
||||
wrapper.find(aboutModalClose).simulate('click');
|
||||
expect(wrapper.find(aboutModalContent)).toHaveLength(0);
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
test('handleNavToggle sets state.isNavOpen to opposite', () => {
|
||||
const appWrapper = mountWithContexts(<App />).find('App');
|
||||
|
||||
const { handleNavToggle } = appWrapper.instance();
|
||||
[true, false, true, false, true].forEach(expected => {
|
||||
expect(appWrapper.state().isNavOpen).toBe(expected);
|
||||
handleNavToggle();
|
||||
});
|
||||
});
|
||||
|
||||
test('onLogout makes expected call to api client', async done => {
|
||||
const appWrapper = mountWithContexts(<App />).find('App');
|
||||
appWrapper.instance().handleLogout();
|
||||
await asyncFlush();
|
||||
expect(RootAPI.logout).toHaveBeenCalledTimes(1);
|
||||
done();
|
||||
expect(wrapper.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
|
||||
import { getLanguageWithoutRegionCode } from './util/language';
|
||||
import ja from './locales/ja/messages';
|
||||
import en from './locales/en/messages';
|
||||
|
||||
class RootProvider extends Component {
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
|
||||
const catalogs = { en, ja };
|
||||
const language = getLanguageWithoutRegionCode(navigator);
|
||||
|
||||
return (
|
||||
<HashRouter>
|
||||
<I18nProvider language={language} catalogs={catalogs}>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default RootProvider;
|
||||
@@ -1,5 +1,6 @@
|
||||
import AdHocCommands from './models/AdHocCommands';
|
||||
import Config from './models/Config';
|
||||
import CredentialInputSources from './models/CredentialInputSources';
|
||||
import CredentialTypes from './models/CredentialTypes';
|
||||
import Credentials from './models/Credentials';
|
||||
import Groups from './models/Groups';
|
||||
@@ -14,9 +15,10 @@ import Labels from './models/Labels';
|
||||
import Me from './models/Me';
|
||||
import NotificationTemplates from './models/NotificationTemplates';
|
||||
import Organizations from './models/Organizations';
|
||||
import Projects from './models/Projects';
|
||||
import ProjectUpdates from './models/ProjectUpdates';
|
||||
import Projects from './models/Projects';
|
||||
import Root from './models/Root';
|
||||
import Roles from './models/Roles';
|
||||
import Schedules from './models/Schedules';
|
||||
import SystemJobs from './models/SystemJobs';
|
||||
import Teams from './models/Teams';
|
||||
@@ -24,14 +26,15 @@ import UnifiedJobTemplates from './models/UnifiedJobTemplates';
|
||||
import UnifiedJobs from './models/UnifiedJobs';
|
||||
import Users from './models/Users';
|
||||
import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates';
|
||||
import WorkflowJobs from './models/WorkflowJobs';
|
||||
import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
|
||||
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
|
||||
import WorkflowJobs from './models/WorkflowJobs';
|
||||
|
||||
const AdHocCommandsAPI = new AdHocCommands();
|
||||
const ConfigAPI = new Config();
|
||||
const CredentialsAPI = new Credentials();
|
||||
const CredentialInputSourcesAPI = new CredentialInputSources();
|
||||
const CredentialTypesAPI = new CredentialTypes();
|
||||
const CredentialsAPI = new Credentials();
|
||||
const GroupsAPI = new Groups();
|
||||
const HostsAPI = new Hosts();
|
||||
const InstanceGroupsAPI = new InstanceGroups();
|
||||
@@ -44,9 +47,10 @@ const LabelsAPI = new Labels();
|
||||
const MeAPI = new Me();
|
||||
const NotificationTemplatesAPI = new NotificationTemplates();
|
||||
const OrganizationsAPI = new Organizations();
|
||||
const ProjectsAPI = new Projects();
|
||||
const ProjectUpdatesAPI = new ProjectUpdates();
|
||||
const ProjectsAPI = new Projects();
|
||||
const RootAPI = new Root();
|
||||
const RolesAPI = new Roles();
|
||||
const SchedulesAPI = new Schedules();
|
||||
const SystemJobsAPI = new SystemJobs();
|
||||
const TeamsAPI = new Teams();
|
||||
@@ -54,15 +58,16 @@ const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
|
||||
const UnifiedJobsAPI = new UnifiedJobs();
|
||||
const UsersAPI = new Users();
|
||||
const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates();
|
||||
const WorkflowJobsAPI = new WorkflowJobs();
|
||||
const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes();
|
||||
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
|
||||
const WorkflowJobsAPI = new WorkflowJobs();
|
||||
|
||||
export {
|
||||
AdHocCommandsAPI,
|
||||
ConfigAPI,
|
||||
CredentialsAPI,
|
||||
CredentialInputSourcesAPI,
|
||||
CredentialTypesAPI,
|
||||
CredentialsAPI,
|
||||
GroupsAPI,
|
||||
HostsAPI,
|
||||
InstanceGroupsAPI,
|
||||
@@ -75,9 +80,10 @@ export {
|
||||
MeAPI,
|
||||
NotificationTemplatesAPI,
|
||||
OrganizationsAPI,
|
||||
ProjectsAPI,
|
||||
ProjectUpdatesAPI,
|
||||
ProjectsAPI,
|
||||
RootAPI,
|
||||
RolesAPI,
|
||||
SchedulesAPI,
|
||||
SystemJobsAPI,
|
||||
TeamsAPI,
|
||||
@@ -85,7 +91,7 @@ export {
|
||||
UnifiedJobsAPI,
|
||||
UsersAPI,
|
||||
WorkflowApprovalTemplatesAPI,
|
||||
WorkflowJobsAPI,
|
||||
WorkflowJobTemplateNodesAPI,
|
||||
WorkflowJobTemplatesAPI,
|
||||
WorkflowJobsAPI,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ class Config extends Base {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/config/';
|
||||
this.read = this.read.bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
awx/ui_next/src/api/models/CredentialInputSources.js
Normal file
10
awx/ui_next/src/api/models/CredentialInputSources.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import Base from '../Base';
|
||||
|
||||
class CredentialInputSources extends Base {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/credential_input_sources/';
|
||||
}
|
||||
}
|
||||
|
||||
export default CredentialInputSources;
|
||||
@@ -6,6 +6,7 @@ class Credentials extends Base {
|
||||
this.baseUrl = '/api/v2/credentials/';
|
||||
|
||||
this.readAccessList = this.readAccessList.bind(this);
|
||||
this.readInputSources = this.readInputSources.bind(this);
|
||||
}
|
||||
|
||||
readAccessList(id, params) {
|
||||
@@ -13,6 +14,12 @@ class Credentials extends Base {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
readInputSources(id, params) {
|
||||
return this.http.get(`${this.baseUrl}${id}/input_sources/`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Credentials;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Base from '../Base';
|
||||
import NotificationsMixin from '../mixins/Notifications.mixin';
|
||||
import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin';
|
||||
|
||||
class InventorySources extends LaunchUpdateMixin(Base) {
|
||||
class InventorySources extends LaunchUpdateMixin(NotificationsMixin(Base)) {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/inventory_sources/';
|
||||
|
||||
23
awx/ui_next/src/api/models/Roles.js
Normal file
23
awx/ui_next/src/api/models/Roles.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import Base from '../Base';
|
||||
|
||||
class Roles extends Base {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/roles/';
|
||||
}
|
||||
|
||||
disassociateUserRole(roleId, userId) {
|
||||
return this.http.post(`${this.baseUrl}/${roleId}/users/`, {
|
||||
disassociate: true,
|
||||
id: userId,
|
||||
});
|
||||
}
|
||||
|
||||
disassociateTeamRole(roleId, teamId) {
|
||||
return this.http.post(`${this.baseUrl}/${roleId}/teams/`, {
|
||||
disassociate: true,
|
||||
id: teamId,
|
||||
});
|
||||
}
|
||||
}
|
||||
export default Roles;
|
||||
@@ -34,6 +34,16 @@ class Users extends Base {
|
||||
readRoleOptions(userId) {
|
||||
return this.http.options(`${this.baseUrl}${userId}/roles/`);
|
||||
}
|
||||
|
||||
readTeams(userId, params) {
|
||||
return this.http.get(`${this.baseUrl}${userId}/teams/`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
readTeamsOptions(userId) {
|
||||
return this.http.options(`${this.baseUrl}${userId}/teams/`);
|
||||
}
|
||||
}
|
||||
|
||||
export default Users;
|
||||
|
||||
@@ -258,7 +258,7 @@ class AddResourceRole extends React.Component {
|
||||
sortColumns={userSortColumns}
|
||||
displayKey="username"
|
||||
onRowClick={this.handleResourceCheckboxClick}
|
||||
onSearch={readUsers}
|
||||
fetchItems={readUsers}
|
||||
selectedLabel={i18n._(t`Selected`)}
|
||||
selectedResourceRows={selectedResourceRows}
|
||||
sortedColumnKey="username"
|
||||
@@ -269,7 +269,7 @@ class AddResourceRole extends React.Component {
|
||||
searchColumns={teamSearchColumns}
|
||||
sortColumns={teamSortColumns}
|
||||
onRowClick={this.handleResourceCheckboxClick}
|
||||
onSearch={readTeams}
|
||||
fetchItems={readTeams}
|
||||
selectedLabel={i18n._(t`Selected`)}
|
||||
selectedResourceRows={selectedResourceRows}
|
||||
/>
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Checkbox } from '@patternfly/react-core';
|
||||
import { Checkbox as PFCheckbox } from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const CheckboxWrapper = styled.div`
|
||||
display: flex;
|
||||
border: 1px solid var(--pf-global--BorderColor--200);
|
||||
border-radius: var(--pf-global--BorderRadius--sm);
|
||||
padding: 10px;
|
||||
`;
|
||||
|
||||
const Checkbox = styled(PFCheckbox)`
|
||||
width: 100%;
|
||||
& label {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
class CheckboxCard extends Component {
|
||||
render() {
|
||||
const { name, description, isSelected, onSelect, itemId } = this.props;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
border: '1px solid var(--pf-global--BorderColor--200)',
|
||||
borderRadius: 'var(--pf-global--BorderRadius--sm)',
|
||||
padding: '10px',
|
||||
}}
|
||||
>
|
||||
<CheckboxWrapper>
|
||||
<Checkbox
|
||||
isChecked={isSelected}
|
||||
onChange={onSelect}
|
||||
@@ -27,7 +35,7 @@ class CheckboxCard extends Component {
|
||||
}
|
||||
value={itemId}
|
||||
/>
|
||||
</div>
|
||||
</CheckboxWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React, { Fragment, useCallback, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withRouter, useLocation } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import useRequest from '../../util/useRequest';
|
||||
|
||||
import { SearchColumns, SortColumns } from '../../types';
|
||||
import PaginatedDataList from '../PaginatedDataList';
|
||||
import DataListToolbar from '../DataListToolbar';
|
||||
@@ -10,82 +12,55 @@ import CheckboxListItem from '../CheckboxListItem';
|
||||
import SelectedList from '../SelectedList';
|
||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||
|
||||
class SelectResourceStep extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isInitialized: false,
|
||||
count: null,
|
||||
error: false,
|
||||
resources: [],
|
||||
};
|
||||
|
||||
this.qsConfig = getQSConfig('resource', {
|
||||
const QS_Config = sortColumns => {
|
||||
return getQSConfig('resource', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: `${
|
||||
props.sortColumns.filter(col => col.key === 'name').length
|
||||
? 'name'
|
||||
: 'username'
|
||||
sortColumns.filter(col => col.key === 'name').length ? 'name' : 'username'
|
||||
}`,
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.readResourceList();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { location } = this.props;
|
||||
if (location !== prevProps.location) {
|
||||
this.readResourceList();
|
||||
}
|
||||
}
|
||||
|
||||
async readResourceList() {
|
||||
const { onSearch, location } = this.props;
|
||||
const queryParams = parseQueryString(this.qsConfig, location.search);
|
||||
|
||||
this.setState({
|
||||
isLoading: true,
|
||||
error: false,
|
||||
});
|
||||
try {
|
||||
const { data } = await onSearch(queryParams);
|
||||
const { count, results } = data;
|
||||
|
||||
this.setState({
|
||||
resources: results,
|
||||
count,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
error: false,
|
||||
});
|
||||
} catch (err) {
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isInitialized, isLoading, count, error, resources } = this.state;
|
||||
|
||||
const {
|
||||
};
|
||||
function SelectResourceStep({
|
||||
searchColumns,
|
||||
sortColumns,
|
||||
displayKey,
|
||||
onRowClick,
|
||||
selectedLabel,
|
||||
selectedResourceRows,
|
||||
fetchItems,
|
||||
i18n,
|
||||
} = this.props;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
request: readResourceList,
|
||||
result: { resources, itemCount },
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const queryParams = parseQueryString(
|
||||
QS_Config(sortColumns),
|
||||
location.search
|
||||
);
|
||||
|
||||
const {
|
||||
data: { count, results },
|
||||
} = await fetchItems(queryParams);
|
||||
return { resources: results, itemCount: count };
|
||||
}, [location, fetchItems, sortColumns]),
|
||||
{
|
||||
resources: [],
|
||||
itemCount: 0,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
readResourceList();
|
||||
}, [readResourceList]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{isInitialized && (
|
||||
<Fragment>
|
||||
<div>
|
||||
{i18n._(
|
||||
@@ -102,9 +77,10 @@ class SelectResourceStep extends React.Component {
|
||||
)}
|
||||
<PaginatedDataList
|
||||
hasContentLoading={isLoading}
|
||||
contentError={error}
|
||||
items={resources}
|
||||
itemCount={count}
|
||||
qsConfig={this.qsConfig}
|
||||
itemCount={itemCount}
|
||||
qsConfig={QS_Config(sortColumns)}
|
||||
onRowClick={onRowClick}
|
||||
toolbarSearchColumns={searchColumns}
|
||||
toolbarSortColumns={sortColumns}
|
||||
@@ -123,19 +99,15 @@ class SelectResourceStep extends React.Component {
|
||||
showPageSizeOptions={false}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
{error ? <div>error</div> : ''}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SelectResourceStep.propTypes = {
|
||||
searchColumns: SearchColumns,
|
||||
sortColumns: SortColumns,
|
||||
displayKey: PropTypes.string,
|
||||
onRowClick: PropTypes.func,
|
||||
onSearch: PropTypes.func.isRequired,
|
||||
fetchItems: PropTypes.func.isRequired,
|
||||
selectedLabel: PropTypes.string,
|
||||
selectedResourceRows: PropTypes.arrayOf(PropTypes.object),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React from 'react';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../testUtils/enzymeHelpers';
|
||||
import { sleep } from '../../../testUtils/testUtils';
|
||||
import SelectResourceStep from './SelectResourceStep';
|
||||
|
||||
@@ -30,12 +34,12 @@ describe('<SelectResourceStep />', () => {
|
||||
sortColumns={sortColumns}
|
||||
displayKey="username"
|
||||
onRowClick={() => {}}
|
||||
onSearch={() => {}}
|
||||
fetchItems={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
test('fetches resources on mount', async () => {
|
||||
test('fetches resources on mount and adds items to list', async () => {
|
||||
const handleSearch = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
count: 2,
|
||||
@@ -45,61 +49,24 @@ describe('<SelectResourceStep />', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
mountWithContexts(
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SelectResourceStep
|
||||
searchColumns={searchColumns}
|
||||
sortColumns={sortColumns}
|
||||
displayKey="username"
|
||||
onRowClick={() => {}}
|
||||
onSearch={handleSearch}
|
||||
fetchItems={handleSearch}
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(handleSearch).toHaveBeenCalledWith({
|
||||
order_by: 'username',
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test('readResourceList properly adds rows to state', async () => {
|
||||
const selectedResourceRows = [{ id: 1, username: 'foo', url: 'item/1' }];
|
||||
const handleSearch = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
count: 2,
|
||||
results: [
|
||||
{ id: 1, username: 'foo', url: 'item/1' },
|
||||
{ id: 2, username: 'bar', url: 'item/2' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: [
|
||||
'/organizations/1/access?resource.page=1&resource.order_by=-username',
|
||||
],
|
||||
});
|
||||
const wrapper = mountWithContexts(
|
||||
<SelectResourceStep
|
||||
searchColumns={searchColumns}
|
||||
sortColumns={sortColumns}
|
||||
displayKey="username"
|
||||
onRowClick={() => {}}
|
||||
onSearch={handleSearch}
|
||||
selectedResourceRows={selectedResourceRows}
|
||||
/>,
|
||||
{
|
||||
context: { router: { history, route: { location: history.location } } },
|
||||
}
|
||||
).find('SelectResourceStep');
|
||||
await wrapper.instance().readResourceList();
|
||||
expect(handleSearch).toHaveBeenCalledWith({
|
||||
order_by: '-username',
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
});
|
||||
expect(wrapper.state('resources')).toEqual([
|
||||
{ id: 1, username: 'foo', url: 'item/1' },
|
||||
{ id: 2, username: 'bar', url: 'item/2' },
|
||||
]);
|
||||
waitForElement(wrapper, 'CheckBoxListItem', el => el.length === 2);
|
||||
});
|
||||
|
||||
test('clicking on row fires callback with correct params', async () => {
|
||||
@@ -111,20 +78,24 @@ describe('<SelectResourceStep />', () => {
|
||||
{ id: 2, username: 'bar', url: 'item/2' },
|
||||
],
|
||||
};
|
||||
const wrapper = mountWithContexts(
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SelectResourceStep
|
||||
searchColumns={searchColumns}
|
||||
sortColumns={sortColumns}
|
||||
displayKey="username"
|
||||
onRowClick={handleRowClick}
|
||||
onSearch={() => ({ data })}
|
||||
fetchItems={() => ({ data })}
|
||||
selectedResourceRows={[]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await sleep(0);
|
||||
wrapper.update();
|
||||
const checkboxListItemWrapper = wrapper.find('CheckboxListItem');
|
||||
expect(checkboxListItemWrapper.length).toBe(2);
|
||||
|
||||
checkboxListItemWrapper
|
||||
.first()
|
||||
.find('input[type="checkbox"]')
|
||||
|
||||
142
awx/ui_next/src/components/AppContainer/AppContainer.jsx
Normal file
142
awx/ui_next/src/components/AppContainer/AppContainer.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useHistory, useLocation, withRouter } from 'react-router-dom';
|
||||
import { global_breakpoint_md } from '@patternfly/react-tokens';
|
||||
import {
|
||||
Nav,
|
||||
NavList,
|
||||
Page,
|
||||
PageHeader as PFPageHeader,
|
||||
PageSidebar,
|
||||
} from '@patternfly/react-core';
|
||||
import { t } from '@lingui/macro';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { ConfigAPI, MeAPI, RootAPI } from '../../api';
|
||||
import { ConfigProvider } from '../../contexts/Config';
|
||||
import About from '../About';
|
||||
import AlertModal from '../AlertModal';
|
||||
import ErrorDetail from '../ErrorDetail';
|
||||
import BrandLogo from './BrandLogo';
|
||||
import NavExpandableGroup from './NavExpandableGroup';
|
||||
import PageHeaderToolbar from './PageHeaderToolbar';
|
||||
|
||||
const PageHeader = styled(PFPageHeader)`
|
||||
& .pf-c-page__header-brand-link {
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
& svg {
|
||||
height: 76px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function AppContainer({ i18n, navRouteConfig = [], children }) {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const [config, setConfig] = useState({});
|
||||
const [configError, setConfigError] = useState(null);
|
||||
const [isAboutModalOpen, setIsAboutModalOpen] = useState(false);
|
||||
const [isNavOpen, setIsNavOpen] = useState(
|
||||
typeof window !== 'undefined' &&
|
||||
window.innerWidth >= parseInt(global_breakpoint_md.value, 10)
|
||||
);
|
||||
|
||||
const handleAboutModalOpen = () => setIsAboutModalOpen(true);
|
||||
const handleAboutModalClose = () => setIsAboutModalOpen(false);
|
||||
const handleConfigErrorClose = () => setConfigError(null);
|
||||
const handleNavToggle = () => setIsNavOpen(!isNavOpen);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await RootAPI.logout();
|
||||
history.replace('/login');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
if (config?.version) return;
|
||||
try {
|
||||
const [
|
||||
{ data },
|
||||
{
|
||||
data: {
|
||||
results: [me],
|
||||
},
|
||||
},
|
||||
] = await Promise.all([ConfigAPI.read(), MeAPI.read()]);
|
||||
setConfig({ ...data, me });
|
||||
} catch (err) {
|
||||
setConfigError(err);
|
||||
}
|
||||
};
|
||||
loadConfig();
|
||||
}, [config, pathname]);
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
showNavToggle
|
||||
onNavToggle={handleNavToggle}
|
||||
logo={<BrandLogo />}
|
||||
logoProps={{ href: '/' }}
|
||||
toolbar={
|
||||
<PageHeaderToolbar
|
||||
loggedInUser={config?.me}
|
||||
isAboutDisabled={!config?.version}
|
||||
onAboutClick={handleAboutModalOpen}
|
||||
onLogoutClick={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const sidebar = (
|
||||
<PageSidebar
|
||||
isNavOpen={isNavOpen}
|
||||
theme="dark"
|
||||
nav={
|
||||
<Nav aria-label={i18n._(t`Navigation`)} theme="dark">
|
||||
<NavList>
|
||||
{navRouteConfig.map(({ groupId, groupTitle, routes }) => (
|
||||
<NavExpandableGroup
|
||||
key={groupId}
|
||||
groupId={groupId}
|
||||
groupTitle={groupTitle}
|
||||
routes={routes}
|
||||
/>
|
||||
))}
|
||||
</NavList>
|
||||
</Nav>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Page usecondensed="True" header={header} sidebar={sidebar}>
|
||||
<ConfigProvider value={config}>{children}</ConfigProvider>
|
||||
</Page>
|
||||
<About
|
||||
ansible_version={config?.ansible_version}
|
||||
version={config?.version}
|
||||
isOpen={isAboutModalOpen}
|
||||
onClose={handleAboutModalClose}
|
||||
/>
|
||||
<AlertModal
|
||||
isOpen={configError}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={handleConfigErrorClose}
|
||||
>
|
||||
{i18n._(t`Failed to retrieve configuration.`)}
|
||||
<ErrorDetail error={configError} />
|
||||
</AlertModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { AppContainer as _AppContainer };
|
||||
export default withI18n()(withRouter(AppContainer));
|
||||
125
awx/ui_next/src/components/AppContainer/AppContainer.test.jsx
Normal file
125
awx/ui_next/src/components/AppContainer/AppContainer.test.jsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../testUtils/enzymeHelpers';
|
||||
import { ConfigAPI, MeAPI, RootAPI } from '../../api';
|
||||
|
||||
import AppContainer from './AppContainer';
|
||||
|
||||
jest.mock('../../api');
|
||||
|
||||
describe('<AppContainer />', () => {
|
||||
const ansible_version = '111';
|
||||
const custom_virtualenvs = [];
|
||||
const version = '222';
|
||||
|
||||
beforeEach(() => {
|
||||
ConfigAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
ansible_version,
|
||||
custom_virtualenvs,
|
||||
version,
|
||||
},
|
||||
});
|
||||
MeAPI.read.mockResolvedValue({ data: { results: [{}] } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('expected content is rendered', async () => {
|
||||
const routeConfig = [
|
||||
{
|
||||
groupTitle: 'Group One',
|
||||
groupId: 'group_one',
|
||||
routes: [
|
||||
{ title: 'Foo', path: '/foo' },
|
||||
{ title: 'Bar', path: '/bar' },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupTitle: 'Group Two',
|
||||
groupId: 'group_two',
|
||||
routes: [{ title: 'Fiz', path: '/fiz' }],
|
||||
},
|
||||
];
|
||||
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<AppContainer navRouteConfig={routeConfig}>
|
||||
{routeConfig.map(({ groupId }) => (
|
||||
<div key={groupId} id={groupId} />
|
||||
))}
|
||||
</AppContainer>
|
||||
);
|
||||
});
|
||||
|
||||
// page components
|
||||
expect(wrapper.length).toBe(1);
|
||||
expect(wrapper.find('PageHeader').length).toBe(1);
|
||||
expect(wrapper.find('PageSidebar').length).toBe(1);
|
||||
|
||||
// sidebar groups and route links
|
||||
expect(wrapper.find('NavExpandableGroup').length).toBe(2);
|
||||
expect(wrapper.find('a[href="/#/foo"]').length).toBe(1);
|
||||
expect(wrapper.find('a[href="/#/bar"]').length).toBe(1);
|
||||
expect(wrapper.find('a[href="/#/fiz"]').length).toBe(1);
|
||||
|
||||
expect(wrapper.find('#group_one').length).toBe(1);
|
||||
expect(wrapper.find('#group_two').length).toBe(1);
|
||||
});
|
||||
|
||||
test('opening the about modal renders prefetched config data', async () => {
|
||||
const aboutDropdown = 'Dropdown QuestionCircleIcon';
|
||||
const aboutButton = 'DropdownItem li button';
|
||||
const aboutModalContent = 'AboutModalBoxContent';
|
||||
const aboutModalClose = 'button[aria-label="Close Dialog"]';
|
||||
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<AppContainer />);
|
||||
});
|
||||
|
||||
// open about dropdown menu
|
||||
await waitForElement(wrapper, aboutDropdown);
|
||||
wrapper.find(aboutDropdown).simulate('click');
|
||||
|
||||
// open about modal
|
||||
(
|
||||
await waitForElement(wrapper, aboutButton, el => !el.props().disabled)
|
||||
).simulate('click');
|
||||
|
||||
// check about modal content
|
||||
const content = await waitForElement(wrapper, aboutModalContent);
|
||||
expect(content.find('dd').text()).toContain(ansible_version);
|
||||
expect(content.find('pre').text()).toContain(`< AWX ${version} >`);
|
||||
|
||||
// close about modal
|
||||
wrapper.find(aboutModalClose).simulate('click');
|
||||
expect(wrapper.find(aboutModalContent)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('logout makes expected call to api client', async () => {
|
||||
const userMenuButton = 'UserIcon';
|
||||
const logoutButton = '#logout-button button';
|
||||
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<AppContainer />);
|
||||
});
|
||||
|
||||
// open the user menu
|
||||
expect(wrapper.find(logoutButton)).toHaveLength(0);
|
||||
wrapper.find(userMenuButton).simulate('click');
|
||||
expect(wrapper.find(logoutButton)).toHaveLength(1);
|
||||
|
||||
// logout
|
||||
wrapper.find(logoutButton).simulate('click');
|
||||
expect(RootAPI.logout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
3
awx/ui_next/src/components/AppContainer/index.jsx
Normal file
3
awx/ui_next/src/components/AppContainer/index.jsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import AppContainer from './AppContainer';
|
||||
|
||||
export default AppContainer;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './BrandLogo';
|
||||
@@ -1,29 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useField } from 'formik';
|
||||
import {
|
||||
Button,
|
||||
ButtonVariant,
|
||||
FormGroup,
|
||||
InputGroup,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
|
||||
import { FormGroup, InputGroup } from '@patternfly/react-core';
|
||||
import PasswordInput from './PasswordInput';
|
||||
|
||||
function PasswordField(props) {
|
||||
const { id, name, label, validate, isRequired, isDisabled, i18n } = props;
|
||||
const [inputType, setInputType] = useState('password');
|
||||
const [field, meta] = useField({ name, validate });
|
||||
|
||||
const { id, name, label, validate, isRequired } = props;
|
||||
const [, meta] = useField({ name, validate });
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
|
||||
const handlePasswordToggle = () => {
|
||||
setInputType(inputType === 'text' ? 'password' : 'text');
|
||||
};
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId={id}
|
||||
@@ -33,32 +18,7 @@ function PasswordField(props) {
|
||||
label={label}
|
||||
>
|
||||
<InputGroup>
|
||||
<Tooltip
|
||||
content={inputType === 'password' ? i18n._(t`Show`) : i18n._(t`Hide`)}
|
||||
>
|
||||
<Button
|
||||
variant={ButtonVariant.control}
|
||||
aria-label={i18n._(t`Toggle Password`)}
|
||||
onClick={handlePasswordToggle}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{inputType === 'password' && <EyeSlashIcon />}
|
||||
{inputType === 'text' && <EyeIcon />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TextInput
|
||||
id={id}
|
||||
placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined}
|
||||
{...field}
|
||||
value={field.value === '$encrypted$' ? '' : field.value}
|
||||
isDisabled={isDisabled}
|
||||
isRequired={isRequired}
|
||||
isValid={isValid}
|
||||
type={inputType}
|
||||
onChange={(_, event) => {
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
<PasswordInput {...props} />
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
);
|
||||
@@ -79,4 +39,4 @@ PasswordField.defaultProps = {
|
||||
isDisabled: false,
|
||||
};
|
||||
|
||||
export default withI18n()(PasswordField);
|
||||
export default PasswordField;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Formik } from 'formik';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import { sleep } from '../../../testUtils/testUtils';
|
||||
import PasswordField from './PasswordField';
|
||||
|
||||
describe('PasswordField', () => {
|
||||
@@ -19,26 +18,4 @@ describe('PasswordField', () => {
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('properly responds to show/hide toggles', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<Formik
|
||||
initialValues={{
|
||||
password: '',
|
||||
}}
|
||||
>
|
||||
{() => (
|
||||
<PasswordField id="test-password" name="password" label="Password" />
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
expect(wrapper.find('input').prop('type')).toBe('password');
|
||||
expect(wrapper.find('EyeSlashIcon').length).toBe(1);
|
||||
expect(wrapper.find('EyeIcon').length).toBe(0);
|
||||
wrapper.find('button').simulate('click');
|
||||
await sleep(1);
|
||||
expect(wrapper.find('input').prop('type')).toBe('text');
|
||||
expect(wrapper.find('EyeSlashIcon').length).toBe(0);
|
||||
expect(wrapper.find('EyeIcon').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
71
awx/ui_next/src/components/FormField/PasswordInput.jsx
Normal file
71
awx/ui_next/src/components/FormField/PasswordInput.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useField } from 'formik';
|
||||
import {
|
||||
Button,
|
||||
ButtonVariant,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
|
||||
|
||||
function PasswordInput(props) {
|
||||
const { id, name, validate, isRequired, isDisabled, i18n } = props;
|
||||
const [inputType, setInputType] = useState('password');
|
||||
const [field, meta] = useField({ name, validate });
|
||||
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
|
||||
const handlePasswordToggle = () => {
|
||||
setInputType(inputType === 'text' ? 'password' : 'text');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
content={inputType === 'password' ? i18n._(t`Show`) : i18n._(t`Hide`)}
|
||||
>
|
||||
<Button
|
||||
variant={ButtonVariant.control}
|
||||
aria-label={i18n._(t`Toggle Password`)}
|
||||
onClick={handlePasswordToggle}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{inputType === 'password' && <EyeSlashIcon />}
|
||||
{inputType === 'text' && <EyeIcon />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TextInput
|
||||
id={id}
|
||||
placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined}
|
||||
{...field}
|
||||
value={field.value === '$encrypted$' ? '' : field.value}
|
||||
isDisabled={isDisabled}
|
||||
isRequired={isRequired}
|
||||
isValid={isValid}
|
||||
type={inputType}
|
||||
onChange={(_, event) => {
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
PasswordInput.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
validate: PropTypes.func,
|
||||
isRequired: PropTypes.bool,
|
||||
isDisabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
PasswordInput.defaultProps = {
|
||||
validate: () => {},
|
||||
isRequired: false,
|
||||
isDisabled: false,
|
||||
};
|
||||
|
||||
export default withI18n()(PasswordInput);
|
||||
42
awx/ui_next/src/components/FormField/PasswordInput.test.jsx
Normal file
42
awx/ui_next/src/components/FormField/PasswordInput.test.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Formik } from 'formik';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import PasswordInput from './PasswordInput';
|
||||
|
||||
describe('PasswordInput', () => {
|
||||
test('renders the expected content', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<Formik
|
||||
initialValues={{
|
||||
password: '',
|
||||
}}
|
||||
>
|
||||
{() => (
|
||||
<PasswordInput id="test-password" name="password" label="Password" />
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('properly responds to show/hide toggles', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<Formik
|
||||
initialValues={{
|
||||
password: '',
|
||||
}}
|
||||
>
|
||||
{() => (
|
||||
<PasswordInput id="test-password" name="password" label="Password" />
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
expect(wrapper.find('input').prop('type')).toBe('password');
|
||||
expect(wrapper.find('EyeSlashIcon').length).toBe(1);
|
||||
expect(wrapper.find('EyeIcon').length).toBe(0);
|
||||
wrapper.find('button').simulate('click');
|
||||
expect(wrapper.find('input').prop('type')).toBe('text');
|
||||
expect(wrapper.find('EyeSlashIcon').length).toBe(0);
|
||||
expect(wrapper.find('EyeIcon').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -2,4 +2,5 @@ export { default } from './FormField';
|
||||
export { default as CheckboxField } from './CheckboxField';
|
||||
export { default as FieldTooltip } from './FieldTooltip';
|
||||
export { default as PasswordField } from './PasswordField';
|
||||
export { default as PasswordInput } from './PasswordInput';
|
||||
export { default as FormSubmitError } from './FormSubmitError';
|
||||
|
||||
@@ -44,7 +44,10 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
|
||||
setValue('limit', values.limit);
|
||||
setValue('job_tags', values.job_tags);
|
||||
setValue('skip_tags', values.skip_tags);
|
||||
setValue('extra_vars', mergeExtraVars(values.extra_vars, surveyValues));
|
||||
const extraVars = config.ask_variables_on_launch
|
||||
? values.extra_vars || '---'
|
||||
: resource.extra_vars;
|
||||
setValue('extra_vars', mergeExtraVars(extraVars, surveyValues));
|
||||
setValue('scm_branch', values.scm_branch);
|
||||
onLaunch(postValues);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
export default function mergeExtraVars(extraVars, survey = {}) {
|
||||
export default function mergeExtraVars(extraVars = '', survey = {}) {
|
||||
const vars = yaml.safeLoad(extraVars) || {};
|
||||
return {
|
||||
...vars,
|
||||
|
||||
@@ -32,6 +32,10 @@ describe('mergeExtraVars', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle undefined', () => {
|
||||
expect(mergeExtraVars(undefined, undefined)).toEqual({});
|
||||
});
|
||||
|
||||
describe('maskPasswords', () => {
|
||||
test('should mask password fields', () => {
|
||||
const vars = {
|
||||
|
||||
@@ -1,44 +1,72 @@
|
||||
import React from 'react';
|
||||
import React, { Fragment } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { ExclamationCircleIcon as PFExclamationCircleIcon } from '@patternfly/react-icons';
|
||||
import { Tooltip } from '@patternfly/react-core';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useFormikContext } from 'formik';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import yaml from 'js-yaml';
|
||||
import PromptDetail from '../../PromptDetail';
|
||||
import mergeExtraVars, { maskPasswords } from '../mergeExtraVars';
|
||||
import getSurveyValues from '../getSurveyValues';
|
||||
import PromptDetail from '../../PromptDetail';
|
||||
|
||||
function PreviewStep({ resource, config, survey, formErrors }) {
|
||||
const ExclamationCircleIcon = styled(PFExclamationCircleIcon)`
|
||||
margin-left: 10px;
|
||||
margin-top: -2px;
|
||||
`;
|
||||
|
||||
const ErrorMessageWrapper = styled.div`
|
||||
align-items: center;
|
||||
color: var(--pf-global--danger-color--200);
|
||||
display: flex;
|
||||
font-weight: var(--pf-global--FontWeight--bold);
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
function PreviewStep({ resource, config, survey, formErrors, i18n }) {
|
||||
const { values } = useFormikContext();
|
||||
const surveyValues = getSurveyValues(values);
|
||||
let extraVars;
|
||||
|
||||
const overrides = { ...values };
|
||||
|
||||
if (config.ask_variables_on_launch || config.survey_enabled) {
|
||||
const initialExtraVars = config.ask_variables_on_launch
|
||||
? values.extra_vars || '---'
|
||||
: resource.extra_vars;
|
||||
if (survey && survey.spec) {
|
||||
const passwordFields = survey.spec
|
||||
.filter(q => q.type === 'password')
|
||||
.map(q => q.variable);
|
||||
const masked = maskPasswords(surveyValues, passwordFields);
|
||||
extraVars = yaml.safeDump(
|
||||
mergeExtraVars(values.extra_vars || '---', masked)
|
||||
overrides.extra_vars = yaml.safeDump(
|
||||
mergeExtraVars(initialExtraVars, masked)
|
||||
);
|
||||
} else {
|
||||
extraVars = values.extra_vars || '---';
|
||||
overrides.extra_vars = initialExtraVars;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Fragment>
|
||||
{formErrors.length > 0 && (
|
||||
<ErrorMessageWrapper>
|
||||
{i18n._(t`Some of the previous step(s) have errors`)}
|
||||
<Tooltip
|
||||
position="right"
|
||||
content={i18n._(t`See errors on the left`)}
|
||||
trigger="click mouseenter focus"
|
||||
>
|
||||
<ExclamationCircleIcon />
|
||||
</Tooltip>
|
||||
</ErrorMessageWrapper>
|
||||
)}
|
||||
<PromptDetail
|
||||
resource={resource}
|
||||
launchConfig={config}
|
||||
overrides={{
|
||||
...values,
|
||||
extra_vars: extraVars,
|
||||
}}
|
||||
overrides={overrides}
|
||||
/>
|
||||
{formErrors && (
|
||||
<ul css="color: red">
|
||||
{Object.keys(formErrors).map(
|
||||
field => `${field}: ${formErrors[field]}`
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewStep;
|
||||
export default withI18n()(PreviewStep);
|
||||
|
||||
@@ -24,6 +24,10 @@ const survey = {
|
||||
],
|
||||
};
|
||||
|
||||
const formErrors = {
|
||||
inventory: 'An inventory must be selected',
|
||||
};
|
||||
|
||||
describe('PreviewStep', () => {
|
||||
test('should render PromptDetail', async () => {
|
||||
let wrapper;
|
||||
@@ -37,6 +41,7 @@ describe('PreviewStep', () => {
|
||||
survey_enabled: true,
|
||||
}}
|
||||
survey={survey}
|
||||
formErrors={formErrors}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
@@ -62,6 +67,7 @@ describe('PreviewStep', () => {
|
||||
config={{
|
||||
ask_limit_on_launch: true,
|
||||
}}
|
||||
formErrors={formErrors}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
@@ -71,8 +77,31 @@ describe('PreviewStep', () => {
|
||||
expect(detail).toHaveLength(1);
|
||||
expect(detail.prop('resource')).toEqual(resource);
|
||||
expect(detail.prop('overrides')).toEqual({
|
||||
extra_vars: '---',
|
||||
limit: '4',
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle extra vars without survey', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Formik initialValues={{ extra_vars: 'one: 1' }}>
|
||||
<PreviewStep
|
||||
resource={resource}
|
||||
config={{
|
||||
ask_variables_on_launch: true,
|
||||
}}
|
||||
formErrors={formErrors}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
});
|
||||
|
||||
const detail = wrapper.find('PromptDetail');
|
||||
expect(detail).toHaveLength(1);
|
||||
expect(detail.prop('resource')).toEqual(resource);
|
||||
expect(detail.prop('overrides')).toEqual({
|
||||
extra_vars: 'one: 1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,8 @@ export default function useCredentialsStep(
|
||||
initialValues: getInitialValues(config, resource),
|
||||
validate,
|
||||
isReady: true,
|
||||
error: null,
|
||||
contentError: null,
|
||||
formError: null,
|
||||
setTouched: setFieldsTouched => {
|
||||
setFieldsTouched({
|
||||
credentials: true,
|
||||
|
||||
@@ -27,7 +27,8 @@ export default function useInventoryStep(config, resource, visitedSteps, i18n) {
|
||||
initialValues: getInitialValues(config, resource),
|
||||
validate,
|
||||
isReady: true,
|
||||
error: null,
|
||||
contentError: null,
|
||||
formError: stepErrors,
|
||||
setTouched: setFieldsTouched => {
|
||||
setFieldsTouched({
|
||||
inventory: true,
|
||||
|
||||
@@ -24,7 +24,8 @@ export default function useOtherPrompt(config, resource, visitedSteps, i18n) {
|
||||
initialValues: getInitialValues(config, resource),
|
||||
validate,
|
||||
isReady: true,
|
||||
error: null,
|
||||
contentError: null,
|
||||
formError: stepErrors,
|
||||
setTouched: setFieldsTouched => {
|
||||
setFieldsTouched({
|
||||
job_type: true,
|
||||
|
||||
@@ -54,7 +54,8 @@ export default function useSurveyStep(config, resource, visitedSteps, i18n) {
|
||||
validate,
|
||||
survey,
|
||||
isReady: !isLoading && !!survey,
|
||||
error,
|
||||
contentError: error,
|
||||
formError: stepErrors,
|
||||
setTouched: setFieldsTouched => {
|
||||
if (!survey || !survey.spec) {
|
||||
return;
|
||||
|
||||
@@ -13,14 +13,13 @@ export default function useSteps(config, resource, i18n) {
|
||||
useOtherPromptsStep(config, resource, visited, i18n),
|
||||
useSurveyStep(config, resource, visited, i18n),
|
||||
];
|
||||
|
||||
const formErrorsContent = steps
|
||||
.filter(s => s?.formError && Object.keys(s.formError).length > 0)
|
||||
.map(({ formError }) => formError);
|
||||
|
||||
steps.push(
|
||||
usePreviewStep(
|
||||
config,
|
||||
resource,
|
||||
steps[3].survey,
|
||||
{}, // TODO: formErrors ?
|
||||
i18n
|
||||
)
|
||||
usePreviewStep(config, resource, steps[3].survey, formErrorsContent, i18n)
|
||||
);
|
||||
|
||||
const pfSteps = steps.map(s => s.step).filter(s => s != null);
|
||||
@@ -31,8 +30,9 @@ export default function useSteps(config, resource, i18n) {
|
||||
};
|
||||
}, {});
|
||||
const isReady = !steps.some(s => !s.isReady);
|
||||
const stepWithError = steps.find(s => s.error);
|
||||
const contentError = stepWithError ? stepWithError.error : null;
|
||||
|
||||
const stepWithError = steps.find(s => s.contentError);
|
||||
const contentError = stepWithError ? stepWithError.contentError : null;
|
||||
|
||||
const validate = values => {
|
||||
const errors = steps.reduce((acc, cur) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { arrayOf, string, func, object, bool } from 'prop-types';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
@@ -8,6 +8,7 @@ import { InstanceGroupsAPI } from '../../api';
|
||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||
import { FieldTooltip } from '../FormField';
|
||||
import OptionsList from '../OptionsList';
|
||||
import useRequest from '../../util/useRequest';
|
||||
import Lookup from './Lookup';
|
||||
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||
|
||||
@@ -27,22 +28,27 @@ function InstanceGroupsLookup(props) {
|
||||
history,
|
||||
i18n,
|
||||
} = props;
|
||||
const [instanceGroups, setInstanceGroups] = useState([]);
|
||||
const [count, setCount] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const {
|
||||
result: { instanceGroups, count },
|
||||
request: fetchInstanceGroups,
|
||||
error,
|
||||
isLoading,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
const { data } = await InstanceGroupsAPI.read(params);
|
||||
return {
|
||||
instanceGroups: data.results,
|
||||
count: data.count,
|
||||
};
|
||||
}, [history.location]),
|
||||
{ instanceGroups: [], count: 0 }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
try {
|
||||
const { data } = await InstanceGroupsAPI.read(params);
|
||||
setInstanceGroups(data.results);
|
||||
setCount(data.count);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
})();
|
||||
}, [history.location]);
|
||||
fetchInstanceGroups();
|
||||
}, [fetchInstanceGroups]);
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
@@ -59,6 +65,7 @@ function InstanceGroupsLookup(props) {
|
||||
qsConfig={QS_CONFIG}
|
||||
multiple
|
||||
required={required}
|
||||
isLoading={isLoading}
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||
<OptionsList
|
||||
value={state.selectedItems}
|
||||
|
||||
@@ -19,22 +19,20 @@ const QS_CONFIG = getQSConfig('inventory', {
|
||||
|
||||
function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
|
||||
const {
|
||||
result: { count, inventories },
|
||||
error,
|
||||
result: { inventories, count },
|
||||
request: fetchInventories,
|
||||
error,
|
||||
isLoading,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
const { data } = await InventoriesAPI.read(params);
|
||||
return {
|
||||
count: data.count,
|
||||
inventories: data.results,
|
||||
count: data.count,
|
||||
};
|
||||
}, [history.location.search]),
|
||||
{
|
||||
count: 0,
|
||||
inventories: [],
|
||||
}
|
||||
}, [history.location]),
|
||||
{ inventories: [], count: 0 }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,6 +48,7 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
required={required}
|
||||
isLoading={isLoading}
|
||||
qsConfig={QS_CONFIG}
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||
<OptionsList
|
||||
|
||||
@@ -56,6 +56,7 @@ function Lookup(props) {
|
||||
header,
|
||||
onChange,
|
||||
onBlur,
|
||||
isLoading,
|
||||
value,
|
||||
multiple,
|
||||
required,
|
||||
@@ -124,6 +125,7 @@ function Lookup(props) {
|
||||
id={id}
|
||||
onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}
|
||||
variant={ButtonVariant.tertiary}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
<SearchIcon />
|
||||
</SearchButton>
|
||||
|
||||
@@ -159,4 +159,30 @@ describe('<Lookup />', () => {
|
||||
const list = wrapper.find('TestList');
|
||||
expect(list.prop('canDelete')).toEqual(false);
|
||||
});
|
||||
|
||||
test('should be disabled while isLoading is true', async () => {
|
||||
const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }];
|
||||
wrapper = mountWithContexts(
|
||||
<Lookup
|
||||
id="test"
|
||||
multiple
|
||||
header="Foo Bar"
|
||||
value={mockSelected}
|
||||
onChange={onChange}
|
||||
qsConfig={QS_CONFIG}
|
||||
isLoading
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||
<TestList
|
||||
id="options-list"
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
checkRootElementNotPresent('body div[role="dialog"]');
|
||||
const button = wrapper.find('button[aria-label="Search"]');
|
||||
expect(button.prop('disabled')).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'styled-components/macro';
|
||||
import React, { Fragment, useState, useEffect } from 'react';
|
||||
import React, { Fragment, useState, useCallback, useEffect } from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
@@ -9,6 +9,7 @@ import { CredentialsAPI, CredentialTypesAPI } from '../../api';
|
||||
import AnsibleSelect from '../AnsibleSelect';
|
||||
import CredentialChip from '../CredentialChip';
|
||||
import OptionsList from '../OptionsList';
|
||||
import useRequest from '../../util/useRequest';
|
||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||
import Lookup from './Lookup';
|
||||
|
||||
@@ -26,42 +27,62 @@ async function loadCredentials(params, selectedCredentialTypeId) {
|
||||
|
||||
function MultiCredentialsLookup(props) {
|
||||
const { value, onChange, onError, history, i18n } = props;
|
||||
const [credentialTypes, setCredentialTypes] = useState([]);
|
||||
const [selectedType, setSelectedType] = useState(null);
|
||||
const [credentials, setCredentials] = useState([]);
|
||||
const [credentialsCount, setCredentialsCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const {
|
||||
result: credentialTypes,
|
||||
request: fetchTypes,
|
||||
error: typesError,
|
||||
isLoading: isTypesLoading,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const types = await CredentialTypesAPI.loadAllTypes();
|
||||
setCredentialTypes(types);
|
||||
const match = types.find(type => type.kind === 'ssh') || types[0];
|
||||
setSelectedType(match);
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
}
|
||||
})();
|
||||
}, [onError]);
|
||||
return types;
|
||||
}, []),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
fetchTypes();
|
||||
}, [fetchTypes]);
|
||||
|
||||
const {
|
||||
result: { credentials, credentialsCount },
|
||||
request: fetchCredentials,
|
||||
error: credentialsError,
|
||||
isLoading: isCredentialsLoading,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
if (!selectedType) {
|
||||
return;
|
||||
return {
|
||||
credentials: [],
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
const { results, count } = await loadCredentials(
|
||||
params,
|
||||
selectedType.id
|
||||
);
|
||||
setCredentials(results);
|
||||
setCredentialsCount(count);
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
const { results, count } = await loadCredentials(params, selectedType.id);
|
||||
return {
|
||||
credentials: results,
|
||||
credentialsCount: count,
|
||||
};
|
||||
}, [selectedType, history.location]),
|
||||
{
|
||||
credentials: [],
|
||||
credentialsCount: 0,
|
||||
}
|
||||
})();
|
||||
}, [selectedType, history.location.search, onError]);
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredentials();
|
||||
}, [fetchCredentials]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typesError || credentialsError) {
|
||||
onError(typesError || credentialsError);
|
||||
}
|
||||
}, [typesError, credentialsError, onError]);
|
||||
|
||||
const renderChip = ({ item, removeItem, canDelete }) => (
|
||||
<CredentialChip
|
||||
@@ -82,6 +103,7 @@ function MultiCredentialsLookup(props) {
|
||||
multiple
|
||||
onChange={onChange}
|
||||
qsConfig={QS_CONFIG}
|
||||
isLoading={isTypesLoading || isCredentialsLoading}
|
||||
renderItemChip={renderChip}
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => {
|
||||
return (
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('<MultiCredentialsLookup />', () => {
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
||||
});
|
||||
expect(onChange).toBeCalledWith([
|
||||
@@ -201,7 +201,7 @@ describe('<MultiCredentialsLookup />', () => {
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
||||
});
|
||||
expect(onChange).toBeCalledWith([
|
||||
@@ -248,7 +248,7 @@ describe('<MultiCredentialsLookup />', () => {
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
||||
});
|
||||
expect(onChange).toBeCalledWith([
|
||||
@@ -301,7 +301,7 @@ describe('<MultiCredentialsLookup />', () => {
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
||||
});
|
||||
expect(onChange).toBeCalledWith([
|
||||
|
||||
@@ -32,9 +32,10 @@ function ProjectLookup({
|
||||
history,
|
||||
}) {
|
||||
const {
|
||||
result: { count, projects },
|
||||
error,
|
||||
result: { projects, count },
|
||||
request: fetchProjects,
|
||||
error,
|
||||
isLoading,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
@@ -74,6 +75,7 @@ function ProjectLookup({
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
required={required}
|
||||
isLoading={isLoading}
|
||||
qsConfig={QS_CONFIG}
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||
<OptionsList
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './NavExpandableGroup';
|
||||
@@ -272,4 +272,39 @@ describe('<NotificationList />', () => {
|
||||
wrapper.find('NotificationList').state('startedTemplateIds')
|
||||
).toEqual([]);
|
||||
});
|
||||
test('should throw toggle error', async () => {
|
||||
MockModelAPI.associateNotificationTemplate.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'post',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
status: 403,
|
||||
},
|
||||
})
|
||||
);
|
||||
const wrapper = mountWithContexts(
|
||||
<NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} />
|
||||
);
|
||||
await sleep(0);
|
||||
wrapper.update();
|
||||
|
||||
expect(
|
||||
wrapper.find('NotificationList').state('startedTemplateIds')
|
||||
).toEqual([3]);
|
||||
const items = wrapper.find('NotificationListItem');
|
||||
items
|
||||
.at(0)
|
||||
.find('Switch[aria-label="Toggle notification start"]')
|
||||
.prop('onChange')();
|
||||
expect(MockModelAPI.associateNotificationTemplate).toHaveBeenCalledWith(
|
||||
1,
|
||||
1,
|
||||
'started'
|
||||
);
|
||||
await sleep(0);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('ErrorDetail').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,9 @@ const DataListAction = styled(_DataListAction)`
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: repeat(3, max-content);
|
||||
`;
|
||||
const Label = styled.b`
|
||||
margin-right: 20px;
|
||||
`;
|
||||
|
||||
function NotificationListItem(props) {
|
||||
const {
|
||||
@@ -54,6 +57,7 @@ function NotificationListItem(props) {
|
||||
</Link>
|
||||
</DataListCell>,
|
||||
<DataListCell key="type">
|
||||
<Label>{i18n._(t`Type `)}</Label>
|
||||
{typeLabels[notification.notification_type]}
|
||||
</DataListCell>,
|
||||
]}
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
||||
.find('DataListCell')
|
||||
.at(1)
|
||||
.find('div');
|
||||
expect(typeCell.text()).toBe('Slack');
|
||||
expect(typeCell.text()).toContain('Slack');
|
||||
});
|
||||
|
||||
test('handles start click when toggle is on', () => {
|
||||
|
||||
@@ -58,6 +58,9 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
</ForwardRef>
|
||||
</ForwardRef(Styled(PFDataListCell))>,
|
||||
<ForwardRef(Styled(PFDataListCell))>
|
||||
<ForwardRef(styled.b)>
|
||||
Type
|
||||
</ForwardRef(styled.b)>
|
||||
Slack
|
||||
</ForwardRef(Styled(PFDataListCell))>,
|
||||
]
|
||||
@@ -167,6 +170,41 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
<div
|
||||
className="pf-c-data-list__cell sc-bdVaJa kruorc"
|
||||
>
|
||||
<styled.b>
|
||||
<StyledComponent
|
||||
forwardedComponent={
|
||||
Object {
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "sc-htpNat",
|
||||
"isStatic": false,
|
||||
"lastClassName": "jyYvCB",
|
||||
"rules": Array [
|
||||
"
|
||||
margin-right: 20px;
|
||||
",
|
||||
],
|
||||
},
|
||||
"displayName": "styled.b",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "sc-htpNat",
|
||||
"target": "b",
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
"withComponent": [Function],
|
||||
}
|
||||
}
|
||||
forwardedRef={null}
|
||||
>
|
||||
<b
|
||||
className="sc-htpNat jyYvCB"
|
||||
>
|
||||
Type
|
||||
</b>
|
||||
</StyledComponent>
|
||||
</styled.b>
|
||||
Slack
|
||||
</div>
|
||||
</PFDataListCell>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './PageHeaderToolbar';
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import useRequest, { useDismissableError } from '../../util/useRequest';
|
||||
import SelectableCard from '../SelectableCard';
|
||||
import AlertModal from '../AlertModal';
|
||||
import ErrorDetail from '../ErrorDetail';
|
||||
import Wizard from '../Wizard/Wizard';
|
||||
import useSelected from '../../util/useSelected';
|
||||
import SelectResourceStep from '../AddRole/SelectResourceStep';
|
||||
import SelectRoleStep from '../AddRole/SelectRoleStep';
|
||||
import getResourceAccessConfig from './getResourceAccessConfig';
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-gap: 20px;
|
||||
grid-template-columns: 33% 33% 33%;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
`;
|
||||
|
||||
function UserAndTeamAccessAdd({
|
||||
i18n,
|
||||
isOpen,
|
||||
title,
|
||||
onSave,
|
||||
apiModel,
|
||||
onClose,
|
||||
}) {
|
||||
const [selectedResourceType, setSelectedResourceType] = useState(null);
|
||||
const [stepIdReached, setStepIdReached] = useState(1);
|
||||
const { id: userId } = useParams();
|
||||
const {
|
||||
selected: resourcesSelected,
|
||||
handleSelect: handleResourceSelect,
|
||||
} = useSelected([]);
|
||||
|
||||
const {
|
||||
selected: rolesSelected,
|
||||
handleSelect: handleRoleSelect,
|
||||
} = useSelected([]);
|
||||
|
||||
const { request: handleWizardSave, error: saveError } = useRequest(
|
||||
useCallback(async () => {
|
||||
const roleRequests = [];
|
||||
const resourceRolesTypes = resourcesSelected.flatMap(resource =>
|
||||
Object.values(resource.summary_fields.object_roles)
|
||||
);
|
||||
|
||||
rolesSelected.map(role =>
|
||||
resourceRolesTypes.forEach(rolename => {
|
||||
if (rolename.name === role.name) {
|
||||
roleRequests.push(apiModel.associateRole(userId, rolename.id));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(roleRequests);
|
||||
onSave();
|
||||
}, [onSave, rolesSelected, apiModel, userId, resourcesSelected]),
|
||||
{}
|
||||
);
|
||||
|
||||
const { error, dismissError } = useDismissableError(saveError);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: 1,
|
||||
name: i18n._(t`Add resource type`),
|
||||
component: (
|
||||
<Grid>
|
||||
{getResourceAccessConfig(i18n).map(resource => (
|
||||
<SelectableCard
|
||||
key={resource.selectedResource}
|
||||
isSelected={
|
||||
resource.selectedResource ===
|
||||
selectedResourceType?.selectedResource
|
||||
}
|
||||
label={resource.label}
|
||||
dataCy={`add-role-${resource.selectedResource}`}
|
||||
onClick={() => setSelectedResourceType(resource)}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
),
|
||||
enableNext: selectedResourceType !== null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: i18n._(t`Select items from list`),
|
||||
component: selectedResourceType && (
|
||||
<SelectResourceStep
|
||||
searchColumns={selectedResourceType.searchColumns}
|
||||
sortColumns={selectedResourceType.sortColumns}
|
||||
displayKey="name"
|
||||
onRowClick={handleResourceSelect}
|
||||
fetchItems={selectedResourceType.fetchItems}
|
||||
selectedLabel={i18n._(t`Selected`)}
|
||||
selectedResourceRows={resourcesSelected}
|
||||
sortedColumnKey="username"
|
||||
/>
|
||||
),
|
||||
enableNext: resourcesSelected.length > 0,
|
||||
canJumpTo: stepIdReached >= 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: i18n._(t`Select roles to apply`),
|
||||
component: resourcesSelected?.length > 0 && (
|
||||
<SelectRoleStep
|
||||
onRolesClick={handleRoleSelect}
|
||||
roles={resourcesSelected[0].summary_fields.object_roles}
|
||||
selectedListKey={
|
||||
selectedResourceType === 'users' ? 'username' : 'name'
|
||||
}
|
||||
selectedListLabel={i18n._(t`Selected`)}
|
||||
selectedResourceRows={resourcesSelected}
|
||||
selectedRoleRows={rolesSelected}
|
||||
/>
|
||||
),
|
||||
nextButtonText: i18n._(t`Save`),
|
||||
canJumpTo: stepIdReached >= 3,
|
||||
},
|
||||
];
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AlertModal
|
||||
aria-label={i18n._(t`Associate role error`)}
|
||||
isOpen={error}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={dismissError}
|
||||
>
|
||||
{i18n._(t`Failed to associate role`)}
|
||||
<ErrorDetail error={error} />
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wizard
|
||||
isOpen={isOpen}
|
||||
title={title}
|
||||
steps={steps}
|
||||
onClose={onClose}
|
||||
onNext={({ id }) =>
|
||||
setStepIdReached(stepIdReached < id ? id : stepIdReached)
|
||||
}
|
||||
onSave={handleWizardSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(UserAndTeamAccessAdd);
|
||||
@@ -0,0 +1,232 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { UsersAPI, JobTemplatesAPI } from '../../api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../testUtils/enzymeHelpers';
|
||||
import UserAndTeamAccessAdd from './UserAndTeamAccessAdd';
|
||||
|
||||
jest.mock('../../api/models/Teams');
|
||||
jest.mock('../../api/models/Users');
|
||||
jest.mock('../../api/models/JobTemplates');
|
||||
|
||||
describe('<UserAndTeamAccessAdd/>', () => {
|
||||
const resources = {
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Job Template Foo Bar',
|
||||
url: '/api/v2/job_template/1/',
|
||||
summary_fields: {
|
||||
object_roles: {
|
||||
admin_role: {
|
||||
description: 'Can manage all aspects of the job template',
|
||||
name: 'Admin',
|
||||
id: 164,
|
||||
},
|
||||
execute_role: {
|
||||
description: 'May run the job template',
|
||||
name: 'Execute',
|
||||
id: 165,
|
||||
},
|
||||
read_role: {
|
||||
description: 'May view settings for the job template',
|
||||
name: 'Read',
|
||||
id: 166,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
},
|
||||
};
|
||||
let wrapper;
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<UserAndTeamAccessAdd
|
||||
apiModel={UsersAPI}
|
||||
isOpen
|
||||
onSave={() => {}}
|
||||
onClose={() => {}}
|
||||
title="Add user permissions"
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('should mount properly', async () => {
|
||||
expect(wrapper.find('PFWizard').length).toBe(1);
|
||||
});
|
||||
test('should disable steps', async () => {
|
||||
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
|
||||
expect(
|
||||
wrapper
|
||||
.find('WizardNavItem[text="Select items from list"]')
|
||||
.prop('isDisabled')
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper
|
||||
.find('WizardNavItem[text="Select roles to apply"]')
|
||||
.prop('isDisabled')
|
||||
).toBe(true);
|
||||
await act(async () =>
|
||||
wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({
|
||||
fetchItems: JobTemplatesAPI.read,
|
||||
label: 'Job template',
|
||||
selectedResource: 'jobTemplate',
|
||||
searchColumns: [{ name: 'Name', key: 'name', isDefault: true }],
|
||||
sortColumns: [{ name: 'Name', key: 'name' }],
|
||||
})
|
||||
);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('WizardNavItem[text="Add resource type"]').prop('isDisabled')
|
||||
).toBe(false);
|
||||
expect(
|
||||
wrapper
|
||||
.find('WizardNavItem[text="Select items from list"]')
|
||||
.prop('isDisabled')
|
||||
).toBe(false);
|
||||
expect(
|
||||
wrapper
|
||||
.find('WizardNavItem[text="Select roles to apply"]')
|
||||
.prop('isDisabled')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('should call api to associate role', async () => {
|
||||
JobTemplatesAPI.read.mockResolvedValue(resources);
|
||||
UsersAPI.associateRole.mockResolvedValue({});
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({
|
||||
fetchItems: JobTemplatesAPI.read,
|
||||
label: 'Job template',
|
||||
selectedResource: 'jobTemplate',
|
||||
searchColumns: [{ name: 'Name', key: 'name', isDefault: true }],
|
||||
sortColumns: [{ name: 'Name', key: 'name' }],
|
||||
})
|
||||
);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||
);
|
||||
expect(JobTemplatesAPI.read).toHaveBeenCalledWith({
|
||||
order_by: 'name',
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
});
|
||||
|
||||
await waitForElement(wrapper, 'SelectResourceStep', el => el.length > 0);
|
||||
expect(JobTemplatesAPI.read).toHaveBeenCalled();
|
||||
await act(async () =>
|
||||
wrapper
|
||||
.find('CheckboxListItem')
|
||||
.first()
|
||||
.find('input[type="checkbox"]')
|
||||
.simulate('change', { target: { checked: true } })
|
||||
);
|
||||
|
||||
wrapper.update();
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||
);
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('RolesStep').length).toBe(1);
|
||||
|
||||
await act(async () =>
|
||||
wrapper
|
||||
.find('CheckboxCard')
|
||||
.first()
|
||||
.prop('onSelect')()
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||
);
|
||||
|
||||
await expect(UsersAPI.associateRole).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should throw error', async () => {
|
||||
JobTemplatesAPI.read.mockResolvedValue(resources);
|
||||
UsersAPI.associateRole.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'post',
|
||||
url: '/api/v2/users/a/roles',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
status: 403,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
id: 'a',
|
||||
}),
|
||||
}));
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({
|
||||
fetchItems: JobTemplatesAPI.read,
|
||||
label: 'Job template',
|
||||
selectedResource: 'jobTemplate',
|
||||
searchColumns: [{ name: 'Name', key: 'name', isDefault: true }],
|
||||
sortColumns: [{ name: 'Name', key: 'name' }],
|
||||
})
|
||||
);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||
);
|
||||
await waitForElement(wrapper, 'SelectResourceStep', el => el.length > 0);
|
||||
expect(JobTemplatesAPI.read).toHaveBeenCalled();
|
||||
await act(async () =>
|
||||
wrapper
|
||||
.find('CheckboxListItem')
|
||||
.first()
|
||||
.find('input[type="checkbox"]')
|
||||
.simulate('change', { target: { checked: true } })
|
||||
);
|
||||
|
||||
wrapper.update();
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||
);
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('RolesStep').length).toBe(1);
|
||||
|
||||
await act(async () =>
|
||||
wrapper
|
||||
.find('CheckboxCard')
|
||||
.first()
|
||||
.prop('onSelect')()
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||
);
|
||||
|
||||
await expect(UsersAPI.associateRole).toHaveBeenCalled();
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AlertModal').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
JobTemplatesAPI,
|
||||
WorkflowJobTemplatesAPI,
|
||||
CredentialsAPI,
|
||||
InventoriesAPI,
|
||||
ProjectsAPI,
|
||||
OrganizationsAPI,
|
||||
} from '../../api';
|
||||
|
||||
export default function getResourceAccessConfig(i18n) {
|
||||
return [
|
||||
{
|
||||
selectedResource: 'jobTemplate',
|
||||
label: i18n._(t`Job templates`),
|
||||
searchColumns: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Playbook name`),
|
||||
key: 'playbook',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username',
|
||||
},
|
||||
],
|
||||
sortColumns: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
],
|
||||
fetchItems: queryParams => JobTemplatesAPI.read(queryParams),
|
||||
},
|
||||
{
|
||||
selectedResource: 'workflowJobTemplate',
|
||||
label: i18n._(t`Workflow job templates`),
|
||||
searchColumns: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Playbook name`),
|
||||
key: 'playbook',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username',
|
||||
},
|
||||
],
|
||||
sortColumns: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
],
|
||||
fetchItems: queryParams => WorkflowJobTemplatesAPI.read(queryParams),
|
||||
},
|
||||
{
|
||||
selectedResource: 'credential',
|
||||
label: i18n._(t`Credentials`),
|
||||
searchColumns: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Type`),
|
||||
key: 'scm_type',
|
||||
options: [
|
||||
[``, i18n._(t`Manual`)],
|
||||
[`git`, i18n._(t`Git`)],
|
||||
[`hg`, i18n._(t`Mercurial`)],
|
||||
[`svn`, i18n._(t`Subversion`)],
|
||||
[`insights`, i18n._(t`Red Hat Insights`)],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Source Control URL`),
|
||||
key: 'scm_url',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username',
|
||||
},
|
||||
],
|
||||
sortColumns: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
],
|
||||
fetchItems: queryParams => CredentialsAPI.read(queryParams),
|
||||
},
|
||||
{
|
||||
selectedResource: 'inventory',
|
||||
label: i18n._(t`Inventories`),
|
||||
searchColumns: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username',
|
||||
},
|
||||
],
|
||||
sortColumns: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
],
|
||||
fetchItems: queryParams => InventoriesAPI.read(queryParams),
|
||||
},
|
||||
{
|
||||
selectedResource: 'project',
|
||||
label: i18n._(t`Projects`),
|
||||
searchColumns: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Type`),
|
||||
key: 'scm_type',
|
||||
options: [
|
||||
[``, i18n._(t`Manual`)],
|
||||
[`git`, i18n._(t`Git`)],
|
||||
[`hg`, i18n._(t`Mercurial`)],
|
||||
[`svn`, i18n._(t`Subversion`)],
|
||||
[`insights`, i18n._(t`Red Hat Insights`)],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Source Control URL`),
|
||||
key: 'scm_url',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username',
|
||||
},
|
||||
],
|
||||
sortColumns: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
],
|
||||
fetchItems: queryParams => ProjectsAPI.read(queryParams),
|
||||
},
|
||||
{
|
||||
selectedResource: 'organization',
|
||||
label: i18n._(t`Organizations`),
|
||||
searchColumns: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username',
|
||||
},
|
||||
],
|
||||
sortColumns: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
],
|
||||
fetchItems: queryParams => OrganizationsAPI.read(queryParams),
|
||||
},
|
||||
];
|
||||
}
|
||||
1
awx/ui_next/src/components/UserAndTeamAccessAdd/index.js
Normal file
1
awx/ui_next/src/components/UserAndTeamAccessAdd/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './UserAndTeamAccessAdd';
|
||||
@@ -1,276 +1,12 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Route, Switch, Redirect } from 'react-router-dom';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import '@patternfly/react-core/dist/styles/base.css';
|
||||
|
||||
import { isAuthenticated } from './util/auth';
|
||||
import Background from './components/Background';
|
||||
import Applications from './screens/Application';
|
||||
import Credentials from './screens/Credential';
|
||||
import CredentialTypes from './screens/CredentialType';
|
||||
import Dashboard from './screens/Dashboard';
|
||||
import Hosts from './screens/Host';
|
||||
import InstanceGroups from './screens/InstanceGroup';
|
||||
import Inventory from './screens/Inventory';
|
||||
import InventoryScripts from './screens/InventoryScript';
|
||||
import { Jobs } from './screens/Job';
|
||||
import Login from './screens/Login';
|
||||
import ManagementJobs from './screens/ManagementJob';
|
||||
import NotificationTemplates from './screens/NotificationTemplate';
|
||||
import Organizations from './screens/Organization';
|
||||
import Portal from './screens/Portal';
|
||||
import Projects from './screens/Project';
|
||||
import Schedules from './screens/Schedule';
|
||||
import AuthSettings from './screens/AuthSetting';
|
||||
import JobsSettings from './screens/JobsSetting';
|
||||
import SystemSettings from './screens/SystemSetting';
|
||||
import UISettings from './screens/UISetting';
|
||||
import License from './screens/License';
|
||||
import Teams from './screens/Team';
|
||||
import Templates from './screens/Template';
|
||||
import Users from './screens/User';
|
||||
import NotFound from './screens/NotFound';
|
||||
|
||||
import App from './App';
|
||||
import RootProvider from './RootProvider';
|
||||
import { BrandName } from './variables';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function main(render) {
|
||||
const el = document.getElementById('app');
|
||||
document.title = `Ansible ${BrandName}`;
|
||||
|
||||
const removeTrailingSlash = (
|
||||
<Route
|
||||
exact
|
||||
strict
|
||||
path="/*/"
|
||||
render={({
|
||||
history: {
|
||||
location: { pathname, search, hash },
|
||||
},
|
||||
}) => <Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />}
|
||||
/>
|
||||
ReactDOM.render(
|
||||
<App />,
|
||||
document.getElementById('app') || document.createElement('div')
|
||||
);
|
||||
|
||||
const defaultRedirect = () => {
|
||||
if (isAuthenticated(document.cookie)) {
|
||||
return <Redirect to="/home" />;
|
||||
}
|
||||
return (
|
||||
<Switch>
|
||||
{removeTrailingSlash}
|
||||
<Route
|
||||
path="/login"
|
||||
render={() => <Login isAuthenticated={isAuthenticated} />}
|
||||
/>
|
||||
<Redirect to="/login" />
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
return render(
|
||||
<RootProvider>
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<Background>
|
||||
<Switch>
|
||||
{removeTrailingSlash}
|
||||
<Route path="/login" render={defaultRedirect} />
|
||||
<Route exact path="/" render={defaultRedirect} />
|
||||
<Route
|
||||
render={() => {
|
||||
if (!isAuthenticated(document.cookie)) {
|
||||
return <Redirect to="/login" />;
|
||||
}
|
||||
return (
|
||||
<App
|
||||
navLabel={i18n._(t`Primary Navigation`)}
|
||||
routeGroups={[
|
||||
{
|
||||
groupTitle: i18n._(t`Views`),
|
||||
groupId: 'views_group',
|
||||
routes: [
|
||||
{
|
||||
title: i18n._(t`Dashboard`),
|
||||
path: '/home',
|
||||
component: Dashboard,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Jobs`),
|
||||
path: '/jobs',
|
||||
component: Jobs,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Schedules`),
|
||||
path: '/schedules',
|
||||
component: Schedules,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`My View`),
|
||||
path: '/portal',
|
||||
component: Portal,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupTitle: i18n._(t`Resources`),
|
||||
groupId: 'resources_group',
|
||||
routes: [
|
||||
{
|
||||
title: i18n._(t`Templates`),
|
||||
path: '/templates',
|
||||
component: Templates,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Credentials`),
|
||||
path: '/credentials',
|
||||
component: Credentials,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Projects`),
|
||||
path: '/projects',
|
||||
component: Projects,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Inventories`),
|
||||
path: '/inventories',
|
||||
component: Inventory,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Hosts`),
|
||||
path: '/hosts',
|
||||
component: Hosts,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Inventory Scripts`),
|
||||
path: '/inventory_scripts',
|
||||
component: InventoryScripts,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupTitle: i18n._(t`Access`),
|
||||
groupId: 'access_group',
|
||||
routes: [
|
||||
{
|
||||
title: i18n._(t`Organizations`),
|
||||
path: '/organizations',
|
||||
component: Organizations,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Users`),
|
||||
path: '/users',
|
||||
component: Users,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Teams`),
|
||||
path: '/teams',
|
||||
component: Teams,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupTitle: i18n._(t`Administration`),
|
||||
groupId: 'administration_group',
|
||||
routes: [
|
||||
{
|
||||
title: i18n._(t`Credential Types`),
|
||||
path: '/credential_types',
|
||||
component: CredentialTypes,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Notifications`),
|
||||
path: '/notification_templates',
|
||||
component: NotificationTemplates,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Management Jobs`),
|
||||
path: '/management_jobs',
|
||||
component: ManagementJobs,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Instance Groups`),
|
||||
path: '/instance_groups',
|
||||
component: InstanceGroups,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Integrations`),
|
||||
path: '/applications',
|
||||
component: Applications,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupTitle: i18n._(t`Settings`),
|
||||
groupId: 'settings_group',
|
||||
routes: [
|
||||
{
|
||||
title: i18n._(t`Authentication`),
|
||||
path: '/auth_settings',
|
||||
component: AuthSettings,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Jobs`),
|
||||
path: '/jobs_settings',
|
||||
component: JobsSettings,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`System`),
|
||||
path: '/system_settings',
|
||||
component: SystemSettings,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`User Interface`),
|
||||
path: '/ui_settings',
|
||||
component: UISettings,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`License`),
|
||||
path: '/license',
|
||||
component: License,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
render={({ routeGroups }) => {
|
||||
const routeList = routeGroups
|
||||
.reduce(
|
||||
(allRoutes, { routes }) => allRoutes.concat(routes),
|
||||
[]
|
||||
)
|
||||
.map(({ component: PageComponent, path }) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
render={({ match }) => (
|
||||
<PageComponent match={match} />
|
||||
)}
|
||||
/>
|
||||
));
|
||||
routeList.push(
|
||||
<Route
|
||||
key="not-found"
|
||||
path="*"
|
||||
component={NotFound}
|
||||
/>
|
||||
);
|
||||
return <Switch>{routeList}</Switch>;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</Background>
|
||||
)}
|
||||
</I18n>
|
||||
</RootProvider>,
|
||||
el || document.createElement('div')
|
||||
);
|
||||
}
|
||||
|
||||
main(ReactDOM.render);
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { main } from './index';
|
||||
import ReactDOM from 'react-dom';
|
||||
import App from './App';
|
||||
|
||||
const render = template => mount(<MemoryRouter>{template}</MemoryRouter>);
|
||||
jest.mock('react-dom', () => ({ render: jest.fn() }));
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.setAttribute('id', 'app');
|
||||
document.body.appendChild(div);
|
||||
|
||||
require('./index.jsx');
|
||||
|
||||
describe('index.jsx', () => {
|
||||
test('index.jsx loads without issue', () => {
|
||||
const wrapper = main(render);
|
||||
expect(wrapper.find('RootProvider')).toHaveLength(1);
|
||||
it('renders ok', () => {
|
||||
expect(ReactDOM.render).toHaveBeenCalledWith(<App />, div);
|
||||
});
|
||||
});
|
||||
|
||||
181
awx/ui_next/src/routeConfig.js
Normal file
181
awx/ui_next/src/routeConfig.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import Applications from './screens/Application';
|
||||
import Credentials from './screens/Credential';
|
||||
import CredentialTypes from './screens/CredentialType';
|
||||
import Dashboard from './screens/Dashboard';
|
||||
import Hosts from './screens/Host';
|
||||
import InstanceGroups from './screens/InstanceGroup';
|
||||
import Inventory from './screens/Inventory';
|
||||
import InventoryScripts from './screens/InventoryScript';
|
||||
import { Jobs } from './screens/Job';
|
||||
import ManagementJobs from './screens/ManagementJob';
|
||||
import NotificationTemplates from './screens/NotificationTemplate';
|
||||
import Organizations from './screens/Organization';
|
||||
import Portal from './screens/Portal';
|
||||
import Projects from './screens/Project';
|
||||
import Schedules from './screens/Schedule';
|
||||
import AuthSettings from './screens/AuthSetting';
|
||||
import JobsSettings from './screens/JobsSetting';
|
||||
import SystemSettings from './screens/SystemSetting';
|
||||
import UISettings from './screens/UISetting';
|
||||
import License from './screens/License';
|
||||
import Teams from './screens/Team';
|
||||
import Templates from './screens/Template';
|
||||
import Users from './screens/User';
|
||||
|
||||
// Ideally, this should just be a regular object that we export, but we
|
||||
// need the i18n. When lingui3 arrives, we will be able to import i18n
|
||||
// directly and we can replace this function with a simple export.
|
||||
|
||||
function getRouteConfig(i18n) {
|
||||
return [
|
||||
{
|
||||
groupTitle: i18n._(t`Views`),
|
||||
groupId: 'views_group',
|
||||
routes: [
|
||||
{
|
||||
title: i18n._(t`Dashboard`),
|
||||
path: '/home',
|
||||
screen: Dashboard,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Jobs`),
|
||||
path: '/jobs',
|
||||
screen: Jobs,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Schedules`),
|
||||
path: '/schedules',
|
||||
screen: Schedules,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`My View`),
|
||||
path: '/portal',
|
||||
screen: Portal,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupTitle: i18n._(t`Resources`),
|
||||
groupId: 'resources_group',
|
||||
routes: [
|
||||
{
|
||||
title: i18n._(t`Templates`),
|
||||
path: '/templates',
|
||||
screen: Templates,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Credentials`),
|
||||
path: '/credentials',
|
||||
screen: Credentials,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Projects`),
|
||||
path: '/projects',
|
||||
screen: Projects,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Inventories`),
|
||||
path: '/inventories',
|
||||
screen: Inventory,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Hosts`),
|
||||
path: '/hosts',
|
||||
screen: Hosts,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Inventory Scripts`),
|
||||
path: '/inventory_scripts',
|
||||
screen: InventoryScripts,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupTitle: i18n._(t`Access`),
|
||||
groupId: 'access_group',
|
||||
routes: [
|
||||
{
|
||||
title: i18n._(t`Organizations`),
|
||||
path: '/organizations',
|
||||
screen: Organizations,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Users`),
|
||||
path: '/users',
|
||||
screen: Users,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Teams`),
|
||||
path: '/teams',
|
||||
screen: Teams,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupTitle: i18n._(t`Administration`),
|
||||
groupId: 'administration_group',
|
||||
routes: [
|
||||
{
|
||||
title: i18n._(t`Credential Types`),
|
||||
path: '/credential_types',
|
||||
screen: CredentialTypes,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Notifications`),
|
||||
path: '/notification_templates',
|
||||
screen: NotificationTemplates,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Management Jobs`),
|
||||
path: '/management_jobs',
|
||||
screen: ManagementJobs,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Instance Groups`),
|
||||
path: '/instance_groups',
|
||||
screen: InstanceGroups,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Integrations`),
|
||||
path: '/applications',
|
||||
screen: Applications,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupTitle: i18n._(t`Settings`),
|
||||
groupId: 'settings_group',
|
||||
routes: [
|
||||
{
|
||||
title: i18n._(t`Authentication`),
|
||||
path: '/auth_settings',
|
||||
screen: AuthSettings,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Jobs`),
|
||||
path: '/jobs_settings',
|
||||
screen: JobsSettings,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`System`),
|
||||
path: '/system_settings',
|
||||
screen: SystemSettings,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`User Interface`),
|
||||
path: '/ui_settings',
|
||||
screen: UISettings,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`License`),
|
||||
path: '/license',
|
||||
screen: License,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default getRouteConfig;
|
||||
@@ -1,20 +1,74 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useCallback, useState, useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { PageSection, Card } from '@patternfly/react-core';
|
||||
import { CardBody } from '../../../components/Card';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import ContentLoading from '../../../components/ContentLoading';
|
||||
|
||||
import { CredentialTypesAPI, CredentialsAPI } from '../../../api';
|
||||
import {
|
||||
CredentialInputSourcesAPI,
|
||||
CredentialTypesAPI,
|
||||
CredentialsAPI,
|
||||
} from '../../../api';
|
||||
import CredentialForm from '../shared/CredentialForm';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
|
||||
function CredentialAdd({ me }) {
|
||||
const [error, setError] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [credentialTypes, setCredentialTypes] = useState(null);
|
||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||
const history = useHistory();
|
||||
|
||||
const {
|
||||
error: submitError,
|
||||
request: submitRequest,
|
||||
result: credentialId,
|
||||
} = useRequest(
|
||||
useCallback(
|
||||
async values => {
|
||||
const { inputs, organization, ...remainingValues } = values;
|
||||
const nonPluginInputs = {};
|
||||
const pluginInputs = {};
|
||||
Object.entries(inputs).forEach(([key, value]) => {
|
||||
if (value.credential && value.inputs) {
|
||||
pluginInputs[key] = value;
|
||||
} else {
|
||||
nonPluginInputs[key] = value;
|
||||
}
|
||||
});
|
||||
const {
|
||||
data: { id: newCredentialId },
|
||||
} = await CredentialsAPI.create({
|
||||
user: (me && me.id) || null,
|
||||
organization: (organization && organization.id) || null,
|
||||
inputs: nonPluginInputs,
|
||||
...remainingValues,
|
||||
});
|
||||
const inputSourceRequests = [];
|
||||
Object.entries(pluginInputs).forEach(([key, value]) => {
|
||||
inputSourceRequests.push(
|
||||
CredentialInputSourcesAPI.create({
|
||||
input_field_name: key,
|
||||
metadata: value.inputs,
|
||||
source_credential: value.credential.id,
|
||||
target_credential: newCredentialId,
|
||||
})
|
||||
);
|
||||
});
|
||||
await Promise.all(inputSourceRequests);
|
||||
|
||||
return newCredentialId;
|
||||
},
|
||||
[me]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (credentialId) {
|
||||
history.push(`/credentials/${credentialId}/details`);
|
||||
}
|
||||
}, [credentialId, history]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
@@ -38,21 +92,7 @@ function CredentialAdd({ me }) {
|
||||
};
|
||||
|
||||
const handleSubmit = async values => {
|
||||
const { organization, ...remainingValues } = values;
|
||||
setFormSubmitError(null);
|
||||
try {
|
||||
const {
|
||||
data: { id: credentialId },
|
||||
} = await CredentialsAPI.create({
|
||||
user: (me && me.id) || null,
|
||||
organization: (organization && organization.id) || null,
|
||||
...remainingValues,
|
||||
});
|
||||
const url = `/credentials/${credentialId}/details`;
|
||||
history.push(`${url}`);
|
||||
} catch (err) {
|
||||
setFormSubmitError(err);
|
||||
}
|
||||
await submitRequest(values);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
@@ -85,7 +125,7 @@ function CredentialAdd({ me }) {
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
credentialTypes={credentialTypes}
|
||||
submitError={formSubmitError}
|
||||
submitError={submitError}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -1,29 +1,117 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useCallback, useState, useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { object } from 'prop-types';
|
||||
|
||||
import { CardBody } from '../../../components/Card';
|
||||
import { CredentialsAPI, CredentialTypesAPI } from '../../../api';
|
||||
import {
|
||||
CredentialsAPI,
|
||||
CredentialInputSourcesAPI,
|
||||
CredentialTypesAPI,
|
||||
} from '../../../api';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import ContentLoading from '../../../components/ContentLoading';
|
||||
import CredentialForm from '../shared/CredentialForm';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
|
||||
function CredentialEdit({ credential, me }) {
|
||||
const [error, setError] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [credentialTypes, setCredentialTypes] = useState(null);
|
||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||
const [inputSources, setInputSources] = useState({});
|
||||
const history = useHistory();
|
||||
|
||||
const { error: submitError, request: submitRequest, result } = useRequest(
|
||||
useCallback(
|
||||
async (values, inputSourceMap) => {
|
||||
const createAndUpdateInputSources = pluginInputs =>
|
||||
Object.entries(pluginInputs).map(([fieldName, fieldValue]) => {
|
||||
if (!inputSourceMap[fieldName]) {
|
||||
return CredentialInputSourcesAPI.create({
|
||||
input_field_name: fieldName,
|
||||
metadata: fieldValue.inputs,
|
||||
source_credential: fieldValue.credential.id,
|
||||
target_credential: credential.id,
|
||||
});
|
||||
}
|
||||
if (fieldValue.touched) {
|
||||
return CredentialInputSourcesAPI.update(
|
||||
inputSourceMap[fieldName].id,
|
||||
{
|
||||
metadata: fieldValue.inputs,
|
||||
source_credential: fieldValue.credential.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const destroyInputSources = inputs => {
|
||||
const destroyRequests = [];
|
||||
Object.values(inputSourceMap).forEach(inputSource => {
|
||||
const { id, input_field_name } = inputSource;
|
||||
if (!inputs[input_field_name]?.credential) {
|
||||
destroyRequests.push(CredentialInputSourcesAPI.destroy(id));
|
||||
}
|
||||
});
|
||||
return destroyRequests;
|
||||
};
|
||||
|
||||
const { inputs, organization, ...remainingValues } = values;
|
||||
const nonPluginInputs = {};
|
||||
const pluginInputs = {};
|
||||
Object.entries(inputs).forEach(([key, value]) => {
|
||||
if (value.credential && value.inputs) {
|
||||
pluginInputs[key] = value;
|
||||
} else {
|
||||
nonPluginInputs[key] = value;
|
||||
}
|
||||
});
|
||||
const [{ data }] = await Promise.all([
|
||||
CredentialsAPI.update(credential.id, {
|
||||
user: (me && me.id) || null,
|
||||
organization: (organization && organization.id) || null,
|
||||
inputs: nonPluginInputs,
|
||||
...remainingValues,
|
||||
}),
|
||||
...destroyInputSources(inputs),
|
||||
]);
|
||||
await Promise.all(createAndUpdateInputSources(pluginInputs));
|
||||
return data;
|
||||
},
|
||||
[credential.id, me]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (result) {
|
||||
history.push(`/credentials/${result.id}/details`);
|
||||
}
|
||||
}, [result, history]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const {
|
||||
const [
|
||||
{
|
||||
data: { results: loadedCredentialTypes },
|
||||
} = await CredentialTypesAPI.read({
|
||||
},
|
||||
{
|
||||
data: { results: loadedInputSources },
|
||||
},
|
||||
] = await Promise.all([
|
||||
CredentialTypesAPI.read({
|
||||
or__namespace: ['gce', 'scm', 'ssh'],
|
||||
});
|
||||
}),
|
||||
CredentialsAPI.readInputSources(credential.id, { page_size: 200 }),
|
||||
]);
|
||||
setCredentialTypes(loadedCredentialTypes);
|
||||
setInputSources(
|
||||
loadedInputSources.reduce((inputSourcesMap, inputSource) => {
|
||||
inputSourcesMap[inputSource.input_field_name] = inputSource;
|
||||
return inputSourcesMap;
|
||||
}, {})
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
@@ -31,30 +119,15 @@ function CredentialEdit({ credential, me }) {
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
}, [credential.id]);
|
||||
|
||||
const handleCancel = () => {
|
||||
const url = `/credentials/${credential.id}/details`;
|
||||
|
||||
history.push(`${url}`);
|
||||
};
|
||||
|
||||
const handleSubmit = async values => {
|
||||
const { organization, ...remainingValues } = values;
|
||||
setFormSubmitError(null);
|
||||
try {
|
||||
const {
|
||||
data: { id: credentialId },
|
||||
} = await CredentialsAPI.update(credential.id, {
|
||||
user: (me && me.id) || null,
|
||||
organization: (organization && organization.id) || null,
|
||||
...remainingValues,
|
||||
});
|
||||
const url = `/credentials/${credentialId}/details`;
|
||||
history.push(`${url}`);
|
||||
} catch (err) {
|
||||
setFormSubmitError(err);
|
||||
}
|
||||
await submitRequest(values, inputSources);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
@@ -72,7 +145,8 @@ function CredentialEdit({ credential, me }) {
|
||||
onSubmit={handleSubmit}
|
||||
credential={credential}
|
||||
credentialTypes={credentialTypes}
|
||||
submitError={formSubmitError}
|
||||
inputSources={inputSources}
|
||||
submitError={submitError}
|
||||
/>
|
||||
</CardBody>
|
||||
);
|
||||
|
||||
@@ -245,6 +245,7 @@ CredentialTypesAPI.read.mockResolvedValue({
|
||||
});
|
||||
|
||||
CredentialsAPI.update.mockResolvedValue({ data: { id: 3 } });
|
||||
CredentialsAPI.readInputSources.mockResolvedValue({ data: { results: [] } });
|
||||
|
||||
describe('<CredentialEdit />', () => {
|
||||
let wrapper;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { Formik, useField } from 'formik';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { func, shape } from 'prop-types';
|
||||
import { arrayOf, func, object, shape } from 'prop-types';
|
||||
import { Form, FormGroup, Title } from '@patternfly/react-core';
|
||||
import FormField, { FormSubmitError } from '../../../components/FormField';
|
||||
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
|
||||
@@ -124,6 +124,7 @@ function CredentialFormFields({
|
||||
function CredentialForm({
|
||||
credential = {},
|
||||
credentialTypes,
|
||||
inputSources,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitError,
|
||||
@@ -147,6 +148,13 @@ function CredentialForm({
|
||||
},
|
||||
};
|
||||
|
||||
Object.values(inputSources).forEach(inputSource => {
|
||||
initialValues.inputs[inputSource.input_field_name] = {
|
||||
credential: inputSource.summary_fields.source_credential,
|
||||
inputs: inputSource.metadata,
|
||||
};
|
||||
});
|
||||
|
||||
const scmCredentialTypeId = Object.keys(credentialTypes)
|
||||
.filter(key => credentialTypes[key].namespace === 'scm')
|
||||
.map(key => credentialTypes[key].id)[0];
|
||||
@@ -232,10 +240,12 @@ CredentialForm.proptype = {
|
||||
handleSubmit: func.isRequired,
|
||||
handleCancel: func.isRequired,
|
||||
credential: shape({}),
|
||||
inputSources: arrayOf(object),
|
||||
};
|
||||
|
||||
CredentialForm.defaultProps = {
|
||||
credential: {},
|
||||
inputSources: [],
|
||||
};
|
||||
|
||||
export default withI18n()(CredentialForm);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useField } from 'formik';
|
||||
import {
|
||||
Button,
|
||||
ButtonVariant,
|
||||
FormGroup,
|
||||
InputGroup,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { KeyIcon } from '@patternfly/react-icons';
|
||||
import { CredentialPluginPrompt } from './CredentialPluginPrompt';
|
||||
import CredentialPluginSelected from './CredentialPluginSelected';
|
||||
|
||||
function CredentialPluginField(props) {
|
||||
const {
|
||||
children,
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
validate,
|
||||
isRequired,
|
||||
isDisabled,
|
||||
i18n,
|
||||
} = props;
|
||||
const [showPluginWizard, setShowPluginWizard] = useState(false);
|
||||
const [field, meta, helpers] = useField({ name, validate });
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId={id}
|
||||
helperTextInvalid={meta.error}
|
||||
isRequired={isRequired}
|
||||
isValid={isValid}
|
||||
label={label}
|
||||
>
|
||||
{field?.value?.credential ? (
|
||||
<CredentialPluginSelected
|
||||
credential={field?.value?.credential}
|
||||
onClearPlugin={() => helpers.setValue('')}
|
||||
onEditPlugin={() => setShowPluginWizard(true)}
|
||||
/>
|
||||
) : (
|
||||
<InputGroup>
|
||||
{React.cloneElement(children, {
|
||||
...field,
|
||||
isRequired,
|
||||
onChange: (_, event) => {
|
||||
field.onChange(event);
|
||||
},
|
||||
})}
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Populate field from an external secret management system`
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant={ButtonVariant.control}
|
||||
aria-label={i18n._(
|
||||
t`Populate field from an external secret management system`
|
||||
)}
|
||||
onClick={() => setShowPluginWizard(true)}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<KeyIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</InputGroup>
|
||||
)}
|
||||
{showPluginWizard && (
|
||||
<CredentialPluginPrompt
|
||||
initialValues={typeof field.value === 'object' ? field.value : {}}
|
||||
onClose={() => setShowPluginWizard(false)}
|
||||
onSubmit={val => {
|
||||
val.touched = true;
|
||||
helpers.setValue(val);
|
||||
setShowPluginWizard(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
CredentialPluginField.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
validate: PropTypes.func,
|
||||
isRequired: PropTypes.bool,
|
||||
isDisabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
CredentialPluginField.defaultProps = {
|
||||
validate: () => {},
|
||||
isRequired: false,
|
||||
isDisabled: false,
|
||||
};
|
||||
|
||||
export default withI18n()(CredentialPluginField);
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { Formik } from 'formik';
|
||||
import { TextInput } from '@patternfly/react-core';
|
||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
||||
import CredentialPluginField from './CredentialPluginField';
|
||||
|
||||
describe('<CredentialPluginField />', () => {
|
||||
let wrapper;
|
||||
describe('No plugin configured', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<Formik
|
||||
initialValues={{
|
||||
inputs: {
|
||||
username: '',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{() => (
|
||||
<CredentialPluginField
|
||||
id="credential-username"
|
||||
name="inputs.username"
|
||||
label="Username"
|
||||
>
|
||||
<TextInput id="credential-username" />
|
||||
</CredentialPluginField>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
});
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('renders the expected content', () => {
|
||||
expect(wrapper.find('input').length).toBe(1);
|
||||
expect(wrapper.find('KeyIcon').length).toBe(1);
|
||||
expect(wrapper.find('CredentialPluginSelected').length).toBe(0);
|
||||
});
|
||||
test('clicking plugin button shows plugin prompt', () => {
|
||||
expect(wrapper.find('CredentialPluginPrompt').length).toBe(0);
|
||||
wrapper.find('KeyIcon').simulate('click');
|
||||
expect(wrapper.find('CredentialPluginPrompt').length).toBe(1);
|
||||
});
|
||||
});
|
||||
describe('Plugin already configured', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<Formik
|
||||
initialValues={{
|
||||
inputs: {
|
||||
username: {
|
||||
credential: {
|
||||
id: 1,
|
||||
name: 'CyberArk Cred',
|
||||
cloud: false,
|
||||
credential_type_id: 20,
|
||||
kind: 'conjur',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{() => (
|
||||
<CredentialPluginField
|
||||
id="credential-username"
|
||||
name="inputs.username"
|
||||
label="Username"
|
||||
>
|
||||
<TextInput id="credential-username" />
|
||||
</CredentialPluginField>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
});
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('renders the expected content', () => {
|
||||
expect(wrapper.find('CredentialPluginPrompt').length).toBe(0);
|
||||
expect(wrapper.find('input').length).toBe(0);
|
||||
expect(wrapper.find('KeyIcon').length).toBe(1);
|
||||
expect(wrapper.find('CredentialPluginSelected').length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { func, shape } from 'prop-types';
|
||||
import { Formik, useField } from 'formik';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Wizard } from '@patternfly/react-core';
|
||||
import CredentialsStep from './CredentialsStep';
|
||||
import MetadataStep from './MetadataStep';
|
||||
|
||||
function CredentialPluginWizard({ i18n, handleSubmit, onClose }) {
|
||||
const [selectedCredential] = useField('credential');
|
||||
const steps = [
|
||||
{
|
||||
id: 1,
|
||||
name: i18n._(t`Credential`),
|
||||
component: <CredentialsStep />,
|
||||
enableNext: !!selectedCredential.value,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: i18n._(t`Metadata`),
|
||||
component: <MetadataStep />,
|
||||
canJumpTo: !!selectedCredential.value,
|
||||
nextButtonText: i18n._(t`OK`),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Wizard
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={i18n._(t`External Secret Management System`)}
|
||||
steps={steps}
|
||||
onSave={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CredentialPluginPrompt({ i18n, onClose, onSubmit, initialValues }) {
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
credential: initialValues?.credential || null,
|
||||
inputs: initialValues?.inputs || {},
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ handleSubmit }) => (
|
||||
<CredentialPluginWizard
|
||||
handleSubmit={handleSubmit}
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
CredentialPluginPrompt.propTypes = {
|
||||
onClose: func.isRequired,
|
||||
onSubmit: func.isRequired,
|
||||
initialValues: shape({}),
|
||||
};
|
||||
|
||||
CredentialPluginPrompt.defaultProps = {
|
||||
initialValues: {},
|
||||
};
|
||||
|
||||
export default withI18n()(CredentialPluginPrompt);
|
||||
@@ -0,0 +1,228 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../../../testUtils/enzymeHelpers';
|
||||
import { CredentialsAPI, CredentialTypesAPI } from '../../../../../api';
|
||||
import selectedCredential from '../../data.cyberArkCredential.json';
|
||||
import azureVaultCredential from '../../data.azureVaultCredential.json';
|
||||
import hashiCorpCredential from '../../data.hashiCorpCredential.json';
|
||||
import CredentialPluginPrompt from './CredentialPluginPrompt';
|
||||
|
||||
jest.mock('../../../../../api/models/Credentials');
|
||||
jest.mock('../../../../../api/models/CredentialTypes');
|
||||
|
||||
CredentialsAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: 3,
|
||||
results: [selectedCredential, azureVaultCredential, hashiCorpCredential],
|
||||
},
|
||||
});
|
||||
|
||||
CredentialTypesAPI.readDetail.mockResolvedValue({
|
||||
data: {
|
||||
id: 20,
|
||||
type: 'credential_type',
|
||||
url: '/api/v2/credential_types/20/',
|
||||
related: {
|
||||
named_url:
|
||||
'/api/v2/credential_types/CyberArk Conjur Secret Lookup+external/',
|
||||
credentials: '/api/v2/credential_types/20/credentials/',
|
||||
activity_stream: '/api/v2/credential_types/20/activity_stream/',
|
||||
},
|
||||
summary_fields: { user_capabilities: { edit: false, delete: false } },
|
||||
created: '2020-05-18T21:53:35.398260Z',
|
||||
modified: '2020-05-18T21:54:05.451444Z',
|
||||
name: 'CyberArk Conjur Secret Lookup',
|
||||
description: '',
|
||||
kind: 'external',
|
||||
namespace: 'conjur',
|
||||
managed_by_tower: true,
|
||||
inputs: {
|
||||
fields: [
|
||||
{ id: 'url', label: 'Conjur URL', type: 'string', format: 'url' },
|
||||
{ id: 'api_key', label: 'API Key', type: 'string', secret: true },
|
||||
{ id: 'account', label: 'Account', type: 'string' },
|
||||
{ id: 'username', label: 'Username', type: 'string' },
|
||||
{
|
||||
id: 'cacert',
|
||||
label: 'Public Key Certificate',
|
||||
type: 'string',
|
||||
multiline: true,
|
||||
},
|
||||
],
|
||||
metadata: [
|
||||
{
|
||||
id: 'secret_path',
|
||||
label: 'Secret Identifier',
|
||||
type: 'string',
|
||||
help_text: 'The identifier for the secret e.g., /some/identifier',
|
||||
},
|
||||
{
|
||||
id: 'secret_version',
|
||||
label: 'Secret Version',
|
||||
type: 'string',
|
||||
help_text:
|
||||
'Used to specify a specific secret version (if left empty, the latest version will be used).',
|
||||
},
|
||||
],
|
||||
required: ['url', 'api_key', 'account', 'username'],
|
||||
},
|
||||
injectors: {},
|
||||
},
|
||||
});
|
||||
|
||||
describe('<CredentialPluginPrompt />', () => {
|
||||
describe('Plugin not configured', () => {
|
||||
let wrapper;
|
||||
const onClose = jest.fn();
|
||||
const onSubmit = jest.fn();
|
||||
beforeAll(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialPluginPrompt onClose={onClose} onSubmit={onSubmit} />
|
||||
);
|
||||
});
|
||||
});
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('should render Wizard with all steps', async () => {
|
||||
const wizard = await waitForElement(wrapper, 'Wizard');
|
||||
const steps = wizard.prop('steps');
|
||||
|
||||
expect(steps).toHaveLength(2);
|
||||
expect(steps[0].name).toEqual('Credential');
|
||||
expect(steps[1].name).toEqual('Metadata');
|
||||
});
|
||||
test('credentials step renders correctly', () => {
|
||||
expect(wrapper.find('CredentialsStep').length).toBe(1);
|
||||
expect(wrapper.find('DataListItem').length).toBe(3);
|
||||
expect(
|
||||
wrapper.find('Radio').filterWhere(radio => radio.isChecked).length
|
||||
).toBe(0);
|
||||
});
|
||||
test('next button disabled until credential selected', () => {
|
||||
expect(wrapper.find('Button[children="Next"]').prop('isDisabled')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
test('clicking cancel button calls correct function', () => {
|
||||
wrapper.find('Button[children="Cancel"]').simulate('click');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
test('clicking credential row enables next button', async () => {
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('Radio')
|
||||
.at(0)
|
||||
.invoke('onChange')(true);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper
|
||||
.find('Radio')
|
||||
.at(0)
|
||||
.prop('isChecked')
|
||||
).toBe(true);
|
||||
expect(wrapper.find('Button[children="Next"]').prop('isDisabled')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
test('clicking next button shows metatdata step', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('Button[children="Next"]').simulate('click');
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('MetadataStep').length).toBe(1);
|
||||
expect(wrapper.find('FormField').length).toBe(2);
|
||||
});
|
||||
test('submit button calls correct function with parameters', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('input#credential-secret_path').simulate('change', {
|
||||
target: { value: '/foo/bar', name: 'secret_path' },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.find('input#credential-secret_version').simulate('change', {
|
||||
target: { value: '9000', name: 'secret_version' },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.find('Button[children="OK"]').simulate('click');
|
||||
});
|
||||
// expect(wrapper.debug()).toBe(false);
|
||||
// wrapper.find('Button[children="OK"]').simulate('click');
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
credential: selectedCredential,
|
||||
secret_path: '/foo/bar',
|
||||
secret_version: '9000',
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin already configured', () => {
|
||||
let wrapper;
|
||||
const onClose = jest.fn();
|
||||
const onSubmit = jest.fn();
|
||||
beforeAll(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialPluginPrompt
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
initialValues={{
|
||||
credential: selectedCredential,
|
||||
inputs: {
|
||||
secret_path: '/foo/bar',
|
||||
secret_version: '9000',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('should render Wizard with all steps', async () => {
|
||||
const wizard = await waitForElement(wrapper, 'Wizard');
|
||||
const steps = wizard.prop('steps');
|
||||
|
||||
expect(steps).toHaveLength(2);
|
||||
expect(steps[0].name).toEqual('Credential');
|
||||
expect(steps[1].name).toEqual('Metadata');
|
||||
});
|
||||
test('credentials step renders correctly', () => {
|
||||
expect(wrapper.find('CredentialsStep').length).toBe(1);
|
||||
expect(wrapper.find('DataListItem').length).toBe(3);
|
||||
expect(
|
||||
wrapper
|
||||
.find('Radio')
|
||||
.at(0)
|
||||
.prop('isChecked')
|
||||
).toBe(true);
|
||||
expect(wrapper.find('Button[children="Next"]').prop('isDisabled')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
test('metadata step renders correctly', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('Button[children="Next"]').simulate('click');
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('MetadataStep').length).toBe(1);
|
||||
expect(wrapper.find('FormField').length).toBe(2);
|
||||
expect(wrapper.find('input#credential-secret_path').prop('value')).toBe(
|
||||
'/foo/bar'
|
||||
);
|
||||
expect(
|
||||
wrapper.find('input#credential-secret_version').prop('value')
|
||||
).toBe('9000');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useField } from 'formik';
|
||||
import { CredentialsAPI } from '../../../../../api';
|
||||
import CheckboxListItem from '../../../../../components/CheckboxListItem';
|
||||
import ContentError from '../../../../../components/ContentError';
|
||||
import DataListToolbar from '../../../../../components/DataListToolbar';
|
||||
import PaginatedDataList from '../../../../../components/PaginatedDataList';
|
||||
import { getQSConfig, parseQueryString } from '../../../../../util/qs';
|
||||
import useRequest from '../../../../../util/useRequest';
|
||||
|
||||
const QS_CONFIG = getQSConfig('credential', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
credential_type__kind: 'external',
|
||||
});
|
||||
|
||||
function CredentialsStep({ i18n }) {
|
||||
const [selectedCredential, , selectedCredentialHelper] = useField(
|
||||
'credential'
|
||||
);
|
||||
const history = useHistory();
|
||||
|
||||
const {
|
||||
result: { credentials, count },
|
||||
error: credentialsError,
|
||||
isLoading: isCredentialsLoading,
|
||||
request: fetchCredentials,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
const { data } = await CredentialsAPI.read({
|
||||
...params,
|
||||
});
|
||||
return {
|
||||
credentials: data.results,
|
||||
count: data.count,
|
||||
};
|
||||
}, [history.location.search]),
|
||||
{ credentials: [], count: 0 }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredentials();
|
||||
}, [fetchCredentials]);
|
||||
|
||||
if (credentialsError) {
|
||||
return <ContentError error={credentialsError} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PaginatedDataList
|
||||
contentError={credentialsError}
|
||||
hasContentLoading={isCredentialsLoading}
|
||||
itemCount={count}
|
||||
items={credentials}
|
||||
onRowClick={row => selectedCredentialHelper.setValue(row)}
|
||||
qsConfig={QS_CONFIG}
|
||||
renderItem={credential => (
|
||||
<CheckboxListItem
|
||||
isSelected={selectedCredential?.value?.id === credential.id}
|
||||
itemId={credential.id}
|
||||
key={credential.id}
|
||||
name={credential.name}
|
||||
label={credential.name}
|
||||
onSelect={() => selectedCredentialHelper.setValue(credential)}
|
||||
onDeselect={() => selectedCredentialHelper.setValue(null)}
|
||||
isRadio
|
||||
/>
|
||||
)}
|
||||
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
|
||||
showPageSizeOptions={false}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(CredentialsStep);
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useField, useFormikContext } from 'formik';
|
||||
import styled from 'styled-components';
|
||||
import { Button, Form, FormGroup, Tooltip } from '@patternfly/react-core';
|
||||
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
||||
import { CredentialTypesAPI } from '../../../../../api';
|
||||
import AnsibleSelect from '../../../../../components/AnsibleSelect';
|
||||
import ContentError from '../../../../../components/ContentError';
|
||||
import ContentLoading from '../../../../../components/ContentLoading';
|
||||
import FormField from '../../../../../components/FormField';
|
||||
import { FormFullWidthLayout } from '../../../../../components/FormLayout';
|
||||
import useRequest from '../../../../../util/useRequest';
|
||||
import { required } from '../../../../../util/validators';
|
||||
|
||||
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
const TestButton = styled(Button)`
|
||||
margin-top: 20px;
|
||||
`;
|
||||
|
||||
function MetadataStep({ i18n }) {
|
||||
const form = useFormikContext();
|
||||
const [selectedCredential] = useField('credential');
|
||||
const [inputValues] = useField('inputs');
|
||||
|
||||
const {
|
||||
result: fields,
|
||||
error,
|
||||
isLoading,
|
||||
request: fetchMetadataOptions,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const {
|
||||
data: {
|
||||
inputs: { required: requiredFields, metadata },
|
||||
},
|
||||
} = await CredentialTypesAPI.readDetail(
|
||||
selectedCredential.value.credential_type ||
|
||||
selectedCredential.value.credential_type_id
|
||||
);
|
||||
metadata.forEach(field => {
|
||||
if (inputValues.value[field.id]) {
|
||||
form.initialValues.inputs[field.id] = inputValues.value[field.id];
|
||||
} else if (field.type === 'string' && field.choices) {
|
||||
form.initialValues.inputs[field.id] =
|
||||
field.default || field.choices[0];
|
||||
} else {
|
||||
form.initialValues.inputs[field.id] = '';
|
||||
}
|
||||
if (requiredFields && requiredFields.includes(field.id)) {
|
||||
field.required = true;
|
||||
}
|
||||
});
|
||||
return metadata;
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, []),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetadataOptions();
|
||||
}, [fetchMetadataOptions]);
|
||||
|
||||
const testMetadata = () => {
|
||||
// https://github.com/ansible/awx/issues/7126
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ContentError error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{fields.length > 0 && (
|
||||
<Form>
|
||||
<FormFullWidthLayout>
|
||||
{fields.map(field => {
|
||||
if (field.type === 'string') {
|
||||
if (field.choices) {
|
||||
return (
|
||||
<FormGroup
|
||||
key={field.id}
|
||||
fieldId={`credential-${field.id}`}
|
||||
label={field.label}
|
||||
isRequired={field.required}
|
||||
>
|
||||
{field.help_text && (
|
||||
<Tooltip content={field.help_text} position="right">
|
||||
<QuestionCircleIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
<AnsibleSelect
|
||||
name={`inputs.${field.id}`}
|
||||
value={form.values.inputs[field.id]}
|
||||
id={`credential-${field.id}`}
|
||||
data={field.choices.map(choice => {
|
||||
return {
|
||||
value: choice,
|
||||
key: choice,
|
||||
label: choice,
|
||||
};
|
||||
})}
|
||||
onChange={(event, value) => {
|
||||
form.setFieldValue(`inputs.${field.id}`, value);
|
||||
}}
|
||||
validate={field.required ? required(null, i18n) : null}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField
|
||||
key={field.id}
|
||||
id={`credential-${field.id}`}
|
||||
label={field.label}
|
||||
tooltip={field.help_text}
|
||||
name={`inputs.${field.id}`}
|
||||
type={field.multiline ? 'textarea' : 'text'}
|
||||
isRequired={field.required}
|
||||
validate={field.required ? required(null, i18n) : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</FormFullWidthLayout>
|
||||
</Form>
|
||||
)}
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Click this button to verify connection to the secret management system using the selected credential and specified inputs.`
|
||||
)}
|
||||
position="right"
|
||||
>
|
||||
<TestButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
onClick={() => testMetadata()}
|
||||
>
|
||||
{i18n._(t`Test`)}
|
||||
</TestButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(MetadataStep);
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as CredentialPluginPrompt } from './CredentialPluginPrompt';
|
||||
export { default as CredentialsStep } from './CredentialsStep';
|
||||
export { default as MetadataStep } from './MetadataStep';
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { func } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import { Button, ButtonVariant, Tooltip } from '@patternfly/react-core';
|
||||
import { KeyIcon } from '@patternfly/react-icons';
|
||||
import CredentialChip from '../../../../components/CredentialChip';
|
||||
import { Credential } from '../../../../types';
|
||||
|
||||
const SelectedCredential = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: white;
|
||||
border-bottom-color: var(--pf-global--BorderColor--200);
|
||||
`;
|
||||
|
||||
const SpacedCredentialChip = styled(CredentialChip)`
|
||||
margin: 5px 8px;
|
||||
`;
|
||||
|
||||
const PluginHelpText = styled.p`
|
||||
margin-top: 5px;
|
||||
`;
|
||||
|
||||
function CredentialPluginSelected({
|
||||
i18n,
|
||||
credential,
|
||||
onEditPlugin,
|
||||
onClearPlugin,
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<SelectedCredential>
|
||||
<SpacedCredentialChip onClick={onClearPlugin} credential={credential} />
|
||||
<Tooltip
|
||||
content={i18n._(t`Edit Credential Plugin Configuration`)}
|
||||
position="top"
|
||||
>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit Credential Plugin Configuration`)}
|
||||
onClick={onEditPlugin}
|
||||
variant={ButtonVariant.control}
|
||||
>
|
||||
<KeyIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</SelectedCredential>
|
||||
<PluginHelpText>
|
||||
<Trans>
|
||||
This field will be retrieved from an external secret management system
|
||||
using the specified credential.
|
||||
</Trans>
|
||||
</PluginHelpText>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CredentialPluginSelected.propTypes = {
|
||||
credential: Credential.isRequired,
|
||||
onEditPlugin: func,
|
||||
onClearPlugin: func,
|
||||
};
|
||||
|
||||
CredentialPluginSelected.defaultProps = {
|
||||
onEditPlugin: () => {},
|
||||
onClearPlugin: () => {},
|
||||
};
|
||||
|
||||
export default withI18n()(CredentialPluginSelected);
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
||||
import selectedCredential from '../data.cyberArkCredential.json';
|
||||
import CredentialPluginSelected from './CredentialPluginSelected';
|
||||
|
||||
describe('<CredentialPluginSelected />', () => {
|
||||
let wrapper;
|
||||
const onClearPlugin = jest.fn();
|
||||
const onEditPlugin = jest.fn();
|
||||
beforeAll(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialPluginSelected
|
||||
credential={selectedCredential}
|
||||
onClearPlugin={onClearPlugin}
|
||||
onEditPlugin={onEditPlugin}
|
||||
/>
|
||||
);
|
||||
});
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('renders the expected content', () => {
|
||||
expect(wrapper.find('CredentialChip').length).toBe(1);
|
||||
expect(wrapper.find('KeyIcon').length).toBe(1);
|
||||
});
|
||||
test('clearing plugin calls expected function', () => {
|
||||
wrapper.find('CredentialChip button').simulate('click');
|
||||
expect(onClearPlugin).toBeCalledTimes(1);
|
||||
});
|
||||
test('editing plugin calls expected function', () => {
|
||||
wrapper.find('KeyIcon').simulate('click');
|
||||
expect(onEditPlugin).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as CredentialPluginSelected } from './CredentialPluginSelected';
|
||||
export { default as CredentialPluginField } from './CredentialPluginField';
|
||||
@@ -2,13 +2,18 @@ import React, { useState } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useField } from 'formik';
|
||||
import { FileUpload, FormGroup } from '@patternfly/react-core';
|
||||
import FormField from '../../../../components/FormField';
|
||||
import {
|
||||
FileUpload,
|
||||
FormGroup,
|
||||
TextArea,
|
||||
TextInput,
|
||||
} from '@patternfly/react-core';
|
||||
import {
|
||||
FormColumnLayout,
|
||||
FormFullWidthLayout,
|
||||
} from '../../../../components/FormLayout';
|
||||
import { required } from '../../../../util/validators';
|
||||
import { CredentialPluginField } from '../CredentialPlugins';
|
||||
|
||||
const GoogleComputeEngineSubForm = ({ i18n }) => {
|
||||
const [fileError, setFileError] = useState(null);
|
||||
@@ -91,30 +96,38 @@ const GoogleComputeEngineSubForm = ({ i18n }) => {
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormField
|
||||
<CredentialPluginField
|
||||
id="credential-username"
|
||||
label={i18n._(t`Service account email address`)}
|
||||
name="inputs.username"
|
||||
type="email"
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
/>
|
||||
<FormField
|
||||
>
|
||||
<TextInput id="credential-username" />
|
||||
</CredentialPluginField>
|
||||
<CredentialPluginField
|
||||
id="credential-project"
|
||||
label={i18n._(t`Project`)}
|
||||
name="inputs.project"
|
||||
type="text"
|
||||
/>
|
||||
>
|
||||
<TextInput id="credential-project" />
|
||||
</CredentialPluginField>
|
||||
<FormFullWidthLayout>
|
||||
<FormField
|
||||
<CredentialPluginField
|
||||
id="credential-sshKeyData"
|
||||
label={i18n._(t`RSA private key`)}
|
||||
name="inputs.ssh_key_data"
|
||||
type="textarea"
|
||||
rows={6}
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
>
|
||||
<TextArea
|
||||
id="credential-sshKeyData"
|
||||
rows={6}
|
||||
resizeOrientation="vertical"
|
||||
/>
|
||||
</CredentialPluginField>
|
||||
</FormFullWidthLayout>
|
||||
</FormColumnLayout>
|
||||
);
|
||||
|
||||
@@ -1,39 +1,50 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import FormField, { PasswordField } from '../../../../components/FormField';
|
||||
import { TextArea, TextInput } from '@patternfly/react-core';
|
||||
import { CredentialPluginField } from '../CredentialPlugins';
|
||||
import { PasswordInput } from '../../../../components/FormField';
|
||||
|
||||
export const UsernameFormField = withI18n()(({ i18n }) => (
|
||||
<FormField
|
||||
id="credentual-username"
|
||||
<CredentialPluginField
|
||||
id="credential-username"
|
||||
label={i18n._(t`Username`)}
|
||||
name="inputs.username"
|
||||
type="text"
|
||||
/>
|
||||
>
|
||||
<TextInput id="credential-username" />
|
||||
</CredentialPluginField>
|
||||
));
|
||||
|
||||
export const PasswordFormField = withI18n()(({ i18n }) => (
|
||||
<PasswordField
|
||||
<CredentialPluginField
|
||||
id="credential-password"
|
||||
label={i18n._(t`Password`)}
|
||||
name="inputs.password"
|
||||
/>
|
||||
>
|
||||
<PasswordInput id="credential-password" />
|
||||
</CredentialPluginField>
|
||||
));
|
||||
|
||||
export const SSHKeyDataField = withI18n()(({ i18n }) => (
|
||||
<FormField
|
||||
<CredentialPluginField
|
||||
id="credential-sshKeyData"
|
||||
label={i18n._(t`SSH Private Key`)}
|
||||
name="inputs.ssh_key_data"
|
||||
type="textarea"
|
||||
>
|
||||
<TextArea
|
||||
id="credential-sshKeyData"
|
||||
rows={6}
|
||||
resizeOrientation="vertical"
|
||||
/>
|
||||
</CredentialPluginField>
|
||||
));
|
||||
|
||||
export const SSHKeyUnlockField = withI18n()(({ i18n }) => (
|
||||
<PasswordField
|
||||
<CredentialPluginField
|
||||
id="credential-sshKeyUnlock"
|
||||
label={i18n._(t`Private Key Passphrase`)}
|
||||
name="inputs.ssh_key_unlock"
|
||||
/>
|
||||
>
|
||||
<PasswordInput id="credential-password" />
|
||||
</CredentialPluginField>
|
||||
));
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"id": 12,
|
||||
"type": "credential",
|
||||
"url": "/api/v2/credentials/12/",
|
||||
"related": {
|
||||
"created_by": "/api/v2/users/1/",
|
||||
"modified_by": "/api/v2/users/1/",
|
||||
"activity_stream": "/api/v2/credentials/12/activity_stream/",
|
||||
"access_list": "/api/v2/credentials/12/access_list/",
|
||||
"object_roles": "/api/v2/credentials/12/object_roles/",
|
||||
"owner_users": "/api/v2/credentials/12/owner_users/",
|
||||
"owner_teams": "/api/v2/credentials/12/owner_teams/",
|
||||
"copy": "/api/v2/credentials/12/copy/",
|
||||
"input_sources": "/api/v2/credentials/12/input_sources/",
|
||||
"credential_type": "/api/v2/credential_types/19/",
|
||||
"user": "/api/v2/users/1/"
|
||||
},
|
||||
"summary_fields": {
|
||||
"credential_type": {
|
||||
"id": 19,
|
||||
"name": "Microsoft Azure Key Vault",
|
||||
"description": ""
|
||||
},
|
||||
"created_by": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"first_name": "",
|
||||
"last_name": ""
|
||||
},
|
||||
"modified_by": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"first_name": "",
|
||||
"last_name": ""
|
||||
},
|
||||
"object_roles": {
|
||||
"admin_role": {
|
||||
"description": "Can manage all aspects of the credential",
|
||||
"name": "Admin",
|
||||
"id": 60
|
||||
},
|
||||
"use_role": {
|
||||
"description": "Can use the credential in a job template",
|
||||
"name": "Use",
|
||||
"id": 61
|
||||
},
|
||||
"read_role": {
|
||||
"description": "May view settings for the credential",
|
||||
"name": "Read",
|
||||
"id": 62
|
||||
}
|
||||
},
|
||||
"user_capabilities": {
|
||||
"edit": true,
|
||||
"delete": true,
|
||||
"copy": true,
|
||||
"use": true
|
||||
},
|
||||
"owners": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "user",
|
||||
"name": "admin",
|
||||
"description": " ",
|
||||
"url": "/api/v2/users/1/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created": "2020-05-26T14:54:45.612847Z",
|
||||
"modified": "2020-05-26T14:54:45.612861Z",
|
||||
"name": "Microsoft Azure Key Vault",
|
||||
"description": "",
|
||||
"organization": null,
|
||||
"credential_type": 19,
|
||||
"inputs": {
|
||||
"url": "https://localhost",
|
||||
"client": "foo",
|
||||
"secret": "$encrypted$",
|
||||
"tenant": "9000",
|
||||
"cloud_name": "AzureCloud"
|
||||
},
|
||||
"kind": "azure_kv",
|
||||
"cloud": false,
|
||||
"kubernetes": false
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"id": 1,
|
||||
"type": "credential",
|
||||
"url": "/api/v2/credentials/1/",
|
||||
"related": {
|
||||
"named_url": "/api/v2/credentials/CyberArk Conjur Secret Lookup++CyberArk Conjur Secret Lookup+external++/",
|
||||
"created_by": "/api/v2/users/1/",
|
||||
"modified_by": "/api/v2/users/1/",
|
||||
"activity_stream": "/api/v2/credentials/1/activity_stream/",
|
||||
"access_list": "/api/v2/credentials/1/access_list/",
|
||||
"object_roles": "/api/v2/credentials/1/object_roles/",
|
||||
"owner_users": "/api/v2/credentials/1/owner_users/",
|
||||
"owner_teams": "/api/v2/credentials/1/owner_teams/",
|
||||
"copy": "/api/v2/credentials/1/copy/",
|
||||
"input_sources": "/api/v2/credentials/1/input_sources/",
|
||||
"credential_type": "/api/v2/credential_types/20/",
|
||||
"user": "/api/v2/users/1/"
|
||||
},
|
||||
"summary_fields": {
|
||||
"credential_type": {
|
||||
"id": 20,
|
||||
"name": "CyberArk Conjur Secret Lookup",
|
||||
"description": ""
|
||||
},
|
||||
"created_by": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"first_name": "",
|
||||
"last_name": ""
|
||||
},
|
||||
"modified_by": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"first_name": "",
|
||||
"last_name": ""
|
||||
},
|
||||
"object_roles": {
|
||||
"admin_role": {
|
||||
"description": "Can manage all aspects of the credential",
|
||||
"name": "Admin",
|
||||
"id": 27
|
||||
},
|
||||
"use_role": {
|
||||
"description": "Can use the credential in a job template",
|
||||
"name": "Use",
|
||||
"id": 28
|
||||
},
|
||||
"read_role": {
|
||||
"description": "May view settings for the credential",
|
||||
"name": "Read",
|
||||
"id": 29
|
||||
}
|
||||
},
|
||||
"user_capabilities": {
|
||||
"edit": true,
|
||||
"delete": true,
|
||||
"copy": true,
|
||||
"use": true
|
||||
},
|
||||
"owners": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "user",
|
||||
"name": "admin",
|
||||
"description": " ",
|
||||
"url": "/api/v2/users/1/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created": "2020-05-19T12:51:36.956029Z",
|
||||
"modified": "2020-05-19T12:51:36.956086Z",
|
||||
"name": "CyberArk Conjur Secret Lookup",
|
||||
"description": "",
|
||||
"organization": null,
|
||||
"credential_type": 20,
|
||||
"inputs": {
|
||||
"url": "https://localhost",
|
||||
"account": "adsf",
|
||||
"api_key": "$encrypted$",
|
||||
"username": "adsf"
|
||||
},
|
||||
"kind": "conjur",
|
||||
"cloud": false,
|
||||
"kubernetes": false
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"id": 11,
|
||||
"type": "credential",
|
||||
"url": "/api/v2/credentials/11/",
|
||||
"related": {
|
||||
"created_by": "/api/v2/users/1/",
|
||||
"modified_by": "/api/v2/users/1/",
|
||||
"activity_stream": "/api/v2/credentials/11/activity_stream/",
|
||||
"access_list": "/api/v2/credentials/11/access_list/",
|
||||
"object_roles": "/api/v2/credentials/11/object_roles/",
|
||||
"owner_users": "/api/v2/credentials/11/owner_users/",
|
||||
"owner_teams": "/api/v2/credentials/11/owner_teams/",
|
||||
"copy": "/api/v2/credentials/11/copy/",
|
||||
"input_sources": "/api/v2/credentials/11/input_sources/",
|
||||
"credential_type": "/api/v2/credential_types/21/",
|
||||
"user": "/api/v2/users/1/"
|
||||
},
|
||||
"summary_fields": {
|
||||
"credential_type": {
|
||||
"id": 21,
|
||||
"name": "HashiCorp Vault Secret Lookup",
|
||||
"description": ""
|
||||
},
|
||||
"created_by": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"first_name": "",
|
||||
"last_name": ""
|
||||
},
|
||||
"modified_by": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"first_name": "",
|
||||
"last_name": ""
|
||||
},
|
||||
"object_roles": {
|
||||
"admin_role": {
|
||||
"description": "Can manage all aspects of the credential",
|
||||
"name": "Admin",
|
||||
"id": 57
|
||||
},
|
||||
"use_role": {
|
||||
"description": "Can use the credential in a job template",
|
||||
"name": "Use",
|
||||
"id": 58
|
||||
},
|
||||
"read_role": {
|
||||
"description": "May view settings for the credential",
|
||||
"name": "Read",
|
||||
"id": 59
|
||||
}
|
||||
},
|
||||
"user_capabilities": {
|
||||
"edit": true,
|
||||
"delete": true,
|
||||
"copy": true,
|
||||
"use": true
|
||||
},
|
||||
"owners": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "user",
|
||||
"name": "admin",
|
||||
"description": " ",
|
||||
"url": "/api/v2/users/1/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created": "2020-05-26T14:54:00.674404Z",
|
||||
"modified": "2020-05-26T14:54:00.674418Z",
|
||||
"name": "HashiCorp Vault Secret Lookup",
|
||||
"description": "",
|
||||
"organization": null,
|
||||
"credential_type": 21,
|
||||
"inputs": {
|
||||
"url": "https://localhost",
|
||||
"api_version": "v1"
|
||||
},
|
||||
"kind": "hashivault_kv",
|
||||
"cloud": false,
|
||||
"kubernetes": false
|
||||
}
|
||||
@@ -13,7 +13,11 @@ import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { CardActions } from '@patternfly/react-core';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
|
||||
import { InventoriesAPI } from '../../../api';
|
||||
import {
|
||||
InventoriesAPI,
|
||||
InventorySourcesAPI,
|
||||
OrganizationsAPI,
|
||||
} from '../../../api';
|
||||
import { TabbedCardHeader } from '../../../components/Card';
|
||||
import CardCloseButton from '../../../components/CardCloseButton';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
@@ -21,20 +25,33 @@ import ContentLoading from '../../../components/ContentLoading';
|
||||
import RoutedTabs from '../../../components/RoutedTabs';
|
||||
import InventorySourceDetail from '../InventorySourceDetail';
|
||||
import InventorySourceEdit from '../InventorySourceEdit';
|
||||
import NotificationList from '../../../components/NotificationList/NotificationList';
|
||||
|
||||
function InventorySource({ i18n, inventory, setBreadcrumb }) {
|
||||
function InventorySource({ i18n, inventory, setBreadcrumb, me }) {
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch('/inventories/inventory/:id/sources/:sourceId');
|
||||
const sourceListUrl = `/inventories/inventory/${inventory.id}/sources`;
|
||||
|
||||
const { result: source, error, isLoading, request: fetchSource } = useRequest(
|
||||
const {
|
||||
result: { source, isNotifAdmin },
|
||||
error,
|
||||
isLoading,
|
||||
request: fetchSource,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
return InventoriesAPI.readSourceDetail(
|
||||
inventory.id,
|
||||
match.params.sourceId
|
||||
);
|
||||
const [inventorySource, notifAdminRes] = await Promise.all([
|
||||
InventoriesAPI.readSourceDetail(inventory.id, match.params.sourceId),
|
||||
OrganizationsAPI.read({
|
||||
page_size: 1,
|
||||
role_level: 'notification_admin_role',
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
source: inventorySource,
|
||||
isNotifAdmin: notifAdminRes.data.results.length > 0,
|
||||
};
|
||||
}, [inventory.id, match.params.sourceId]),
|
||||
null
|
||||
{ source: null, isNotifAdmin: false }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -63,18 +80,24 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) {
|
||||
link: `${match.url}/details`,
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Notifications`),
|
||||
link: `${match.url}/notifications`,
|
||||
id: 2,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Schedules`),
|
||||
link: `${match.url}/schedules`,
|
||||
id: 3,
|
||||
id: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const canToggleNotifications = isNotifAdmin;
|
||||
const canSeeNotificationsTab = me.is_system_auditor || isNotifAdmin;
|
||||
|
||||
if (canSeeNotificationsTab) {
|
||||
tabsArray.push({
|
||||
name: i18n._(t`Notifications`),
|
||||
link: `${match.url}/notifications`,
|
||||
id: 3,
|
||||
});
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ContentError error={error} />;
|
||||
}
|
||||
@@ -111,6 +134,16 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) {
|
||||
>
|
||||
<InventorySourceEdit source={source} inventory={inventory} />
|
||||
</Route>
|
||||
<Route
|
||||
key="notifications"
|
||||
path="/inventories/inventory/:id/sources/:sourceId/notifications"
|
||||
>
|
||||
<NotificationList
|
||||
id={Number(match.params.sourceId)}
|
||||
canToggleNotifications={canToggleNotifications}
|
||||
apiModel={InventorySourcesAPI}
|
||||
/>
|
||||
</Route>
|
||||
<Route key="not-found" path="*">
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${match.url}/details`}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { InventoriesAPI } from '../../../api';
|
||||
import { InventoriesAPI, OrganizationsAPI } from '../../../api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
@@ -10,6 +10,9 @@ import mockInventorySource from '../shared/data.inventory_source.json';
|
||||
import InventorySource from './InventorySource';
|
||||
|
||||
jest.mock('../../../api/models/Inventories');
|
||||
jest.mock('../../../api/models/Organizations');
|
||||
jest.mock('../../../api/models/InventorySources');
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useRouteMatch: () => ({
|
||||
@@ -18,10 +21,6 @@ jest.mock('react-router-dom', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
InventoriesAPI.readSourceDetail.mockResolvedValue({
|
||||
data: { ...mockInventorySource },
|
||||
});
|
||||
|
||||
const mockInventory = {
|
||||
id: 2,
|
||||
name: 'Mock Inventory',
|
||||
@@ -34,22 +33,31 @@ describe('<InventorySource />', () => {
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />
|
||||
<InventorySource
|
||||
inventory={mockInventory}
|
||||
me={{ is_system_auditor: false }}
|
||||
setBreadcrumb={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('should render expected tabs', () => {
|
||||
InventoriesAPI.readSourceDetail.mockResolvedValue({
|
||||
data: { ...mockInventorySource },
|
||||
});
|
||||
OrganizationsAPI.read.mockResolvedValue({
|
||||
data: { results: [{ id: 1, name: 'isNotifAdmin' }] },
|
||||
});
|
||||
const expectedTabs = [
|
||||
'Back to Sources',
|
||||
'Details',
|
||||
'Notifications',
|
||||
'Schedules',
|
||||
'Notifications',
|
||||
];
|
||||
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||
expect(tab.text()).toEqual(expectedTabs[index]);
|
||||
@@ -57,10 +65,20 @@ describe('<InventorySource />', () => {
|
||||
});
|
||||
|
||||
test('should show content error when api throws error on initial render', async () => {
|
||||
InventoriesAPI.readSourceDetail.mockResolvedValue({
|
||||
data: { ...mockInventorySource },
|
||||
});
|
||||
OrganizationsAPI.read.mockResolvedValue({
|
||||
data: { results: [{ id: 1, name: 'isNotifAdmin' }] },
|
||||
});
|
||||
InventoriesAPI.readSourceDetail.mockRejectedValueOnce(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />
|
||||
<InventorySource
|
||||
inventory={mockInventory}
|
||||
me={{ is_system_auditor: false }}
|
||||
setBreadcrumb={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
@@ -71,16 +89,47 @@ describe('<InventorySource />', () => {
|
||||
});
|
||||
|
||||
test('should show content error when user attempts to navigate to erroneous route', async () => {
|
||||
InventoriesAPI.readSourceDetail.mockResolvedValue({
|
||||
data: { ...mockInventorySource },
|
||||
});
|
||||
OrganizationsAPI.read.mockResolvedValue({
|
||||
data: { results: [{ id: 1, name: 'isNotifAdmin' }] },
|
||||
});
|
||||
history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/inventory/2/sources/1/foobar'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />,
|
||||
<InventorySource
|
||||
inventory={mockInventory}
|
||||
setBreadcrumb={() => {}}
|
||||
me={{ is_system_auditor: false }}
|
||||
/>,
|
||||
{ context: { router: { history } } }
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||
expect(wrapper.find('ContentError Title').text()).toEqual('Not Found');
|
||||
});
|
||||
|
||||
test('should call api', () => {
|
||||
InventoriesAPI.readSourceDetail.mockResolvedValue({
|
||||
data: { ...mockInventorySource },
|
||||
});
|
||||
OrganizationsAPI.read.mockResolvedValue({
|
||||
data: { results: [{ id: 1, name: 'isNotifAdmin' }] },
|
||||
});
|
||||
expect(InventoriesAPI.readSourceDetail).toBeCalledWith(2, 123);
|
||||
expect(OrganizationsAPI.read).toBeCalled();
|
||||
});
|
||||
|
||||
test('should not render notifications tab', () => {
|
||||
InventoriesAPI.readSourceDetail.mockResolvedValue({
|
||||
data: { ...mockInventorySource },
|
||||
});
|
||||
OrganizationsAPI.read.mockResolvedValue({
|
||||
data: { results: [] },
|
||||
});
|
||||
expect(wrapper.find('button[aria-label="Notifications"]').length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Switch, Route } from 'react-router-dom';
|
||||
import InventorySource from '../InventorySource';
|
||||
import { Config } from '../../../contexts/Config';
|
||||
import InventorySourceAdd from '../InventorySourceAdd';
|
||||
import InventorySourceList from './InventorySourceList';
|
||||
|
||||
@@ -11,7 +12,15 @@ function InventorySources({ inventory, setBreadcrumb }) {
|
||||
<InventorySourceAdd />
|
||||
</Route>
|
||||
<Route path="/inventories/inventory/:id/sources/:sourceId">
|
||||
<InventorySource inventory={inventory} setBreadcrumb={setBreadcrumb} />
|
||||
<Config>
|
||||
{({ me }) => (
|
||||
<InventorySource
|
||||
inventory={inventory}
|
||||
setBreadcrumb={setBreadcrumb}
|
||||
me={me || {}}
|
||||
/>
|
||||
)}
|
||||
</Config>
|
||||
</Route>
|
||||
<Route path="/inventories/:inventoryType/:id/sources">
|
||||
<InventorySourceList />
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useLocation, useRouteMatch, useParams } from 'react-router-dom';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import { Card } from '@patternfly/react-core';
|
||||
import { TeamsAPI } from '../../../api';
|
||||
|
||||
import useRequest from '../../../util/useRequest';
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
EmptyStateBody,
|
||||
EmptyStateIcon,
|
||||
Title,
|
||||
} from '@patternfly/react-core';
|
||||
import { CubesIcon } from '@patternfly/react-icons';
|
||||
import { TeamsAPI, RolesAPI } from '../../../api';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import DataListToolbar from '../../../components/DataListToolbar';
|
||||
import PaginatedDataList, {
|
||||
ToolbarAddButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
import PaginatedDataList from '../../../components/PaginatedDataList';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import TeamAccessListItem from './TeamAccessListItem';
|
||||
import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd';
|
||||
|
||||
const QS_CONFIG = getQSConfig('team', {
|
||||
const QS_CONFIG = getQSConfig('roles', {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
order_by: 'id',
|
||||
});
|
||||
|
||||
function TeamAccessList({ i18n }) {
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||
const { search } = useLocation();
|
||||
const match = useRouteMatch();
|
||||
const { id } = useParams();
|
||||
const [roleToDisassociate, setRoleToDisassociate] = useState(null);
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
@@ -57,6 +65,26 @@ function TeamAccessList({ i18n }) {
|
||||
fetchRoles();
|
||||
}, [fetchRoles]);
|
||||
|
||||
const saveRoles = () => {
|
||||
setIsWizardOpen(false);
|
||||
fetchRoles();
|
||||
};
|
||||
const {
|
||||
isLoading: isDisassociateLoading,
|
||||
deleteItems: disassociateRole,
|
||||
deletionError: disassociationError,
|
||||
clearDeletionError: clearDisassociationError,
|
||||
} = useDeleteItems(
|
||||
useCallback(async () => {
|
||||
setRoleToDisassociate(null);
|
||||
await RolesAPI.disassociateTeamRole(
|
||||
roleToDisassociate.id,
|
||||
parseInt(id, 10)
|
||||
);
|
||||
}, [roleToDisassociate, id]),
|
||||
{ qsConfig: QS_CONFIG, fetchItems: fetchRoles }
|
||||
);
|
||||
|
||||
const canAdd =
|
||||
options && Object.prototype.hasOwnProperty.call(options, 'POST');
|
||||
|
||||
@@ -76,11 +104,28 @@ function TeamAccessList({ i18n }) {
|
||||
return `/${resource_type}s/${resource_id}/details`;
|
||||
};
|
||||
|
||||
const isSysAdmin = roles.some(role => role.name === 'System Administrator');
|
||||
if (isSysAdmin) {
|
||||
return (
|
||||
<Card>
|
||||
<EmptyState variant="full">
|
||||
<EmptyStateIcon icon={CubesIcon} />
|
||||
<Title headingLevel="h5" size="lg">
|
||||
{i18n._(t`System Administrator`)}
|
||||
</Title>
|
||||
<EmptyStateBody>
|
||||
{i18n._(
|
||||
t`System administrators have unrestricted access to all resources.`
|
||||
)}
|
||||
</EmptyStateBody>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaginatedDataList
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading}
|
||||
hasContentLoading={isLoading || isDisassociateLoading}
|
||||
items={roles}
|
||||
itemCount={roleCount}
|
||||
pluralizedItemName={i18n._(t`Teams`)}
|
||||
@@ -104,7 +149,17 @@ function TeamAccessList({ i18n }) {
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
...(canAdd
|
||||
? [<ToolbarAddButton key="add" linkTo={`${match.url}/add`} />]
|
||||
? [
|
||||
<Button
|
||||
key="add"
|
||||
aria-label={i18n._(t`Add resource roles`)}
|
||||
onClick={() => {
|
||||
setIsWizardOpen(true);
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>,
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
@@ -114,16 +169,69 @@ function TeamAccessList({ i18n }) {
|
||||
key={role.id}
|
||||
role={role}
|
||||
detailUrl={detailUrl(role)}
|
||||
onSelect={() => {}}
|
||||
onSelect={item => {
|
||||
setRoleToDisassociate(item);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={
|
||||
canAdd ? (
|
||||
<ToolbarAddButton key="add" linkTo={`${match.url}/add`} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
{isWizardOpen && (
|
||||
<UserAndTeamAccessAdd
|
||||
apiModel={TeamsAPI}
|
||||
isOpen={isWizardOpen}
|
||||
onSave={saveRoles}
|
||||
onClose={() => setIsWizardOpen(false)}
|
||||
title={i18n._(t`Add team permissions`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{roleToDisassociate && (
|
||||
<AlertModal
|
||||
aria-label={i18n._(t`Disassociate role`)}
|
||||
isOpen={roleToDisassociate}
|
||||
variant="error"
|
||||
title={i18n._(t`Disassociate role!`)}
|
||||
onClose={() => setRoleToDisassociate(null)}
|
||||
actions={[
|
||||
<Button
|
||||
key="disassociate"
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`confirm disassociate`)}
|
||||
onClick={() => disassociateRole()}
|
||||
>
|
||||
{i18n._(t`Disassociate`)}
|
||||
</Button>,
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
onClick={() => setRoleToDisassociate(null)}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div>
|
||||
{i18n._(
|
||||
t`This action will disassociate the following role from ${roleToDisassociate.summary_fields.resource_name}:`
|
||||
)}
|
||||
<br />
|
||||
<strong>{roleToDisassociate.name}</strong>
|
||||
</div>
|
||||
</AlertModal>
|
||||
)}
|
||||
{disassociationError && (
|
||||
<AlertModal
|
||||
isOpen={disassociationError}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={clearDisassociationError}
|
||||
>
|
||||
{i18n._(t`Failed to delete role.`)}
|
||||
<ErrorDetail error={disassociationError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default withI18n()(TeamAccessList);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { TeamsAPI } from '../../../api';
|
||||
import { TeamsAPI, RolesAPI } from '../../../api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
@@ -10,11 +8,16 @@ import {
|
||||
import TeamAccessList from './TeamAccessList';
|
||||
|
||||
jest.mock('../../../api/models/Teams');
|
||||
describe('<TeamAccessList />', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
beforeEach(async () => {
|
||||
TeamsAPI.readRoles.mockResolvedValue({
|
||||
jest.mock('../../../api/models/Roles');
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
id: 18,
|
||||
}),
|
||||
}));
|
||||
|
||||
const roles = {
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
@@ -32,7 +35,7 @@ describe('<TeamAccessList />', () => {
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Admin',
|
||||
name: 'Admin Read Only',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/257/',
|
||||
summary_fields: {
|
||||
@@ -83,46 +86,36 @@ describe('<TeamAccessList />', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
count: 4,
|
||||
count: 5,
|
||||
},
|
||||
});
|
||||
|
||||
TeamsAPI.readRoleOptions.mockResolvedValue({
|
||||
};
|
||||
const options = {
|
||||
data: { actions: { POST: { id: 1, disassociate: true } } },
|
||||
});
|
||||
};
|
||||
describe('<TeamAccessList />', () => {
|
||||
let wrapper;
|
||||
|
||||
history = createMemoryHistory({
|
||||
initialEntries: ['/teams/18/access'],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route path="/teams/:id/access">
|
||||
<TeamAccessList />
|
||||
</Route>,
|
||||
{
|
||||
context: {
|
||||
router: {
|
||||
history,
|
||||
route: {
|
||||
location: history.location,
|
||||
match: { params: { id: 18 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('should render properly', async () => {
|
||||
TeamsAPI.readRoles.mockResolvedValue(roles);
|
||||
TeamsAPI.readRoleOptions.mockResolvedValue(options);
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<TeamAccessList />);
|
||||
});
|
||||
expect(wrapper.find('TeamAccessList').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should create proper detailUrl', async () => {
|
||||
TeamsAPI.readRoles.mockResolvedValue(roles);
|
||||
TeamsAPI.readRoleOptions.mockResolvedValue(options);
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<TeamAccessList />);
|
||||
});
|
||||
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||
|
||||
expect(wrapper.find(`Link#teamRole-2`).prop('to')).toBe(
|
||||
@@ -141,4 +134,164 @@ describe('<TeamAccessList />', () => {
|
||||
'/inventories/smart_inventory/77/details'
|
||||
);
|
||||
});
|
||||
test('should not render add button', async () => {
|
||||
TeamsAPI.readRoleOptions.mockResolvedValueOnce({
|
||||
data: {},
|
||||
});
|
||||
|
||||
TeamsAPI.readRoles.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Admin',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/257/',
|
||||
summary_fields: {
|
||||
resource_name: 'template delete project',
|
||||
resource_id: 15,
|
||||
resource_type: 'job_template',
|
||||
resource_type_display_name: 'Job Template',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
description: 'Can manage all aspects of the job template',
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<TeamAccessList />);
|
||||
});
|
||||
|
||||
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||
expect(wrapper.find('Button[aria-label="Add resource roles"]').length).toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test('should render disassociate modal', async () => {
|
||||
TeamsAPI.readRoles.mockResolvedValue(roles);
|
||||
TeamsAPI.readRoleOptions.mockResolvedValue(options);
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<TeamAccessList />);
|
||||
});
|
||||
|
||||
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({
|
||||
id: 4,
|
||||
name: 'Execute',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/258/',
|
||||
summary_fields: {
|
||||
resource_name: 'Credential Bar',
|
||||
resource_id: 75,
|
||||
resource_type: 'credential',
|
||||
resource_type_display_name: 'Credential',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('AlertModal[aria-label="Disassociate role"]').length
|
||||
).toBe(1);
|
||||
await act(async () =>
|
||||
wrapper
|
||||
.find('button[aria-label="confirm disassociate"]')
|
||||
.prop('onClick')()
|
||||
);
|
||||
expect(RolesAPI.disassociateTeamRole).toBeCalledWith(4, 18);
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('AlertModal[aria-label="Disassociate role"]').length
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('should throw disassociation error', async () => {
|
||||
TeamsAPI.readRoles.mockResolvedValue(roles);
|
||||
RolesAPI.disassociateTeamRole.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'post',
|
||||
url: '/api/v2/roles/18/roles',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
status: 403,
|
||||
},
|
||||
})
|
||||
);
|
||||
TeamsAPI.readRoleOptions.mockResolvedValue(options);
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<TeamAccessList />);
|
||||
});
|
||||
|
||||
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({
|
||||
id: 4,
|
||||
name: 'Execute',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/258/',
|
||||
summary_fields: {
|
||||
resource_name: 'Credential Bar',
|
||||
resource_id: 75,
|
||||
resource_type: 'credential',
|
||||
resource_type_display_name: 'Credential',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('AlertModal[aria-label="Disassociate role"]').length
|
||||
).toBe(1);
|
||||
await act(async () =>
|
||||
wrapper
|
||||
.find('button[aria-label="confirm disassociate"]')
|
||||
.prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AlertModal[title="Error!"]').length).toBe(1);
|
||||
});
|
||||
|
||||
test('user with sys admin privilege should show empty state', async () => {
|
||||
TeamsAPI.readRoles.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'System Administrator',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/257/',
|
||||
summary_fields: {
|
||||
resource_name: 'template delete project',
|
||||
resource_id: 15,
|
||||
resource_type: 'job_template',
|
||||
resource_type_display_name: 'Job Template',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
},
|
||||
});
|
||||
TeamsAPI.readRoleOptions.mockResolvedValue(options);
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<TeamAccessList />);
|
||||
});
|
||||
|
||||
waitForElement(
|
||||
wrapper,
|
||||
'EmptyState[title="System Administrator"]',
|
||||
el => el.length === 1
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
DataListItem,
|
||||
DataListItemCells,
|
||||
DataListItemRow,
|
||||
Chip,
|
||||
} from '@patternfly/react-core';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DetailList, Detail } from '../../../components/DetailList';
|
||||
import DataListCell from '../../../components/DataListCell';
|
||||
|
||||
function TeamAccessListItem({ role, i18n, detailUrl }) {
|
||||
function TeamAccessListItem({ role, i18n, detailUrl, onSelect }) {
|
||||
const labelId = `teamRole-${role.id}`;
|
||||
return (
|
||||
<DataListItem key={role.id} aria-labelledby={labelId} id={`${role.id}`}>
|
||||
@@ -23,18 +25,33 @@ function TeamAccessListItem({ role, i18n, detailUrl }) {
|
||||
</DataListCell>,
|
||||
<DataListCell key="type" aria-label={i18n._(t`resource type`)}>
|
||||
{role.summary_fields && (
|
||||
<>
|
||||
<b css="margin-right: 24px">{i18n._(t`Type`)}</b>
|
||||
{role.summary_fields.resource_type_display_name}
|
||||
</>
|
||||
<DetailList stacked>
|
||||
<Detail
|
||||
label={i18n._(t`Type`)}
|
||||
value={role.summary_fields.resource_type_display_name}
|
||||
/>
|
||||
</DetailList>
|
||||
)}
|
||||
</DataListCell>,
|
||||
<DataListCell key="role" aria-label={i18n._(t`resource role`)}>
|
||||
{role.name && (
|
||||
<>
|
||||
<b css="margin-right: 24px">{i18n._(t`Role`)}</b>
|
||||
<DetailList stacked>
|
||||
<Detail
|
||||
label={i18n._(t`Role`)}
|
||||
value={
|
||||
<Chip
|
||||
isReadOnly={
|
||||
!role.summary_fields.user_capabilities.unattach
|
||||
}
|
||||
key={role.name}
|
||||
aria-label={role.name}
|
||||
onClick={() => onSelect(role)}
|
||||
>
|
||||
{role.name}
|
||||
</>
|
||||
</Chip>
|
||||
}
|
||||
/>
|
||||
</DetailList>
|
||||
)}
|
||||
</DataListCell>,
|
||||
]}
|
||||
|
||||
@@ -18,20 +18,25 @@ describe('<TeamAccessListItem/>', () => {
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
test('should mount properly', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<TeamAccessListItem
|
||||
role={role}
|
||||
detailUrl="/templates/job_template/15/details"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
test('should mount properly', () => {
|
||||
expect(wrapper.length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render proper list item data', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<TeamAccessListItem
|
||||
role={role}
|
||||
detailUrl="/templates/job_template/15/details"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find('PFDataListCell[aria-label="resource name"]').text()
|
||||
).toBe('template delete project');
|
||||
@@ -42,4 +47,23 @@ describe('<TeamAccessListItem/>', () => {
|
||||
wrapper.find('PFDataListCell[aria-label="resource role"]').text()
|
||||
).toContain('Admin');
|
||||
});
|
||||
test('should render deletable chip', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<TeamAccessListItem
|
||||
role={role}
|
||||
detailUrl="/templates/job_template/15/details"
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('Chip').prop('isReadOnly')).toBe(false);
|
||||
});
|
||||
test('should render read only chip', () => {
|
||||
role.summary_fields.user_capabilities.unattach = false;
|
||||
wrapper = mountWithContexts(
|
||||
<TeamAccessListItem
|
||||
role={role}
|
||||
detailUrl="/templates/job_template/15/details"
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('Chip').prop('isReadOnly')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ class TeamListItem extends React.Component {
|
||||
<DataListCell key="organization">
|
||||
{team.summary_fields.organization && (
|
||||
<Fragment>
|
||||
<b css="margin-right: 24px">{i18n._(t`Organization`)}</b>
|
||||
<b>{i18n._(t`Organization`)}</b>{' '}
|
||||
<Link
|
||||
to={`/organizations/${team.summary_fields.organization.id}/details`}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { withFormik, useField, useFormikContext } from 'formik';
|
||||
import { withFormik, useField } from 'formik';
|
||||
import {
|
||||
Form,
|
||||
FormGroup,
|
||||
@@ -52,8 +52,6 @@ function JobTemplateForm({
|
||||
submitError,
|
||||
i18n,
|
||||
}) {
|
||||
const { values: formikValues } = useFormikContext();
|
||||
|
||||
const [contentError, setContentError] = useState(false);
|
||||
const [inventory, setInventory] = useState(
|
||||
template?.summary_fields?.inventory
|
||||
@@ -65,6 +63,7 @@ function JobTemplateForm({
|
||||
Boolean(template.webhook_service)
|
||||
);
|
||||
|
||||
const [askInventoryOnLaunchField] = useField('ask_inventory_on_launch');
|
||||
const [jobTypeField, jobTypeMeta, jobTypeHelpers] = useField({
|
||||
name: 'job_type',
|
||||
validate: required(null, i18n),
|
||||
@@ -81,7 +80,7 @@ function JobTemplateForm({
|
||||
});
|
||||
const [credentialField, , credentialHelpers] = useField('credentials');
|
||||
const [labelsField, , labelsHelpers] = useField('labels');
|
||||
const [limitField, limitMeta] = useField('limit');
|
||||
const [limitField, limitMeta, limitHelpers] = useField('limit');
|
||||
const [verbosityField] = useField('verbosity');
|
||||
const [diffModeField, , diffModeHelpers] = useField('diff_mode');
|
||||
const [instanceGroupsField, , instanceGroupsHelpers] = useField(
|
||||
@@ -231,7 +230,7 @@ function JobTemplateForm({
|
||||
</FieldWithPrompt>
|
||||
<FieldWithPrompt
|
||||
fieldId="template-inventory"
|
||||
isRequired={!formikValues.ask_inventory_on_launch}
|
||||
isRequired={!askInventoryOnLaunchField.value}
|
||||
label={i18n._(t`Inventory`)}
|
||||
promptId="template-ask-inventory-on-launch"
|
||||
promptName="ask_inventory_on_launch"
|
||||
@@ -245,11 +244,11 @@ function JobTemplateForm({
|
||||
inventoryHelpers.setValue(value ? value.id : null);
|
||||
setInventory(value);
|
||||
}}
|
||||
required={!formikValues.ask_inventory_on_launch}
|
||||
required={!askInventoryOnLaunchField.value}
|
||||
touched={inventoryMeta.touched}
|
||||
error={inventoryMeta.error}
|
||||
/>
|
||||
{(inventoryMeta.touched || formikValues.ask_inventory_on_launch) &&
|
||||
{(inventoryMeta.touched || askInventoryOnLaunchField.value) &&
|
||||
inventoryMeta.error && (
|
||||
<div
|
||||
className="pf-c-form__helper-text pf-m-error"
|
||||
@@ -283,8 +282,8 @@ function JobTemplateForm({
|
||||
<TextInput
|
||||
id="template-scm-branch"
|
||||
{...scmField}
|
||||
onChange={(value, event) => {
|
||||
scmField.onChange(event);
|
||||
onChange={value => {
|
||||
scmHelpers.setValue(value);
|
||||
}}
|
||||
/>
|
||||
</FieldWithPrompt>
|
||||
@@ -383,8 +382,8 @@ function JobTemplateForm({
|
||||
id="template-limit"
|
||||
{...limitField}
|
||||
isValid={!limitMeta.touched || !limitMeta.error}
|
||||
onChange={(value, event) => {
|
||||
limitField.onChange(event);
|
||||
onChange={value => {
|
||||
limitHelpers.setValue(value);
|
||||
}}
|
||||
/>
|
||||
</FieldWithPrompt>
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('<JobTemplateForm />', () => {
|
||||
playbook: 'Baz',
|
||||
type: 'job_template',
|
||||
scm_branch: 'Foo',
|
||||
limit: '5000',
|
||||
summary_fields: {
|
||||
inventory: {
|
||||
id: 2,
|
||||
@@ -184,9 +185,10 @@ describe('<JobTemplateForm />', () => {
|
||||
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper.find('input#template-scm-branch').simulate('change', {
|
||||
target: { value: 'devel', name: 'scm_branch' },
|
||||
});
|
||||
wrapper.find('TextInputBase#template-scm-branch').prop('onChange')(
|
||||
'devel'
|
||||
);
|
||||
wrapper.find('TextInputBase#template-limit').prop('onChange')(1234567890);
|
||||
wrapper.find('AnsibleSelect[name="playbook"]').simulate('change', {
|
||||
target: { value: 'new baz type', name: 'playbook' },
|
||||
});
|
||||
@@ -221,6 +223,9 @@ describe('<JobTemplateForm />', () => {
|
||||
expect(wrapper.find('input#template-scm-branch').prop('value')).toEqual(
|
||||
'devel'
|
||||
);
|
||||
expect(wrapper.find('input#template-limit').prop('value')).toEqual(
|
||||
1234567890
|
||||
);
|
||||
expect(
|
||||
wrapper.find('AnsibleSelect[name="playbook"]').prop('value')
|
||||
).toEqual('new baz type');
|
||||
|
||||
@@ -30,6 +30,7 @@ async function loadLabelOptions(setLabels, onError) {
|
||||
}
|
||||
|
||||
function LabelSelect({ value, placeholder, onChange, onError, createText }) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { selections, onSelect, options, setOptions } = useSyncedSelectValue(
|
||||
value,
|
||||
onChange
|
||||
@@ -41,7 +42,10 @@ function LabelSelect({ value, placeholder, onChange, onError, createText }) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadLabelOptions(setOptions, onError);
|
||||
(async () => {
|
||||
await loadLabelOptions(setOptions, onError);
|
||||
setIsLoading(false);
|
||||
})();
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, []);
|
||||
|
||||
@@ -77,6 +81,7 @@ function LabelSelect({ value, placeholder, onChange, onError, createText }) {
|
||||
}
|
||||
return label;
|
||||
}}
|
||||
isDisabled={isLoading}
|
||||
selections={selections}
|
||||
isExpanded={isExpanded}
|
||||
ariaLabelledBy="label-select"
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { number, string, oneOfType } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import AnsibleSelect from '../../../components/AnsibleSelect';
|
||||
import { ProjectsAPI } from '../../../api';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
|
||||
function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) {
|
||||
const [options, setOptions] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const {
|
||||
result: options,
|
||||
request: fetchOptions,
|
||||
isLoading,
|
||||
error,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
if (!projectId) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await ProjectsAPI.readPlaybooks(projectId);
|
||||
const opts = (data || []).map(playbook => ({
|
||||
value: playbook,
|
||||
@@ -28,12 +31,21 @@ function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) {
|
||||
label: i18n._(t`Choose a playbook`),
|
||||
isDisabled: false,
|
||||
});
|
||||
setOptions(opts);
|
||||
} catch (contentError) {
|
||||
onError(contentError);
|
||||
return opts;
|
||||
}, [projectId, i18n]),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOptions();
|
||||
}, [fetchOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
onError(error);
|
||||
}
|
||||
})();
|
||||
}, [projectId, i18n, onError]);
|
||||
}, [error, onError]);
|
||||
|
||||
return (
|
||||
<AnsibleSelect
|
||||
id="template-playbook"
|
||||
@@ -41,6 +53,7 @@ function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) {
|
||||
isValid={isValid}
|
||||
{...field}
|
||||
onBlur={onBlur}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ function User({ i18n, setBreadcrumb }) {
|
||||
<UserOrganizations id={Number(match.params.id)} />
|
||||
</Route>
|
||||
<Route path="/users/:id/teams">
|
||||
<UserTeams id={Number(match.params.id)} />
|
||||
<UserTeams userId={Number(match.params.id)} />
|
||||
</Route>
|
||||
{user && (
|
||||
<Route path="/users/:id/access">
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams, useLocation } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
EmptyStateBody,
|
||||
EmptyStateIcon,
|
||||
Title,
|
||||
} from '@patternfly/react-core';
|
||||
import { CubesIcon } from '@patternfly/react-icons';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import { UsersAPI } from '../../../api';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
import PaginatedDataList, {
|
||||
ToolbarAddButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
import { UsersAPI, RolesAPI } from '../../../api';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import PaginatedDataList from '../../../components/PaginatedDataList';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
|
||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
||||
import UserAccessListItem from './UserAccessListItem';
|
||||
import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd';
|
||||
|
||||
const QS_CONFIG = getQSConfig('roles', {
|
||||
page: 1,
|
||||
@@ -22,7 +32,9 @@ const QS_CONFIG = getQSConfig('roles', {
|
||||
function UserAccessList({ i18n }) {
|
||||
const { id } = useParams();
|
||||
const { search } = useLocation();
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||
|
||||
const [roleToDisassociate, setRoleToDisassociate] = useState(null);
|
||||
const {
|
||||
isLoading,
|
||||
request: fetchRoles,
|
||||
@@ -52,9 +64,31 @@ function UserAccessList({ i18n }) {
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
}, [fetchRoles]);
|
||||
|
||||
const {
|
||||
isLoading: isDisassociateLoading,
|
||||
deleteItems: disassociateRole,
|
||||
deletionError: disassociationError,
|
||||
clearDeletionError: clearDisassociationError,
|
||||
} = useDeleteItems(
|
||||
useCallback(async () => {
|
||||
setRoleToDisassociate(null);
|
||||
await RolesAPI.disassociateUserRole(
|
||||
roleToDisassociate.id,
|
||||
parseInt(id, 10)
|
||||
);
|
||||
}, [roleToDisassociate, id]),
|
||||
{ qsConfig: QS_CONFIG, fetchItems: fetchRoles }
|
||||
);
|
||||
|
||||
const canAdd =
|
||||
options && Object.prototype.hasOwnProperty.call(options, 'POST');
|
||||
|
||||
const saveRoles = () => {
|
||||
setIsWizardOpen(false);
|
||||
fetchRoles();
|
||||
};
|
||||
|
||||
const detailUrl = role => {
|
||||
const { resource_id, resource_type } = role.summary_fields;
|
||||
|
||||
@@ -70,11 +104,27 @@ function UserAccessList({ i18n }) {
|
||||
}
|
||||
return `/${resource_type}s/${resource_id}/details`;
|
||||
};
|
||||
|
||||
const isSysAdmin = roles.some(role => role.name === 'System Administrator');
|
||||
if (isSysAdmin) {
|
||||
return (
|
||||
<EmptyState variant="full">
|
||||
<EmptyStateIcon icon={CubesIcon} />
|
||||
<Title headingLevel="h5" size="lg">
|
||||
{i18n._(t`System Administrator`)}
|
||||
</Title>
|
||||
<EmptyStateBody>
|
||||
{i18n._(
|
||||
t`System administrators have unrestricted access to all resources.`
|
||||
)}
|
||||
</EmptyStateBody>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<PaginatedDataList
|
||||
contentError={error}
|
||||
hasContentLoading={isLoading}
|
||||
hasContentLoading={isLoading || isDisassociateLoading}
|
||||
items={roles}
|
||||
itemCount={roleCount}
|
||||
pluralizedItemName={i18n._(t`User Roles`)}
|
||||
@@ -99,8 +149,10 @@ function UserAccessList({ i18n }) {
|
||||
value={role.name}
|
||||
role={role}
|
||||
detailUrl={detailUrl(role)}
|
||||
onSelect={() => {}}
|
||||
isSelected={false}
|
||||
onSelect={item => {
|
||||
setRoleToDisassociate(item);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -109,11 +161,79 @@ function UserAccessList({ i18n }) {
|
||||
{...props}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
...(canAdd ? [<ToolbarAddButton key="add" linkTo="/" />] : []),
|
||||
...(canAdd
|
||||
? [
|
||||
<Button
|
||||
key="add"
|
||||
aria-label={i18n._(t`Add resource roles`)}
|
||||
onClick={() => {
|
||||
setIsWizardOpen(true);
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>,
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isWizardOpen && (
|
||||
<UserAndTeamAccessAdd
|
||||
apiModel={UsersAPI}
|
||||
isOpen={isWizardOpen}
|
||||
onSave={saveRoles}
|
||||
onClose={() => setIsWizardOpen(false)}
|
||||
title={i18n._(t`Add user permissions`)}
|
||||
/>
|
||||
)}
|
||||
{roleToDisassociate && (
|
||||
<AlertModal
|
||||
aria-label={i18n._(t`Disassociate role`)}
|
||||
isOpen={roleToDisassociate}
|
||||
variant="error"
|
||||
title={i18n._(t`Disassociate role!`)}
|
||||
onClose={() => setRoleToDisassociate(null)}
|
||||
actions={[
|
||||
<Button
|
||||
key="disassociate"
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`confirm disassociate`)}
|
||||
onClick={() => disassociateRole()}
|
||||
>
|
||||
{i18n._(t`Disassociate`)}
|
||||
</Button>,
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
onClick={() => setRoleToDisassociate(null)}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div>
|
||||
{i18n._(
|
||||
t`This action will disassociate the following role from ${roleToDisassociate.summary_fields.resource_name}:`
|
||||
)}
|
||||
<br />
|
||||
<strong>{roleToDisassociate.name}</strong>
|
||||
</div>
|
||||
</AlertModal>
|
||||
)}
|
||||
{disassociationError && (
|
||||
<AlertModal
|
||||
isOpen={disassociationError}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={clearDisassociationError}
|
||||
>
|
||||
{i18n._(t`Failed to delete role.`)}
|
||||
<ErrorDetail error={disassociationError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default withI18n()(UserAccessList);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { UsersAPI } from '../../../api';
|
||||
import { UsersAPI, RolesAPI } from '../../../api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
@@ -10,11 +8,15 @@ import {
|
||||
import UserAccessList from './UserAccessList';
|
||||
|
||||
jest.mock('../../../api/models/Users');
|
||||
describe('<UserAccessList />', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
beforeEach(async () => {
|
||||
UsersAPI.readRoles.mockResolvedValue({
|
||||
jest.mock('../../../api/models/Roles');
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
id: 18,
|
||||
}),
|
||||
}));
|
||||
const roles = {
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
@@ -83,46 +85,37 @@ describe('<UserAccessList />', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
count: 4,
|
||||
count: 5,
|
||||
},
|
||||
});
|
||||
|
||||
UsersAPI.readRoleOptions.mockResolvedValue({
|
||||
};
|
||||
const options = {
|
||||
data: { actions: { POST: { id: 1, disassociate: true } } },
|
||||
});
|
||||
|
||||
history = createMemoryHistory({
|
||||
initialEntries: ['/users/18/access'],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route path="/users/:id/access">
|
||||
<UserAccessList />
|
||||
</Route>,
|
||||
{
|
||||
context: {
|
||||
router: {
|
||||
history,
|
||||
route: {
|
||||
location: history.location,
|
||||
match: { params: { id: 18 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
describe('<UserAccessList />', () => {
|
||||
let wrapper;
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
// wrapper.unmount();
|
||||
});
|
||||
test('should render properly', async () => {
|
||||
UsersAPI.readRoles.mockResolvedValue(roles);
|
||||
UsersAPI.readRoleOptions.mockResolvedValue(options);
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<UserAccessList />);
|
||||
});
|
||||
|
||||
expect(wrapper.find('UserAccessList').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should create proper detailUrl', async () => {
|
||||
UsersAPI.readRoles.mockResolvedValue(roles);
|
||||
UsersAPI.readRoleOptions.mockResolvedValue(options);
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<UserAccessList />);
|
||||
});
|
||||
|
||||
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||
|
||||
expect(wrapper.find(`Link#userRole-2`).prop('to')).toBe(
|
||||
@@ -141,4 +134,195 @@ describe('<UserAccessList />', () => {
|
||||
'/inventories/smart_inventory/77/details'
|
||||
);
|
||||
});
|
||||
test('should not render add button', async () => {
|
||||
UsersAPI.readRoleOptions.mockResolvedValueOnce({
|
||||
data: {},
|
||||
});
|
||||
|
||||
UsersAPI.readRoles.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Admin',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/257/',
|
||||
summary_fields: {
|
||||
resource_name: 'template delete project',
|
||||
resource_id: 15,
|
||||
resource_type: 'job_template',
|
||||
resource_type_display_name: 'Job Template',
|
||||
user_capabilities: { unattach: true },
|
||||
object_roles: {
|
||||
admin_role: {
|
||||
description: 'Can manage all aspects of the job template',
|
||||
name: 'Admin',
|
||||
id: 164,
|
||||
},
|
||||
execute_role: {
|
||||
description: 'May run the job template',
|
||||
name: 'Execute',
|
||||
id: 165,
|
||||
},
|
||||
read_role: {
|
||||
description: 'May view settings for the job template',
|
||||
name: 'Read',
|
||||
id: 166,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<UserAccessList />);
|
||||
});
|
||||
|
||||
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||
expect(wrapper.find('Button[aria-label="Add resource roles"]').length).toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
test('should open and close wizard', async () => {
|
||||
UsersAPI.readRoles.mockResolvedValue(roles);
|
||||
UsersAPI.readRoleOptions.mockResolvedValue(options);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<UserAccessList />);
|
||||
});
|
||||
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Add resource roles"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('PFWizard').length).toBe(1);
|
||||
await act(async () =>
|
||||
wrapper.find("Button[aria-label='Close']").prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('PFWizard').length).toBe(0);
|
||||
});
|
||||
test('should render disassociate modal', async () => {
|
||||
UsersAPI.readRoles.mockResolvedValue(roles);
|
||||
UsersAPI.readRoleOptions.mockResolvedValue(options);
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<UserAccessList />);
|
||||
});
|
||||
|
||||
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({
|
||||
id: 4,
|
||||
name: 'Execute',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/258/',
|
||||
summary_fields: {
|
||||
resource_name: 'Credential Bar',
|
||||
resource_id: 75,
|
||||
resource_type: 'credential',
|
||||
resource_type_display_name: 'Credential',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('AlertModal[aria-label="Disassociate role"]').length
|
||||
).toBe(1);
|
||||
await act(async () =>
|
||||
wrapper
|
||||
.find('button[aria-label="confirm disassociate"]')
|
||||
.prop('onClick')()
|
||||
);
|
||||
expect(RolesAPI.disassociateUserRole).toBeCalledWith(4, 18);
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('AlertModal[aria-label="Disassociate role"]').length
|
||||
).toBe(0);
|
||||
});
|
||||
test('should throw disassociation error', async () => {
|
||||
UsersAPI.readRoles.mockResolvedValue(roles);
|
||||
RolesAPI.disassociateUserRole.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'post',
|
||||
url: '/api/v2/roles/18/roles',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
status: 403,
|
||||
},
|
||||
})
|
||||
);
|
||||
UsersAPI.readRoleOptions.mockResolvedValue(options);
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<UserAccessList />);
|
||||
});
|
||||
|
||||
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({
|
||||
id: 4,
|
||||
name: 'Execute',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/258/',
|
||||
summary_fields: {
|
||||
resource_name: 'Credential Bar',
|
||||
resource_id: 75,
|
||||
resource_type: 'credential',
|
||||
resource_type_display_name: 'Credential',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('AlertModal[aria-label="Disassociate role"]').length
|
||||
).toBe(1);
|
||||
await act(async () =>
|
||||
wrapper
|
||||
.find('button[aria-label="confirm disassociate"]')
|
||||
.prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AlertModal[title="Error!"]').length).toBe(1);
|
||||
});
|
||||
test('user with sys admin privilege should show empty state', async () => {
|
||||
UsersAPI.readRoles.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'System Administrator',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/257/',
|
||||
summary_fields: {
|
||||
resource_name: 'template delete project',
|
||||
resource_id: 15,
|
||||
resource_type: 'job_template',
|
||||
resource_type_display_name: 'Job Template',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
},
|
||||
});
|
||||
UsersAPI.readRoleOptions.mockResolvedValue(options);
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<UserAccessList />);
|
||||
});
|
||||
|
||||
waitForElement(
|
||||
wrapper,
|
||||
'EmptyState[title="System Administrator"]',
|
||||
el => el.length === 1
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user