From 57c3b9ab17e0353740e88f6c618f24292ffd8824 Mon Sep 17 00:00:00 2001 From: "Christian M. Adams" Date: Fri, 8 Jan 2021 16:36:47 -0500 Subject: [PATCH 01/41] Run PG upgrade tasks in container because of permissions * update pg references --- .../roles/image_build/templates/Dockerfile.j2 | 1 + .../roles/local_docker/defaults/main.yml | 1 + .../local_docker/tasks/upgrade_postgres.yml | 41 ++++++++----------- .../templates/docker-compose.yml.j2 | 2 +- 4 files changed, 20 insertions(+), 25 deletions(-) diff --git a/installer/roles/image_build/templates/Dockerfile.j2 b/installer/roles/image_build/templates/Dockerfile.j2 index 7572c6219f..fa4f9e35e8 100644 --- a/installer/roles/image_build/templates/Dockerfile.j2 +++ b/installer/roles/image_build/templates/Dockerfile.j2 @@ -214,6 +214,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/upgrade_postgres.yml b/installer/roles/local_docker/tasks/upgrade_postgres.yml index 0a2b3afd33..ed163605ac 100644 --- a/installer/roles/local_docker/tasks/upgrade_postgres.yml +++ b/installer/roles/local_docker/tasks/upgrade_postgres.yml @@ -1,26 +1,24 @@ --- -- name: Check for existing Postgres data - stat: - path: "{{ postgres_data_dir }}/pgdata/PG_VERSION" + +- name: Register temporary docker container + set_fact: + container_command: "docker run -v '{{ postgres_data_dir | realpath }}:/var/lib/postgresql' centos:8 bash " + +- 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.stdout == 'exists' - name: Determine whether to upgrade postgres set_fact: - upgrade_postgres: "{{ old_pg_version is defined and old_pg_version == '9.6' }}" - -- name: Set up new postgres paths pre-upgrade - file: - state: directory - path: "{{ item }}" - recurse: true - when: upgrade_postgres | bool - with_items: - - "{{ postgres_data_dir }}/10/data" + upgrade_postgres: "{{ old_pg_version is defined and old_pg_version.stdout == '10' | bool }}" + when: not old_pg_version.skipped | bool - name: Stop AWX before upgrading postgres docker_service: @@ -31,20 +29,15 @@ - name: Upgrade Postgres shell: | docker run --rm \ - -v {{ postgres_data_dir }}/10/data:/var/lib/postgresql/10/data \ - -v {{ postgres_data_dir }}/12/data:/var/lib/postgresql/12/data \ + -v {{ postgres_data_dir | realpath }}:/var/lib/postgresql \ -e PGUSER={{ pg_username }} -e POSTGRES_INITDB_ARGS="-U {{ pg_username }}" \ tianon/postgres-upgrade:10-to-12 --username={{ pg_username }} 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 + shell: "{{ container_command }} rm -rf /var/lib/postgresql/10/data" when: 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..f97e438984 100644 --- a/installer/roles/local_docker/templates/docker-compose.yml.j2 +++ b/installer/roles/local_docker/templates/docker-compose.yml.j2 @@ -177,7 +177,7 @@ services: container_name: awx_postgres restart: unless-stopped volumes: - - "{{ postgres_data_dir }}/10/data/:/var/lib/postgresql/data:Z" + - "{{ postgres_data_dir }}:/var/lib/postgresql:Z" environment: POSTGRES_USER: {{ pg_username }} POSTGRES_PASSWORD: {{ pg_password }} From 88a0d984478f7cf19b790b1b524d1545dcebb4ff Mon Sep 17 00:00:00 2001 From: "Christian M. Adams" Date: Wed, 13 Jan 2021 15:44:05 -0500 Subject: [PATCH 02/41] Run migrations via a task, not in the container * Issue: https://github.com/ansible/awx/issues/9077 * Fixes problem with migrations not completing --- installer/roles/local_docker/tasks/compose.yml | 10 ++++++++++ .../roles/local_docker/templates/docker-compose.yml.j2 | 1 + 2 files changed, 11 insertions(+) diff --git a/installer/roles/local_docker/tasks/compose.yml b/installer/roles/local_docker/tasks/compose.yml index e2f0fc6663..0366a5c0b9 100644 --- a/installer/roles/local_docker/tasks/compose.yml +++ b/installer/roles/local_docker/tasks/compose.yml @@ -36,6 +36,11 @@ register: awx_secret_key - block: + - 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 +55,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/templates/docker-compose.yml.j2 b/installer/roles/local_docker/templates/docker-compose.yml.j2 index f97e438984..a0b4a59bf0 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('') }} From dcb17739180fcf61c79ad7195b988c5ccea61876 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Thu, 3 Dec 2020 10:51:04 -0500 Subject: [PATCH 03/41] Add radius category setting edit form --- .../screens/Setting/RADIUS/RADIUS.test.jsx | 32 +++- .../Setting/RADIUS/RADIUSEdit/RADIUSEdit.jsx | 124 ++++++++++++--- .../RADIUS/RADIUSEdit/RADIUSEdit.test.jsx | 141 +++++++++++++++++- 3 files changed, 267 insertions(+), 30 deletions(-) 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); + }); }); From 62eae017cfb64af2ea687b239a5c6cd111cbcb77 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Thu, 3 Dec 2020 13:30:00 -0500 Subject: [PATCH 04/41] Add tacacs category setting edit form --- .../screens/Setting/TACACS/TACACS.test.jsx | 32 +++- .../Setting/TACACS/TACACSEdit/TACACSEdit.jsx | 141 ++++++++++++++-- .../TACACS/TACACSEdit/TACACSEdit.test.jsx | 158 +++++++++++++++++- 3 files changed, 302 insertions(+), 29 deletions(-) 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); + }); }); From 328435d4359ff082531bcb6658aff1841f3a80e9 Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Thu, 14 Jan 2021 15:37:59 -0600 Subject: [PATCH 05/41] update to survey option on workflow module. --- .../plugins/modules/tower_workflow_job_template.py | 10 ++++++---- awx_collection/test/awx/test_workflow_job_template.py | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/awx_collection/plugins/modules/tower_workflow_job_template.py b/awx_collection/plugins/modules/tower_workflow_job_template.py index 73435894b2..6bfa8fafe4 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'), # special handling 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_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) From b744c4ebb7df3b603ba5616cf5305487e6364c87 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 14 Jan 2021 14:43:54 -0500 Subject: [PATCH 06/41] further optimize callback receiver buffering for certain situations see: https://github.com/ansible/awx/issues/9085 --- awx/main/dispatch/worker/callback.py | 5 ++++- awx/settings/defaults.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) 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 From 73c46030bc7ce42cfc0051c0c4dfc5f5a526fefd Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Thu, 14 Jan 2021 16:19:39 -0600 Subject: [PATCH 07/41] update completeness --- awx_collection/test/awx/test_completeness.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'], } From 87604749b73f76d5700ea87d8a04121994ac1380 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 14 Dec 2020 12:01:58 -0500 Subject: [PATCH 08/41] Adds smart inventory button on host list --- .../src/screens/Host/HostList/HostList.jsx | 58 ++++++++++++++++++- .../screens/Host/HostList/HostList.test.jsx | 45 +++++++++++++- .../Inventory/shared/SmartInventoryForm.jsx | 17 +++++- .../shared/SmartInventoryForm.test.jsx | 24 ++++++++ 4 files changed, 137 insertions(+), 7 deletions(-) diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.jsx index 1f5e0833bf..d62bae0d32 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.jsx @@ -1,8 +1,8 @@ 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'; +import { Button, Card, PageSection, Tooltip } from '@patternfly/react-core'; import { HostsAPI } from '../../../api'; import AlertModal from '../../../components/AlertModal'; @@ -13,7 +13,11 @@ 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'; @@ -24,9 +28,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 +115,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 +181,34 @@ function HostList({ i18n }) { itemsToDelete={selected} pluralizedItemName={i18n._(t`Hosts`)} />, + ...(canAdd + ? [ + +
+ +
+
, + ] + : []), ]} /> )} 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/Inventory/shared/SmartInventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx index 9b82b25a8b..4d5805db64 100644 --- a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx @@ -2,7 +2,8 @@ import React, { useEffect, useCallback } from 'react'; import { Formik, useField, useFormikContext } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { func, shape, arrayOf } from 'prop-types'; +import { useLocation } from 'react-router-dom'; +import { func, shape, object, arrayOf } from 'prop-types'; import { Form } from '@patternfly/react-core'; import { InstanceGroup } from '../../../types'; import { VariablesField } from '../../../components/CodeMirrorInput'; @@ -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(() => From fa6de04e790638444be2f85811f23476b4a3aaf3 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 13 Jan 2021 14:00:17 -0500 Subject: [PATCH 09/41] Remove object import as its not used --- awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx index 4d5805db64..a851fbfdc4 100644 --- a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx @@ -3,7 +3,7 @@ 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, object, arrayOf } from 'prop-types'; +import { func, shape, arrayOf } from 'prop-types'; import { Form } from '@patternfly/react-core'; import { InstanceGroup } from '../../../types'; import { VariablesField } from '../../../components/CodeMirrorInput'; From bb2248cb2409dfdf56d5efdab89fde1ca2a4e1f8 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 14 Jan 2021 13:30:02 -0500 Subject: [PATCH 10/41] Adds tooltip around host filter lookup when an organization is not selected --- .../components/Lookup/HostFilterLookup.jsx | 67 ++++++++++++------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx b/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx index d1b15a8a06..78a5f3fd6d 100644 --- a/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx +++ b/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx @@ -12,6 +12,7 @@ import { FormGroup, InputGroup, Modal, + Tooltip, } from '@patternfly/react-core'; import ChipGroup from '../ChipGroup'; import Popover from '../Popover'; @@ -243,6 +244,36 @@ function HostFilterLookup({ }); }; + const renderLookup = () => ( + + + + {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() + )} Date: Fri, 15 Jan 2021 14:50:41 -0500 Subject: [PATCH 11/41] Move smart inventory button out to it's own component to properly handle kebabification --- .../src/screens/Host/HostList/HostList.jsx | 31 +++-------- .../Host/HostList/SmartInventoryButton.jsx | 53 +++++++++++++++++++ .../HostList/SmartInventoryButton.test.jsx | 16 ++++++ 3 files changed, 75 insertions(+), 25 deletions(-) create mode 100644 awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.jsx create mode 100644 awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.test.jsx diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.jsx index d62bae0d32..0fabce1b60 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.jsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useHistory, useLocation, useRouteMatch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Button, Card, PageSection, Tooltip } from '@patternfly/react-core'; +import { Card, PageSection } from '@patternfly/react-core'; import { HostsAPI } from '../../../api'; import AlertModal from '../../../components/AlertModal'; @@ -20,6 +20,7 @@ import { } from '../../../util/qs'; import HostListItem from './HostListItem'; +import SmartInventoryButton from './SmartInventoryButton'; const QS_CONFIG = getQSConfig('host', { page: 1, @@ -183,30 +184,10 @@ function HostList({ i18n }) { />, ...(canAdd ? [ - -
- -
-
, + handleSmartInventoryClick()} + />, ] : []), ]} 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(); + }); +}); From 74d1859552ac5bdd8df8a4f45b156fc7873b240d Mon Sep 17 00:00:00 2001 From: sean-m-sullivan Date: Mon, 18 Jan 2021 09:12:30 -0600 Subject: [PATCH 12/41] add alias --- awx_collection/plugins/modules/tower_workflow_job_template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx_collection/plugins/modules/tower_workflow_job_template.py b/awx_collection/plugins/modules/tower_workflow_job_template.py index 6bfa8fafe4..f6d972e76f 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template.py @@ -163,7 +163,7 @@ def main(): description=dict(), extra_vars=dict(type='dict'), organization=dict(), - survey_spec=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'), From 9d53bab050df3e28e45e1021b4cb4f7ded18ec4b Mon Sep 17 00:00:00 2001 From: cl1ent Date: Tue, 19 Jan 2021 13:17:24 +0100 Subject: [PATCH 13/41] fixed default location for docker_compose_dir variable --- INSTALL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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* From 448e49ae43177b7b28b3cf7c96fe5922d8084682 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 6 Jan 2021 13:17:49 -0500 Subject: [PATCH 14/41] Add support for password prompting on job launch --- .../components/FormField/PasswordField.jsx | 2 +- .../components/FormField/PasswordInput.jsx | 13 +- .../components/LaunchButton/LaunchButton.jsx | 2 + .../components/LaunchPrompt/LaunchPrompt.jsx | 25 +- .../LaunchPrompt/LaunchPrompt.test.jsx | 29 +- .../steps/CredentialPasswordsStep.jsx | 129 ++++ .../steps/CredentialPasswordsStep.test.jsx | 603 ++++++++++++++++++ .../LaunchPrompt/steps/InventoryStep.jsx | 77 ++- .../steps/useCredentialPasswordsStep.jsx | 342 ++++++++++ .../LaunchPrompt/steps/useCredentialsStep.jsx | 10 +- .../LaunchPrompt/steps/useInventoryStep.jsx | 21 +- .../steps/useOtherPromptsStep.jsx | 23 +- .../LaunchPrompt/steps/usePreviewStep.jsx | 2 +- .../LaunchPrompt/steps/useSurveyStep.jsx | 159 ++--- .../components/LaunchPrompt/useLaunchSteps.js | 76 ++- .../Modals/NodeModals/NodeModal.jsx | 18 +- .../NodeTypeStep/useNodeTypeStep.jsx | 9 +- .../Modals/NodeModals/useRunTypeStep.jsx | 9 +- .../Modals/NodeModals/useWorkflowNodeSteps.js | 18 +- .../src/util/prompt/getCredentialPasswords.js | 29 + .../prompt/getCredentialPasswords.test.js | 66 ++ 21 files changed, 1481 insertions(+), 181 deletions(-) create mode 100644 awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.jsx create mode 100644 awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.test.jsx create mode 100644 awx/ui_next/src/components/LaunchPrompt/steps/useCredentialPasswordsStep.jsx create mode 100644 awx/ui_next/src/util/prompt/getCredentialPasswords.js create mode 100644 awx/ui_next/src/util/prompt/getCredentialPasswords.test.js diff --git a/awx/ui_next/src/components/FormField/PasswordField.jsx b/awx/ui_next/src/components/FormField/PasswordField.jsx index 44ebe5f889..fcba330c8c 100644 --- a/awx/ui_next/src/components/FormField/PasswordField.jsx +++ b/awx/ui_next/src/components/FormField/PasswordField.jsx @@ -8,7 +8,7 @@ import PasswordInput from './PasswordInput'; function PasswordField(props) { const { id, name, label, validate, isRequired, helperText } = props; const [, meta] = useField({ name, validate }); - const isValid = !(meta.touched && meta.error); + const isValid = !meta.touched || (meta.value && meta.value !== ''); return ( {}, 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..06c1fce0b6 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) ); diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index 00074a3e87..89b348d62b 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -7,6 +7,7 @@ import ContentError from '../ContentError'; import ContentLoading from '../ContentLoading'; import { useDismissableError } from '../../util/useRequest'; import mergeExtraVars from '../../util/prompt/mergeExtraVars'; +import getCredentialPasswords from '../../util/prompt/getCredentialPasswords'; import getSurveyValues from '../../util/prompt/getSurveyValues'; import useLaunchSteps from './useLaunchSteps'; import AlertModal from '../AlertModal'; @@ -19,17 +20,18 @@ function PromptModalForm({ resource, surveyConfig, }) { - const { values, setTouched, validateForm } = useFormikContext(); + const { setFieldTouched, values } = useFormikContext(); const { steps, isReady, + validateStep, visitStep, visitAllSteps, contentError, } = useLaunchSteps(launchConfig, surveyConfig, resource, i18n); - const handleSave = () => { + const handleSubmit = () => { const postValues = {}; const setValue = (key, value) => { if (typeof value !== 'undefined' && value !== null) { @@ -37,6 +39,8 @@ function PromptModalForm({ } }; const surveyValues = getSurveyValues(values); + const credentialPasswords = getCredentialPasswords(values); + setValue('credential_passwords', credentialPasswords); setValue('inventory_id', values.inventory?.id); setValue( 'credentials', @@ -75,22 +79,25 @@ function PromptModalForm({ { + validateStep(nextStep.id); + }} 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(); }} 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(); }} title={i18n._(t`Prompts`)} steps={ diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx index c320aac30b..08b777d12d 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx @@ -82,8 +82,26 @@ describe('LaunchPrompt', () => { ask_credential_on_launch: true, ask_scm_branch_on_launch: true, survey_enabled: true, + passwords_needed_to_start: ['ssh_password'], + defaults: { + credentials: [ + { + id: 1, + passwords_needed: ['ssh_password'], + }, + ], + }, + }} + resource={{ + ...resource, + summary_fields: { + credentials: [ + { + id: 1, + }, + ], + }, }} - resource={resource} onLaunch={noop} onCancel={noop} surveyConfig={{ @@ -110,12 +128,13 @@ describe('LaunchPrompt', () => { const wizard = await waitForElement(wrapper, 'Wizard'); const steps = wizard.prop('steps'); - expect(steps).toHaveLength(5); + expect(steps).toHaveLength(6); expect(steps[0].name.props.children).toEqual('Inventory'); expect(steps[1].name.props.children).toEqual('Credentials'); - expect(steps[2].name.props.children).toEqual('Other prompts'); - expect(steps[3].name.props.children).toEqual('Survey'); - expect(steps[4].name.props.children).toEqual('Preview'); + expect(steps[2].name.props.children).toEqual('Credential passwords'); + expect(steps[3].name.props.children).toEqual('Other prompts'); + expect(steps[4].name.props.children).toEqual('Survey'); + expect(steps[5].name.props.children).toEqual('Preview'); }); test('should add inventory step', async () => { diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.jsx new file mode 100644 index 0000000000..ac17ce1071 --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.jsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Form } from '@patternfly/react-core'; +import { useFormikContext } from 'formik'; +import { PasswordField } from '../../FormField'; + +function CredentialPasswordsStep({ launchConfig, i18n }) { + const { + values: { credentials }, + } = useFormikContext(); + + const vaultsThatPrompt = []; + let showcredentialPasswordSsh = false; + let showcredentialPasswordPrivilegeEscalation = false; + let showcredentialPasswordPrivateKeyPassphrase = false; + + if ( + !launchConfig.ask_credential_on_launch && + launchConfig.passwords_needed_to_start + ) { + launchConfig.passwords_needed_to_start.forEach(password => { + if (password === 'ssh_password') { + showcredentialPasswordSsh = true; + } else if (password === 'become_password') { + showcredentialPasswordPrivilegeEscalation = true; + } else if (password === 'ssh_key_unlock') { + showcredentialPasswordPrivateKeyPassphrase = true; + } else if (password.startsWith('vault_password')) { + const vaultId = password.split(/\.(.+)/)[1] || ''; + vaultsThatPrompt.push(vaultId); + } + }); + } else if (credentials) { + credentials.forEach(credential => { + if (!credential.inputs) { + const launchConfigCredential = launchConfig.defaults.credentials.find( + defaultCred => defaultCred.id === credential.id + ); + + if (launchConfigCredential?.passwords_needed.length > 0) { + if ( + launchConfigCredential.passwords_needed.includes('ssh_password') + ) { + showcredentialPasswordSsh = true; + } + if ( + launchConfigCredential.passwords_needed.includes('become_password') + ) { + showcredentialPasswordPrivilegeEscalation = true; + } + if ( + launchConfigCredential.passwords_needed.includes('ssh_key_unlock') + ) { + showcredentialPasswordPrivateKeyPassphrase = true; + } + + const vaultPasswordIds = launchConfigCredential.passwords_needed + .filter(passwordNeeded => + passwordNeeded.startsWith('vault_password') + ) + .map(vaultPassword => vaultPassword.split(/\.(.+)/)[1] || ''); + + vaultsThatPrompt.push(...vaultPasswordIds); + } + } else { + if (credential?.inputs?.password === 'ASK') { + showcredentialPasswordSsh = true; + } + + if (credential?.inputs?.become_password === 'ASK') { + showcredentialPasswordPrivilegeEscalation = true; + } + + if (credential?.inputs?.ssh_key_unlock === 'ASK') { + showcredentialPasswordPrivateKeyPassphrase = true; + } + + if (credential?.inputs?.vault_password === 'ASK') { + vaultsThatPrompt.push(credential.inputs.vault_id); + } + } + }); + } + + return ( +
+ {showcredentialPasswordSsh && ( + + )} + {showcredentialPasswordPrivateKeyPassphrase && ( + + )} + {showcredentialPasswordPrivilegeEscalation && ( + + )} + {vaultsThatPrompt.map(credId => ( + + ))} + + ); +} + +export default withI18n()(CredentialPasswordsStep); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.test.jsx new file mode 100644 index 0000000000..a9c63cce63 --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.test.jsx @@ -0,0 +1,603 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import CredentialPasswordsStep from './CredentialPasswordsStep'; + +describe('CredentialPasswordsStep', () => { + describe('JT default credentials (no credential replacement) and creds are promptable', () => { + test('should render ssh password field when JT has default machine cred', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render become password field when JT has default machine cred', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render private key passphrase field when JT has default machine cred', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render vault password field when JT has default vault cred', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-vault-password-1') + ).toHaveLength(1); + }); + test('should render all password field when JT has default vault cred and machine cred', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-vault-password-1') + ).toHaveLength(1); + }); + }); + describe('Credentials have been replaced and creds are promptable', () => { + test('should render ssh password field when replacement machine cred prompts for it', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render become password field when replacement machine cred prompts for it', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render private key passphrase field when replacement machine cred prompts for it', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render vault password field when replacement vault cred prompts for it', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-vault-password-foobar') + ).toHaveLength(1); + }); + test('should render all password fields when replacement vault and machine creds prompt for it', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-vault-password-foobar') + ).toHaveLength(1); + }); + }); + describe('Credentials have been replaced and creds are not promptable', () => { + test('should render ssh password field when required', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render become password field when required', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render private key passphrase field when required', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render vault password field when required', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-vault-password-foobar') + ).toHaveLength(1); + }); + test('should render all password fields when required', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-vault-password-foobar') + ).toHaveLength(1); + }); + }); +}); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx index 696809f547..06760f6809 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx @@ -3,6 +3,7 @@ import { useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { useField } from 'formik'; +import { Alert } from '@patternfly/react-core'; import { InventoriesAPI } from '../../../api'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import useRequest from '../../../util/useRequest'; @@ -17,9 +18,10 @@ const QS_CONFIG = getQSConfig('inventory', { }); function InventoryStep({ i18n }) { - const [field, , helpers] = useField({ + const [field, meta, helpers] = useField({ name: 'inventory', }); + const history = useHistory(); const { @@ -65,40 +67,45 @@ function InventoryStep({ i18n }) { } return ( - field.onChange(null)} - /> + <> + field.onChange(null)} + /> + {meta.touched && meta.error && ( + + )} + ); } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialPasswordsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialPasswordsStep.jsx new file mode 100644 index 0000000000..10b2193722 --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialPasswordsStep.jsx @@ -0,0 +1,342 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import { useFormikContext } from 'formik'; +import CredentialPasswordsStep from './CredentialPasswordsStep'; +import StepName from './StepName'; + +const STEP_ID = 'credentialPasswords'; + +const isValueMissing = val => { + return !val || val === ''; +}; + +export default function useCredentialPasswordsStep( + launchConfig, + i18n, + showStep, + visitedSteps +) { + const { values, setFieldError } = useFormikContext(); + const hasError = + Object.keys(visitedSteps).includes(STEP_ID) && + checkForError(launchConfig, values); + + return { + step: showStep + ? { + id: STEP_ID, + name: ( + + {i18n._(t`Credential passwords`)} + + ), + component: ( + + ), + enableNext: true, + } + : null, + initialValues: getInitialValues(launchConfig, values.credentials), + isReady: true, + contentError: null, + hasError, + setTouched: setFieldTouched => { + Object.keys(values) + .filter(valueKey => valueKey.startsWith('credentialPassword')) + .forEach(credentialValueKey => + setFieldTouched(credentialValueKey, true, false) + ); + }, + validate: () => { + const setPasswordFieldError = fieldName => { + setFieldError(fieldName, i18n._(t`This field may not be blank`)); + }; + + const { + credentialPasswordSsh, + credentialPasswordPrivilegeEscalation, + credentialPasswordPrivateKeyPassphrase, + } = values; + + if ( + !launchConfig.ask_credential_on_launch && + launchConfig.passwords_needed_to_start + ) { + launchConfig.passwords_needed_to_start.forEach(password => { + if ( + password === 'ssh_password' && + isValueMissing(credentialPasswordSsh) + ) { + setPasswordFieldError('credentialPasswordSsh'); + } else if ( + password === 'become_password' && + isValueMissing(credentialPasswordPrivilegeEscalation) + ) { + setPasswordFieldError('credentialPasswordPrivilegeEscalation'); + } else if ( + password === 'ssh_key_unlock' && + isValueMissing(credentialPasswordPrivateKeyPassphrase) + ) { + setPasswordFieldError('credentialPasswordPrivateKeyPassphrase'); + } else if (password.startsWith('vault_password')) { + const vaultId = password.split(/\.(.+)/)[1] || ''; + if (isValueMissing(values[`credentialPasswordVault_${vaultId}`])) { + setPasswordFieldError(`credentialPasswordVault_${vaultId}`); + } + } + }); + } else if (values.credentials) { + values.credentials.forEach(credential => { + if (!credential.inputs) { + const launchConfigCredential = launchConfig.defaults.credentials.find( + defaultCred => defaultCred.id === credential.id + ); + + if (launchConfigCredential?.passwords_needed.length > 0) { + if ( + launchConfigCredential.passwords_needed.includes( + 'ssh_password' + ) && + isValueMissing(credentialPasswordSsh) + ) { + setPasswordFieldError('credentialPasswordSsh'); + } + if ( + launchConfigCredential.passwords_needed.includes( + 'become_password' + ) && + isValueMissing(credentialPasswordPrivilegeEscalation) + ) { + setPasswordFieldError('credentialPasswordPrivilegeEscalation'); + } + if ( + launchConfigCredential.passwords_needed.includes( + 'ssh_key_unlock' + ) && + isValueMissing(credentialPasswordPrivateKeyPassphrase) + ) { + setPasswordFieldError('credentialPasswordPrivateKeyPassphrase'); + } + + launchConfigCredential.passwords_needed + .filter(passwordNeeded => + passwordNeeded.startsWith('vault_password') + ) + .map(vaultPassword => vaultPassword.split(/\.(.+)/)[1] || '') + .forEach(vaultId => { + if ( + isValueMissing(values[`credentialPasswordVault_${vaultId}`]) + ) { + setPasswordFieldError(`credentialPasswordVault_${vaultId}`); + } + }); + } + } else { + if ( + credential?.inputs?.password === 'ASK' && + isValueMissing(credentialPasswordSsh) + ) { + setPasswordFieldError('credentialPasswordSsh'); + } + + if ( + credential?.inputs?.become_password === 'ASK' && + isValueMissing(credentialPasswordPrivilegeEscalation) + ) { + setPasswordFieldError('credentialPasswordPrivilegeEscalation'); + } + + if ( + credential?.inputs?.ssh_key_unlock === 'ASK' && + isValueMissing(credentialPasswordPrivateKeyPassphrase) + ) { + setPasswordFieldError('credentialPasswordPrivateKeyPassphrase'); + } + + if ( + credential?.inputs?.vault_password === 'ASK' && + isValueMissing( + values[`credentialPasswordVault_${credential.inputs.vault_id}`] + ) + ) { + setPasswordFieldError( + `credentialPasswordVault_${credential.inputs.vault_id}` + ); + } + } + }); + } + }, + }; +} + +function getInitialValues(launchConfig, selectedCredentials = []) { + const initialValues = {}; + + if (!launchConfig) { + return initialValues; + } + + if ( + !launchConfig.ask_credential_on_launch && + launchConfig.passwords_needed_to_start + ) { + launchConfig.passwords_needed_to_start.forEach(password => { + if (password === 'ssh_password') { + initialValues.credentialPasswordSsh = ''; + } else if (password === 'become_password') { + initialValues.credentialPasswordPrivilegeEscalation = ''; + } else if (password === 'ssh_key_unlock') { + initialValues.credentialPasswordPrivateKeyPassphrase = ''; + } else if (password.startsWith('vault_password')) { + const vaultId = password.split(/\.(.+)/)[1] || ''; + initialValues[`credentialPasswordVault_${vaultId}`] = ''; + } + }); + return initialValues; + } + + selectedCredentials.forEach(credential => { + if (!credential.inputs) { + const launchConfigCredential = launchConfig.defaults.credentials.find( + defaultCred => defaultCred.id === credential.id + ); + + if (launchConfigCredential?.passwords_needed.length > 0) { + if (launchConfigCredential.passwords_needed.includes('ssh_password')) { + initialValues.credentialPasswordSsh = ''; + } + if ( + launchConfigCredential.passwords_needed.includes('become_password') + ) { + initialValues.credentialPasswordPrivilegeEscalation = ''; + } + if ( + launchConfigCredential.passwords_needed.includes('ssh_key_unlock') + ) { + initialValues.credentialPasswordPrivateKeyPassphrase = ''; + } + + const vaultPasswordIds = launchConfigCredential.passwords_needed + .filter(passwordNeeded => passwordNeeded.startsWith('vault_password')) + .map(vaultPassword => vaultPassword.split(/\.(.+)/)[1] || ''); + + vaultPasswordIds.forEach(vaultPasswordId => { + initialValues[`credentialPasswordVault_${vaultPasswordId}`] = ''; + }); + } + } else { + if (credential?.inputs?.password === 'ASK') { + initialValues.credentialPasswordSsh = ''; + } + + if (credential?.inputs?.become_password === 'ASK') { + initialValues.credentialPasswordPrivilegeEscalation = ''; + } + + if (credential?.inputs?.ssh_key_unlock === 'ASK') { + initialValues.credentialPasswordPrivateKeyPassphrase = ''; + } + + if (credential?.inputs?.vault_password === 'ASK') { + initialValues[`credentialPasswordVault_${credential.inputs.vault_id}`] = + ''; + } + } + }); + + return initialValues; +} + +function checkForError(launchConfig, values) { + const { + credentialPasswordSsh, + credentialPasswordPrivilegeEscalation, + credentialPasswordPrivateKeyPassphrase, + } = values; + + let hasError = false; + + if ( + !launchConfig.ask_credential_on_launch && + launchConfig.passwords_needed_to_start + ) { + launchConfig.passwords_needed_to_start.forEach(password => { + if ( + (password === 'ssh_password' && + isValueMissing(credentialPasswordSsh)) || + (password === 'become_password' && + isValueMissing(credentialPasswordPrivilegeEscalation)) || + (password === 'ssh_key_unlock' && + isValueMissing(credentialPasswordPrivateKeyPassphrase)) + ) { + hasError = true; + } else if (password.startsWith('vault_password')) { + const vaultId = password.split(/\.(.+)/)[1] || ''; + if (isValueMissing(values[`credentialPasswordVault_${vaultId}`])) { + hasError = true; + } + } + }); + } else if (values.credentials) { + values.credentials.forEach(credential => { + if (!credential.inputs) { + const launchConfigCredential = launchConfig.defaults.credentials.find( + defaultCred => defaultCred.id === credential.id + ); + + if (launchConfigCredential?.passwords_needed.length > 0) { + if ( + (launchConfigCredential.passwords_needed.includes('ssh_password') && + isValueMissing(credentialPasswordSsh)) || + (launchConfigCredential.passwords_needed.includes( + 'become_password' + ) && + isValueMissing(credentialPasswordPrivilegeEscalation)) || + (launchConfigCredential.passwords_needed.includes( + 'ssh_key_unlock' + ) && + isValueMissing(credentialPasswordPrivateKeyPassphrase)) + ) { + hasError = true; + } + + launchConfigCredential.passwords_needed + .filter(passwordNeeded => + passwordNeeded.startsWith('vault_password') + ) + .map(vaultPassword => vaultPassword.split(/\.(.+)/)[1] || '') + .forEach(vaultId => { + if ( + isValueMissing(values[`credentialPasswordVault_${vaultId}`]) + ) { + hasError = true; + } + }); + } + } else { + if ( + (credential?.inputs?.password === 'ASK' && + isValueMissing(credentialPasswordSsh)) || + (credential?.inputs?.become_password === 'ASK' && + isValueMissing(credentialPasswordPrivilegeEscalation)) || + (credential?.inputs?.ssh_key_unlock === 'ASK' && + isValueMissing(credentialPasswordPrivateKeyPassphrase)) + ) { + hasError = true; + } + + if ( + credential?.inputs?.vault_password === 'ASK' && + isValueMissing( + values[`credentialPasswordVault_${credential.inputs.vault_id}`] + ) + ) { + hasError = true; + } + } + }); + } + + return hasError; +} diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx index 8151332078..eb3eab3eb7 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx @@ -9,15 +9,13 @@ export default function useCredentialsStep(launchConfig, resource, i18n) { return { step: getStep(launchConfig, i18n), initialValues: getInitialValues(launchConfig, resource), - validate: () => ({}), isReady: true, contentError: null, - formError: null, - setTouched: setFieldsTouched => { - setFieldsTouched({ - credentials: true, - }); + hasError: false, + setTouched: setFieldTouched => { + setFieldTouched('credentials', true, false); }, + validate: () => {}, }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx index 0d00a3b747..d81ee22e70 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx @@ -12,20 +12,27 @@ export default function useInventoryStep( i18n, visitedSteps ) { - const [, meta] = useField('inventory'); + const [, meta, helpers] = useField('inventory'); const formError = - Object.keys(visitedSteps).includes(STEP_ID) && (!meta.value || meta.error); + !resource || resource?.type === 'workflow_job_template' + ? false + : Object.keys(visitedSteps).includes(STEP_ID) && + meta.touched && + !meta.value; return { step: getStep(launchConfig, i18n, formError), initialValues: getInitialValues(launchConfig, resource), isReady: true, contentError: null, - formError: launchConfig.ask_inventory_on_launch && formError, - setTouched: setFieldsTouched => { - setFieldsTouched({ - inventory: true, - }); + hasError: launchConfig.ask_inventory_on_launch && formError, + setTouched: setFieldTouched => { + setFieldTouched('inventory', true, false); + }, + validate: () => { + if (meta.touched && !meta.value && resource.type === 'job_template') { + helpers.setError(i18n._(t`An inventory must be selected`)); + } }, }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx index 1f63397c17..c38f23f665 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx @@ -22,18 +22,19 @@ export default function useOtherPromptsStep(launchConfig, resource, i18n) { initialValues: getInitialValues(launchConfig, resource), isReady: true, contentError: null, - formError: null, - setTouched: setFieldsTouched => { - setFieldsTouched({ - job_type: true, - limit: true, - verbosity: true, - diff_mode: true, - job_tags: true, - skip_tags: true, - extra_vars: true, - }); + hasError: false, + setTouched: setFieldTouched => { + [ + 'job_type', + 'limit', + 'verbosity', + 'diff_mode', + 'job_tags', + 'skip_tags', + 'extra_vars', + ].forEach(field => setFieldTouched(field, true, false)); }, + validate: () => {}, }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx index 77570fab0b..8a4cc73dde 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx @@ -35,9 +35,9 @@ export default function usePreviewStep( } : null, initialValues: {}, - validate: () => ({}), isReady: true, error: null, setTouched: () => {}, + validate: () => {}, }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx index 37e5454d13..6069878dd6 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx @@ -13,89 +13,51 @@ export default function useSurveyStep( i18n, visitedSteps ) { - const { values } = useFormikContext(); - const errors = {}; - const validate = () => { - if (!launchConfig.survey_enabled || !surveyConfig?.spec) { - return {}; - } - surveyConfig.spec.forEach(question => { - const errMessage = validateField( - question, - values[`survey_${question.variable}`], - i18n - ); - if (errMessage) { - errors[`survey_${question.variable}`] = errMessage; - } - }); - return errors; - }; - const formError = Object.keys(validate()).length > 0; + const { setFieldError, values } = useFormikContext(); + const hasError = + Object.keys(visitedSteps).includes(STEP_ID) && + checkForError(launchConfig, surveyConfig, values); + return { - step: getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps), + step: launchConfig.survey_enabled + ? { + id: STEP_ID, + name: ( + + {i18n._(t`Survey`)} + + ), + component: , + enableNext: true, + } + : null, initialValues: getInitialValues(launchConfig, surveyConfig, resource), - validate, surveyConfig, isReady: true, contentError: null, - formError, - setTouched: setFieldsTouched => { + hasError, + setTouched: setFieldTouched => { if (!surveyConfig?.spec) { return; } - const fields = {}; surveyConfig.spec.forEach(question => { - fields[`survey_${question.variable}`] = true; + setFieldTouched(`survey_${question.variable}`, true, false); }); - setFieldsTouched(fields); }, - }; -} - -function validateField(question, value, i18n) { - const isTextField = ['text', 'textarea'].includes(question.type); - const isNumeric = ['integer', 'float'].includes(question.type); - if (isTextField && (value || value === 0)) { - if (question.min && value.length < question.min) { - return i18n._(t`This field must be at least ${question.min} characters`); - } - if (question.max && value.length > question.max) { - return i18n._(t`This field must not exceed ${question.max} characters`); - } - } - if (isNumeric && (value || value === 0)) { - if (value < question.min || value > question.max) { - return i18n._( - t`This field must be a number and have a value between ${question.min} and ${question.max}` - ); - } - } - if (question.required && !value && value !== 0) { - return i18n._(t`This field must not be blank`); - } - return null; -} -function getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps) { - if (!launchConfig.survey_enabled) { - return null; - } - - return { - id: STEP_ID, - name: ( - - {i18n._(t`Survey`)} - - ), - component: , - enableNext: true, + validate: () => { + if (launchConfig.survey_enabled && surveyConfig.spec) { + surveyConfig.spec.forEach(question => { + const errMessage = validateSurveyField( + question, + values[`survey_${question.variable}`], + i18n + ); + if (errMessage) { + setFieldError(`survey_${question.variable}`, errMessage); + } + }); + } + }, }; } @@ -133,3 +95,56 @@ function getInitialValues(launchConfig, surveyConfig, resource) { return values; } + +function validateSurveyField(question, value, i18n) { + const isTextField = ['text', 'textarea'].includes(question.type); + const isNumeric = ['integer', 'float'].includes(question.type); + if (isTextField && (value || value === 0)) { + if (question.min && value.length < question.min) { + return i18n._(t`This field must be at least ${question.min} characters`); + } + if (question.max && value.length > question.max) { + return i18n._(t`This field must not exceed ${question.max} characters`); + } + } + if (isNumeric && (value || value === 0)) { + if (value < question.min || value > question.max) { + return i18n._( + t`This field must be a number and have a value between ${question.min} and ${question.max}` + ); + } + } + if (question.required && !value && value !== 0) { + return i18n._(t`This field must not be blank`); + } + return null; +} + +function checkForError(launchConfig, surveyConfig, values) { + let hasError = false; + if (launchConfig.survey_enabled && surveyConfig.spec) { + surveyConfig.spec.forEach(question => { + const value = values[`survey_${question.variable}`]; + const isTextField = ['text', 'textarea'].includes(question.type); + const isNumeric = ['integer', 'float'].includes(question.type); + if (isTextField && (value || value === 0)) { + if ( + (question.min && value.length < question.min) || + (question.max && value.length > question.max) + ) { + hasError = true; + } + } + if (isNumeric && (value || value === 0)) { + if (value < question.min || value > question.max) { + hasError = true; + } + } + if (question.required && !value && value !== 0) { + hasError = true; + } + }); + } + + return hasError; +} diff --git a/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js b/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js index 3d6958a0ae..616aeb0a26 100644 --- a/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js +++ b/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js @@ -2,10 +2,43 @@ import { useState, useEffect } from 'react'; import { useFormikContext } from 'formik'; import useInventoryStep from './steps/useInventoryStep'; import useCredentialsStep from './steps/useCredentialsStep'; +import useCredentialPasswordsStep from './steps/useCredentialPasswordsStep'; import useOtherPromptsStep from './steps/useOtherPromptsStep'; import useSurveyStep from './steps/useSurveyStep'; import usePreviewStep from './steps/usePreviewStep'; +function showCredentialPasswordsStep(credentials = [], launchConfig) { + if ( + !launchConfig?.ask_credential_on_launch && + launchConfig?.passwords_needed_to_start + ) { + return launchConfig.passwords_needed_to_start.length > 0; + } + + let credentialPasswordStepRequired = false; + + credentials.forEach(credential => { + if (!credential.inputs) { + const launchConfigCredential = launchConfig.defaults.credentials.find( + defaultCred => defaultCred.id === credential.id + ); + + if (launchConfigCredential?.passwords_needed.length > 0) { + credentialPasswordStepRequired = true; + } + } else if ( + credential?.inputs?.password === 'ASK' || + credential?.inputs?.become_password === 'ASK' || + credential?.inputs?.ssh_key_unlock === 'ASK' || + credential?.inputs?.vault_password === 'ASK' + ) { + credentialPasswordStepRequired = true; + } + }); + + return credentialPasswordStepRequired; +} + export default function useLaunchSteps( launchConfig, surveyConfig, @@ -14,14 +47,21 @@ export default function useLaunchSteps( ) { const [visited, setVisited] = useState({}); const [isReady, setIsReady] = useState(false); + const { touched, values: formikValues } = useFormikContext(); const steps = [ useInventoryStep(launchConfig, resource, i18n, visited), useCredentialsStep(launchConfig, resource, i18n), + useCredentialPasswordsStep( + launchConfig, + i18n, + showCredentialPasswordsStep(formikValues.credentials, launchConfig), + visited + ), useOtherPromptsStep(launchConfig, resource, i18n), useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited), ]; const { resetForm } = useFormikContext(); - const hasErrors = steps.some(step => step.formError); + const hasErrors = steps.some(step => step.hasError); steps.push( usePreviewStep(launchConfig, i18n, resource, surveyConfig, hasErrors, true) @@ -38,16 +78,26 @@ export default function useLaunchSteps( ...cur.initialValues, }; }, {}); + + const newFormValues = { ...initialValues }; + + Object.keys(formikValues).forEach(formikValueKey => { + if ( + Object.prototype.hasOwnProperty.call(newFormValues, formikValueKey) + ) { + newFormValues[formikValueKey] = formikValues[formikValueKey]; + } + }); + resetForm({ - values: { - ...initialValues, - }, + values: newFormValues, + touched, }); setIsReady(true); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [stepsAreReady]); + }, [formikValues.credentials, stepsAreReady]); const stepWithError = steps.find(s => s.contentError); const contentError = stepWithError ? stepWithError.contentError : null; @@ -55,20 +105,26 @@ export default function useLaunchSteps( return { steps: pfSteps, isReady, - 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, + credentialPasswords: true, other: true, 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/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/util/prompt/getCredentialPasswords.js b/awx/ui_next/src/util/prompt/getCredentialPasswords.js new file mode 100644 index 0000000000..d38ba858a3 --- /dev/null +++ b/awx/ui_next/src/util/prompt/getCredentialPasswords.js @@ -0,0 +1,29 @@ +export default function getCredentialPasswords(values) { + const credentialPasswords = {}; + Object.keys(values) + .filter(valueKey => valueKey.startsWith('credentialPassword')) + .forEach(credentialValueKey => { + if (credentialValueKey === 'credentialPasswordSsh') { + credentialPasswords.ssh_password = values[credentialValueKey]; + } + + if (credentialValueKey === 'credentialPasswordPrivilegeEscalation') { + credentialPasswords.become_password = values[credentialValueKey]; + } + + if (credentialValueKey === 'credentialPasswordPrivateKeyPassphrase') { + credentialPasswords.ssh_key_unlock = values[credentialValueKey]; + } + + if (credentialValueKey.startsWith('credentialPasswordVault_')) { + const vaultId = credentialValueKey.split('credentialPasswordVault_')[1]; + if (vaultId.length > 0) { + credentialPasswords[`vault_password.${vaultId}`] = + values[credentialValueKey]; + } else { + credentialPasswords.vault_password = values[credentialValueKey]; + } + } + }); + return credentialPasswords; +} diff --git a/awx/ui_next/src/util/prompt/getCredentialPasswords.test.js b/awx/ui_next/src/util/prompt/getCredentialPasswords.test.js new file mode 100644 index 0000000000..2959d42141 --- /dev/null +++ b/awx/ui_next/src/util/prompt/getCredentialPasswords.test.js @@ -0,0 +1,66 @@ +import getCredentialPasswords from './getCredentialPasswords'; + +describe('getCredentialPasswords', () => { + test('should handle ssh password', () => { + expect( + getCredentialPasswords({ + credentialPasswordSsh: 'foobar', + }) + ).toEqual({ + ssh_password: 'foobar', + }); + }); + test('should handle become password', () => { + expect( + getCredentialPasswords({ + credentialPasswordPrivilegeEscalation: 'foobar', + }) + ).toEqual({ + become_password: 'foobar', + }); + }); + test('should handle ssh key unlock', () => { + expect( + getCredentialPasswords({ + credentialPasswordPrivateKeyPassphrase: 'foobar', + }) + ).toEqual({ + ssh_key_unlock: 'foobar', + }); + }); + test('should handle vault password with identifier', () => { + expect( + getCredentialPasswords({ + credentialPasswordVault_1: 'foobar', + }) + ).toEqual({ + 'vault_password.1': 'foobar', + }); + }); + test('should handle vault password without identifier', () => { + expect( + getCredentialPasswords({ + credentialPasswordVault_: 'foobar', + }) + ).toEqual({ + vault_password: 'foobar', + }); + }); + test('should handle all password types', () => { + expect( + getCredentialPasswords({ + credentialPasswordSsh: '1', + credentialPasswordPrivilegeEscalation: '2', + credentialPasswordPrivateKeyPassphrase: '3', + credentialPasswordVault_: '4', + credentialPasswordVault_1: '5', + }) + ).toEqual({ + ssh_password: '1', + become_password: '2', + ssh_key_unlock: '3', + vault_password: '4', + 'vault_password.1': '5', + }); + }); +}); From fb62e0ec2caf55ac0d4e97e43e16abd8ebf279d5 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 6 Jan 2021 16:06:08 -0500 Subject: [PATCH 15/41] Revert changes to isValid --- awx/ui_next/src/components/FormField/PasswordField.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui_next/src/components/FormField/PasswordField.jsx b/awx/ui_next/src/components/FormField/PasswordField.jsx index fcba330c8c..44ebe5f889 100644 --- a/awx/ui_next/src/components/FormField/PasswordField.jsx +++ b/awx/ui_next/src/components/FormField/PasswordField.jsx @@ -8,7 +8,7 @@ import PasswordInput from './PasswordInput'; function PasswordField(props) { const { id, name, label, validate, isRequired, helperText } = props; const [, meta] = useField({ name, validate }); - const isValid = !meta.touched || (meta.value && meta.value !== ''); + const isValid = !(meta.touched && meta.error); return ( Date: Tue, 19 Jan 2021 11:32:06 -0500 Subject: [PATCH 16/41] Do not set default admin_password --- installer/inventory | 2 +- installer/roles/check_vars/tasks/main.yml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) 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 != '' From 7f7801838629c60a9670d84f40e0ceb4a7345b4b Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 19 Jan 2021 11:34:19 -0500 Subject: [PATCH 17/41] Fix job relaunch where credentials are needed --- .../components/LaunchButton/LaunchButton.jsx | 41 ++-- .../LaunchButton/LaunchButton.test.jsx | 178 +++++++++++++++++- .../src/screens/Job/JobTypeRedirect.jsx | 10 +- 3 files changed, 202 insertions(+), 27 deletions(-) diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx index 06c1fce0b6..a832a929f8 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx @@ -102,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 }); } @@ -129,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 { @@ -151,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 }) => ( - - - - - - ); - } +function ExpandCollapse({ isCompact, onCompact, onExpand, i18n }) { + return ( + + + + + + + + + ); } ExpandCollapse.propTypes = { 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/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/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/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/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); From 54e1a802c5ab8493508361172d6055658864bccf Mon Sep 17 00:00:00 2001 From: Shane McDonald Date: Wed, 20 Jan 2021 15:34:38 -0500 Subject: [PATCH 22/41] Dont fail imagee builds because of debuginfo repo problems --- installer/roles/image_build/templates/Dockerfile.j2 | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/installer/roles/image_build/templates/Dockerfile.j2 b/installer/roles/image_build/templates/Dockerfile.j2 index fa4f9e35e8..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 From 90edb3b551f52e036681513c857ecfbe227b27d5 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Thu, 14 Jan 2021 09:42:12 -0500 Subject: [PATCH 23/41] update Breadcrumb component to ScreenHeader: - show last breadcrum item as Title on new line - add activity stream type (to display activity stream icon link in header) --- .../components/Breadcrumbs/Breadcrumbs.jsx | 73 ---------- .../src/components/Breadcrumbs/index.js | 1 - .../components/ScreenHeader/ScreenHeader.jsx | 129 ++++++++++++++++++ .../ScreenHeader.test.jsx} | 24 ++-- .../src/components/ScreenHeader/index.js | 1 + .../src/screens/Application/Applications.jsx | 7 +- .../screens/Application/Applications.test.jsx | 4 + .../src/screens/Credential/Credentials.jsx | 7 +- .../screens/Credential/Credentials.test.jsx | 12 +- .../CredentialType/CredentialTypes.jsx | 7 +- .../CredentialType/CredentialTypes.test.jsx | 4 + .../src/screens/Dashboard/Dashboard.jsx | 7 +- .../src/screens/Dashboard/Dashboard.test.jsx | 3 + awx/ui_next/src/screens/Host/Hosts.jsx | 4 +- awx/ui_next/src/screens/Host/Hosts.test.jsx | 6 +- .../screens/InstanceGroup/InstanceGroups.jsx | 7 +- .../InstanceGroup/InstanceGroups.test.jsx | 4 + .../src/screens/Inventory/Inventories.jsx | 7 +- .../screens/Inventory/Inventories.test.jsx | 4 + awx/ui_next/src/screens/Job/Job.test.jsx | 4 + awx/ui_next/src/screens/Job/Jobs.jsx | 4 +- awx/ui_next/src/screens/Job/Jobs.test.jsx | 6 +- .../screens/ManagementJob/ManagementJobs.jsx | 5 +- .../ManagementJob/ManagementJobs.test.jsx | 6 +- .../NotificationTemplates.jsx | 7 +- .../NotificationTemplates.test.jsx | 4 + .../screens/Organization/Organizations.jsx | 7 +- .../Organization/Organizations.test.jsx | 3 + awx/ui_next/src/screens/Project/Projects.jsx | 4 +- .../src/screens/Project/Projects.test.jsx | 6 +- .../src/screens/Schedule/AllSchedules.jsx | 5 +- .../screens/Schedule/AllSchedules.test.jsx | 7 +- awx/ui_next/src/screens/Setting/Settings.jsx | 4 +- .../src/screens/Setting/Settings.test.jsx | 3 + awx/ui_next/src/screens/Team/Teams.jsx | 4 +- awx/ui_next/src/screens/Team/Teams.test.jsx | 3 + .../src/screens/Template/Templates.jsx | 17 ++- .../src/screens/Template/Templates.test.jsx | 4 + awx/ui_next/src/screens/User/Users.jsx | 4 +- awx/ui_next/src/screens/User/Users.test.jsx | 6 +- .../WorkflowApproval/WorkflowApprovals.jsx | 7 +- .../WorkflowApprovals.test.jsx | 7 +- 42 files changed, 302 insertions(+), 136 deletions(-) delete mode 100644 awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.jsx delete mode 100644 awx/ui_next/src/components/Breadcrumbs/index.js create mode 100644 awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx rename awx/ui_next/src/components/{Breadcrumbs/Breadcrumbs.test.jsx => ScreenHeader/ScreenHeader.test.jsx} (70%) create mode 100644 awx/ui_next/src/components/ScreenHeader/index.js 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/ScreenHeader/ScreenHeader.jsx b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx new file mode 100644 index 0000000000..791e72b78e --- /dev/null +++ b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx @@ -0,0 +1,129 @@ +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/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/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..947a9be42a 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -4,7 +4,7 @@ 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'; @@ -95,7 +95,10 @@ function Inventories({ i18n }) { return ( <> - + 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/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/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/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/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/Team/Teams.jsx b/awx/ui_next/src/screens/Team/Teams.jsx index 3022ca98c7..f71098752a 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'; @@ -36,7 +36,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..5f471baaa5 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,24 @@ 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 setBreadcrumbConfig = useCallback( (template, schedule) => { 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`), @@ -49,7 +51,10 @@ function Templates({ i18n }) { 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/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(); }); }); From 87a2039ded4d82ab9bfc75a20c3f0733a7aadc73 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Thu, 14 Jan 2021 09:50:01 -0500 Subject: [PATCH 24/41] don't strip out non-namespaced params when encoding url search params --- .../src/components/ListHeader/ListHeader.jsx | 7 ++++++- .../PaginatedDataList/PaginatedDataList.jsx | 11 +++++++++-- .../src/components/PaginatedTable/HeaderRow.jsx | 7 ++++++- .../components/PaginatedTable/PaginatedTable.jsx | 9 +++++++-- awx/ui_next/src/util/qs.js | 15 ++++++++++----- 5 files changed, 38 insertions(+), 11 deletions(-) diff --git a/awx/ui_next/src/components/ListHeader/ListHeader.jsx b/awx/ui_next/src/components/ListHeader/ListHeader.jsx index 6e1f9cb4e9..d5e359c3bc 100644 --- a/awx/ui_next/src/components/ListHeader/ListHeader.jsx +++ b/awx/ui_next/src/components/ListHeader/ListHeader.jsx @@ -85,7 +85,12 @@ class ListHeader extends React.Component { pushHistoryState(params) { const { history, qsConfig } = this.props; const { pathname } = history.location; - 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/PaginatedDataList/PaginatedDataList.jsx b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx index 7f5fe9afdd..a31553658d 100644 --- a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx @@ -60,8 +60,15 @@ function PaginatedDataList({ pushHistoryState(replaceParams(oldParams, { page_size: pageSize, page })); }; - const pushHistoryState = params => { - const encodedParams = encodeNonDefaultQueryString(qsConfig, params); + const pushHistoryState = (params) => { + const { history, qsConfig } = this.props; + const { pathname } = history.location; + 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/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, + }); }; /** From 7c57a8e5d092369f64a1a1f195457e214fba43f9 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Thu, 14 Jan 2021 09:50:17 -0500 Subject: [PATCH 25/41] add activity stream ui --- awx/ui_next/src/api/index.js | 3 + awx/ui_next/src/api/models/ActivityStream.js | 10 + awx/ui_next/src/routeConfig.js | 6 + .../screens/ActivityStream/ActivityStream.jsx | 228 +++++++ .../ActivityStream/ActivityStream.test.jsx | 25 + .../ActivityStream/ActivityStreamListItem.jsx | 61 ++ .../ActivityStreamListItem.test.jsx | 22 + .../ActivityStream/StreamDetailButton.jsx | 64 ++ .../StreamDetailButton.test.jsx | 21 + .../buildActivityDescription.jsx | 578 ++++++++++++++++++ .../buildActivityDescription.test.jsx | 10 + .../src/screens/ActivityStream/index.js | 1 + 12 files changed, 1029 insertions(+) create mode 100644 awx/ui_next/src/api/models/ActivityStream.js create mode 100644 awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx create mode 100644 awx/ui_next/src/screens/ActivityStream/ActivityStream.test.jsx create mode 100644 awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.jsx create mode 100644 awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.test.jsx create mode 100644 awx/ui_next/src/screens/ActivityStream/StreamDetailButton.jsx create mode 100644 awx/ui_next/src/screens/ActivityStream/StreamDetailButton.test.jsx create mode 100644 awx/ui_next/src/screens/ActivityStream/buildActivityDescription.jsx create mode 100644 awx/ui_next/src/screens/ActivityStream/buildActivityDescription.test.jsx create mode 100644 awx/ui_next/src/screens/ActivityStream/index.js 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/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..86b715ebc1 --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx @@ -0,0 +1,228 @@ +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, + 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 } 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'); + + 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]); + + 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/ActivityStreamListItem.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.jsx new file mode 100644 index 0000000000..ab961aeb06 --- /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 StreamDetailButton from './StreamDetailButton'; +import buildDescription from './buildActivityDescription'; + +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 = buildDescription(streamItem, i18n); + + 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/StreamDetailButton.jsx b/awx/ui_next/src/screens/ActivityStream/StreamDetailButton.jsx new file mode 100644 index 0000000000..b2e8dc368b --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/StreamDetailButton.jsx @@ -0,0 +1,64 @@ +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 StreamDetailButton({ 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)} + > + + + + + + + + + + + ); +} + +export default withI18n()(StreamDetailButton); diff --git a/awx/ui_next/src/screens/ActivityStream/StreamDetailButton.test.jsx b/awx/ui_next/src/screens/ActivityStream/StreamDetailButton.test.jsx new file mode 100644 index 0000000000..04c112c9e0 --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/StreamDetailButton.test.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import StreamDetailButton from './StreamDetailButton'; + +jest.mock('../../api/models/ActivityStream'); + +describe('', () => { + test('initially renders succesfully', () => { + mountWithContexts( + Bob} + description={foo} + /> + ); + }); +}); diff --git a/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.jsx b/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.jsx new file mode 100644 index 0000000000..6e9f12b9c9 --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.jsx @@ -0,0 +1,578 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { t } from '@lingui/macro'; + +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.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 { + wfjt_id, + 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} + + ); +}; + +export default (activity, i18n) => { + 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 + )} + + ); +}; diff --git a/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.test.jsx b/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.test.jsx new file mode 100644 index 0000000000..3d90ef5915 --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.test.jsx @@ -0,0 +1,10 @@ +import { mount } from 'enzyme'; + +import buildDescription from './buildActivityDescription'; + +describe('buildActivityStream', () => { + test('initially renders succesfully', () => { + const description = mount(buildDescription({}, {})); + expect(description.find('span').length).toBe(1); + }); +}); 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'; From cec5a77762733f76361025f968b695f231b82f9f Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Thu, 14 Jan 2021 11:16:34 -0500 Subject: [PATCH 26/41] add username-based search to activity stream --- awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx index 86b715ebc1..88527fc13f 100644 --- a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx @@ -195,6 +195,10 @@ function ActivityStream({ i18n }) { key: 'search', isDefault: true, }, + { + name: i18n._(t`Initiated by (username)`), + key: 'actor__username__icontains', + }, ]} toolbarSortColumns={[ { From 2f7a7b453f0bac0d606e674b0ee5b4c383c82610 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Thu, 14 Jan 2021 12:47:47 -0500 Subject: [PATCH 27/41] add workflow node based events to be shown when templates is selected in activity streram --- awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx | 2 +- awx/ui_next/src/screens/Template/Templates.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx index 88527fc13f..42621b3344 100644 --- a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx @@ -162,7 +162,7 @@ function ActivityStream({ i18n }) { {i18n._(t`Templates`)} diff --git a/awx/ui_next/src/screens/Template/Templates.jsx b/awx/ui_next/src/screens/Template/Templates.jsx index 5f471baaa5..a3e819c151 100644 --- a/awx/ui_next/src/screens/Template/Templates.jsx +++ b/awx/ui_next/src/screens/Template/Templates.jsx @@ -52,7 +52,7 @@ function Templates({ i18n }) { return ( <> From fd708456df0c186bab5f7b0b223d6d7b44cc1e50 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Thu, 14 Jan 2021 12:48:20 -0500 Subject: [PATCH 28/41] fix workflow event activity steam linking --- .../screens/ActivityStream/buildActivityDescription.jsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.jsx b/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.jsx index 6e9f12b9c9..645923a9cb 100644 --- a/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.jsx +++ b/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.jsx @@ -52,6 +52,9 @@ const buildAnchor = (obj, resource, activity) => { 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) { @@ -82,8 +85,8 @@ const buildAnchor = (obj, resource, activity) => { break; case 'workflow_job_template_node': { const { - wfjt_id, - wfjt_name, + id: wfjt_id, + name: wfjt_name, } = activity.summary_fields.workflow_job_template[0]; url = `/templates/workflow_job_template/${wfjt_id}/`; name = wfjt_name; From 77cd875a9c4529261acbdddd551af46edcda7f2a Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Fri, 15 Jan 2021 12:52:49 -0500 Subject: [PATCH 29/41] add initiated by sort on activity stream --- awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx index 42621b3344..6d9227e8b5 100644 --- a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx @@ -205,13 +205,19 @@ function ActivityStream({ i18n }) { name: i18n._(t`Time`), key: 'timestamp', }, + { + name: i18n._(t`Initiated by`), + key: 'actor__username', + }, ]} toolbarSearchableKeys={searchableKeys} toolbarRelatedSearchableKeys={relatedSearchableKeys} headerRow={ {i18n._(t`Time`)} - {i18n._(t`Initiated by`)} + + {i18n._(t`Initiated by`)} + {i18n._(t`Event`)} {i18n._(t`Actions`)} From c793b3a9c84121605a4210f26a6c848d9f738e3f Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Fri, 15 Jan 2021 14:32:34 -0500 Subject: [PATCH 30/41] fix issues from rebase fallout --- .../src/components/PaginatedDataList/PaginatedDataList.jsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx index a31553658d..92b471c8c3 100644 --- a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx @@ -60,9 +60,7 @@ function PaginatedDataList({ pushHistoryState(replaceParams(oldParams, { page_size: pageSize, page })); }; - const pushHistoryState = (params) => { - const { history, qsConfig } = this.props; - const { pathname } = history.location; + const pushHistoryState = params => { const nonNamespacedParams = parseQueryString({}, history.location.search); const encodedParams = encodeNonDefaultQueryString( qsConfig, From ce28968a118d42069b325a887eab4eae2d532cc5 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Tue, 19 Jan 2021 10:06:08 -0500 Subject: [PATCH 31/41] reset page to 1 after activity stream type changes --- .../screens/ActivityStream/ActivityStream.jsx | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx index 6d9227e8b5..bd36e33821 100644 --- a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx @@ -18,7 +18,12 @@ import PaginatedTable, { HeaderCell, } from '../../components/PaginatedTable'; import useRequest from '../../util/useRequest'; -import { getQSConfig, parseQueryString } from '../../util/qs'; +import { + getQSConfig, + parseQueryString, + replaceParams, + encodeNonDefaultQueryString, +} from '../../util/qs'; import { ActivityStreamAPI } from '../../api'; import ActivityStreamListItem from './ActivityStreamListItem'; @@ -89,6 +94,19 @@ function ActivityStream({ i18n }) { fetchActivityStream(); }, [fetchActivityStream]); + const pushHistoryState = urlParams => { + let searchParams = parseQueryString(QS_CONFIG, location.search); + searchParams = replaceParams(searchParams, { page: 1 }); + const encodedParams = encodeNonDefaultQueryString(QS_CONFIG, searchParams, { + type: urlParams.get('type'), + }); + history.push( + encodedParams + ? `${location.pathname}?${encodedParams}` + : location.pathname + ); + }; + return ( Date: Tue, 19 Jan 2021 10:07:44 -0500 Subject: [PATCH 32/41] fix spacing issues with activity stream description builder --- .../src/screens/ActivityStream/buildActivityDescription.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.jsx b/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.jsx index 645923a9cb..95d613a44a 100644 --- a/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.jsx +++ b/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.jsx @@ -131,7 +131,7 @@ const buildAnchor = (obj, resource, activity) => { }; const getPastTense = item => { - return /e$/.test(item) ? `${item}d ` : `${item}ed `; + return /e$/.test(item) ? `${item}d` : `${item}ed`; }; const isGroupRelationship = item => { @@ -232,7 +232,7 @@ export default (activity, i18n) => { ); labeledLinks.push( buildLabeledLink( - `${activity.summary_fields.role[0].role_field} to `, + `${activity.summary_fields.role[0].role_field} to`, buildAnchor( activity.summary_fields.group[0], object1, @@ -253,7 +253,7 @@ export default (activity, i18n) => { ); labeledLinks.push( buildLabeledLink( - `${activity.summary_fields.role[0].role_field} to `, + `${activity.summary_fields.role[0].role_field} to`, buildAnchor( activity.summary_fields[object1][0], object1, From 8d46e786062355a8f6a134abdb2fc04cfa691b7b Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Tue, 19 Jan 2021 11:48:14 -0500 Subject: [PATCH 33/41] fix lint issue with urlParam name --- awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx index bd36e33821..13cd573fe3 100644 --- a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx @@ -94,11 +94,11 @@ function ActivityStream({ i18n }) { fetchActivityStream(); }, [fetchActivityStream]); - const pushHistoryState = urlParams => { + const pushHistoryState = urlParamsToAdd => { let searchParams = parseQueryString(QS_CONFIG, location.search); searchParams = replaceParams(searchParams, { page: 1 }); const encodedParams = encodeNonDefaultQueryString(QS_CONFIG, searchParams, { - type: urlParams.get('type'), + type: urlParamsToAdd.get('type'), }); history.push( encodedParams From a0ded889f9c1d219e0863dcba6f4682bdb22f6d8 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Tue, 19 Jan 2021 11:49:58 -0500 Subject: [PATCH 34/41] add min height to title to keep page redrwaw from flashing --- .../src/components/ScreenHeader/ScreenHeader.jsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx index 791e72b78e..d856a430ee 100644 --- a/awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx +++ b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx @@ -39,9 +39,15 @@ const ScreenHeader = ({ breadcrumbConfig, i18n, streamType }) => {
)} - - - +
+ + + +
{streamType !== 'none' && (
From 7a3002f2180d8e89e0af82a4b57981b589ebd2f9 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Tue, 19 Jan 2021 11:51:10 -0500 Subject: [PATCH 35/41] fix routes with breadcrumb issues: team roles title crumb missing various inventory crums missing make it so inventories and templates don't get rid of data needed to generate the crumb config --- .../src/screens/Inventory/Inventories.jsx | 96 ++++++++++++------- awx/ui_next/src/screens/Team/Teams.jsx | 1 + .../src/screens/Template/Templates.jsx | 20 +++- 3 files changed, 79 insertions(+), 38 deletions(-) diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index 947a9be42a..06e793f962 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -1,4 +1,4 @@ -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'; @@ -12,14 +12,31 @@ 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.id !== inventory?.id) { + setInventory(passedInventory); + } + if (passedNestedObject && passedNestedObject.id !== nestedObject?.id) { + setNestedGroup(passedNestedObject); + } + if (passedSchedule && passedSchedule.id !== schedule?.id) { + setSchedule(passedSchedule); + } if (!inventory) { return; } @@ -32,13 +49,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,50 +59,66 @@ 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 ( @@ -109,12 +137,12 @@ function Inventories({ i18n }) { {({ me }) => ( - + )} - + diff --git a/awx/ui_next/src/screens/Team/Teams.jsx b/awx/ui_next/src/screens/Team/Teams.jsx index f71098752a..0797a6685e 100644 --- a/awx/ui_next/src/screens/Team/Teams.jsx +++ b/awx/ui_next/src/screens/Team/Teams.jsx @@ -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] diff --git a/awx/ui_next/src/screens/Template/Templates.jsx b/awx/ui_next/src/screens/Template/Templates.jsx index a3e819c151..ae38afd414 100644 --- a/awx/ui_next/src/screens/Template/Templates.jsx +++ b/awx/ui_next/src/screens/Template/Templates.jsx @@ -22,8 +22,18 @@ function Templates({ i18n }) { const [breadcrumbConfig, setScreenHeader] = useState( initScreenHeader.current ); + + const [schedule, setSchedule] = useState(); + const [template, setTemplate] = useState(); + const setBreadcrumbConfig = useCallback( - (template, schedule) => { + (passedTemplate, passedSchedule) => { + if (passedTemplate && passedTemplate.id !== template?.id) { + setTemplate(passedTemplate); + } + if (passedSchedule && passedSchedule.id !== schedule?.id) { + setSchedule(passedSchedule); + } if (!template) return; const templatePath = `/templates/${template.type}/${template.id}`; const schedulesPath = `${templatePath}/schedules`; @@ -42,11 +52,13 @@ 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 ( From f07818f04a7c70923dd7817e2b891ca7e7064291 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Wed, 20 Jan 2021 09:14:42 -0500 Subject: [PATCH 36/41] check if breadcrumbs should update by name (which changes), not ID --- awx/ui_next/src/screens/Inventory/Inventories.jsx | 6 +++--- awx/ui_next/src/screens/Template/Templates.jsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index 06e793f962..e137032ea4 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -28,13 +28,13 @@ function Inventories({ i18n }) { const setBreadcrumbConfig = useCallback( (passedInventory, passedNestedObject, passedSchedule) => { - if (passedInventory && passedInventory.id !== inventory?.id) { + if (passedInventory && passedInventory.name !== inventory?.name) { setInventory(passedInventory); } - if (passedNestedObject && passedNestedObject.id !== nestedObject?.id) { + if (passedNestedObject && passedNestedObject.name !== nestedObject?.name) { setNestedGroup(passedNestedObject); } - if (passedSchedule && passedSchedule.id !== schedule?.id) { + if (passedSchedule && passedSchedule.name !== schedule?.name) { setSchedule(passedSchedule); } if (!inventory) { diff --git a/awx/ui_next/src/screens/Template/Templates.jsx b/awx/ui_next/src/screens/Template/Templates.jsx index ae38afd414..f3905608cc 100644 --- a/awx/ui_next/src/screens/Template/Templates.jsx +++ b/awx/ui_next/src/screens/Template/Templates.jsx @@ -28,10 +28,10 @@ function Templates({ i18n }) { const setBreadcrumbConfig = useCallback( (passedTemplate, passedSchedule) => { - if (passedTemplate && passedTemplate.id !== template?.id) { + if (passedTemplate && passedTemplate.name !== template?.name) { setTemplate(passedTemplate); } - if (passedSchedule && passedSchedule.id !== schedule?.id) { + if (passedSchedule && passedSchedule.name !== schedule?.name) { setSchedule(passedSchedule); } if (!template) return; From 06ff178f9eff56376e201d97a87f6022ceafb83a Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Wed, 20 Jan 2021 10:05:35 -0500 Subject: [PATCH 37/41] update activity stream file structure to be consistent with other routes and aid in testing --- ...ription.jsx => ActivityStreamDescription.jsx} | 7 +++++-- .../ActivityStreamDescription.test.jsx | 12 ++++++++++++ ...Button.jsx => ActivityStreamDetailButton.jsx} | 16 +++++++++------- ...t.jsx => ActivityStreamDetailButton.test.jsx} | 6 +++--- .../ActivityStream/ActivityStreamListItem.jsx | 8 ++++---- .../buildActivityDescription.test.jsx | 10 ---------- .../src/screens/Inventory/Inventories.jsx | 5 ++++- 7 files changed, 37 insertions(+), 27 deletions(-) rename awx/ui_next/src/screens/ActivityStream/{buildActivityDescription.jsx => ActivityStreamDescription.jsx} (99%) create mode 100644 awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.test.jsx rename awx/ui_next/src/screens/ActivityStream/{StreamDetailButton.jsx => ActivityStreamDetailButton.jsx} (82%) rename awx/ui_next/src/screens/ActivityStream/{StreamDetailButton.test.jsx => ActivityStreamDetailButton.test.jsx} (73%) delete mode 100644 awx/ui_next/src/screens/ActivityStream/buildActivityDescription.test.jsx diff --git a/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx similarity index 99% rename from awx/ui_next/src/screens/ActivityStream/buildActivityDescription.jsx rename to awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx index 95d613a44a..d933e0c259 100644 --- a/awx/ui_next/src/screens/ActivityStream/buildActivityDescription.jsx +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx @@ -1,6 +1,7 @@ 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; @@ -150,7 +151,7 @@ const buildLabeledLink = (label, link) => { ); }; -export default (activity, i18n) => { +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 @@ -578,4 +579,6 @@ export default (activity, i18n) => { )} ); -}; +} + +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/StreamDetailButton.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.jsx similarity index 82% rename from awx/ui_next/src/screens/ActivityStream/StreamDetailButton.jsx rename to awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.jsx index b2e8dc368b..22559831b2 100644 --- a/awx/ui_next/src/screens/ActivityStream/StreamDetailButton.jsx +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.jsx @@ -9,7 +9,7 @@ import { formatDateString } from '../../util/dates'; import { DetailList, Detail } from '../../components/DetailList'; import { VariablesDetail } from '../../components/CodeMirrorInput'; -function StreamDetailButton({ i18n, streamItem, user, description }) { +function ActivityStreamDetailButton({ i18n, streamItem, user, description }) { const [isOpen, setIsOpen] = useState(false); const setting = streamItem?.summary_fields?.setting; @@ -50,15 +50,17 @@ function StreamDetailButton({ i18n, streamItem, user, description }) { value={setting && setting[0]?.name} /> - + {streamItem?.changes && ( + + )} ); } -export default withI18n()(StreamDetailButton); +export default withI18n()(ActivityStreamDetailButton); diff --git a/awx/ui_next/src/screens/ActivityStream/StreamDetailButton.test.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.test.jsx similarity index 73% rename from awx/ui_next/src/screens/ActivityStream/StreamDetailButton.test.jsx rename to awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.test.jsx index 04c112c9e0..40dc104117 100644 --- a/awx/ui_next/src/screens/ActivityStream/StreamDetailButton.test.jsx +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.test.jsx @@ -2,14 +2,14 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; -import StreamDetailButton from './StreamDetailButton'; +import ActivityStreamDetailButton from './ActivityStreamDetailButton'; jest.mock('../../api/models/ActivityStream'); -describe('', () => { +describe('', () => { test('initially renders succesfully', () => { mountWithContexts( - ; return ( @@ -48,7 +48,7 @@ function ActivityStreamListItem({ streamItem, i18n }) { - { - test('initially renders succesfully', () => { - const description = mount(buildDescription({}, {})); - expect(description.find('span').length).toBe(1); - }); -}); diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index e137032ea4..17ee02b3be 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -31,7 +31,10 @@ function Inventories({ i18n }) { if (passedInventory && passedInventory.name !== inventory?.name) { setInventory(passedInventory); } - if (passedNestedObject && passedNestedObject.name !== nestedObject?.name) { + if ( + passedNestedObject && + passedNestedObject.name !== nestedObject?.name + ) { setNestedGroup(passedNestedObject); } if (passedSchedule && passedSchedule.name !== schedule?.name) { From 00e837c17ce2474b08be8ed4821b8ef2dbb3e1f5 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Thu, 21 Jan 2021 10:01:39 -0500 Subject: [PATCH 38/41] update grouping of activity stream type select and remove inv scripts --- .../screens/ActivityStream/ActivityStream.jsx | 136 ++++++++++-------- 1 file changed, 73 insertions(+), 63 deletions(-) diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx index 13cd573fe3..c4199d6304 100644 --- a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx @@ -6,6 +6,7 @@ import { Card, PageSection, PageSectionVariants, + SelectGroup, Select, SelectVariant, SelectOption, @@ -119,6 +120,7 @@ function ActivityStream({ i18n }) { From 7d495713eedd556b111b62eebfa69e0cc581dd64 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Thu, 21 Jan 2021 15:31:08 -0500 Subject: [PATCH 39/41] updated aria label for activity stream type select --- awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx index c4199d6304..84dd9a6066 100644 --- a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx @@ -37,7 +37,7 @@ function ActivityStream({ i18n }) { const history = useHistory(); const urlParams = new URLSearchParams(location.search); - const activityStreamType = urlParams.get('type'); + const activityStreamType = urlParams.get('type') || 'all'; let typeParams = {}; @@ -118,11 +118,14 @@ function ActivityStream({ i18n }) { {i18n._(t`Activity Stream`)} +