diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f4300311..6394b4f6d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ 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/`. +# 17.0.0 (January 22, 2021) +- AWX now requires PostgreSQL 12 by default: https://github.com/ansible/awx/pull/8943 + **Note:** users who encounter permissions errors at upgrade time should `chown -R ~/.awx/pgdocker` to ensure it's owned by the user running the install playbook +- Added support for region name for OpenStack inventory: https://github.com/ansible/awx/issues/5080 +- Added the ability to chain undefined attributes in custom notification templates: https://github.com/ansible/awx/issues/8677 +- Dramatically simplified the `image_build` role: https://github.com/ansible/awx/pull/8980 +- Fixed a bug which can cause schema migrations to fail at install time: https://github.com/ansible/awx/issues/9077 +- Fixed a bug which caused the `is_superuser` user property to be out of date in certain circumstances: https://github.com/ansible/awx/pull/8833 +- Fixed a bug which sometimes results in race conditions on setting access: https://github.com/ansible/awx/pull/8580 +- Fixed a bug which sometimes causes an unexpected delay in stdout for some playbooks: https://github.com/ansible/awx/issues/9085 +- (UI) Added support for credential password prompting on job launch: https://github.com/ansible/awx/pull/9028 +- (UI) Added the ability to configure LDAP settings in the UI: https://github.com/ansible/awx/issues/8291 +- (UI) Added a sync button to the Project detail view: https://github.com/ansible/awx/issues/8847 +- (UI) Added a form for configuring Google Outh 2.0 settings: https://github.com/ansible/awx/pull/8762 +- (UI) Added searchable keys and related keys to the Credentials list: https://github.com/ansible/awx/issues/8603 +- (UI) Added support for advanced search and copying to Notification Templates: https://github.com/ansible/awx/issues/7879 +- (UI) Added support for prompting on workflow nodes: https://github.com/ansible/awx/issues/5913 +- (UI) Added support for session timeouts: https://github.com/ansible/awx/pull/8250 +- (UI) Fixed a bug that broke websocket streaming for the insecure ws:// protocol: https://github.com/ansible/awx/pull/8877 +- (UI) Fixed a bug in the user interface when a translation for the browser's preferred locale isn't available: https://github.com/ansible/awx/issues/8884 +- (UI) Fixed bug where navigating from one survey question form directly to another wasn't reloading the form: https://github.com/ansible/awx/issues/7522 +- (UI) Fixed a bug which can cause an uncaught error while launching a Job Template: https://github.com/ansible/awx/issues/8936 +- Updated autobahn to address CVE-2020-35678 + ## 16.0.0 (December 10, 2020) - AWX now ships with a reimagined user interface. **Please read this before upgrading:** https://groups.google.com/g/awx-project/c/KuT5Ao92HWo - Removed support for syncing inventory from Red Hat CloudForms - https://github.com/ansible/awx/commit/0b701b3b2 diff --git a/INSTALL.md b/INSTALL.md index 1ed8484e29..f7ab93a6e3 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -497,7 +497,7 @@ Before starting the install process, review the [inventory](./installer/inventor *docker_compose_dir* -> When using docker-compose, the `docker-compose.yml` file will be created there (default `/tmp/awxcompose`). +> When using docker-compose, the `docker-compose.yml` file will be created there (default `~/.awx/awxcompose`). *custom_venv_dir* diff --git a/VERSION b/VERSION index 946789e619..aac58983e6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -16.0.0 +17.0.0 diff --git a/awx/main/dispatch/worker/callback.py b/awx/main/dispatch/worker/callback.py index fd96a4f04e..e61a516094 100644 --- a/awx/main/dispatch/worker/callback.py +++ b/awx/main/dispatch/worker/callback.py @@ -38,6 +38,7 @@ class CallbackBrokerWorker(BaseWorker): MAX_RETRIES = 2 last_stats = time.time() + last_flush = time.time() total = 0 last_event = '' prof = None @@ -52,7 +53,7 @@ class CallbackBrokerWorker(BaseWorker): def read(self, queue): try: - res = self.redis.blpop(settings.CALLBACK_QUEUE, timeout=settings.JOB_EVENT_BUFFER_SECONDS) + res = self.redis.blpop(settings.CALLBACK_QUEUE, timeout=1) if res is None: return {'event': 'FLUSH'} self.total += 1 @@ -102,6 +103,7 @@ class CallbackBrokerWorker(BaseWorker): now = tz_now() if ( force or + (time.time() - self.last_flush) > settings.JOB_EVENT_BUFFER_SECONDS or any([len(events) >= 1000 for events in self.buff.values()]) ): for cls, events in self.buff.items(): @@ -124,6 +126,7 @@ class CallbackBrokerWorker(BaseWorker): for e in events: emit_event_detail(e) self.buff = {} + self.last_flush = time.time() def perform_work(self, body): try: diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 05c8a42f20..71881918a3 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -196,9 +196,9 @@ LOCAL_STDOUT_EXPIRE_TIME = 2592000 # events into the database JOB_EVENT_WORKERS = 4 -# The number of seconds (must be an integer) to buffer callback receiver bulk +# The number of seconds to buffer callback receiver bulk # writes in memory before flushing via JobEvent.objects.bulk_create() -JOB_EVENT_BUFFER_SECONDS = 1 +JOB_EVENT_BUFFER_SECONDS = .1 # The interval at which callback receiver statistics should be # recorded diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index f5f0c05330..cddf01e259 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -1,3 +1,4 @@ +import ActivityStream from './models/ActivityStream'; import AdHocCommands from './models/AdHocCommands'; import Applications from './models/Applications'; import Auth from './models/Auth'; @@ -39,6 +40,7 @@ import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes'; import WorkflowJobTemplates from './models/WorkflowJobTemplates'; import WorkflowJobs from './models/WorkflowJobs'; +const ActivityStreamAPI = new ActivityStream(); const AdHocCommandsAPI = new AdHocCommands(); const ApplicationsAPI = new Applications(); const AuthAPI = new Auth(); @@ -81,6 +83,7 @@ const WorkflowJobTemplatesAPI = new WorkflowJobTemplates(); const WorkflowJobsAPI = new WorkflowJobs(); export { + ActivityStreamAPI, AdHocCommandsAPI, ApplicationsAPI, AuthAPI, diff --git a/awx/ui_next/src/api/models/ActivityStream.js b/awx/ui_next/src/api/models/ActivityStream.js new file mode 100644 index 0000000000..99b65bc634 --- /dev/null +++ b/awx/ui_next/src/api/models/ActivityStream.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class ActivityStream extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/activity_stream/'; + } +} + +export default ActivityStream; diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 2f12953afa..e339142b52 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx @@ -1,4 +1,4 @@ -import React, { Fragment } from 'react'; +import React, { Fragment, useState } from 'react'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -17,95 +17,57 @@ const readTeams = async queryParams => TeamsAPI.read(queryParams); const readTeamsOptions = async () => TeamsAPI.readOptions(); -class AddResourceRole extends React.Component { - constructor(props) { - super(props); - - this.state = { - selectedResource: null, - selectedResourceRows: [], - selectedRoleRows: [], - currentStepId: 1, - maxEnabledStep: 1, - }; - - this.handleResourceCheckboxClick = this.handleResourceCheckboxClick.bind( - this - ); - this.handleResourceSelect = this.handleResourceSelect.bind(this); - this.handleRoleCheckboxClick = this.handleRoleCheckboxClick.bind(this); - this.handleWizardNext = this.handleWizardNext.bind(this); - this.handleWizardSave = this.handleWizardSave.bind(this); - this.handleWizardGoToStep = this.handleWizardGoToStep.bind(this); - } - - handleResourceCheckboxClick(user) { - const { selectedResourceRows, currentStepId } = this.state; +function AddResourceRole({ onSave, onClose, roles, i18n, resource }) { + const [selectedResource, setSelectedResource] = useState(null); + const [selectedResourceRows, setSelectedResourceRows] = useState([]); + const [selectedRoleRows, setSelectedRoleRows] = useState([]); + const [currentStepId, setCurrentStepId] = useState(1); + const [maxEnabledStep, setMaxEnabledStep] = useState(1); + const handleResourceCheckboxClick = user => { const selectedIndex = selectedResourceRows.findIndex( selectedRow => selectedRow.id === user.id ); - if (selectedIndex > -1) { selectedResourceRows.splice(selectedIndex, 1); - const stateToUpdate = { selectedResourceRows }; if (selectedResourceRows.length === 0) { - stateToUpdate.maxEnabledStep = currentStepId; + setMaxEnabledStep(currentStepId); } - this.setState(stateToUpdate); + setSelectedRoleRows(selectedResourceRows); } else { - this.setState(prevState => ({ - selectedResourceRows: [...prevState.selectedResourceRows, user], - })); + setSelectedResourceRows([...selectedResourceRows, user]); } - } - - handleRoleCheckboxClick(role) { - const { selectedRoleRows } = this.state; + }; + const handleRoleCheckboxClick = role => { const selectedIndex = selectedRoleRows.findIndex( selectedRow => selectedRow.id === role.id ); if (selectedIndex > -1) { selectedRoleRows.splice(selectedIndex, 1); - this.setState({ selectedRoleRows }); + setSelectedRoleRows(selectedRoleRows); } else { - this.setState(prevState => ({ - selectedRoleRows: [...prevState.selectedRoleRows, role], - })); + setSelectedRoleRows([...selectedRoleRows, role]); } - } + }; - handleResourceSelect(resourceType) { - this.setState({ - selectedResource: resourceType, - selectedResourceRows: [], - selectedRoleRows: [], - }); - } + const handleResourceSelect = resourceType => { + setSelectedResource(resourceType); + setSelectedResourceRows([]); + setSelectedRoleRows([]); + }; - handleWizardNext(step) { - this.setState({ - currentStepId: step.id, - maxEnabledStep: step.id, - }); - } + const handleWizardNext = step => { + setCurrentStepId(step.id); + setMaxEnabledStep(step.id); + }; - handleWizardGoToStep(step) { - this.setState({ - currentStepId: step.id, - }); - } - - async handleWizardSave() { - const { onSave } = this.props; - const { - selectedResourceRows, - selectedRoleRows, - selectedResource, - } = this.state; + const handleWizardGoToStep = step => { + setCurrentStepId(step.id); + }; + const handleWizardSave = async () => { try { const roleRequests = []; @@ -134,201 +96,186 @@ class AddResourceRole extends React.Component { } catch (err) { // TODO: handle this error } + }; + + // Object roles can be user only, so we remove them when + // showing role choices for team access + const selectableRoles = { ...roles }; + if (selectedResource === 'teams') { + Object.keys(roles).forEach(key => { + if (selectableRoles[key].user_only) { + delete selectableRoles[key]; + } + }); } - render() { - const { - selectedResource, - selectedResourceRows, - selectedRoleRows, - currentStepId, - maxEnabledStep, - } = this.state; - const { onClose, roles, i18n, resource } = this.props; + const userSearchColumns = [ + { + name: i18n._(t`Username`), + key: 'username__icontains', + isDefault: true, + }, + { + name: i18n._(t`First Name`), + key: 'first_name__icontains', + }, + { + name: i18n._(t`Last Name`), + key: 'last_name__icontains', + }, + ]; + const userSortColumns = [ + { + name: i18n._(t`Username`), + key: 'username', + }, + { + name: i18n._(t`First Name`), + key: 'first_name', + }, + { + name: i18n._(t`Last Name`), + key: 'last_name', + }, + ]; + const teamSearchColumns = [ + { + 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', + }, + ]; - // Object roles can be user only, so we remove them when - // showing role choices for team access - const selectableRoles = { ...roles }; - if (selectedResource === 'teams') { - Object.keys(roles).forEach(key => { - if (selectableRoles[key].user_only) { - delete selectableRoles[key]; - } - }); - } + const teamSortColumns = [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ]; - const userSearchColumns = [ - { - name: i18n._(t`Username`), - key: 'username__icontains', - isDefault: true, - }, - { - name: i18n._(t`First Name`), - key: 'first_name__icontains', - }, - { - name: i18n._(t`Last Name`), - key: 'last_name__icontains', - }, - ]; + let wizardTitle = ''; - const userSortColumns = [ - { - name: i18n._(t`Username`), - key: 'username', - }, - { - name: i18n._(t`First Name`), - key: 'first_name', - }, - { - name: i18n._(t`Last Name`), - key: 'last_name', - }, - ]; + switch (selectedResource) { + case 'users': + wizardTitle = i18n._(t`Add User Roles`); + break; + case 'teams': + wizardTitle = i18n._(t`Add Team Roles`); + break; + default: + wizardTitle = i18n._(t`Add Roles`); + } - const teamSearchColumns = [ - { - 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', - }, - ]; - - const teamSortColumns = [ - { - name: i18n._(t`Name`), - key: 'name', - }, - ]; - - let wizardTitle = ''; - - switch (selectedResource) { - case 'users': - wizardTitle = i18n._(t`Add User Roles`); - break; - case 'teams': - wizardTitle = i18n._(t`Add Team Roles`); - break; - default: - wizardTitle = i18n._(t`Add Roles`); - } - - const steps = [ - { - id: 1, - name: i18n._(t`Select a Resource Type`), - component: ( -
-
- {i18n._( - t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.` - )} -
- - this.handleResourceSelect('users')} - /> - {resource?.type === 'credential' && - !resource?.organization ? null : ( - this.handleResourceSelect('teams')} - /> + const steps = [ + { + id: 1, + name: i18n._(t`Select a Resource Type`), + component: ( +
+
+ {i18n._( + t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.` )}
- ), - enableNext: selectedResource !== null, - }, - { - id: 2, - name: i18n._(t`Select Items from List`), - component: ( - - {selectedResource === 'users' && ( - - )} - {selectedResource === 'teams' && ( - - )} - - ), - enableNext: selectedResourceRows.length > 0, - canJumpTo: maxEnabledStep >= 2, - }, - { - id: 3, - name: i18n._(t`Select Roles to Apply`), - component: ( - handleResourceSelect('users')} /> - ), - nextButtonText: i18n._(t`Save`), - enableNext: selectedRoleRows.length > 0, - canJumpTo: maxEnabledStep >= 3, - }, - ]; + {resource?.type === 'credential' && !resource?.organization ? null : ( + handleResourceSelect('teams')} + /> + )} +
+ ), + enableNext: selectedResource !== null, + }, + { + id: 2, + name: i18n._(t`Select Items from List`), + component: ( + + {selectedResource === 'users' && ( + + )} + {selectedResource === 'teams' && ( + + )} + + ), + enableNext: selectedResourceRows.length > 0, + canJumpTo: maxEnabledStep >= 2, + }, + { + id: 3, + name: i18n._(t`Select Roles to Apply`), + component: ( + + ), + nextButtonText: i18n._(t`Save`), + enableNext: selectedRoleRows.length > 0, + canJumpTo: maxEnabledStep >= 3, + }, + ]; - const currentStep = steps.find(step => step.id === currentStepId); + const currentStep = steps.find(step => step.id === currentStepId); - // TODO: somehow internationalize steps and currentStep.nextButtonText - return ( - - ); - } + // TODO: somehow internationalize steps and currentStep.nextButtonText + return ( + handleWizardGoToStep(step)} + steps={steps} + title={wizardTitle} + nextButtonText={currentStep.nextButtonText || undefined} + backButtonText={i18n._(t`Back`)} + cancelButtonText={i18n._(t`Cancel`)} + /> + ); } AddResourceRole.propTypes = { diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx index a681999391..264f7cdb28 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx @@ -1,22 +1,46 @@ /* eslint-disable react/jsx-pascal-case */ import React from 'react'; import { shallow } from 'enzyme'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; + +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; import AddResourceRole, { _AddResourceRole } from './AddResourceRole'; import { TeamsAPI, UsersAPI } from '../../api'; -jest.mock('../../api'); +jest.mock('../../api/models/Teams'); +jest.mock('../../api/models/Users'); + +// TODO: Once error handling is functional in +// this component write tests for it describe('<_AddResourceRole />', () => { UsersAPI.read.mockResolvedValue({ data: { count: 2, results: [ - { id: 1, username: 'foo' }, - { id: 2, username: 'bar' }, + { id: 1, username: 'foo', url: '' }, + { id: 2, username: 'bar', url: '' }, ], }, }); + UsersAPI.readOptions.mockResolvedValue({ + data: { related: {}, actions: { GET: {} } }, + }); + TeamsAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { id: 1, name: 'Team foo', url: '' }, + { id: 2, name: 'Team bar', url: '' }, + ], + }, + }); + TeamsAPI.readOptions.mockResolvedValue({ + data: { related: {}, actions: { GET: {} } }, + }); const roles = { admin_role: { description: 'Can manage all aspects of the organization', @@ -39,191 +63,165 @@ describe('<_AddResourceRole />', () => { /> ); }); - test('handleRoleCheckboxClick properly updates state', () => { - const wrapper = shallow( - <_AddResourceRole - onClose={() => {}} - onSave={() => {}} - roles={roles} - i18n={{ _: val => val.toString() }} - /> - ); - wrapper.setState({ - selectedRoleRows: [ - { - description: 'Can manage all aspects of the organization', - name: 'Admin', - id: 1, - }, - ], + test('should save properly', async () => { + let wrapper; + act(() => { + wrapper = mountWithContexts( + {}} onSave={() => {}} roles={roles} />, + { context: { network: { handleHttpError: () => {} } } } + ); }); - wrapper.instance().handleRoleCheckboxClick({ - description: 'Can manage all aspects of the organization', - name: 'Admin', - id: 1, - }); - expect(wrapper.state('selectedRoleRows')).toEqual([]); - wrapper.instance().handleRoleCheckboxClick({ - description: 'Can manage all aspects of the organization', - name: 'Admin', - id: 1, - }); - expect(wrapper.state('selectedRoleRows')).toEqual([ - { - description: 'Can manage all aspects of the organization', - name: 'Admin', - id: 1, - }, - ]); - }); - test('handleResourceCheckboxClick properly updates state', () => { - const wrapper = shallow( - <_AddResourceRole - onClose={() => {}} - onSave={() => {}} - roles={roles} - i18n={{ _: val => val.toString() }} - /> - ); - wrapper.setState({ - selectedResourceRows: [ - { - id: 1, - username: 'foobar', - }, - ], - }); - wrapper.instance().handleResourceCheckboxClick({ - id: 1, - username: 'foobar', - }); - expect(wrapper.state('selectedResourceRows')).toEqual([]); - wrapper.instance().handleResourceCheckboxClick({ - id: 1, - username: 'foobar', - }); - expect(wrapper.state('selectedResourceRows')).toEqual([ - { - id: 1, - username: 'foobar', - }, - ]); - }); - test('clicking user/team cards updates state', () => { - const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect'); - const wrapper = mountWithContexts( - {}} onSave={() => {}} roles={roles} />, - { context: { network: { handleHttpError: () => {} } } } - ).find('AddResourceRole'); + wrapper.update(); + + // Step 1 const selectableCardWrapper = wrapper.find('SelectableCard'); expect(selectableCardWrapper.length).toBe(2); - selectableCardWrapper.first().simulate('click'); - expect(spy).toHaveBeenCalledWith('users'); - expect(wrapper.state('selectedResource')).toBe('users'); - selectableCardWrapper.at(1).simulate('click'); - expect(spy).toHaveBeenCalledWith('teams'); - expect(wrapper.state('selectedResource')).toBe('teams'); - }); - test('handleResourceSelect clears out selected lists and sets selectedResource', () => { - const wrapper = shallow( - <_AddResourceRole - onClose={() => {}} - onSave={() => {}} - roles={roles} - i18n={{ _: val => val.toString() }} - /> + act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')()); + wrapper.update(); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() ); - wrapper.setState({ - selectedResource: 'teams', - selectedResourceRows: [ - { - id: 1, - username: 'foobar', - }, - ], - selectedRoleRows: [ - { - description: 'Can manage all aspects of the organization', - id: 1, - name: 'Admin', - }, - ], - }); - wrapper.instance().handleResourceSelect('users'); - expect(wrapper.state()).toEqual({ - selectedResource: 'users', - selectedResourceRows: [], - selectedRoleRows: [], - currentStepId: 1, - maxEnabledStep: 1, - }); - wrapper.instance().handleResourceSelect('teams'); - expect(wrapper.state()).toEqual({ - selectedResource: 'teams', - selectedResourceRows: [], - selectedRoleRows: [], - currentStepId: 1, - maxEnabledStep: 1, - }); + wrapper.update(); + + // Step 2 + await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + act(() => + wrapper.find('DataListCheck[name="foo"]').invoke('onChange')(true) + ); + wrapper.update(); + expect(wrapper.find('DataListCheck[name="foo"]').prop('checked')).toBe( + true + ); + act(() => wrapper.find('Button[type="submit"]').prop('onClick')()); + wrapper.update(); + + // Step 3 + act(() => + wrapper.find('Checkbox[aria-label="Admin"]').invoke('onChange')(true) + ); + wrapper.update(); + expect(wrapper.find('Checkbox[aria-label="Admin"]').prop('isChecked')).toBe( + true + ); + + // Save + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + expect(UsersAPI.associateRole).toBeCalledWith(1, 1); }); - test('handleWizardSave makes correct api calls, calls onSave when done', async () => { - const handleSave = jest.fn(); - const wrapper = mountWithContexts( - {}} onSave={handleSave} roles={roles} />, - { context: { network: { handleHttpError: () => {} } } } - ).find('AddResourceRole'); - wrapper.setState({ - selectedResource: 'users', - selectedResourceRows: [ - { - id: 1, - username: 'foobar', - }, - ], - selectedRoleRows: [ - { - description: 'Can manage all aspects of the organization', - id: 1, - name: 'Admin', - }, - { - description: 'May run any executable resources in the organization', - id: 2, - name: 'Execute', - }, - ], + + test('should successfuly click user/team cards', async () => { + let wrapper; + act(() => { + wrapper = mountWithContexts( + {}} onSave={() => {}} roles={roles} />, + { context: { network: { handleHttpError: () => {} } } } + ); }); - await wrapper.instance().handleWizardSave(); - expect(UsersAPI.associateRole).toHaveBeenCalledTimes(2); - expect(handleSave).toHaveBeenCalled(); - wrapper.setState({ - selectedResource: 'teams', - selectedResourceRows: [ - { - id: 1, - name: 'foobar', - }, - ], - selectedRoleRows: [ - { - description: 'Can manage all aspects of the organization', - id: 1, - name: 'Admin', - }, - { - description: 'May run any executable resources in the organization', - id: 2, - name: 'Execute', - }, - ], + wrapper.update(); + + const selectableCardWrapper = wrapper.find('SelectableCard'); + expect(selectableCardWrapper.length).toBe(2); + act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')()); + wrapper.update(); + + await waitForElement( + wrapper, + 'SelectableCard[label="Users"]', + el => el.prop('isSelected') === true + ); + act(() => wrapper.find('SelectableCard[label="Teams"]').prop('onClick')()); + wrapper.update(); + + await waitForElement( + wrapper, + 'SelectableCard[label="Teams"]', + el => el.prop('isSelected') === true + ); + }); + + test('should reset values with resource type changes', async () => { + let wrapper; + act(() => { + wrapper = mountWithContexts( + {}} onSave={() => {}} roles={roles} />, + { context: { network: { handleHttpError: () => {} } } } + ); }); - await wrapper.instance().handleWizardSave(); - expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2); - expect(handleSave).toHaveBeenCalled(); + wrapper.update(); + + // Step 1 + const selectableCardWrapper = wrapper.find('SelectableCard'); + expect(selectableCardWrapper.length).toBe(2); + act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')()); + wrapper.update(); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + wrapper.update(); + + // Step 2 + await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + act(() => + wrapper.find('DataListCheck[name="foo"]').invoke('onChange')(true) + ); + wrapper.update(); + expect(wrapper.find('DataListCheck[name="foo"]').prop('checked')).toBe( + true + ); + act(() => wrapper.find('Button[type="submit"]').prop('onClick')()); + wrapper.update(); + + // Step 3 + act(() => + wrapper.find('Checkbox[aria-label="Admin"]').invoke('onChange')(true) + ); + wrapper.update(); + expect(wrapper.find('Checkbox[aria-label="Admin"]').prop('isChecked')).toBe( + true + ); + + // Go back to step 1 + act(() => { + wrapper + .find('WizardNavItem[content="Select a Resource Type"]') + .find('button') + .prop('onClick')({ id: 1 }); + }); + wrapper.update(); + expect( + wrapper + .find('WizardNavItem[content="Select a Resource Type"]') + .prop('isCurrent') + ).toBe(true); + + // Go back to step 1 and this time select teams. Doing so should clear following steps + act(() => wrapper.find('SelectableCard[label="Teams"]').prop('onClick')()); + wrapper.update(); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + wrapper.update(); + + // Make sure no teams have been selected + await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + wrapper + .find('DataListCheck') + .map(item => expect(item.prop('checked')).toBe(false)); + act(() => wrapper.find('Button[type="submit"]').prop('onClick')()); + wrapper.update(); + + // Make sure that no roles have been selected + wrapper + .find('Checkbox') + .map(card => expect(card.prop('isChecked')).toBe(false)); + + // Make sure the save button is disabled + expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); }); test('should not display team as a choice in case credential does not have organization', () => { - const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect'); const wrapper = mountWithContexts( {}} @@ -232,11 +230,13 @@ describe('<_AddResourceRole />', () => { resource={{ type: 'credential', organization: null }} />, { context: { network: { handleHttpError: () => {} } } } - ).find('AddResourceRole'); - const selectableCardWrapper = wrapper.find('SelectableCard'); - expect(selectableCardWrapper.length).toBe(1); - selectableCardWrapper.first().simulate('click'); - expect(spy).toHaveBeenCalledWith('users'); - expect(wrapper.state('selectedResource')).toBe('users'); + ); + + expect(wrapper.find('SelectableCard').length).toBe(1); + wrapper.find('SelectableCard[label="Users"]').simulate('click'); + wrapper.update(); + expect( + wrapper.find('SelectableCard[label="Users"]').prop('isSelected') + ).toBe(true); }); }); diff --git a/awx/ui_next/src/components/AddRole/SelectRoleStep.jsx b/awx/ui_next/src/components/AddRole/SelectRoleStep.jsx index 826c2d52aa..32f0e6a96c 100644 --- a/awx/ui_next/src/components/AddRole/SelectRoleStep.jsx +++ b/awx/ui_next/src/components/AddRole/SelectRoleStep.jsx @@ -7,59 +7,55 @@ import { t } from '@lingui/macro'; import CheckboxCard from './CheckboxCard'; import SelectedList from '../SelectedList'; -class RolesStep extends React.Component { - render() { - const { - onRolesClick, - roles, - selectedListKey, - selectedListLabel, - selectedResourceRows, - selectedRoleRows, - i18n, - } = this.props; - - return ( - -
- {i18n._( - t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.` - )} -
-
- {selectedResourceRows.length > 0 && ( - - )} -
-
- {Object.keys(roles).map(role => ( - item.id === roles[role].id - )} - key={roles[role].id} - name={roles[role].name} - onSelect={() => onRolesClick(roles[role])} - /> - ))} -
-
- ); - } +function RolesStep({ + onRolesClick, + roles, + selectedListKey, + selectedListLabel, + selectedResourceRows, + selectedRoleRows, + i18n, +}) { + return ( + +
+ {i18n._( + t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.` + )} +
+
+ {selectedResourceRows.length > 0 && ( + + )} +
+
+ {Object.keys(roles).map(role => ( + item.id === roles[role].id + )} + key={roles[role].id} + name={roles[role].name} + onSelect={() => onRolesClick(roles[role])} + /> + ))} +
+
+ ); } RolesStep.propTypes = { diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx index 4f49268c0a..62b8983eb4 100644 --- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx +++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx @@ -12,52 +12,44 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { FormSelect, FormSelectOption } from '@patternfly/react-core'; -class AnsibleSelect extends React.Component { - constructor(props) { - super(props); - this.onSelectChange = this.onSelectChange.bind(this); - } - - onSelectChange(val, event) { - const { onChange, name } = this.props; +function AnsibleSelect({ + id, + data, + i18n, + isValid, + onBlur, + value, + className, + isDisabled, + onChange, + name, +}) { + const onSelectChange = (val, event) => { event.target.name = name; onChange(event, val); - } + }; - render() { - const { - id, - data, - i18n, - isValid, - onBlur, - value, - className, - isDisabled, - } = this.props; - - return ( - - {data.map(option => ( - - ))} - - ); - } + return ( + + {data.map(option => ( + + ))} + + ); } const Option = shape({ diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx index ced058754a..bb671c8823 100644 --- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx +++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; -import AnsibleSelect, { _AnsibleSelect } from './AnsibleSelect'; +import AnsibleSelect from './AnsibleSelect'; const mockData = [ { @@ -16,6 +16,7 @@ const mockData = [ ]; describe('', () => { + const onChange = jest.fn(); test('initially renders succesfully', async () => { mountWithContexts( ', () => { }); test('calls "onSelectChange" on dropdown select change', () => { - const spy = jest.spyOn(_AnsibleSelect.prototype, 'onSelectChange'); const wrapper = mountWithContexts( {}} + onChange={onChange} data={mockData} /> ); - expect(spy).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); wrapper.find('select').simulate('change'); - expect(spy).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalled(); }); test('Returns correct select options', () => { diff --git a/awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.jsx b/awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.jsx deleted file mode 100644 index 93a9b3d7f4..0000000000 --- a/awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { - PageSection as PFPageSection, - PageSectionVariants, - Breadcrumb, - BreadcrumbItem, - BreadcrumbHeading, -} from '@patternfly/react-core'; -import { Link, Route, useRouteMatch } from 'react-router-dom'; - -import styled from 'styled-components'; - -const PageSection = styled(PFPageSection)` - padding-top: 10px; - padding-bottom: 10px; -`; - -const Breadcrumbs = ({ breadcrumbConfig }) => { - const { light } = PageSectionVariants; - - return ( - - - - - - - - ); -}; - -const Crumb = ({ breadcrumbConfig, showDivider }) => { - const match = useRouteMatch(); - const crumb = breadcrumbConfig[match.url]; - - let crumbElement = ( - - {crumb} - - ); - - if (match.isExact) { - crumbElement = ( - - {crumb} - - ); - } - - if (!crumb) { - crumbElement = null; - } - - return ( - - {crumbElement} - - - - - ); -}; - -Breadcrumbs.propTypes = { - breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired, -}; - -Crumb.propTypes = { - breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired, -}; - -export default Breadcrumbs; diff --git a/awx/ui_next/src/components/Breadcrumbs/index.js b/awx/ui_next/src/components/Breadcrumbs/index.js deleted file mode 100644 index 3ff68ca589..0000000000 --- a/awx/ui_next/src/components/Breadcrumbs/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './Breadcrumbs'; diff --git a/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx b/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx index 7ffce947d8..00a33b68c2 100644 --- a/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx +++ b/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx @@ -31,35 +31,31 @@ const ToolbarItem = styled(PFToolbarItem)` // TODO: Recommend renaming this component to avoid confusion // with ExpandingContainer -class ExpandCollapse extends React.Component { - render() { - const { isCompact, onCompact, onExpand, i18n } = this.props; - - return ( - - - - - - - - - ); - } +function ExpandCollapse({ isCompact, onCompact, onExpand, i18n }) { + return ( + + + + + + + + + ); } ExpandCollapse.propTypes = { diff --git a/awx/ui_next/src/components/FormField/PasswordInput.jsx b/awx/ui_next/src/components/FormField/PasswordInput.jsx index 8fa0977f48..1fd6979206 100644 --- a/awx/ui_next/src/components/FormField/PasswordInput.jsx +++ b/awx/ui_next/src/components/FormField/PasswordInput.jsx @@ -12,7 +12,15 @@ import { import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons'; function PasswordInput(props) { - const { id, name, validate, isRequired, isDisabled, i18n } = props; + const { + autocomplete, + id, + name, + validate, + isRequired, + isDisabled, + i18n, + } = props; const [inputType, setInputType] = useState('password'); const [field, meta] = useField({ name, validate }); @@ -38,6 +46,7 @@ function PasswordInput(props) { {}, isRequired: false, isDisabled: false, diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx index d38cc7f2c2..a832a929f8 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx @@ -25,6 +25,8 @@ function canLaunchWithoutPrompt(launchData) { !launchData.ask_limit_on_launch && !launchData.ask_scm_branch_on_launch && !launchData.survey_enabled && + (!launchData.passwords_needed_to_start || + launchData.passwords_needed_to_start.length === 0) && (!launchData.variables_needed_to_start || launchData.variables_needed_to_start.length === 0) ); @@ -100,17 +102,20 @@ class LaunchButton extends React.Component { async launchWithParams(params) { try { const { history, resource } = this.props; - const jobPromise = - resource.type === 'workflow_job_template' - ? WorkflowJobTemplatesAPI.launch(resource.id, params || {}) - : JobTemplatesAPI.launch(resource.id, params || {}); + let jobPromise; + + if (resource.type === 'job_template') { + jobPromise = JobTemplatesAPI.launch(resource.id, params || {}); + } else if (resource.type === 'workflow_job_template') { + jobPromise = WorkflowJobTemplatesAPI.launch(resource.id, params || {}); + } else if (resource.type === 'job') { + jobPromise = JobsAPI.relaunch(resource.id, params || {}); + } else if (resource.type === 'workflow_job') { + jobPromise = WorkflowJobsAPI.relaunch(resource.id, params || {}); + } const { data: job } = await jobPromise; - history.push( - `/${ - resource.type === 'workflow_job_template' ? 'jobs/workflow' : 'jobs' - }/${job.id}/output` - ); + history.push(`/jobs/${job.id}/output`); } catch (launchError) { this.setState({ launchError }); } @@ -127,20 +132,15 @@ class LaunchButton extends React.Component { readRelaunch = InventorySourcesAPI.readLaunchUpdate( resource.inventory_source ); - relaunch = InventorySourcesAPI.launchUpdate(resource.inventory_source); } else if (resource.type === 'project_update') { // We'll need to handle the scenario where the project no longer exists readRelaunch = ProjectsAPI.readLaunchUpdate(resource.project); - relaunch = ProjectsAPI.launchUpdate(resource.project); } else if (resource.type === 'workflow_job') { readRelaunch = WorkflowJobsAPI.readRelaunch(resource.id); - relaunch = WorkflowJobsAPI.relaunch(resource.id); } else if (resource.type === 'ad_hoc_command') { readRelaunch = AdHocCommandsAPI.readRelaunch(resource.id); - relaunch = AdHocCommandsAPI.relaunch(resource.id); } else if (resource.type === 'job') { readRelaunch = JobsAPI.readRelaunch(resource.id); - relaunch = JobsAPI.relaunch(resource.id); } try { @@ -149,11 +149,22 @@ class LaunchButton extends React.Component { !relaunchConfig.passwords_needed_to_start || relaunchConfig.passwords_needed_to_start.length === 0 ) { + if (resource.type === 'inventory_update') { + relaunch = InventorySourcesAPI.launchUpdate( + resource.inventory_source + ); + } else if (resource.type === 'project_update') { + relaunch = ProjectsAPI.launchUpdate(resource.project); + } else if (resource.type === 'workflow_job') { + relaunch = WorkflowJobsAPI.relaunch(resource.id); + } else if (resource.type === 'ad_hoc_command') { + relaunch = AdHocCommandsAPI.relaunch(resource.id); + } else if (resource.type === 'job') { + relaunch = JobsAPI.relaunch(resource.id); + } const { data: job } = await relaunch; history.push(`/jobs/${job.id}/output`); } else { - // TODO: restructure (async?) to send launch command after prompts - // TODO: does relaunch need different prompt treatment than launch? this.setState({ showLaunchPrompt: true, launchConfig: relaunchConfig, diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx index 10fbbd1bf4..c84c7fde4d 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx @@ -4,10 +4,16 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { sleep } from '../../../testUtils/testUtils'; import LaunchButton from './LaunchButton'; -import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api'; +import { + InventorySourcesAPI, + JobsAPI, + JobTemplatesAPI, + ProjectsAPI, + WorkflowJobsAPI, + WorkflowJobTemplatesAPI, +} from '../../api'; -jest.mock('../../api/models/WorkflowJobTemplates'); -jest.mock('../../api/models/JobTemplates'); +jest.mock('../../api'); describe('LaunchButton', () => { JobTemplatesAPI.readLaunch.mockResolvedValue({ @@ -22,10 +28,14 @@ describe('LaunchButton', () => { }, }); - const children = ({ handleLaunch }) => ( + const launchButton = ({ handleLaunch }) => ( + + {searchColumns.map(({ name, key }) => ( + + {chips[key]?.chips?.map(chip => ( + + {chip.node} + + ))} + + ))} + + + ); + return ( } > - - - - {searchColumns.map(({ name, key }) => ( - - {chips[key]?.chips?.map(chip => ( - - {chip.node} - - ))} - - ))} - - + {renderLookup()} + + ) : ( + renderLookup() + )} { - const encodedParams = encodeNonDefaultQueryString(qsConfig, params); + const nonNamespacedParams = parseQueryString({}, history.location.search); + const encodedParams = encodeNonDefaultQueryString( + qsConfig, + params, + nonNamespacedParams + ); history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname); }; diff --git a/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx b/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx index 259cc39bac..14c5c0b8ee 100644 --- a/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx +++ b/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx @@ -23,7 +23,12 @@ export default function HeaderRow({ qsConfig, children }) { order_by: order === 'asc' ? key : `-${key}`, page: null, }); - const encodedParams = encodeNonDefaultQueryString(qsConfig, newParams); + const nonNamespacedParams = parseQueryString({}, history.location.search); + const encodedParams = encodeNonDefaultQueryString( + qsConfig, + newParams, + nonNamespacedParams + ); history.push( encodedParams ? `${location.pathname}?${encodedParams}` diff --git a/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx b/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx index 9892df34fe..42bf01a638 100644 --- a/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx +++ b/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx @@ -40,8 +40,13 @@ function PaginatedTable({ const history = useHistory(); const pushHistoryState = params => { - const { pathname } = history.location; - const encodedParams = encodeNonDefaultQueryString(qsConfig, params); + const { pathname, search } = history.location; + const nonNamespacedParams = parseQueryString({}, search); + const encodedParams = encodeNonDefaultQueryString( + qsConfig, + params, + nonNamespacedParams + ); history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname); }; diff --git a/awx/ui_next/src/components/ResourceAccessList/DeleteRoleConfirmationModal.jsx b/awx/ui_next/src/components/ResourceAccessList/DeleteRoleConfirmationModal.jsx index 8ac2f79bc4..407ebdb499 100644 --- a/awx/ui_next/src/components/ResourceAccessList/DeleteRoleConfirmationModal.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/DeleteRoleConfirmationModal.jsx @@ -7,69 +7,71 @@ import { t } from '@lingui/macro'; import AlertModal from '../AlertModal'; import { Role } from '../../types'; -class DeleteRoleConfirmationModal extends React.Component { - static propTypes = { - role: Role.isRequired, - username: string, - onCancel: func.isRequired, - onConfirm: func.isRequired, - }; - - static defaultProps = { - username: '', - }; - - isTeamRole() { - const { role } = this.props; +function DeleteRoleConfirmationModal({ + role, + username, + onCancel, + onConfirm, + i18n, +}) { + const isTeamRole = () => { return typeof role.team_id !== 'undefined'; - } + }; - render() { - const { role, username, onCancel, onConfirm, i18n } = this.props; - const title = i18n._( - t`Remove ${this.isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access` - ); - return ( - - {i18n._(t`Delete`)} - , - , - ]} - > - {this.isTeamRole() ? ( - - {i18n._( - t`Are you sure you want to remove ${role.name} access from ${role.team_name}? Doing so affects all members of the team.` - )} -
-
- {i18n._( - t`If you only want to remove access for this particular user, please remove them from the team.` - )} -
- ) : ( - - {i18n._( - t`Are you sure you want to remove ${role.name} access from ${username}?` - )} - - )} -
- ); - } + const title = i18n._( + t`Remove ${isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access` + ); + return ( + + {i18n._(t`Delete`)} + , + , + ]} + > + {isTeamRole() ? ( + + {i18n._( + t`Are you sure you want to remove ${role.name} access from ${role.team_name}? Doing so affects all members of the team.` + )} +
+
+ {i18n._( + t`If you only want to remove access for this particular user, please remove them from the team.` + )} +
+ ) : ( + + {i18n._( + t`Are you sure you want to remove ${role.name} access from ${username}?` + )} + + )} +
+ ); } +DeleteRoleConfirmationModal.propTypes = { + role: Role.isRequired, + username: string, + onCancel: func.isRequired, + onConfirm: func.isRequired, +}; + +DeleteRoleConfirmationModal.defaultProps = { + username: '', +}; + export default withI18n()(DeleteRoleConfirmationModal); diff --git a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx index b5b1765d45..0f5f7c1c64 100644 --- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx @@ -144,6 +144,7 @@ function ResourceAccessList({ i18n, apiModel, resource }) { setDeletionRole(role); setShowDeleteModal(true); }} + i18n={i18n} /> )} /> diff --git a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx index 3fe656a3fb..d641e67e01 100644 --- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx @@ -24,19 +24,13 @@ const DataListItemCells = styled(PFDataListItemCells)` align-items: start; `; -class ResourceAccessListItem extends React.Component { - static propTypes = { +function ResourceAccessListItem({ accessRecord, onRoleDelete, i18n }) { + ResourceAccessListItem.propTypes = { accessRecord: AccessRecord.isRequired, onRoleDelete: func.isRequired, }; - constructor(props) { - super(props); - this.renderChip = this.renderChip.bind(this); - } - - getRoleLists() { - const { accessRecord } = this.props; + const getRoleLists = () => { const teamRoles = []; const userRoles = []; @@ -52,10 +46,9 @@ class ResourceAccessListItem extends React.Component { accessRecord.summary_fields.direct_access.map(sort); accessRecord.summary_fields.indirect_access.map(sort); return [teamRoles, userRoles]; - } + }; - renderChip(role) { - const { accessRecord, onRoleDelete } = this.props; + const renderChip = role => { return ( ); - } + }; - render() { - const { accessRecord, i18n } = this.props; - const [teamRoles, userRoles] = this.getRoleLists(); + const [teamRoles, userRoles] = getRoleLists(); - return ( - - - - {accessRecord.username && ( - - {accessRecord.id ? ( - - - {accessRecord.username} - - - ) : ( - + return ( + + + + {accessRecord.username && ( + + {accessRecord.id ? ( + + {accessRecord.username} - - )} - - )} - {accessRecord.first_name || accessRecord.last_name ? ( - - - - ) : null} - , - + + + ) : ( + + {accessRecord.username} + + )} + + )} + {accessRecord.first_name || accessRecord.last_name ? ( - {userRoles.length > 0 && ( - - {userRoles.map(this.renderChip)} - - } - /> - )} - {teamRoles.length > 0 && ( - - {teamRoles.map(this.renderChip)} - - } - /> - )} + - , - ]} - /> - - - ); - } + ) : null} + , + + + {userRoles.length > 0 && ( + + {userRoles.map(renderChip)} + + } + /> + )} + {teamRoles.length > 0 && ( + + {teamRoles.map(renderChip)} + + } + /> + )} + + , + ]} + /> + + + ); } export default withI18n()(ResourceAccessListItem); diff --git a/awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx new file mode 100644 index 0000000000..d856a430ee --- /dev/null +++ b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx @@ -0,0 +1,135 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Button, + PageSection, + PageSectionVariants, + Breadcrumb, + BreadcrumbItem, + Title, + Tooltip, +} from '@patternfly/react-core'; +import { HistoryIcon } from '@patternfly/react-icons'; +import { Link, Route, useRouteMatch } from 'react-router-dom'; + +const ScreenHeader = ({ breadcrumbConfig, i18n, streamType }) => { + const { light } = PageSectionVariants; + const oneCrumbMatch = useRouteMatch({ + path: Object.keys(breadcrumbConfig)[0], + strict: true, + }); + const isOnlyOneCrumb = oneCrumbMatch && oneCrumbMatch.isExact; + + return ( + +
+
+ {!isOnlyOneCrumb && ( + + + + + + )} +
+ + + +
+
+ {streamType !== 'none' && ( +
+ + + +
+ )} +
+
+ ); +}; + +const ActualTitle = ({ breadcrumbConfig }) => { + const match = useRouteMatch(); + const title = breadcrumbConfig[match.url]; + let titleElement; + + if (match.isExact) { + titleElement = ( + + {title} + + ); + } + + if (!title) { + titleElement = null; + } + + return ( + + {titleElement} + + + + + ); +}; + +const Crumb = ({ breadcrumbConfig, showDivider }) => { + const match = useRouteMatch(); + const crumb = breadcrumbConfig[match.url]; + + let crumbElement = ( + + {crumb} + + ); + + if (match.isExact) { + crumbElement = null; + } + + if (!crumb) { + crumbElement = null; + } + return ( + + {crumbElement} + + + + + ); +}; + +ScreenHeader.propTypes = { + breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired, +}; + +Crumb.propTypes = { + breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired, +}; + +export default withI18n()(ScreenHeader); diff --git a/awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.test.jsx b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.test.jsx similarity index 70% rename from awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.test.jsx rename to awx/ui_next/src/components/ScreenHeader/ScreenHeader.test.jsx index 83e5e8c3fe..64f3a92c53 100644 --- a/awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.test.jsx +++ b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.test.jsx @@ -1,9 +1,15 @@ import React from 'react'; -import { mount } from 'enzyme'; import { MemoryRouter } from 'react-router-dom'; -import Breadcrumbs from './Breadcrumbs'; -describe('', () => { +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; + +import ScreenHeader from './ScreenHeader'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + +describe('', () => { let breadcrumbWrapper; let breadcrumb; let breadcrumbItem; @@ -17,15 +23,15 @@ describe('', () => { }; const findChildren = () => { - breadcrumb = breadcrumbWrapper.find('Breadcrumb'); + breadcrumb = breadcrumbWrapper.find('ScreenHeader'); breadcrumbItem = breadcrumbWrapper.find('BreadcrumbItem'); - breadcrumbHeading = breadcrumbWrapper.find('BreadcrumbHeading'); + breadcrumbHeading = breadcrumbWrapper.find('Title'); }; test('initially renders succesfully', () => { - breadcrumbWrapper = mount( + breadcrumbWrapper = mountWithContexts( - + ); @@ -51,9 +57,9 @@ describe('', () => { ]; routes.forEach(([location, crumbLength]) => { - breadcrumbWrapper = mount( + breadcrumbWrapper = mountWithContexts( - + ); diff --git a/awx/ui_next/src/components/ScreenHeader/index.js b/awx/ui_next/src/components/ScreenHeader/index.js new file mode 100644 index 0000000000..7f5ab32733 --- /dev/null +++ b/awx/ui_next/src/components/ScreenHeader/index.js @@ -0,0 +1 @@ +export { default } from './ScreenHeader'; diff --git a/awx/ui_next/src/components/Sort/Sort.jsx b/awx/ui_next/src/components/Sort/Sort.jsx index c0a513cd48..ee8599b531 100644 --- a/awx/ui_next/src/components/Sort/Sort.jsx +++ b/awx/ui_next/src/components/Sort/Sort.jsx @@ -1,7 +1,7 @@ -import React, { Fragment } from 'react'; +import React, { Fragment, useState } from 'react'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; -import { withRouter } from 'react-router-dom'; +import { useLocation, withRouter } from 'react-router-dom'; import { t } from '@lingui/macro'; import { Button, @@ -31,140 +31,110 @@ const NoOptionDropdown = styled.div` border-bottom-color: var(--pf-global--BorderColor--200); `; -class Sort extends React.Component { - constructor(props) { - super(props); +function Sort({ columns, qsConfig, onSort, i18n }) { + const location = useLocation(); + const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false); - let sortKey; - let sortOrder; - let isNumeric; + let sortKey; + let sortOrder; + let isNumeric; - const { qsConfig, location } = this.props; - const queryParams = parseQueryString(qsConfig, location.search); - if (queryParams.order_by && queryParams.order_by.startsWith('-')) { - sortKey = queryParams.order_by.substr(1); - sortOrder = 'descending'; - } else if (queryParams.order_by) { - sortKey = queryParams.order_by; - sortOrder = 'ascending'; - } - - if (qsConfig.integerFields.find(field => field === sortKey)) { - isNumeric = true; - } else { - isNumeric = false; - } - - this.state = { - isSortDropdownOpen: false, - sortKey, - sortOrder, - isNumeric, - }; - - this.handleDropdownToggle = this.handleDropdownToggle.bind(this); - this.handleDropdownSelect = this.handleDropdownSelect.bind(this); - this.handleSort = this.handleSort.bind(this); + const queryParams = parseQueryString(qsConfig, location.search); + if (queryParams.order_by && queryParams.order_by.startsWith('-')) { + sortKey = queryParams.order_by.substr(1); + sortOrder = 'descending'; + } else if (queryParams.order_by) { + sortKey = queryParams.order_by; + sortOrder = 'ascending'; } - handleDropdownToggle(isSortDropdownOpen) { - this.setState({ isSortDropdownOpen }); + if (qsConfig.integerFields.find(field => field === sortKey)) { + isNumeric = true; + } else { + isNumeric = false; } - handleDropdownSelect({ target }) { - const { columns, onSort, qsConfig } = this.props; - const { sortOrder } = this.state; + const handleDropdownToggle = isOpen => { + setIsSortDropdownOpen(isOpen); + }; + + const handleDropdownSelect = ({ target }) => { const { innerText } = target; - const [{ key: sortKey }] = columns.filter(({ name }) => name === innerText); - - let isNumeric; - - if (qsConfig.integerFields.find(field => field === sortKey)) { + const [{ key }] = columns.filter(({ name }) => name === innerText); + sortKey = key; + if (qsConfig.integerFields.find(field => field === key)) { isNumeric = true; } else { isNumeric = false; } - this.setState({ isSortDropdownOpen: false, sortKey, isNumeric }); + setIsSortDropdownOpen(false); onSort(sortKey, sortOrder); - } + }; - handleSort() { - const { onSort } = this.props; - const { sortKey, sortOrder } = this.state; - const newSortOrder = sortOrder === 'ascending' ? 'descending' : 'ascending'; - this.setState({ sortOrder: newSortOrder }); - onSort(sortKey, newSortOrder); - } + const handleSort = () => { + onSort(sortKey, sortOrder === 'ascending' ? 'descending' : 'ascending'); + }; - render() { - const { up } = DropdownPosition; - const { columns, i18n } = this.props; - const { isSortDropdownOpen, sortKey, sortOrder, isNumeric } = this.state; + const { up } = DropdownPosition; - const defaultSortedColumn = columns.find(({ key }) => key === sortKey); + const defaultSortedColumn = columns.find(({ key }) => key === sortKey); - if (!defaultSortedColumn) { - throw new Error( - 'sortKey must match one of the column keys, check the sortColumns prop passed to ' - ); - } - - const sortedColumnName = defaultSortedColumn?.name; - - const sortDropdownItems = columns - .filter(({ key }) => key !== sortKey) - .map(({ key, name }) => ( - - {name} - - )); - - let SortIcon; - if (isNumeric) { - SortIcon = - sortOrder === 'ascending' - ? SortNumericDownIcon - : SortNumericDownAltIcon; - } else { - SortIcon = - sortOrder === 'ascending' ? SortAlphaDownIcon : SortAlphaDownAltIcon; - } - - return ( - - {sortedColumnName && ( - - {(sortDropdownItems.length > 0 && ( - - {sortedColumnName} - - } - dropdownItems={sortDropdownItems} - /> - )) || {sortedColumnName}} - - - )} - + if (!defaultSortedColumn) { + throw new Error( + 'sortKey must match one of the column keys, check the sortColumns prop passed to ' ); } + + const sortedColumnName = defaultSortedColumn?.name; + + const sortDropdownItems = columns + .filter(({ key }) => key !== sortKey) + .map(({ key, name }) => ( + + {name} + + )); + + let SortIcon; + if (isNumeric) { + SortIcon = + sortOrder === 'ascending' ? SortNumericDownIcon : SortNumericDownAltIcon; + } else { + SortIcon = + sortOrder === 'ascending' ? SortAlphaDownIcon : SortAlphaDownAltIcon; + } + return ( + + {sortedColumnName && ( + + {(sortDropdownItems.length > 0 && ( + + {sortedColumnName} + + } + dropdownItems={sortDropdownItems} + /> + )) || {sortedColumnName}} + + + + )} + + ); } Sort.propTypes = { diff --git a/awx/ui_next/src/components/Sort/Sort.test.jsx b/awx/ui_next/src/components/Sort/Sort.test.jsx index 1764cf0298..c6cf89ad0d 100644 --- a/awx/ui_next/src/components/Sort/Sort.test.jsx +++ b/awx/ui_next/src/components/Sort/Sort.test.jsx @@ -1,5 +1,10 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; + import Sort from './Sort'; describe('', () => { @@ -105,7 +110,7 @@ describe('', () => { expect(onSort).toHaveBeenCalledWith('foo', 'ascending'); }); - test('Changing dropdown correctly passes back new sort key', () => { + test('Changing dropdown correctly passes back new sort key', async () => { const qsConfig = { namespace: 'item', defaultParams: { page: 1, page_size: 5, order_by: 'foo' }, @@ -131,44 +136,18 @@ describe('', () => { const wrapper = mountWithContexts( - ).find('Sort'); - - wrapper.instance().handleDropdownSelect({ target: { innerText: 'Bar' } }); + ); + act(() => wrapper.find('Dropdown').invoke('onToggle')(true)); + wrapper.update(); + await waitForElement(wrapper, 'Dropdown', el => el.prop('isOpen') === true); + wrapper + .find('li') + .at(0) + .prop('onClick')({ target: { innerText: 'Bar' } }); + wrapper.update(); expect(onSort).toBeCalledWith('bar', 'ascending'); }); - test('Opening dropdown correctly updates state', () => { - const qsConfig = { - namespace: 'item', - defaultParams: { page: 1, page_size: 5, order_by: 'foo' }, - integerFields: ['page', 'page_size'], - }; - - const columns = [ - { - name: 'Foo', - key: 'foo', - }, - { - name: 'Bar', - key: 'bar', - }, - { - name: 'Bakery', - key: 'bakery', - }, - ]; - - const onSort = jest.fn(); - - const wrapper = mountWithContexts( - - ).find('Sort'); - expect(wrapper.state('isSortDropdownOpen')).toEqual(false); - wrapper.instance().handleDropdownToggle(true); - expect(wrapper.state('isSortDropdownOpen')).toEqual(true); - }); - test('It displays correct sort icon', () => { const forwardNumericIconSelector = 'SortNumericDownIcon'; const reverseNumericIconSelector = 'SortNumericDownAltIcon'; diff --git a/awx/ui_next/src/routeConfig.js b/awx/ui_next/src/routeConfig.js index 0b9e0591fa..a343a7d1e0 100644 --- a/awx/ui_next/src/routeConfig.js +++ b/awx/ui_next/src/routeConfig.js @@ -1,5 +1,6 @@ import { t } from '@lingui/macro'; +import ActivityStream from './screens/ActivityStream'; import Applications from './screens/Application'; import Credentials from './screens/Credential'; import CredentialTypes from './screens/CredentialType'; @@ -44,6 +45,11 @@ function getRouteConfig(i18n) { path: '/schedules', screen: Schedules, }, + { + title: i18n._(t`Activity Stream`), + path: '/activity_stream', + screen: ActivityStream, + }, { title: i18n._(t`Workflow Approvals`), path: '/workflow_approvals', diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx new file mode 100644 index 0000000000..84dd9a6066 --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx @@ -0,0 +1,269 @@ +import React, { Fragment, useState, useEffect, useCallback } from 'react'; +import { useLocation, useHistory } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Card, + PageSection, + PageSectionVariants, + SelectGroup, + Select, + SelectVariant, + SelectOption, + Title, +} from '@patternfly/react-core'; + +import DatalistToolbar from '../../components/DataListToolbar'; +import PaginatedTable, { + HeaderRow, + HeaderCell, +} from '../../components/PaginatedTable'; +import useRequest from '../../util/useRequest'; +import { + getQSConfig, + parseQueryString, + replaceParams, + encodeNonDefaultQueryString, +} from '../../util/qs'; +import { ActivityStreamAPI } from '../../api'; + +import ActivityStreamListItem from './ActivityStreamListItem'; + +function ActivityStream({ i18n }) { + const { light } = PageSectionVariants; + + const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false); + const location = useLocation(); + const history = useHistory(); + const urlParams = new URLSearchParams(location.search); + + const activityStreamType = urlParams.get('type') || 'all'; + + let typeParams = {}; + + if (activityStreamType !== 'all') { + typeParams = { + or__object1__in: activityStreamType, + or__object2__in: activityStreamType, + }; + } + + const QS_CONFIG = getQSConfig( + 'activity_stream', + { + page: 1, + page_size: 20, + order_by: '-timestamp', + }, + ['id', 'page', 'page_size'] + ); + + const { + result: { results, count, relatedSearchableKeys, searchableKeys }, + error: contentError, + isLoading, + request: fetchActivityStream, + } = useRequest( + useCallback( + async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const [response, actionsResponse] = await Promise.all([ + ActivityStreamAPI.read({ ...params, ...typeParams }), + ActivityStreamAPI.readOptions(), + ]); + return { + results: response.data.results, + count: response.data.count, + relatedSearchableKeys: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: Object.keys( + actionsResponse.data.actions?.GET || {} + ).filter(key => actionsResponse.data.actions?.GET[key].filterable), + }; + }, + [location] // eslint-disable-line react-hooks/exhaustive-deps + ), + { + results: [], + count: 0, + relatedSearchableKeys: [], + searchableKeys: [], + } + ); + useEffect(() => { + fetchActivityStream(); + }, [fetchActivityStream]); + + const pushHistoryState = urlParamsToAdd => { + let searchParams = parseQueryString(QS_CONFIG, location.search); + searchParams = replaceParams(searchParams, { page: 1 }); + const encodedParams = encodeNonDefaultQueryString(QS_CONFIG, searchParams, { + type: urlParamsToAdd.get('type'), + }); + history.push( + encodedParams + ? `${location.pathname}?${encodedParams}` + : location.pathname + ); + }; + + return ( + + + + {i18n._(t`Activity Stream`)} + + + + + + + + {i18n._(t`Time`)} + + {i18n._(t`Initiated by`)} + + {i18n._(t`Event`)} + {i18n._(t`Actions`)} + + } + renderToolbar={props => ( + + )} + renderRow={streamItem => ( + + )} + /> + + + + ); +} + +export default withI18n()(ActivityStream); diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStream.test.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStream.test.jsx new file mode 100644 index 0000000000..b5afeef3d6 --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStream.test.jsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; + +import ActivityStream from './ActivityStream'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + +describe('', () => { + let pageWrapper; + + beforeEach(() => { + pageWrapper = mountWithContexts(); + }); + + afterEach(() => { + pageWrapper.unmount(); + }); + + test('initially renders without crashing', () => { + expect(pageWrapper.length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx new file mode 100644 index 0000000000..d933e0c259 --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx @@ -0,0 +1,584 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; + +const buildAnchor = (obj, resource, activity) => { + let url; + let name; + // try/except pattern asserts that: + // if we encounter a case where a UI url can't or + // shouldn't be generated, just supply the name of the resource + try { + // catch-all case to avoid generating urls if a resource has been deleted + // if a resource still exists, it'll be serialized in the activity's summary_fields + if (!activity.summary_fields[resource]) { + throw new Error('The referenced resource no longer exists'); + } + switch (resource) { + case 'custom_inventory_script': + url = `/inventory_scripts/${obj.id}/`; + break; + case 'group': + if ( + activity.operation === 'create' || + activity.operation === 'delete' + ) { + // the API formats the changes.inventory field as str 'myInventoryName-PrimaryKey' + const [inventory_id] = activity.changes.inventory + .split('-') + .slice(-1); + url = `/inventories/inventory/${inventory_id}/groups/${activity.changes.id}/details/`; + } else { + url = `/inventories/inventory/${ + activity.summary_fields.inventory[0].id + }/groups/${activity.changes.id || + activity.changes.object1_pk}/details/`; + } + break; + case 'host': + url = `/hosts/${obj.id}/`; + break; + case 'job': + url = `/jobs/${obj.id}/`; + break; + case 'inventory': + url = + obj?.kind === 'smart' + ? `/inventories/smart_inventory/${obj.id}/` + : `/inventories/inventory/${obj.id}/`; + break; + case 'schedule': + // schedule urls depend on the resource they're associated with + if (activity.summary_fields.job_template) { + const jt_id = activity.summary_fields.job_template[0].id; + url = `/templates/job_template/${jt_id}/schedules/${obj.id}/`; + } else if (activity.summary_fields.workflow_job_template) { + const wfjt_id = activity.summary_fields.workflow_job_template[0].id; + url = `/templates/workflow_job_template/${wfjt_id}/schedules/${obj.id}/`; + } else if (activity.summary_fields.project) { + url = `/projects/${activity.summary_fields.project[0].id}/schedules/${obj.id}/`; + } else if (activity.summary_fields.system_job_template) { + url = null; + } else { + // urls for inventory sync schedules currently depend on having + // an inventory id and group id + throw new Error( + 'activity.summary_fields to build this url not implemented yet' + ); + } + break; + case 'setting': + url = `/settings/`; + break; + case 'notification_template': + url = `/notification_templates/${obj.id}/`; + break; + case 'role': + throw new Error( + 'role object management is not consolidated to a single UI view' + ); + case 'job_template': + url = `/templates/job_template/${obj.id}/`; + break; + case 'workflow_job_template': + url = `/templates/workflow_job_template/${obj.id}/`; + break; + case 'workflow_job_template_node': { + const { + id: wfjt_id, + name: wfjt_name, + } = activity.summary_fields.workflow_job_template[0]; + url = `/templates/workflow_job_template/${wfjt_id}/`; + name = wfjt_name; + break; + } + case 'workflow_job': + url = `/workflows/${obj.id}/`; + break; + case 'label': + url = null; + break; + case 'inventory_source': { + const inventoryId = (obj.inventory || '').split('-').reverse()[0]; + url = `/inventories/inventory/${inventoryId}/sources/${obj.id}/details/`; + break; + } + case 'o_auth2_application': + url = `/applications/${obj.id}/`; + break; + case 'workflow_approval': + url = `/jobs/workflow/${activity.summary_fields.workflow_job[0].id}/output/`; + name = `${activity.summary_fields.workflow_job[0].name} | ${activity.summary_fields.workflow_approval[0].name}`; + break; + case 'workflow_approval_template': + url = `/templates/workflow_job_template/${activity.summary_fields.workflow_job_template[0].id}/visualizer/`; + name = `${activity.summary_fields.workflow_job_template[0].name} | ${activity.summary_fields.workflow_approval_template[0].name}`; + break; + default: + url = `/${resource}s/${obj.id}/`; + } + + name = name || obj.name || obj.username; + + if (url) { + return {name}; + } + + return {name}; + } catch (err) { + return {obj.name || obj.username || ''}; + } +}; + +const getPastTense = item => { + return /e$/.test(item) ? `${item}d` : `${item}ed`; +}; + +const isGroupRelationship = item => { + return ( + item.object1 === 'group' && + item.object2 === 'group' && + item.summary_fields.group.length > 1 + ); +}; + +const buildLabeledLink = (label, link) => { + return ( + + {label} {link} + + ); +}; + +function ActivityStreamDescription({ i18n, activity }) { + const labeledLinks = []; + // Activity stream objects will outlive the resources they reference + // in that case, summary_fields will not be available - show generic error text instead + try { + switch (activity.object_association) { + // explicit role dis+associations + case 'role': { + let { object1, object2 } = activity; + + // if object1 winds up being the role's resource, we need to swap the objects + // in order to make the sentence make sense. + if (activity.object_type === object1) { + object1 = activity.object2; + object2 = activity.object1; + } + + // object1 field is resource targeted by the dis+association + // object2 field is the resource the role is inherited from + // summary_field.role[0] contains ref info about the role + switch (activity.operation) { + // expected outcome: "disassociated role_name from " + case 'disassociate': + if (isGroupRelationship(activity)) { + labeledLinks.push( + buildLabeledLink( + getPastTense(activity.operation), + buildAnchor( + activity.summary_fields.group[1], + object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `${activity.summary_fields.role[0].role_field} from`, + buildAnchor( + activity.summary_fields.group[0], + object1, + activity + ) + ) + ); + } else { + labeledLinks.push( + buildLabeledLink( + getPastTense(activity.operation), + buildAnchor( + activity.summary_fields[object2][0], + object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `${activity.summary_fields.role[0].role_field} from`, + buildAnchor( + activity.summary_fields[object1][0], + object1, + activity + ) + ) + ); + } + break; + // expected outcome: "associated role_name to " + case 'associate': + if (isGroupRelationship(activity)) { + labeledLinks.push( + buildLabeledLink( + getPastTense(activity.operation), + buildAnchor( + activity.summary_fields.group[1], + object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `${activity.summary_fields.role[0].role_field} to`, + buildAnchor( + activity.summary_fields.group[0], + object1, + activity + ) + ) + ); + } else { + labeledLinks.push( + buildLabeledLink( + getPastTense(activity.operation), + buildAnchor( + activity.summary_fields[object2][0], + object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `${activity.summary_fields.role[0].role_field} to`, + buildAnchor( + activity.summary_fields[object1][0], + object1, + activity + ) + ) + ); + } + break; + default: + break; + } + break; + // inherited role dis+associations (logic identical to case 'role') + } + case 'parents': + // object1 field is resource targeted by the dis+association + // object2 field is the resource the role is inherited from + // summary_field.role[0] contains ref info about the role + switch (activity.operation) { + // expected outcome: "disassociated role_name from " + case 'disassociate': + if (isGroupRelationship(activity)) { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object2}`, + buildAnchor( + activity.summary_fields.group[1], + activity.object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `from ${activity.object1}`, + buildAnchor( + activity.summary_fields.group[0], + activity.object1, + activity + ) + ) + ); + } else { + labeledLinks.push( + buildLabeledLink( + getPastTense(activity.operation), + buildAnchor( + activity.summary_fields[activity.object2][0], + activity.object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `${activity.summary_fields.role[0].role_field} from`, + buildAnchor( + activity.summary_fields[activity.object1][0], + activity.object1, + activity + ) + ) + ); + } + break; + // expected outcome: "associated role_name to " + case 'associate': + if (isGroupRelationship(activity)) { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object1}`, + buildAnchor( + activity.summary_fields.group[0], + activity.object1, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `to ${activity.object2}`, + buildAnchor( + activity.summary_fields.group[1], + activity.object2, + activity + ) + ) + ); + } else { + labeledLinks.push( + buildLabeledLink( + getPastTense(activity.operation), + buildAnchor( + activity.summary_fields[activity.object2][0], + activity.object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `${activity.summary_fields.role[0].role_field} to`, + buildAnchor( + activity.summary_fields[activity.object1][0], + activity.object1, + activity + ) + ) + ); + } + break; + default: + break; + } + break; + // CRUD operations / resource on resource dis+associations + default: + switch (activity.operation) { + // expected outcome: "disassociated from " + case 'disassociate': + if (isGroupRelationship(activity)) { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object2}`, + buildAnchor( + activity.summary_fields.group[1], + activity.object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `from ${activity.object1}`, + buildAnchor( + activity.summary_fields.group[0], + activity.object1, + activity + ) + ) + ); + } else if ( + activity.object1 === 'workflow_job_template_node' && + activity.object2 === 'workflow_job_template_node' + ) { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} two nodes on workflow`, + buildAnchor( + activity.summary_fields[activity.object1[0]], + activity.object1, + activity + ) + ) + ); + } else { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object2}`, + buildAnchor( + activity.summary_fields[activity.object2][0], + activity.object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `from ${activity.object1}`, + buildAnchor( + activity.summary_fields[activity.object1][0], + activity.object1, + activity + ) + ) + ); + } + break; + // expected outcome "associated to " + case 'associate': + // groups are the only resource that can be associated/disassociated into each other + if (isGroupRelationship(activity)) { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object1}`, + buildAnchor( + activity.summary_fields.group[0], + activity.object1, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `to ${activity.object2}`, + buildAnchor( + activity.summary_fields.group[1], + activity.object2, + activity + ) + ) + ); + } else if ( + activity.object1 === 'workflow_job_template_node' && + activity.object2 === 'workflow_job_template_node' + ) { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} two nodes on workflow`, + buildAnchor( + activity.summary_fields[activity.object1[0]], + activity.object1, + activity + ) + ) + ); + } else { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object1}`, + buildAnchor( + activity.summary_fields[activity.object1][0], + activity.object1, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `to ${activity.object2}`, + buildAnchor( + activity.summary_fields[activity.object2][0], + activity.object2, + activity + ) + ) + ); + } + break; + case 'delete': + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object1}`, + buildAnchor(activity.changes, activity.object1, activity) + ) + ); + break; + // expected outcome: "operation " + case 'update': + if ( + activity.object1 === 'workflow_approval' && + activity?.changes?.status?.length === 2 + ) { + let operationText = ''; + if (activity.changes.status[1] === 'successful') { + operationText = i18n._(t`approved`); + } else if (activity.changes.status[1] === 'failed') { + if ( + activity.changes.timed_out && + activity.changes.timed_out[1] === true + ) { + operationText = i18n._(t`timed out`); + } else { + operationText = i18n._(t`denied`); + } + } else { + operationText = i18n._(t`updated`); + } + labeledLinks.push( + buildLabeledLink( + `${operationText} ${activity.object1}`, + buildAnchor( + activity.summary_fields[activity.object1][0], + activity.object1, + activity + ) + ) + ); + } else { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object1}`, + buildAnchor( + activity.summary_fields[activity.object1][0], + activity.object1, + activity + ) + ) + ); + } + break; + case 'create': + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object1}`, + buildAnchor(activity.changes, activity.object1, activity) + ) + ); + break; + default: + break; + } + break; + } + } catch (err) { + return {i18n._(t`Event summary not available`)}; + } + + return ( + + {labeledLinks.reduce( + (acc, x) => + acc === null ? ( + x + ) : ( + <> + {acc} {x} + + ), + null + )} + + ); +} + +export default withI18n()(ActivityStreamDescription); diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.test.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.test.jsx new file mode 100644 index 0000000000..9f3a2982d0 --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.test.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import ActivityStreamDescription from './ActivityStreamDescription'; + +describe('ActivityStreamDescription', () => { + test('initially renders succesfully', () => { + const description = mountWithContexts( + + ); + expect(description.find('span').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.jsx new file mode 100644 index 0000000000..22559831b2 --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.jsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button, Modal } from '@patternfly/react-core'; +import { SearchPlusIcon } from '@patternfly/react-icons'; + +import { formatDateString } from '../../util/dates'; + +import { DetailList, Detail } from '../../components/DetailList'; +import { VariablesDetail } from '../../components/CodeMirrorInput'; + +function ActivityStreamDetailButton({ i18n, streamItem, user, description }) { + const [isOpen, setIsOpen] = useState(false); + + const setting = streamItem?.summary_fields?.setting; + const changeRows = Math.max( + Object.keys(streamItem?.changes || []).length + 2, + 6 + ); + + return ( + <> + + setIsOpen(false)} + > + + + + + + + {streamItem?.changes && ( + + )} + + + + ); +} + +export default withI18n()(ActivityStreamDetailButton); diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.test.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.test.jsx new file mode 100644 index 0000000000..40dc104117 --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.test.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import ActivityStreamDetailButton from './ActivityStreamDetailButton'; + +jest.mock('../../api/models/ActivityStream'); + +describe('', () => { + test('initially renders succesfully', () => { + mountWithContexts( + Bob} + description={foo} + /> + ); + }); +}); diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.jsx new file mode 100644 index 0000000000..c5463565bb --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.jsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { shape } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { Tr, Td } from '@patternfly/react-table'; +import { t } from '@lingui/macro'; +import { Link } from 'react-router-dom'; + +import { formatDateString } from '../../util/dates'; +import { ActionsTd, ActionItem } from '../../components/PaginatedTable'; + +import ActivityStreamDetailButton from './ActivityStreamDetailButton'; +import ActivityStreamDescription from './ActivityStreamDescription'; + +function ActivityStreamListItem({ streamItem, i18n }) { + ActivityStreamListItem.propTypes = { + streamItem: shape({}).isRequired, + }; + + const buildUser = item => { + let link; + if (item?.summary_fields?.actor?.id) { + link = ( + + {item.summary_fields.actor.username} + + ); + } else if (item?.summary_fields?.actor) { + link = i18n._(t`${item.summary_fields.actor.username} (deleted)`); + } else { + link = i18n._(t`system`); + } + return link; + }; + + const labelId = `check-action-${streamItem.id}`; + const user = buildUser(streamItem); + const description = ; + + return ( + + + + {streamItem.timestamp ? formatDateString(streamItem.timestamp) : ''} + + {user} + + {description} + + + + + + + + ); +} +export default withI18n()(ActivityStreamListItem); diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.test.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.test.jsx new file mode 100644 index 0000000000..c0f03d23ee --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.test.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import ActivityStreamListItem from './ActivityStreamListItem'; + +jest.mock('../../api/models/ActivityStream'); + +describe('', () => { + test('initially renders succesfully', () => { + mountWithContexts( + + + {}} + /> + +
+ ); + }); +}); diff --git a/awx/ui_next/src/screens/ActivityStream/index.js b/awx/ui_next/src/screens/ActivityStream/index.js new file mode 100644 index 0000000000..5c0c72d9ef --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/index.js @@ -0,0 +1 @@ +export { default } from './ActivityStream'; diff --git a/awx/ui_next/src/screens/Application/Applications.jsx b/awx/ui_next/src/screens/Application/Applications.jsx index 85995c8512..ae6fa94af1 100644 --- a/awx/ui_next/src/screens/Application/Applications.jsx +++ b/awx/ui_next/src/screens/Application/Applications.jsx @@ -12,7 +12,7 @@ import { import ApplicationsList from './ApplicationsList'; import ApplicationAdd from './ApplicationAdd'; import Application from './Application'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; import { Detail, DetailList } from '../../components/DetailList'; const ApplicationAlert = styled(Alert)` @@ -45,7 +45,10 @@ function Applications({ i18n }) { return ( <> - + ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let wrapper; diff --git a/awx/ui_next/src/screens/Credential/Credentials.jsx b/awx/ui_next/src/screens/Credential/Credentials.jsx index beca652fd5..d883aa0dca 100644 --- a/awx/ui_next/src/screens/Credential/Credentials.jsx +++ b/awx/ui_next/src/screens/Credential/Credentials.jsx @@ -3,7 +3,7 @@ import { Route, Switch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Config } from '../../contexts/Config'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; import Credential from './Credential'; import CredentialAdd from './CredentialAdd'; import { CredentialList } from './CredentialList'; @@ -34,7 +34,10 @@ function Credentials({ i18n }) { return ( <> - + {({ me }) => } diff --git a/awx/ui_next/src/screens/Credential/Credentials.test.jsx b/awx/ui_next/src/screens/Credential/Credentials.test.jsx index f63c9d9b65..e5cb618fb2 100644 --- a/awx/ui_next/src/screens/Credential/Credentials.test.jsx +++ b/awx/ui_next/src/screens/Credential/Credentials.test.jsx @@ -3,6 +3,10 @@ import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Credentials from './Credentials'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let wrapper; @@ -30,8 +34,8 @@ describe('', () => { }, }); - expect(wrapper.find('Crumb').length).toBe(1); - expect(wrapper.find('BreadcrumbHeading').text()).toBe('Credentials'); + expect(wrapper.find('Crumb').length).toBe(0); + expect(wrapper.find('Title').text()).toBe('Credentials'); }); test('should display create new credential breadcrumb heading', () => { @@ -51,8 +55,6 @@ describe('', () => { }); expect(wrapper.find('Crumb').length).toBe(2); - expect(wrapper.find('BreadcrumbHeading').text()).toBe( - 'Create New Credential' - ); + expect(wrapper.find('Title').text()).toBe('Create New Credential'); }); }); diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx index 7cdbbccb00..4eb47c1892 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx @@ -6,7 +6,7 @@ import { Route, Switch } from 'react-router-dom'; import CredentialTypeAdd from './CredentialTypeAdd'; import CredentialTypeList from './CredentialTypeList'; import CredentialType from './CredentialType'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; function CredentialTypes({ i18n }) { const [breadcrumbConfig, setBreadcrumbConfig] = useState({ @@ -33,7 +33,10 @@ function CredentialTypes({ i18n }) { ); return ( <> - + diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypes.test.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypes.test.jsx index 468a55ac7a..bd8eff8e21 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypes.test.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypes.test.jsx @@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import CredentialTypes from './CredentialTypes'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let pageWrapper; let pageSections; diff --git a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx index f60e049631..714e6cf152 100644 --- a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx +++ b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx @@ -18,7 +18,7 @@ import { import useRequest from '../../util/useRequest'; import { DashboardAPI } from '../../api'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; import JobList from '../../components/JobList'; import ContentLoading from '../../components/ContentLoading'; import LineChart from './shared/LineChart'; @@ -117,7 +117,10 @@ function Dashboard({ i18n }) { } return ( - + ({ + ...jest.requireActual('react-router-dom'), +})); describe('', () => { let pageWrapper; diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.jsx index 1f5e0833bf..0fabce1b60 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { useLocation, useRouteMatch } from 'react-router-dom'; +import { useHistory, useLocation, useRouteMatch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Card, PageSection } from '@patternfly/react-core'; @@ -13,9 +13,14 @@ import PaginatedDataList, { ToolbarDeleteButton, } from '../../../components/PaginatedDataList'; import useRequest, { useDeleteItems } from '../../../util/useRequest'; -import { getQSConfig, parseQueryString } from '../../../util/qs'; +import { + encodeQueryString, + getQSConfig, + parseQueryString, +} from '../../../util/qs'; import HostListItem from './HostListItem'; +import SmartInventoryButton from './SmartInventoryButton'; const QS_CONFIG = getQSConfig('host', { page: 1, @@ -24,9 +29,21 @@ const QS_CONFIG = getQSConfig('host', { }); function HostList({ i18n }) { + const history = useHistory(); const location = useLocation(); const match = useRouteMatch(); const [selected, setSelected] = useState([]); + const parsedQueryStrings = parseQueryString(QS_CONFIG, location.search); + const nonDefaultSearchParams = {}; + + Object.keys(parsedQueryStrings).forEach(key => { + if (!QS_CONFIG.defaultParams[key]) { + nonDefaultSearchParams[key] = parsedQueryStrings[key]; + } + }); + + const hasNonDefaultSearchParams = + Object.keys(nonDefaultSearchParams).length > 0; const { result: { hosts, count, actions, relatedSearchableKeys, searchableKeys }, @@ -99,6 +116,14 @@ function HostList({ i18n }) { } }; + const handleSmartInventoryClick = () => { + history.push( + `/inventories/smart_inventory/add?host_filter=${encodeURIComponent( + encodeQueryString(nonDefaultSearchParams) + )}` + ); + }; + const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); @@ -157,6 +182,14 @@ function HostList({ i18n }) { itemsToDelete={selected} pluralizedItemName={i18n._(t`Hosts`)} />, + ...(canAdd + ? [ + handleSmartInventoryClick()} + />, + ] + : []), ]} /> )} diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx index b65c98b67b..5b502979e1 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; import { HostsAPI } from '../../../api'; import { mountWithContexts, @@ -257,7 +258,7 @@ describe('', () => { expect(modal.prop('title')).toEqual('Error!'); }); - test('should show Add button according to permissions', async () => { + test('should show Add and Smart Inventory buttons according to permissions', async () => { let wrapper; await act(async () => { wrapper = mountWithContexts(); @@ -265,9 +266,10 @@ describe('', () => { await waitForLoaded(wrapper); expect(wrapper.find('ToolbarAddButton').length).toBe(1); + expect(wrapper.find('Button[aria-label="Smart Inventory"]').length).toBe(1); }); - test('should hide Add button according to permissions', async () => { + test('should hide Add and Smart Inventory buttons according to permissions', async () => { HostsAPI.readOptions.mockResolvedValue({ data: { actions: { @@ -282,5 +284,44 @@ describe('', () => { await waitForLoaded(wrapper); expect(wrapper.find('ToolbarAddButton').length).toBe(0); + expect(wrapper.find('Button[aria-label="Smart Inventory"]').length).toBe(0); + }); + + test('Smart Inventory button should be disabled when no search params are present', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + expect( + wrapper.find('Button[aria-label="Smart Inventory"]').props().isDisabled + ).toBe(true); + }); + + test('Clicking Smart Inventory button should navigate to smart inventory form with correct query param', async () => { + let wrapper; + const history = createMemoryHistory({ + initialEntries: ['/hosts?host.name__icontains=foo'], + }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + + await waitForLoaded(wrapper); + expect( + wrapper.find('Button[aria-label="Smart Inventory"]').props().isDisabled + ).toBe(false); + await act(async () => { + wrapper.find('Button[aria-label="Smart Inventory"]').simulate('click'); + }); + wrapper.update(); + expect(history.location.pathname).toEqual( + '/inventories/smart_inventory/add' + ); + expect(history.location.search).toEqual( + '?host_filter=name__icontains%3Dfoo' + ); }); }); diff --git a/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.jsx b/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.jsx new file mode 100644 index 0000000000..9e90bbf531 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { func } from 'prop-types'; +import { Button, DropdownItem, Tooltip } from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useKebabifiedMenu } from '../../../contexts/Kebabified'; + +function SmartInventoryButton({ onClick, i18n, isDisabled }) { + const { isKebabified } = useKebabifiedMenu(); + + if (isKebabified) { + return ( + + {i18n._(t`Smart Inventory`)} + + ); + } + + return ( + +
+ +
+
+ ); +} +SmartInventoryButton.propTypes = { + onClick: func.isRequired, +}; + +export default withI18n()(SmartInventoryButton); diff --git a/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.test.jsx b/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.test.jsx new file mode 100644 index 0000000000..b8f47725f5 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryButton from './SmartInventoryButton'; + +describe('', () => { + test('should render button', () => { + const onClick = jest.fn(); + const wrapper = mountWithContexts( + + ); + const button = wrapper.find('button'); + expect(button).toHaveLength(1); + button.simulate('click'); + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/Host/Hosts.jsx b/awx/ui_next/src/screens/Host/Hosts.jsx index f66a28aa94..4c128bc202 100644 --- a/awx/ui_next/src/screens/Host/Hosts.jsx +++ b/awx/ui_next/src/screens/Host/Hosts.jsx @@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Config } from '../../contexts/Config'; -import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; import HostList from './HostList'; import HostAdd from './HostAdd'; @@ -37,7 +37,7 @@ function Hosts({ i18n }) { return ( <> - + diff --git a/awx/ui_next/src/screens/Host/Hosts.test.jsx b/awx/ui_next/src/screens/Host/Hosts.test.jsx index ba199f842f..1c0b9821c0 100644 --- a/awx/ui_next/src/screens/Host/Hosts.test.jsx +++ b/awx/ui_next/src/screens/Host/Hosts.test.jsx @@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Hosts from './Hosts'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { test('initially renders succesfully', () => { mountWithContexts(); @@ -27,7 +31,7 @@ describe('', () => { }, }, }); - expect(wrapper.find('BreadcrumbHeading').length).toBe(1); + expect(wrapper.find('Title').length).toBe(1); wrapper.unmount(); }); diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx index 4fbdd5d9b2..a133bd91f2 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx @@ -9,7 +9,7 @@ import InstanceGroup from './InstanceGroup'; import ContainerGroupAdd from './ContainerGroupAdd'; import ContainerGroup from './ContainerGroup'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; function InstanceGroups({ i18n }) { const [breadcrumbConfig, setBreadcrumbConfig] = useState({ @@ -54,7 +54,10 @@ function InstanceGroups({ i18n }) { ); return ( <> - + diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx index 321b6ca71b..db32e7e4eb 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx @@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import InstanceGroups from './InstanceGroups'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let pageWrapper; let pageSections; diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index cf286c05eb..17ee02b3be 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -1,10 +1,10 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useRef } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Route, Switch } from 'react-router-dom'; import { Config } from '../../contexts/Config'; -import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; import { InventoryList } from './InventoryList'; import Inventory from './Inventory'; import SmartInventory from './SmartInventory'; @@ -12,14 +12,34 @@ import InventoryAdd from './InventoryAdd'; import SmartInventoryAdd from './SmartInventoryAdd'; function Inventories({ i18n }) { - const [breadcrumbConfig, setBreadcrumbConfig] = useState({ + const initScreenHeader = useRef({ '/inventories': i18n._(t`Inventories`), '/inventories/inventory/add': i18n._(t`Create new inventory`), '/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`), }); - const buildBreadcrumbConfig = useCallback( - (inventory, nested, schedule) => { + const [breadcrumbConfig, setScreenHeader] = useState( + initScreenHeader.current + ); + + const [inventory, setInventory] = useState(); + const [nestedObject, setNestedGroup] = useState(); + const [schedule, setSchedule] = useState(); + + const setBreadcrumbConfig = useCallback( + (passedInventory, passedNestedObject, passedSchedule) => { + if (passedInventory && passedInventory.name !== inventory?.name) { + setInventory(passedInventory); + } + if ( + passedNestedObject && + passedNestedObject.name !== nestedObject?.name + ) { + setNestedGroup(passedNestedObject); + } + if (passedSchedule && passedSchedule.name !== schedule?.name) { + setSchedule(passedSchedule); + } if (!inventory) { return; } @@ -32,13 +52,8 @@ function Inventories({ i18n }) { const inventoryGroupsPath = `${inventoryPath}/groups`; const inventorySourcesPath = `${inventoryPath}/sources`; - setBreadcrumbConfig({ - '/inventories': i18n._(t`Inventories`), - '/inventories/inventory/add': i18n._(t`Create new inventory`), - '/inventories/smart_inventory/add': i18n._( - t`Create new smart inventory` - ), - + setScreenHeader({ + ...initScreenHeader.current, [inventoryPath]: `${inventory.name}`, [`${inventoryPath}/access`]: i18n._(t`Access`), [`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`), @@ -47,55 +62,74 @@ function Inventories({ i18n }) { [inventoryHostsPath]: i18n._(t`Hosts`), [`${inventoryHostsPath}/add`]: i18n._(t`Create new host`), - [`${inventoryHostsPath}/${nested?.id}`]: `${nested?.name}`, - [`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), - [`${inventoryHostsPath}/${nested?.id}/details`]: i18n._( + [`${inventoryHostsPath}/${nestedObject?.id}`]: `${nestedObject?.name}`, + [`${inventoryHostsPath}/${nestedObject?.id}/edit`]: i18n._( + t`Edit details` + ), + [`${inventoryHostsPath}/${nestedObject?.id}/details`]: i18n._( t`Host details` ), - [`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._( + [`${inventoryHostsPath}/${nestedObject?.id}/completed_jobs`]: i18n._( t`Completed jobs` ), - [`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`), - [`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`), + [`${inventoryHostsPath}/${nestedObject?.id}/facts`]: i18n._(t`Facts`), + [`${inventoryHostsPath}/${nestedObject?.id}/groups`]: i18n._(t`Groups`), [inventoryGroupsPath]: i18n._(t`Groups`), [`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`), - [`${inventoryGroupsPath}/${nested?.id}`]: `${nested?.name}`, - [`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), - [`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._( + [`${inventoryGroupsPath}/${nestedObject?.id}`]: `${nestedObject?.name}`, + [`${inventoryGroupsPath}/${nestedObject?.id}/edit`]: i18n._( + t`Edit details` + ), + [`${inventoryGroupsPath}/${nestedObject?.id}/details`]: i18n._( t`Group details` ), - [`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`), - [`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._( + [`${inventoryGroupsPath}/${nestedObject?.id}/nested_hosts`]: i18n._( + t`Hosts` + ), + [`${inventoryGroupsPath}/${nestedObject?.id}/nested_hosts/add`]: i18n._( t`Create new host` ), - [`${inventoryGroupsPath}/${nested?.id}/nested_groups`]: i18n._( - t`Groups` + [`${inventoryGroupsPath}/${nestedObject?.id}/nested_groups`]: i18n._( + t`Related Groups` ), - [`${inventoryGroupsPath}/${nested?.id}/nested_groups/add`]: i18n._( + [`${inventoryGroupsPath}/${nestedObject?.id}/nested_groups/add`]: i18n._( t`Create new group` ), [`${inventorySourcesPath}`]: i18n._(t`Sources`), [`${inventorySourcesPath}/add`]: i18n._(t`Create new source`), - [`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`, - [`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`), - [`${inventorySourcesPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), - [`${inventorySourcesPath}/${nested?.id}/schedules`]: i18n._( + [`${inventorySourcesPath}/${nestedObject?.id}`]: `${nestedObject?.name}`, + [`${inventorySourcesPath}/${nestedObject?.id}/details`]: i18n._( + t`Details` + ), + [`${inventorySourcesPath}/${nestedObject?.id}/edit`]: i18n._( + t`Edit details` + ), + [`${inventorySourcesPath}/${nestedObject?.id}/schedules`]: i18n._( t`Schedules` ), - [`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`, - [`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}/details`]: i18n._( + [`${inventorySourcesPath}/${nestedObject?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`, + [`${inventorySourcesPath}/${nestedObject?.id}/schedules/add`]: i18n._( + t`Create New Schedule` + ), + [`${inventorySourcesPath}/${nestedObject?.id}/schedules/${schedule?.id}/details`]: i18n._( t`Schedule details` ), + [`${inventorySourcesPath}/${nestedObject?.id}/notifications`]: i18n._( + t`Notifcations` + ), }); }, - [i18n] + [i18n, inventory, nestedObject, schedule] ); return ( <> - + @@ -106,12 +140,12 @@ function Inventories({ i18n }) { {({ me }) => ( - + )} - + diff --git a/awx/ui_next/src/screens/Inventory/Inventories.test.jsx b/awx/ui_next/src/screens/Inventory/Inventories.test.jsx index ca452f877a..35e1ee5da0 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.test.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.test.jsx @@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Inventories from './Inventories'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let pageWrapper; diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx index 9b82b25a8b..a851fbfdc4 100644 --- a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useCallback } from 'react'; import { Formik, useField, useFormikContext } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { useLocation } from 'react-router-dom'; import { func, shape, arrayOf } from 'prop-types'; import { Form } from '@patternfly/react-core'; import { InstanceGroup } from '../../../types'; @@ -14,6 +15,10 @@ import { FormColumnLayout, FormFullWidthLayout, } from '../../../components/FormLayout'; +import { + toHostFilter, + toSearchParams, +} from '../../../components/Lookup/shared/HostFilterUtils'; import HostFilterLookup from '../../../components/Lookup/HostFilterLookup'; import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup'; import OrganizationLookup from '../../../components/Lookup/OrganizationLookup'; @@ -109,9 +114,17 @@ function SmartInventoryForm({ onCancel, submitError, }) { + const { search } = useLocation(); + const queryParams = new URLSearchParams(search); + const hostFilterFromParams = queryParams.get('host_filter'); + const initialValues = { description: inventory.description || '', - host_filter: inventory.host_filter || '', + host_filter: + inventory.host_filter || + (hostFilterFromParams + ? toHostFilter(toSearchParams(hostFilterFromParams)) + : ''), instance_groups: instanceGroups || [], kind: 'smart', name: inventory.name || '', diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx index 1f3c1127cc..382a4cea80 100644 --- a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; import { mountWithContexts, waitForElement, @@ -135,6 +136,29 @@ describe('', () => { }); }); + test('should pre-fill the host filter when query param present and not editing', async () => { + let wrapper; + const history = createMemoryHistory({ + initialEntries: [ + '/inventories/smart_inventory/add?host_filter=name__icontains%3Dfoo', + ], + }); + await act(async () => { + wrapper = mountWithContexts( + {}} onSubmit={() => {}} />, + { + context: { router: { history } }, + } + ); + }); + wrapper.update(); + const nameChipGroup = wrapper.find( + 'HostFilterLookup ChipGroup[categoryName="Name"]' + ); + expect(nameChipGroup.find('Chip').length).toBe(1); + wrapper.unmount(); + }); + test('should throw content error when option request fails', async () => { let wrapper; InventoriesAPI.readOptions.mockImplementationOnce(() => diff --git a/awx/ui_next/src/screens/Job/Job.test.jsx b/awx/ui_next/src/screens/Job/Job.test.jsx index 7b902984f0..afb1f6e148 100644 --- a/awx/ui_next/src/screens/Job/Job.test.jsx +++ b/awx/ui_next/src/screens/Job/Job.test.jsx @@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Job from './Jobs'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { test('initially renders succesfully', () => { mountWithContexts(); diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx index 2bc991b969..88e32be49f 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx @@ -1,6 +1,6 @@ import React, { Component, Fragment } from 'react'; import { withRouter } from 'react-router-dom'; -import { withI18n } from '@lingui/react'; +import { I18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; import { @@ -518,7 +518,7 @@ class JobOutput extends Component { } render() { - const { job, i18n } = this.props; + const { job } = this.props; const { contentError, @@ -596,15 +596,21 @@ class JobOutput extends Component { {deletionError && ( - this.setState({ deletionError: null })} - title={i18n._(t`Job Delete Error`)} - label={i18n._(t`Job Delete Error`)} - > - - + <> + + {({ i18n }) => ( + this.setState({ deletionError: null })} + title={i18n._(t`Job Delete Error`)} + label={i18n._(t`Job Delete Error`)} + > + + + )} + + )}
); @@ -612,4 +618,4 @@ class JobOutput extends Component { } export { JobOutput as _JobOutput }; -export default withI18n()(withRouter(JobOutput)); +export default withRouter(JobOutput); diff --git a/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx b/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx index 599fc402a9..dbf2256fdc 100644 --- a/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx +++ b/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx @@ -6,6 +6,7 @@ import { t } from '@lingui/macro'; import useRequest from '../../util/useRequest'; import { UnifiedJobsAPI } from '../../api'; import ContentError from '../../components/ContentError'; +import ContentLoading from '../../components/ContentLoading'; import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; const NOT_FOUND = 'not found'; @@ -46,8 +47,13 @@ function JobTypeRedirect({ id, path, view, i18n }) { ); } if (isLoading || !job?.id) { - // TODO show loading state - return
Loading...
; + return ( + + + + + + ); } const type = JOB_TYPE_URL_SEGMENTS[job.type]; return ; diff --git a/awx/ui_next/src/screens/Job/Jobs.jsx b/awx/ui_next/src/screens/Job/Jobs.jsx index 83c8d0d11f..318729407a 100644 --- a/awx/ui_next/src/screens/Job/Jobs.jsx +++ b/awx/ui_next/src/screens/Job/Jobs.jsx @@ -3,7 +3,7 @@ import { Route, Switch, useParams, useRouteMatch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { PageSection } from '@patternfly/react-core'; -import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; import Job from './Job'; import JobTypeRedirect from './JobTypeRedirect'; import JobList from '../../components/JobList'; @@ -40,7 +40,7 @@ function Jobs({ i18n }) { return ( <> - + diff --git a/awx/ui_next/src/screens/Job/Jobs.test.jsx b/awx/ui_next/src/screens/Job/Jobs.test.jsx index 5782335404..e866a6d20d 100644 --- a/awx/ui_next/src/screens/Job/Jobs.test.jsx +++ b/awx/ui_next/src/screens/Job/Jobs.test.jsx @@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Jobs from './Jobs'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { test('initially renders succesfully', () => { mountWithContexts(); @@ -27,7 +31,7 @@ describe('', () => { }, }, }); - expect(wrapper.find('BreadcrumbHeading').length).toBe(1); + expect(wrapper.find('Title').length).toBe(1); wrapper.unmount(); }); }); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx index 774eb3e235..94f5a077c5 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx @@ -2,12 +2,13 @@ import React, { Fragment } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; function ManagementJobs({ i18n }) { return ( - diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx index a667a47690..df422fe8ec 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx @@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import ManagementJobs from './ManagementJobs'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let pageWrapper; @@ -17,6 +21,6 @@ describe('', () => { test('initially renders without crashing', () => { expect(pageWrapper.length).toBe(1); - expect(pageWrapper.find('Breadcrumbs').length).toBe(1); + expect(pageWrapper.find('ScreenHeader').length).toBe(1); }); }); diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx index 2ae913202f..9d41166d1d 100644 --- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx @@ -5,7 +5,7 @@ import { t } from '@lingui/macro'; import NotificationTemplateList from './NotificationTemplateList'; import NotificationTemplateAdd from './NotificationTemplateAdd'; import NotificationTemplate from './NotificationTemplate'; -import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; function NotificationTemplates({ i18n }) { const match = useRouteMatch(); @@ -32,7 +32,10 @@ function NotificationTemplates({ i18n }) { return ( <> - + diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx index 9333850cf9..f8b02d4735 100644 --- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx @@ -2,6 +2,10 @@ import React from 'react'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import NotificationTemplates from './NotificationTemplates'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let pageWrapper; let pageSections; diff --git a/awx/ui_next/src/screens/Organization/Organizations.jsx b/awx/ui_next/src/screens/Organization/Organizations.jsx index 5942d75147..6c7b17dc69 100644 --- a/awx/ui_next/src/screens/Organization/Organizations.jsx +++ b/awx/ui_next/src/screens/Organization/Organizations.jsx @@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Config } from '../../contexts/Config'; -import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; import OrganizationsList from './OrganizationList/OrganizationList'; import OrganizationAdd from './OrganizationAdd/OrganizationAdd'; @@ -42,7 +42,10 @@ function Organizations({ i18n }) { return ( - + diff --git a/awx/ui_next/src/screens/Organization/Organizations.test.jsx b/awx/ui_next/src/screens/Organization/Organizations.test.jsx index 4f510463a4..819b86d88d 100644 --- a/awx/ui_next/src/screens/Organization/Organizations.test.jsx +++ b/awx/ui_next/src/screens/Organization/Organizations.test.jsx @@ -5,6 +5,9 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Organizations from './Organizations'; jest.mock('../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); describe('', () => { test('initially renders succesfully', async () => { diff --git a/awx/ui_next/src/screens/Project/Projects.jsx b/awx/ui_next/src/screens/Project/Projects.jsx index a45dd1890b..8063d4c1e6 100644 --- a/awx/ui_next/src/screens/Project/Projects.jsx +++ b/awx/ui_next/src/screens/Project/Projects.jsx @@ -3,7 +3,7 @@ import { Route, withRouter, Switch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; import ProjectsList from './ProjectList/ProjectList'; import ProjectAdd from './ProjectAdd/ProjectAdd'; @@ -45,7 +45,7 @@ function Projects({ i18n }) { return ( <> - + diff --git a/awx/ui_next/src/screens/Project/Projects.test.jsx b/awx/ui_next/src/screens/Project/Projects.test.jsx index b46f37ae23..4d522a3d14 100644 --- a/awx/ui_next/src/screens/Project/Projects.test.jsx +++ b/awx/ui_next/src/screens/Project/Projects.test.jsx @@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Projects from './Projects'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { test('initially renders succesfully', () => { mountWithContexts(); @@ -27,7 +31,7 @@ describe('', () => { }, }, }); - expect(wrapper.find('BreadcrumbHeading').length).toBe(1); + expect(wrapper.find('Title').length).toBe(1); wrapper.unmount(); }); }); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx index c5b454246f..8c52218c1e 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx @@ -310,7 +310,17 @@ function ProjectForm({ i18n, project, submitError, ...props }) { const { summary_fields = {} } = project; const [contentError, setContentError] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [scmSubFormState, setScmSubFormState] = useState(null); + const [scmSubFormState, setScmSubFormState] = useState({ + scm_url: '', + scm_branch: '', + scm_refspec: '', + credential: '', + scm_clean: false, + scm_delete_on_update: false, + scm_update_on_launch: false, + allow_override: false, + scm_update_cache_timeout: 0, + }); const [scmTypeOptions, setScmTypeOptions] = useState(null); const [credentials, setCredentials] = useState({ scm: { typeId: null, value: null }, diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx index 864142b046..5a4cc23fdc 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx @@ -6,8 +6,8 @@ import { SyncIcon } from '@patternfly/react-icons'; import { number } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; - import useRequest, { useDismissableError } from '../../../util/useRequest'; + import AlertModal from '../../../components/AlertModal'; import ErrorDetail from '../../../components/ErrorDetail'; import { ProjectsAPI } from '../../../api'; diff --git a/awx/ui_next/src/screens/Schedule/AllSchedules.jsx b/awx/ui_next/src/screens/Schedule/AllSchedules.jsx index a55e9df3bb..4e778e9623 100644 --- a/awx/ui_next/src/screens/Schedule/AllSchedules.jsx +++ b/awx/ui_next/src/screens/Schedule/AllSchedules.jsx @@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { PageSection, Card } from '@patternfly/react-core'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; import { ScheduleList } from '../../components/Schedule'; import { SchedulesAPI } from '../../api'; @@ -19,7 +19,8 @@ function AllSchedules({ i18n }) { return ( <> - ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let wrapper; @@ -30,7 +34,6 @@ describe('', () => { }, }); - expect(wrapper.find('Crumb').length).toBe(1); - expect(wrapper.find('BreadcrumbHeading').text()).toBe('Schedules'); + expect(wrapper.find('Title').text()).toBe('Schedules'); }); }); diff --git a/awx/ui_next/src/screens/Setting/RADIUS/RADIUS.test.jsx b/awx/ui_next/src/screens/Setting/RADIUS/RADIUS.test.jsx index a275e74d95..cf3e15723c 100644 --- a/awx/ui_next/src/screens/Setting/RADIUS/RADIUS.test.jsx +++ b/awx/ui_next/src/screens/Setting/RADIUS/RADIUS.test.jsx @@ -2,12 +2,18 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import RADIUS from './RADIUS'; +import { SettingsProvider } from '../../../contexts/Settings'; import { SettingsAPI } from '../../../api'; +import mockAllOptions from '../shared/data.allSettingOptions.json'; +import RADIUS from './RADIUS'; jest.mock('../../../api/models/Settings'); SettingsAPI.readCategory.mockResolvedValue({ - data: {}, + data: { + RADIUS_SERVER: 'radius.example.org', + RADIUS_PORT: 1812, + RADIUS_SECRET: '$encrypted$', + }, }); describe('', () => { @@ -23,9 +29,14 @@ describe('', () => { initialEntries: ['/settings/radius/details'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('RADIUSDetail').length).toBe(1); }); @@ -35,9 +46,14 @@ describe('', () => { initialEntries: ['/settings/radius/edit'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('RADIUSEdit').length).toBe(1); }); diff --git a/awx/ui_next/src/screens/Setting/RADIUS/RADIUSEdit/RADIUSEdit.jsx b/awx/ui_next/src/screens/Setting/RADIUS/RADIUSEdit/RADIUSEdit.jsx index 624a624f06..a29c259f34 100644 --- a/awx/ui_next/src/screens/Setting/RADIUS/RADIUSEdit/RADIUSEdit.jsx +++ b/awx/ui_next/src/screens/Setting/RADIUS/RADIUSEdit/RADIUSEdit.jsx @@ -1,25 +1,113 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { Button } from '@patternfly/react-core'; -import { CardBody, CardActionsRow } from '../../../../components/Card'; +import React, { useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Formik } from 'formik'; +import { Form } from '@patternfly/react-core'; +import { CardBody } from '../../../../components/Card'; +import ContentError from '../../../../components/ContentError'; +import ContentLoading from '../../../../components/ContentLoading'; +import { FormSubmitError } from '../../../../components/FormField'; +import { FormColumnLayout } from '../../../../components/FormLayout'; +import { useSettings } from '../../../../contexts/Settings'; +import { RevertAllAlert, RevertFormActionGroup } from '../../shared'; +import { EncryptedField, InputField } from '../../shared/SharedFields'; +import useModal from '../../../../util/useModal'; +import useRequest from '../../../../util/useRequest'; +import { SettingsAPI } from '../../../../api'; + +function RADIUSEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { isLoading, error, request: fetchRadius, result: radius } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('radius'); + const mergedData = {}; + Object.keys(data).forEach(key => { + mergedData[key] = options[key]; + mergedData[key].value = data[key]; + }); + return mergedData; + }, [options]), + null + ); + + useEffect(() => { + fetchRadius(); + }, [fetchRadius]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async values => { + await SettingsAPI.updateAll(values); + history.push('/settings/radius/details'); + }, + [history] + ), + null + ); + + const handleSubmit = async form => { + await submitForm(form); + }; + + const handleRevertAll = async () => { + const defaultValues = Object.assign( + ...Object.entries(radius).map(([key, value]) => ({ + [key]: value.default, + })) + ); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/radius/details'); + }; + + const initialValues = fields => + Object.keys(fields).reduce((acc, key) => { + acc[key] = fields[key].value ?? ''; + return acc; + }, {}); -function RADIUSEdit({ i18n }) { return ( - {i18n._(t`Edit form coming soon :)`)} - - - + {isLoading && } + {!isLoading && error && } + {!isLoading && radius && ( + + {formik => ( +
+ + + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} +
+ )}
); } -export default withI18n()(RADIUSEdit); +export default RADIUSEdit; diff --git a/awx/ui_next/src/screens/Setting/RADIUS/RADIUSEdit/RADIUSEdit.test.jsx b/awx/ui_next/src/screens/Setting/RADIUS/RADIUSEdit/RADIUSEdit.test.jsx index 934aeb3825..94ea2f9824 100644 --- a/awx/ui_next/src/screens/Setting/RADIUS/RADIUSEdit/RADIUSEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/RADIUS/RADIUSEdit/RADIUSEdit.test.jsx @@ -1,16 +1,149 @@ import React from 'react'; -import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../../testUtils/enzymeHelpers'; +import mockAllOptions from '../../shared/data.allSettingOptions.json'; +import { SettingsProvider } from '../../../../contexts/Settings'; +import { SettingsAPI } from '../../../../api'; import RADIUSEdit from './RADIUSEdit'; +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: { + RADIUS_SERVER: 'radius.mock.org', + RADIUS_PORT: 1812, + RADIUS_SECRET: '$encrypted$', + }, +}); + describe('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/radius/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + test('initially renders without crashing', () => { expect(wrapper.find('RADIUSEdit').length).toBe(1); }); + + test('should display expected form fields', async () => { + expect(wrapper.find('FormGroup[label="RADIUS Server"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="RADIUS Port"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="RADIUS Secret"]').length).toBe(1); + }); + + test('should successfully send default values to api on form revert all', async () => { + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + expect(wrapper.find('RevertAllAlert')).toHaveLength(0); + await act(async () => { + wrapper + .find('button[aria-label="Revert all to default"]') + .invoke('onClick')(); + }); + wrapper.update(); + expect(wrapper.find('RevertAllAlert')).toHaveLength(1); + await act(async () => { + wrapper + .find('RevertAllAlert button[aria-label="Confirm revert all"]') + .invoke('onClick')(); + }); + wrapper.update(); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + RADIUS_SERVER: '', + RADIUS_PORT: 1812, + RADIUS_SECRET: '', + }); + }); + + test('should successfully send request to api on form submission', async () => { + act(() => { + wrapper.find('input#RADIUS_SERVER').simulate('change', { + target: { value: 'radius.new_mock.org', name: 'RADIUS_SERVER' }, + }); + wrapper + .find('FormGroup[fieldId="RADIUS_SECRET"] button[aria-label="Revert"]') + .invoke('onClick')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + RADIUS_SERVER: 'radius.new_mock.org', + RADIUS_PORT: 1812, + RADIUS_SECRET: '', + }); + }); + + test('should navigate to radius detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual('/settings/radius/details'); + }); + + test('should navigate to radius detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/radius/details'); + }); + + test('should display error message on unsuccessful submission', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error)); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + }); + + test('should display ContentError on throw', async () => { + SettingsAPI.readCategory.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/screens/Setting/Settings.jsx b/awx/ui_next/src/screens/Setting/Settings.jsx index ae3356f950..a535384afa 100644 --- a/awx/ui_next/src/screens/Setting/Settings.jsx +++ b/awx/ui_next/src/screens/Setting/Settings.jsx @@ -5,7 +5,7 @@ import { t } from '@lingui/macro'; import { PageSection, Card } from '@patternfly/react-core'; import ContentError from '../../components/ContentError'; import ContentLoading from '../../components/ContentLoading'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; import ActivityStream from './ActivityStream'; import AzureAD from './AzureAD'; import GitHub from './GitHub'; @@ -129,7 +129,7 @@ function Settings({ i18n }) { return ( - + diff --git a/awx/ui_next/src/screens/Setting/Settings.test.jsx b/awx/ui_next/src/screens/Setting/Settings.test.jsx index 24a82986f8..3c63750e71 100644 --- a/awx/ui_next/src/screens/Setting/Settings.test.jsx +++ b/awx/ui_next/src/screens/Setting/Settings.test.jsx @@ -13,6 +13,9 @@ jest.mock('../../api/models/Settings'); SettingsAPI.readAllOptions.mockResolvedValue({ data: mockAllOptions, }); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); describe('', () => { let wrapper; diff --git a/awx/ui_next/src/screens/Setting/TACACS/TACACS.test.jsx b/awx/ui_next/src/screens/Setting/TACACS/TACACS.test.jsx index ec3c69aed0..ad8fcdbe61 100644 --- a/awx/ui_next/src/screens/Setting/TACACS/TACACS.test.jsx +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACS.test.jsx @@ -2,12 +2,20 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { SettingsProvider } from '../../../contexts/Settings'; import { SettingsAPI } from '../../../api'; +import mockAllOptions from '../shared/data.allSettingOptions.json'; import TACACS from './TACACS'; jest.mock('../../../api/models/Settings'); SettingsAPI.readCategory.mockResolvedValue({ - data: {}, + data: { + TACACSPLUS_HOST: 'mockhost', + TACACSPLUS_PORT: 49, + TACACSPLUS_SECRET: '$encrypted$', + TACACSPLUS_SESSION_TIMEOUT: 5, + TACACSPLUS_AUTH_PROTOCOL: 'ascii', + }, }); describe('', () => { @@ -23,9 +31,14 @@ describe('', () => { initialEntries: ['/settings/tacacs/details'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('TACACSDetail').length).toBe(1); }); @@ -35,9 +48,14 @@ describe('', () => { initialEntries: ['/settings/tacacs/edit'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('TACACSEdit').length).toBe(1); }); diff --git a/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.jsx b/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.jsx index 8ec22acb07..a45e59d069 100644 --- a/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.jsx +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.jsx @@ -1,25 +1,130 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { Button } from '@patternfly/react-core'; -import { CardBody, CardActionsRow } from '../../../../components/Card'; +import React, { useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Formik } from 'formik'; +import { Form } from '@patternfly/react-core'; +import { CardBody } from '../../../../components/Card'; +import ContentError from '../../../../components/ContentError'; +import ContentLoading from '../../../../components/ContentLoading'; +import { FormSubmitError } from '../../../../components/FormField'; +import { FormColumnLayout } from '../../../../components/FormLayout'; +import { useSettings } from '../../../../contexts/Settings'; +import { RevertAllAlert, RevertFormActionGroup } from '../../shared'; +import { + ChoiceField, + EncryptedField, + InputField, +} from '../../shared/SharedFields'; +import useModal from '../../../../util/useModal'; +import useRequest from '../../../../util/useRequest'; +import { SettingsAPI } from '../../../../api'; + +function TACACSEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { isLoading, error, request: fetchTACACS, result: tacacs } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('tacacsplus'); + const mergedData = {}; + Object.keys(data).forEach(key => { + mergedData[key] = options[key]; + mergedData[key].value = data[key]; + }); + return mergedData; + }, [options]), + null + ); + + useEffect(() => { + fetchTACACS(); + }, [fetchTACACS]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async values => { + await SettingsAPI.updateAll(values); + history.push('/settings/tacacs/details'); + }, + [history] + ), + null + ); + + const handleSubmit = async form => { + await submitForm(form); + }; + + const handleRevertAll = async () => { + const defaultValues = Object.assign( + ...Object.entries(tacacs).map(([key, value]) => ({ + [key]: value.default, + })) + ); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/tacacs/details'); + }; + + const initialValues = fields => + Object.keys(fields).reduce((acc, key) => { + acc[key] = fields[key].value ?? ''; + return acc; + }, {}); -function TACACSEdit({ i18n }) { return ( - {i18n._(t`Edit form coming soon :)`)} - - - + {isLoading && } + {!isLoading && error && } + {!isLoading && tacacs && ( + + {formik => ( +
+ + + + + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} +
+ )}
); } -export default withI18n()(TACACSEdit); +export default TACACSEdit; diff --git a/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.test.jsx b/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.test.jsx index 529090a34f..a62cdbc289 100644 --- a/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.test.jsx @@ -1,16 +1,166 @@ import React from 'react'; -import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../../testUtils/enzymeHelpers'; +import mockAllOptions from '../../shared/data.allSettingOptions.json'; +import { SettingsProvider } from '../../../../contexts/Settings'; +import { SettingsAPI } from '../../../../api'; import TACACSEdit from './TACACSEdit'; +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: { + TACACSPLUS_HOST: 'mockhost', + TACACSPLUS_PORT: 49, + TACACSPLUS_SECRET: '$encrypted$', + TACACSPLUS_SESSION_TIMEOUT: 123, + TACACSPLUS_AUTH_PROTOCOL: 'ascii', + }, +}); + describe('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/tacacs/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + test('initially renders without crashing', () => { expect(wrapper.find('TACACSEdit').length).toBe(1); }); + + test('should display expected form fields', async () => { + expect(wrapper.find('FormGroup[label="TACACS+ Server"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="TACACS+ Port"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="TACACS+ Secret"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="TACACS+ Auth Session Timeout"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="TACACS+ Authentication Protocol"]').length + ).toBe(1); + }); + + test('should successfully send default values to api on form revert all', async () => { + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + expect(wrapper.find('RevertAllAlert')).toHaveLength(0); + await act(async () => { + wrapper + .find('button[aria-label="Revert all to default"]') + .invoke('onClick')(); + }); + wrapper.update(); + expect(wrapper.find('RevertAllAlert')).toHaveLength(1); + await act(async () => { + wrapper + .find('RevertAllAlert button[aria-label="Confirm revert all"]') + .invoke('onClick')(); + }); + wrapper.update(); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + TACACSPLUS_HOST: '', + TACACSPLUS_PORT: 49, + TACACSPLUS_SECRET: '', + TACACSPLUS_SESSION_TIMEOUT: 5, + TACACSPLUS_AUTH_PROTOCOL: 'ascii', + }); + }); + + test('should successfully send request to api on form submission', async () => { + act(() => { + wrapper.find('input#TACACSPLUS_HOST').simulate('change', { + target: { value: 'new_host', name: 'TACACSPLUS_HOST' }, + }); + wrapper.find('input#TACACSPLUS_PORT').simulate('change', { + target: { value: 999, name: 'TACACSPLUS_PORT' }, + }); + wrapper + .find( + 'FormGroup[fieldId="TACACSPLUS_SECRET"] button[aria-label="Revert"]' + ) + .invoke('onClick')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + TACACSPLUS_HOST: 'new_host', + TACACSPLUS_PORT: 999, + TACACSPLUS_SECRET: '', + TACACSPLUS_SESSION_TIMEOUT: 123, + TACACSPLUS_AUTH_PROTOCOL: 'ascii', + }); + }); + + test('should navigate to tacacs detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual('/settings/tacacs/details'); + }); + + test('should navigate to tacacs detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/tacacs/details'); + }); + + test('should display error message on unsuccessful submission', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error)); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + }); + + test('should display ContentError on throw', async () => { + SettingsAPI.readCategory.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx index 47b2b4011c..6d3691da71 100644 --- a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx +++ b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx @@ -27,71 +27,68 @@ const DataListAction = styled(_DataListAction)` grid-template-columns: 40px; `; -class TeamListItem extends React.Component { - static propTypes = { +function TeamListItem({ team, isSelected, onSelect, detailUrl, i18n }) { + TeamListItem.propTypes = { team: Team.isRequired, detailUrl: string.isRequired, isSelected: bool.isRequired, onSelect: func.isRequired, }; - render() { - const { team, isSelected, onSelect, detailUrl, i18n } = this.props; - const labelId = `check-action-${team.id}`; + const labelId = `check-action-${team.id}`; - return ( - - - - - - {team.name} - - , - - {team.summary_fields.organization && ( - - {i18n._(t`Organization`)}{' '} - - {team.summary_fields.organization.name} - - - )} - , - ]} - /> - - {team.summary_fields.user_capabilities.edit ? ( - - - - ) : ( - '' - )} - - - - ); - } + return ( + + + + + + {team.name} + + , + + {team.summary_fields.organization && ( + + {i18n._(t`Organization`)}{' '} + + {team.summary_fields.organization.name} + + + )} + , + ]} + /> + + {team.summary_fields.user_capabilities.edit ? ( + + + + ) : ( + '' + )} + + + + ); } export default withI18n()(TeamListItem); diff --git a/awx/ui_next/src/screens/Team/Teams.jsx b/awx/ui_next/src/screens/Team/Teams.jsx index 3022ca98c7..0797a6685e 100644 --- a/awx/ui_next/src/screens/Team/Teams.jsx +++ b/awx/ui_next/src/screens/Team/Teams.jsx @@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Config } from '../../contexts/Config'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; import TeamList from './TeamList'; import TeamAdd from './TeamAdd'; import Team from './Team'; @@ -29,6 +29,7 @@ function Teams({ i18n }) { [`/teams/${team.id}/details`]: i18n._(t`Details`), [`/teams/${team.id}/users`]: i18n._(t`Users`), [`/teams/${team.id}/access`]: i18n._(t`Access`), + [`/teams/${team.id}/roles`]: i18n._(t`Roles`), }); }, [i18n] @@ -36,7 +37,7 @@ function Teams({ i18n }) { return ( <> - + diff --git a/awx/ui_next/src/screens/Team/Teams.test.jsx b/awx/ui_next/src/screens/Team/Teams.test.jsx index db73b4d7d4..181b814077 100644 --- a/awx/ui_next/src/screens/Team/Teams.test.jsx +++ b/awx/ui_next/src/screens/Team/Teams.test.jsx @@ -3,6 +3,9 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Teams from './Teams'; jest.mock('../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); describe('', () => { test('initially renders succesfully', () => { diff --git a/awx/ui_next/src/screens/Template/Templates.jsx b/awx/ui_next/src/screens/Template/Templates.jsx index 167d458141..f3905608cc 100644 --- a/awx/ui_next/src/screens/Template/Templates.jsx +++ b/awx/ui_next/src/screens/Template/Templates.jsx @@ -4,7 +4,7 @@ import { t } from '@lingui/macro'; import { Route, withRouter, Switch } from 'react-router-dom'; import { PageSection } from '@patternfly/react-core'; -import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; import { TemplateList } from './TemplateList'; import Template from './Template'; import WorkflowJobTemplate from './WorkflowJobTemplate'; @@ -12,22 +12,34 @@ import JobTemplateAdd from './JobTemplateAdd'; import WorkflowJobTemplateAdd from './WorkflowJobTemplateAdd'; function Templates({ i18n }) { - const initBreadcrumbs = useRef({ + const initScreenHeader = useRef({ '/templates': i18n._(t`Templates`), '/templates/job_template/add': i18n._(t`Create New Job Template`), '/templates/workflow_job_template/add': i18n._( t`Create New Workflow Template` ), }); - const [breadcrumbConfig, setBreadcrumbs] = useState(initBreadcrumbs.current); + const [breadcrumbConfig, setScreenHeader] = useState( + initScreenHeader.current + ); + + const [schedule, setSchedule] = useState(); + const [template, setTemplate] = useState(); + const setBreadcrumbConfig = useCallback( - (template, schedule) => { + (passedTemplate, passedSchedule) => { + if (passedTemplate && passedTemplate.name !== template?.name) { + setTemplate(passedTemplate); + } + if (passedSchedule && passedSchedule.name !== schedule?.name) { + setSchedule(passedSchedule); + } if (!template) return; const templatePath = `/templates/${template.type}/${template.id}`; const schedulesPath = `${templatePath}/schedules`; const surveyPath = `${templatePath}/survey`; - setBreadcrumbs({ - ...initBreadcrumbs.current, + setScreenHeader({ + ...initScreenHeader.current, [templatePath]: `${template.name}`, [`${templatePath}/details`]: i18n._(t`Details`), [`${templatePath}/edit`]: i18n._(t`Edit Details`), @@ -40,16 +52,21 @@ function Templates({ i18n }) { [schedulesPath]: i18n._(t`Schedules`), [`${schedulesPath}/add`]: i18n._(t`Create New Schedule`), [`${schedulesPath}/${schedule?.id}`]: `${schedule?.name}`, - [`${schedulesPath}/details`]: i18n._(t`Schedule Details`), - [`${schedulesPath}/edit`]: i18n._(t`Edit Details`), + [`${schedulesPath}/${schedule?.id}/details`]: i18n._( + t`Schedule Details` + ), + [`${schedulesPath}/${schedule?.id}/edit`]: i18n._(t`Edit Schedule`), }); }, - [i18n] + [i18n, template, schedule] ); return ( <> - + diff --git a/awx/ui_next/src/screens/Template/Templates.test.jsx b/awx/ui_next/src/screens/Template/Templates.test.jsx index f39643053d..1b126cda31 100644 --- a/awx/ui_next/src/screens/Template/Templates.test.jsx +++ b/awx/ui_next/src/screens/Template/Templates.test.jsx @@ -3,6 +3,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Templates from './Templates'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let pageWrapper; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx index 2b57effb35..a0aeb1b184 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx @@ -44,7 +44,7 @@ function NodeModalForm({ }) { const history = useHistory(); const dispatch = useContext(WorkflowDispatchContext); - const { values, setTouched, validateForm } = useFormikContext(); + const { values, setFieldTouched } = useFormikContext(); const [triggerNext, setTriggerNext] = useState(0); @@ -60,6 +60,7 @@ function NodeModalForm({ const { steps: promptSteps, + validateStep, visitStep, visitAllSteps, contentError, @@ -192,24 +193,27 @@ function NodeModalForm({ onSave={() => { handleSaveNode(); }} + onBack={async nextStep => { + validateStep(nextStep.id); + }} onGoToStep={async (nextStep, prevStep) => { if (nextStep.id === 'preview') { - visitAllSteps(setTouched); + visitAllSteps(setFieldTouched); } else { - visitStep(prevStep.prevId); + visitStep(prevStep.prevId, setFieldTouched); + validateStep(nextStep.id); } - await validateForm(); }} steps={promptSteps} css="overflow: scroll" title={title} onNext={async (nextStep, prevStep) => { if (nextStep.id === 'preview') { - visitAllSteps(setTouched); + visitAllSteps(setFieldTouched); } else { - visitStep(prevStep.prevId); + visitStep(prevStep.prevId, setFieldTouched); + validateStep(nextStep.id); } - await validateForm(); }} /> ); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx index 2b0dcd888d..787c30674d 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx @@ -17,12 +17,11 @@ export default function useNodeTypeStep(i18n) { initialValues: getInitialValues(), isReady: true, contentError: null, - formError: meta.error, - setTouched: setFieldsTouched => { - setFieldsTouched({ - inventory: true, - }); + hasError: !!meta.error, + setTouched: setFieldTouched => { + setFieldTouched('nodeType', true, false); }, + validate: () => {}, }; } function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx index 2e117da77e..2ee6098fb7 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx @@ -14,12 +14,11 @@ export default function useRunTypeStep(i18n, askLinkType) { initialValues: askLinkType ? { linkType: 'success' } : {}, isReady: true, contentError: null, - formError: meta.error, - setTouched: setFieldsTouched => { - setFieldsTouched({ - inventory: true, - }); + hasError: !!meta.error, + setTouched: setFieldTouched => { + setFieldTouched('linkType', true, false); }, + validate: () => {}, }; } function getStep(askLinkType, meta, i18n) { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js index bd8a8c74e4..b6a80d72f2 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js @@ -194,7 +194,8 @@ export default function useWorkflowNodeSteps( useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited), ]; - const hasErrors = steps.some(step => step.formError); + const hasErrors = steps.some(step => step.hasError); + steps.push( usePreviewStep( launchConfig, @@ -250,12 +251,17 @@ export default function useWorkflowNodeSteps( return { steps: pfSteps, - visitStep: stepId => + validateStep: stepId => { + steps.find(s => s?.step?.id === stepId).validate(); + }, + visitStep: (prevStepId, setFieldTouched) => { setVisited({ ...visited, - [stepId]: true, - }), - visitAllSteps: setFieldsTouched => { + [prevStepId]: true, + }); + steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched); + }, + visitAllSteps: setFieldTouched => { setVisited({ inventory: true, credentials: true, @@ -263,7 +269,7 @@ export default function useWorkflowNodeSteps( survey: true, preview: true, }); - steps.forEach(s => s.setTouched(setFieldsTouched)); + steps.forEach(s => s.setTouched(setFieldTouched)); }, contentError, }; diff --git a/awx/ui_next/src/screens/User/Users.jsx b/awx/ui_next/src/screens/User/Users.jsx index 35e4c4517b..323e8e37a4 100644 --- a/awx/ui_next/src/screens/User/Users.jsx +++ b/awx/ui_next/src/screens/User/Users.jsx @@ -3,7 +3,7 @@ import { Route, useRouteMatch, Switch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; import { Config } from '../../contexts/Config'; import UsersList from './UserList/UserList'; @@ -46,7 +46,7 @@ function Users({ i18n }) { ); return ( - + diff --git a/awx/ui_next/src/screens/User/Users.test.jsx b/awx/ui_next/src/screens/User/Users.test.jsx index 7193234f8f..862934e99b 100644 --- a/awx/ui_next/src/screens/User/Users.test.jsx +++ b/awx/ui_next/src/screens/User/Users.test.jsx @@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Users from './Users'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { test('initially renders successfully', () => { mountWithContexts(); @@ -27,7 +31,7 @@ describe('', () => { }, }, }); - expect(wrapper.find('BreadcrumbHeading').length).toBe(1); + expect(wrapper.find('Title').length).toBe(1); wrapper.unmount(); }); }); diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.jsx index a8d66ccdba..84654810fa 100644 --- a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.jsx +++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.jsx @@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import WorkflowApprovalList from './WorkflowApprovalList'; import WorkflowApproval from './WorkflowApproval'; -import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; function WorkflowApprovals({ i18n }) { const match = useRouteMatch(); @@ -26,7 +26,10 @@ function WorkflowApprovals({ i18n }) { return ( <> - + diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.test.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.test.jsx index b5bfdcf2a0..45aaffa7bf 100644 --- a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.test.jsx +++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.test.jsx @@ -3,6 +3,10 @@ import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import WorkflowApprovals from './WorkflowApprovals'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { test('initially renders succesfully', () => { mountWithContexts(); @@ -29,7 +33,8 @@ describe('', () => { }, }, }); - expect(wrapper.find('BreadcrumbHeading').length).toBe(1); + + expect(wrapper.find('Title').length).toBe(1); wrapper.unmount(); }); }); diff --git a/awx/ui_next/src/util/qs.js b/awx/ui_next/src/util/qs.js index c02e3be075..729a28790d 100644 --- a/awx/ui_next/src/util/qs.js +++ b/awx/ui_next/src/util/qs.js @@ -118,15 +118,20 @@ function encodeValue(key, value) { * removing defaults. Used to put into url bar after ui route * @param {object} qs config object for namespacing params, filtering defaults * @param {object} query param object + * @param {object} any non-namespaced params to append * @return {string} url query string */ -export const encodeNonDefaultQueryString = (config, params) => { +export const encodeNonDefaultQueryString = ( + config, + params, + nonNamespacedParams = {} +) => { if (!params) return ''; - const paramsWithoutDefaults = removeParams({}, params, config.defaultParams); - return encodeQueryString( - namespaceParams(config.namespace, paramsWithoutDefaults) - ); + return encodeQueryString({ + ...namespaceParams(config.namespace, paramsWithoutDefaults), + ...nonNamespacedParams, + }); }; /** diff --git a/awx_collection/plugins/modules/tower_workflow_job_template.py b/awx_collection/plugins/modules/tower_workflow_job_template.py index 73435894b2..f6d972e76f 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template.py @@ -94,10 +94,12 @@ options: - Setting that variable will prompt the user for job type on the workflow launch. type: bool - survey: + survey_spec: description: - The definition of the survey associated to the workflow. type: dict + aliases: + - survey labels: description: - The labels applied to this job template @@ -149,7 +151,7 @@ import json def update_survey(module, last_request): spec_endpoint = last_request.get('related', {}).get('survey_spec') - module.post_endpoint(spec_endpoint, **{'data': module.params.get('survey')}) + module.post_endpoint(spec_endpoint, **{'data': module.params.get('survey_spec')}) module.exit_json(**module.json_output) @@ -161,7 +163,7 @@ def main(): description=dict(), extra_vars=dict(type='dict'), organization=dict(), - survey=dict(type='dict'), # special handling + survey_spec=dict(type='dict', aliases=['survey']), survey_enabled=dict(type='bool'), allow_simultaneous=dict(type='bool'), ask_variables_on_launch=dict(type='bool'), @@ -266,7 +268,7 @@ def main(): # association_fields['labels'].append(label_id) on_change = None - new_spec = module.params.get('survey') + new_spec = module.params.get('survey_spec') if new_spec: existing_spec = None if existing_item: diff --git a/awx_collection/test/awx/test_completeness.py b/awx_collection/test/awx/test_completeness.py index 08bbd8b0ad..922cc785bc 100644 --- a/awx_collection/test/awx/test_completeness.py +++ b/awx_collection/test/awx/test_completeness.py @@ -47,7 +47,7 @@ no_api_parameter_ok = { # Organization is how we are looking up job templates, Approval node is for workflow_approval_templates 'tower_workflow_job_template_node': ['organization', 'approval_node'], # Survey is how we handle associations - 'tower_workflow_job_template': ['survey'], + 'tower_workflow_job_template': ['survey_spec'], # ad hoc commands support interval and timeout since its more like tower_job_launc 'tower_ad_hoc_command': ['interval', 'timeout', 'wait'], } diff --git a/awx_collection/test/awx/test_workflow_job_template.py b/awx_collection/test/awx/test_workflow_job_template.py index bc2a44b11e..a125b01249 100644 --- a/awx_collection/test/awx/test_workflow_job_template.py +++ b/awx_collection/test/awx/test_workflow_job_template.py @@ -12,7 +12,7 @@ def test_create_workflow_job_template(run_module, admin_user, organization, surv 'name': 'foo-workflow', 'organization': organization.name, 'extra_vars': {'foo': 'bar', 'another-foo': {'barz': 'bar2'}}, - 'survey': survey_spec, + 'survey_spec': survey_spec, 'survey_enabled': True, 'state': 'present' }, admin_user) @@ -72,7 +72,7 @@ def test_survey_spec_only_changed(run_module, admin_user, organization, survey_s result = run_module('tower_workflow_job_template', { 'name': 'foo-workflow', 'organization': organization.name, - 'survey': survey_spec, + 'survey_spec': survey_spec, 'state': 'present' }, admin_user) assert not result.get('failed', False), result.get('msg', result) diff --git a/awxkit/VERSION b/awxkit/VERSION index 946789e619..aac58983e6 100644 --- a/awxkit/VERSION +++ b/awxkit/VERSION @@ -1 +1 @@ -16.0.0 +17.0.0 diff --git a/installer/inventory b/installer/inventory index d4596f5d96..289bfdcb53 100644 --- a/installer/inventory +++ b/installer/inventory @@ -104,7 +104,7 @@ pg_port=5432 # This will create or update a default admin (superuser) account in AWX, if not provided # then these default values are used admin_user=admin -admin_password=password +# admin_password=password # Whether or not to create preload data for demonstration purposes create_preload_data=True diff --git a/installer/roles/check_vars/tasks/main.yml b/installer/roles/check_vars/tasks/main.yml index 3699d4e4f6..61ffa1cb77 100644 --- a/installer/roles/check_vars/tasks/main.yml +++ b/installer/roles/check_vars/tasks/main.yml @@ -1,5 +1,11 @@ # main.yml --- +- name: admin_password should be defined + assert: + that: + - admin_password is defined and admin_password != '' + msg: "Set the value of 'admin_password' in the inventory file." + - include_tasks: check_openshift.yml when: openshift_host is defined and openshift_host != '' diff --git a/installer/roles/image_build/templates/Dockerfile.j2 b/installer/roles/image_build/templates/Dockerfile.j2 index 7572c6219f..fc3424abf4 100644 --- a/installer/roles/image_build/templates/Dockerfile.j2 +++ b/installer/roles/image_build/templates/Dockerfile.j2 @@ -148,7 +148,7 @@ RUN cd /usr/local/bin && \ {% if build_dev|bool %} # Install development/test requirements -RUN dnf --enablerepo=debuginfo -y install \ +RUN dnf -y install \ gdb \ gtk3 \ gettext \ @@ -162,13 +162,17 @@ RUN dnf --enablerepo=debuginfo -y install \ nss \ make \ patch \ - python3-debuginfo \ socat \ tmux \ wget \ diffutils \ unzip && \ npm install -g n && n 14.15.1 && dnf remove -y nodejs + +# This package randomly fails to download. +# It is nice to have in the dev env, but not necessary. +# Add it back to the list above if the repo ever straighten up. +RUN dnf --enablerepo=debuginfo -y install python3-debuginfo || : {% endif %} # Copy app from builder @@ -214,6 +218,7 @@ RUN for dir in \ /var/run/awx-rsyslog \ /var/log/tower \ /var/log/nginx \ + /var/lib/postgresql \ /var/run/supervisor \ /var/lib/nginx ; \ do mkdir -m 0775 -p $dir ; chmod g+rw $dir ; chgrp root $dir ; done && \ diff --git a/installer/roles/local_docker/defaults/main.yml b/installer/roles/local_docker/defaults/main.yml index 03742f5e14..4b97d47196 100644 --- a/installer/roles/local_docker/defaults/main.yml +++ b/installer/roles/local_docker/defaults/main.yml @@ -8,3 +8,4 @@ postgresql_version: "12" postgresql_image: "postgres:{{postgresql_version}}" compose_start_containers: true +upgrade_postgres: false diff --git a/installer/roles/local_docker/tasks/compose.yml b/installer/roles/local_docker/tasks/compose.yml index e2f0fc6663..59ba2623ff 100644 --- a/installer/roles/local_docker/tasks/compose.yml +++ b/installer/roles/local_docker/tasks/compose.yml @@ -36,6 +36,17 @@ register: awx_secret_key - block: + - name: Remove AWX containers before migrating postgres so that the old postgres container does not get used + docker_compose: + project_src: "{{ docker_compose_dir }}" + state: absent + ignore_errors: true + + - name: Run migrations in task container + shell: docker-compose run --rm --service-ports task awx-manage migrate --no-input + args: + chdir: "{{ docker_compose_dir }}" + - name: Start the containers docker_compose: project_src: "{{ docker_compose_dir }}" @@ -50,6 +61,11 @@ command: docker exec awx_task '/usr/bin/update-ca-trust' when: awx_compose_config.changed or awx_compose_start.changed + - name: Wait for launch script to create user + wait_for: + timeout: 10 + delegate_to: localhost + - name: Create Preload data command: docker exec awx_task bash -c "/usr/bin/awx-manage create_preload_data" when: create_preload_data|bool diff --git a/installer/roles/local_docker/tasks/upgrade_postgres.yml b/installer/roles/local_docker/tasks/upgrade_postgres.yml index 0a2b3afd33..d7655d7f75 100644 --- a/installer/roles/local_docker/tasks/upgrade_postgres.yml +++ b/installer/roles/local_docker/tasks/upgrade_postgres.yml @@ -1,29 +1,38 @@ --- -- name: Check for existing Postgres data - stat: - path: "{{ postgres_data_dir }}/pgdata/PG_VERSION" + +- name: Get full path of postgres data dir + shell: "echo {{ postgres_data_dir }}" + register: fq_postgres_data_dir + +- name: Register temporary docker container + set_fact: + container_command: "docker run --rm -v '{{ fq_postgres_data_dir.stdout }}:/var/lib/postgresql' centos:8 bash -c " + +- name: Check for existing Postgres data (run from inside the container for access to file) + shell: + cmd: | + {{ container_command }} "[[ -f /var/lib/postgresql/10/data/PG_VERSION ]] && echo 'exists'" register: pg_version_file + ignore_errors: true - name: Record Postgres version - set_fact: - old_pg_version: "{{ lookup('file', postgres_data_dir + '/pgdata/PG_VERSION') }}" - when: pg_version_file.stat.exists + shell: | + {{ container_command }} "cat /var/lib/postgresql/10/data/PG_VERSION" + register: old_pg_version + when: pg_version_file is defined and pg_version_file.stdout == 'exists' - name: Determine whether to upgrade postgres set_fact: - upgrade_postgres: "{{ old_pg_version is defined and old_pg_version == '9.6' }}" + upgrade_postgres: "{{ old_pg_version.stdout == '10' }}" + when: old_pg_version.changed - name: Set up new postgres paths pre-upgrade - file: - state: directory - path: "{{ item }}" - recurse: true + shell: | + {{ container_command }} "mkdir -p /var/lib/postgresql/12/data/" when: upgrade_postgres | bool - with_items: - - "{{ postgres_data_dir }}/10/data" - name: Stop AWX before upgrading postgres - docker_service: + docker_compose: project_src: "{{ docker_compose_dir }}" stopped: true when: upgrade_postgres | bool @@ -38,13 +47,13 @@ when: upgrade_postgres | bool - name: Copy old pg_hba.conf - copy: - src: "{{ postgres_data_dir + '/pgdata/pg_hba.conf' }}" - dest: "{{ postgres_data_dir + '/12/data/' }}" + shell: | + {{ container_command }} "cp /var/lib/postgresql/10/data/pg_hba.conf /var/lib/postgresql/12/data/pg_hba.conf" when: upgrade_postgres | bool - name: Remove old data directory - file: - path: "{{ postgres_data_dir + '/10/data' }}" - state: absent - when: compose_start_containers|bool + shell: | + {{ container_command }} "rm -rf /var/lib/postgresql/10/data" + when: + - upgrade_postgres | bool + - compose_start_containers|bool diff --git a/installer/roles/local_docker/templates/docker-compose.yml.j2 b/installer/roles/local_docker/templates/docker-compose.yml.j2 index 03bb530485..b18aa83408 100644 --- a/installer/roles/local_docker/templates/docker-compose.yml.j2 +++ b/installer/roles/local_docker/templates/docker-compose.yml.j2 @@ -149,6 +149,7 @@ services: {% endfor %} {% endif %} environment: + AWX_SKIP_MIGRATIONS: "1" http_proxy: {{ http_proxy | default('') }} https_proxy: {{ https_proxy | default('') }} no_proxy: {{ no_proxy | default('') }} @@ -177,7 +178,7 @@ services: container_name: awx_postgres restart: unless-stopped volumes: - - "{{ postgres_data_dir }}/10/data/:/var/lib/postgresql/data:Z" + - "{{ postgres_data_dir }}/12/data/:/var/lib/postgresql/data:Z" environment: POSTGRES_USER: {{ pg_username }} POSTGRES_PASSWORD: {{ pg_password }}