diff --git a/CHANGELOG.md b/CHANGELOG.md index cd60d6ba69..64e65baca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/`. ## 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) diff --git a/INSTALL.md b/INSTALL.md index 8af4c332dd..f790bc03ba 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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* diff --git a/awx/main/access.py b/awx/main/access.py index f1bbc42683..4705fb2cfc 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -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 ) diff --git a/awx/main/credential_plugins/conjur.py b/awx/main/credential_plugins/conjur.py index a82b91893b..718eebbc64 100644 --- a/awx/main/credential_plugins/conjur.py +++ b/awx/main/credential_plugins/conjur.py @@ -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) diff --git a/awx/main/tests/functional/test_labels.py b/awx/main/tests/functional/test_labels.py new file mode 100644 index 0000000000..fad1869d0e --- /dev/null +++ b/awx/main/tests/functional/test_labels.py @@ -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 diff --git a/awx/ui_next/CONTRIBUTING.md b/awx/ui_next/CONTRIBUTING.md index f2b41cee68..85c3ce18e3 100644 --- a/awx/ui_next/CONTRIBUTING.md +++ b/awx/ui_next/CONTRIBUTING.md @@ -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`| Use for methods that process events | -|`on`| Use for component prop names | -|`toggle`| Use for methods that flip one value to the opposite value | -|`show`| Use for methods that always set a value to show or add an element | -|`hide`| Use for methods that always set a value to hide or remove an element | -|`create`| Use for methods that make API `POST` requests | -|`read`| Use for methods that make API `GET` requests | -|`update`| Use for methods that make API `PATCH` requests | -|`destroy`| Use for methods that make API `DESTROY` requests | -|`replace`| Use for methods that make API `PUT` requests | -|`disassociate`| Use for methods that pass `{ disassociate: true }` as a data param to an endpoint | -|`associate`| Use for methods that pass a resource id as a data param to an endpoint | -|`can`| Use for props dealing with RBAC to denote whether a user has access to something | +| Naming Convention | Description | +| ----------------- | --------------------------------------------------------------------------------- | +| `handle` | Use for methods that process events | +| `on` | Use for component prop names | +| `toggle` | Use for methods that flip one value to the opposite value | +| `show` | Use for methods that always set a value to show or add an element | +| `hide` | Use for methods that always set a value to hide or remove an element | +| `create` | Use for methods that make API `POST` requests | +| `read` | Use for methods that make API `GET` requests | +| `update` | Use for methods that make API `PATCH` requests | +| `destroy` | Use for methods that make API `DESTROY` requests | +| `replace` | Use for methods that make API `PUT` requests | +| `disassociate` | Use for methods that pass `{ disassociate: true }` as a data param to an endpoint | +| `associate` | Use for methods that pass a resource id as a data param to an endpoint | +| `can` | 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. diff --git a/awx/ui_next/package-lock.json b/awx/ui_next/package-lock.json index f9a6b9cf15..57577d86d9 100644 --- a/awx/ui_next/package-lock.json +++ b/awx/ui_next/package-lock.json @@ -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", diff --git a/awx/ui_next/src/App.jsx b/awx/ui_next/src/App.jsx index df2291fbd5..ffafd07dba 100644 --- a/awx/ui_next/src/App.jsx +++ b/awx/ui_next/src/App.jsx @@ -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) ? ( + {children} + ) : ( + + ); -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 = ( - } - logoProps={{ href: '/' }} - toolbar={ - - } - /> - ); - - const sidebar = ( - - - {routeGroups.map(({ groupId, groupTitle, routes }) => ( - - ))} - - - } - /> - ); - - return ( - - - - {render({ routeGroups })} - - - - - {i18n._(t`Failed to retrieve configuration.`)} - - - - ); - } + return ( + + + {({ i18n }) => ( + + + + + + + + + + + + + + + {getRouteConfig(i18n) + .flatMap(({ routes }) => routes) + .map(({ path, screen: Screen }) => ( + + + + )) + .concat( + + + + )} + + + + + + )} + + + ); } -export { App as _App }; -export default withI18n()(withRouter(App)); +export default () => ( + + + +); diff --git a/awx/ui_next/src/App.test.jsx b/awx/ui_next/src/App.test.jsx index 5b410f95eb..c2d667df3b 100644 --- a/awx/ui_next/src/App.test.jsx +++ b/awx/ui_next/src/App.test.jsx @@ -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('', () => { - 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( - - routeGroups.map(({ 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(); - 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().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().find('App'); - appWrapper.instance().handleLogout(); - await asyncFlush(); - expect(RootAPI.logout).toHaveBeenCalledTimes(1); - done(); + expect(wrapper.length).toBe(1); }); }); diff --git a/awx/ui_next/src/RootProvider.jsx b/awx/ui_next/src/RootProvider.jsx deleted file mode 100644 index d81121492b..0000000000 --- a/awx/ui_next/src/RootProvider.jsx +++ /dev/null @@ -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 ( - - - {children} - - - ); - } -} - -export default RootProvider; diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index e5f1f34557..36330716fd 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -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, }; diff --git a/awx/ui_next/src/api/models/Config.js b/awx/ui_next/src/api/models/Config.js index f8e89df245..878ddfad70 100644 --- a/awx/ui_next/src/api/models/Config.js +++ b/awx/ui_next/src/api/models/Config.js @@ -4,6 +4,7 @@ class Config extends Base { constructor(http) { super(http); this.baseUrl = '/api/v2/config/'; + this.read = this.read.bind(this); } } diff --git a/awx/ui_next/src/api/models/CredentialInputSources.js b/awx/ui_next/src/api/models/CredentialInputSources.js new file mode 100644 index 0000000000..ec09cba267 --- /dev/null +++ b/awx/ui_next/src/api/models/CredentialInputSources.js @@ -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; diff --git a/awx/ui_next/src/api/models/Credentials.js b/awx/ui_next/src/api/models/Credentials.js index 9b31506956..ec7f97812d 100644 --- a/awx/ui_next/src/api/models/Credentials.js +++ b/awx/ui_next/src/api/models/Credentials.js @@ -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; diff --git a/awx/ui_next/src/api/models/InventorySources.js b/awx/ui_next/src/api/models/InventorySources.js index 4889a9434c..292aebf290 100644 --- a/awx/ui_next/src/api/models/InventorySources.js +++ b/awx/ui_next/src/api/models/InventorySources.js @@ -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/'; diff --git a/awx/ui_next/src/api/models/Roles.js b/awx/ui_next/src/api/models/Roles.js new file mode 100644 index 0000000000..5846d9fa79 --- /dev/null +++ b/awx/ui_next/src/api/models/Roles.js @@ -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; diff --git a/awx/ui_next/src/api/models/Users.js b/awx/ui_next/src/api/models/Users.js index b98cf45cae..12eb74c4a6 100644 --- a/awx/ui_next/src/api/models/Users.js +++ b/awx/ui_next/src/api/models/Users.js @@ -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; diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 639a6e8128..5727a78c74 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx @@ -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} /> diff --git a/awx/ui_next/src/components/AddRole/CheckboxCard.jsx b/awx/ui_next/src/components/AddRole/CheckboxCard.jsx index a1b56939d0..361bf1a60d 100644 --- a/awx/ui_next/src/components/AddRole/CheckboxCard.jsx +++ b/awx/ui_next/src/components/AddRole/CheckboxCard.jsx @@ -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 ( -
+ -
+ ); } } diff --git a/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx b/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx index 9427bca17b..461327587e 100644 --- a/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx +++ b/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx @@ -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 ( - - {isInitialized && ( - -
- {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.` - )} -
- {selectedResourceRows.length > 0 && ( - - )} - ( - i.id === item.id)} - itemId={item.id} - key={item.id} - name={item[displayKey]} - label={item[displayKey]} - onSelect={() => onRowClick(item)} - onDeselect={() => onRowClick(item)} - /> - )} - renderToolbar={props => } - showPageSizeOptions={false} - /> -
+ return ( + +
+ {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 ?
error
: ''} - - ); - } +
+ {selectedResourceRows.length > 0 && ( + + )} + ( + i.id === item.id)} + itemId={item.id} + key={item.id} + name={item[displayKey]} + label={item[displayKey]} + onSelect={() => onRowClick(item)} + onDeselect={() => onRowClick(item)} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> +
+ ); } 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), }; diff --git a/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx b/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx index e925044ed5..d309ea706f 100644 --- a/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx +++ b/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx @@ -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('', () => { 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('', () => { ], }, }); - mountWithContexts( - {}} - onSearch={handleSearch} - /> - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + {}} + 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( - {}} - 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('', () => { { id: 2, username: 'bar', url: 'item/2' }, ], }; - const wrapper = mountWithContexts( - ({ data })} - selectedResourceRows={[]} - /> - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + ({ data })} + selectedResourceRows={[]} + /> + ); + }); await sleep(0); wrapper.update(); const checkboxListItemWrapper = wrapper.find('CheckboxListItem'); expect(checkboxListItemWrapper.length).toBe(2); + checkboxListItemWrapper .first() .find('input[type="checkbox"]') diff --git a/awx/ui_next/src/components/AppContainer/AppContainer.jsx b/awx/ui_next/src/components/AppContainer/AppContainer.jsx new file mode 100644 index 0000000000..690d7fae72 --- /dev/null +++ b/awx/ui_next/src/components/AppContainer/AppContainer.jsx @@ -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 = ( + } + logoProps={{ href: '/' }} + toolbar={ + + } + /> + ); + + const sidebar = ( + + + {navRouteConfig.map(({ groupId, groupTitle, routes }) => ( + + ))} + + + } + /> + ); + + return ( + <> + + {children} + + + + {i18n._(t`Failed to retrieve configuration.`)} + + + + ); +} + +export { AppContainer as _AppContainer }; +export default withI18n()(withRouter(AppContainer)); diff --git a/awx/ui_next/src/components/AppContainer/AppContainer.test.jsx b/awx/ui_next/src/components/AppContainer/AppContainer.test.jsx new file mode 100644 index 0000000000..c01a7ee6d4 --- /dev/null +++ b/awx/ui_next/src/components/AppContainer/AppContainer.test.jsx @@ -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('', () => { + 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( + + {routeConfig.map(({ groupId }) => ( +
+ ))} + + ); + }); + + // 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(); + }); + + // 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(); + }); + + // 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); + }); +}); diff --git a/awx/ui_next/src/components/BrandLogo/BrandLogo.jsx b/awx/ui_next/src/components/AppContainer/BrandLogo.jsx similarity index 100% rename from awx/ui_next/src/components/BrandLogo/BrandLogo.jsx rename to awx/ui_next/src/components/AppContainer/BrandLogo.jsx diff --git a/awx/ui_next/src/components/BrandLogo/BrandLogo.test.jsx b/awx/ui_next/src/components/AppContainer/BrandLogo.test.jsx similarity index 100% rename from awx/ui_next/src/components/BrandLogo/BrandLogo.test.jsx rename to awx/ui_next/src/components/AppContainer/BrandLogo.test.jsx diff --git a/awx/ui_next/src/components/NavExpandableGroup/NavExpandableGroup.jsx b/awx/ui_next/src/components/AppContainer/NavExpandableGroup.jsx similarity index 100% rename from awx/ui_next/src/components/NavExpandableGroup/NavExpandableGroup.jsx rename to awx/ui_next/src/components/AppContainer/NavExpandableGroup.jsx diff --git a/awx/ui_next/src/components/NavExpandableGroup/NavExpandableGroup.test.jsx b/awx/ui_next/src/components/AppContainer/NavExpandableGroup.test.jsx similarity index 100% rename from awx/ui_next/src/components/NavExpandableGroup/NavExpandableGroup.test.jsx rename to awx/ui_next/src/components/AppContainer/NavExpandableGroup.test.jsx diff --git a/awx/ui_next/src/components/PageHeaderToolbar/PageHeaderToolbar.jsx b/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx similarity index 100% rename from awx/ui_next/src/components/PageHeaderToolbar/PageHeaderToolbar.jsx rename to awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx diff --git a/awx/ui_next/src/components/PageHeaderToolbar/PageHeaderToolbar.test.jsx b/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.test.jsx similarity index 100% rename from awx/ui_next/src/components/PageHeaderToolbar/PageHeaderToolbar.test.jsx rename to awx/ui_next/src/components/AppContainer/PageHeaderToolbar.test.jsx diff --git a/awx/ui_next/src/components/AppContainer/index.jsx b/awx/ui_next/src/components/AppContainer/index.jsx new file mode 100644 index 0000000000..4fa9f5e563 --- /dev/null +++ b/awx/ui_next/src/components/AppContainer/index.jsx @@ -0,0 +1,3 @@ +import AppContainer from './AppContainer'; + +export default AppContainer; diff --git a/awx/ui_next/src/components/BrandLogo/index.js b/awx/ui_next/src/components/BrandLogo/index.js deleted file mode 100644 index aa50d3906d..0000000000 --- a/awx/ui_next/src/components/BrandLogo/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './BrandLogo'; diff --git a/awx/ui_next/src/components/FormField/PasswordField.jsx b/awx/ui_next/src/components/FormField/PasswordField.jsx index d865a8b70c..f65aa6d3c4 100644 --- a/awx/ui_next/src/components/FormField/PasswordField.jsx +++ b/awx/ui_next/src/components/FormField/PasswordField.jsx @@ -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 ( - - - - { - field.onChange(event); - }} - /> + ); @@ -79,4 +39,4 @@ PasswordField.defaultProps = { isDisabled: false, }; -export default withI18n()(PasswordField); +export default PasswordField; diff --git a/awx/ui_next/src/components/FormField/PasswordField.test.jsx b/awx/ui_next/src/components/FormField/PasswordField.test.jsx index 2f2ceab52a..e2b40dbd09 100644 --- a/awx/ui_next/src/components/FormField/PasswordField.test.jsx +++ b/awx/ui_next/src/components/FormField/PasswordField.test.jsx @@ -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( - - {() => ( - - )} - - ); - 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); - }); }); diff --git a/awx/ui_next/src/components/FormField/PasswordInput.jsx b/awx/ui_next/src/components/FormField/PasswordInput.jsx new file mode 100644 index 0000000000..993ee9a523 --- /dev/null +++ b/awx/ui_next/src/components/FormField/PasswordInput.jsx @@ -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 ( + <> + + + + { + 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); diff --git a/awx/ui_next/src/components/FormField/PasswordInput.test.jsx b/awx/ui_next/src/components/FormField/PasswordInput.test.jsx new file mode 100644 index 0000000000..a506328f15 --- /dev/null +++ b/awx/ui_next/src/components/FormField/PasswordInput.test.jsx @@ -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( + + {() => ( + + )} + + ); + expect(wrapper).toHaveLength(1); + }); + + test('properly responds to show/hide toggles', async () => { + const wrapper = mountWithContexts( + + {() => ( + + )} + + ); + 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); + }); +}); diff --git a/awx/ui_next/src/components/FormField/index.js b/awx/ui_next/src/components/FormField/index.js index 563f8519eb..fd0c95dafd 100644 --- a/awx/ui_next/src/components/FormField/index.js +++ b/awx/ui_next/src/components/FormField/index.js @@ -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'; diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index 4f4e1002e5..6eb9ec0ecc 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -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); }; diff --git a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js index 261c02a875..5cb60ac2ac 100644 --- a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js +++ b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js @@ -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, diff --git a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js index bd696ab9e5..bd3d04cae9 100644 --- a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js +++ b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js @@ -32,6 +32,10 @@ describe('mergeExtraVars', () => { }); }); + test('should handle undefined', () => { + expect(mergeExtraVars(undefined, undefined)).toEqual({}); + }); + describe('maskPasswords', () => { test('should mask password fields', () => { const vars = { diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx index f7e8ed1c36..14e26f4d10 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx @@ -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 ( - <> + + {formErrors.length > 0 && ( + + {i18n._(t`Some of the previous step(s) have errors`)} + + + + + )} - {formErrors && ( -
    - {Object.keys(formErrors).map( - field => `${field}: ${formErrors[field]}` - )} -
- )} - +
); } -export default PreviewStep; +export default withI18n()(PreviewStep); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx index 47aba96bb1..b596e866ea 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx @@ -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} /> ); @@ -62,6 +67,7 @@ describe('PreviewStep', () => { config={{ ask_limit_on_launch: true, }} + formErrors={formErrors} /> ); @@ -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( + + + + ); + }); + + const detail = wrapper.find('PromptDetail'); + expect(detail).toHaveLength(1); + expect(detail.prop('resource')).toEqual(resource); + expect(detail.prop('overrides')).toEqual({ + extra_vars: 'one: 1', + }); + }); }); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx index f63d85599b..85299a08c1 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx @@ -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, diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx index cd1deb76f7..aa8acbd6f6 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx @@ -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, diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx index 516238ca7a..1d33987c92 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx @@ -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, diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx index 5ee623dd14..ac0fbe0c3c 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx @@ -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; diff --git a/awx/ui_next/src/components/LaunchPrompt/useSteps.js b/awx/ui_next/src/components/LaunchPrompt/useSteps.js index ed61a01804..e8519c58a4 100644 --- a/awx/ui_next/src/components/LaunchPrompt/useSteps.js +++ b/awx/ui_next/src/components/LaunchPrompt/useSteps.js @@ -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) => { diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index 26d1760f57..ecc5aa142b 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -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 ( ( { 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 }) => ( dispatch({ type: 'TOGGLE_MODAL' })} variant={ButtonVariant.tertiary} + isDisabled={isLoading} > diff --git a/awx/ui_next/src/components/Lookup/Lookup.test.jsx b/awx/ui_next/src/components/Lookup/Lookup.test.jsx index 7fb1ed464c..bcb4d9fdc1 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.test.jsx @@ -159,4 +159,30 @@ describe('', () => { 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( + ( + + )} + /> + ); + checkRootElementNotPresent('body div[role="dialog"]'); + const button = wrapper.find('button[aria-label="Search"]'); + expect(button.prop('disabled')).toEqual(true); + }); }); diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index c1dec4f9d7..d76bd905c8 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -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 }) => ( { return ( diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx index a73a6b4025..dedd7bc7c7 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx @@ -156,7 +156,7 @@ describe('', () => { }); }); wrapper.update(); - act(() => { + await act(async () => { wrapper.find('Button[variant="primary"]').invoke('onClick')(); }); expect(onChange).toBeCalledWith([ @@ -201,7 +201,7 @@ describe('', () => { }); }); wrapper.update(); - act(() => { + await act(async () => { wrapper.find('Button[variant="primary"]').invoke('onClick')(); }); expect(onChange).toBeCalledWith([ @@ -248,7 +248,7 @@ describe('', () => { }); }); wrapper.update(); - act(() => { + await act(async () => { wrapper.find('Button[variant="primary"]').invoke('onClick')(); }); expect(onChange).toBeCalledWith([ @@ -301,7 +301,7 @@ describe('', () => { }); }); wrapper.update(); - act(() => { + await act(async () => { wrapper.find('Button[variant="primary"]').invoke('onClick')(); }); expect(onChange).toBeCalledWith([ diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index 2b1ee265ac..03c6dbbbe3 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -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 }) => ( ', () => { 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( + + ); + 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); + }); }); diff --git a/awx/ui_next/src/components/NotificationList/NotificationListItem.jsx b/awx/ui_next/src/components/NotificationList/NotificationListItem.jsx index d42f536084..6857114479 100644 --- a/awx/ui_next/src/components/NotificationList/NotificationListItem.jsx +++ b/awx/ui_next/src/components/NotificationList/NotificationListItem.jsx @@ -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) { , + {typeLabels[notification.notification_type]} , ]} diff --git a/awx/ui_next/src/components/NotificationList/NotificationListItem.test.jsx b/awx/ui_next/src/components/NotificationList/NotificationListItem.test.jsx index d954bb51b2..6965b50ff2 100644 --- a/awx/ui_next/src/components/NotificationList/NotificationListItem.test.jsx +++ b/awx/ui_next/src/components/NotificationList/NotificationListItem.test.jsx @@ -55,7 +55,7 @@ describe('', () => { .find('DataListCell') .at(1) .find('div'); - expect(typeCell.text()).toBe('Slack'); + expect(typeCell.text()).toContain('Slack'); }); test('handles start click when toggle is on', () => { diff --git a/awx/ui_next/src/components/NotificationList/__snapshots__/NotificationListItem.test.jsx.snap b/awx/ui_next/src/components/NotificationList/__snapshots__/NotificationListItem.test.jsx.snap index f99a0c51fd..841f4d2060 100644 --- a/awx/ui_next/src/components/NotificationList/__snapshots__/NotificationListItem.test.jsx.snap +++ b/awx/ui_next/src/components/NotificationList/__snapshots__/NotificationListItem.test.jsx.snap @@ -58,6 +58,9 @@ exports[` initially renders succe , + + Type + Slack , ] @@ -167,6 +170,41 @@ exports[` initially renders succe
+ + + + Type + + + Slack
diff --git a/awx/ui_next/src/components/PageHeaderToolbar/index.js b/awx/ui_next/src/components/PageHeaderToolbar/index.js deleted file mode 100644 index debdb7da16..0000000000 --- a/awx/ui_next/src/components/PageHeaderToolbar/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './PageHeaderToolbar'; diff --git a/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx new file mode 100644 index 0000000000..ceac1a32f5 --- /dev/null +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx @@ -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: ( + + {getResourceAccessConfig(i18n).map(resource => ( + setSelectedResourceType(resource)} + /> + ))} + + ), + enableNext: selectedResourceType !== null, + }, + { + id: 2, + name: i18n._(t`Select items from list`), + component: selectedResourceType && ( + + ), + enableNext: resourcesSelected.length > 0, + canJumpTo: stepIdReached >= 2, + }, + { + id: 3, + name: i18n._(t`Select roles to apply`), + component: resourcesSelected?.length > 0 && ( + + ), + nextButtonText: i18n._(t`Save`), + canJumpTo: stepIdReached >= 3, + }, + ]; + + if (error) { + return ( + + {i18n._(t`Failed to associate role`)} + + + ); + } + + return ( + + setStepIdReached(stepIdReached < id ? id : stepIdReached) + } + onSave={handleWizardSave} + /> + ); +} + +export default withI18n()(UserAndTeamAccessAdd); diff --git a/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx new file mode 100644 index 0000000000..b80c24a493 --- /dev/null +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx @@ -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('', () => { + 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( + {}} + 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); + }); +}); diff --git a/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js b/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js new file mode 100644 index 0000000000..718476e70e --- /dev/null +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js @@ -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), + }, + ]; +} diff --git a/awx/ui_next/src/components/UserAndTeamAccessAdd/index.js b/awx/ui_next/src/components/UserAndTeamAccessAdd/index.js new file mode 100644 index 0000000000..c445f018ba --- /dev/null +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/index.js @@ -0,0 +1 @@ +export { default } from './UserAndTeamAccessAdd'; diff --git a/awx/ui_next/src/index.jsx b/awx/ui_next/src/index.jsx index 779b12b772..ea4b815a21 100644 --- a/awx/ui_next/src/index.jsx +++ b/awx/ui_next/src/index.jsx @@ -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 = ( - } - /> - ); - - const defaultRedirect = () => { - if (isAuthenticated(document.cookie)) { - return ; - } - return ( - - {removeTrailingSlash} - } - /> - - - ); - }; - - return render( - - - {({ i18n }) => ( - - - {removeTrailingSlash} - - - { - if (!isAuthenticated(document.cookie)) { - return ; - } - return ( - { - const routeList = routeGroups - .reduce( - (allRoutes, { routes }) => allRoutes.concat(routes), - [] - ) - .map(({ component: PageComponent, path }) => ( - ( - - )} - /> - )); - routeList.push( - - ); - return {routeList}; - }} - /> - ); - }} - /> - - - )} - - , - el || document.createElement('div') - ); -} - -main(ReactDOM.render); +ReactDOM.render( + , + document.getElementById('app') || document.createElement('div') +); diff --git a/awx/ui_next/src/index.test.jsx b/awx/ui_next/src/index.test.jsx index 085e86ac66..82dec86c56 100644 --- a/awx/ui_next/src/index.test.jsx +++ b/awx/ui_next/src/index.test.jsx @@ -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({template}); +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(, div); }); }); diff --git a/awx/ui_next/src/routeConfig.js b/awx/ui_next/src/routeConfig.js new file mode 100644 index 0000000000..37f1732cb0 --- /dev/null +++ b/awx/ui_next/src/routeConfig.js @@ -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; diff --git a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx index e42b3faec7..c721b56789 100644 --- a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx @@ -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} /> diff --git a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx index 71409638f6..aef18ea8e9 100644 --- a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx @@ -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} /> ); diff --git a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx index 4b0ab68187..3d4ce756cb 100644 --- a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx @@ -245,6 +245,7 @@ CredentialTypesAPI.read.mockResolvedValue({ }); CredentialsAPI.update.mockResolvedValue({ data: { id: 3 } }); +CredentialsAPI.readInputSources.mockResolvedValue({ data: { results: [] } }); describe('', () => { let wrapper; diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx index bccfa50583..ddd4ccaa5f 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx @@ -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); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.jsx new file mode 100644 index 0000000000..77cc11ceb9 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.jsx @@ -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 ( + + {field?.value?.credential ? ( + helpers.setValue('')} + onEditPlugin={() => setShowPluginWizard(true)} + /> + ) : ( + + {React.cloneElement(children, { + ...field, + isRequired, + onChange: (_, event) => { + field.onChange(event); + }, + })} + + + + + )} + {showPluginWizard && ( + setShowPluginWizard(false)} + onSubmit={val => { + val.touched = true; + helpers.setValue(val); + setShowPluginWizard(false); + }} + /> + )} + + ); +} + +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); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.test.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.test.jsx new file mode 100644 index 0000000000..39f77b63b4 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.test.jsx @@ -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('', () => { + let wrapper; + describe('No plugin configured', () => { + beforeAll(() => { + wrapper = mountWithContexts( + + {() => ( + + + + )} + + ); + }); + 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( + + {() => ( + + + + )} + + ); + }); + 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); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.jsx new file mode 100644 index 0000000000..831291871e --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.jsx @@ -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: , + enableNext: !!selectedCredential.value, + }, + { + id: 2, + name: i18n._(t`Metadata`), + component: , + canJumpTo: !!selectedCredential.value, + nextButtonText: i18n._(t`OK`), + }, + ]; + + return ( + + ); +} + +function CredentialPluginPrompt({ i18n, onClose, onSubmit, initialValues }) { + return ( + + {({ handleSubmit }) => ( + + )} + + ); +} + +CredentialPluginPrompt.propTypes = { + onClose: func.isRequired, + onSubmit: func.isRequired, + initialValues: shape({}), +}; + +CredentialPluginPrompt.defaultProps = { + initialValues: {}, +}; + +export default withI18n()(CredentialPluginPrompt); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.test.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.test.jsx new file mode 100644 index 0000000000..2634301e07 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.test.jsx @@ -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('', () => { + describe('Plugin not configured', () => { + let wrapper; + const onClose = jest.fn(); + const onSubmit = jest.fn(); + beforeAll(async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + }); + 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( + + ); + }); + }); + 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'); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx new file mode 100644 index 0000000000..a59f894470 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx @@ -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 ; + } + + return ( + selectedCredentialHelper.setValue(row)} + qsConfig={QS_CONFIG} + renderItem={credential => ( + selectedCredentialHelper.setValue(credential)} + onDeselect={() => selectedCredentialHelper.setValue(null)} + isRadio + /> + )} + renderToolbar={props => } + 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); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/MetadataStep.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/MetadataStep.jsx new file mode 100644 index 0000000000..c612fc4fdb --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/MetadataStep.jsx @@ -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 ; + } + + if (error) { + return ; + } + + return ( + <> + {fields.length > 0 && ( +
+ + {fields.map(field => { + if (field.type === 'string') { + if (field.choices) { + return ( + + {field.help_text && ( + + + + )} + { + return { + value: choice, + key: choice, + label: choice, + }; + })} + onChange={(event, value) => { + form.setFieldValue(`inputs.${field.id}`, value); + }} + validate={field.required ? required(null, i18n) : null} + /> + + ); + } + + return ( + + ); + } + + return null; + })} + +
+ )} + + testMetadata()} + > + {i18n._(t`Test`)} + + + + ); +} + +export default withI18n()(MetadataStep); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/index.js b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/index.js new file mode 100644 index 0000000000..467b3f3936 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/index.js @@ -0,0 +1,3 @@ +export { default as CredentialPluginPrompt } from './CredentialPluginPrompt'; +export { default as CredentialsStep } from './CredentialsStep'; +export { default as MetadataStep } from './MetadataStep'; diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.jsx new file mode 100644 index 0000000000..d97a7a39df --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.jsx @@ -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 ( + <> + + + + + + + + + This field will be retrieved from an external secret management system + using the specified credential. + + + + ); +} + +CredentialPluginSelected.propTypes = { + credential: Credential.isRequired, + onEditPlugin: func, + onClearPlugin: func, +}; + +CredentialPluginSelected.defaultProps = { + onEditPlugin: () => {}, + onClearPlugin: () => {}, +}; + +export default withI18n()(CredentialPluginSelected); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.test.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.test.jsx new file mode 100644 index 0000000000..ce69724904 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.test.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import selectedCredential from '../data.cyberArkCredential.json'; +import CredentialPluginSelected from './CredentialPluginSelected'; + +describe('', () => { + let wrapper; + const onClearPlugin = jest.fn(); + const onEditPlugin = jest.fn(); + beforeAll(() => { + wrapper = mountWithContexts( + + ); + }); + 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); + }); +}); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/index.js b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/index.js new file mode 100644 index 0000000000..033586567f --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/index.js @@ -0,0 +1,2 @@ +export { default as CredentialPluginSelected } from './CredentialPluginSelected'; +export { default as CredentialPluginField } from './CredentialPluginField'; diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx index 89584b956c..2622106afb 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx @@ -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 }) => { }} />
- - + + + + > + + - + > +