Merge branch 'devel' into devel

This commit is contained in:
mo-saeed 2020-06-06 00:19:19 +02:00 committed by GitHub
commit 0bfcacfcf4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
172 changed files with 4810 additions and 1885 deletions

View File

@ -3,6 +3,7 @@
This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<version>`.
## 12.0.0 (TBD)
- Moved to a single container image build instead of separate awx_web and awx_task images. The container image is just `awx` (https://github.com/ansible/awx/pull/7228)
- Official AWX container image builds now use a two-stage container build process that notably reduces the size of our published images (https://github.com/ansible/awx/pull/7017)
- Removed support for HipChat notifications ([EoL announcement](https://www.atlassian.com/partnerships/slack/faq#faq-98b17ca3-247f-423b-9a78-70a91681eff0)); all previously-created HipChat notification templates will be deleted due to this removal.
- Fixed a bug which broke AWX installations with oc version 4.3 (https://github.com/ansible/awx/pull/6948/files)
@ -10,11 +11,13 @@ This is a list of high-level changes for each release of AWX. A full list of com
- Fixed a bug that caused CyberArk AIM credential plugin looks to hang forever in some environments (https://github.com/ansible/awx/issues/6986)
- Fixed a bug that caused ANY/ALL converage settings not to properly save when editing approval nodes in the UI (https://github.com/ansible/awx/issues/6998)
- Fixed a bug that broke support for the satellite6_group_prefix source variable (https://github.com/ansible/awx/issues/7031)
- Fixed a bug that prevented the usage of the Conjur credential plugin with secrets that contain spaces (https://github.com/ansible/awx/issues/7191)
- Fixed a bug in awx-manage run_wsbroadcast --status in kubernetes (https://github.com/ansible/awx/pull/7009)
- Fixed a bug that broke notification toggles for system jobs in the UI (https://github.com/ansible/awx/pull/7042)
- Fixed a bug that broke local pip installs of awxkit (https://github.com/ansible/awx/issues/7107)
- Fixed a bug that prevented PagerDuty notifications from sending for workflow job template approvals (https://github.com/ansible/awx/issues/7094)
- Fixed a bug that broke external log aggregation support for URL paths that include the = character (such as the tokens for SumoLogic) (https://github.com/ansible/awx/issues/7139)
- Fixed a bug that prevented organization admins from removing labels from workflow job templates (https://github.com/ansible/awx/pull/7143)
## 11.2.0 (Apr 29, 2020)

View File

@ -109,7 +109,7 @@ In the sections below, you'll find deployment details and instructions for each
### Official vs Building Images
When installing AWX you have the option of building your own images or using the images provided on DockerHub (see [awx_web](https://hub.docker.com/r/ansible/awx_web/) and [awx_task](https://hub.docker.com/r/ansible/awx_task/))
When installing AWX you have the option of building your own image or using the image provided on DockerHub (see [awx](https://hub.docker.com/r/ansible/awx/))
This is controlled by the following variables in the `inventory` file
@ -122,7 +122,7 @@ If these variables are present then all deployments will use these hosted images
*dockerhub_base*
> The base location on DockerHub where the images are hosted (by default this pulls container images named `ansible/awx_web:tag` and `ansible/awx_task:tag`)
> The base location on DockerHub where the images are hosted (by default this pulls a container image named `ansible/awx:tag`)
*dockerhub_version*

View File

@ -495,7 +495,7 @@ class NotificationAttachMixin(BaseAccess):
# due to this special case, we use symmetrical logic with attach permission
return self._can_attach(notification_template=sub_obj, resource_obj=obj)
return super(NotificationAttachMixin, self).can_unattach(
obj, sub_obj, relationship, relationship, data=data
obj, sub_obj, relationship, data=data
)

View File

@ -1,7 +1,7 @@
from .plugin import CredentialPlugin, CertFiles
import base64
from urllib.parse import urljoin, quote_plus
from urllib.parse import urljoin, quote
from django.utils.translation import ugettext_lazy as _
import requests
@ -50,9 +50,9 @@ conjur_inputs = {
def conjur_backend(**kwargs):
url = kwargs['url']
api_key = kwargs['api_key']
account = quote_plus(kwargs['account'])
username = quote_plus(kwargs['username'])
secret_path = quote_plus(kwargs['secret_path'])
account = quote(kwargs['account'], safe='')
username = quote(kwargs['username'], safe='')
secret_path = quote(kwargs['secret_path'], safe='')
version = kwargs.get('secret_version')
cacert = kwargs.get('cacert', None)

View 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

View File

@ -6,24 +6,33 @@ Have questions about this document or anything not covered here? Feel free to re
## Table of contents
* [Things to know prior to submitting code](#things-to-know-prior-to-submitting-code)
* [Setting up your development environment](#setting-up-your-development-environment)
* [Prerequisites](#prerequisites)
* [Node and npm](#node-and-npm)
* [Build the user interface](#build-the-user-interface)
* [Accessing the AWX web interface](#accessing-the-awx-web-interface)
* [AWX REST API Interaction](#awx-rest-api-interaction)
* [Handling API Errors](#handling-api-errors)
* [Forms](#forms)
* [Working with React](#working-with-react)
* [App structure](#app-structure)
* [Naming files](#naming-files)
* [Class constructors vs Class properties](#class-constructors-vs-class-properties)
* [Binding](#binding)
* [Typechecking with PropTypes](#typechecking-with-proptypes)
* [Naming Functions](#naming-functions)
* [Default State Initialization](#default-state-initialization)
* [Internationalization](#internationalization)
- [Ansible AWX UI With PatternFly](#ansible-awx-ui-with-patternfly)
- [Table of contents](#table-of-contents)
- [Things to know prior to submitting code](#things-to-know-prior-to-submitting-code)
- [Setting up your development environment](#setting-up-your-development-environment)
- [Prerequisites](#prerequisites)
- [Node and npm](#node-and-npm)
- [Build the User Interface](#build-the-user-interface)
- [Accessing the AWX web interface](#accessing-the-awx-web-interface)
- [AWX REST API Interaction](#awx-rest-api-interaction)
- [Handling API Errors](#handling-api-errors)
- [Forms](#forms)
- [Working with React](#working-with-react)
- [App structure](#app-structure)
- [Patterns](#patterns)
- [Bootstrapping the application (root src/ files)](#bootstrapping-the-application-root-src-files)
- [Naming files](#naming-files)
- [Naming components that use the context api](#naming-components-that-use-the-context-api)
- [Class constructors vs Class properties](#class-constructors-vs-class-properties)
- [Binding](#binding)
- [Typechecking with PropTypes](#typechecking-with-proptypes)
- [Naming Functions](#naming-functions)
- [Default State Initialization](#default-state-initialization)
- [Testing components that use contexts](#testing-components-that-use-contexts)
- [Internationalization](#internationalization)
- [Marking strings for translation and replacement in the UI](#marking-strings-for-translation-and-replacement-in-the-ui)
- [Setting up .po files to give to translation team](#setting-up-po-files-to-give-to-translation-team)
- [Marking an issue to be translated](#marking-an-issue-to-be-translated)
## Things to know prior to submitting code
@ -35,7 +44,7 @@ Have questions about this document or anything not covered here? Feel free to re
- functions should adopt camelCase
- constructors/classes should adopt PascalCase
- constants to be exported should adopt UPPERCASE
- For strings, we adopt the `sentence capitalization` since it is a (patternfly style guide)[https://www.patternfly.org/v4/design-guidelines/content/grammar-and-terminology#capitalization].
- For strings, we adopt the `sentence capitalization` since it is a [Patternfly style guide](https://www.patternfly.org/v4/design-guidelines/content/grammar-and-terminology#capitalization).
## Setting up your development environment
@ -237,21 +246,21 @@ About.defaultProps = {
### Naming Functions
Here are the guidelines for how to name functions.
| Naming Convention | Description |
|----------|-------------|
|`handle<x>`| Use for methods that process events |
|`on<x>`| Use for component prop names |
|`toggle<x>`| Use for methods that flip one value to the opposite value |
|`show<x>`| Use for methods that always set a value to show or add an element |
|`hide<x>`| Use for methods that always set a value to hide or remove an element |
|`create<x>`| Use for methods that make API `POST` requests |
|`read<x>`| Use for methods that make API `GET` requests |
|`update<x>`| Use for methods that make API `PATCH` requests |
|`destroy<x>`| Use for methods that make API `DESTROY` requests |
|`replace<x>`| Use for methods that make API `PUT` requests |
|`disassociate<x>`| Use for methods that pass `{ disassociate: true }` as a data param to an endpoint |
|`associate<x>`| Use for methods that pass a resource id as a data param to an endpoint |
|`can<x>`| Use for props dealing with RBAC to denote whether a user has access to something |
| Naming Convention | Description |
| ----------------- | --------------------------------------------------------------------------------- |
| `handle<x>` | Use for methods that process events |
| `on<x>` | Use for component prop names |
| `toggle<x>` | Use for methods that flip one value to the opposite value |
| `show<x>` | Use for methods that always set a value to show or add an element |
| `hide<x>` | Use for methods that always set a value to hide or remove an element |
| `create<x>` | Use for methods that make API `POST` requests |
| `read<x>` | Use for methods that make API `GET` requests |
| `update<x>` | Use for methods that make API `PATCH` requests |
| `destroy<x>` | Use for methods that make API `DESTROY` requests |
| `replace<x>` | Use for methods that make API `PUT` requests |
| `disassociate<x>` | Use for methods that pass `{ disassociate: true }` as a data param to an endpoint |
| `associate<x>` | Use for methods that pass a resource id as a data param to an endpoint |
| `can<x>` | Use for props dealing with RBAC to denote whether a user has access to something |
### Default State Initialization
When declaring empty initial states, prefer the following instead of leaving them undefined:
@ -320,3 +329,9 @@ You can learn more about the ways lingui and its React helpers at [this link](ht
3) Open up the .po file for the language you want to test and add some translations. In production we would pass this .po file off to the translation team.
4) Once you've edited your .po file (or we've gotten a .po file back from the translation team) run `npm run compile-strings`. This command takes the .po files and turns them into a minified JSON object and can be seen in the `messages.js` file in each locale directory. These files get loaded at the App root level (see: App.jsx).
5) Change the language in your browser and reload the page. You should see your specified translations in place of English strings.
### Marking an issue to be translated
1) Issues marked with `component:I10n` should not be closed after the issue was fixed.
2) Remove the label `state:needs_devel`.
3) Add the label `state:pending_translations`. At this point, the translations will be batch translated by a maintainer, creating relevant entries in the PO files. Then after those translations have been merged, the issue can be closed.

View File

@ -6690,9 +6690,9 @@
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
},
"eventemitter3": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz",
"integrity": "sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg=="
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.4.tgz",
"integrity": "sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ=="
},
"events": {
"version": "3.1.0",
@ -7996,9 +7996,9 @@
"integrity": "sha1-ksnBN0w1CF912zWexWzCV8u5P6Q="
},
"http-proxy": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.0.tgz",
"integrity": "sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==",
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"requires": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",

View File

@ -1,221 +1,82 @@
import React, { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import { global_breakpoint_md } from '@patternfly/react-tokens';
import React from 'react';
import {
Nav,
NavList,
Page,
PageHeader as PFPageHeader,
PageSidebar,
} from '@patternfly/react-core';
import styled from 'styled-components';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
useRouteMatch,
useLocation,
HashRouter,
Route,
Switch,
Redirect,
} from 'react-router-dom';
import { I18n, I18nProvider } from '@lingui/react';
import { ConfigAPI, MeAPI, RootAPI } from './api';
import About from './components/About';
import AlertModal from './components/AlertModal';
import NavExpandableGroup from './components/NavExpandableGroup';
import BrandLogo from './components/BrandLogo';
import PageHeaderToolbar from './components/PageHeaderToolbar';
import ErrorDetail from './components/ErrorDetail';
import { ConfigProvider } from './contexts/Config';
import AppContainer from './components/AppContainer';
import Background from './components/Background';
import NotFound from './screens/NotFound';
import Login from './screens/Login';
const PageHeader = styled(PFPageHeader)`
& .pf-c-page__header-brand-link {
color: inherit;
import ja from './locales/ja/messages';
import en from './locales/en/messages';
import { isAuthenticated } from './util/auth';
import { getLanguageWithoutRegionCode } from './util/language';
&:hover {
color: inherit;
}
import getRouteConfig from './routeConfig';
& svg {
height: 76px;
}
}
`;
const ProtectedRoute = ({ children, ...rest }) =>
isAuthenticated(document.cookie) ? (
<Route {...rest}>{children}</Route>
) : (
<Redirect to="/login" />
);
class App extends Component {
constructor(props) {
super(props);
function App() {
const catalogs = { en, ja };
const language = getLanguageWithoutRegionCode(navigator);
const match = useRouteMatch();
const { hash, search, pathname } = useLocation();
// initialize with a closed navbar if window size is small
const isNavOpen =
typeof window !== 'undefined' &&
window.innerWidth >= parseInt(global_breakpoint_md.value, 10);
this.state = {
ansible_version: null,
custom_virtualenvs: null,
me: null,
version: null,
isAboutModalOpen: false,
isNavOpen,
configError: null,
};
this.handleLogout = this.handleLogout.bind(this);
this.handleAboutClose = this.handleAboutClose.bind(this);
this.handleAboutOpen = this.handleAboutOpen.bind(this);
this.handleNavToggle = this.handleNavToggle.bind(this);
this.handleConfigErrorClose = this.handleConfigErrorClose.bind(this);
}
async componentDidMount() {
await this.loadConfig();
}
// eslint-disable-next-line class-methods-use-this
async handleLogout() {
const { history } = this.props;
await RootAPI.logout();
history.replace('/login');
}
handleAboutOpen() {
this.setState({ isAboutModalOpen: true });
}
handleAboutClose() {
this.setState({ isAboutModalOpen: false });
}
handleNavToggle() {
this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen }));
}
handleConfigErrorClose() {
this.setState({
configError: null,
});
}
async loadConfig() {
try {
const [configRes, meRes] = await Promise.all([
ConfigAPI.read(),
MeAPI.read(),
]);
const {
data: {
ansible_version,
custom_virtualenvs,
project_base_dir,
project_local_paths,
version,
},
} = configRes;
const {
data: {
results: [me],
},
} = meRes;
this.setState({
ansible_version,
custom_virtualenvs,
project_base_dir,
project_local_paths,
version,
me,
});
} catch (err) {
this.setState({ configError: err });
}
}
render() {
const {
ansible_version,
custom_virtualenvs,
project_base_dir,
project_local_paths,
isAboutModalOpen,
isNavOpen,
me,
version,
configError,
} = this.state;
const {
i18n,
render = () => {},
routeGroups = [],
navLabel = '',
} = this.props;
const header = (
<PageHeader
showNavToggle
onNavToggle={this.handleNavToggle}
logo={<BrandLogo />}
logoProps={{ href: '/' }}
toolbar={
<PageHeaderToolbar
loggedInUser={me}
isAboutDisabled={!version}
onAboutClick={this.handleAboutOpen}
onLogoutClick={this.handleLogout}
/>
}
/>
);
const sidebar = (
<PageSidebar
isNavOpen={isNavOpen}
theme="dark"
nav={
<Nav aria-label={navLabel} theme="dark">
<NavList>
{routeGroups.map(({ groupId, groupTitle, routes }) => (
<NavExpandableGroup
key={groupId}
groupId={groupId}
groupTitle={groupTitle}
routes={routes}
/>
))}
</NavList>
</Nav>
}
/>
);
return (
<Fragment>
<Page usecondensed="True" header={header} sidebar={sidebar}>
<ConfigProvider
value={{
ansible_version,
custom_virtualenvs,
project_base_dir,
project_local_paths,
me,
version,
}}
>
{render({ routeGroups })}
</ConfigProvider>
</Page>
<About
ansible_version={ansible_version}
version={version}
isOpen={isAboutModalOpen}
onClose={this.handleAboutClose}
/>
<AlertModal
isOpen={configError}
variant="error"
title={i18n._(t`Error!`)}
onClose={this.handleConfigErrorClose}
>
{i18n._(t`Failed to retrieve configuration.`)}
<ErrorDetail error={configError} />
</AlertModal>
</Fragment>
);
}
return (
<I18nProvider language={language} catalogs={catalogs}>
<I18n>
{({ i18n }) => (
<Background>
<Switch>
<Route exact strict path="/*/">
<Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />
</Route>
<Route path="/login">
<Login isAuthenticated={isAuthenticated} />
</Route>
<Route exact path="/">
<Redirect to="/home" />
</Route>
<ProtectedRoute>
<AppContainer navRouteConfig={getRouteConfig(i18n)}>
<Switch>
{getRouteConfig(i18n)
.flatMap(({ routes }) => routes)
.map(({ path, screen: Screen }) => (
<ProtectedRoute key={path} path={path}>
<Screen match={match} />
</ProtectedRoute>
))
.concat(
<ProtectedRoute key="not-found" path="*">
<NotFound />
</ProtectedRoute>
)}
</Switch>
</AppContainer>
</ProtectedRoute>
</Switch>
</Background>
)}
</I18n>
</I18nProvider>
);
}
export { App as _App };
export default withI18n()(withRouter(App));
export default () => (
<HashRouter>
<App />
</HashRouter>
);

View File

@ -1,121 +1,14 @@
import React from 'react';
import { mountWithContexts, waitForElement } from '../testUtils/enzymeHelpers';
import { ConfigAPI, MeAPI, RootAPI } from './api';
import { asyncFlush } from './setupTests';
import { mountWithContexts } from '../testUtils/enzymeHelpers';
import App from './App';
jest.mock('./api');
describe('<App />', () => {
const ansible_version = '111';
const custom_virtualenvs = [];
const version = '222';
beforeEach(() => {
ConfigAPI.read = () =>
Promise.resolve({
data: {
ansible_version,
custom_virtualenvs,
version,
},
});
MeAPI.read = () => Promise.resolve({ data: { results: [{}] } });
});
afterEach(() => {
jest.clearAllMocks();
});
test('expected content is rendered', () => {
const appWrapper = mountWithContexts(
<App
routeGroups={[
{
groupTitle: 'Group One',
groupId: 'group_one',
routes: [
{ title: 'Foo', path: '/foo' },
{ title: 'Bar', path: '/bar' },
],
},
{
groupTitle: 'Group Two',
groupId: 'group_two',
routes: [{ title: 'Fiz', path: '/fiz' }],
},
]}
render={({ routeGroups }) =>
routeGroups.map(({ groupId }) => <div key={groupId} id={groupId} />)
}
/>
);
// page components
expect(appWrapper.length).toBe(1);
expect(appWrapper.find('PageHeader').length).toBe(1);
expect(appWrapper.find('PageSidebar').length).toBe(1);
// sidebar groups and route links
expect(appWrapper.find('NavExpandableGroup').length).toBe(2);
expect(appWrapper.find('a[href="/#/foo"]').length).toBe(1);
expect(appWrapper.find('a[href="/#/bar"]').length).toBe(1);
expect(appWrapper.find('a[href="/#/fiz"]').length).toBe(1);
// inline render
expect(appWrapper.find('#group_one').length).toBe(1);
expect(appWrapper.find('#group_two').length).toBe(1);
});
test('opening the about modal renders prefetched config data', async done => {
test('renders ok', () => {
const wrapper = mountWithContexts(<App />);
wrapper.update();
// open about modal
const aboutDropdown = 'Dropdown QuestionCircleIcon';
const aboutButton = 'DropdownItem li button';
const aboutModalContent = 'AboutModalBoxContent';
const aboutModalClose = 'button[aria-label="Close Dialog"]';
await waitForElement(wrapper, aboutDropdown);
wrapper.find(aboutDropdown).simulate('click');
const button = await waitForElement(
wrapper,
aboutButton,
el => !el.props().disabled
);
button.simulate('click');
// check about modal content
const content = await waitForElement(wrapper, aboutModalContent);
expect(content.find('dd').text()).toContain(ansible_version);
expect(content.find('pre').text()).toContain(`< AWX ${version} >`);
// close about modal
wrapper.find(aboutModalClose).simulate('click');
expect(wrapper.find(aboutModalContent)).toHaveLength(0);
done();
});
test('handleNavToggle sets state.isNavOpen to opposite', () => {
const appWrapper = mountWithContexts(<App />).find('App');
const { handleNavToggle } = appWrapper.instance();
[true, false, true, false, true].forEach(expected => {
expect(appWrapper.state().isNavOpen).toBe(expected);
handleNavToggle();
});
});
test('onLogout makes expected call to api client', async done => {
const appWrapper = mountWithContexts(<App />).find('App');
appWrapper.instance().handleLogout();
await asyncFlush();
expect(RootAPI.logout).toHaveBeenCalledTimes(1);
done();
expect(wrapper.length).toBe(1);
});
});

View File

@ -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;

View File

@ -1,5 +1,6 @@
import AdHocCommands from './models/AdHocCommands';
import Config from './models/Config';
import CredentialInputSources from './models/CredentialInputSources';
import CredentialTypes from './models/CredentialTypes';
import Credentials from './models/Credentials';
import Groups from './models/Groups';
@ -14,9 +15,10 @@ import Labels from './models/Labels';
import Me from './models/Me';
import NotificationTemplates from './models/NotificationTemplates';
import Organizations from './models/Organizations';
import Projects from './models/Projects';
import ProjectUpdates from './models/ProjectUpdates';
import Projects from './models/Projects';
import Root from './models/Root';
import Roles from './models/Roles';
import Schedules from './models/Schedules';
import SystemJobs from './models/SystemJobs';
import Teams from './models/Teams';
@ -24,14 +26,15 @@ import UnifiedJobTemplates from './models/UnifiedJobTemplates';
import UnifiedJobs from './models/UnifiedJobs';
import Users from './models/Users';
import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates';
import WorkflowJobs from './models/WorkflowJobs';
import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
import WorkflowJobs from './models/WorkflowJobs';
const AdHocCommandsAPI = new AdHocCommands();
const ConfigAPI = new Config();
const CredentialsAPI = new Credentials();
const CredentialInputSourcesAPI = new CredentialInputSources();
const CredentialTypesAPI = new CredentialTypes();
const CredentialsAPI = new Credentials();
const GroupsAPI = new Groups();
const HostsAPI = new Hosts();
const InstanceGroupsAPI = new InstanceGroups();
@ -44,9 +47,10 @@ const LabelsAPI = new Labels();
const MeAPI = new Me();
const NotificationTemplatesAPI = new NotificationTemplates();
const OrganizationsAPI = new Organizations();
const ProjectsAPI = new Projects();
const ProjectUpdatesAPI = new ProjectUpdates();
const ProjectsAPI = new Projects();
const RootAPI = new Root();
const RolesAPI = new Roles();
const SchedulesAPI = new Schedules();
const SystemJobsAPI = new SystemJobs();
const TeamsAPI = new Teams();
@ -54,15 +58,16 @@ const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
const UnifiedJobsAPI = new UnifiedJobs();
const UsersAPI = new Users();
const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates();
const WorkflowJobsAPI = new WorkflowJobs();
const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes();
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
const WorkflowJobsAPI = new WorkflowJobs();
export {
AdHocCommandsAPI,
ConfigAPI,
CredentialsAPI,
CredentialInputSourcesAPI,
CredentialTypesAPI,
CredentialsAPI,
GroupsAPI,
HostsAPI,
InstanceGroupsAPI,
@ -75,9 +80,10 @@ export {
MeAPI,
NotificationTemplatesAPI,
OrganizationsAPI,
ProjectsAPI,
ProjectUpdatesAPI,
ProjectsAPI,
RootAPI,
RolesAPI,
SchedulesAPI,
SystemJobsAPI,
TeamsAPI,
@ -85,7 +91,7 @@ export {
UnifiedJobsAPI,
UsersAPI,
WorkflowApprovalTemplatesAPI,
WorkflowJobsAPI,
WorkflowJobTemplateNodesAPI,
WorkflowJobTemplatesAPI,
WorkflowJobsAPI,
};

View File

@ -4,6 +4,7 @@ class Config extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/config/';
this.read = this.read.bind(this);
}
}

View 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;

View File

@ -6,6 +6,7 @@ class Credentials extends Base {
this.baseUrl = '/api/v2/credentials/';
this.readAccessList = this.readAccessList.bind(this);
this.readInputSources = this.readInputSources.bind(this);
}
readAccessList(id, params) {
@ -13,6 +14,12 @@ class Credentials extends Base {
params,
});
}
readInputSources(id, params) {
return this.http.get(`${this.baseUrl}${id}/input_sources/`, {
params,
});
}
}
export default Credentials;

View File

@ -1,7 +1,8 @@
import Base from '../Base';
import NotificationsMixin from '../mixins/Notifications.mixin';
import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin';
class InventorySources extends LaunchUpdateMixin(Base) {
class InventorySources extends LaunchUpdateMixin(NotificationsMixin(Base)) {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/inventory_sources/';

View 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;

View File

@ -34,6 +34,16 @@ class Users extends Base {
readRoleOptions(userId) {
return this.http.options(`${this.baseUrl}${userId}/roles/`);
}
readTeams(userId, params) {
return this.http.get(`${this.baseUrl}${userId}/teams/`, {
params,
});
}
readTeamsOptions(userId) {
return this.http.options(`${this.baseUrl}${userId}/teams/`);
}
}
export default Users;

View File

@ -258,7 +258,7 @@ class AddResourceRole extends React.Component {
sortColumns={userSortColumns}
displayKey="username"
onRowClick={this.handleResourceCheckboxClick}
onSearch={readUsers}
fetchItems={readUsers}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
sortedColumnKey="username"
@ -269,7 +269,7 @@ class AddResourceRole extends React.Component {
searchColumns={teamSearchColumns}
sortColumns={teamSortColumns}
onRowClick={this.handleResourceCheckboxClick}
onSearch={readTeams}
fetchItems={readTeams}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
/>

View File

@ -1,19 +1,27 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { Checkbox } from '@patternfly/react-core';
import { Checkbox as PFCheckbox } from '@patternfly/react-core';
import styled from 'styled-components';
const CheckboxWrapper = styled.div`
display: flex;
border: 1px solid var(--pf-global--BorderColor--200);
border-radius: var(--pf-global--BorderRadius--sm);
padding: 10px;
`;
const Checkbox = styled(PFCheckbox)`
width: 100%;
& label {
width: 100%;
}
`;
class CheckboxCard extends Component {
render() {
const { name, description, isSelected, onSelect, itemId } = this.props;
return (
<div
style={{
display: 'flex',
border: '1px solid var(--pf-global--BorderColor--200)',
borderRadius: 'var(--pf-global--BorderRadius--sm)',
padding: '10px',
}}
>
<CheckboxWrapper>
<Checkbox
isChecked={isSelected}
onChange={onSelect}
@ -27,7 +35,7 @@ class CheckboxCard extends Component {
}
value={itemId}
/>
</div>
</CheckboxWrapper>
);
}
}

View File

@ -1,8 +1,10 @@
import React, { Fragment } from 'react';
import React, { Fragment, useCallback, useEffect } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withRouter, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import useRequest from '../../util/useRequest';
import { SearchColumns, SortColumns } from '../../types';
import PaginatedDataList from '../PaginatedDataList';
import DataListToolbar from '../DataListToolbar';
@ -10,124 +12,94 @@ import CheckboxListItem from '../CheckboxListItem';
import SelectedList from '../SelectedList';
import { getQSConfig, parseQueryString } from '../../util/qs';
class SelectResourceStep extends React.Component {
constructor(props) {
super(props);
const QS_Config = sortColumns => {
return getQSConfig('resource', {
page: 1,
page_size: 5,
order_by: `${
sortColumns.filter(col => col.key === 'name').length ? 'name' : 'username'
}`,
});
};
function SelectResourceStep({
searchColumns,
sortColumns,
displayKey,
onRowClick,
selectedLabel,
selectedResourceRows,
fetchItems,
i18n,
}) {
const location = useLocation();
this.state = {
isInitialized: false,
count: null,
error: false,
const {
isLoading,
error,
request: readResourceList,
result: { resources, itemCount },
} = useRequest(
useCallback(async () => {
const queryParams = parseQueryString(
QS_Config(sortColumns),
location.search
);
const {
data: { count, results },
} = await fetchItems(queryParams);
return { resources: results, itemCount: count };
}, [location, fetchItems, sortColumns]),
{
resources: [],
};
this.qsConfig = getQSConfig('resource', {
page: 1,
page_size: 5,
order_by: `${
props.sortColumns.filter(col => col.key === 'name').length
? 'name'
: 'username'
}`,
});
}
componentDidMount() {
this.readResourceList();
}
componentDidUpdate(prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.readResourceList();
itemCount: 0,
}
}
);
async readResourceList() {
const { onSearch, location } = this.props;
const queryParams = parseQueryString(this.qsConfig, location.search);
useEffect(() => {
readResourceList();
}, [readResourceList]);
this.setState({
isLoading: true,
error: false,
});
try {
const { data } = await onSearch(queryParams);
const { count, results } = data;
this.setState({
resources: results,
count,
isInitialized: true,
isLoading: false,
error: false,
});
} catch (err) {
this.setState({
isLoading: false,
error: true,
});
}
}
render() {
const { isInitialized, isLoading, count, error, resources } = this.state;
const {
searchColumns,
sortColumns,
displayKey,
onRowClick,
selectedLabel,
selectedResourceRows,
i18n,
} = this.props;
return (
<Fragment>
{isInitialized && (
<Fragment>
<div>
{i18n._(
t`Choose the resources that will be receiving new roles. You'll be able to select the roles to apply in the next step. Note that the resources chosen here will receive all roles chosen in the next step.`
)}
</div>
{selectedResourceRows.length > 0 && (
<SelectedList
displayKey={displayKey}
label={selectedLabel}
onRemove={onRowClick}
selected={selectedResourceRows}
/>
)}
<PaginatedDataList
hasContentLoading={isLoading}
items={resources}
itemCount={count}
qsConfig={this.qsConfig}
onRowClick={onRowClick}
toolbarSearchColumns={searchColumns}
toolbarSortColumns={sortColumns}
renderItem={item => (
<CheckboxListItem
isSelected={selectedResourceRows.some(i => i.id === item.id)}
itemId={item.id}
key={item.id}
name={item[displayKey]}
label={item[displayKey]}
onSelect={() => onRowClick(item)}
onDeselect={() => onRowClick(item)}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
</Fragment>
return (
<Fragment>
<div>
{i18n._(
t`Choose the resources that will be receiving new roles. You'll be able to select the roles to apply in the next step. Note that the resources chosen here will receive all roles chosen in the next step.`
)}
{error ? <div>error</div> : ''}
</Fragment>
);
}
</div>
{selectedResourceRows.length > 0 && (
<SelectedList
displayKey={displayKey}
label={selectedLabel}
onRemove={onRowClick}
selected={selectedResourceRows}
/>
)}
<PaginatedDataList
hasContentLoading={isLoading}
contentError={error}
items={resources}
itemCount={itemCount}
qsConfig={QS_Config(sortColumns)}
onRowClick={onRowClick}
toolbarSearchColumns={searchColumns}
toolbarSortColumns={sortColumns}
renderItem={item => (
<CheckboxListItem
isSelected={selectedResourceRows.some(i => i.id === item.id)}
itemId={item.id}
key={item.id}
name={item[displayKey]}
label={item[displayKey]}
onSelect={() => onRowClick(item)}
onDeselect={() => onRowClick(item)}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
</Fragment>
);
}
SelectResourceStep.propTypes = {
@ -135,7 +107,7 @@ SelectResourceStep.propTypes = {
sortColumns: SortColumns,
displayKey: PropTypes.string,
onRowClick: PropTypes.func,
onSearch: PropTypes.func.isRequired,
fetchItems: PropTypes.func.isRequired,
selectedLabel: PropTypes.string,
selectedResourceRows: PropTypes.arrayOf(PropTypes.object),
};

View File

@ -1,7 +1,11 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { shallow } from 'enzyme';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import { sleep } from '../../../testUtils/testUtils';
import SelectResourceStep from './SelectResourceStep';
@ -30,12 +34,12 @@ describe('<SelectResourceStep />', () => {
sortColumns={sortColumns}
displayKey="username"
onRowClick={() => {}}
onSearch={() => {}}
fetchItems={() => {}}
/>
);
});
test('fetches resources on mount', async () => {
test('fetches resources on mount and adds items to list', async () => {
const handleSearch = jest.fn().mockResolvedValue({
data: {
count: 2,
@ -45,61 +49,24 @@ describe('<SelectResourceStep />', () => {
],
},
});
mountWithContexts(
<SelectResourceStep
searchColumns={searchColumns}
sortColumns={sortColumns}
displayKey="username"
onRowClick={() => {}}
onSearch={handleSearch}
/>
);
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<SelectResourceStep
searchColumns={searchColumns}
sortColumns={sortColumns}
displayKey="username"
onRowClick={() => {}}
fetchItems={handleSearch}
/>
);
});
expect(handleSearch).toHaveBeenCalledWith({
order_by: 'username',
page: 1,
page_size: 5,
});
});
test('readResourceList properly adds rows to state', async () => {
const selectedResourceRows = [{ id: 1, username: 'foo', url: 'item/1' }];
const handleSearch = jest.fn().mockResolvedValue({
data: {
count: 2,
results: [
{ id: 1, username: 'foo', url: 'item/1' },
{ id: 2, username: 'bar', url: 'item/2' },
],
},
});
const history = createMemoryHistory({
initialEntries: [
'/organizations/1/access?resource.page=1&resource.order_by=-username',
],
});
const wrapper = mountWithContexts(
<SelectResourceStep
searchColumns={searchColumns}
sortColumns={sortColumns}
displayKey="username"
onRowClick={() => {}}
onSearch={handleSearch}
selectedResourceRows={selectedResourceRows}
/>,
{
context: { router: { history, route: { location: history.location } } },
}
).find('SelectResourceStep');
await wrapper.instance().readResourceList();
expect(handleSearch).toHaveBeenCalledWith({
order_by: '-username',
page: 1,
page_size: 5,
});
expect(wrapper.state('resources')).toEqual([
{ id: 1, username: 'foo', url: 'item/1' },
{ id: 2, username: 'bar', url: 'item/2' },
]);
waitForElement(wrapper, 'CheckBoxListItem', el => el.length === 2);
});
test('clicking on row fires callback with correct params', async () => {
@ -111,20 +78,24 @@ describe('<SelectResourceStep />', () => {
{ id: 2, username: 'bar', url: 'item/2' },
],
};
const wrapper = mountWithContexts(
<SelectResourceStep
searchColumns={searchColumns}
sortColumns={sortColumns}
displayKey="username"
onRowClick={handleRowClick}
onSearch={() => ({ data })}
selectedResourceRows={[]}
/>
);
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<SelectResourceStep
searchColumns={searchColumns}
sortColumns={sortColumns}
displayKey="username"
onRowClick={handleRowClick}
fetchItems={() => ({ data })}
selectedResourceRows={[]}
/>
);
});
await sleep(0);
wrapper.update();
const checkboxListItemWrapper = wrapper.find('CheckboxListItem');
expect(checkboxListItemWrapper.length).toBe(2);
checkboxListItemWrapper
.first()
.find('input[type="checkbox"]')

