mirror of
https://github.com/ansible/awx.git
synced 2026-05-19 23:07:42 -02:30
Merge branch 'devel' into devel
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<version>`.
|
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)
|
## 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)
|
- 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.
|
- 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)
|
- 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 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 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 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 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 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 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 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 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)
|
## 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
|
### 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
|
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*
|
*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*
|
*dockerhub_version*
|
||||||
|
|
||||||
|
|||||||
@@ -495,7 +495,7 @@ class NotificationAttachMixin(BaseAccess):
|
|||||||
# due to this special case, we use symmetrical logic with attach permission
|
# due to this special case, we use symmetrical logic with attach permission
|
||||||
return self._can_attach(notification_template=sub_obj, resource_obj=obj)
|
return self._can_attach(notification_template=sub_obj, resource_obj=obj)
|
||||||
return super(NotificationAttachMixin, self).can_unattach(
|
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
|
from .plugin import CredentialPlugin, CertFiles
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
from urllib.parse import urljoin, quote_plus
|
from urllib.parse import urljoin, quote
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
import requests
|
import requests
|
||||||
@@ -50,9 +50,9 @@ conjur_inputs = {
|
|||||||
def conjur_backend(**kwargs):
|
def conjur_backend(**kwargs):
|
||||||
url = kwargs['url']
|
url = kwargs['url']
|
||||||
api_key = kwargs['api_key']
|
api_key = kwargs['api_key']
|
||||||
account = quote_plus(kwargs['account'])
|
account = quote(kwargs['account'], safe='')
|
||||||
username = quote_plus(kwargs['username'])
|
username = quote(kwargs['username'], safe='')
|
||||||
secret_path = quote_plus(kwargs['secret_path'])
|
secret_path = quote(kwargs['secret_path'], safe='')
|
||||||
version = kwargs.get('secret_version')
|
version = kwargs.get('secret_version')
|
||||||
cacert = kwargs.get('cacert', None)
|
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
|
## Table of contents
|
||||||
|
|
||||||
* [Things to know prior to submitting code](#things-to-know-prior-to-submitting-code)
|
- [Ansible AWX UI With PatternFly](#ansible-awx-ui-with-patternfly)
|
||||||
* [Setting up your development environment](#setting-up-your-development-environment)
|
- [Table of contents](#table-of-contents)
|
||||||
* [Prerequisites](#prerequisites)
|
- [Things to know prior to submitting code](#things-to-know-prior-to-submitting-code)
|
||||||
* [Node and npm](#node-and-npm)
|
- [Setting up your development environment](#setting-up-your-development-environment)
|
||||||
* [Build the user interface](#build-the-user-interface)
|
- [Prerequisites](#prerequisites)
|
||||||
* [Accessing the AWX web interface](#accessing-the-awx-web-interface)
|
- [Node and npm](#node-and-npm)
|
||||||
* [AWX REST API Interaction](#awx-rest-api-interaction)
|
- [Build the User Interface](#build-the-user-interface)
|
||||||
* [Handling API Errors](#handling-api-errors)
|
- [Accessing the AWX web interface](#accessing-the-awx-web-interface)
|
||||||
* [Forms](#forms)
|
- [AWX REST API Interaction](#awx-rest-api-interaction)
|
||||||
* [Working with React](#working-with-react)
|
- [Handling API Errors](#handling-api-errors)
|
||||||
* [App structure](#app-structure)
|
- [Forms](#forms)
|
||||||
* [Naming files](#naming-files)
|
- [Working with React](#working-with-react)
|
||||||
* [Class constructors vs Class properties](#class-constructors-vs-class-properties)
|
- [App structure](#app-structure)
|
||||||
* [Binding](#binding)
|
- [Patterns](#patterns)
|
||||||
* [Typechecking with PropTypes](#typechecking-with-proptypes)
|
- [Bootstrapping the application (root src/ files)](#bootstrapping-the-application-root-src-files)
|
||||||
* [Naming Functions](#naming-functions)
|
- [Naming files](#naming-files)
|
||||||
* [Default State Initialization](#default-state-initialization)
|
- [Naming components that use the context api](#naming-components-that-use-the-context-api)
|
||||||
* [Internationalization](#internationalization)
|
- [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
|
## 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
|
- functions should adopt camelCase
|
||||||
- constructors/classes should adopt PascalCase
|
- constructors/classes should adopt PascalCase
|
||||||
- constants to be exported should adopt UPPERCASE
|
- 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
|
## Setting up your development environment
|
||||||
|
|
||||||
@@ -238,20 +247,20 @@ About.defaultProps = {
|
|||||||
Here are the guidelines for how to name functions.
|
Here are the guidelines for how to name functions.
|
||||||
|
|
||||||
| Naming Convention | Description |
|
| Naming Convention | Description |
|
||||||
|----------|-------------|
|
| ----------------- | --------------------------------------------------------------------------------- |
|
||||||
|`handle<x>`| Use for methods that process events |
|
| `handle<x>` | Use for methods that process events |
|
||||||
|`on<x>`| Use for component prop names |
|
| `on<x>` | Use for component prop names |
|
||||||
|`toggle<x>`| Use for methods that flip one value to the opposite value |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `create<x>` | Use for methods that make API `POST` requests |
|
||||||
|`read<x>`| Use for methods that make API `GET` requests |
|
| `read<x>` | Use for methods that make API `GET` requests |
|
||||||
|`update<x>`| Use for methods that make API `PATCH` requests |
|
| `update<x>` | Use for methods that make API `PATCH` requests |
|
||||||
|`destroy<x>`| Use for methods that make API `DESTROY` requests |
|
| `destroy<x>` | Use for methods that make API `DESTROY` requests |
|
||||||
|`replace<x>`| Use for methods that make API `PUT` 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 |
|
| `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 |
|
| `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 |
|
| `can<x>` | Use for props dealing with RBAC to denote whether a user has access to something |
|
||||||
|
|
||||||
### Default State Initialization
|
### Default State Initialization
|
||||||
When declaring empty initial states, prefer the following instead of leaving them undefined:
|
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.
|
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).
|
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.
|
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="
|
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
|
||||||
},
|
},
|
||||||
"eventemitter3": {
|
"eventemitter3": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.4.tgz",
|
||||||
"integrity": "sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg=="
|
"integrity": "sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ=="
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@@ -7996,9 +7996,9 @@
|
|||||||
"integrity": "sha1-ksnBN0w1CF912zWexWzCV8u5P6Q="
|
"integrity": "sha1-ksnBN0w1CF912zWexWzCV8u5P6Q="
|
||||||
},
|
},
|
||||||
"http-proxy": {
|
"http-proxy": {
|
||||||
"version": "1.18.0",
|
"version": "1.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
|
||||||
"integrity": "sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==",
|
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"eventemitter3": "^4.0.0",
|
"eventemitter3": "^4.0.0",
|
||||||
"follow-redirects": "^1.0.0",
|
"follow-redirects": "^1.0.0",
|
||||||
|
|||||||
@@ -1,221 +1,82 @@
|
|||||||
import React, { Component, Fragment } from 'react';
|
import React from 'react';
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
import { global_breakpoint_md } from '@patternfly/react-tokens';
|
|
||||||
import {
|
import {
|
||||||
Nav,
|
useRouteMatch,
|
||||||
NavList,
|
useLocation,
|
||||||
Page,
|
HashRouter,
|
||||||
PageHeader as PFPageHeader,
|
Route,
|
||||||
PageSidebar,
|
Switch,
|
||||||
} from '@patternfly/react-core';
|
Redirect,
|
||||||
import styled from 'styled-components';
|
} from 'react-router-dom';
|
||||||
import { t } from '@lingui/macro';
|
import { I18n, I18nProvider } from '@lingui/react';
|
||||||
import { withI18n } from '@lingui/react';
|
|
||||||
|
|
||||||
import { ConfigAPI, MeAPI, RootAPI } from './api';
|
import AppContainer from './components/AppContainer';
|
||||||
import About from './components/About';
|
import Background from './components/Background';
|
||||||
import AlertModal from './components/AlertModal';
|
import NotFound from './screens/NotFound';
|
||||||
import NavExpandableGroup from './components/NavExpandableGroup';
|
import Login from './screens/Login';
|
||||||
import BrandLogo from './components/BrandLogo';
|
|
||||||
import PageHeaderToolbar from './components/PageHeaderToolbar';
|
|
||||||
import ErrorDetail from './components/ErrorDetail';
|
|
||||||
import { ConfigProvider } from './contexts/Config';
|
|
||||||
|
|
||||||
const PageHeader = styled(PFPageHeader)`
|
import ja from './locales/ja/messages';
|
||||||
& .pf-c-page__header-brand-link {
|
import en from './locales/en/messages';
|
||||||
color: inherit;
|
import { isAuthenticated } from './util/auth';
|
||||||
|
import { getLanguageWithoutRegionCode } from './util/language';
|
||||||
|
|
||||||
&:hover {
|
import getRouteConfig from './routeConfig';
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
& svg {
|
const ProtectedRoute = ({ children, ...rest }) =>
|
||||||
height: 76px;
|
isAuthenticated(document.cookie) ? (
|
||||||
}
|
<Route {...rest}>{children}</Route>
|
||||||
}
|
) : (
|
||||||
`;
|
<Redirect to="/login" />
|
||||||
|
|
||||||
class App extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
// initialize with a closed navbar if window size is small
|
|
||||||
const isNavOpen =
|
|
||||||
typeof window !== 'undefined' &&
|
|
||||||
window.innerWidth >= parseInt(global_breakpoint_md.value, 10);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
ansible_version: null,
|
|
||||||
custom_virtualenvs: null,
|
|
||||||
me: null,
|
|
||||||
version: null,
|
|
||||||
isAboutModalOpen: false,
|
|
||||||
isNavOpen,
|
|
||||||
configError: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleLogout = this.handleLogout.bind(this);
|
|
||||||
this.handleAboutClose = this.handleAboutClose.bind(this);
|
|
||||||
this.handleAboutOpen = this.handleAboutOpen.bind(this);
|
|
||||||
this.handleNavToggle = this.handleNavToggle.bind(this);
|
|
||||||
this.handleConfigErrorClose = this.handleConfigErrorClose.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
async componentDidMount() {
|
|
||||||
await this.loadConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line class-methods-use-this
|
|
||||||
async handleLogout() {
|
|
||||||
const { history } = this.props;
|
|
||||||
await RootAPI.logout();
|
|
||||||
history.replace('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAboutOpen() {
|
|
||||||
this.setState({ isAboutModalOpen: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAboutClose() {
|
|
||||||
this.setState({ isAboutModalOpen: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleNavToggle() {
|
|
||||||
this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen }));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleConfigErrorClose() {
|
|
||||||
this.setState({
|
|
||||||
configError: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadConfig() {
|
|
||||||
try {
|
|
||||||
const [configRes, meRes] = await Promise.all([
|
|
||||||
ConfigAPI.read(),
|
|
||||||
MeAPI.read(),
|
|
||||||
]);
|
|
||||||
const {
|
|
||||||
data: {
|
|
||||||
ansible_version,
|
|
||||||
custom_virtualenvs,
|
|
||||||
project_base_dir,
|
|
||||||
project_local_paths,
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
} = configRes;
|
|
||||||
const {
|
|
||||||
data: {
|
|
||||||
results: [me],
|
|
||||||
},
|
|
||||||
} = meRes;
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
ansible_version,
|
|
||||||
custom_virtualenvs,
|
|
||||||
project_base_dir,
|
|
||||||
project_local_paths,
|
|
||||||
version,
|
|
||||||
me,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.setState({ configError: err });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
ansible_version,
|
|
||||||
custom_virtualenvs,
|
|
||||||
project_base_dir,
|
|
||||||
project_local_paths,
|
|
||||||
isAboutModalOpen,
|
|
||||||
isNavOpen,
|
|
||||||
me,
|
|
||||||
version,
|
|
||||||
configError,
|
|
||||||
} = this.state;
|
|
||||||
const {
|
|
||||||
i18n,
|
|
||||||
render = () => {},
|
|
||||||
routeGroups = [],
|
|
||||||
navLabel = '',
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const header = (
|
|
||||||
<PageHeader
|
|
||||||
showNavToggle
|
|
||||||
onNavToggle={this.handleNavToggle}
|
|
||||||
logo={<BrandLogo />}
|
|
||||||
logoProps={{ href: '/' }}
|
|
||||||
toolbar={
|
|
||||||
<PageHeaderToolbar
|
|
||||||
loggedInUser={me}
|
|
||||||
isAboutDisabled={!version}
|
|
||||||
onAboutClick={this.handleAboutOpen}
|
|
||||||
onLogoutClick={this.handleLogout}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const sidebar = (
|
function App() {
|
||||||
<PageSidebar
|
const catalogs = { en, ja };
|
||||||
isNavOpen={isNavOpen}
|
const language = getLanguageWithoutRegionCode(navigator);
|
||||||
theme="dark"
|
const match = useRouteMatch();
|
||||||
nav={
|
const { hash, search, pathname } = useLocation();
|
||||||
<Nav aria-label={navLabel} theme="dark">
|
|
||||||
<NavList>
|
|
||||||
{routeGroups.map(({ groupId, groupTitle, routes }) => (
|
|
||||||
<NavExpandableGroup
|
|
||||||
key={groupId}
|
|
||||||
groupId={groupId}
|
|
||||||
groupTitle={groupTitle}
|
|
||||||
routes={routes}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</NavList>
|
|
||||||
</Nav>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<I18nProvider language={language} catalogs={catalogs}>
|
||||||
<Page usecondensed="True" header={header} sidebar={sidebar}>
|
<I18n>
|
||||||
<ConfigProvider
|
{({ i18n }) => (
|
||||||
value={{
|
<Background>
|
||||||
ansible_version,
|
<Switch>
|
||||||
custom_virtualenvs,
|
<Route exact strict path="/*/">
|
||||||
project_base_dir,
|
<Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />
|
||||||
project_local_paths,
|
</Route>
|
||||||
me,
|
<Route path="/login">
|
||||||
version,
|
<Login isAuthenticated={isAuthenticated} />
|
||||||
}}
|
</Route>
|
||||||
>
|
<Route exact path="/">
|
||||||
{render({ routeGroups })}
|
<Redirect to="/home" />
|
||||||
</ConfigProvider>
|
</Route>
|
||||||
</Page>
|
<ProtectedRoute>
|
||||||
<About
|
<AppContainer navRouteConfig={getRouteConfig(i18n)}>
|
||||||
ansible_version={ansible_version}
|
<Switch>
|
||||||
version={version}
|
{getRouteConfig(i18n)
|
||||||
isOpen={isAboutModalOpen}
|
.flatMap(({ routes }) => routes)
|
||||||
onClose={this.handleAboutClose}
|
.map(({ path, screen: Screen }) => (
|
||||||
/>
|
<ProtectedRoute key={path} path={path}>
|
||||||
<AlertModal
|
<Screen match={match} />
|
||||||
isOpen={configError}
|
</ProtectedRoute>
|
||||||
variant="error"
|
))
|
||||||
title={i18n._(t`Error!`)}
|
.concat(
|
||||||
onClose={this.handleConfigErrorClose}
|
<ProtectedRoute key="not-found" path="*">
|
||||||
>
|
<NotFound />
|
||||||
{i18n._(t`Failed to retrieve configuration.`)}
|
</ProtectedRoute>
|
||||||
<ErrorDetail error={configError} />
|
)}
|
||||||
</AlertModal>
|
</Switch>
|
||||||
</Fragment>
|
</AppContainer>
|
||||||
|
</ProtectedRoute>
|
||||||
|
</Switch>
|
||||||
|
</Background>
|
||||||
|
)}
|
||||||
|
</I18n>
|
||||||
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { App as _App };
|
export default () => (
|
||||||
export default withI18n()(withRouter(App));
|
<HashRouter>
|
||||||
|
<App />
|
||||||
|
</HashRouter>
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,121 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { mountWithContexts, waitForElement } from '../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../testUtils/enzymeHelpers';
|
||||||
import { ConfigAPI, MeAPI, RootAPI } from './api';
|
|
||||||
import { asyncFlush } from './setupTests';
|
|
||||||
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
jest.mock('./api');
|
jest.mock('./api');
|
||||||
|
|
||||||
describe('<App />', () => {
|
describe('<App />', () => {
|
||||||
const ansible_version = '111';
|
test('renders ok', () => {
|
||||||
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 => {
|
|
||||||
const wrapper = mountWithContexts(<App />);
|
const wrapper = mountWithContexts(<App />);
|
||||||
wrapper.update();
|
expect(wrapper.length).toBe(1);
|
||||||
|
|
||||||
// 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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 AdHocCommands from './models/AdHocCommands';
|
||||||
import Config from './models/Config';
|
import Config from './models/Config';
|
||||||
|
import CredentialInputSources from './models/CredentialInputSources';
|
||||||
import CredentialTypes from './models/CredentialTypes';
|
import CredentialTypes from './models/CredentialTypes';
|
||||||
import Credentials from './models/Credentials';
|
import Credentials from './models/Credentials';
|
||||||
import Groups from './models/Groups';
|
import Groups from './models/Groups';
|
||||||
@@ -14,9 +15,10 @@ import Labels from './models/Labels';
|
|||||||
import Me from './models/Me';
|
import Me from './models/Me';
|
||||||
import NotificationTemplates from './models/NotificationTemplates';
|
import NotificationTemplates from './models/NotificationTemplates';
|
||||||
import Organizations from './models/Organizations';
|
import Organizations from './models/Organizations';
|
||||||
import Projects from './models/Projects';
|
|
||||||
import ProjectUpdates from './models/ProjectUpdates';
|
import ProjectUpdates from './models/ProjectUpdates';
|
||||||
|
import Projects from './models/Projects';
|
||||||
import Root from './models/Root';
|
import Root from './models/Root';
|
||||||
|
import Roles from './models/Roles';
|
||||||
import Schedules from './models/Schedules';
|
import Schedules from './models/Schedules';
|
||||||
import SystemJobs from './models/SystemJobs';
|
import SystemJobs from './models/SystemJobs';
|
||||||
import Teams from './models/Teams';
|
import Teams from './models/Teams';
|
||||||
@@ -24,14 +26,15 @@ import UnifiedJobTemplates from './models/UnifiedJobTemplates';
|
|||||||
import UnifiedJobs from './models/UnifiedJobs';
|
import UnifiedJobs from './models/UnifiedJobs';
|
||||||
import Users from './models/Users';
|
import Users from './models/Users';
|
||||||
import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates';
|
import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates';
|
||||||
import WorkflowJobs from './models/WorkflowJobs';
|
|
||||||
import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
|
import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
|
||||||
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
|
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
|
||||||
|
import WorkflowJobs from './models/WorkflowJobs';
|
||||||
|
|
||||||
const AdHocCommandsAPI = new AdHocCommands();
|
const AdHocCommandsAPI = new AdHocCommands();
|
||||||
const ConfigAPI = new Config();
|
const ConfigAPI = new Config();
|
||||||
const CredentialsAPI = new Credentials();
|
const CredentialInputSourcesAPI = new CredentialInputSources();
|
||||||
const CredentialTypesAPI = new CredentialTypes();
|
const CredentialTypesAPI = new CredentialTypes();
|
||||||
|
const CredentialsAPI = new Credentials();
|
||||||
const GroupsAPI = new Groups();
|
const GroupsAPI = new Groups();
|
||||||
const HostsAPI = new Hosts();
|
const HostsAPI = new Hosts();
|
||||||
const InstanceGroupsAPI = new InstanceGroups();
|
const InstanceGroupsAPI = new InstanceGroups();
|
||||||
@@ -44,9 +47,10 @@ const LabelsAPI = new Labels();
|
|||||||
const MeAPI = new Me();
|
const MeAPI = new Me();
|
||||||
const NotificationTemplatesAPI = new NotificationTemplates();
|
const NotificationTemplatesAPI = new NotificationTemplates();
|
||||||
const OrganizationsAPI = new Organizations();
|
const OrganizationsAPI = new Organizations();
|
||||||
const ProjectsAPI = new Projects();
|
|
||||||
const ProjectUpdatesAPI = new ProjectUpdates();
|
const ProjectUpdatesAPI = new ProjectUpdates();
|
||||||
|
const ProjectsAPI = new Projects();
|
||||||
const RootAPI = new Root();
|
const RootAPI = new Root();
|
||||||
|
const RolesAPI = new Roles();
|
||||||
const SchedulesAPI = new Schedules();
|
const SchedulesAPI = new Schedules();
|
||||||
const SystemJobsAPI = new SystemJobs();
|
const SystemJobsAPI = new SystemJobs();
|
||||||
const TeamsAPI = new Teams();
|
const TeamsAPI = new Teams();
|
||||||
@@ -54,15 +58,16 @@ const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
|
|||||||
const UnifiedJobsAPI = new UnifiedJobs();
|
const UnifiedJobsAPI = new UnifiedJobs();
|
||||||
const UsersAPI = new Users();
|
const UsersAPI = new Users();
|
||||||
const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates();
|
const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates();
|
||||||
const WorkflowJobsAPI = new WorkflowJobs();
|
|
||||||
const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes();
|
const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes();
|
||||||
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
|
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
|
||||||
|
const WorkflowJobsAPI = new WorkflowJobs();
|
||||||
|
|
||||||
export {
|
export {
|
||||||
AdHocCommandsAPI,
|
AdHocCommandsAPI,
|
||||||
ConfigAPI,
|
ConfigAPI,
|
||||||
CredentialsAPI,
|
CredentialInputSourcesAPI,
|
||||||
CredentialTypesAPI,
|
CredentialTypesAPI,
|
||||||
|
CredentialsAPI,
|
||||||
GroupsAPI,
|
GroupsAPI,
|
||||||
HostsAPI,
|
HostsAPI,
|
||||||
InstanceGroupsAPI,
|
InstanceGroupsAPI,
|
||||||
@@ -75,9 +80,10 @@ export {
|
|||||||
MeAPI,
|
MeAPI,
|
||||||
NotificationTemplatesAPI,
|
NotificationTemplatesAPI,
|
||||||
OrganizationsAPI,
|
OrganizationsAPI,
|
||||||
ProjectsAPI,
|
|
||||||
ProjectUpdatesAPI,
|
ProjectUpdatesAPI,
|
||||||
|
ProjectsAPI,
|
||||||
RootAPI,
|
RootAPI,
|
||||||
|
RolesAPI,
|
||||||
SchedulesAPI,
|
SchedulesAPI,
|
||||||
SystemJobsAPI,
|
SystemJobsAPI,
|
||||||
TeamsAPI,
|
TeamsAPI,
|
||||||
@@ -85,7 +91,7 @@ export {
|
|||||||
UnifiedJobsAPI,
|
UnifiedJobsAPI,
|
||||||
UsersAPI,
|
UsersAPI,
|
||||||
WorkflowApprovalTemplatesAPI,
|
WorkflowApprovalTemplatesAPI,
|
||||||
WorkflowJobsAPI,
|
|
||||||
WorkflowJobTemplateNodesAPI,
|
WorkflowJobTemplateNodesAPI,
|
||||||
WorkflowJobTemplatesAPI,
|
WorkflowJobTemplatesAPI,
|
||||||
|
WorkflowJobsAPI,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ class Config extends Base {
|
|||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/config/';
|
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.baseUrl = '/api/v2/credentials/';
|
||||||
|
|
||||||
this.readAccessList = this.readAccessList.bind(this);
|
this.readAccessList = this.readAccessList.bind(this);
|
||||||
|
this.readInputSources = this.readInputSources.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
readAccessList(id, params) {
|
readAccessList(id, params) {
|
||||||
@@ -13,6 +14,12 @@ class Credentials extends Base {
|
|||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readInputSources(id, params) {
|
||||||
|
return this.http.get(`${this.baseUrl}${id}/input_sources/`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Credentials;
|
export default Credentials;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Base from '../Base';
|
import Base from '../Base';
|
||||||
|
import NotificationsMixin from '../mixins/Notifications.mixin';
|
||||||
import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin';
|
import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin';
|
||||||
|
|
||||||
class InventorySources extends LaunchUpdateMixin(Base) {
|
class InventorySources extends LaunchUpdateMixin(NotificationsMixin(Base)) {
|
||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/inventory_sources/';
|
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) {
|
readRoleOptions(userId) {
|
||||||
return this.http.options(`${this.baseUrl}${userId}/roles/`);
|
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;
|
export default Users;
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ class AddResourceRole extends React.Component {
|
|||||||
sortColumns={userSortColumns}
|
sortColumns={userSortColumns}
|
||||||
displayKey="username"
|
displayKey="username"
|
||||||
onRowClick={this.handleResourceCheckboxClick}
|
onRowClick={this.handleResourceCheckboxClick}
|
||||||
onSearch={readUsers}
|
fetchItems={readUsers}
|
||||||
selectedLabel={i18n._(t`Selected`)}
|
selectedLabel={i18n._(t`Selected`)}
|
||||||
selectedResourceRows={selectedResourceRows}
|
selectedResourceRows={selectedResourceRows}
|
||||||
sortedColumnKey="username"
|
sortedColumnKey="username"
|
||||||
@@ -269,7 +269,7 @@ class AddResourceRole extends React.Component {
|
|||||||
searchColumns={teamSearchColumns}
|
searchColumns={teamSearchColumns}
|
||||||
sortColumns={teamSortColumns}
|
sortColumns={teamSortColumns}
|
||||||
onRowClick={this.handleResourceCheckboxClick}
|
onRowClick={this.handleResourceCheckboxClick}
|
||||||
onSearch={readTeams}
|
fetchItems={readTeams}
|
||||||
selectedLabel={i18n._(t`Selected`)}
|
selectedLabel={i18n._(t`Selected`)}
|
||||||
selectedResourceRows={selectedResourceRows}
|
selectedResourceRows={selectedResourceRows}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
import React, { Component, Fragment } from 'react';
|
import React, { Component, Fragment } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
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 {
|
class CheckboxCard extends Component {
|
||||||
render() {
|
render() {
|
||||||
const { name, description, isSelected, onSelect, itemId } = this.props;
|
const { name, description, isSelected, onSelect, itemId } = this.props;
|
||||||
return (
|
return (
|
||||||
<div
|
<CheckboxWrapper>
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
border: '1px solid var(--pf-global--BorderColor--200)',
|
|
||||||
borderRadius: 'var(--pf-global--BorderRadius--sm)',
|
|
||||||
padding: '10px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
isChecked={isSelected}
|
isChecked={isSelected}
|
||||||
onChange={onSelect}
|
onChange={onSelect}
|
||||||
@@ -27,7 +35,7 @@ class CheckboxCard extends Component {
|
|||||||
}
|
}
|
||||||
value={itemId}
|
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 PropTypes from 'prop-types';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter, useLocation } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
import useRequest from '../../util/useRequest';
|
||||||
|
|
||||||
import { SearchColumns, SortColumns } from '../../types';
|
import { SearchColumns, SortColumns } from '../../types';
|
||||||
import PaginatedDataList from '../PaginatedDataList';
|
import PaginatedDataList from '../PaginatedDataList';
|
||||||
import DataListToolbar from '../DataListToolbar';
|
import DataListToolbar from '../DataListToolbar';
|
||||||
@@ -10,82 +12,55 @@ import CheckboxListItem from '../CheckboxListItem';
|
|||||||
import SelectedList from '../SelectedList';
|
import SelectedList from '../SelectedList';
|
||||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||||
|
|
||||||
class SelectResourceStep extends React.Component {
|
const QS_Config = sortColumns => {
|
||||||
constructor(props) {
|
return getQSConfig('resource', {
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isInitialized: false,
|
|
||||||
count: null,
|
|
||||||
error: false,
|
|
||||||
resources: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
this.qsConfig = getQSConfig('resource', {
|
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 5,
|
page_size: 5,
|
||||||
order_by: `${
|
order_by: `${
|
||||||
props.sortColumns.filter(col => col.key === 'name').length
|
sortColumns.filter(col => col.key === 'name').length ? 'name' : 'username'
|
||||||
? 'name'
|
|
||||||
: 'username'
|
|
||||||
}`,
|
}`,
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
function SelectResourceStep({
|
||||||
componentDidMount() {
|
|
||||||
this.readResourceList();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const { location } = this.props;
|
|
||||||
if (location !== prevProps.location) {
|
|
||||||
this.readResourceList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async readResourceList() {
|
|
||||||
const { onSearch, location } = this.props;
|
|
||||||
const queryParams = parseQueryString(this.qsConfig, location.search);
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
isLoading: true,
|
|
||||||
error: false,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const { data } = await onSearch(queryParams);
|
|
||||||
const { count, results } = data;
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
resources: results,
|
|
||||||
count,
|
|
||||||
isInitialized: true,
|
|
||||||
isLoading: false,
|
|
||||||
error: false,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.setState({
|
|
||||||
isLoading: false,
|
|
||||||
error: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { isInitialized, isLoading, count, error, resources } = this.state;
|
|
||||||
|
|
||||||
const {
|
|
||||||
searchColumns,
|
searchColumns,
|
||||||
sortColumns,
|
sortColumns,
|
||||||
displayKey,
|
displayKey,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
selectedLabel,
|
selectedLabel,
|
||||||
selectedResourceRows,
|
selectedResourceRows,
|
||||||
|
fetchItems,
|
||||||
i18n,
|
i18n,
|
||||||
} = this.props;
|
}) {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
request: readResourceList,
|
||||||
|
result: { resources, itemCount },
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const queryParams = parseQueryString(
|
||||||
|
QS_Config(sortColumns),
|
||||||
|
location.search
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { count, results },
|
||||||
|
} = await fetchItems(queryParams);
|
||||||
|
return { resources: results, itemCount: count };
|
||||||
|
}, [location, fetchItems, sortColumns]),
|
||||||
|
{
|
||||||
|
resources: [],
|
||||||
|
itemCount: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
readResourceList();
|
||||||
|
}, [readResourceList]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
|
||||||
{isInitialized && (
|
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div>
|
<div>
|
||||||
{i18n._(
|
{i18n._(
|
||||||
@@ -102,9 +77,10 @@ class SelectResourceStep extends React.Component {
|
|||||||
)}
|
)}
|
||||||
<PaginatedDataList
|
<PaginatedDataList
|
||||||
hasContentLoading={isLoading}
|
hasContentLoading={isLoading}
|
||||||
|
contentError={error}
|
||||||
items={resources}
|
items={resources}
|
||||||
itemCount={count}
|
itemCount={itemCount}
|
||||||
qsConfig={this.qsConfig}
|
qsConfig={QS_Config(sortColumns)}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
toolbarSearchColumns={searchColumns}
|
toolbarSearchColumns={searchColumns}
|
||||||
toolbarSortColumns={sortColumns}
|
toolbarSortColumns={sortColumns}
|
||||||
@@ -123,11 +99,7 @@ class SelectResourceStep extends React.Component {
|
|||||||
showPageSizeOptions={false}
|
showPageSizeOptions={false}
|
||||||
/>
|
/>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
|
||||||
{error ? <div>error</div> : ''}
|
|
||||||
</Fragment>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectResourceStep.propTypes = {
|
SelectResourceStep.propTypes = {
|
||||||
@@ -135,7 +107,7 @@ SelectResourceStep.propTypes = {
|
|||||||
sortColumns: SortColumns,
|
sortColumns: SortColumns,
|
||||||
displayKey: PropTypes.string,
|
displayKey: PropTypes.string,
|
||||||
onRowClick: PropTypes.func,
|
onRowClick: PropTypes.func,
|
||||||
onSearch: PropTypes.func.isRequired,
|
fetchItems: PropTypes.func.isRequired,
|
||||||
selectedLabel: PropTypes.string,
|
selectedLabel: PropTypes.string,
|
||||||
selectedResourceRows: PropTypes.arrayOf(PropTypes.object),
|
selectedResourceRows: PropTypes.arrayOf(PropTypes.object),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createMemoryHistory } from 'history';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
waitForElement,
|
||||||
|
} from '../../../testUtils/enzymeHelpers';
|
||||||
import { sleep } from '../../../testUtils/testUtils';
|
import { sleep } from '../../../testUtils/testUtils';
|
||||||
import SelectResourceStep from './SelectResourceStep';
|
import SelectResourceStep from './SelectResourceStep';
|
||||||
|
|
||||||
@@ -30,12 +34,12 @@ describe('<SelectResourceStep />', () => {
|
|||||||
sortColumns={sortColumns}
|
sortColumns={sortColumns}
|
||||||
displayKey="username"
|
displayKey="username"
|
||||||
onRowClick={() => {}}
|
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({
|
const handleSearch = jest.fn().mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
count: 2,
|
count: 2,
|
||||||
@@ -45,61 +49,24 @@ describe('<SelectResourceStep />', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
mountWithContexts(
|
let wrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
<SelectResourceStep
|
<SelectResourceStep
|
||||||
searchColumns={searchColumns}
|
searchColumns={searchColumns}
|
||||||
sortColumns={sortColumns}
|
sortColumns={sortColumns}
|
||||||
displayKey="username"
|
displayKey="username"
|
||||||
onRowClick={() => {}}
|
onRowClick={() => {}}
|
||||||
onSearch={handleSearch}
|
fetchItems={handleSearch}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
expect(handleSearch).toHaveBeenCalledWith({
|
expect(handleSearch).toHaveBeenCalledWith({
|
||||||
order_by: 'username',
|
order_by: 'username',
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 5,
|
page_size: 5,
|
||||||
});
|
});
|
||||||
});
|
waitForElement(wrapper, 'CheckBoxListItem', el => el.length === 2);
|
||||||
|
|
||||||
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' },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking on row fires callback with correct params', async () => {
|
test('clicking on row fires callback with correct params', async () => {
|
||||||
@@ -111,20 +78,24 @@ describe('<SelectResourceStep />', () => {
|
|||||||
{ id: 2, username: 'bar', url: 'item/2' },
|
{ id: 2, username: 'bar', url: 'item/2' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const wrapper = mountWithContexts(
|
let wrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
<SelectResourceStep
|
<SelectResourceStep
|
||||||
searchColumns={searchColumns}
|
searchColumns={searchColumns}
|
||||||
sortColumns={sortColumns}
|
sortColumns={sortColumns}
|
||||||
displayKey="username"
|
displayKey="username"
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
onSearch={() => ({ data })}
|
fetchItems={() => ({ data })}
|
||||||
selectedResourceRows={[]}
|
selectedResourceRows={[]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
await sleep(0);
|
await sleep(0);
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
const checkboxListItemWrapper = wrapper.find('CheckboxListItem');
|
const checkboxListItemWrapper = wrapper.find('CheckboxListItem');
|
||||||
expect(checkboxListItemWrapper.length).toBe(2);
|
expect(checkboxListItemWrapper.length).toBe(2);
|
||||||
|
|
||||||
checkboxListItemWrapper
|
checkboxListItemWrapper
|
||||||
.first()
|
.first()
|
||||||
.find('input[type="checkbox"]')
|
.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 PropTypes from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
|
||||||
import { t } from '@lingui/macro';
|
|
||||||
import { useField } from 'formik';
|
import { useField } from 'formik';
|
||||||
import {
|
import { FormGroup, InputGroup } from '@patternfly/react-core';
|
||||||
Button,
|
import PasswordInput from './PasswordInput';
|
||||||
ButtonVariant,
|
|
||||||
FormGroup,
|
|
||||||
InputGroup,
|
|
||||||
TextInput,
|
|
||||||
Tooltip,
|
|
||||||
} from '@patternfly/react-core';
|
|
||||||
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
|
|
||||||
|
|
||||||
function PasswordField(props) {
|
function PasswordField(props) {
|
||||||
const { id, name, label, validate, isRequired, isDisabled, i18n } = props;
|
const { id, name, label, validate, isRequired } = props;
|
||||||
const [inputType, setInputType] = useState('password');
|
const [, meta] = useField({ name, validate });
|
||||||
const [field, meta] = useField({ name, validate });
|
|
||||||
|
|
||||||
const isValid = !(meta.touched && meta.error);
|
const isValid = !(meta.touched && meta.error);
|
||||||
|
|
||||||
const handlePasswordToggle = () => {
|
|
||||||
setInputType(inputType === 'text' ? 'password' : 'text');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
fieldId={id}
|
fieldId={id}
|
||||||
@@ -33,32 +18,7 @@ function PasswordField(props) {
|
|||||||
label={label}
|
label={label}
|
||||||
>
|
>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<Tooltip
|
<PasswordInput {...props} />
|
||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
);
|
);
|
||||||
@@ -79,4 +39,4 @@ PasswordField.defaultProps = {
|
|||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withI18n()(PasswordField);
|
export default PasswordField;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Formik } from 'formik';
|
import { Formik } from 'formik';
|
||||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
import { sleep } from '../../../testUtils/testUtils';
|
|
||||||
import PasswordField from './PasswordField';
|
import PasswordField from './PasswordField';
|
||||||
|
|
||||||
describe('PasswordField', () => {
|
describe('PasswordField', () => {
|
||||||
@@ -19,26 +18,4 @@ describe('PasswordField', () => {
|
|||||||
);
|
);
|
||||||
expect(wrapper).toHaveLength(1);
|
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 CheckboxField } from './CheckboxField';
|
||||||
export { default as FieldTooltip } from './FieldTooltip';
|
export { default as FieldTooltip } from './FieldTooltip';
|
||||||
export { default as PasswordField } from './PasswordField';
|
export { default as PasswordField } from './PasswordField';
|
||||||
|
export { default as PasswordInput } from './PasswordInput';
|
||||||
export { default as FormSubmitError } from './FormSubmitError';
|
export { default as FormSubmitError } from './FormSubmitError';
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
|
|||||||
setValue('limit', values.limit);
|
setValue('limit', values.limit);
|
||||||
setValue('job_tags', values.job_tags);
|
setValue('job_tags', values.job_tags);
|
||||||
setValue('skip_tags', values.skip_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);
|
setValue('scm_branch', values.scm_branch);
|
||||||
onLaunch(postValues);
|
onLaunch(postValues);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
|
|
||||||
export default function mergeExtraVars(extraVars, survey = {}) {
|
export default function mergeExtraVars(extraVars = '', survey = {}) {
|
||||||
const vars = yaml.safeLoad(extraVars) || {};
|
const vars = yaml.safeLoad(extraVars) || {};
|
||||||
return {
|
return {
|
||||||
...vars,
|
...vars,
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ describe('mergeExtraVars', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should handle undefined', () => {
|
||||||
|
expect(mergeExtraVars(undefined, undefined)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
describe('maskPasswords', () => {
|
describe('maskPasswords', () => {
|
||||||
test('should mask password fields', () => {
|
test('should mask password fields', () => {
|
||||||
const vars = {
|
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 { useFormikContext } from 'formik';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
import PromptDetail from '../../PromptDetail';
|
|
||||||
import mergeExtraVars, { maskPasswords } from '../mergeExtraVars';
|
import mergeExtraVars, { maskPasswords } from '../mergeExtraVars';
|
||||||
import getSurveyValues from '../getSurveyValues';
|
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 { values } = useFormikContext();
|
||||||
const surveyValues = getSurveyValues(values);
|
const surveyValues = getSurveyValues(values);
|
||||||
let extraVars;
|
|
||||||
|
const overrides = { ...values };
|
||||||
|
|
||||||
|
if (config.ask_variables_on_launch || config.survey_enabled) {
|
||||||
|
const initialExtraVars = config.ask_variables_on_launch
|
||||||
|
? values.extra_vars || '---'
|
||||||
|
: resource.extra_vars;
|
||||||
if (survey && survey.spec) {
|
if (survey && survey.spec) {
|
||||||
const passwordFields = survey.spec
|
const passwordFields = survey.spec
|
||||||
.filter(q => q.type === 'password')
|
.filter(q => q.type === 'password')
|
||||||
.map(q => q.variable);
|
.map(q => q.variable);
|
||||||
const masked = maskPasswords(surveyValues, passwordFields);
|
const masked = maskPasswords(surveyValues, passwordFields);
|
||||||
extraVars = yaml.safeDump(
|
overrides.extra_vars = yaml.safeDump(
|
||||||
mergeExtraVars(values.extra_vars || '---', masked)
|
mergeExtraVars(initialExtraVars, masked)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
extraVars = values.extra_vars || '---';
|
overrides.extra_vars = initialExtraVars;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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
|
<PromptDetail
|
||||||
resource={resource}
|
resource={resource}
|
||||||
launchConfig={config}
|
launchConfig={config}
|
||||||
overrides={{
|
overrides={overrides}
|
||||||
...values,
|
|
||||||
extra_vars: extraVars,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{formErrors && (
|
</Fragment>
|
||||||
<ul css="color: red">
|
|
||||||
{Object.keys(formErrors).map(
|
|
||||||
field => `${field}: ${formErrors[field]}`
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PreviewStep;
|
export default withI18n()(PreviewStep);
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ const survey = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formErrors = {
|
||||||
|
inventory: 'An inventory must be selected',
|
||||||
|
};
|
||||||
|
|
||||||
describe('PreviewStep', () => {
|
describe('PreviewStep', () => {
|
||||||
test('should render PromptDetail', async () => {
|
test('should render PromptDetail', async () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
@@ -37,6 +41,7 @@ describe('PreviewStep', () => {
|
|||||||
survey_enabled: true,
|
survey_enabled: true,
|
||||||
}}
|
}}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
|
formErrors={formErrors}
|
||||||
/>
|
/>
|
||||||
</Formik>
|
</Formik>
|
||||||
);
|
);
|
||||||
@@ -62,6 +67,7 @@ describe('PreviewStep', () => {
|
|||||||
config={{
|
config={{
|
||||||
ask_limit_on_launch: true,
|
ask_limit_on_launch: true,
|
||||||
}}
|
}}
|
||||||
|
formErrors={formErrors}
|
||||||
/>
|
/>
|
||||||
</Formik>
|
</Formik>
|
||||||
);
|
);
|
||||||
@@ -71,8 +77,31 @@ describe('PreviewStep', () => {
|
|||||||
expect(detail).toHaveLength(1);
|
expect(detail).toHaveLength(1);
|
||||||
expect(detail.prop('resource')).toEqual(resource);
|
expect(detail.prop('resource')).toEqual(resource);
|
||||||
expect(detail.prop('overrides')).toEqual({
|
expect(detail.prop('overrides')).toEqual({
|
||||||
extra_vars: '---',
|
|
||||||
limit: '4',
|
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),
|
initialValues: getInitialValues(config, resource),
|
||||||
validate,
|
validate,
|
||||||
isReady: true,
|
isReady: true,
|
||||||
error: null,
|
contentError: null,
|
||||||
|
formError: null,
|
||||||
setTouched: setFieldsTouched => {
|
setTouched: setFieldsTouched => {
|
||||||
setFieldsTouched({
|
setFieldsTouched({
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export default function useInventoryStep(config, resource, visitedSteps, i18n) {
|
|||||||
initialValues: getInitialValues(config, resource),
|
initialValues: getInitialValues(config, resource),
|
||||||
validate,
|
validate,
|
||||||
isReady: true,
|
isReady: true,
|
||||||
error: null,
|
contentError: null,
|
||||||
|
formError: stepErrors,
|
||||||
setTouched: setFieldsTouched => {
|
setTouched: setFieldsTouched => {
|
||||||
setFieldsTouched({
|
setFieldsTouched({
|
||||||
inventory: true,
|
inventory: true,
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export default function useOtherPrompt(config, resource, visitedSteps, i18n) {
|
|||||||
initialValues: getInitialValues(config, resource),
|
initialValues: getInitialValues(config, resource),
|
||||||
validate,
|
validate,
|
||||||
isReady: true,
|
isReady: true,
|
||||||
error: null,
|
contentError: null,
|
||||||
|
formError: stepErrors,
|
||||||
setTouched: setFieldsTouched => {
|
setTouched: setFieldsTouched => {
|
||||||
setFieldsTouched({
|
setFieldsTouched({
|
||||||
job_type: true,
|
job_type: true,
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export default function useSurveyStep(config, resource, visitedSteps, i18n) {
|
|||||||
validate,
|
validate,
|
||||||
survey,
|
survey,
|
||||||
isReady: !isLoading && !!survey,
|
isReady: !isLoading && !!survey,
|
||||||
error,
|
contentError: error,
|
||||||
|
formError: stepErrors,
|
||||||
setTouched: setFieldsTouched => {
|
setTouched: setFieldsTouched => {
|
||||||
if (!survey || !survey.spec) {
|
if (!survey || !survey.spec) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ export default function useSteps(config, resource, i18n) {
|
|||||||
useOtherPromptsStep(config, resource, visited, i18n),
|
useOtherPromptsStep(config, resource, visited, i18n),
|
||||||
useSurveyStep(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(
|
steps.push(
|
||||||
usePreviewStep(
|
usePreviewStep(config, resource, steps[3].survey, formErrorsContent, i18n)
|
||||||
config,
|
|
||||||
resource,
|
|
||||||
steps[3].survey,
|
|
||||||
{}, // TODO: formErrors ?
|
|
||||||
i18n
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const pfSteps = steps.map(s => s.step).filter(s => s != null);
|
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 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 validate = values => {
|
||||||
const errors = steps.reduce((acc, cur) => {
|
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 { arrayOf, string, func, object, bool } from 'prop-types';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
@@ -8,6 +8,7 @@ import { InstanceGroupsAPI } from '../../api';
|
|||||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||||
import { FieldTooltip } from '../FormField';
|
import { FieldTooltip } from '../FormField';
|
||||||
import OptionsList from '../OptionsList';
|
import OptionsList from '../OptionsList';
|
||||||
|
import useRequest from '../../util/useRequest';
|
||||||
import Lookup from './Lookup';
|
import Lookup from './Lookup';
|
||||||
import LookupErrorMessage from './shared/LookupErrorMessage';
|
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||||
|
|
||||||
@@ -27,22 +28,27 @@ function InstanceGroupsLookup(props) {
|
|||||||
history,
|
history,
|
||||||
i18n,
|
i18n,
|
||||||
} = props;
|
} = props;
|
||||||
const [instanceGroups, setInstanceGroups] = useState([]);
|
|
||||||
const [count, setCount] = useState(0);
|
const {
|
||||||
const [error, setError] = useState(null);
|
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(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
fetchInstanceGroups();
|
||||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
}, [fetchInstanceGroups]);
|
||||||
try {
|
|
||||||
const { data } = await InstanceGroupsAPI.read(params);
|
|
||||||
setInstanceGroups(data.results);
|
|
||||||
setCount(data.count);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [history.location]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
@@ -59,6 +65,7 @@ function InstanceGroupsLookup(props) {
|
|||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
multiple
|
multiple
|
||||||
required={required}
|
required={required}
|
||||||
|
isLoading={isLoading}
|
||||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||||
<OptionsList
|
<OptionsList
|
||||||
value={state.selectedItems}
|
value={state.selectedItems}
|
||||||
|
|||||||
@@ -19,22 +19,20 @@ const QS_CONFIG = getQSConfig('inventory', {
|
|||||||
|
|
||||||
function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
|
function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
|
||||||
const {
|
const {
|
||||||
result: { count, inventories },
|
result: { inventories, count },
|
||||||
error,
|
|
||||||
request: fetchInventories,
|
request: fetchInventories,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||||
const { data } = await InventoriesAPI.read(params);
|
const { data } = await InventoriesAPI.read(params);
|
||||||
return {
|
return {
|
||||||
count: data.count,
|
|
||||||
inventories: data.results,
|
inventories: data.results,
|
||||||
|
count: data.count,
|
||||||
};
|
};
|
||||||
}, [history.location.search]),
|
}, [history.location]),
|
||||||
{
|
{ inventories: [], count: 0 }
|
||||||
count: 0,
|
|
||||||
inventories: [],
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,6 +48,7 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
required={required}
|
required={required}
|
||||||
|
isLoading={isLoading}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||||
<OptionsList
|
<OptionsList
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function Lookup(props) {
|
|||||||
header,
|
header,
|
||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
|
isLoading,
|
||||||
value,
|
value,
|
||||||
multiple,
|
multiple,
|
||||||
required,
|
required,
|
||||||
@@ -124,6 +125,7 @@ function Lookup(props) {
|
|||||||
id={id}
|
id={id}
|
||||||
onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}
|
onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}
|
||||||
variant={ButtonVariant.tertiary}
|
variant={ButtonVariant.tertiary}
|
||||||
|
isDisabled={isLoading}
|
||||||
>
|
>
|
||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
</SearchButton>
|
</SearchButton>
|
||||||
|
|||||||
@@ -159,4 +159,30 @@ describe('<Lookup />', () => {
|
|||||||
const list = wrapper.find('TestList');
|
const list = wrapper.find('TestList');
|
||||||
expect(list.prop('canDelete')).toEqual(false);
|
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 '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 { withRouter } from 'react-router-dom';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
@@ -9,6 +9,7 @@ import { CredentialsAPI, CredentialTypesAPI } from '../../api';
|
|||||||
import AnsibleSelect from '../AnsibleSelect';
|
import AnsibleSelect from '../AnsibleSelect';
|
||||||
import CredentialChip from '../CredentialChip';
|
import CredentialChip from '../CredentialChip';
|
||||||
import OptionsList from '../OptionsList';
|
import OptionsList from '../OptionsList';
|
||||||
|
import useRequest from '../../util/useRequest';
|
||||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||||
import Lookup from './Lookup';
|
import Lookup from './Lookup';
|
||||||
|
|
||||||
@@ -26,42 +27,62 @@ async function loadCredentials(params, selectedCredentialTypeId) {
|
|||||||
|
|
||||||
function MultiCredentialsLookup(props) {
|
function MultiCredentialsLookup(props) {
|
||||||
const { value, onChange, onError, history, i18n } = props;
|
const { value, onChange, onError, history, i18n } = props;
|
||||||
const [credentialTypes, setCredentialTypes] = useState([]);
|
|
||||||
const [selectedType, setSelectedType] = useState(null);
|
const [selectedType, setSelectedType] = useState(null);
|
||||||
const [credentials, setCredentials] = useState([]);
|
|
||||||
const [credentialsCount, setCredentialsCount] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const {
|
||||||
(async () => {
|
result: credentialTypes,
|
||||||
try {
|
request: fetchTypes,
|
||||||
|
error: typesError,
|
||||||
|
isLoading: isTypesLoading,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
const types = await CredentialTypesAPI.loadAllTypes();
|
const types = await CredentialTypesAPI.loadAllTypes();
|
||||||
setCredentialTypes(types);
|
|
||||||
const match = types.find(type => type.kind === 'ssh') || types[0];
|
const match = types.find(type => type.kind === 'ssh') || types[0];
|
||||||
setSelectedType(match);
|
setSelectedType(match);
|
||||||
} catch (err) {
|
return types;
|
||||||
onError(err);
|
}, []),
|
||||||
}
|
[]
|
||||||
})();
|
);
|
||||||
}, [onError]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
fetchTypes();
|
||||||
|
}, [fetchTypes]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
result: { credentials, credentialsCount },
|
||||||
|
request: fetchCredentials,
|
||||||
|
error: credentialsError,
|
||||||
|
isLoading: isCredentialsLoading,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
if (!selectedType) {
|
if (!selectedType) {
|
||||||
return;
|
return {
|
||||||
|
credentials: [],
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||||
const { results, count } = await loadCredentials(
|
const { results, count } = await loadCredentials(params, selectedType.id);
|
||||||
params,
|
return {
|
||||||
selectedType.id
|
credentials: results,
|
||||||
);
|
credentialsCount: count,
|
||||||
setCredentials(results);
|
};
|
||||||
setCredentialsCount(count);
|
}, [selectedType, history.location]),
|
||||||
} catch (err) {
|
{
|
||||||
onError(err);
|
credentials: [],
|
||||||
|
credentialsCount: 0,
|
||||||
}
|
}
|
||||||
})();
|
);
|
||||||
}, [selectedType, history.location.search, onError]);
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCredentials();
|
||||||
|
}, [fetchCredentials]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typesError || credentialsError) {
|
||||||
|
onError(typesError || credentialsError);
|
||||||
|
}
|
||||||
|
}, [typesError, credentialsError, onError]);
|
||||||
|
|
||||||
const renderChip = ({ item, removeItem, canDelete }) => (
|
const renderChip = ({ item, removeItem, canDelete }) => (
|
||||||
<CredentialChip
|
<CredentialChip
|
||||||
@@ -82,6 +103,7 @@ function MultiCredentialsLookup(props) {
|
|||||||
multiple
|
multiple
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
|
isLoading={isTypesLoading || isCredentialsLoading}
|
||||||
renderItemChip={renderChip}
|
renderItemChip={renderChip}
|
||||||
renderOptionsList={({ state, dispatch, canDelete }) => {
|
renderOptionsList={({ state, dispatch, canDelete }) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ describe('<MultiCredentialsLookup />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
act(() => {
|
await act(async () => {
|
||||||
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
||||||
});
|
});
|
||||||
expect(onChange).toBeCalledWith([
|
expect(onChange).toBeCalledWith([
|
||||||
@@ -201,7 +201,7 @@ describe('<MultiCredentialsLookup />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
act(() => {
|
await act(async () => {
|
||||||
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
||||||
});
|
});
|
||||||
expect(onChange).toBeCalledWith([
|
expect(onChange).toBeCalledWith([
|
||||||
@@ -248,7 +248,7 @@ describe('<MultiCredentialsLookup />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
act(() => {
|
await act(async () => {
|
||||||
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
||||||
});
|
});
|
||||||
expect(onChange).toBeCalledWith([
|
expect(onChange).toBeCalledWith([
|
||||||
@@ -301,7 +301,7 @@ describe('<MultiCredentialsLookup />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
act(() => {
|
await act(async () => {
|
||||||
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
||||||
});
|
});
|
||||||
expect(onChange).toBeCalledWith([
|
expect(onChange).toBeCalledWith([
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ function ProjectLookup({
|
|||||||
history,
|
history,
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
result: { count, projects },
|
result: { projects, count },
|
||||||
error,
|
|
||||||
request: fetchProjects,
|
request: fetchProjects,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||||
@@ -74,6 +75,7 @@ function ProjectLookup({
|
|||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
required={required}
|
required={required}
|
||||||
|
isLoading={isLoading}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||||
<OptionsList
|
<OptionsList
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from './NavExpandableGroup';
|
|
||||||
@@ -272,4 +272,39 @@ describe('<NotificationList />', () => {
|
|||||||
wrapper.find('NotificationList').state('startedTemplateIds')
|
wrapper.find('NotificationList').state('startedTemplateIds')
|
||||||
).toEqual([]);
|
).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-gap: 16px;
|
||||||
grid-template-columns: repeat(3, max-content);
|
grid-template-columns: repeat(3, max-content);
|
||||||
`;
|
`;
|
||||||
|
const Label = styled.b`
|
||||||
|
margin-right: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
function NotificationListItem(props) {
|
function NotificationListItem(props) {
|
||||||
const {
|
const {
|
||||||
@@ -54,6 +57,7 @@ function NotificationListItem(props) {
|
|||||||
</Link>
|
</Link>
|
||||||
</DataListCell>,
|
</DataListCell>,
|
||||||
<DataListCell key="type">
|
<DataListCell key="type">
|
||||||
|
<Label>{i18n._(t`Type `)}</Label>
|
||||||
{typeLabels[notification.notification_type]}
|
{typeLabels[notification.notification_type]}
|
||||||
</DataListCell>,
|
</DataListCell>,
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
|||||||
.find('DataListCell')
|
.find('DataListCell')
|
||||||
.at(1)
|
.at(1)
|
||||||
.find('div');
|
.find('div');
|
||||||
expect(typeCell.text()).toBe('Slack');
|
expect(typeCell.text()).toContain('Slack');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles start click when toggle is on', () => {
|
test('handles start click when toggle is on', () => {
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
|||||||
</ForwardRef>
|
</ForwardRef>
|
||||||
</ForwardRef(Styled(PFDataListCell))>,
|
</ForwardRef(Styled(PFDataListCell))>,
|
||||||
<ForwardRef(Styled(PFDataListCell))>
|
<ForwardRef(Styled(PFDataListCell))>
|
||||||
|
<ForwardRef(styled.b)>
|
||||||
|
Type
|
||||||
|
</ForwardRef(styled.b)>
|
||||||
Slack
|
Slack
|
||||||
</ForwardRef(Styled(PFDataListCell))>,
|
</ForwardRef(Styled(PFDataListCell))>,
|
||||||
]
|
]
|
||||||
@@ -167,6 +170,41 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
|||||||
<div
|
<div
|
||||||
className="pf-c-data-list__cell sc-bdVaJa kruorc"
|
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
|
Slack
|
||||||
</div>
|
</div>
|
||||||
</PFDataListCell>
|
</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 React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
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 '@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 App from './App';
|
||||||
import RootProvider from './RootProvider';
|
|
||||||
import { BrandName } from './variables';
|
import { BrandName } from './variables';
|
||||||
|
|
||||||
// eslint-disable-next-line import/prefer-default-export
|
document.title = `Ansible ${BrandName}`;
|
||||||
export function main(render) {
|
|
||||||
const el = document.getElementById('app');
|
|
||||||
document.title = `Ansible ${BrandName}`;
|
|
||||||
|
|
||||||
const removeTrailingSlash = (
|
ReactDOM.render(
|
||||||
<Route
|
<App />,
|
||||||
exact
|
document.getElementById('app') || document.createElement('div')
|
||||||
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);
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { mount } from 'enzyme';
|
import ReactDOM from 'react-dom';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import App from './App';
|
||||||
import { main } from './index';
|
|
||||||
|
|
||||||
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', () => {
|
describe('index.jsx', () => {
|
||||||
test('index.jsx loads without issue', () => {
|
it('renders ok', () => {
|
||||||
const wrapper = main(render);
|
expect(ReactDOM.render).toHaveBeenCalledWith(<App />, div);
|
||||||
expect(wrapper.find('RootProvider')).toHaveLength(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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 { useHistory } from 'react-router-dom';
|
||||||
import { PageSection, Card } from '@patternfly/react-core';
|
import { PageSection, Card } from '@patternfly/react-core';
|
||||||
import { CardBody } from '../../../components/Card';
|
import { CardBody } from '../../../components/Card';
|
||||||
import ContentError from '../../../components/ContentError';
|
import ContentError from '../../../components/ContentError';
|
||||||
import ContentLoading from '../../../components/ContentLoading';
|
import ContentLoading from '../../../components/ContentLoading';
|
||||||
|
|
||||||
import { CredentialTypesAPI, CredentialsAPI } from '../../../api';
|
import {
|
||||||
|
CredentialInputSourcesAPI,
|
||||||
|
CredentialTypesAPI,
|
||||||
|
CredentialsAPI,
|
||||||
|
} from '../../../api';
|
||||||
import CredentialForm from '../shared/CredentialForm';
|
import CredentialForm from '../shared/CredentialForm';
|
||||||
|
import useRequest from '../../../util/useRequest';
|
||||||
|
|
||||||
function CredentialAdd({ me }) {
|
function CredentialAdd({ me }) {
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [credentialTypes, setCredentialTypes] = useState(null);
|
const [credentialTypes, setCredentialTypes] = useState(null);
|
||||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
|
||||||
const history = useHistory();
|
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(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -38,21 +92,7 @@ function CredentialAdd({ me }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async values => {
|
const handleSubmit = async values => {
|
||||||
const { organization, ...remainingValues } = values;
|
await submitRequest(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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -85,7 +125,7 @@ function CredentialAdd({ me }) {
|
|||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
credentialTypes={credentialTypes}
|
credentialTypes={credentialTypes}
|
||||||
submitError={formSubmitError}
|
submitError={submitError}
|
||||||
/>
|
/>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</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 { useHistory } from 'react-router-dom';
|
||||||
import { object } from 'prop-types';
|
import { object } from 'prop-types';
|
||||||
|
|
||||||
import { CardBody } from '../../../components/Card';
|
import { CardBody } from '../../../components/Card';
|
||||||
import { CredentialsAPI, CredentialTypesAPI } from '../../../api';
|
import {
|
||||||
|
CredentialsAPI,
|
||||||
|
CredentialInputSourcesAPI,
|
||||||
|
CredentialTypesAPI,
|
||||||
|
} from '../../../api';
|
||||||
import ContentError from '../../../components/ContentError';
|
import ContentError from '../../../components/ContentError';
|
||||||
import ContentLoading from '../../../components/ContentLoading';
|
import ContentLoading from '../../../components/ContentLoading';
|
||||||
import CredentialForm from '../shared/CredentialForm';
|
import CredentialForm from '../shared/CredentialForm';
|
||||||
|
import useRequest from '../../../util/useRequest';
|
||||||
|
|
||||||
function CredentialEdit({ credential, me }) {
|
function CredentialEdit({ credential, me }) {
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [credentialTypes, setCredentialTypes] = useState(null);
|
const [credentialTypes, setCredentialTypes] = useState(null);
|
||||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
const [inputSources, setInputSources] = useState({});
|
||||||
const history = useHistory();
|
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(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const {
|
const [
|
||||||
|
{
|
||||||
data: { results: loadedCredentialTypes },
|
data: { results: loadedCredentialTypes },
|
||||||
} = await CredentialTypesAPI.read({
|
},
|
||||||
|
{
|
||||||
|
data: { results: loadedInputSources },
|
||||||
|
},
|
||||||
|
] = await Promise.all([
|
||||||
|
CredentialTypesAPI.read({
|
||||||
or__namespace: ['gce', 'scm', 'ssh'],
|
or__namespace: ['gce', 'scm', 'ssh'],
|
||||||
});
|
}),
|
||||||
|
CredentialsAPI.readInputSources(credential.id, { page_size: 200 }),
|
||||||
|
]);
|
||||||
setCredentialTypes(loadedCredentialTypes);
|
setCredentialTypes(loadedCredentialTypes);
|
||||||
|
setInputSources(
|
||||||
|
loadedInputSources.reduce((inputSourcesMap, inputSource) => {
|
||||||
|
inputSourcesMap[inputSource.input_field_name] = inputSource;
|
||||||
|
return inputSourcesMap;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err);
|
setError(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -31,30 +119,15 @@ function CredentialEdit({ credential, me }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, [credential.id]);
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
const url = `/credentials/${credential.id}/details`;
|
const url = `/credentials/${credential.id}/details`;
|
||||||
|
|
||||||
history.push(`${url}`);
|
history.push(`${url}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async values => {
|
const handleSubmit = async values => {
|
||||||
const { organization, ...remainingValues } = values;
|
await submitRequest(values, inputSources);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -72,7 +145,8 @@ function CredentialEdit({ credential, me }) {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
credential={credential}
|
credential={credential}
|
||||||
credentialTypes={credentialTypes}
|
credentialTypes={credentialTypes}
|
||||||
submitError={formSubmitError}
|
inputSources={inputSources}
|
||||||
|
submitError={submitError}
|
||||||
/>
|
/>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ CredentialTypesAPI.read.mockResolvedValue({
|
|||||||
});
|
});
|
||||||
|
|
||||||
CredentialsAPI.update.mockResolvedValue({ data: { id: 3 } });
|
CredentialsAPI.update.mockResolvedValue({ data: { id: 3 } });
|
||||||
|
CredentialsAPI.readInputSources.mockResolvedValue({ data: { results: [] } });
|
||||||
|
|
||||||
describe('<CredentialEdit />', () => {
|
describe('<CredentialEdit />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { Formik, useField } from 'formik';
|
import { Formik, useField } from 'formik';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
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 { Form, FormGroup, Title } from '@patternfly/react-core';
|
||||||
import FormField, { FormSubmitError } from '../../../components/FormField';
|
import FormField, { FormSubmitError } from '../../../components/FormField';
|
||||||
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
|
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
|
||||||
@@ -124,6 +124,7 @@ function CredentialFormFields({
|
|||||||
function CredentialForm({
|
function CredentialForm({
|
||||||
credential = {},
|
credential = {},
|
||||||
credentialTypes,
|
credentialTypes,
|
||||||
|
inputSources,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
submitError,
|
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)
|
const scmCredentialTypeId = Object.keys(credentialTypes)
|
||||||
.filter(key => credentialTypes[key].namespace === 'scm')
|
.filter(key => credentialTypes[key].namespace === 'scm')
|
||||||
.map(key => credentialTypes[key].id)[0];
|
.map(key => credentialTypes[key].id)[0];
|
||||||
@@ -232,10 +240,12 @@ CredentialForm.proptype = {
|
|||||||
handleSubmit: func.isRequired,
|
handleSubmit: func.isRequired,
|
||||||
handleCancel: func.isRequired,
|
handleCancel: func.isRequired,
|
||||||
credential: shape({}),
|
credential: shape({}),
|
||||||
|
inputSources: arrayOf(object),
|
||||||
};
|
};
|
||||||
|
|
||||||
CredentialForm.defaultProps = {
|
CredentialForm.defaultProps = {
|
||||||
credential: {},
|
credential: {},
|
||||||
|
inputSources: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withI18n()(CredentialForm);
|
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 { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { useField } from 'formik';
|
import { useField } from 'formik';
|
||||||
import { FileUpload, FormGroup } from '@patternfly/react-core';
|
import {
|
||||||
import FormField from '../../../../components/FormField';
|
FileUpload,
|
||||||
|
FormGroup,
|
||||||
|
TextArea,
|
||||||
|
TextInput,
|
||||||
|
} from '@patternfly/react-core';
|
||||||
import {
|
import {
|
||||||
FormColumnLayout,
|
FormColumnLayout,
|
||||||
FormFullWidthLayout,
|
FormFullWidthLayout,
|
||||||
} from '../../../../components/FormLayout';
|
} from '../../../../components/FormLayout';
|
||||||
import { required } from '../../../../util/validators';
|
import { required } from '../../../../util/validators';
|
||||||
|
import { CredentialPluginField } from '../CredentialPlugins';
|
||||||
|
|
||||||
const GoogleComputeEngineSubForm = ({ i18n }) => {
|
const GoogleComputeEngineSubForm = ({ i18n }) => {
|
||||||
const [fileError, setFileError] = useState(null);
|
const [fileError, setFileError] = useState(null);
|
||||||
@@ -91,30 +96,38 @@ const GoogleComputeEngineSubForm = ({ i18n }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormField
|
<CredentialPluginField
|
||||||
id="credential-username"
|
id="credential-username"
|
||||||
label={i18n._(t`Service account email address`)}
|
label={i18n._(t`Service account email address`)}
|
||||||
name="inputs.username"
|
name="inputs.username"
|
||||||
type="email"
|
type="email"
|
||||||
validate={required(null, i18n)}
|
validate={required(null, i18n)}
|
||||||
isRequired
|
isRequired
|
||||||
/>
|
>
|
||||||
<FormField
|
<TextInput id="credential-username" />
|
||||||
|
</CredentialPluginField>
|
||||||
|
<CredentialPluginField
|
||||||
id="credential-project"
|
id="credential-project"
|
||||||
label={i18n._(t`Project`)}
|
label={i18n._(t`Project`)}
|
||||||
name="inputs.project"
|
name="inputs.project"
|
||||||
type="text"
|
>
|
||||||
/>
|
<TextInput id="credential-project" />
|
||||||
|
</CredentialPluginField>
|
||||||
<FormFullWidthLayout>
|
<FormFullWidthLayout>
|
||||||
<FormField
|
<CredentialPluginField
|
||||||
id="credential-sshKeyData"
|
id="credential-sshKeyData"
|
||||||
label={i18n._(t`RSA private key`)}
|
label={i18n._(t`RSA private key`)}
|
||||||
name="inputs.ssh_key_data"
|
name="inputs.ssh_key_data"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
rows={6}
|
|
||||||
validate={required(null, i18n)}
|
validate={required(null, i18n)}
|
||||||
isRequired
|
isRequired
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
id="credential-sshKeyData"
|
||||||
|
rows={6}
|
||||||
|
resizeOrientation="vertical"
|
||||||
/>
|
/>
|
||||||
|
</CredentialPluginField>
|
||||||
</FormFullWidthLayout>
|
</FormFullWidthLayout>
|
||||||
</FormColumnLayout>
|
</FormColumnLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,39 +1,50 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
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 }) => (
|
export const UsernameFormField = withI18n()(({ i18n }) => (
|
||||||
<FormField
|
<CredentialPluginField
|
||||||
id="credentual-username"
|
id="credential-username"
|
||||||
label={i18n._(t`Username`)}
|
label={i18n._(t`Username`)}
|
||||||
name="inputs.username"
|
name="inputs.username"
|
||||||
type="text"
|
>
|
||||||
/>
|
<TextInput id="credential-username" />
|
||||||
|
</CredentialPluginField>
|
||||||
));
|
));
|
||||||
|
|
||||||
export const PasswordFormField = withI18n()(({ i18n }) => (
|
export const PasswordFormField = withI18n()(({ i18n }) => (
|
||||||
<PasswordField
|
<CredentialPluginField
|
||||||
id="credential-password"
|
id="credential-password"
|
||||||
label={i18n._(t`Password`)}
|
label={i18n._(t`Password`)}
|
||||||
name="inputs.password"
|
name="inputs.password"
|
||||||
/>
|
>
|
||||||
|
<PasswordInput id="credential-password" />
|
||||||
|
</CredentialPluginField>
|
||||||
));
|
));
|
||||||
|
|
||||||
export const SSHKeyDataField = withI18n()(({ i18n }) => (
|
export const SSHKeyDataField = withI18n()(({ i18n }) => (
|
||||||
<FormField
|
<CredentialPluginField
|
||||||
id="credential-sshKeyData"
|
id="credential-sshKeyData"
|
||||||
label={i18n._(t`SSH Private Key`)}
|
label={i18n._(t`SSH Private Key`)}
|
||||||
name="inputs.ssh_key_data"
|
name="inputs.ssh_key_data"
|
||||||
type="textarea"
|
>
|
||||||
|
<TextArea
|
||||||
|
id="credential-sshKeyData"
|
||||||
rows={6}
|
rows={6}
|
||||||
|
resizeOrientation="vertical"
|
||||||
/>
|
/>
|
||||||
|
</CredentialPluginField>
|
||||||
));
|
));
|
||||||
|
|
||||||
export const SSHKeyUnlockField = withI18n()(({ i18n }) => (
|
export const SSHKeyUnlockField = withI18n()(({ i18n }) => (
|
||||||
<PasswordField
|
<CredentialPluginField
|
||||||
id="credential-sshKeyUnlock"
|
id="credential-sshKeyUnlock"
|
||||||
label={i18n._(t`Private Key Passphrase`)}
|
label={i18n._(t`Private Key Passphrase`)}
|
||||||
name="inputs.ssh_key_unlock"
|
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 { CardActions } from '@patternfly/react-core';
|
||||||
import useRequest from '../../../util/useRequest';
|
import useRequest from '../../../util/useRequest';
|
||||||
|
|
||||||
import { InventoriesAPI } from '../../../api';
|
import {
|
||||||
|
InventoriesAPI,
|
||||||
|
InventorySourcesAPI,
|
||||||
|
OrganizationsAPI,
|
||||||
|
} from '../../../api';
|
||||||
import { TabbedCardHeader } from '../../../components/Card';
|
import { TabbedCardHeader } from '../../../components/Card';
|
||||||
import CardCloseButton from '../../../components/CardCloseButton';
|
import CardCloseButton from '../../../components/CardCloseButton';
|
||||||
import ContentError from '../../../components/ContentError';
|
import ContentError from '../../../components/ContentError';
|
||||||
@@ -21,20 +25,33 @@ import ContentLoading from '../../../components/ContentLoading';
|
|||||||
import RoutedTabs from '../../../components/RoutedTabs';
|
import RoutedTabs from '../../../components/RoutedTabs';
|
||||||
import InventorySourceDetail from '../InventorySourceDetail';
|
import InventorySourceDetail from '../InventorySourceDetail';
|
||||||
import InventorySourceEdit from '../InventorySourceEdit';
|
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 location = useLocation();
|
||||||
const match = useRouteMatch('/inventories/inventory/:id/sources/:sourceId');
|
const match = useRouteMatch('/inventories/inventory/:id/sources/:sourceId');
|
||||||
const sourceListUrl = `/inventories/inventory/${inventory.id}/sources`;
|
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 () => {
|
useCallback(async () => {
|
||||||
return InventoriesAPI.readSourceDetail(
|
const [inventorySource, notifAdminRes] = await Promise.all([
|
||||||
inventory.id,
|
InventoriesAPI.readSourceDetail(inventory.id, match.params.sourceId),
|
||||||
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]),
|
}, [inventory.id, match.params.sourceId]),
|
||||||
null
|
{ source: null, isNotifAdmin: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,18 +80,24 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) {
|
|||||||
link: `${match.url}/details`,
|
link: `${match.url}/details`,
|
||||||
id: 1,
|
id: 1,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: i18n._(t`Notifications`),
|
|
||||||
link: `${match.url}/notifications`,
|
|
||||||
id: 2,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: i18n._(t`Schedules`),
|
name: i18n._(t`Schedules`),
|
||||||
link: `${match.url}/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) {
|
if (error) {
|
||||||
return <ContentError error={error} />;
|
return <ContentError error={error} />;
|
||||||
}
|
}
|
||||||
@@ -111,6 +134,16 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) {
|
|||||||
>
|
>
|
||||||
<InventorySourceEdit source={source} inventory={inventory} />
|
<InventorySourceEdit source={source} inventory={inventory} />
|
||||||
</Route>
|
</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="*">
|
<Route key="not-found" path="*">
|
||||||
<ContentError isNotFound>
|
<ContentError isNotFound>
|
||||||
<Link to={`${match.url}/details`}>
|
<Link to={`${match.url}/details`}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { InventoriesAPI } from '../../../api';
|
import { InventoriesAPI, OrganizationsAPI } from '../../../api';
|
||||||
import {
|
import {
|
||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
@@ -10,6 +10,9 @@ import mockInventorySource from '../shared/data.inventory_source.json';
|
|||||||
import InventorySource from './InventorySource';
|
import InventorySource from './InventorySource';
|
||||||
|
|
||||||
jest.mock('../../../api/models/Inventories');
|
jest.mock('../../../api/models/Inventories');
|
||||||
|
jest.mock('../../../api/models/Organizations');
|
||||||
|
jest.mock('../../../api/models/InventorySources');
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
jest.mock('react-router-dom', () => ({
|
||||||
...jest.requireActual('react-router-dom'),
|
...jest.requireActual('react-router-dom'),
|
||||||
useRouteMatch: () => ({
|
useRouteMatch: () => ({
|
||||||
@@ -18,10 +21,6 @@ jest.mock('react-router-dom', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
InventoriesAPI.readSourceDetail.mockResolvedValue({
|
|
||||||
data: { ...mockInventorySource },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockInventory = {
|
const mockInventory = {
|
||||||
id: 2,
|
id: 2,
|
||||||
name: 'Mock Inventory',
|
name: 'Mock Inventory',
|
||||||
@@ -34,22 +33,31 @@ describe('<InventorySource />', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />
|
<InventorySource
|
||||||
|
inventory={mockInventory}
|
||||||
|
me={{ is_system_auditor: false }}
|
||||||
|
setBreadcrumb={() => {}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
wrapper.unmount();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render expected tabs', () => {
|
test('should render expected tabs', () => {
|
||||||
|
InventoriesAPI.readSourceDetail.mockResolvedValue({
|
||||||
|
data: { ...mockInventorySource },
|
||||||
|
});
|
||||||
|
OrganizationsAPI.read.mockResolvedValue({
|
||||||
|
data: { results: [{ id: 1, name: 'isNotifAdmin' }] },
|
||||||
|
});
|
||||||
const expectedTabs = [
|
const expectedTabs = [
|
||||||
'Back to Sources',
|
'Back to Sources',
|
||||||
'Details',
|
'Details',
|
||||||
'Notifications',
|
|
||||||
'Schedules',
|
'Schedules',
|
||||||
|
'Notifications',
|
||||||
];
|
];
|
||||||
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||||
expect(tab.text()).toEqual(expectedTabs[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 () => {
|
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());
|
InventoriesAPI.readSourceDetail.mockRejectedValueOnce(new Error());
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />
|
<InventorySource
|
||||||
|
inventory={mockInventory}
|
||||||
|
me={{ is_system_auditor: false }}
|
||||||
|
setBreadcrumb={() => {}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
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 () => {
|
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({
|
history = createMemoryHistory({
|
||||||
initialEntries: ['/inventories/inventory/2/sources/1/foobar'],
|
initialEntries: ['/inventories/inventory/2/sources/1/foobar'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />,
|
<InventorySource
|
||||||
|
inventory={mockInventory}
|
||||||
|
setBreadcrumb={() => {}}
|
||||||
|
me={{ is_system_auditor: false }}
|
||||||
|
/>,
|
||||||
{ context: { router: { history } } }
|
{ context: { router: { history } } }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||||
expect(wrapper.find('ContentError Title').text()).toEqual('Not Found');
|
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 React from 'react';
|
||||||
import { Switch, Route } from 'react-router-dom';
|
import { Switch, Route } from 'react-router-dom';
|
||||||
import InventorySource from '../InventorySource';
|
import InventorySource from '../InventorySource';
|
||||||
|
import { Config } from '../../../contexts/Config';
|
||||||
import InventorySourceAdd from '../InventorySourceAdd';
|
import InventorySourceAdd from '../InventorySourceAdd';
|
||||||
import InventorySourceList from './InventorySourceList';
|
import InventorySourceList from './InventorySourceList';
|
||||||
|
|
||||||
@@ -11,7 +12,15 @@ function InventorySources({ inventory, setBreadcrumb }) {
|
|||||||
<InventorySourceAdd />
|
<InventorySourceAdd />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/inventories/inventory/:id/sources/:sourceId">
|
<Route path="/inventories/inventory/:id/sources/:sourceId">
|
||||||
<InventorySource inventory={inventory} setBreadcrumb={setBreadcrumb} />
|
<Config>
|
||||||
|
{({ me }) => (
|
||||||
|
<InventorySource
|
||||||
|
inventory={inventory}
|
||||||
|
setBreadcrumb={setBreadcrumb}
|
||||||
|
me={me || {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Config>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/inventories/:inventoryType/:id/sources">
|
<Route path="/inventories/:inventoryType/:id/sources">
|
||||||
<InventorySourceList />
|
<InventorySourceList />
|
||||||
|
|||||||
@@ -1,30 +1,38 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useLocation, useRouteMatch, useParams } from 'react-router-dom';
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
import { Card } from '@patternfly/react-core';
|
import {
|
||||||
import { TeamsAPI } from '../../../api';
|
Button,
|
||||||
|
EmptyState,
|
||||||
import useRequest from '../../../util/useRequest';
|
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 DataListToolbar from '../../../components/DataListToolbar';
|
||||||
import PaginatedDataList, {
|
import PaginatedDataList from '../../../components/PaginatedDataList';
|
||||||
ToolbarAddButton,
|
|
||||||
} from '../../../components/PaginatedDataList';
|
|
||||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||||
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
|
import AlertModal from '../../../components/AlertModal';
|
||||||
import TeamAccessListItem from './TeamAccessListItem';
|
import TeamAccessListItem from './TeamAccessListItem';
|
||||||
|
import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('team', {
|
const QS_CONFIG = getQSConfig('roles', {
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 20,
|
page_size: 20,
|
||||||
order_by: 'id',
|
order_by: 'id',
|
||||||
});
|
});
|
||||||
|
|
||||||
function TeamAccessList({ i18n }) {
|
function TeamAccessList({ i18n }) {
|
||||||
|
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
const match = useRouteMatch();
|
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
const [roleToDisassociate, setRoleToDisassociate] = useState(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -57,6 +65,26 @@ function TeamAccessList({ i18n }) {
|
|||||||
fetchRoles();
|
fetchRoles();
|
||||||
}, [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 =
|
const canAdd =
|
||||||
options && Object.prototype.hasOwnProperty.call(options, 'POST');
|
options && Object.prototype.hasOwnProperty.call(options, 'POST');
|
||||||
|
|
||||||
@@ -76,11 +104,28 @@ function TeamAccessList({ i18n }) {
|
|||||||
return `/${resource_type}s/${resource_id}/details`;
|
return `/${resource_type}s/${resource_id}/details`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSysAdmin = roles.some(role => role.name === 'System Administrator');
|
||||||
|
if (isSysAdmin) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<EmptyState variant="full">
|
||||||
|
<EmptyStateIcon icon={CubesIcon} />
|
||||||
|
<Title headingLevel="h5" size="lg">
|
||||||
|
{i18n._(t`System Administrator`)}
|
||||||
|
</Title>
|
||||||
|
<EmptyStateBody>
|
||||||
|
{i18n._(
|
||||||
|
t`System administrators have unrestricted access to all resources.`
|
||||||
|
)}
|
||||||
|
</EmptyStateBody>
|
||||||
|
</EmptyState>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<PaginatedDataList
|
<PaginatedDataList
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
hasContentLoading={isLoading}
|
hasContentLoading={isLoading || isDisassociateLoading}
|
||||||
items={roles}
|
items={roles}
|
||||||
itemCount={roleCount}
|
itemCount={roleCount}
|
||||||
pluralizedItemName={i18n._(t`Teams`)}
|
pluralizedItemName={i18n._(t`Teams`)}
|
||||||
@@ -104,7 +149,17 @@ function TeamAccessList({ i18n }) {
|
|||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
additionalControls={[
|
additionalControls={[
|
||||||
...(canAdd
|
...(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}
|
key={role.id}
|
||||||
role={role}
|
role={role}
|
||||||
detailUrl={detailUrl(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);
|
export default withI18n()(TeamAccessList);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { TeamsAPI, RolesAPI } from '../../../api';
|
||||||
import { Route } from 'react-router-dom';
|
|
||||||
import { TeamsAPI } from '../../../api';
|
|
||||||
import {
|
import {
|
||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
@@ -10,11 +8,16 @@ import {
|
|||||||
import TeamAccessList from './TeamAccessList';
|
import TeamAccessList from './TeamAccessList';
|
||||||
|
|
||||||
jest.mock('../../../api/models/Teams');
|
jest.mock('../../../api/models/Teams');
|
||||||
describe('<TeamAccessList />', () => {
|
jest.mock('../../../api/models/Roles');
|
||||||
let wrapper;
|
|
||||||
let history;
|
jest.mock('react-router-dom', () => ({
|
||||||
beforeEach(async () => {
|
...jest.requireActual('react-router-dom'),
|
||||||
TeamsAPI.readRoles.mockResolvedValue({
|
useParams: () => ({
|
||||||
|
id: 18,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const roles = {
|
||||||
data: {
|
data: {
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
@@ -32,7 +35,7 @@ describe('<TeamAccessList />', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
name: 'Admin',
|
name: 'Admin Read Only',
|
||||||
type: 'role',
|
type: 'role',
|
||||||
url: '/api/v2/roles/257/',
|
url: '/api/v2/roles/257/',
|
||||||
summary_fields: {
|
summary_fields: {
|
||||||
@@ -83,46 +86,36 @@ describe('<TeamAccessList />', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
count: 4,
|
count: 5,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
const options = {
|
||||||
TeamsAPI.readRoleOptions.mockResolvedValue({
|
|
||||||
data: { actions: { POST: { id: 1, disassociate: true } } },
|
data: { actions: { POST: { id: 1, disassociate: true } } },
|
||||||
});
|
};
|
||||||
|
describe('<TeamAccessList />', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
history = createMemoryHistory({
|
|
||||||
initialEntries: ['/teams/18/access'],
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<Route path="/teams/:id/access">
|
|
||||||
<TeamAccessList />
|
|
||||||
</Route>,
|
|
||||||
{
|
|
||||||
context: {
|
|
||||||
router: {
|
|
||||||
history,
|
|
||||||
route: {
|
|
||||||
location: history.location,
|
|
||||||
match: { params: { id: 18 } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
test('should render properly', async () => {
|
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);
|
expect(wrapper.find('TeamAccessList').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create proper detailUrl', async () => {
|
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);
|
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||||
|
|
||||||
expect(wrapper.find(`Link#teamRole-2`).prop('to')).toBe(
|
expect(wrapper.find(`Link#teamRole-2`).prop('to')).toBe(
|
||||||
@@ -141,4 +134,164 @@ describe('<TeamAccessList />', () => {
|
|||||||
'/inventories/smart_inventory/77/details'
|
'/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,
|
DataListItem,
|
||||||
DataListItemCells,
|
DataListItemCells,
|
||||||
DataListItemRow,
|
DataListItemRow,
|
||||||
|
Chip,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { DetailList, Detail } from '../../../components/DetailList';
|
||||||
import DataListCell from '../../../components/DataListCell';
|
import DataListCell from '../../../components/DataListCell';
|
||||||
|
|
||||||
function TeamAccessListItem({ role, i18n, detailUrl }) {
|
function TeamAccessListItem({ role, i18n, detailUrl, onSelect }) {
|
||||||
const labelId = `teamRole-${role.id}`;
|
const labelId = `teamRole-${role.id}`;
|
||||||
return (
|
return (
|
||||||
<DataListItem key={role.id} aria-labelledby={labelId} id={`${role.id}`}>
|
<DataListItem key={role.id} aria-labelledby={labelId} id={`${role.id}`}>
|
||||||
@@ -23,18 +25,33 @@ function TeamAccessListItem({ role, i18n, detailUrl }) {
|
|||||||
</DataListCell>,
|
</DataListCell>,
|
||||||
<DataListCell key="type" aria-label={i18n._(t`resource type`)}>
|
<DataListCell key="type" aria-label={i18n._(t`resource type`)}>
|
||||||
{role.summary_fields && (
|
{role.summary_fields && (
|
||||||
<>
|
<DetailList stacked>
|
||||||
<b css="margin-right: 24px">{i18n._(t`Type`)}</b>
|
<Detail
|
||||||
{role.summary_fields.resource_type_display_name}
|
label={i18n._(t`Type`)}
|
||||||
</>
|
value={role.summary_fields.resource_type_display_name}
|
||||||
|
/>
|
||||||
|
</DetailList>
|
||||||
)}
|
)}
|
||||||
</DataListCell>,
|
</DataListCell>,
|
||||||
<DataListCell key="role" aria-label={i18n._(t`resource role`)}>
|
<DataListCell key="role" aria-label={i18n._(t`resource role`)}>
|
||||||
{role.name && (
|
{role.name && (
|
||||||
<>
|
<DetailList stacked>
|
||||||
<b css="margin-right: 24px">{i18n._(t`Role`)}</b>
|
<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}
|
{role.name}
|
||||||
</>
|
</Chip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DetailList>
|
||||||
)}
|
)}
|
||||||
</DataListCell>,
|
</DataListCell>,
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -18,20 +18,25 @@ describe('<TeamAccessListItem/>', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
test('should mount properly', () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<TeamAccessListItem
|
<TeamAccessListItem
|
||||||
role={role}
|
role={role}
|
||||||
detailUrl="/templates/job_template/15/details"
|
detailUrl="/templates/job_template/15/details"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
test('should mount properly', () => {
|
|
||||||
expect(wrapper.length).toBe(1);
|
expect(wrapper.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render proper list item data', () => {
|
test('should render proper list item data', () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<TeamAccessListItem
|
||||||
|
role={role}
|
||||||
|
detailUrl="/templates/job_template/15/details"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('PFDataListCell[aria-label="resource name"]').text()
|
wrapper.find('PFDataListCell[aria-label="resource name"]').text()
|
||||||
).toBe('template delete project');
|
).toBe('template delete project');
|
||||||
@@ -42,4 +47,23 @@ describe('<TeamAccessListItem/>', () => {
|
|||||||
wrapper.find('PFDataListCell[aria-label="resource role"]').text()
|
wrapper.find('PFDataListCell[aria-label="resource role"]').text()
|
||||||
).toContain('Admin');
|
).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">
|
<DataListCell key="organization">
|
||||||
{team.summary_fields.organization && (
|
{team.summary_fields.organization && (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<b css="margin-right: 24px">{i18n._(t`Organization`)}</b>
|
<b>{i18n._(t`Organization`)}</b>{' '}
|
||||||
<Link
|
<Link
|
||||||
to={`/organizations/${team.summary_fields.organization.id}/details`}
|
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 PropTypes from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { withFormik, useField, useFormikContext } from 'formik';
|
import { withFormik, useField } from 'formik';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -52,8 +52,6 @@ function JobTemplateForm({
|
|||||||
submitError,
|
submitError,
|
||||||
i18n,
|
i18n,
|
||||||
}) {
|
}) {
|
||||||
const { values: formikValues } = useFormikContext();
|
|
||||||
|
|
||||||
const [contentError, setContentError] = useState(false);
|
const [contentError, setContentError] = useState(false);
|
||||||
const [inventory, setInventory] = useState(
|
const [inventory, setInventory] = useState(
|
||||||
template?.summary_fields?.inventory
|
template?.summary_fields?.inventory
|
||||||
@@ -65,6 +63,7 @@ function JobTemplateForm({
|
|||||||
Boolean(template.webhook_service)
|
Boolean(template.webhook_service)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [askInventoryOnLaunchField] = useField('ask_inventory_on_launch');
|
||||||
const [jobTypeField, jobTypeMeta, jobTypeHelpers] = useField({
|
const [jobTypeField, jobTypeMeta, jobTypeHelpers] = useField({
|
||||||
name: 'job_type',
|
name: 'job_type',
|
||||||
validate: required(null, i18n),
|
validate: required(null, i18n),
|
||||||
@@ -81,7 +80,7 @@ function JobTemplateForm({
|
|||||||
});
|
});
|
||||||
const [credentialField, , credentialHelpers] = useField('credentials');
|
const [credentialField, , credentialHelpers] = useField('credentials');
|
||||||
const [labelsField, , labelsHelpers] = useField('labels');
|
const [labelsField, , labelsHelpers] = useField('labels');
|
||||||
const [limitField, limitMeta] = useField('limit');
|
const [limitField, limitMeta, limitHelpers] = useField('limit');
|
||||||
const [verbosityField] = useField('verbosity');
|
const [verbosityField] = useField('verbosity');
|
||||||
const [diffModeField, , diffModeHelpers] = useField('diff_mode');
|
const [diffModeField, , diffModeHelpers] = useField('diff_mode');
|
||||||
const [instanceGroupsField, , instanceGroupsHelpers] = useField(
|
const [instanceGroupsField, , instanceGroupsHelpers] = useField(
|
||||||
@@ -231,7 +230,7 @@ function JobTemplateForm({
|
|||||||
</FieldWithPrompt>
|
</FieldWithPrompt>
|
||||||
<FieldWithPrompt
|
<FieldWithPrompt
|
||||||
fieldId="template-inventory"
|
fieldId="template-inventory"
|
||||||
isRequired={!formikValues.ask_inventory_on_launch}
|
isRequired={!askInventoryOnLaunchField.value}
|
||||||
label={i18n._(t`Inventory`)}
|
label={i18n._(t`Inventory`)}
|
||||||
promptId="template-ask-inventory-on-launch"
|
promptId="template-ask-inventory-on-launch"
|
||||||
promptName="ask_inventory_on_launch"
|
promptName="ask_inventory_on_launch"
|
||||||
@@ -245,11 +244,11 @@ function JobTemplateForm({
|
|||||||
inventoryHelpers.setValue(value ? value.id : null);
|
inventoryHelpers.setValue(value ? value.id : null);
|
||||||
setInventory(value);
|
setInventory(value);
|
||||||
}}
|
}}
|
||||||
required={!formikValues.ask_inventory_on_launch}
|
required={!askInventoryOnLaunchField.value}
|
||||||
touched={inventoryMeta.touched}
|
touched={inventoryMeta.touched}
|
||||||
error={inventoryMeta.error}
|
error={inventoryMeta.error}
|
||||||
/>
|
/>
|
||||||
{(inventoryMeta.touched || formikValues.ask_inventory_on_launch) &&
|
{(inventoryMeta.touched || askInventoryOnLaunchField.value) &&
|
||||||
inventoryMeta.error && (
|
inventoryMeta.error && (
|
||||||
<div
|
<div
|
||||||
className="pf-c-form__helper-text pf-m-error"
|
className="pf-c-form__helper-text pf-m-error"
|
||||||
@@ -283,8 +282,8 @@ function JobTemplateForm({
|
|||||||
<TextInput
|
<TextInput
|
||||||
id="template-scm-branch"
|
id="template-scm-branch"
|
||||||
{...scmField}
|
{...scmField}
|
||||||
onChange={(value, event) => {
|
onChange={value => {
|
||||||
scmField.onChange(event);
|
scmHelpers.setValue(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FieldWithPrompt>
|
</FieldWithPrompt>
|
||||||
@@ -383,8 +382,8 @@ function JobTemplateForm({
|
|||||||
id="template-limit"
|
id="template-limit"
|
||||||
{...limitField}
|
{...limitField}
|
||||||
isValid={!limitMeta.touched || !limitMeta.error}
|
isValid={!limitMeta.touched || !limitMeta.error}
|
||||||
onChange={(value, event) => {
|
onChange={value => {
|
||||||
limitField.onChange(event);
|
limitHelpers.setValue(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FieldWithPrompt>
|
</FieldWithPrompt>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe('<JobTemplateForm />', () => {
|
|||||||
playbook: 'Baz',
|
playbook: 'Baz',
|
||||||
type: 'job_template',
|
type: 'job_template',
|
||||||
scm_branch: 'Foo',
|
scm_branch: 'Foo',
|
||||||
|
limit: '5000',
|
||||||
summary_fields: {
|
summary_fields: {
|
||||||
inventory: {
|
inventory: {
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -184,9 +185,10 @@ describe('<JobTemplateForm />', () => {
|
|||||||
|
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('input#template-scm-branch').simulate('change', {
|
wrapper.find('TextInputBase#template-scm-branch').prop('onChange')(
|
||||||
target: { value: 'devel', name: 'scm_branch' },
|
'devel'
|
||||||
});
|
);
|
||||||
|
wrapper.find('TextInputBase#template-limit').prop('onChange')(1234567890);
|
||||||
wrapper.find('AnsibleSelect[name="playbook"]').simulate('change', {
|
wrapper.find('AnsibleSelect[name="playbook"]').simulate('change', {
|
||||||
target: { value: 'new baz type', name: 'playbook' },
|
target: { value: 'new baz type', name: 'playbook' },
|
||||||
});
|
});
|
||||||
@@ -221,6 +223,9 @@ describe('<JobTemplateForm />', () => {
|
|||||||
expect(wrapper.find('input#template-scm-branch').prop('value')).toEqual(
|
expect(wrapper.find('input#template-scm-branch').prop('value')).toEqual(
|
||||||
'devel'
|
'devel'
|
||||||
);
|
);
|
||||||
|
expect(wrapper.find('input#template-limit').prop('value')).toEqual(
|
||||||
|
1234567890
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('AnsibleSelect[name="playbook"]').prop('value')
|
wrapper.find('AnsibleSelect[name="playbook"]').prop('value')
|
||||||
).toEqual('new baz type');
|
).toEqual('new baz type');
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ async function loadLabelOptions(setLabels, onError) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LabelSelect({ value, placeholder, onChange, onError, createText }) {
|
function LabelSelect({ value, placeholder, onChange, onError, createText }) {
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const { selections, onSelect, options, setOptions } = useSyncedSelectValue(
|
const { selections, onSelect, options, setOptions } = useSyncedSelectValue(
|
||||||
value,
|
value,
|
||||||
onChange
|
onChange
|
||||||
@@ -41,7 +42,10 @@ function LabelSelect({ value, placeholder, onChange, onError, createText }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLabelOptions(setOptions, onError);
|
(async () => {
|
||||||
|
await loadLabelOptions(setOptions, onError);
|
||||||
|
setIsLoading(false);
|
||||||
|
})();
|
||||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -77,6 +81,7 @@ function LabelSelect({ value, placeholder, onChange, onError, createText }) {
|
|||||||
}
|
}
|
||||||
return label;
|
return label;
|
||||||
}}
|
}}
|
||||||
|
isDisabled={isLoading}
|
||||||
selections={selections}
|
selections={selections}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
ariaLabelledBy="label-select"
|
ariaLabelledBy="label-select"
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { number, string, oneOfType } from 'prop-types';
|
import { number, string, oneOfType } from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import AnsibleSelect from '../../../components/AnsibleSelect';
|
import AnsibleSelect from '../../../components/AnsibleSelect';
|
||||||
import { ProjectsAPI } from '../../../api';
|
import { ProjectsAPI } from '../../../api';
|
||||||
|
import useRequest from '../../../util/useRequest';
|
||||||
|
|
||||||
function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) {
|
function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) {
|
||||||
const [options, setOptions] = useState([]);
|
const {
|
||||||
|
result: options,
|
||||||
useEffect(() => {
|
request: fetchOptions,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
return;
|
return [];
|
||||||
}
|
}
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await ProjectsAPI.readPlaybooks(projectId);
|
const { data } = await ProjectsAPI.readPlaybooks(projectId);
|
||||||
const opts = (data || []).map(playbook => ({
|
const opts = (data || []).map(playbook => ({
|
||||||
value: playbook,
|
value: playbook,
|
||||||
@@ -28,12 +31,21 @@ function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) {
|
|||||||
label: i18n._(t`Choose a playbook`),
|
label: i18n._(t`Choose a playbook`),
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
});
|
});
|
||||||
setOptions(opts);
|
return opts;
|
||||||
} catch (contentError) {
|
}, [projectId, i18n]),
|
||||||
onError(contentError);
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOptions();
|
||||||
|
}, [fetchOptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
onError(error);
|
||||||
}
|
}
|
||||||
})();
|
}, [error, onError]);
|
||||||
}, [projectId, i18n, onError]);
|
|
||||||
return (
|
return (
|
||||||
<AnsibleSelect
|
<AnsibleSelect
|
||||||
id="template-playbook"
|
id="template-playbook"
|
||||||
@@ -41,6 +53,7 @@ function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) {
|
|||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
{...field}
|
{...field}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
|
isDisabled={isLoading}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ function User({ i18n, setBreadcrumb }) {
|
|||||||
<UserOrganizations id={Number(match.params.id)} />
|
<UserOrganizations id={Number(match.params.id)} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/users/:id/teams">
|
<Route path="/users/:id/teams">
|
||||||
<UserTeams id={Number(match.params.id)} />
|
<UserTeams userId={Number(match.params.id)} />
|
||||||
</Route>
|
</Route>
|
||||||
{user && (
|
{user && (
|
||||||
<Route path="/users/:id/access">
|
<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 { useParams, useLocation } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
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 { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||||
import { UsersAPI } from '../../../api';
|
import { UsersAPI, RolesAPI } from '../../../api';
|
||||||
import useRequest from '../../../util/useRequest';
|
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||||
import PaginatedDataList, {
|
import PaginatedDataList from '../../../components/PaginatedDataList';
|
||||||
ToolbarAddButton,
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
} from '../../../components/PaginatedDataList';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
|
|
||||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
import DatalistToolbar from '../../../components/DataListToolbar';
|
||||||
import UserAccessListItem from './UserAccessListItem';
|
import UserAccessListItem from './UserAccessListItem';
|
||||||
|
import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('roles', {
|
const QS_CONFIG = getQSConfig('roles', {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -22,7 +32,9 @@ const QS_CONFIG = getQSConfig('roles', {
|
|||||||
function UserAccessList({ i18n }) {
|
function UserAccessList({ i18n }) {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
|
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||||
|
|
||||||
|
const [roleToDisassociate, setRoleToDisassociate] = useState(null);
|
||||||
const {
|
const {
|
||||||
isLoading,
|
isLoading,
|
||||||
request: fetchRoles,
|
request: fetchRoles,
|
||||||
@@ -52,9 +64,31 @@ function UserAccessList({ i18n }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRoles();
|
fetchRoles();
|
||||||
}, [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 =
|
const canAdd =
|
||||||
options && Object.prototype.hasOwnProperty.call(options, 'POST');
|
options && Object.prototype.hasOwnProperty.call(options, 'POST');
|
||||||
|
|
||||||
|
const saveRoles = () => {
|
||||||
|
setIsWizardOpen(false);
|
||||||
|
fetchRoles();
|
||||||
|
};
|
||||||
|
|
||||||
const detailUrl = role => {
|
const detailUrl = role => {
|
||||||
const { resource_id, resource_type } = role.summary_fields;
|
const { resource_id, resource_type } = role.summary_fields;
|
||||||
|
|
||||||
@@ -70,11 +104,27 @@ function UserAccessList({ i18n }) {
|
|||||||
}
|
}
|
||||||
return `/${resource_type}s/${resource_id}/details`;
|
return `/${resource_type}s/${resource_id}/details`;
|
||||||
};
|
};
|
||||||
|
const isSysAdmin = roles.some(role => role.name === 'System Administrator');
|
||||||
|
if (isSysAdmin) {
|
||||||
return (
|
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
|
<PaginatedDataList
|
||||||
contentError={error}
|
contentError={error}
|
||||||
hasContentLoading={isLoading}
|
hasContentLoading={isLoading || isDisassociateLoading}
|
||||||
items={roles}
|
items={roles}
|
||||||
itemCount={roleCount}
|
itemCount={roleCount}
|
||||||
pluralizedItemName={i18n._(t`User Roles`)}
|
pluralizedItemName={i18n._(t`User Roles`)}
|
||||||
@@ -99,8 +149,10 @@ function UserAccessList({ i18n }) {
|
|||||||
value={role.name}
|
value={role.name}
|
||||||
role={role}
|
role={role}
|
||||||
detailUrl={detailUrl(role)}
|
detailUrl={detailUrl(role)}
|
||||||
onSelect={() => {}}
|
|
||||||
isSelected={false}
|
isSelected={false}
|
||||||
|
onSelect={item => {
|
||||||
|
setRoleToDisassociate(item);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -109,11 +161,79 @@ function UserAccessList({ i18n }) {
|
|||||||
{...props}
|
{...props}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
additionalControls={[
|
additionalControls={[
|
||||||
...(canAdd ? [<ToolbarAddButton key="add" linkTo="/" />] : []),
|
...(canAdd
|
||||||
|
? [
|
||||||
|
<Button
|
||||||
|
key="add"
|
||||||
|
aria-label={i18n._(t`Add resource roles`)}
|
||||||
|
onClick={() => {
|
||||||
|
setIsWizardOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{isWizardOpen && (
|
||||||
|
<UserAndTeamAccessAdd
|
||||||
|
apiModel={UsersAPI}
|
||||||
|
isOpen={isWizardOpen}
|
||||||
|
onSave={saveRoles}
|
||||||
|
onClose={() => setIsWizardOpen(false)}
|
||||||
|
title={i18n._(t`Add user permissions`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{roleToDisassociate && (
|
||||||
|
<AlertModal
|
||||||
|
aria-label={i18n._(t`Disassociate role`)}
|
||||||
|
isOpen={roleToDisassociate}
|
||||||
|
variant="error"
|
||||||
|
title={i18n._(t`Disassociate role!`)}
|
||||||
|
onClose={() => setRoleToDisassociate(null)}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="disassociate"
|
||||||
|
variant="danger"
|
||||||
|
aria-label={i18n._(t`confirm disassociate`)}
|
||||||
|
onClick={() => disassociateRole()}
|
||||||
|
>
|
||||||
|
{i18n._(t`Disassociate`)}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="cancel"
|
||||||
|
variant="secondary"
|
||||||
|
aria-label={i18n._(t`Cancel`)}
|
||||||
|
onClick={() => setRoleToDisassociate(null)}
|
||||||
|
>
|
||||||
|
{i18n._(t`Cancel`)}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{i18n._(
|
||||||
|
t`This action will disassociate the following role from ${roleToDisassociate.summary_fields.resource_name}:`
|
||||||
|
)}
|
||||||
|
<br />
|
||||||
|
<strong>{roleToDisassociate.name}</strong>
|
||||||
|
</div>
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
{disassociationError && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={disassociationError}
|
||||||
|
variant="error"
|
||||||
|
title={i18n._(t`Error!`)}
|
||||||
|
onClose={clearDisassociationError}
|
||||||
|
>
|
||||||
|
{i18n._(t`Failed to delete role.`)}
|
||||||
|
<ErrorDetail error={disassociationError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export default withI18n()(UserAccessList);
|
export default withI18n()(UserAccessList);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { UsersAPI, RolesAPI } from '../../../api';
|
||||||
import { Route } from 'react-router-dom';
|
|
||||||
import { UsersAPI } from '../../../api';
|
|
||||||
import {
|
import {
|
||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
@@ -10,11 +8,15 @@ import {
|
|||||||
import UserAccessList from './UserAccessList';
|
import UserAccessList from './UserAccessList';
|
||||||
|
|
||||||
jest.mock('../../../api/models/Users');
|
jest.mock('../../../api/models/Users');
|
||||||
describe('<UserAccessList />', () => {
|
jest.mock('../../../api/models/Roles');
|
||||||
let wrapper;
|
|
||||||
let history;
|
jest.mock('react-router-dom', () => ({
|
||||||
beforeEach(async () => {
|
...jest.requireActual('react-router-dom'),
|
||||||
UsersAPI.readRoles.mockResolvedValue({
|
useParams: () => ({
|
||||||
|
id: 18,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const roles = {
|
||||||
data: {
|
data: {
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
@@ -83,46 +85,37 @@ describe('<UserAccessList />', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
count: 4,
|
count: 5,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
const options = {
|
||||||
UsersAPI.readRoleOptions.mockResolvedValue({
|
|
||||||
data: { actions: { POST: { id: 1, disassociate: true } } },
|
data: { actions: { POST: { id: 1, disassociate: true } } },
|
||||||
});
|
};
|
||||||
|
describe('<UserAccessList />', () => {
|
||||||
history = createMemoryHistory({
|
let wrapper;
|
||||||
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(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
wrapper.unmount();
|
// wrapper.unmount();
|
||||||
});
|
});
|
||||||
test('should render properly', async () => {
|
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);
|
expect(wrapper.find('UserAccessList').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create proper detailUrl', async () => {
|
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);
|
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||||
|
|
||||||
expect(wrapper.find(`Link#userRole-2`).prop('to')).toBe(
|
expect(wrapper.find(`Link#userRole-2`).prop('to')).toBe(
|
||||||
@@ -141,4 +134,195 @@ describe('<UserAccessList />', () => {
|
|||||||
'/inventories/smart_inventory/77/details'
|
'/inventories/smart_inventory/77/details'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
test('should not render add button', async () => {
|
||||||
|
UsersAPI.readRoleOptions.mockResolvedValueOnce({
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
UsersAPI.readRoles.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Admin',
|
||||||
|
type: 'role',
|
||||||
|
url: '/api/v2/roles/257/',
|
||||||
|
summary_fields: {
|
||||||
|
resource_name: 'template delete project',
|
||||||
|
resource_id: 15,
|
||||||
|
resource_type: 'job_template',
|
||||||
|
resource_type_display_name: 'Job Template',
|
||||||
|
user_capabilities: { unattach: true },
|
||||||
|
object_roles: {
|
||||||
|
admin_role: {
|
||||||
|
description: 'Can manage all aspects of the job template',
|
||||||
|
name: 'Admin',
|
||||||
|
id: 164,
|
||||||
|
},
|
||||||
|
execute_role: {
|
||||||
|
description: 'May run the job template',
|
||||||
|
name: 'Execute',
|
||||||
|
id: 165,
|
||||||
|
},
|
||||||
|
read_role: {
|
||||||
|
description: 'May view settings for the job template',
|
||||||
|
name: 'Read',
|
||||||
|
id: 166,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<UserAccessList />);
|
||||||
|
});
|
||||||
|
|
||||||
|
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||||
|
expect(wrapper.find('Button[aria-label="Add resource roles"]').length).toBe(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('should open and close wizard', async () => {
|
||||||
|
UsersAPI.readRoles.mockResolvedValue(roles);
|
||||||
|
UsersAPI.readRoleOptions.mockResolvedValue(options);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<UserAccessList />);
|
||||||
|
});
|
||||||
|
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||||
|
await act(async () =>
|
||||||
|
wrapper.find('Button[aria-label="Add resource roles"]').prop('onClick')()
|
||||||
|
);
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('PFWizard').length).toBe(1);
|
||||||
|
await act(async () =>
|
||||||
|
wrapper.find("Button[aria-label='Close']").prop('onClick')()
|
||||||
|
);
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('PFWizard').length).toBe(0);
|
||||||
|
});
|
||||||
|
test('should render disassociate modal', async () => {
|
||||||
|
UsersAPI.readRoles.mockResolvedValue(roles);
|
||||||
|
UsersAPI.readRoleOptions.mockResolvedValue(options);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<UserAccessList />);
|
||||||
|
});
|
||||||
|
|
||||||
|
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||||
|
|
||||||
|
await act(async () =>
|
||||||
|
wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({
|
||||||
|
id: 4,
|
||||||
|
name: 'Execute',
|
||||||
|
type: 'role',
|
||||||
|
url: '/api/v2/roles/258/',
|
||||||
|
summary_fields: {
|
||||||
|
resource_name: 'Credential Bar',
|
||||||
|
resource_id: 75,
|
||||||
|
resource_type: 'credential',
|
||||||
|
resource_type_display_name: 'Credential',
|
||||||
|
user_capabilities: { unattach: true },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[aria-label="Disassociate role"]').length
|
||||||
|
).toBe(1);
|
||||||
|
await act(async () =>
|
||||||
|
wrapper
|
||||||
|
.find('button[aria-label="confirm disassociate"]')
|
||||||
|
.prop('onClick')()
|
||||||
|
);
|
||||||
|
expect(RolesAPI.disassociateUserRole).toBeCalledWith(4, 18);
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[aria-label="Disassociate role"]').length
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
test('should throw disassociation error', async () => {
|
||||||
|
UsersAPI.readRoles.mockResolvedValue(roles);
|
||||||
|
RolesAPI.disassociateUserRole.mockRejectedValue(
|
||||||
|
new Error({
|
||||||
|
response: {
|
||||||
|
config: {
|
||||||
|
method: 'post',
|
||||||
|
url: '/api/v2/roles/18/roles',
|
||||||
|
},
|
||||||
|
data: 'An error occurred',
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
UsersAPI.readRoleOptions.mockResolvedValue(options);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<UserAccessList />);
|
||||||
|
});
|
||||||
|
|
||||||
|
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||||
|
|
||||||
|
await act(async () =>
|
||||||
|
wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({
|
||||||
|
id: 4,
|
||||||
|
name: 'Execute',
|
||||||
|
type: 'role',
|
||||||
|
url: '/api/v2/roles/258/',
|
||||||
|
summary_fields: {
|
||||||
|
resource_name: 'Credential Bar',
|
||||||
|
resource_id: 75,
|
||||||
|
resource_type: 'credential',
|
||||||
|
resource_type_display_name: 'Credential',
|
||||||
|
user_capabilities: { unattach: true },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[aria-label="Disassociate role"]').length
|
||||||
|
).toBe(1);
|
||||||
|
await act(async () =>
|
||||||
|
wrapper
|
||||||
|
.find('button[aria-label="confirm disassociate"]')
|
||||||
|
.prop('onClick')()
|
||||||
|
);
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('AlertModal[title="Error!"]').length).toBe(1);
|
||||||
|
});
|
||||||
|
test('user with sys admin privilege should show empty state', async () => {
|
||||||
|
UsersAPI.readRoles.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'System Administrator',
|
||||||
|
type: 'role',
|
||||||
|
url: '/api/v2/roles/257/',
|
||||||
|
summary_fields: {
|
||||||
|
resource_name: 'template delete project',
|
||||||
|
resource_id: 15,
|
||||||
|
resource_type: 'job_template',
|
||||||
|
resource_type_display_name: 'Job Template',
|
||||||
|
user_capabilities: { unattach: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
UsersAPI.readRoleOptions.mockResolvedValue(options);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<UserAccessList />);
|
||||||
|
});
|
||||||
|
|
||||||
|
waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'EmptyState[title="System Administrator"]',
|
||||||
|
el => el.length === 1
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user