mirror of
https://github.com/ansible/awx.git
synced 2026-01-20 14:11:24 -03:30
Merge branch 'devel' into devel
This commit is contained in:
commit
0bfcacfcf4
@ -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
|
||||
|
||||
@ -237,21 +246,21 @@ About.defaultProps = {
|
||||
### Naming Functions
|
||||
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 |
|
||||
|`show<x>`| Use for methods that always set a value to show or add an element |
|
||||
|`hide<x>`| Use for methods that always set a value to hide or remove an element |
|
||||
|`create<x>`| Use for methods that make API `POST` requests |
|
||||
|`read<x>`| Use for methods that make API `GET` requests |
|
||||
|`update<x>`| Use for methods that make API `PATCH` requests |
|
||||
|`destroy<x>`| Use for methods that make API `DESTROY` requests |
|
||||
|`replace<x>`| Use for methods that make API `PUT` requests |
|
||||
|`disassociate<x>`| Use for methods that pass `{ disassociate: true }` as a data param to an endpoint |
|
||||
|`associate<x>`| Use for methods that pass a resource id as a data param to an endpoint |
|
||||
|`can<x>`| Use for props dealing with RBAC to denote whether a user has access to something |
|
||||
| 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 |
|
||||
| `show<x>` | Use for methods that always set a value to show or add an element |
|
||||
| `hide<x>` | Use for methods that always set a value to hide or remove an element |
|
||||
| `create<x>` | Use for methods that make API `POST` requests |
|
||||
| `read<x>` | Use for methods that make API `GET` requests |
|
||||
| `update<x>` | Use for methods that make API `PATCH` requests |
|
||||
| `destroy<x>` | Use for methods that make API `DESTROY` requests |
|
||||
| `replace<x>` | Use for methods that make API `PUT` requests |
|
||||
| `disassociate<x>` | Use for methods that pass `{ disassociate: true }` as a data param to an endpoint |
|
||||
| `associate<x>` | Use for methods that pass a resource id as a data param to an endpoint |
|
||||
| `can<x>` | Use for props dealing with RBAC to denote whether a user has access to something |
|
||||
|
||||
### Default State Initialization
|
||||
When declaring empty initial states, prefer the following instead of leaving them undefined:
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
const ProtectedRoute = ({ children, ...rest }) =>
|
||||
isAuthenticated(document.cookie) ? (
|
||||
<Route {...rest}>{children}</Route>
|
||||
) : (
|
||||
<Redirect to="/login" />
|
||||
);
|
||||
|
||||
class App extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
function App() {
|
||||
const catalogs = { en, ja };
|
||||
const language = getLanguageWithoutRegionCode(navigator);
|
||||
const match = useRouteMatch();
|
||||
const { hash, search, pathname } = useLocation();
|
||||
|
||||
// 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 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<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,124 +12,94 @@ import CheckboxListItem from '../CheckboxListItem';
|
||||
import SelectedList from '../SelectedList';
|
||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||
|
||||
class SelectResourceStep extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const QS_Config = sortColumns => {
|
||||
return getQSConfig('resource', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: `${
|
||||
sortColumns.filter(col => col.key === 'name').length ? 'name' : 'username'
|
||||
}`,
|
||||
});
|
||||
};
|
||||
function SelectResourceStep({
|
||||
searchColumns,
|
||||
sortColumns,
|
||||
displayKey,
|
||||
onRowClick,
|
||||
selectedLabel,
|
||||
selectedResourceRows,
|
||||
fetchItems,
|
||||
i18n,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
|
||||
this.state = {
|
||||
isInitialized: false,
|
||||
count: null,
|
||||
error: false,
|
||||
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: [],
|
||||
};
|
||||
|
||||
this.qsConfig = getQSConfig('resource', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: `${
|
||||
props.sortColumns.filter(col => col.key === 'name').length
|
||||
? 'name'
|
||||
: 'username'
|
||||
}`,
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.readResourceList();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { location } = this.props;
|
||||
if (location !== prevProps.location) {
|
||||
this.readResourceList();
|
||||
itemCount: 0,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async readResourceList() {
|
||||
const { onSearch, location } = this.props;
|
||||
const queryParams = parseQueryString(this.qsConfig, location.search);
|
||||
useEffect(() => {
|
||||
readResourceList();
|
||||
}, [readResourceList]);
|
||||
|
||||
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 {
|
||||
searchColumns,
|
||||
sortColumns,
|
||||
displayKey,
|
||||
onRowClick,
|
||||
selectedLabel,
|
||||
selectedResourceRows,
|
||||
i18n,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{isInitialized && (
|
||||
<Fragment>
|
||||
<div>
|
||||
{i18n._(
|
||||
t`Choose the resources that will be receiving new roles. You'll be able to select the roles to apply in the next step. Note that the resources chosen here will receive all roles chosen in the next step.`
|
||||
)}
|
||||
</div>
|
||||
{selectedResourceRows.length > 0 && (
|
||||
<SelectedList
|
||||
displayKey={displayKey}
|
||||
label={selectedLabel}
|
||||
onRemove={onRowClick}
|
||||
selected={selectedResourceRows}
|
||||
/>
|
||||
)}
|
||||
<PaginatedDataList
|
||||
hasContentLoading={isLoading}
|
||||
items={resources}
|
||||
itemCount={count}
|
||||
qsConfig={this.qsConfig}
|
||||
onRowClick={onRowClick}
|
||||
toolbarSearchColumns={searchColumns}
|
||||
toolbarSortColumns={sortColumns}
|
||||
renderItem={item => (
|
||||
<CheckboxListItem
|
||||
isSelected={selectedResourceRows.some(i => i.id === item.id)}
|
||||
itemId={item.id}
|
||||
key={item.id}
|
||||
name={item[displayKey]}
|
||||
label={item[displayKey]}
|
||||
onSelect={() => onRowClick(item)}
|
||||
onDeselect={() => onRowClick(item)}
|
||||
/>
|
||||
)}
|
||||
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
|
||||
showPageSizeOptions={false}
|
||||
/>
|
||||
</Fragment>
|
||||
return (
|
||||
<Fragment>
|
||||
<div>
|
||||
{i18n._(
|
||||
t`Choose the resources that will be receiving new roles. You'll be able to select the roles to apply in the next step. Note that the resources chosen here will receive all roles chosen in the next step.`
|
||||
)}
|
||||
{error ? <div>error</div> : ''}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
{selectedResourceRows.length > 0 && (
|
||||
<SelectedList
|
||||
displayKey={displayKey}
|
||||
label={selectedLabel}
|
||||
onRemove={onRowClick}
|
||||
selected={selectedResourceRows}
|
||||
/>
|
||||
)}
|
||||
<PaginatedDataList
|
||||
hasContentLoading={isLoading}
|
||||
contentError={error}
|
||||
items={resources}
|
||||
itemCount={itemCount}
|
||||
qsConfig={QS_Config(sortColumns)}
|
||||
onRowClick={onRowClick}
|
||||
toolbarSearchColumns={searchColumns}
|
||||
toolbarSortColumns={sortColumns}
|
||||
renderItem={item => (
|
||||
<CheckboxListItem
|
||||
isSelected={selectedResourceRows.some(i => i.id === item.id)}
|
||||
itemId={item.id}
|
||||
key={item.id}
|
||||
name={item[displayKey]}
|
||||
label={item[displayKey]}
|
||||
onSelect={() => onRowClick(item)}
|
||||
onDeselect={() => onRowClick(item)}
|
||||
/>
|
||||
)}
|
||||
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
|
||||
showPageSizeOptions={false}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
SelectResourceStep.propTypes = {
|
||||
@ -135,7 +107,7 @@ SelectResourceStep.propTypes = {
|
||||
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(
|
||||
<SelectResourceStep
|
||||
searchColumns={searchColumns}
|
||||
sortColumns={sortColumns}
|
||||
displayKey="username"
|
||||
onRowClick={() => {}}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
);
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SelectResourceStep
|
||||
searchColumns={searchColumns}
|
||||
sortColumns={sortColumns}
|
||||
displayKey="username"
|
||||
onRowClick={() => {}}
|
||||
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(
|
||||
<SelectResourceStep
|
||||
searchColumns={searchColumns}
|
||||
sortColumns={sortColumns}
|
||||
displayKey="username"
|
||||
onRowClick={handleRowClick}
|
||||
onSearch={() => ({ data })}
|
||||
selectedResourceRows={[]}
|
||||
/>
|
||||
);
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SelectResourceStep
|
||||
searchColumns={searchColumns}
|
||||
sortColumns={sortColumns}
|
||||
displayKey="username"
|
||||
onRowClick={handleRowClick}
|
||||
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;
|
||||
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)
|
||||
);
|
||||
} else {
|
||||
extraVars = values.extra_vars || '---';
|
||||
|
||||
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);
|
||||
overrides.extra_vars = yaml.safeDump(
|
||||
mergeExtraVars(initialExtraVars, masked)
|
||||
);
|
||||
} else {
|
||||
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);
|
||||
|
||||
const {
|
||||
result: credentialTypes,
|
||||
request: fetchTypes,
|
||||
error: typesError,
|
||||
isLoading: isTypesLoading,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const types = await CredentialTypesAPI.loadAllTypes();
|
||||
const match = types.find(type => type.kind === 'ssh') || types[0];
|
||||
setSelectedType(match);
|
||||
return types;
|
||||
}, []),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const types = await CredentialTypesAPI.loadAllTypes();
|
||||
setCredentialTypes(types);
|
||||
const match = types.find(type => type.kind === 'ssh') || types[0];
|
||||
setSelectedType(match);
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
}
|
||||
})();
|
||||
}, [onError]);
|
||||
fetchTypes();
|
||||
}, [fetchTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
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);
|
||||
}
|
||||
})();
|
||||
}, [selectedType, history.location.search, onError]);
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
const { results, count } = await loadCredentials(params, selectedType.id);
|
||||
return {
|
||||
credentials: results,
|
||||
credentialsCount: count,
|
||||
};
|
||||
}, [selectedType, history.location]),
|
||||
{
|
||||
credentials: [],
|
||||
credentialsCount: 0,
|
||||
}
|
||||
);
|
||||
|
||||
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}`;
|
||||
document.title = `Ansible ${BrandName}`;
|
||||
|
||||
const removeTrailingSlash = (
|
||||
<Route
|
||||
exact
|
||||
strict
|
||||
path="/*/"
|
||||
render={({
|
||||
history: {
|
||||
location: { pathname, search, hash },
|
||||
},
|
||||
}) => <Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />}
|
||||
/>
|
||||
);
|
||||
|
||||
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);
|
||||
ReactDOM.render(
|
||||
<App />,
|
||||
document.getElementById('app') || document.createElement('div')
|
||||
);
|
||||
|
||||
@ -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 {
|
||||
data: { results: loadedCredentialTypes },
|
||||
} = await CredentialTypesAPI.read({
|
||||
or__namespace: ['gce', 'scm', 'ssh'],
|
||||
});
|
||||
const [
|
||||
{
|
||||
data: { results: loadedCredentialTypes },
|
||||
},
|
||||
{
|
||||
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"
|
||||
rows={6}
|
||||
/>
|
||||
>
|
||||
<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 (
|
||||
<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 (
|
||||
<Card>
|
||||
<>
|
||||
<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,119 +8,114 @@ import {
|
||||
import TeamAccessList from './TeamAccessList';
|
||||
|
||||
jest.mock('../../../api/models/Teams');
|
||||
jest.mock('../../../api/models/Roles');
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
id: 18,
|
||||
}),
|
||||
}));
|
||||
|
||||
const roles = {
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Admin Read Only',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/257/',
|
||||
summary_fields: {
|
||||
resource_name: 'template delete project',
|
||||
resource_id: 16,
|
||||
resource_type: 'workflow_job_template',
|
||||
resource_type_display_name: 'Job Template',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Update',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/259/',
|
||||
summary_fields: {
|
||||
resource_name: 'Inventory Foo',
|
||||
resource_id: 76,
|
||||
resource_type: 'inventory',
|
||||
resource_type_display_name: 'Inventory',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Admin',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/260/',
|
||||
summary_fields: {
|
||||
resource_name: 'Smart Inventory Foo',
|
||||
resource_id: 77,
|
||||
resource_type: 'smart_inventory',
|
||||
resource_type_display_name: 'Inventory',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
count: 5,
|
||||
},
|
||||
};
|
||||
const options = {
|
||||
data: { actions: { POST: { id: 1, disassociate: true } } },
|
||||
};
|
||||
describe('<TeamAccessList />', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
beforeEach(async () => {
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Admin',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/257/',
|
||||
summary_fields: {
|
||||
resource_name: 'template delete project',
|
||||
resource_id: 16,
|
||||
resource_type: 'workflow_job_template',
|
||||
resource_type_display_name: 'Job Template',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Update',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/259/',
|
||||
summary_fields: {
|
||||
resource_name: 'Inventory Foo',
|
||||
resource_id: 76,
|
||||
resource_type: 'inventory',
|
||||
resource_type_display_name: 'Inventory',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Admin',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/260/',
|
||||
summary_fields: {
|
||||
resource_name: 'Smart Inventory Foo',
|
||||
resource_id: 77,
|
||||
resource_type: 'smart_inventory',
|
||||
resource_type_display_name: 'Inventory',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
count: 4,
|
||||
},
|
||||
});
|
||||
|
||||
TeamsAPI.readRoleOptions.mockResolvedValue({
|
||||
data: { actions: { POST: { id: 1, disassociate: true } } },
|
||||
});
|
||||
|
||||
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>
|
||||
{role.name}
|
||||
</>
|
||||
<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,39 +1,51 @@
|
||||
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([]);
|
||||
const {
|
||||
result: options,
|
||||
request: fetchOptions,
|
||||
isLoading,
|
||||
error,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
if (!projectId) {
|
||||
return [];
|
||||
}
|
||||
const { data } = await ProjectsAPI.readPlaybooks(projectId);
|
||||
const opts = (data || []).map(playbook => ({
|
||||
value: playbook,
|
||||
key: playbook,
|
||||
label: playbook,
|
||||
isDisabled: false,
|
||||
}));
|
||||
|
||||
opts.unshift({
|
||||
value: '',
|
||||
key: '',
|
||||
label: i18n._(t`Choose a playbook`),
|
||||
isDisabled: false,
|
||||
});
|
||||
return opts;
|
||||
}, [projectId, i18n]),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await ProjectsAPI.readPlaybooks(projectId);
|
||||
const opts = (data || []).map(playbook => ({
|
||||
value: playbook,
|
||||
key: playbook,
|
||||
label: playbook,
|
||||
isDisabled: false,
|
||||
}));
|
||||
fetchOptions();
|
||||
}, [fetchOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
onError(error);
|
||||
}
|
||||
}, [error, onError]);
|
||||
|
||||
opts.unshift({
|
||||
value: '',
|
||||
key: '',
|
||||
label: i18n._(t`Choose a playbook`),
|
||||
isDisabled: false,
|
||||
});
|
||||
setOptions(opts);
|
||||
} catch (contentError) {
|
||||
onError(contentError);
|
||||
}
|
||||
})();
|
||||
}, [projectId, i18n, 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,50 +104,136 @@ 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}
|
||||
items={roles}
|
||||
itemCount={roleCount}
|
||||
pluralizedItemName={i18n._(t`User Roles`)}
|
||||
qsConfig={QS_CONFIG}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Role`),
|
||||
key: 'role_field',
|
||||
isDefault: true,
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'id',
|
||||
},
|
||||
]}
|
||||
renderItem={role => {
|
||||
return (
|
||||
<UserAccessListItem
|
||||
key={role.id}
|
||||
value={role.name}
|
||||
role={role}
|
||||
detailUrl={detailUrl(role)}
|
||||
onSelect={() => {}}
|
||||
isSelected={false}
|
||||
<>
|
||||
<PaginatedDataList
|
||||
contentError={error}
|
||||
hasContentLoading={isLoading || isDisassociateLoading}
|
||||
items={roles}
|
||||
itemCount={roleCount}
|
||||
pluralizedItemName={i18n._(t`User Roles`)}
|
||||
qsConfig={QS_CONFIG}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Role`),
|
||||
key: 'role_field',
|
||||
isDefault: true,
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'id',
|
||||
},
|
||||
]}
|
||||
renderItem={role => {
|
||||
return (
|
||||
<UserAccessListItem
|
||||
key={role.id}
|
||||
value={role.name}
|
||||
role={role}
|
||||
detailUrl={detailUrl(role)}
|
||||
isSelected={false}
|
||||
onSelect={item => {
|
||||
setRoleToDisassociate(item);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
renderToolbar={props => (
|
||||
<DatalistToolbar
|
||||
{...props}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
...(canAdd
|
||||
? [
|
||||
<Button
|
||||
key="add"
|
||||
aria-label={i18n._(t`Add resource roles`)}
|
||||
onClick={() => {
|
||||
setIsWizardOpen(true);
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>,
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
renderToolbar={props => (
|
||||
<DatalistToolbar
|
||||
{...props}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
...(canAdd ? [<ToolbarAddButton key="add" linkTo="/" />] : []),
|
||||
]}
|
||||
)}
|
||||
/>
|
||||
{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,119 +8,114 @@ import {
|
||||
import UserAccessList from './UserAccessList';
|
||||
|
||||
jest.mock('../../../api/models/Users');
|
||||
jest.mock('../../../api/models/Roles');
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
id: 18,
|
||||
}),
|
||||
}));
|
||||
const roles = {
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Admin',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/257/',
|
||||
summary_fields: {
|
||||
resource_name: 'template delete project',
|
||||
resource_id: 16,
|
||||
resource_type: 'workflow_job_template',
|
||||
resource_type_display_name: 'Job Template',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Update',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/259/',
|
||||
summary_fields: {
|
||||
resource_name: 'Inventory Foo',
|
||||
resource_id: 76,
|
||||
resource_type: 'inventory',
|
||||
resource_type_display_name: 'Inventory',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Admin',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/260/',
|
||||
summary_fields: {
|
||||
resource_name: 'Smart Inventory Foo',
|
||||
resource_id: 77,
|
||||
resource_type: 'smart_inventory',
|
||||
resource_type_display_name: 'Inventory',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
count: 5,
|
||||
},
|
||||
};
|
||||
const options = {
|
||||
data: { actions: { POST: { id: 1, disassociate: true } } },
|
||||
};
|
||||
describe('<UserAccessList />', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
beforeEach(async () => {
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Admin',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/257/',
|
||||
summary_fields: {
|
||||
resource_name: 'template delete project',
|
||||
resource_id: 16,
|
||||
resource_type: 'workflow_job_template',
|
||||
resource_type_display_name: 'Job Template',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Update',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/259/',
|
||||
summary_fields: {
|
||||
resource_name: 'Inventory Foo',
|
||||
resource_id: 76,
|
||||
resource_type: 'inventory',
|
||||
resource_type_display_name: 'Inventory',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Admin',
|
||||
type: 'role',
|
||||
url: '/api/v2/roles/260/',
|
||||
summary_fields: {
|
||||
resource_name: 'Smart Inventory Foo',
|
||||
resource_id: 77,
|
||||
resource_type: 'smart_inventory',
|
||||
resource_type_display_name: 'Inventory',
|
||||
user_capabilities: { unattach: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
count: 4,
|
||||
},
|
||||
});
|
||||
|
||||
UsersAPI.readRoleOptions.mockResolvedValue({
|
||||
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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
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
Loading…
x
Reference in New Issue
Block a user