View 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));

View 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);
});
});

View File

@ -0,0 +1,3 @@
import AppContainer from './AppContainer';
export default AppContainer;

View File

@ -1 +0,0 @@
export { default } from './BrandLogo';

View File

@ -1,29 +1,14 @@
import React, { useState } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import {
Button,
ButtonVariant,
FormGroup,
InputGroup,
TextInput,
Tooltip,
} from '@patternfly/react-core';
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
import { FormGroup, InputGroup } from '@patternfly/react-core';
import PasswordInput from './PasswordInput';
function PasswordField(props) {
const { id, name, label, validate, isRequired, isDisabled, i18n } = props;
const [inputType, setInputType] = useState('password');
const [field, meta] = useField({ name, validate });
const { id, name, label, validate, isRequired } = props;
const [, meta] = useField({ name, validate });
const isValid = !(meta.touched && meta.error);
const handlePasswordToggle = () => {
setInputType(inputType === 'text' ? 'password' : 'text');
};
return (
<FormGroup
fieldId={id}
@ -33,32 +18,7 @@ function PasswordField(props) {
label={label}
>
<InputGroup>
<Tooltip
content={inputType === 'password' ? i18n._(t`Show`) : i18n._(t`Hide`)}
>
<Button
variant={ButtonVariant.control}
aria-label={i18n._(t`Toggle Password`)}
onClick={handlePasswordToggle}
isDisabled={isDisabled}
>
{inputType === 'password' && <EyeSlashIcon />}
{inputType === 'text' && <EyeIcon />}
</Button>
</Tooltip>
<TextInput
id={id}
placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined}
{...field}
value={field.value === '$encrypted$' ? '' : field.value}
isDisabled={isDisabled}
isRequired={isRequired}
isValid={isValid}
type={inputType}
onChange={(_, event) => {
field.onChange(event);
}}
/>
<PasswordInput {...props} />
</InputGroup>
</FormGroup>
);
@ -79,4 +39,4 @@ PasswordField.defaultProps = {
isDisabled: false,
};
export default withI18n()(PasswordField);
export default PasswordField;

View File

@ -1,7 +1,6 @@
import React from 'react';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import { sleep } from '../../../testUtils/testUtils';
import PasswordField from './PasswordField';
describe('PasswordField', () => {
@ -19,26 +18,4 @@ describe('PasswordField', () => {
);
expect(wrapper).toHaveLength(1);
});
test('properly responds to show/hide toggles', async () => {
const wrapper = mountWithContexts(
<Formik
initialValues={{
password: '',
}}
>
{() => (
<PasswordField id="test-password" name="password" label="Password" />
)}
</Formik>
);
expect(wrapper.find('input').prop('type')).toBe('password');
expect(wrapper.find('EyeSlashIcon').length).toBe(1);
expect(wrapper.find('EyeIcon').length).toBe(0);
wrapper.find('button').simulate('click');
await sleep(1);
expect(wrapper.find('input').prop('type')).toBe('text');
expect(wrapper.find('EyeSlashIcon').length).toBe(0);
expect(wrapper.find('EyeIcon').length).toBe(1);
});
});

View 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);

View 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);
});
});

View File

@ -2,4 +2,5 @@ export { default } from './FormField';
export { default as CheckboxField } from './CheckboxField';
export { default as FieldTooltip } from './FieldTooltip';
export { default as PasswordField } from './PasswordField';
export { default as PasswordInput } from './PasswordInput';
export { default as FormSubmitError } from './FormSubmitError';

View File

@ -44,7 +44,10 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
setValue('limit', values.limit);
setValue('job_tags', values.job_tags);
setValue('skip_tags', values.skip_tags);
setValue('extra_vars', mergeExtraVars(values.extra_vars, surveyValues));
const extraVars = config.ask_variables_on_launch
? values.extra_vars || '---'
: resource.extra_vars;
setValue('extra_vars', mergeExtraVars(extraVars, surveyValues));
setValue('scm_branch', values.scm_branch);
onLaunch(postValues);
};

View File

@ -1,6 +1,6 @@
import yaml from 'js-yaml';
export default function mergeExtraVars(extraVars, survey = {}) {
export default function mergeExtraVars(extraVars = '', survey = {}) {
const vars = yaml.safeLoad(extraVars) || {};
return {
...vars,

View File

@ -32,6 +32,10 @@ describe('mergeExtraVars', () => {
});
});
test('should handle undefined', () => {
expect(mergeExtraVars(undefined, undefined)).toEqual({});
});
describe('maskPasswords', () => {
test('should mask password fields', () => {
const vars = {

View File

@ -1,44 +1,72 @@
import React from 'react';
import React, { Fragment } from 'react';
import styled from 'styled-components';
import { ExclamationCircleIcon as PFExclamationCircleIcon } from '@patternfly/react-icons';
import { Tooltip } from '@patternfly/react-core';
import { t } from '@lingui/macro';
import { useFormikContext } from 'formik';
import { withI18n } from '@lingui/react';
import yaml from 'js-yaml';
import PromptDetail from '../../PromptDetail';
import mergeExtraVars, { maskPasswords } from '../mergeExtraVars';
import getSurveyValues from '../getSurveyValues';
import PromptDetail from '../../PromptDetail';
function PreviewStep({ resource, config, survey, formErrors }) {
const ExclamationCircleIcon = styled(PFExclamationCircleIcon)`
margin-left: 10px;
margin-top: -2px;
`;
const ErrorMessageWrapper = styled.div`
align-items: center;
color: var(--pf-global--danger-color--200);
display: flex;
font-weight: var(--pf-global--FontWeight--bold);
margin-bottom: 10px;
`;
function PreviewStep({ resource, config, survey, formErrors, i18n }) {
const { values } = useFormikContext();
const surveyValues = getSurveyValues(values);
let extraVars;
if (survey && survey.spec) {
const passwordFields = survey.spec
.filter(q => q.type === 'password')
.map(q => q.variable);
const masked = maskPasswords(surveyValues, passwordFields);
extraVars = yaml.safeDump(
mergeExtraVars(values.extra_vars || '---', masked)
);
} else {
extraVars = values.extra_vars || '---';
const overrides = { ...values };
if (config.ask_variables_on_launch || config.survey_enabled) {
const initialExtraVars = config.ask_variables_on_launch
? values.extra_vars || '---'
: resource.extra_vars;
if (survey && survey.spec) {
const passwordFields = survey.spec
.filter(q => q.type === 'password')
.map(q => q.variable);
const masked = maskPasswords(surveyValues, passwordFields);
overrides.extra_vars = yaml.safeDump(
mergeExtraVars(initialExtraVars, masked)
);
} else {
overrides.extra_vars = initialExtraVars;
}
}
return (
<>
<Fragment>
{formErrors.length > 0 && (
<ErrorMessageWrapper>
{i18n._(t`Some of the previous step(s) have errors`)}
<Tooltip
position="right"
content={i18n._(t`See errors on the left`)}
trigger="click mouseenter focus"
>
<ExclamationCircleIcon />
</Tooltip>
</ErrorMessageWrapper>
)}
<PromptDetail
resource={resource}
launchConfig={config}
overrides={{
...values,
extra_vars: extraVars,
}}
overrides={overrides}
/>
{formErrors && (
<ul css="color: red">
{Object.keys(formErrors).map(
field => `${field}: ${formErrors[field]}`
)}
</ul>
)}
</>
</Fragment>
);
}
export default PreviewStep;
export default withI18n()(PreviewStep);

View File

@ -24,6 +24,10 @@ const survey = {
],
};
const formErrors = {
inventory: 'An inventory must be selected',
};
describe('PreviewStep', () => {
test('should render PromptDetail', async () => {
let wrapper;
@ -37,6 +41,7 @@ describe('PreviewStep', () => {
survey_enabled: true,
}}
survey={survey}
formErrors={formErrors}
/>
</Formik>
);
@ -62,6 +67,7 @@ describe('PreviewStep', () => {
config={{
ask_limit_on_launch: true,
}}
formErrors={formErrors}
/>
</Formik>
);
@ -71,8 +77,31 @@ describe('PreviewStep', () => {
expect(detail).toHaveLength(1);
expect(detail.prop('resource')).toEqual(resource);
expect(detail.prop('overrides')).toEqual({
extra_vars: '---',
limit: '4',
});
});
test('should handle extra vars without survey', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{ extra_vars: 'one: 1' }}>
<PreviewStep
resource={resource}
config={{
ask_variables_on_launch: true,
}}
formErrors={formErrors}
/>
</Formik>
);
});
const detail = wrapper.find('PromptDetail');
expect(detail).toHaveLength(1);
expect(detail.prop('resource')).toEqual(resource);
expect(detail.prop('overrides')).toEqual({
extra_vars: 'one: 1',
});
});
});

View File

@ -19,7 +19,8 @@ export default function useCredentialsStep(
initialValues: getInitialValues(config, resource),
validate,
isReady: true,
error: null,
contentError: null,
formError: null,
setTouched: setFieldsTouched => {
setFieldsTouched({
credentials: true,

View File

@ -27,7 +27,8 @@ export default function useInventoryStep(config, resource, visitedSteps, i18n) {
initialValues: getInitialValues(config, resource),
validate,
isReady: true,
error: null,
contentError: null,
formError: stepErrors,
setTouched: setFieldsTouched => {
setFieldsTouched({
inventory: true,

View File

@ -24,7 +24,8 @@ export default function useOtherPrompt(config, resource, visitedSteps, i18n) {
initialValues: getInitialValues(config, resource),
validate,
isReady: true,
error: null,
contentError: null,
formError: stepErrors,
setTouched: setFieldsTouched => {
setFieldsTouched({
job_type: true,

View File

@ -54,7 +54,8 @@ export default function useSurveyStep(config, resource, visitedSteps, i18n) {
validate,
survey,
isReady: !isLoading && !!survey,
error,
contentError: error,
formError: stepErrors,
setTouched: setFieldsTouched => {
if (!survey || !survey.spec) {
return;

View File

@ -13,14 +13,13 @@ export default function useSteps(config, resource, i18n) {
useOtherPromptsStep(config, resource, visited, i18n),
useSurveyStep(config, resource, visited, i18n),
];
const formErrorsContent = steps
.filter(s => s?.formError && Object.keys(s.formError).length > 0)
.map(({ formError }) => formError);
steps.push(
usePreviewStep(
config,
resource,
steps[3].survey,
{}, // TODO: formErrors ?
i18n
)
usePreviewStep(config, resource, steps[3].survey, formErrorsContent, i18n)
);
const pfSteps = steps.map(s => s.step).filter(s => s != null);
@ -31,8 +30,9 @@ export default function useSteps(config, resource, i18n) {
};
}, {});
const isReady = !steps.some(s => !s.isReady);
const stepWithError = steps.find(s => s.error);
const contentError = stepWithError ? stepWithError.error : null;
const stepWithError = steps.find(s => s.contentError);
const contentError = stepWithError ? stepWithError.contentError : null;
const validate = values => {
const errors = steps.reduce((acc, cur) => {

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useCallback, useEffect } from 'react';
import { arrayOf, string, func, object, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
@ -8,6 +8,7 @@ import { InstanceGroupsAPI } from '../../api';
import { getQSConfig, parseQueryString } from '../../util/qs';
import { FieldTooltip } from '../FormField';
import OptionsList from '../OptionsList';
import useRequest from '../../util/useRequest';
import Lookup from './Lookup';
import LookupErrorMessage from './shared/LookupErrorMessage';
@ -27,22 +28,27 @@ function InstanceGroupsLookup(props) {
history,
i18n,
} = props;
const [instanceGroups, setInstanceGroups] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
const {
result: { instanceGroups, count },
request: fetchInstanceGroups,
error,
isLoading,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
const { data } = await InstanceGroupsAPI.read(params);
return {
instanceGroups: data.results,
count: data.count,
};
}, [history.location]),
{ instanceGroups: [], count: 0 }
);
useEffect(() => {
(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
try {
const { data } = await InstanceGroupsAPI.read(params);
setInstanceGroups(data.results);
setCount(data.count);
} catch (err) {
setError(err);
}
})();
}, [history.location]);
fetchInstanceGroups();
}, [fetchInstanceGroups]);
return (
<FormGroup
@ -59,6 +65,7 @@ function InstanceGroupsLookup(props) {
qsConfig={QS_CONFIG}
multiple
required={required}
isLoading={isLoading}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}

View File

@ -19,22 +19,20 @@ const QS_CONFIG = getQSConfig('inventory', {
function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
const {
result: { count, inventories },
error,
result: { inventories, count },
request: fetchInventories,
error,
isLoading,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
const { data } = await InventoriesAPI.read(params);
return {
count: data.count,
inventories: data.results,
count: data.count,
};
}, [history.location.search]),
{
count: 0,
inventories: [],
}
}, [history.location]),
{ inventories: [], count: 0 }
);
useEffect(() => {
@ -50,6 +48,7 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
onChange={onChange}
onBlur={onBlur}
required={required}
isLoading={isLoading}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList

View File

@ -56,6 +56,7 @@ function Lookup(props) {
header,
onChange,
onBlur,
isLoading,
value,
multiple,
required,
@ -124,6 +125,7 @@ function Lookup(props) {
id={id}
onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}
variant={ButtonVariant.tertiary}
isDisabled={isLoading}
>
<SearchIcon />
</SearchButton>

View File

@ -159,4 +159,30 @@ describe('<Lookup />', () => {
const list = wrapper.find('TestList');
expect(list.prop('canDelete')).toEqual(false);
});
test('should be disabled while isLoading is true', async () => {
const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }];
wrapper = mountWithContexts(
<Lookup
id="test"
multiple
header="Foo Bar"
value={mockSelected}
onChange={onChange}
qsConfig={QS_CONFIG}
isLoading
renderOptionsList={({ state, dispatch, canDelete }) => (
<TestList
id="options-list"
state={state}
dispatch={dispatch}
canDelete={canDelete}
/>
)}
/>
);
checkRootElementNotPresent('body div[role="dialog"]');
const button = wrapper.find('button[aria-label="Search"]');
expect(button.prop('disabled')).toEqual(true);
});
});

View File

@ -1,5 +1,5 @@
import 'styled-components/macro';
import React, { Fragment, useState, useEffect } from 'react';
import React, { Fragment, useState, useCallback, useEffect } from 'react';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
@ -9,6 +9,7 @@ import { CredentialsAPI, CredentialTypesAPI } from '../../api';
import AnsibleSelect from '../AnsibleSelect';
import CredentialChip from '../CredentialChip';
import OptionsList from '../OptionsList';
import useRequest from '../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../util/qs';
import Lookup from './Lookup';
@ -26,42 +27,62 @@ async function loadCredentials(params, selectedCredentialTypeId) {
function MultiCredentialsLookup(props) {
const { value, onChange, onError, history, i18n } = props;
const [credentialTypes, setCredentialTypes] = useState([]);
const [selectedType, setSelectedType] = useState(null);
const [credentials, setCredentials] = useState([]);
const [credentialsCount, setCredentialsCount] = useState(0);
const {
result: credentialTypes,
request: fetchTypes,
error: typesError,
isLoading: isTypesLoading,
} = useRequest(
useCallback(async () => {
const types = await CredentialTypesAPI.loadAllTypes();
const match = types.find(type => type.kind === 'ssh') || types[0];
setSelectedType(match);
return types;
}, []),
[]
);
useEffect(() => {
(async () => {
try {
const types = await CredentialTypesAPI.loadAllTypes();
setCredentialTypes(types);
const match = types.find(type => type.kind === 'ssh') || types[0];
setSelectedType(match);
} catch (err) {
onError(err);
}
})();
}, [onError]);
fetchTypes();
}, [fetchTypes]);
useEffect(() => {
(async () => {
const {
result: { credentials, credentialsCount },
request: fetchCredentials,
error: credentialsError,
isLoading: isCredentialsLoading,
} = useRequest(
useCallback(async () => {
if (!selectedType) {
return;
return {
credentials: [],
count: 0,
};
}
try {
const params = parseQueryString(QS_CONFIG, history.location.search);
const { results, count } = await loadCredentials(
params,
selectedType.id
);
setCredentials(results);
setCredentialsCount(count);
} catch (err) {
onError(err);
}
})();
}, [selectedType, history.location.search, onError]);
const params = parseQueryString(QS_CONFIG, history.location.search);
const { results, count } = await loadCredentials(params, selectedType.id);
return {
credentials: results,
credentialsCount: count,
};
}, [selectedType, history.location]),
{
credentials: [],
credentialsCount: 0,
}
);
useEffect(() => {
fetchCredentials();
}, [fetchCredentials]);
useEffect(() => {
if (typesError || credentialsError) {
onError(typesError || credentialsError);
}
}, [typesError, credentialsError, onError]);
const renderChip = ({ item, removeItem, canDelete }) => (
<CredentialChip
@ -82,6 +103,7 @@ function MultiCredentialsLookup(props) {
multiple
onChange={onChange}
qsConfig={QS_CONFIG}
isLoading={isTypesLoading || isCredentialsLoading}
renderItemChip={renderChip}
renderOptionsList={({ state, dispatch, canDelete }) => {
return (

View File

@ -156,7 +156,7 @@ describe('<MultiCredentialsLookup />', () => {
});
});
wrapper.update();
act(() => {
await act(async () => {
wrapper.find('Button[variant="primary"]').invoke('onClick')();
});
expect(onChange).toBeCalledWith([
@ -201,7 +201,7 @@ describe('<MultiCredentialsLookup />', () => {
});
});
wrapper.update();
act(() => {
await act(async () => {
wrapper.find('Button[variant="primary"]').invoke('onClick')();
});
expect(onChange).toBeCalledWith([
@ -248,7 +248,7 @@ describe('<MultiCredentialsLookup />', () => {
});
});
wrapper.update();
act(() => {
await act(async () => {
wrapper.find('Button[variant="primary"]').invoke('onClick')();
});
expect(onChange).toBeCalledWith([
@ -301,7 +301,7 @@ describe('<MultiCredentialsLookup />', () => {
});
});
wrapper.update();
act(() => {
await act(async () => {
wrapper.find('Button[variant="primary"]').invoke('onClick')();
});
expect(onChange).toBeCalledWith([

View File

@ -32,9 +32,10 @@ function ProjectLookup({
history,
}) {
const {
result: { count, projects },
error,
result: { projects, count },
request: fetchProjects,
error,
isLoading,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
@ -74,6 +75,7 @@ function ProjectLookup({
onBlur={onBlur}
onChange={onChange}
required={required}
isLoading={isLoading}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList

View File

@ -1 +0,0 @@
export { default } from './NavExpandableGroup';

View File

@ -272,4 +272,39 @@ describe('<NotificationList />', () => {
wrapper.find('NotificationList').state('startedTemplateIds')
).toEqual([]);
});
test('should throw toggle error', async () => {
MockModelAPI.associateNotificationTemplate.mockRejectedValue(
new Error({
response: {
config: {
method: 'post',
},
data: 'An error occurred',
status: 403,
},
})
);
const wrapper = mountWithContexts(
<NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} />
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('NotificationList').state('startedTemplateIds')
).toEqual([3]);
const items = wrapper.find('NotificationListItem');
items
.at(0)
.find('Switch[aria-label="Toggle notification start"]')
.prop('onChange')();
expect(MockModelAPI.associateNotificationTemplate).toHaveBeenCalledWith(
1,
1,
'started'
);
await sleep(0);
wrapper.update();
expect(wrapper.find('ErrorDetail').length).toBe(1);
});
});

View File

@ -19,6 +19,9 @@ const DataListAction = styled(_DataListAction)`
grid-gap: 16px;
grid-template-columns: repeat(3, max-content);
`;
const Label = styled.b`
margin-right: 20px;
`;
function NotificationListItem(props) {
const {
@ -54,6 +57,7 @@ function NotificationListItem(props) {
</Link>
</DataListCell>,
<DataListCell key="type">
<Label>{i18n._(t`Type `)}</Label>
{typeLabels[notification.notification_type]}
</DataListCell>,
]}

View File

@ -55,7 +55,7 @@ describe('<NotificationListItem canToggleNotifications />', () => {
.find('DataListCell')
.at(1)
.find('div');
expect(typeCell.text()).toBe('Slack');
expect(typeCell.text()).toContain('Slack');
});
test('handles start click when toggle is on', () => {

View File

@ -58,6 +58,9 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
</ForwardRef>
</ForwardRef(Styled(PFDataListCell))>,
<ForwardRef(Styled(PFDataListCell))>
<ForwardRef(styled.b)>
Type
</ForwardRef(styled.b)>
Slack
</ForwardRef(Styled(PFDataListCell))>,
]
@ -167,6 +170,41 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
<div
className="pf-c-data-list__cell sc-bdVaJa kruorc"
>
<styled.b>
<StyledComponent
forwardedComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "sc-htpNat",
"isStatic": false,
"lastClassName": "jyYvCB",
"rules": Array [
"
margin-right: 20px;
",
],
},
"displayName": "styled.b",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "sc-htpNat",
"target": "b",
"toString": [Function],
"warnTooManyClasses": [Function],
"withComponent": [Function],
}
}
forwardedRef={null}
>
<b
className="sc-htpNat jyYvCB"
>
Type
</b>
</StyledComponent>
</styled.b>
Slack
</div>
</PFDataListCell>

View File

@ -1 +0,0 @@
export { default } from './PageHeaderToolbar';

View File

@ -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);

View File

@ -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);
});
});

View File

@ -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),
},
];
}

View File

@ -0,0 +1 @@
export { default } from './UserAndTeamAccessAdd';

View File

@ -1,276 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Route, Switch, Redirect } from 'react-router-dom';
import { I18n } from '@lingui/react';
import { t } from '@lingui/macro';
import '@patternfly/react-core/dist/styles/base.css';
import { isAuthenticated } from './util/auth';
import Background from './components/Background';
import Applications from './screens/Application';
import Credentials from './screens/Credential';
import CredentialTypes from './screens/CredentialType';
import Dashboard from './screens/Dashboard';
import Hosts from './screens/Host';
import InstanceGroups from './screens/InstanceGroup';
import Inventory from './screens/Inventory';
import InventoryScripts from './screens/InventoryScript';
import { Jobs } from './screens/Job';
import Login from './screens/Login';
import ManagementJobs from './screens/ManagementJob';
import NotificationTemplates from './screens/NotificationTemplate';
import Organizations from './screens/Organization';
import Portal from './screens/Portal';
import Projects from './screens/Project';
import Schedules from './screens/Schedule';
import AuthSettings from './screens/AuthSetting';
import JobsSettings from './screens/JobsSetting';
import SystemSettings from './screens/SystemSetting';
import UISettings from './screens/UISetting';
import License from './screens/License';
import Teams from './screens/Team';
import Templates from './screens/Template';
import Users from './screens/User';
import NotFound from './screens/NotFound';
import App from './App';
import RootProvider from './RootProvider';
import { BrandName } from './variables';
// eslint-disable-next-line import/prefer-default-export
export function main(render) {
const el = document.getElementById('app');
document.title = `Ansible ${BrandName}`;
document.title = `Ansible ${BrandName}`;
const removeTrailingSlash = (
<Route
exact
strict
path="/*/"
render={({
history: {
location: { pathname, search, hash },
},
}) => <Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />}
/>
);
const defaultRedirect = () => {
if (isAuthenticated(document.cookie)) {
return <Redirect to="/home" />;
}
return (
<Switch>
{removeTrailingSlash}
<Route
path="/login"
render={() => <Login isAuthenticated={isAuthenticated} />}
/>
<Redirect to="/login" />
</Switch>
);
};
return render(
<RootProvider>
<I18n>
{({ i18n }) => (
<Background>
<Switch>
{removeTrailingSlash}
<Route path="/login" render={defaultRedirect} />
<Route exact path="/" render={defaultRedirect} />
<Route
render={() => {
if (!isAuthenticated(document.cookie)) {
return <Redirect to="/login" />;
}
return (
<App
navLabel={i18n._(t`Primary Navigation`)}
routeGroups={[
{
groupTitle: i18n._(t`Views`),
groupId: 'views_group',
routes: [
{
title: i18n._(t`Dashboard`),
path: '/home',
component: Dashboard,
},
{
title: i18n._(t`Jobs`),
path: '/jobs',
component: Jobs,
},
{
title: i18n._(t`Schedules`),
path: '/schedules',
component: Schedules,
},
{
title: i18n._(t`My View`),
path: '/portal',
component: Portal,
},
],
},
{
groupTitle: i18n._(t`Resources`),
groupId: 'resources_group',
routes: [
{
title: i18n._(t`Templates`),
path: '/templates',
component: Templates,
},
{
title: i18n._(t`Credentials`),
path: '/credentials',
component: Credentials,
},
{
title: i18n._(t`Projects`),
path: '/projects',
component: Projects,
},
{
title: i18n._(t`Inventories`),
path: '/inventories',
component: Inventory,
},
{
title: i18n._(t`Hosts`),
path: '/hosts',
component: Hosts,
},
{
title: i18n._(t`Inventory Scripts`),
path: '/inventory_scripts',
component: InventoryScripts,
},
],
},
{
groupTitle: i18n._(t`Access`),
groupId: 'access_group',
routes: [
{
title: i18n._(t`Organizations`),
path: '/organizations',
component: Organizations,
},
{
title: i18n._(t`Users`),
path: '/users',
component: Users,
},
{
title: i18n._(t`Teams`),
path: '/teams',
component: Teams,
},
],
},
{
groupTitle: i18n._(t`Administration`),
groupId: 'administration_group',
routes: [
{
title: i18n._(t`Credential Types`),
path: '/credential_types',
component: CredentialTypes,
},
{
title: i18n._(t`Notifications`),
path: '/notification_templates',
component: NotificationTemplates,
},
{
title: i18n._(t`Management Jobs`),
path: '/management_jobs',
component: ManagementJobs,
},
{
title: i18n._(t`Instance Groups`),
path: '/instance_groups',
component: InstanceGroups,
},
{
title: i18n._(t`Integrations`),
path: '/applications',
component: Applications,
},
],
},
{
groupTitle: i18n._(t`Settings`),
groupId: 'settings_group',
routes: [
{
title: i18n._(t`Authentication`),
path: '/auth_settings',
component: AuthSettings,
},
{
title: i18n._(t`Jobs`),
path: '/jobs_settings',
component: JobsSettings,
},
{
title: i18n._(t`System`),
path: '/system_settings',
component: SystemSettings,
},
{
title: i18n._(t`User Interface`),
path: '/ui_settings',
component: UISettings,
},
{
title: i18n._(t`License`),
path: '/license',
component: License,
},
],
},
]}
render={({ routeGroups }) => {
const routeList = routeGroups
.reduce(
(allRoutes, { routes }) => allRoutes.concat(routes),
[]
)
.map(({ component: PageComponent, path }) => (
<Route
key={path}
path={path}
render={({ match }) => (
<PageComponent match={match} />
)}
/>
));
routeList.push(
<Route
key="not-found"
path="*"
component={NotFound}
/>
);
return <Switch>{routeList}</Switch>;
}}
/>
);
}}
/>
</Switch>
</Background>
)}
</I18n>
</RootProvider>,
el || document.createElement('div')
);
}
main(ReactDOM.render);
ReactDOM.render(
<App />,
document.getElementById('app') || document.createElement('div')
);

View File

@ -1,13 +1,17 @@
import React from 'react';
import { mount } from 'enzyme';
import { MemoryRouter } from 'react-router-dom';
import { main } from './index';
import ReactDOM from 'react-dom';
import App from './App';
const render = template => mount(<MemoryRouter>{template}</MemoryRouter>);
jest.mock('react-dom', () => ({ render: jest.fn() }));
const div = document.createElement('div');
div.setAttribute('id', 'app');
document.body.appendChild(div);
require('./index.jsx');
describe('index.jsx', () => {
test('index.jsx loads without issue', () => {
const wrapper = main(render);
expect(wrapper.find('RootProvider')).toHaveLength(1);
it('renders ok', () => {
expect(ReactDOM.render).toHaveBeenCalledWith(<App />, div);
});
});

View 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;

View File

@ -1,20 +1,74 @@
import React, { useState, useEffect } from 'react';
import React, { useCallback, useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { PageSection, Card } from '@patternfly/react-core';
import { CardBody } from '../../../components/Card';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
import { CredentialTypesAPI, CredentialsAPI } from '../../../api';
import {
CredentialInputSourcesAPI,
CredentialTypesAPI,
CredentialsAPI,
} from '../../../api';
import CredentialForm from '../shared/CredentialForm';
import useRequest from '../../../util/useRequest';
function CredentialAdd({ me }) {
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [credentialTypes, setCredentialTypes] = useState(null);
const [formSubmitError, setFormSubmitError] = useState(null);
const history = useHistory();
const {
error: submitError,
request: submitRequest,
result: credentialId,
} = useRequest(
useCallback(
async values => {
const { inputs, organization, ...remainingValues } = values;
const nonPluginInputs = {};
const pluginInputs = {};
Object.entries(inputs).forEach(([key, value]) => {
if (value.credential && value.inputs) {
pluginInputs[key] = value;
} else {
nonPluginInputs[key] = value;
}
});
const {
data: { id: newCredentialId },
} = await CredentialsAPI.create({
user: (me && me.id) || null,
organization: (organization && organization.id) || null,
inputs: nonPluginInputs,
...remainingValues,
});
const inputSourceRequests = [];
Object.entries(pluginInputs).forEach(([key, value]) => {
inputSourceRequests.push(
CredentialInputSourcesAPI.create({
input_field_name: key,
metadata: value.inputs,
source_credential: value.credential.id,
target_credential: newCredentialId,
})
);
});
await Promise.all(inputSourceRequests);
return newCredentialId;
},
[me]
)
);
useEffect(() => {
if (credentialId) {
history.push(`/credentials/${credentialId}/details`);
}
}, [credentialId, history]);
useEffect(() => {
const loadData = async () => {
try {
@ -38,21 +92,7 @@ function CredentialAdd({ me }) {
};
const handleSubmit = async values => {
const { organization, ...remainingValues } = values;
setFormSubmitError(null);
try {
const {
data: { id: credentialId },
} = await CredentialsAPI.create({
user: (me && me.id) || null,
organization: (organization && organization.id) || null,
...remainingValues,
});
const url = `/credentials/${credentialId}/details`;
history.push(`${url}`);
} catch (err) {
setFormSubmitError(err);
}
await submitRequest(values);
};
if (error) {
@ -85,7 +125,7 @@ function CredentialAdd({ me }) {
onCancel={handleCancel}
onSubmit={handleSubmit}
credentialTypes={credentialTypes}
submitError={formSubmitError}
submitError={submitError}
/>
</CardBody>
</Card>

View File

@ -1,29 +1,117 @@
import React, { useState, useEffect } from 'react';
import React, { useCallback, useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { object } from 'prop-types';
import { CardBody } from '../../../components/Card';
import { CredentialsAPI, CredentialTypesAPI } from '../../../api';
import {
CredentialsAPI,
CredentialInputSourcesAPI,
CredentialTypesAPI,
} from '../../../api';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
import CredentialForm from '../shared/CredentialForm';
import useRequest from '../../../util/useRequest';
function CredentialEdit({ credential, me }) {
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [credentialTypes, setCredentialTypes] = useState(null);
const [formSubmitError, setFormSubmitError] = useState(null);
const [inputSources, setInputSources] = useState({});
const history = useHistory();
const { error: submitError, request: submitRequest, result } = useRequest(
useCallback(
async (values, inputSourceMap) => {
const createAndUpdateInputSources = pluginInputs =>
Object.entries(pluginInputs).map(([fieldName, fieldValue]) => {
if (!inputSourceMap[fieldName]) {
return CredentialInputSourcesAPI.create({
input_field_name: fieldName,
metadata: fieldValue.inputs,
source_credential: fieldValue.credential.id,
target_credential: credential.id,
});
}
if (fieldValue.touched) {
return CredentialInputSourcesAPI.update(
inputSourceMap[fieldName].id,
{
metadata: fieldValue.inputs,
source_credential: fieldValue.credential.id,
}
);
}
return null;
});
const destroyInputSources = inputs => {
const destroyRequests = [];
Object.values(inputSourceMap).forEach(inputSource => {
const { id, input_field_name } = inputSource;
if (!inputs[input_field_name]?.credential) {
destroyRequests.push(CredentialInputSourcesAPI.destroy(id));
}
});
return destroyRequests;
};
const { inputs, organization, ...remainingValues } = values;
const nonPluginInputs = {};
const pluginInputs = {};
Object.entries(inputs).forEach(([key, value]) => {
if (value.credential && value.inputs) {
pluginInputs[key] = value;
} else {
nonPluginInputs[key] = value;
}
});
const [{ data }] = await Promise.all([
CredentialsAPI.update(credential.id, {
user: (me && me.id) || null,
organization: (organization && organization.id) || null,
inputs: nonPluginInputs,
...remainingValues,
}),
...destroyInputSources(inputs),
]);
await Promise.all(createAndUpdateInputSources(pluginInputs));
return data;
},
[credential.id, me]
)
);
useEffect(() => {
if (result) {
history.push(`/credentials/${result.id}/details`);
}
}, [result, history]);
useEffect(() => {
const loadData = async () => {
try {
const {
data: { results: loadedCredentialTypes },
} = await CredentialTypesAPI.read({
or__namespace: ['gce', 'scm', 'ssh'],
});
const [
{
data: { results: loadedCredentialTypes },
},
{
data: { results: loadedInputSources },
},
] = await Promise.all([
CredentialTypesAPI.read({
or__namespace: ['gce', 'scm', 'ssh'],
}),
CredentialsAPI.readInputSources(credential.id, { page_size: 200 }),
]);
setCredentialTypes(loadedCredentialTypes);
setInputSources(
loadedInputSources.reduce((inputSourcesMap, inputSource) => {
inputSourcesMap[inputSource.input_field_name] = inputSource;
return inputSourcesMap;
}, {})
);
} catch (err) {
setError(err);
} finally {
@ -31,30 +119,15 @@ function CredentialEdit({ credential, me }) {
}
};
loadData();
}, []);
}, [credential.id]);
const handleCancel = () => {
const url = `/credentials/${credential.id}/details`;
history.push(`${url}`);
};
const handleSubmit = async values => {
const { organization, ...remainingValues } = values;
setFormSubmitError(null);
try {
const {
data: { id: credentialId },
} = await CredentialsAPI.update(credential.id, {
user: (me && me.id) || null,
organization: (organization && organization.id) || null,
...remainingValues,
});
const url = `/credentials/${credentialId}/details`;
history.push(`${url}`);
} catch (err) {
setFormSubmitError(err);
}
await submitRequest(values, inputSources);
};
if (error) {
@ -72,7 +145,8 @@ function CredentialEdit({ credential, me }) {
onSubmit={handleSubmit}
credential={credential}
credentialTypes={credentialTypes}
submitError={formSubmitError}
inputSources={inputSources}
submitError={submitError}
/>
</CardBody>
);

View File

@ -245,6 +245,7 @@ CredentialTypesAPI.read.mockResolvedValue({
});
CredentialsAPI.update.mockResolvedValue({ data: { id: 3 } });
CredentialsAPI.readInputSources.mockResolvedValue({ data: { results: [] } });
describe('<CredentialEdit />', () => {
let wrapper;

View File

@ -2,7 +2,7 @@ import React from 'react';
import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { func, shape } from 'prop-types';
import { arrayOf, func, object, shape } from 'prop-types';
import { Form, FormGroup, Title } from '@patternfly/react-core';
import FormField, { FormSubmitError } from '../../../components/FormField';
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
@ -124,6 +124,7 @@ function CredentialFormFields({
function CredentialForm({
credential = {},
credentialTypes,
inputSources,
onSubmit,
onCancel,
submitError,
@ -147,6 +148,13 @@ function CredentialForm({
},
};
Object.values(inputSources).forEach(inputSource => {
initialValues.inputs[inputSource.input_field_name] = {
credential: inputSource.summary_fields.source_credential,
inputs: inputSource.metadata,
};
});
const scmCredentialTypeId = Object.keys(credentialTypes)
.filter(key => credentialTypes[key].namespace === 'scm')
.map(key => credentialTypes[key].id)[0];
@ -232,10 +240,12 @@ CredentialForm.proptype = {
handleSubmit: func.isRequired,
handleCancel: func.isRequired,
credential: shape({}),
inputSources: arrayOf(object),
};
CredentialForm.defaultProps = {
credential: {},
inputSources: [],
};
export default withI18n()(CredentialForm);

View File

@ -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);

View File

@ -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);
});
});
});

View File

@ -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);

View File

@ -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');
});
});
});

View File

@ -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);

View File

@ -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);

View File

@ -0,0 +1,3 @@
export { default as CredentialPluginPrompt } from './CredentialPluginPrompt';
export { default as CredentialsStep } from './CredentialsStep';
export { default as MetadataStep } from './MetadataStep';

View File

@ -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);

View File

@ -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);
});
});

View File

@ -0,0 +1,2 @@
export { default as CredentialPluginSelected } from './CredentialPluginSelected';
export { default as CredentialPluginField } from './CredentialPluginField';

View File

@ -2,13 +2,18 @@ import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import { FileUpload, FormGroup } from '@patternfly/react-core';
import FormField from '../../../../components/FormField';
import {
FileUpload,
FormGroup,
TextArea,
TextInput,
} from '@patternfly/react-core';
import {
FormColumnLayout,
FormFullWidthLayout,
} from '../../../../components/FormLayout';
import { required } from '../../../../util/validators';
import { CredentialPluginField } from '../CredentialPlugins';
const GoogleComputeEngineSubForm = ({ i18n }) => {
const [fileError, setFileError] = useState(null);
@ -91,30 +96,38 @@ const GoogleComputeEngineSubForm = ({ i18n }) => {
}}
/>
</FormGroup>
<FormField
<CredentialPluginField
id="credential-username"
label={i18n._(t`Service account email address`)}
name="inputs.username"
type="email"
validate={required(null, i18n)}
isRequired
/>
<FormField
>
<TextInput id="credential-username" />
</CredentialPluginField>
<CredentialPluginField
id="credential-project"
label={i18n._(t`Project`)}
name="inputs.project"
type="text"
/>
>
<TextInput id="credential-project" />
</CredentialPluginField>
<FormFullWidthLayout>
<FormField
<CredentialPluginField
id="credential-sshKeyData"
label={i18n._(t`RSA private key`)}
name="inputs.ssh_key_data"
type="textarea"
rows={6}
validate={required(null, i18n)}
isRequired
/>
>
<TextArea
id="credential-sshKeyData"
rows={6}
resizeOrientation="vertical"
/>
</CredentialPluginField>
</FormFullWidthLayout>
</FormColumnLayout>
);

View File

@ -1,39 +1,50 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import FormField, { PasswordField } from '../../../../components/FormField';
import { TextArea, TextInput } from '@patternfly/react-core';
import { CredentialPluginField } from '../CredentialPlugins';
import { PasswordInput } from '../../../../components/FormField';
export const UsernameFormField = withI18n()(({ i18n }) => (
<FormField
id="credentual-username"
<CredentialPluginField
id="credential-username"
label={i18n._(t`Username`)}
name="inputs.username"
type="text"
/>
>
<TextInput id="credential-username" />
</CredentialPluginField>
));
export const PasswordFormField = withI18n()(({ i18n }) => (
<PasswordField
<CredentialPluginField
id="credential-password"
label={i18n._(t`Password`)}
name="inputs.password"
/>
>
<PasswordInput id="credential-password" />
</CredentialPluginField>
));
export const SSHKeyDataField = withI18n()(({ i18n }) => (
<FormField
<CredentialPluginField
id="credential-sshKeyData"
label={i18n._(t`SSH Private Key`)}
name="inputs.ssh_key_data"
type="textarea"
rows={6}
/>
>
<TextArea
id="credential-sshKeyData"
rows={6}
resizeOrientation="vertical"
/>
</CredentialPluginField>
));
export const SSHKeyUnlockField = withI18n()(({ i18n }) => (
<PasswordField
<CredentialPluginField
id="credential-sshKeyUnlock"
label={i18n._(t`Private Key Passphrase`)}
name="inputs.ssh_key_unlock"
/>
>
<PasswordInput id="credential-password" />
</CredentialPluginField>
));

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -13,7 +13,11 @@ import { CaretLeftIcon } from '@patternfly/react-icons';
import { CardActions } from '@patternfly/react-core';
import useRequest from '../../../util/useRequest';
import { InventoriesAPI } from '../../../api';
import {
InventoriesAPI,
InventorySourcesAPI,
OrganizationsAPI,
} from '../../../api';
import { TabbedCardHeader } from '../../../components/Card';
import CardCloseButton from '../../../components/CardCloseButton';
import ContentError from '../../../components/ContentError';
@ -21,20 +25,33 @@ import ContentLoading from '../../../components/ContentLoading';
import RoutedTabs from '../../../components/RoutedTabs';
import InventorySourceDetail from '../InventorySourceDetail';
import InventorySourceEdit from '../InventorySourceEdit';
import NotificationList from '../../../components/NotificationList/NotificationList';
function InventorySource({ i18n, inventory, setBreadcrumb }) {
function InventorySource({ i18n, inventory, setBreadcrumb, me }) {
const location = useLocation();
const match = useRouteMatch('/inventories/inventory/:id/sources/:sourceId');
const sourceListUrl = `/inventories/inventory/${inventory.id}/sources`;
const { result: source, error, isLoading, request: fetchSource } = useRequest(
const {
result: { source, isNotifAdmin },
error,
isLoading,
request: fetchSource,
} = useRequest(
useCallback(async () => {
return InventoriesAPI.readSourceDetail(
inventory.id,
match.params.sourceId
);
const [inventorySource, notifAdminRes] = await Promise.all([
InventoriesAPI.readSourceDetail(inventory.id, match.params.sourceId),
OrganizationsAPI.read({
page_size: 1,
role_level: 'notification_admin_role',
}),
]);
return {
source: inventorySource,
isNotifAdmin: notifAdminRes.data.results.length > 0,
};
}, [inventory.id, match.params.sourceId]),
null
{ source: null, isNotifAdmin: false }
);
useEffect(() => {
@ -63,18 +80,24 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) {
link: `${match.url}/details`,
id: 1,
},
{
name: i18n._(t`Notifications`),
link: `${match.url}/notifications`,
id: 2,
},
{
name: i18n._(t`Schedules`),
link: `${match.url}/schedules`,
id: 3,
id: 2,
},
];
const canToggleNotifications = isNotifAdmin;
const canSeeNotificationsTab = me.is_system_auditor || isNotifAdmin;
if (canSeeNotificationsTab) {
tabsArray.push({
name: i18n._(t`Notifications`),
link: `${match.url}/notifications`,
id: 3,
});
}
if (error) {
return <ContentError error={error} />;
}
@ -111,6 +134,16 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) {
>
<InventorySourceEdit source={source} inventory={inventory} />
</Route>
<Route
key="notifications"
path="/inventories/inventory/:id/sources/:sourceId/notifications"
>
<NotificationList
id={Number(match.params.sourceId)}
canToggleNotifications={canToggleNotifications}
apiModel={InventorySourcesAPI}
/>
</Route>
<Route key="not-found" path="*">
<ContentError isNotFound>
<Link to={`${match.url}/details`}>

View File

@ -1,7 +1,7 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { InventoriesAPI } from '../../../api';
import { InventoriesAPI, OrganizationsAPI } from '../../../api';
import {
mountWithContexts,
waitForElement,
@ -10,6 +10,9 @@ import mockInventorySource from '../shared/data.inventory_source.json';
import InventorySource from './InventorySource';
jest.mock('../../../api/models/Inventories');
jest.mock('../../../api/models/Organizations');
jest.mock('../../../api/models/InventorySources');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useRouteMatch: () => ({
@ -18,10 +21,6 @@ jest.mock('react-router-dom', () => ({
}),
}));
InventoriesAPI.readSourceDetail.mockResolvedValue({
data: { ...mockInventorySource },
});
const mockInventory = {
id: 2,
name: 'Mock Inventory',
@ -34,22 +33,31 @@ describe('<InventorySource />', () => {
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />
<InventorySource
inventory={mockInventory}
me={{ is_system_auditor: false }}
setBreadcrumb={() => {}}
/>
);
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render expected tabs', () => {
InventoriesAPI.readSourceDetail.mockResolvedValue({
data: { ...mockInventorySource },
});
OrganizationsAPI.read.mockResolvedValue({
data: { results: [{ id: 1, name: 'isNotifAdmin' }] },
});
const expectedTabs = [
'Back to Sources',
'Details',
'Notifications',
'Schedules',
'Notifications',
];
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
@ -57,10 +65,20 @@ describe('<InventorySource />', () => {
});
test('should show content error when api throws error on initial render', async () => {
InventoriesAPI.readSourceDetail.mockResolvedValue({
data: { ...mockInventorySource },
});
OrganizationsAPI.read.mockResolvedValue({
data: { results: [{ id: 1, name: 'isNotifAdmin' }] },
});
InventoriesAPI.readSourceDetail.mockRejectedValueOnce(new Error());
await act(async () => {
wrapper = mountWithContexts(
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />
<InventorySource
inventory={mockInventory}
me={{ is_system_auditor: false }}
setBreadcrumb={() => {}}
/>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
@ -71,16 +89,47 @@ describe('<InventorySource />', () => {
});
test('should show content error when user attempts to navigate to erroneous route', async () => {
InventoriesAPI.readSourceDetail.mockResolvedValue({
data: { ...mockInventorySource },
});
OrganizationsAPI.read.mockResolvedValue({
data: { results: [{ id: 1, name: 'isNotifAdmin' }] },
});
history = createMemoryHistory({
initialEntries: ['/inventories/inventory/2/sources/1/foobar'],
});
await act(async () => {
wrapper = mountWithContexts(
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />,
<InventorySource
inventory={mockInventory}
setBreadcrumb={() => {}}
me={{ is_system_auditor: false }}
/>,
{ context: { router: { history } } }
);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
expect(wrapper.find('ContentError Title').text()).toEqual('Not Found');
});
test('should call api', () => {
InventoriesAPI.readSourceDetail.mockResolvedValue({
data: { ...mockInventorySource },
});
OrganizationsAPI.read.mockResolvedValue({
data: { results: [{ id: 1, name: 'isNotifAdmin' }] },
});
expect(InventoriesAPI.readSourceDetail).toBeCalledWith(2, 123);
expect(OrganizationsAPI.read).toBeCalled();
});
test('should not render notifications tab', () => {
InventoriesAPI.readSourceDetail.mockResolvedValue({
data: { ...mockInventorySource },
});
OrganizationsAPI.read.mockResolvedValue({
data: { results: [] },
});
expect(wrapper.find('button[aria-label="Notifications"]').length).toBe(0);
});
});

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import InventorySource from '../InventorySource';
import { Config } from '../../../contexts/Config';
import InventorySourceAdd from '../InventorySourceAdd';
import InventorySourceList from './InventorySourceList';
@ -11,7 +12,15 @@ function InventorySources({ inventory, setBreadcrumb }) {
<InventorySourceAdd />
</Route>
<Route path="/inventories/inventory/:id/sources/:sourceId">
<InventorySource inventory={inventory} setBreadcrumb={setBreadcrumb} />
<Config>
{({ me }) => (
<InventorySource
inventory={inventory}
setBreadcrumb={setBreadcrumb}
me={me || {}}
/>
)}
</Config>
</Route>
<Route path="/inventories/:inventoryType/:id/sources">
<InventorySourceList />

View File

@ -1,30 +1,38 @@
import React, { useCallback, useEffect } from 'react';
import { useLocation, useRouteMatch, useParams } from 'react-router-dom';
import React, { useCallback, useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Card } from '@patternfly/react-core';
import { TeamsAPI } from '../../../api';
import useRequest from '../../../util/useRequest';
import {
Button,
EmptyState,
EmptyStateBody,
EmptyStateIcon,
Title,
} from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons';
import { TeamsAPI, RolesAPI } from '../../../api';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import DataListToolbar from '../../../components/DataListToolbar';
import PaginatedDataList, {
ToolbarAddButton,
} from '../../../components/PaginatedDataList';
import PaginatedDataList from '../../../components/PaginatedDataList';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import ErrorDetail from '../../../components/ErrorDetail';
import AlertModal from '../../../components/AlertModal';
import TeamAccessListItem from './TeamAccessListItem';
import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd';
const QS_CONFIG = getQSConfig('team', {
const QS_CONFIG = getQSConfig('roles', {
page: 1,
page_size: 20,
order_by: 'id',
});
function TeamAccessList({ i18n }) {
const [isWizardOpen, setIsWizardOpen] = useState(false);
const { search } = useLocation();
const match = useRouteMatch();
const { id } = useParams();
const [roleToDisassociate, setRoleToDisassociate] = useState(null);
const {
isLoading,
@ -57,6 +65,26 @@ function TeamAccessList({ i18n }) {
fetchRoles();
}, [fetchRoles]);
const saveRoles = () => {
setIsWizardOpen(false);
fetchRoles();
};
const {
isLoading: isDisassociateLoading,
deleteItems: disassociateRole,
deletionError: disassociationError,
clearDeletionError: clearDisassociationError,
} = useDeleteItems(
useCallback(async () => {
setRoleToDisassociate(null);
await RolesAPI.disassociateTeamRole(
roleToDisassociate.id,
parseInt(id, 10)
);
}, [roleToDisassociate, id]),
{ qsConfig: QS_CONFIG, fetchItems: fetchRoles }
);
const canAdd =
options && Object.prototype.hasOwnProperty.call(options, 'POST');
@ -76,11 +104,28 @@ function TeamAccessList({ i18n }) {
return `/${resource_type}s/${resource_id}/details`;
};
const isSysAdmin = roles.some(role => role.name === 'System Administrator');
if (isSysAdmin) {
return (
<EmptyState variant="full">
<EmptyStateIcon icon={CubesIcon} />
<Title headingLevel="h5" size="lg">
{i18n._(t`System Administrator`)}
</Title>
<EmptyStateBody>
{i18n._(
t`System administrators have unrestricted access to all resources.`
)}
</EmptyStateBody>
</EmptyState>
);
}
return (
<Card>
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
hasContentLoading={isLoading || isDisassociateLoading}
items={roles}
itemCount={roleCount}
pluralizedItemName={i18n._(t`Teams`)}
@ -104,7 +149,17 @@ function TeamAccessList({ i18n }) {
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
? [<ToolbarAddButton key="add" linkTo={`${match.url}/add`} />]
? [
<Button
key="add"
aria-label={i18n._(t`Add resource roles`)}
onClick={() => {
setIsWizardOpen(true);
}}
>
Add
</Button>,
]
: []),
]}
/>
@ -114,16 +169,69 @@ function TeamAccessList({ i18n }) {
key={role.id}
role={role}
detailUrl={detailUrl(role)}
onSelect={() => {}}
onSelect={item => {
setRoleToDisassociate(item);
}}
/>
)}
emptyStateControls={
canAdd ? (
<ToolbarAddButton key="add" linkTo={`${match.url}/add`} />
) : null
}
/>
</Card>
{isWizardOpen && (
<UserAndTeamAccessAdd
apiModel={TeamsAPI}
isOpen={isWizardOpen}
onSave={saveRoles}
onClose={() => setIsWizardOpen(false)}
title={i18n._(t`Add team permissions`)}
/>
)}
{roleToDisassociate && (
<AlertModal
aria-label={i18n._(t`Disassociate role`)}
isOpen={roleToDisassociate}
variant="error"
title={i18n._(t`Disassociate role!`)}
onClose={() => setRoleToDisassociate(null)}
actions={[
<Button
key="disassociate"
variant="danger"
aria-label={i18n._(t`confirm disassociate`)}
onClick={() => disassociateRole()}
>
{i18n._(t`Disassociate`)}
</Button>,
<Button
key="cancel"
variant="secondary"
aria-label={i18n._(t`Cancel`)}
onClick={() => setRoleToDisassociate(null)}
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
<div>
{i18n._(
t`This action will disassociate the following role from ${roleToDisassociate.summary_fields.resource_name}:`
)}
<br />
<strong>{roleToDisassociate.name}</strong>
</div>
</AlertModal>
)}
{disassociationError && (
<AlertModal
isOpen={disassociationError}
variant="error"
title={i18n._(t`Error!`)}
onClose={clearDisassociationError}
>
{i18n._(t`Failed to delete role.`)}
<ErrorDetail error={disassociationError} />
</AlertModal>
)}
</>
);
}
export default withI18n()(TeamAccessList);

View File

@ -1,8 +1,6 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { Route } from 'react-router-dom';
import { TeamsAPI } from '../../../api';
import { TeamsAPI, RolesAPI } from '../../../api';
import {
mountWithContexts,
waitForElement,
@ -10,119 +8,114 @@ import {
import TeamAccessList from './TeamAccessList';
jest.mock('../../../api/models/Teams');
jest.mock('../../../api/models/Roles');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 18,
}),
}));
const roles = {
data: {
results: [
{
id: 2,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/257/',
summary_fields: {
resource_name: 'template delete project',
resource_id: 15,
resource_type: 'job_template',
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
},
{
id: 3,
name: 'Admin Read Only',
type: 'role',
url: '/api/v2/roles/257/',
summary_fields: {
resource_name: 'template delete project',
resource_id: 16,
resource_type: 'workflow_job_template',
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
},
{
id: 4,
name: 'Execute',
type: 'role',
url: '/api/v2/roles/258/',
summary_fields: {
resource_name: 'Credential Bar',
resource_id: 75,
resource_type: 'credential',
resource_type_display_name: 'Credential',
user_capabilities: { unattach: true },
},
},
{
id: 5,
name: 'Update',
type: 'role',
url: '/api/v2/roles/259/',
summary_fields: {
resource_name: 'Inventory Foo',
resource_id: 76,
resource_type: 'inventory',
resource_type_display_name: 'Inventory',
user_capabilities: { unattach: true },
},
},
{
id: 6,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/260/',
summary_fields: {
resource_name: 'Smart Inventory Foo',
resource_id: 77,
resource_type: 'smart_inventory',
resource_type_display_name: 'Inventory',
user_capabilities: { unattach: true },
},
},
],
count: 5,
},
};
const options = {
data: { actions: { POST: { id: 1, disassociate: true } } },
};
describe('<TeamAccessList />', () => {
let wrapper;
let history;
beforeEach(async () => {
TeamsAPI.readRoles.mockResolvedValue({
data: {
results: [
{
id: 2,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/257/',
summary_fields: {
resource_name: 'template delete project',
resource_id: 15,
resource_type: 'job_template',
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
},
{
id: 3,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/257/',
summary_fields: {
resource_name: 'template delete project',
resource_id: 16,
resource_type: 'workflow_job_template',
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
},
{
id: 4,
name: 'Execute',
type: 'role',
url: '/api/v2/roles/258/',
summary_fields: {
resource_name: 'Credential Bar',
resource_id: 75,
resource_type: 'credential',
resource_type_display_name: 'Credential',
user_capabilities: { unattach: true },
},
},
{
id: 5,
name: 'Update',
type: 'role',
url: '/api/v2/roles/259/',
summary_fields: {
resource_name: 'Inventory Foo',
resource_id: 76,
resource_type: 'inventory',
resource_type_display_name: 'Inventory',
user_capabilities: { unattach: true },
},
},
{
id: 6,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/260/',
summary_fields: {
resource_name: 'Smart Inventory Foo',
resource_id: 77,
resource_type: 'smart_inventory',
resource_type_display_name: 'Inventory',
user_capabilities: { unattach: true },
},
},
],
count: 4,
},
});
TeamsAPI.readRoleOptions.mockResolvedValue({
data: { actions: { POST: { id: 1, disassociate: true } } },
});
history = createMemoryHistory({
initialEntries: ['/teams/18/access'],
});
await act(async () => {
wrapper = mountWithContexts(
<Route path="/teams/:id/access">
<TeamAccessList />
</Route>,
{
context: {
router: {
history,
route: {
location: history.location,
match: { params: { id: 18 } },
},
},
},
}
);
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render properly', async () => {
TeamsAPI.readRoles.mockResolvedValue(roles);
TeamsAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => {
wrapper = mountWithContexts(<TeamAccessList />);
});
expect(wrapper.find('TeamAccessList').length).toBe(1);
});
test('should create proper detailUrl', async () => {
TeamsAPI.readRoles.mockResolvedValue(roles);
TeamsAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => {
wrapper = mountWithContexts(<TeamAccessList />);
});
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
expect(wrapper.find(`Link#teamRole-2`).prop('to')).toBe(
@ -141,4 +134,164 @@ describe('<TeamAccessList />', () => {
'/inventories/smart_inventory/77/details'
);
});
test('should not render add button', async () => {
TeamsAPI.readRoleOptions.mockResolvedValueOnce({
data: {},
});
TeamsAPI.readRoles.mockResolvedValue({
data: {
results: [
{
id: 2,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/257/',
summary_fields: {
resource_name: 'template delete project',
resource_id: 15,
resource_type: 'job_template',
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
description: 'Can manage all aspects of the job template',
},
],
count: 1,
},
});
await act(async () => {
wrapper = mountWithContexts(<TeamAccessList />);
});
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Add resource roles"]').length).toBe(
0
);
});
test('should render disassociate modal', async () => {
TeamsAPI.readRoles.mockResolvedValue(roles);
TeamsAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => {
wrapper = mountWithContexts(<TeamAccessList />);
});
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
await act(async () =>
wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({
id: 4,
name: 'Execute',
type: 'role',
url: '/api/v2/roles/258/',
summary_fields: {
resource_name: 'Credential Bar',
resource_id: 75,
resource_type: 'credential',
resource_type_display_name: 'Credential',
user_capabilities: { unattach: true },
},
})
);
wrapper.update();
expect(
wrapper.find('AlertModal[aria-label="Disassociate role"]').length
).toBe(1);
await act(async () =>
wrapper
.find('button[aria-label="confirm disassociate"]')
.prop('onClick')()
);
expect(RolesAPI.disassociateTeamRole).toBeCalledWith(4, 18);
wrapper.update();
expect(
wrapper.find('AlertModal[aria-label="Disassociate role"]').length
).toBe(0);
});
test('should throw disassociation error', async () => {
TeamsAPI.readRoles.mockResolvedValue(roles);
RolesAPI.disassociateTeamRole.mockRejectedValue(
new Error({
response: {
config: {
method: 'post',
url: '/api/v2/roles/18/roles',
},
data: 'An error occurred',
status: 403,
},
})
);
TeamsAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => {
wrapper = mountWithContexts(<TeamAccessList />);
});
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
await act(async () =>
wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({
id: 4,
name: 'Execute',
type: 'role',
url: '/api/v2/roles/258/',
summary_fields: {
resource_name: 'Credential Bar',
resource_id: 75,
resource_type: 'credential',
resource_type_display_name: 'Credential',
user_capabilities: { unattach: true },
},
})
);
wrapper.update();
expect(
wrapper.find('AlertModal[aria-label="Disassociate role"]').length
).toBe(1);
await act(async () =>
wrapper
.find('button[aria-label="confirm disassociate"]')
.prop('onClick')()
);
wrapper.update();
expect(wrapper.find('AlertModal[title="Error!"]').length).toBe(1);
});
test('user with sys admin privilege should show empty state', async () => {
TeamsAPI.readRoles.mockResolvedValue({
data: {
results: [
{
id: 2,
name: 'System Administrator',
type: 'role',
url: '/api/v2/roles/257/',
summary_fields: {
resource_name: 'template delete project',
resource_id: 15,
resource_type: 'job_template',
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
},
],
count: 1,
},
});
TeamsAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => {
wrapper = mountWithContexts(<TeamAccessList />);
});
waitForElement(
wrapper,
'EmptyState[title="System Administrator"]',
el => el.length === 1
);
});
});

View File

@ -5,11 +5,13 @@ import {
DataListItem,
DataListItemCells,
DataListItemRow,
Chip,
} from '@patternfly/react-core';
import { Link } from 'react-router-dom';
import { DetailList, Detail } from '../../../components/DetailList';
import DataListCell from '../../../components/DataListCell';
function TeamAccessListItem({ role, i18n, detailUrl }) {
function TeamAccessListItem({ role, i18n, detailUrl, onSelect }) {
const labelId = `teamRole-${role.id}`;
return (
<DataListItem key={role.id} aria-labelledby={labelId} id={`${role.id}`}>
@ -23,18 +25,33 @@ function TeamAccessListItem({ role, i18n, detailUrl }) {
</DataListCell>,
<DataListCell key="type" aria-label={i18n._(t`resource type`)}>
{role.summary_fields && (
<>
<b css="margin-right: 24px">{i18n._(t`Type`)}</b>
{role.summary_fields.resource_type_display_name}
</>
<DetailList stacked>
<Detail
label={i18n._(t`Type`)}
value={role.summary_fields.resource_type_display_name}
/>
</DetailList>
)}
</DataListCell>,
<DataListCell key="role" aria-label={i18n._(t`resource role`)}>
{role.name && (
<>
<b css="margin-right: 24px">{i18n._(t`Role`)}</b>
{role.name}
</>
<DetailList stacked>
<Detail
label={i18n._(t`Role`)}
value={
<Chip
isReadOnly={
!role.summary_fields.user_capabilities.unattach
}
key={role.name}
aria-label={role.name}
onClick={() => onSelect(role)}
>
{role.name}
</Chip>
}
/>
</DetailList>
)}
</DataListCell>,
]}

View File

@ -18,20 +18,25 @@ describe('<TeamAccessListItem/>', () => {
},
};
beforeEach(() => {
test('should mount properly', () => {
wrapper = mountWithContexts(
<TeamAccessListItem
role={role}
detailUrl="/templates/job_template/15/details"
/>
);
});
test('should mount properly', () => {
expect(wrapper.length).toBe(1);
});
test('should render proper list item data', () => {
wrapper = mountWithContexts(
<TeamAccessListItem
role={role}
detailUrl="/templates/job_template/15/details"
/>
);
expect(
wrapper.find('PFDataListCell[aria-label="resource name"]').text()
).toBe('template delete project');
@ -42,4 +47,23 @@ describe('<TeamAccessListItem/>', () => {
wrapper.find('PFDataListCell[aria-label="resource role"]').text()
).toContain('Admin');
});
test('should render deletable chip', () => {
wrapper = mountWithContexts(
<TeamAccessListItem
role={role}
detailUrl="/templates/job_template/15/details"
/>
);
expect(wrapper.find('Chip').prop('isReadOnly')).toBe(false);
});
test('should render read only chip', () => {
role.summary_fields.user_capabilities.unattach = false;
wrapper = mountWithContexts(
<TeamAccessListItem
role={role}
detailUrl="/templates/job_template/15/details"
/>
);
expect(wrapper.find('Chip').prop('isReadOnly')).toBe(true);
});
});

View File

@ -58,7 +58,7 @@ class TeamListItem extends React.Component {
<DataListCell key="organization">
{team.summary_fields.organization && (
<Fragment>
<b css="margin-right: 24px">{i18n._(t`Organization`)}</b>
<b>{i18n._(t`Organization`)}</b>{' '}
<Link
to={`/organizations/${team.summary_fields.organization.id}/details`}
>

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { withFormik, useField, useFormikContext } from 'formik';
import { withFormik, useField } from 'formik';
import {
Form,
FormGroup,
@ -52,8 +52,6 @@ function JobTemplateForm({
submitError,
i18n,
}) {
const { values: formikValues } = useFormikContext();
const [contentError, setContentError] = useState(false);
const [inventory, setInventory] = useState(
template?.summary_fields?.inventory
@ -65,6 +63,7 @@ function JobTemplateForm({
Boolean(template.webhook_service)
);
const [askInventoryOnLaunchField] = useField('ask_inventory_on_launch');
const [jobTypeField, jobTypeMeta, jobTypeHelpers] = useField({
name: 'job_type',
validate: required(null, i18n),
@ -81,7 +80,7 @@ function JobTemplateForm({
});
const [credentialField, , credentialHelpers] = useField('credentials');
const [labelsField, , labelsHelpers] = useField('labels');
const [limitField, limitMeta] = useField('limit');
const [limitField, limitMeta, limitHelpers] = useField('limit');
const [verbosityField] = useField('verbosity');
const [diffModeField, , diffModeHelpers] = useField('diff_mode');
const [instanceGroupsField, , instanceGroupsHelpers] = useField(
@ -231,7 +230,7 @@ function JobTemplateForm({
</FieldWithPrompt>
<FieldWithPrompt
fieldId="template-inventory"
isRequired={!formikValues.ask_inventory_on_launch}
isRequired={!askInventoryOnLaunchField.value}
label={i18n._(t`Inventory`)}
promptId="template-ask-inventory-on-launch"
promptName="ask_inventory_on_launch"
@ -245,11 +244,11 @@ function JobTemplateForm({
inventoryHelpers.setValue(value ? value.id : null);
setInventory(value);
}}
required={!formikValues.ask_inventory_on_launch}
required={!askInventoryOnLaunchField.value}
touched={inventoryMeta.touched}
error={inventoryMeta.error}
/>
{(inventoryMeta.touched || formikValues.ask_inventory_on_launch) &&
{(inventoryMeta.touched || askInventoryOnLaunchField.value) &&
inventoryMeta.error && (
<div
className="pf-c-form__helper-text pf-m-error"
@ -283,8 +282,8 @@ function JobTemplateForm({
<TextInput
id="template-scm-branch"
{...scmField}
onChange={(value, event) => {
scmField.onChange(event);
onChange={value => {
scmHelpers.setValue(value);
}}
/>
</FieldWithPrompt>
@ -383,8 +382,8 @@ function JobTemplateForm({
id="template-limit"
{...limitField}
isValid={!limitMeta.touched || !limitMeta.error}
onChange={(value, event) => {
limitField.onChange(event);
onChange={value => {
limitHelpers.setValue(value);
}}
/>
</FieldWithPrompt>

View File

@ -29,6 +29,7 @@ describe('<JobTemplateForm />', () => {
playbook: 'Baz',
type: 'job_template',
scm_branch: 'Foo',
limit: '5000',
summary_fields: {
inventory: {
id: 2,
@ -184,9 +185,10 @@ describe('<JobTemplateForm />', () => {
wrapper.update();
await act(async () => {
wrapper.find('input#template-scm-branch').simulate('change', {
target: { value: 'devel', name: 'scm_branch' },
});
wrapper.find('TextInputBase#template-scm-branch').prop('onChange')(
'devel'
);
wrapper.find('TextInputBase#template-limit').prop('onChange')(1234567890);
wrapper.find('AnsibleSelect[name="playbook"]').simulate('change', {
target: { value: 'new baz type', name: 'playbook' },
});
@ -221,6 +223,9 @@ describe('<JobTemplateForm />', () => {
expect(wrapper.find('input#template-scm-branch').prop('value')).toEqual(
'devel'
);
expect(wrapper.find('input#template-limit').prop('value')).toEqual(
1234567890
);
expect(
wrapper.find('AnsibleSelect[name="playbook"]').prop('value')
).toEqual('new baz type');

View File

@ -30,6 +30,7 @@ async function loadLabelOptions(setLabels, onError) {
}
function LabelSelect({ value, placeholder, onChange, onError, createText }) {
const [isLoading, setIsLoading] = useState(true);
const { selections, onSelect, options, setOptions } = useSyncedSelectValue(
value,
onChange
@ -41,7 +42,10 @@ function LabelSelect({ value, placeholder, onChange, onError, createText }) {
};
useEffect(() => {
loadLabelOptions(setOptions, onError);
(async () => {
await loadLabelOptions(setOptions, onError);
setIsLoading(false);
})();
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, []);
@ -77,6 +81,7 @@ function LabelSelect({ value, placeholder, onChange, onError, createText }) {
}
return label;
}}
isDisabled={isLoading}
selections={selections}
isExpanded={isExpanded}
ariaLabelledBy="label-select"

View File

@ -1,39 +1,51 @@
import React, { useState, useEffect } from 'react';
import React, { useCallback, useEffect } from 'react';
import { number, string, oneOfType } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import AnsibleSelect from '../../../components/AnsibleSelect';
import { ProjectsAPI } from '../../../api';
import useRequest from '../../../util/useRequest';
function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) {
const [options, setOptions] = useState([]);
const {
result: options,
request: fetchOptions,
isLoading,
error,
} = useRequest(
useCallback(async () => {
if (!projectId) {
return [];
}
const { data } = await ProjectsAPI.readPlaybooks(projectId);
const opts = (data || []).map(playbook => ({
value: playbook,
key: playbook,
label: playbook,
isDisabled: false,
}));
opts.unshift({
value: '',
key: '',
label: i18n._(t`Choose a playbook`),
isDisabled: false,
});
return opts;
}, [projectId, i18n]),
[]
);
useEffect(() => {
if (!projectId) {
return;
}
(async () => {
try {
const { data } = await ProjectsAPI.readPlaybooks(projectId);
const opts = (data || []).map(playbook => ({
value: playbook,
key: playbook,
label: playbook,
isDisabled: false,
}));
fetchOptions();
}, [fetchOptions]);
useEffect(() => {
if (error) {
onError(error);
}
}, [error, onError]);
opts.unshift({
value: '',
key: '',
label: i18n._(t`Choose a playbook`),
isDisabled: false,
});
setOptions(opts);
} catch (contentError) {
onError(contentError);
}
})();
}, [projectId, i18n, onError]);
return (
<AnsibleSelect
id="template-playbook"
@ -41,6 +53,7 @@ function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) {
isValid={isValid}
{...field}
onBlur={onBlur}
isDisabled={isLoading}
/>
);
}

View File

@ -108,7 +108,7 @@ function User({ i18n, setBreadcrumb }) {
<UserOrganizations id={Number(match.params.id)} />
</Route>
<Route path="/users/:id/teams">
<UserTeams id={Number(match.params.id)} />
<UserTeams userId={Number(match.params.id)} />
</Route>
{user && (
<Route path="/users/:id/access">

View File

@ -1,15 +1,25 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useParams, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Button,
EmptyState,
EmptyStateBody,
EmptyStateIcon,
Title,
} from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import { UsersAPI } from '../../../api';
import useRequest from '../../../util/useRequest';
import PaginatedDataList, {
ToolbarAddButton,
} from '../../../components/PaginatedDataList';
import { UsersAPI, RolesAPI } from '../../../api';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import PaginatedDataList from '../../../components/PaginatedDataList';
import ErrorDetail from '../../../components/ErrorDetail';
import AlertModal from '../../../components/AlertModal';
import DatalistToolbar from '../../../components/DataListToolbar';
import UserAccessListItem from './UserAccessListItem';
import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd';
const QS_CONFIG = getQSConfig('roles', {
page: 1,
@ -22,7 +32,9 @@ const QS_CONFIG = getQSConfig('roles', {
function UserAccessList({ i18n }) {
const { id } = useParams();
const { search } = useLocation();
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [roleToDisassociate, setRoleToDisassociate] = useState(null);
const {
isLoading,
request: fetchRoles,
@ -52,9 +64,31 @@ function UserAccessList({ i18n }) {
useEffect(() => {
fetchRoles();
}, [fetchRoles]);
const {
isLoading: isDisassociateLoading,
deleteItems: disassociateRole,
deletionError: disassociationError,
clearDeletionError: clearDisassociationError,
} = useDeleteItems(
useCallback(async () => {
setRoleToDisassociate(null);
await RolesAPI.disassociateUserRole(
roleToDisassociate.id,
parseInt(id, 10)
);
}, [roleToDisassociate, id]),
{ qsConfig: QS_CONFIG, fetchItems: fetchRoles }
);
const canAdd =
options && Object.prototype.hasOwnProperty.call(options, 'POST');
const saveRoles = () => {
setIsWizardOpen(false);
fetchRoles();
};
const detailUrl = role => {
const { resource_id, resource_type } = role.summary_fields;
@ -70,50 +104,136 @@ function UserAccessList({ i18n }) {
}
return `/${resource_type}s/${resource_id}/details`;
};
const isSysAdmin = roles.some(role => role.name === 'System Administrator');
if (isSysAdmin) {
return (
<EmptyState variant="full">
<EmptyStateIcon icon={CubesIcon} />
<Title headingLevel="h5" size="lg">
{i18n._(t`System Administrator`)}
</Title>
<EmptyStateBody>
{i18n._(
t`System administrators have unrestricted access to all resources.`
)}
</EmptyStateBody>
</EmptyState>
);
}
return (
<PaginatedDataList
contentError={error}
hasContentLoading={isLoading}
items={roles}
itemCount={roleCount}
pluralizedItemName={i18n._(t`User Roles`)}
qsConfig={QS_CONFIG}
toolbarSearchColumns={[
{
name: i18n._(t`Role`),
key: 'role_field',
isDefault: true,
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'id',
},
]}
renderItem={role => {
return (
<UserAccessListItem
key={role.id}
value={role.name}
role={role}
detailUrl={detailUrl(role)}
onSelect={() => {}}
isSelected={false}
<>
<PaginatedDataList
contentError={error}
hasContentLoading={isLoading || isDisassociateLoading}
items={roles}
itemCount={roleCount}
pluralizedItemName={i18n._(t`User Roles`)}
qsConfig={QS_CONFIG}
toolbarSearchColumns={[
{
name: i18n._(t`Role`),
key: 'role_field',
isDefault: true,
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'id',
},
]}
renderItem={role => {
return (
<UserAccessListItem
key={role.id}
value={role.name}
role={role}
detailUrl={detailUrl(role)}
isSelected={false}
onSelect={item => {
setRoleToDisassociate(item);
}}
/>
);
}}
renderToolbar={props => (
<DatalistToolbar
{...props}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
? [
<Button
key="add"
aria-label={i18n._(t`Add resource roles`)}
onClick={() => {
setIsWizardOpen(true);
}}
>
Add
</Button>,
]
: []),
]}
/>
);
}}
renderToolbar={props => (
<DatalistToolbar
{...props}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd ? [<ToolbarAddButton key="add" linkTo="/" />] : []),
]}
)}
/>
{isWizardOpen && (
<UserAndTeamAccessAdd
apiModel={UsersAPI}
isOpen={isWizardOpen}
onSave={saveRoles}
onClose={() => setIsWizardOpen(false)}
title={i18n._(t`Add user permissions`)}
/>
)}
/>
{roleToDisassociate && (
<AlertModal
aria-label={i18n._(t`Disassociate role`)}
isOpen={roleToDisassociate}
variant="error"
title={i18n._(t`Disassociate role!`)}
onClose={() => setRoleToDisassociate(null)}
actions={[
<Button
key="disassociate"
variant="danger"
aria-label={i18n._(t`confirm disassociate`)}
onClick={() => disassociateRole()}
>
{i18n._(t`Disassociate`)}
</Button>,
<Button
key="cancel"
variant="secondary"
aria-label={i18n._(t`Cancel`)}
onClick={() => setRoleToDisassociate(null)}
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
<div>
{i18n._(
t`This action will disassociate the following role from ${roleToDisassociate.summary_fields.resource_name}:`
)}
<br />
<strong>{roleToDisassociate.name}</strong>
</div>
</AlertModal>
)}
{disassociationError && (
<AlertModal
isOpen={disassociationError}
variant="error"
title={i18n._(t`Error!`)}
onClose={clearDisassociationError}
>
{i18n._(t`Failed to delete role.`)}
<ErrorDetail error={disassociationError} />
</AlertModal>
)}
</>
);
}
export default withI18n()(UserAccessList);

View File

@ -1,8 +1,6 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { Route } from 'react-router-dom';
import { UsersAPI } from '../../../api';
import { UsersAPI, RolesAPI } from '../../../api';
import {
mountWithContexts,
waitForElement,
@ -10,119 +8,114 @@ import {
import UserAccessList from './UserAccessList';
jest.mock('../../../api/models/Users');
jest.mock('../../../api/models/Roles');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 18,
}),
}));
const roles = {
data: {
results: [
{
id: 2,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/257/',
summary_fields: {
resource_name: 'template delete project',
resource_id: 15,
resource_type: 'job_template',
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
},
{
id: 3,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/257/',
summary_fields: {
resource_name: 'template delete project',
resource_id: 16,
resource_type: 'workflow_job_template',
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
},
{
id: 4,
name: 'Execute',
type: 'role',
url: '/api/v2/roles/258/',
summary_fields: {
resource_name: 'Credential Bar',
resource_id: 75,
resource_type: 'credential',
resource_type_display_name: 'Credential',
user_capabilities: { unattach: true },
},
},
{
id: 5,
name: 'Update',
type: 'role',
url: '/api/v2/roles/259/',
summary_fields: {
resource_name: 'Inventory Foo',
resource_id: 76,
resource_type: 'inventory',
resource_type_display_name: 'Inventory',
user_capabilities: { unattach: true },
},
},
{
id: 6,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/260/',
summary_fields: {
resource_name: 'Smart Inventory Foo',
resource_id: 77,
resource_type: 'smart_inventory',
resource_type_display_name: 'Inventory',
user_capabilities: { unattach: true },
},
},
],
count: 5,
},
};
const options = {
data: { actions: { POST: { id: 1, disassociate: true } } },
};
describe('<UserAccessList />', () => {
let wrapper;
let history;
beforeEach(async () => {
UsersAPI.readRoles.mockResolvedValue({
data: {
results: [
{
id: 2,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/257/',
summary_fields: {
resource_name: 'template delete project',
resource_id: 15,
resource_type: 'job_template',
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
},
{
id: 3,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/257/',
summary_fields: {
resource_name: 'template delete project',
resource_id: 16,
resource_type: 'workflow_job_template',
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
},
{
id: 4,
name: 'Execute',
type: 'role',
url: '/api/v2/roles/258/',
summary_fields: {
resource_name: 'Credential Bar',
resource_id: 75,
resource_type: 'credential',
resource_type_display_name: 'Credential',
user_capabilities: { unattach: true },
},
},
{
id: 5,
name: 'Update',
type: 'role',
url: '/api/v2/roles/259/',
summary_fields: {
resource_name: 'Inventory Foo',
resource_id: 76,
resource_type: 'inventory',
resource_type_display_name: 'Inventory',
user_capabilities: { unattach: true },
},
},
{
id: 6,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/260/',
summary_fields: {
resource_name: 'Smart Inventory Foo',
resource_id: 77,
resource_type: 'smart_inventory',
resource_type_display_name: 'Inventory',
user_capabilities: { unattach: true },
},
},
],
count: 4,
},
});
UsersAPI.readRoleOptions.mockResolvedValue({
data: { actions: { POST: { id: 1, disassociate: true } } },
});
history = createMemoryHistory({
initialEntries: ['/users/18/access'],
});
await act(async () => {
wrapper = mountWithContexts(
<Route path="/users/:id/access">
<UserAccessList />
</Route>,
{
context: {
router: {
history,
route: {
location: history.location,
match: { params: { id: 18 } },
},
},
},
}
);
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
// wrapper.unmount();
});
test('should render properly', async () => {
UsersAPI.readRoles.mockResolvedValue(roles);
UsersAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => {
wrapper = mountWithContexts(<UserAccessList />);
});
expect(wrapper.find('UserAccessList').length).toBe(1);
});
test('should create proper detailUrl', async () => {
UsersAPI.readRoles.mockResolvedValue(roles);
UsersAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => {
wrapper = mountWithContexts(<UserAccessList />);
});
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
expect(wrapper.find(`Link#userRole-2`).prop('to')).toBe(
@ -141,4 +134,195 @@ describe('<UserAccessList />', () => {
'/inventories/smart_inventory/77/details'
);
});
test('should not render add button', async () => {
UsersAPI.readRoleOptions.mockResolvedValueOnce({
data: {},
});
UsersAPI.readRoles.mockResolvedValue({
data: {
results: [
{
id: 2,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/257/',
summary_fields: {
resource_name: 'template delete project',
resource_id: 15,
resource_type: 'job_template',
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
object_roles: {
admin_role: {
description: 'Can manage all aspects of the job template',
name: 'Admin',
id: 164,
},
execute_role: {
description: 'May run the job template',
name: 'Execute',
id: 165,
},
read_role: {
description: 'May view settings for the job template',
name: 'Read',
id: 166,
},
},
},
},
],
count: 1,
},
});
await act(async () => {
wrapper = mountWithContexts(<UserAccessList />);
});
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Add resource roles"]').length).toBe(
0
);
});
test('should open and close wizard', async () => {
UsersAPI.readRoles.mockResolvedValue(roles);
UsersAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => {
wrapper = mountWithContexts(<UserAccessList />);
});
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
await act(async () =>
wrapper.find('Button[aria-label="Add resource roles"]').prop('onClick')()
);
wrapper.update();
expect(wrapper.find('PFWizard').length).toBe(1);
await act(async () =>
wrapper.find("Button[aria-label='Close']").prop('onClick')()
);
wrapper.update();
expect(wrapper.find('PFWizard').length).toBe(0);
});
test('should render disassociate modal', async () => {
UsersAPI.readRoles.mockResolvedValue(roles);
UsersAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => {
wrapper = mountWithContexts(<UserAccessList />);
});
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
await act(async () =>
wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({
id: 4,
name: 'Execute',
type: 'role',
url: '/api/v2/roles/258/',
summary_fields: {
resource_name: 'Credential Bar',
resource_id: 75,
resource_type: 'credential',
resource_type_display_name: 'Credential',
user_capabilities: { unattach: true },
},
})
);
wrapper.update();
expect(
wrapper.find('AlertModal[aria-label="Disassociate role"]').length
).toBe(1);
await act(async () =>
wrapper
.find('button[aria-label="confirm disassociate"]')
.prop('onClick')()
);
expect(RolesAPI.disassociateUserRole).toBeCalledWith(4, 18);
wrapper.update();
expect(
wrapper.find('AlertModal[aria-label="Disassociate role"]').length
).toBe(0);
});
test('should throw disassociation error', async () => {
UsersAPI.readRoles.mockResolvedValue(roles);
RolesAPI.disassociateUserRole.mockRejectedValue(
new Error({
response: {
config: {
method: 'post',
url: '/api/v2/roles/18/roles',
},
data: 'An error occurred',
status: 403,
},
})
);
UsersAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => {
wrapper = mountWithContexts(<UserAccessList />);
});
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
await act(async () =>
wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({
id: 4,
name: 'Execute',
type: 'role',
url: '/api/v2/roles/258/',
summary_fields: {
resource_name: 'Credential Bar',
resource_id: 75,
resource_type: 'credential',
resource_type_display_name: 'Credential',
user_capabilities: { unattach: true },
},
})
);
wrapper.update();
expect(
wrapper.find('AlertModal[aria-label="Disassociate role"]').length
).toBe(1);
await act(async () =>
wrapper
.find('button[aria-label="confirm disassociate"]')
.prop('onClick')()
);
wrapper.update();
expect(wrapper.find('AlertModal[title="Error!"]').length).toBe(1);
});
test('user with sys admin privilege should show empty state', async () => {
UsersAPI.readRoles.mockResolvedValue({
data: {
results: [
{
id: 2,
name: 'System Administrator',
type: 'role',
url: '/api/v2/roles/257/',
summary_fields: {
resource_name: 'template delete project',
resource_id: 15,
resource_type: 'job_template',
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
},
],
count: 1,
},
});
UsersAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => {
wrapper = mountWithContexts(<UserAccessList />);
});
waitForElement(
wrapper,
'EmptyState[title="System Administrator"]',
el => el.length === 1
);
});
});

Some files were not shown because too many files have changed in this diff Show More