From c0690cddc8357f0afb9baeb3f0750a9bd77b0dc2 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 7 Jan 2021 09:32:59 -0500 Subject: [PATCH 01/15] Display source workflow job when available on job details view --- awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx | 11 +++++++++++ .../src/screens/Job/JobDetail/JobDetail.test.jsx | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx index 9255b27af1..f239081f48 100644 --- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx +++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx @@ -93,6 +93,7 @@ function JobDetail({ job, i18n }) { workflow_job_template: workflowJobTemplate, labels, project, + source_workflow_job, } = job.summary_fields; const [errorMsg, setErrorMsg] = useState(); const history = useHistory(); @@ -195,6 +196,16 @@ function JobDetail({ job, i18n }) { } /> )} + {source_workflow_job && ( + + {source_workflow_job.id} - {source_workflow_job.name} + + } + /> + )} ', () => { kubernetes: false, credential_type_id: 1, }, + source_workflow_job: { + id: 1234, + name: 'Test Source Workflow', + }, }, }} /> @@ -45,6 +49,7 @@ describe('', () => { assertDetail('Started', '8/8/2019, 7:24:18 PM'); assertDetail('Finished', '8/8/2019, 7:24:50 PM'); assertDetail('Job Template', mockJobData.summary_fields.job_template.name); + assertDetail('Source Workflow Job', `1234 - Test Source Workflow`); assertDetail('Job Type', 'Playbook Run'); assertDetail('Launched By', mockJobData.summary_fields.created_by.username); assertDetail('Inventory', mockJobData.summary_fields.inventory.name); From 186a1b04b48f21656e1419db97a085df539d498f Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Fri, 8 Jan 2021 15:34:56 -0500 Subject: [PATCH 02/15] fixes erronous render of add button --- awx/ui_next/src/screens/User/UserRoles/UserRolesList.jsx | 3 +-- awx/ui_next/src/screens/User/UserRoles/UserRolesList.test.jsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/awx/ui_next/src/screens/User/UserRoles/UserRolesList.jsx b/awx/ui_next/src/screens/User/UserRoles/UserRolesList.jsx index 1553af92df..9e6c8e9757 100644 --- a/awx/ui_next/src/screens/User/UserRoles/UserRolesList.jsx +++ b/awx/ui_next/src/screens/User/UserRoles/UserRolesList.jsx @@ -98,8 +98,7 @@ function UserRolesList({ i18n, user }) { ); const canAdd = - user?.summary_fields?.user_capabilities?.edit || - (actions && Object.prototype.hasOwnProperty.call(actions, 'POST')); + actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); const detailUrl = role => { const { resource_id, resource_type } = role.summary_fields; diff --git a/awx/ui_next/src/screens/User/UserRoles/UserRolesList.test.jsx b/awx/ui_next/src/screens/User/UserRoles/UserRolesList.test.jsx index 0ff83bf813..a2f5b8438a 100644 --- a/awx/ui_next/src/screens/User/UserRoles/UserRolesList.test.jsx +++ b/awx/ui_next/src/screens/User/UserRoles/UserRolesList.test.jsx @@ -12,7 +12,7 @@ jest.mock('../../../api/models/Roles'); UsersAPI.readOptions.mockResolvedValue({ data: { - actions: { GET: {} }, + actions: { GET: {}, POST: {} }, related_search_fields: [], }, }); From 6b7d712f9f1b2f8cfad7606960cc93095bff2308 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 15 Jan 2021 11:54:35 -0500 Subject: [PATCH 03/15] Give setting toggle form group a unique form field id --- awx/ui_next/src/screens/Setting/shared/SharedFields.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx index 13ec5dc11c..877aa2be35 100644 --- a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx +++ b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx @@ -48,10 +48,10 @@ const SettingGroup = withI18n()( Date: Thu, 21 Jan 2021 12:48:42 -0500 Subject: [PATCH 04/15] Assert checkbox label click event updates checkbox value --- .../Setting/shared/SharedFields.test.jsx | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx b/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx index e603ae10fe..d0c8a1437a 100644 --- a/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx +++ b/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx @@ -1,5 +1,7 @@ import React from 'react'; +import { mount } from 'enzyme'; import { Formik } from 'formik'; +import { I18nProvider } from '@lingui/react'; import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { @@ -12,28 +14,38 @@ import { describe('Setting form fields', () => { test('BooleanField renders the expected content', async () => { - const wrapper = mountWithContexts( - - {() => ( - - )} - + const outerNode = document.createElement('div'); + document.body.appendChild(outerNode); + const wrapper = mount( + + + {() => ( + + )} + + , + { + attachTo: outerNode, + } ); expect(wrapper.find('Switch')).toHaveLength(1); expect(wrapper.find('Switch').prop('isChecked')).toBe(true); expect(wrapper.find('Switch').prop('isDisabled')).toBe(false); await act(async () => { - wrapper.find('Switch').invoke('onChange')(false); + wrapper + .find('Switch label') + .instance() + .dispatchEvent(new Event('click')); }); wrapper.update(); expect(wrapper.find('Switch').prop('isChecked')).toBe(false); From 81875f0971f308d6c420d4ca9f7e70cf8e81ac1f Mon Sep 17 00:00:00 2001 From: Nikhil Jain Date: Fri, 22 Jan 2021 11:03:44 +0530 Subject: [PATCH 05/15] fix the handling of wrong survey spec --- .../modules/tower_workflow_job_template.py | 10 +++++- awx_collection/test/awx/test_job_template.py | 32 +++++++++++++++++++ .../test/awx/test_workflow_job_template.py | 28 ++++++++++++++++ 3 files changed, 69 insertions(+), 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 f6d972e76f..7836b42cc4 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template.py @@ -151,7 +151,15 @@ 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_spec')}) + if module.params.get('survey_spec') == {}: + response = module.delete_endpoint(spec_endpoint) + if response['status_code'] != 200: + # Not sure how to make this actually return a non 200 to test what to dump in the respinse + module.fail_json(msg="Failed to delete survey: {0}".format(response['json'])) + else: + response = module.post_endpoint(spec_endpoint, **{'data': module.params.get('survey_spec')}) + if response['status_code'] != 200: + module.fail_json(msg="Failed to update survey: {0}".format(response['json']['error'])) module.exit_json(**module.json_output) diff --git a/awx_collection/test/awx/test_job_template.py b/awx_collection/test/awx/test_job_template.py index 80b3cd9529..5d4100dac1 100644 --- a/awx_collection/test/awx/test_job_template.py +++ b/awx_collection/test/awx/test_job_template.py @@ -177,6 +177,38 @@ def test_job_template_with_survey_spec(run_module, admin_user, project, inventor assert ActivityStream.objects.count() == prior_ct +@pytest.mark.django_db +def test_job_template_with_wrong_survey_spec(run_module, admin_user, project, inventory, survey_spec): + result = run_module('tower_job_template', dict( + name='foo', + playbook='helloworld.yml', + project=project.name, + inventory=inventory.name, + survey_spec=survey_spec, + survey_enabled=True + ), admin_user) + assert not result.get('failed', False), result.get('msg', result) + assert result.get('changed', False), result + jt = JobTemplate.objects.get(pk=result['id']) + + assert jt.survey_spec == survey_spec + + prior_ct = ActivityStream.objects.count() + + del survey_spec['description'] + + result = run_module('tower_job_template', dict( + name='foo', + playbook='helloworld.yml', + project=project.name, + inventory=inventory.name, + survey_spec=survey_spec, + survey_enabled=True + ), admin_user) + assert result.get('failed', True) + assert result.get('msg') == "Failed to update survey: Field 'description' is missing from survey spec." + + @pytest.mark.django_db def test_job_template_with_survey_encrypted_default(run_module, admin_user, project, inventory, silence_warning): spec = { diff --git a/awx_collection/test/awx/test_workflow_job_template.py b/awx_collection/test/awx/test_workflow_job_template.py index a125b01249..9e5d914cfb 100644 --- a/awx_collection/test/awx/test_workflow_job_template.py +++ b/awx_collection/test/awx/test_workflow_job_template.py @@ -81,6 +81,34 @@ def test_survey_spec_only_changed(run_module, admin_user, organization, survey_s assert wfjt.survey_spec == survey_spec +@pytest.mark.django_db +def test_survey_spec_only_changed(run_module, admin_user, organization, survey_spec): + wfjt = WorkflowJobTemplate.objects.create( + organization=organization, name='foo-workflow', + survey_enabled=True, survey_spec=survey_spec + ) + result = run_module('tower_workflow_job_template', { + 'name': 'foo-workflow', + 'organization': organization.name, + 'state': 'present' + }, admin_user) + assert not result.get('failed', False), result.get('msg', result) + assert not result.get('changed', True), result + wfjt.refresh_from_db() + assert wfjt.survey_spec == survey_spec + + del survey_spec['description'] + + result = run_module('tower_workflow_job_template', { + 'name': 'foo-workflow', + 'organization': organization.name, + 'survey_spec': survey_spec, + 'state': 'present' + }, admin_user) + assert result.get('failed', True) + assert result.get('msg') == "Failed to update survey: Field 'description' is missing from survey spec." + + @pytest.mark.django_db def test_associate_only_on_success(run_module, admin_user, organization, project): wfjt = WorkflowJobTemplate.objects.create( From 7c8bd471980d26083d4c4e11067bb53730175496 Mon Sep 17 00:00:00 2001 From: Shane McDonald Date: Mon, 25 Jan 2021 12:25:37 -0500 Subject: [PATCH 06/15] Minikube-based development environment Works in conjunction with https://github.com/ansible/awx-operator/pull/71 See docs/development/minikube.md --- .gitignore | 3 + Makefile | 36 ++++++-- awx/settings/development.py | 5 +- awx/settings/minikube.py | 4 + docs/development/minikube.md | 90 +++++++++++++++++++ installer/build.yml | 1 + installer/dockerfile.yml | 6 ++ installer/roles/dockerfile/defaults/main.yml | 6 ++ .../files/RPM-GPG-KEY-ansible-release | 0 .../files/launch_awx.sh | 8 ++ .../files/launch_awx_task.sh | 8 ++ .../files/rsyslog.conf | 0 .../files/settings.py | 0 installer/roles/dockerfile/tasks/main.yml | 19 ++++ .../templates/Dockerfile.j2 | 40 ++++----- .../templates/supervisor.conf.j2} | 33 ++++++- .../templates/supervisor_task.conf.j2} | 10 +++ installer/roles/image_build/defaults/main.yml | 1 - installer/roles/image_build/tasks/main.yml | 5 -- .../kubernetes/templates/supervisor.yml.j2 | 1 + 20 files changed, 241 insertions(+), 35 deletions(-) create mode 100644 awx/settings/minikube.py create mode 100644 docs/development/minikube.md create mode 100644 installer/dockerfile.yml create mode 100644 installer/roles/dockerfile/defaults/main.yml rename installer/roles/{image_build => dockerfile}/files/RPM-GPG-KEY-ansible-release (100%) rename installer/roles/{image_build => dockerfile}/files/launch_awx.sh (83%) rename installer/roles/{image_build => dockerfile}/files/launch_awx_task.sh (90%) rename installer/roles/{image_build => dockerfile}/files/rsyslog.conf (100%) rename installer/roles/{image_build => dockerfile}/files/settings.py (100%) create mode 100644 installer/roles/dockerfile/tasks/main.yml rename installer/roles/{image_build => dockerfile}/templates/Dockerfile.j2 (90%) rename installer/roles/{image_build/files/supervisor.conf => dockerfile/templates/supervisor.conf.j2} (75%) rename installer/roles/{image_build/files/supervisor_task.conf => dockerfile/templates/supervisor_task.conf.j2} (86%) diff --git a/.gitignore b/.gitignore index 54c57c36fe..b0d0e0bcfa 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,6 @@ use_dev_supervisor.txt /tools/docker-compose/overrides/ /awx/ui_next/.ui-built /Dockerfile +/_build/ +/_build_kube_dev/ +/Dockerfile.kube-dev diff --git a/Makefile b/Makefile index 9c7ccd028b..a0d4761ae8 100644 --- a/Makefile +++ b/Makefile @@ -267,11 +267,27 @@ collectstatic: fi; \ mkdir -p awx/public/static && $(PYTHON) manage.py collectstatic --clear --noinput > /dev/null 2>&1 +UWSGI_DEV_RELOAD_COMMAND ?= supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver + uwsgi: collectstatic @if [ "$(VENV_BASE)" ]; then \ . $(VENV_BASE)/awx/bin/activate; \ fi; \ - uwsgi -b 32768 --socket 127.0.0.1:8050 --module=awx.wsgi:application --home=/var/lib/awx/venv/awx --chdir=/awx_devel/ --vacuum --processes=5 --harakiri=120 --master --no-orphans --py-autoreload 1 --max-requests=1000 --stats /tmp/stats.socket --lazy-apps --logformat "%(addr) %(method) %(uri) - %(proto) %(status)" --hook-accepting1="exec:supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver" + uwsgi -b 32768 \ + --socket 127.0.0.1:8050 \ + --module=awx.wsgi:application \ + --home=/var/lib/awx/venv/awx \ + --chdir=/awx_devel/ \ + --vacuum \ + --processes=5 \ + --harakiri=120 --master \ + --no-orphans \ + --py-autoreload 1 \ + --max-requests=1000 \ + --stats /tmp/stats.socket \ + --lazy-apps \ + --logformat "%(addr) %(method) %(uri) - %(proto) %(status)" \ + --hook-accepting1="exec: $(UWSGI_DEV_RELOAD_COMMAND)" daphne: @if [ "$(VENV_BASE)" ]; then \ @@ -579,8 +595,8 @@ docker-compose-clean: awx/projects # Base development image build docker-compose-build: - ansible localhost -m template -a "src=installer/roles/image_build/templates/Dockerfile.j2 dest=tools/docker-compose/Dockerfile" -e build_dev=True - docker build -t ansible/awx_devel -f tools/docker-compose/Dockerfile \ + ansible-playbook installer/dockerfile.yml -e build_dev=True + docker build -t ansible/awx_devel \ --cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) . docker tag ansible/awx_devel $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) #docker push $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) @@ -624,5 +640,15 @@ psql-container: VERSION: @echo "awx: $(VERSION)" -Dockerfile: installer/roles/image_build/templates/Dockerfile.j2 - ansible localhost -m template -a "src=installer/roles/image_build/templates/Dockerfile.j2 dest=Dockerfile" +Dockerfile: installer/roles/dockerfile/templates/Dockerfile.j2 + ansible-playbook installer/dockerfile.yml + +Dockerfile.kube-dev: installer/roles/dockerfile/templates/Dockerfile.j2 + ansible-playbook installer/dockerfile.yml \ + -e dockerfile_name=Dockerfile.kube-dev \ + -e kube_dev=True \ + -e template_dest=_build_kube_dev + +awx-kube-dev-build: Dockerfile.kube-dev + docker build -f Dockerfile.kube-dev \ + -t $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) . diff --git a/awx/settings/development.py b/awx/settings/development.py index 9846705fa5..6181d16ec6 100644 --- a/awx/settings/development.py +++ b/awx/settings/development.py @@ -158,7 +158,10 @@ AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx") # default settings for development. If not present, we can still run using # only the defaults. try: - include(optional('local_*.py'), scope=locals()) + if os.getenv('AWX_KUBE_DEVEL', False): + include(optional('minikube.py'), scope=locals()) + else: + include(optional('local_*.py'), scope=locals()) except ImportError: traceback.print_exc() sys.exit(1) diff --git a/awx/settings/minikube.py b/awx/settings/minikube.py new file mode 100644 index 0000000000..0ac81875bc --- /dev/null +++ b/awx/settings/minikube.py @@ -0,0 +1,4 @@ +BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖' +BROADCAST_WEBSOCKET_PORT = 8013 +BROADCAST_WEBSOCKET_VERIFY_CERT = False +BROADCAST_WEBSOCKET_PROTOCOL = 'http' diff --git a/docs/development/minikube.md b/docs/development/minikube.md new file mode 100644 index 0000000000..b0fa1f163e --- /dev/null +++ b/docs/development/minikube.md @@ -0,0 +1,90 @@ +# Running Development Environment in Kubernetes + +## Start Minikube + +If you do not already have Minikube, install it from: +https://minikube.sigs.k8s.io/docs/start/ + +Note: This environment has only been tested on Linux. + +``` +$ minikube start \ + --mount \ + --mount-string="/path/to/awx:/awx_devel" \ + --cpus=4 \ + --memory=8g \ + --addons=ingress +``` + +### Verify + +Ensure that your AWX source code is properly mounted inside of the minikube node: + +``` +$ minikube ssh +$ ls -la /awx_devel +``` + +## Deploy the AWX Operator + +Clone the [awx-operator](https://github.com/ansible/awx-operator). + +If you are not changing any code in the operator itself, simply run: + +``` +$ ansible-playbook ansible/deploy-operator.yml +``` + +If making changes to the operator itself, run the following command in the root +of the awx-operator repo. If not, continue to the next section. + +### Building and Deploying a Custom AWX Operator Image + +``` +$ operator-sdk build quay.io//awx-operator +$ docker push quay.io//awx-operator +$ ansible-playbook ansible/deploy-operator.yml \ + -e pull_policy=Always \ + -e operator_image=quay.io//awx-operator \ + -e operator_version=latest +``` + +## Deploy AWX into Minikube using the AWX Operator + +If have have not made any changes to the AWX Dockerfile, run the following +command. If you need to test out changes to the Dockerfile, see the +"Custom AWX Development Image for Kubernetes" section below. + +In the root of awx-operator: + +``` +$ ansible-playbook ansible/instantiate-awx-deployment.yml \ + -e development_mode=yes \ + -e tower_image=gcr.io/ansible-tower-engineering/awx_kube_devel:devel \ + -e tower_image_pull_policy=Always +``` + +### Custom AWX Development Image for Kubernetes + +I have found `minikube cache add` to be unacceptably slow for larger images such +as this. A faster workflow involves building the image and pushing it to a +registry: + +In the root of the AWX repo: + +``` +$ make awx-kube-dev-build +$ docker push gcr.io/ansible-tower-engineering/awx_kube_devel:${COMPOSE_TAG} +``` + +In the root of awx-operator: + +``` +$ ansible-playbook ansible/instantiate-awx-deployment.yml \ + -e development_mode=yes \ + -e tower_image=gcr.io/ansible-tower-engineering/awx_kube_devel:${COMPOSE_TAG} \ + -e tower_image_pull_policy=Always +``` + +To iterate on changes to the Dockerfile, rebuild and push the image, then delete +the AWX Pod. A new Pod will respawn with the latest revision. diff --git a/installer/build.yml b/installer/build.yml index 0bea5821e3..a8bd2552f9 100644 --- a/installer/build.yml +++ b/installer/build.yml @@ -3,5 +3,6 @@ hosts: localhost gather_facts: true roles: + - {role: dockerfile} - {role: image_build} - {role: image_push, when: "docker_registry is defined"} diff --git a/installer/dockerfile.yml b/installer/dockerfile.yml new file mode 100644 index 0000000000..9b6bfdf974 --- /dev/null +++ b/installer/dockerfile.yml @@ -0,0 +1,6 @@ +--- +- name: Render AWX Dockerfile and sources + hosts: localhost + gather_facts: true + roles: + - {role: dockerfile} diff --git a/installer/roles/dockerfile/defaults/main.yml b/installer/roles/dockerfile/defaults/main.yml new file mode 100644 index 0000000000..fd1d93961a --- /dev/null +++ b/installer/roles/dockerfile/defaults/main.yml @@ -0,0 +1,6 @@ +--- +build_dev: false +kube_dev: false +dockerfile_dest: '..' +dockerfile_name: 'Dockerfile' +template_dest: '_build' diff --git a/installer/roles/image_build/files/RPM-GPG-KEY-ansible-release b/installer/roles/dockerfile/files/RPM-GPG-KEY-ansible-release similarity index 100% rename from installer/roles/image_build/files/RPM-GPG-KEY-ansible-release rename to installer/roles/dockerfile/files/RPM-GPG-KEY-ansible-release diff --git a/installer/roles/image_build/files/launch_awx.sh b/installer/roles/dockerfile/files/launch_awx.sh similarity index 83% rename from installer/roles/image_build/files/launch_awx.sh rename to installer/roles/dockerfile/files/launch_awx.sh index 819c353837..839f7cf746 100755 --- a/installer/roles/image_build/files/launch_awx.sh +++ b/installer/roles/dockerfile/files/launch_awx.sh @@ -5,6 +5,14 @@ if [ `id -u` -ge 500 ]; then rm /tmp/passwd fi +if [ -n "${AWX_KUBE_DEVEL}" ]; then + pushd /awx_devel + make awx-link + popd + + export SDB_NOTIFY_HOST=$(ip route | head -n1 | awk '{print $3}') +fi + source /etc/tower/conf.d/environment.sh ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m wait_for -a "host=$DATABASE_HOST port=$DATABASE_PORT" all diff --git a/installer/roles/image_build/files/launch_awx_task.sh b/installer/roles/dockerfile/files/launch_awx_task.sh similarity index 90% rename from installer/roles/image_build/files/launch_awx_task.sh rename to installer/roles/dockerfile/files/launch_awx_task.sh index 49806df485..3fe6630963 100755 --- a/installer/roles/image_build/files/launch_awx_task.sh +++ b/installer/roles/dockerfile/files/launch_awx_task.sh @@ -5,6 +5,14 @@ if [ `id -u` -ge 500 ]; then rm /tmp/passwd fi +if [ -n "${AWX_KUBE_DEVEL}" ]; then + pushd /awx_devel + make awx-link + popd + + export SDB_NOTIFY_HOST=$(ip route | head -n1 | awk '{print $3}') +fi + source /etc/tower/conf.d/environment.sh ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m wait_for -a "host=$DATABASE_HOST port=$DATABASE_PORT" all diff --git a/installer/roles/image_build/files/rsyslog.conf b/installer/roles/dockerfile/files/rsyslog.conf similarity index 100% rename from installer/roles/image_build/files/rsyslog.conf rename to installer/roles/dockerfile/files/rsyslog.conf diff --git a/installer/roles/image_build/files/settings.py b/installer/roles/dockerfile/files/settings.py similarity index 100% rename from installer/roles/image_build/files/settings.py rename to installer/roles/dockerfile/files/settings.py diff --git a/installer/roles/dockerfile/tasks/main.yml b/installer/roles/dockerfile/tasks/main.yml new file mode 100644 index 0000000000..7d315e1856 --- /dev/null +++ b/installer/roles/dockerfile/tasks/main.yml @@ -0,0 +1,19 @@ +--- + +- name: Create .build directory + file: + path: "{{ dockerfile_dest }}/{{ template_dest }}" + state: directory + +- name: Render supervisor configs + template: + src: "{{ item }}.j2" + dest: "{{ dockerfile_dest }}/{{ template_dest }}/{{ item }}" + with_items: + - "supervisor.conf" + - "supervisor_task.conf" + +- name: Render Dockerfile + template: + src: Dockerfile.j2 + dest: "{{ dockerfile_dest }}/{{ dockerfile_name }}" diff --git a/installer/roles/image_build/templates/Dockerfile.j2 b/installer/roles/dockerfile/templates/Dockerfile.j2 similarity index 90% rename from installer/roles/image_build/templates/Dockerfile.j2 rename to installer/roles/dockerfile/templates/Dockerfile.j2 index fc3424abf4..0364a5a591 100644 --- a/installer/roles/image_build/templates/Dockerfile.j2 +++ b/installer/roles/dockerfile/templates/Dockerfile.j2 @@ -1,12 +1,8 @@ -{% if build_dev|default(False)|bool %} ### This file is generated from -### installer/roles/image_build/templates/Dockerfile.j2 +### installer/roles/dockerfile/templates/Dockerfile.j2 ### ### DO NOT EDIT ### -{% else %} - {% set build_dev = False %} -{% endif %} # Locations - set globally to be used across stages ARG COLLECTION_BASE="/var/lib/awx/vendor/awx_ansible_collections" @@ -67,12 +63,10 @@ ADD requirements/requirements_ansible.txt \ RUN cd /tmp && make requirements_awx requirements_ansible_py3 RUN cd /tmp && make requirements_collections -{% if build_dev|bool %} +{% if (build_dev|bool) or (kube_dev|bool) %} ADD requirements/requirements_dev.txt /tmp/requirements RUN cd /tmp && make requirements_awx_dev requirements_ansible_dev -{% endif %} - -{% if not build_dev|bool %} +{% else %} # Use the distro provided npm to bootstrap our required version of node RUN npm install -g n && n 14.15.1 && dnf remove -y nodejs @@ -81,6 +75,7 @@ COPY . /tmp/src/ WORKDIR /tmp/src/ RUN make sdist && \ /var/lib/awx/venv/awx/bin/pip install dist/awx-$(cat VERSION).tar.gz +RUN ln -s /var/lib/awx/venv/awx/bin/awx-manage /usr/bin/awx-manage {% endif %} # Final container(s) @@ -146,7 +141,7 @@ RUN cd /usr/local/bin && \ curl -L https://github.com/openshift/origin/releases/download/v3.11.0/openshift-origin-client-tools-v3.11.0-0cbc58b-linux-64bit.tar.gz | \ tar -xz --strip-components=1 --wildcards --no-anchored 'oc' -{% if build_dev|bool %} +{% if (build_dev|bool) or (kube_dev|bool) %} # Install development/test requirements RUN dnf -y install \ gdb \ @@ -183,32 +178,32 @@ RUN openssl req -nodes -newkey rsa:2048 -keyout /etc/nginx/nginx.key -out /etc/n -subj "/C=US/ST=North Carolina/L=Durham/O=Ansible/OU=AWX Development/CN=awx.localhost" && \ openssl x509 -req -days 365 -in /etc/nginx/nginx.csr -signkey /etc/nginx/nginx.key -out /etc/nginx/nginx.crt && \ chmod 640 /etc/nginx/nginx.{csr,key,crt} -{% else %} -RUN ln -s /var/lib/awx/venv/awx/bin/awx-manage /usr/bin/awx-manage {% endif %} # Create default awx rsyslog config -ADD installer/roles/image_build/files/rsyslog.conf /var/lib/awx/rsyslog/rsyslog.conf +ADD installer/roles/dockerfile/files/rsyslog.conf /var/lib/awx/rsyslog/rsyslog.conf ## File mappings {% if build_dev|bool %} ADD tools/docker-compose/launch_awx.sh /usr/bin/launch_awx.sh -ADD tools/docker-compose/awx-manage /usr/local/bin/awx-manage -ADD tools/docker-compose/awx.egg-link /tmp/awx.egg-link ADD tools/docker-compose/nginx.conf /etc/nginx/nginx.conf ADD tools/docker-compose/nginx.vh.default.conf /etc/nginx/conf.d/nginx.vh.default.conf ADD tools/docker-compose/start_tests.sh /start_tests.sh ADD tools/docker-compose/bootstrap_development.sh /usr/bin/bootstrap_development.sh ADD tools/docker-compose/entrypoint.sh /entrypoint.sh -ADD tools/scripts/awx-python /usr/bin/awx-python {% else %} -ADD installer/roles/image_build/files/launch_awx.sh /usr/bin/launch_awx.sh -ADD installer/roles/image_build/files/launch_awx_task.sh /usr/bin/launch_awx_task.sh -ADD installer/roles/image_build/files/settings.py /etc/tower/settings.py -ADD installer/roles/image_build/files/supervisor.conf /etc/supervisord.conf -ADD installer/roles/image_build/files/supervisor_task.conf /etc/supervisord_task.conf +ADD installer/roles/dockerfile/files/launch_awx.sh /usr/bin/launch_awx.sh +ADD installer/roles/dockerfile/files/launch_awx_task.sh /usr/bin/launch_awx_task.sh +ADD installer/roles/dockerfile/files/settings.py /etc/tower/settings.py +ADD {{ template_dest }}/supervisor.conf /etc/supervisord.conf +ADD {{ template_dest }}/supervisor_task.conf /etc/supervisord_task.conf ADD tools/scripts/config-watcher /usr/bin/config-watcher {% endif %} +{% if (build_dev|bool) or (kube_dev|bool) %} +ADD tools/docker-compose/awx.egg-link /tmp/awx.egg-link +ADD tools/docker-compose/awx-manage /usr/local/bin/awx-manage +ADD tools/scripts/awx-python /usr/bin/awx-python +{% endif %} # Pre-create things we need to access RUN for dir in \ @@ -231,7 +226,7 @@ RUN chmod u+s /usr/bin/bwrap ; \ chgrp -R root ${COLLECTION_BASE} ; \ chmod -R g+rw ${COLLECTION_BASE} -{% if build_dev|bool %} +{% if (build_dev|bool) or (kube_dev|bool) %} RUN for dir in \ /var/lib/awx/venv \ /var/lib/awx/venv/awx/lib/python3.6 \ @@ -256,6 +251,7 @@ ENV HOME="/var/lib/awx" ENV PATH="/usr/pgsql-10/bin:${PATH}" {% if build_dev|bool %} + EXPOSE 8043 8013 8080 22 ENTRYPOINT ["/entrypoint.sh"] diff --git a/installer/roles/image_build/files/supervisor.conf b/installer/roles/dockerfile/templates/supervisor.conf.j2 similarity index 75% rename from installer/roles/image_build/files/supervisor.conf rename to installer/roles/dockerfile/templates/supervisor.conf.j2 index 99dd288975..56210abb77 100644 --- a/installer/roles/image_build/files/supervisor.conf +++ b/installer/roles/dockerfile/templates/supervisor.conf.j2 @@ -6,7 +6,12 @@ logfile_maxbytes = 0 pidfile = /var/run/supervisor/supervisor.web.pid [program:nginx] +{% if kube_dev | bool %} +command = make nginx +directory = /awx_devel +{% else %} command = nginx -g "daemon off;" +{% endif %} autostart = true autorestart = true stopwaitsecs = 5 @@ -16,34 +21,59 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:uwsgi] + +{% if kube_dev | bool %} +command = make uwsgi +directory = /awx_devel +environment = + UWSGI_DEV_RELOAD_COMMAND='supervisorctl -c /etc/supervisord_task.conf restart all; supervisorctl restart tower-processes:daphne tower-processes:wsbroadcast' +{% else %} command = /var/lib/awx/venv/awx/bin/uwsgi --socket 127.0.0.1:8050 --module=awx.wsgi:application --vacuum --processes=5 --harakiri=120 --no-orphans --master --max-requests=1000 --master-fifo=/var/lib/awx/awxfifo --lazy-apps -b 32768 directory = /var/lib/awx +{% endif %} autostart = true autorestart = true stopwaitsecs = 15 -stopsignal = INT +stopasgroup=true +killasgroup=true +stopsignal=KILL stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:daphne] +{% if kube_dev | bool %} +command = make daphne +directory = /awx_devel +{% else %} command = /var/lib/awx/venv/awx/bin/daphne -b 127.0.0.1 -p 8051 --websocket_timeout -1 awx.asgi:channel_layer directory = /var/lib/awx +{% endif %} autostart = true +stopsignal=KILL autorestart = true stopwaitsecs = 5 +stopasgroup=true +killasgroup=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:wsbroadcast] +{% if kube_dev | bool %} +command = make wsbroadcast +directory = /awx_devel +{% else %} command = awx-manage run_wsbroadcast directory = /var/lib/awx +{% endif %} autostart = true autorestart = true stopwaitsecs = 5 +stopasgroup=true +killasgroup=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr @@ -53,6 +83,7 @@ stderr_logfile_maxbytes=0 command = rsyslogd -n -i /var/run/awx-rsyslog/rsyslog.pid -f /var/lib/awx/rsyslog/rsyslog.conf autostart = true autorestart = true +startretries = 10 stopwaitsecs = 5 stopsignal=TERM stopasgroup=true diff --git a/installer/roles/image_build/files/supervisor_task.conf b/installer/roles/dockerfile/templates/supervisor_task.conf.j2 similarity index 86% rename from installer/roles/image_build/files/supervisor_task.conf rename to installer/roles/dockerfile/templates/supervisor_task.conf.j2 index e199787f08..b9fe0be41a 100644 --- a/installer/roles/image_build/files/supervisor_task.conf +++ b/installer/roles/dockerfile/templates/supervisor_task.conf.j2 @@ -6,8 +6,13 @@ logfile_maxbytes = 0 pidfile = /var/run/supervisor/supervisor.pid [program:dispatcher] +{% if kube_dev | bool %} +command = make dispatcher +directory = /awx_devel +{% else %} command = awx-manage run_dispatcher directory = /var/lib/awx +{% endif %} autostart = true autorestart = true stopwaitsecs = 5 @@ -17,8 +22,13 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:callback-receiver] +{% if kube_dev | bool %} +command = make receiver +directory = /awx_devel +{% else %} command = awx-manage run_callback_receiver directory = /var/lib/awx +{% endif %} autostart = true autorestart = true stopwaitsecs = 5 diff --git a/installer/roles/image_build/defaults/main.yml b/installer/roles/image_build/defaults/main.yml index ab152975ce..0d45e047d8 100644 --- a/installer/roles/image_build/defaults/main.yml +++ b/installer/roles/image_build/defaults/main.yml @@ -1,6 +1,5 @@ --- create_preload_data: true -build_dev: false # Helper vars to construct the proper download URL for the current architecture tini_architecture: '{{ { "x86_64": "amd64", "aarch64": "arm64", "armv7": "arm" }[ansible_facts.architecture] }}' diff --git a/installer/roles/image_build/tasks/main.yml b/installer/roles/image_build/tasks/main.yml index 463e12ec73..065710974c 100644 --- a/installer/roles/image_build/tasks/main.yml +++ b/installer/roles/image_build/tasks/main.yml @@ -21,11 +21,6 @@ set_fact: awx_image: "{{ awx_image|default('awx') }}" -- name: Render Dockerfile - template: - src: Dockerfile.j2 - dest: ../Dockerfile - # Calling Docker directly because docker-py doesnt support BuildKit - name: Build AWX image command: docker build -t {{ awx_image }}:{{ awx_version }} .. diff --git a/installer/roles/kubernetes/templates/supervisor.yml.j2 b/installer/roles/kubernetes/templates/supervisor.yml.j2 index e86a9ca9fd..da93f29e5d 100644 --- a/installer/roles/kubernetes/templates/supervisor.yml.j2 +++ b/installer/roles/kubernetes/templates/supervisor.yml.j2 @@ -61,6 +61,7 @@ data: autostart = true autorestart = true stopwaitsecs = 5 + startretries = 10 stopsignal=TERM stopasgroup=true killasgroup=true From c4c1b9799e40610dea41428ce8663046a364fd9a Mon Sep 17 00:00:00 2001 From: djj106 Date: Tue, 26 Jan 2021 08:48:08 -0600 Subject: [PATCH 07/15] fix workflow url Signed-off-by: djj106 --- awx/main/models/notifications.py | 2 +- awx/main/models/workflow.py | 6 +++--- .../screens/ActivityStream/ActivityStreamDescription.jsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index 33562e7fca..74a9896385 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -357,7 +357,7 @@ class JobNotificationMixin(object): 'url': 'https://towerhost/#/jobs/playbook/1010', 'approval_status': 'approved', 'approval_node_name': 'Approve Me', - 'workflow_url': 'https://towerhost/#/workflows/1010', + 'workflow_url': 'https://towerhost/#/jobs/workflow/1010', 'job_metadata': """{'url': 'https://towerhost/$/jobs/playbook/13', 'traceback': '', 'status': 'running', diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index dd8bc3e894..d9ac8afcf9 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -620,7 +620,7 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio return reverse('api:workflow_job_detail', kwargs={'pk': self.pk}, request=request) def get_ui_url(self): - return urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.pk)) + return urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.pk)) def notification_data(self): result = super(WorkflowJob, self).notification_data() @@ -752,7 +752,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin): return None def get_ui_url(self): - return urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id)) + return urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.workflow_job.id)) def _get_parent_field_name(self): return 'workflow_approval_template' @@ -840,7 +840,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin): return (msg, body) def context(self, approval_status): - workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id)) + workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.workflow_job.id)) return {'approval_status': approval_status, 'approval_node_name': self.workflow_approval_template.name, 'workflow_url': workflow_url, diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx index d933e0c259..517491797a 100644 --- a/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx @@ -94,7 +94,7 @@ const buildAnchor = (obj, resource, activity) => { break; } case 'workflow_job': - url = `/workflows/${obj.id}/`; + url = `/jobs/workflow/${obj.id}/`; break; case 'label': url = null; From bda4db462f63b6a90594ff3d039e7aa1bf21a964 Mon Sep 17 00:00:00 2001 From: Shane McDonald Date: Tue, 26 Jan 2021 10:23:13 -0500 Subject: [PATCH 08/15] Enable inline caching for image builds --- Makefile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index a0d4761ae8..9cf77e597e 100644 --- a/Makefile +++ b/Makefile @@ -597,13 +597,16 @@ docker-compose-clean: awx/projects docker-compose-build: ansible-playbook installer/dockerfile.yml -e build_dev=True docker build -t ansible/awx_devel \ - --cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) . + --build-arg BUILDKIT_INLINE_CACHE=1 \ + --cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) . docker tag ansible/awx_devel $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) #docker push $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) # For use when developing on "isolated" AWX deployments docker-compose-isolated-build: docker-compose-build - docker build -t ansible/awx_isolated -f tools/docker-isolated/Dockerfile . + docker build -t ansible/awx_isolated \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + -f tools/docker-isolated/Dockerfile . docker tag ansible/awx_isolated $(DEV_DOCKER_TAG_BASE)/awx_isolated:$(COMPOSE_TAG) #docker push $(DEV_DOCKER_TAG_BASE)/awx_isolated:$(COMPOSE_TAG) @@ -651,4 +654,5 @@ Dockerfile.kube-dev: installer/roles/dockerfile/templates/Dockerfile.j2 awx-kube-dev-build: Dockerfile.kube-dev docker build -f Dockerfile.kube-dev \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ -t $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) . From 8d8aadb1931fc1416007f3f0d38080d687afec6f Mon Sep 17 00:00:00 2001 From: "Christian M. Adams" Date: Tue, 26 Jan 2021 11:04:49 -0500 Subject: [PATCH 09/15] Bump version to 17.0.1 & update changelog --- CHANGELOG.md | 4 ++++ VERSION | 2 +- awxkit/VERSION | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6394b4f6d7..6237a9f54a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/`. +# 17.0.1 (January 26, 2021) +- Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152 +- Fixed a bug in the UI which caused toggle settings to not be changed when clicked: https://github.com/ansible/awx/pull/9093 + # 17.0.0 (January 22, 2021) - AWX now requires PostgreSQL 12 by default: https://github.com/ansible/awx/pull/8943 **Note:** users who encounter permissions errors at upgrade time should `chown -R ~/.awx/pgdocker` to ensure it's owned by the user running the install playbook diff --git a/VERSION b/VERSION index aac58983e6..3e17df0287 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -17.0.0 +17.0.1 diff --git a/awxkit/VERSION b/awxkit/VERSION index aac58983e6..3e17df0287 100644 --- a/awxkit/VERSION +++ b/awxkit/VERSION @@ -1 +1 @@ -17.0.0 +17.0.1 From 9cf306659139343ff35097e2a143776c0e8a1d03 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 4 Dec 2020 10:21:26 -0500 Subject: [PATCH 10/15] Add SAML settings edit form --- .../src/screens/Setting/SAML/SAML.test.jsx | 44 +++- .../SAML/SAMLDetail/SAMLDetail.test.jsx | 6 + .../Setting/SAML/SAMLEdit/SAMLEdit.jsx | 219 ++++++++++++++-- .../Setting/SAML/SAMLEdit/SAMLEdit.test.jsx | 243 +++++++++++++++++- .../screens/Setting/shared/RevertButton.jsx | 9 +- .../screens/Setting/shared/SharedFields.jsx | 55 +++- .../Setting/shared/SharedFields.test.jsx | 43 ++++ 7 files changed, 587 insertions(+), 32 deletions(-) diff --git a/awx/ui_next/src/screens/Setting/SAML/SAML.test.jsx b/awx/ui_next/src/screens/Setting/SAML/SAML.test.jsx index 0c662fd927..70d7ae5bb2 100644 --- a/awx/ui_next/src/screens/Setting/SAML/SAML.test.jsx +++ b/awx/ui_next/src/screens/Setting/SAML/SAML.test.jsx @@ -3,11 +3,31 @@ import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { SettingsAPI } from '../../../api'; +import { SettingsProvider } from '../../../contexts/Settings'; +import mockAllOptions from '../shared/data.allSettingOptions.json'; import SAML from './SAML'; jest.mock('../../../api/models/Settings'); SettingsAPI.readCategory.mockResolvedValue({ - data: {}, + data: { + SOCIAL_AUTH_SAML_CALLBACK_URL: 'https://towerhost/sso/complete/saml/', + SOCIAL_AUTH_SAML_METADATA_URL: 'https://towerhost/sso/metadata/saml/', + SOCIAL_AUTH_SAML_SP_ENTITY_ID: '', + SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: '', + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '', + SOCIAL_AUTH_SAML_ORG_INFO: {}, + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {}, + SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {}, + SOCIAL_AUTH_SAML_ENABLED_IDPS: {}, + SOCIAL_AUTH_SAML_SECURITY_CONFIG: {}, + SOCIAL_AUTH_SAML_SP_EXTRA: {}, + SOCIAL_AUTH_SAML_EXTRA_DATA: [], + SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {}, + SOCIAL_AUTH_SAML_TEAM_MAP: {}, + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {}, + SOCIAL_AUTH_SAML_TEAM_ATTR: {}, + SAML_AUTO_CREATE_OBJECTS: false, + }, }); describe('', () => { @@ -23,9 +43,14 @@ describe('', () => { initialEntries: ['/settings/saml/details'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('SAMLDetail').length).toBe(1); }); @@ -35,9 +60,14 @@ describe('', () => { initialEntries: ['/settings/saml/edit'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('SAMLEdit').length).toBe(1); }); diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.test.jsx b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.test.jsx index 1afaee4e24..85fd5aa0d8 100644 --- a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.test.jsx +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.test.jsx @@ -32,6 +32,7 @@ SettingsAPI.readCategory.mockResolvedValue({ SOCIAL_AUTH_SAML_TEAM_MAP: {}, SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {}, SOCIAL_AUTH_SAML_TEAM_ATTR: {}, + SAML_AUTO_CREATE_OBJECTS: false, }, }); @@ -59,6 +60,11 @@ describe('', () => { }); test('should render expected details', () => { + assertDetail( + wrapper, + 'Automatically Create Organizations and Teams on SAML Login', + 'Off' + ); assertDetail( wrapper, 'SAML Assertion Consumer Service (ACS) URL', diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.jsx b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.jsx index fc9740b16c..93010d1ee5 100644 --- a/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.jsx +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.jsx @@ -1,25 +1,208 @@ -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 { + BooleanField, + FileUploadField, + InputField, + ObjectField, +} from '../../shared/SharedFields'; +import { formatJson } from '../../shared/settingUtils'; +import useModal from '../../../../util/useModal'; +import useRequest from '../../../../util/useRequest'; +import { SettingsAPI } from '../../../../api'; + +function SAMLEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { isLoading, error, request: fetchSAML, result: saml } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('saml'); + const mergedData = {}; + Object.keys(data).forEach(key => { + if (!options[key]) { + return; + } + mergedData[key] = options[key]; + mergedData[key].value = data[key]; + }); + return mergedData; + }, [options]), + null + ); + + useEffect(() => { + fetchSAML(); + }, [fetchSAML]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async values => { + await SettingsAPI.updateAll(values); + history.push('/settings/saml/details'); + }, + [history] + ), + null + ); + + const handleSubmit = async form => { + await submitForm({ + ...form, + SOCIAL_AUTH_SAML_ORG_INFO: formatJson(form.SOCIAL_AUTH_SAML_ORG_INFO), + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: formatJson( + form.SOCIAL_AUTH_SAML_TECHNICAL_CONTACT + ), + SOCIAL_AUTH_SAML_SUPPORT_CONTACT: formatJson( + form.SOCIAL_AUTH_SAML_SUPPORT_CONTACT + ), + SOCIAL_AUTH_SAML_ENABLED_IDPS: formatJson( + form.SOCIAL_AUTH_SAML_ENABLED_IDPS + ), + SOCIAL_AUTH_SAML_ORGANIZATION_MAP: formatJson( + form.SOCIAL_AUTH_SAML_ORGANIZATION_MAP + ), + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: formatJson( + form.SOCIAL_AUTH_SAML_ORGANIZATION_ATTR + ), + SOCIAL_AUTH_SAML_TEAM_MAP: formatJson(form.SOCIAL_AUTH_SAML_TEAM_MAP), + SOCIAL_AUTH_SAML_TEAM_ATTR: formatJson(form.SOCIAL_AUTH_SAML_TEAM_ATTR), + SOCIAL_AUTH_SAML_SECURITY_CONFIG: formatJson( + form.SOCIAL_AUTH_SAML_SECURITY_CONFIG + ), + SOCIAL_AUTH_SAML_SP_EXTRA: formatJson(form.SOCIAL_AUTH_SAML_SP_EXTRA), + SOCIAL_AUTH_SAML_EXTRA_DATA: formatJson(form.SOCIAL_AUTH_SAML_EXTRA_DATA), + }); + }; + + const handleRevertAll = async () => { + const defaultValues = Object.assign( + ...Object.entries(saml).map(([key, value]) => ({ + [key]: value.default, + })) + ); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/saml/details'); + }; + + const initialValues = fields => + Object.keys(fields).reduce((acc, key) => { + if (fields[key].type === 'list' || fields[key].type === 'nested object') { + const emptyDefault = fields[key].type === 'list' ? '[]' : '{}'; + acc[key] = fields[key].value + ? JSON.stringify(fields[key].value, null, 2) + : emptyDefault; + } else { + acc[key] = fields[key].value ?? ''; + } + return acc; + }, {}); -function SAMLEdit({ i18n }) { return ( - {i18n._(t`Edit form coming soon :)`)} - - - + {isLoading && } + {!isLoading && error && } + {!isLoading && saml && ( + + {formik => ( +
+ + + + + + + + + + + + + + + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} +
+ )}
); } -export default withI18n()(SAMLEdit); +export default SAMLEdit; diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.test.jsx b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.test.jsx index d6319d9b2e..858bf814f7 100644 --- a/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.test.jsx @@ -1,16 +1,251 @@ 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 SAMLEdit from './SAMLEdit'; +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: { + SAML_AUTO_CREATE_OBJECTS: true, + SOCIAL_AUTH_SAML_CALLBACK_URL: 'https://towerhost/sso/complete/saml/', + SOCIAL_AUTH_SAML_METADATA_URL: 'https://towerhost/sso/metadata/saml/', + SOCIAL_AUTH_SAML_SP_ENTITY_ID: 'mock_id', + SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: 'mock_cert', + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '$encrypted$', + SOCIAL_AUTH_SAML_ORG_INFO: {}, + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: { + givenName: 'Mock User', + emailAddress: 'mockuser@example.com', + }, + SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {}, + SOCIAL_AUTH_SAML_ENABLED_IDPS: {}, + SOCIAL_AUTH_SAML_SP_EXTRA: {}, + SOCIAL_AUTH_SAML_EXTRA_DATA: [], + SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {}, + SOCIAL_AUTH_SAML_TEAM_MAP: {}, + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {}, + SOCIAL_AUTH_SAML_TEAM_ATTR: {}, + SOCIAL_AUTH_SAML_SECURITY_CONFIG: { + requestedAuthnContext: false, + }, + }, +}); + describe('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/saml/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('SAMLEdit').length).toBe(1); }); + + test('should display expected form fields', async () => { + expect( + wrapper.find('FormGroup[label="SAML Service Provider Entity ID"]').length + ).toBe(1); + expect( + wrapper.find( + 'FormGroup[label="Automatically Create Organizations and Teams on SAML Login"]' + ).length + ).toBe(1); + expect( + wrapper.find( + 'FormGroup[label="SAML Service Provider Public Certificate"]' + ).length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Service Provider Private Key"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Service Provider Organization Info"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Service Provider Technical Contact"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Service Provider Support Contact"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Enabled Identity Providers"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Organization Map"]').length + ).toBe(1); + expect(wrapper.find('FormGroup[label="SAML Team Map"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Organization Attribute Mapping"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Team Attribute Mapping"]').length + ).toBe(1); + expect(wrapper.find('FormGroup[label="SAML Security Config"]').length).toBe( + 1 + ); + expect( + wrapper.find( + 'FormGroup[label="SAML Service Provider extra configuration data"]' + ).length + ).toBe(1); + expect( + wrapper.find( + 'FormGroup[label="SAML IDP to extra_data attribute mapping"]' + ).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({ + SAML_AUTO_CREATE_OBJECTS: true, + SOCIAL_AUTH_SAML_ENABLED_IDPS: {}, + SOCIAL_AUTH_SAML_EXTRA_DATA: null, + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {}, + SOCIAL_AUTH_SAML_ORGANIZATION_MAP: null, + SOCIAL_AUTH_SAML_ORG_INFO: {}, + SOCIAL_AUTH_SAML_SP_ENTITY_ID: '', + SOCIAL_AUTH_SAML_SP_EXTRA: null, + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '', + SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: '', + SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {}, + SOCIAL_AUTH_SAML_TEAM_ATTR: {}, + SOCIAL_AUTH_SAML_TEAM_MAP: null, + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {}, + SOCIAL_AUTH_SAML_SECURITY_CONFIG: { + requestedAuthnContext: false, + }, + }); + }); + + test('should successfully send request to api on form submission', async () => { + act(() => { + wrapper.find('input#SOCIAL_AUTH_SAML_SP_ENTITY_ID').simulate('change', { + target: { value: 'new_id', name: 'SOCIAL_AUTH_SAML_SP_ENTITY_ID' }, + }); + wrapper + .find( + 'FormGroup[fieldId="SOCIAL_AUTH_SAML_TECHNICAL_CONTACT"] 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({ + SAML_AUTO_CREATE_OBJECTS: true, + SOCIAL_AUTH_SAML_ENABLED_IDPS: {}, + SOCIAL_AUTH_SAML_EXTRA_DATA: [], + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {}, + SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {}, + SOCIAL_AUTH_SAML_ORG_INFO: {}, + SOCIAL_AUTH_SAML_SP_ENTITY_ID: 'new_id', + SOCIAL_AUTH_SAML_SP_EXTRA: {}, + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '$encrypted$', + SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: 'mock_cert', + SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {}, + SOCIAL_AUTH_SAML_TEAM_ATTR: {}, + SOCIAL_AUTH_SAML_TEAM_MAP: {}, + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {}, + SOCIAL_AUTH_SAML_SECURITY_CONFIG: { + requestedAuthnContext: false, + }, + }); + }); + + test('should navigate to saml detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual('/settings/saml/details'); + }); + + test('should navigate to saml detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/saml/details'); + }); + + test('should display error message on unsuccessful submission', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error)); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + }); + + test('should display ContentError on throw', async () => { + SettingsAPI.readCategory.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/screens/Setting/shared/RevertButton.jsx b/awx/ui_next/src/screens/Setting/shared/RevertButton.jsx index f0fe7e8b72..a997388395 100644 --- a/awx/ui_next/src/screens/Setting/shared/RevertButton.jsx +++ b/awx/ui_next/src/screens/Setting/shared/RevertButton.jsx @@ -13,7 +13,13 @@ const ButtonWrapper = styled.div` } `; -function RevertButton({ i18n, id, defaultValue, isDisabled = false }) { +function RevertButton({ + i18n, + id, + defaultValue, + isDisabled = false, + onRevertCallback = () => null, +}) { const [field, meta, helpers] = useField(id); const initialValue = meta.initialValue ?? ''; const currentValue = field.value; @@ -30,6 +36,7 @@ function RevertButton({ i18n, id, defaultValue, isDisabled = false }) { function handleConfirm() { helpers.setValue(isRevertable ? defaultValue : initialValue); + onRevertCallback(); } const revertTooltipContent = isRevertable diff --git a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx index 877aa2be35..f668289976 100644 --- a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx +++ b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useState } from 'react'; import { bool, oneOf, shape, string } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { useField } from 'formik'; import { + FileUpload, FormGroup as PFFormGroup, InputGroup, TextInput, @@ -42,6 +43,7 @@ const SettingGroup = withI18n()( isDisabled, isRequired, label, + onRevertCallback, popoverContent, validated, }) => ( @@ -62,6 +64,7 @@ const SettingGroup = withI18n()( id={fieldId} defaultValue={defaultValue} isDisabled={isDisabled} + onRevertCallback={onRevertCallback} /> } @@ -261,4 +264,52 @@ ObjectField.propTypes = { isRequired: bool, }; -export { BooleanField, ChoiceField, EncryptedField, InputField, ObjectField }; +const FileUploadField = withI18n()( + ({ i18n, name, config, isRequired = false }) => { + const validate = isRequired ? required(null, i18n) : null; + const [filename, setFilename] = useState(''); + const [fileIsUploading, setFileIsUploading] = useState(false); + const [field, meta, helpers] = useField({ name, validate }); + const isValid = !(meta.touched && meta.error); + + return config ? ( + + setFilename('')} + > + { + helpers.setValue(value); + setFilename(title); + }} + onReadStarted={() => setFileIsUploading(true)} + onReadFinished={() => setFileIsUploading(false)} + isLoading={fileIsUploading} + allowEditingUploadedText + validated={isValid ? 'default' : 'error'} + /> + + + ) : null; + } +); + +export { + BooleanField, + ChoiceField, + EncryptedField, + FileUploadField, + InputField, + ObjectField, +}; diff --git a/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx b/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx index d0c8a1437a..39b49f9428 100644 --- a/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx +++ b/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx @@ -8,6 +8,7 @@ import { BooleanField, ChoiceField, EncryptedField, + FileUploadField, InputField, ObjectField, } from './SharedFields'; @@ -161,4 +162,46 @@ describe('Setting form fields', () => { wrapper.update(); expect(wrapper.find('CodeMirrorInput').prop('value')).toBe('[]'); }); + + test('FileUploadField renders the expected content', async () => { + const wrapper = mountWithContexts( + + {() => ( + + )} + + ); + expect(wrapper.find('FileUploadField')).toHaveLength(1); + expect(wrapper.find('label').text()).toEqual('mock file label'); + expect(wrapper.find('input#mock_file-filename').prop('value')).toEqual(''); + await act(async () => { + wrapper.find('FileUpload').invoke('onChange')( + { + text: () => + '-----BEGIN PRIVATE KEY-----\\nAAAAAAAAAAAAAA\\n-----END PRIVATE KEY-----\\n', + }, + 'new file name' + ); + }); + wrapper.update(); + expect(wrapper.find('input#mock_file-filename').prop('value')).toEqual( + 'new file name' + ); + await act(async () => { + wrapper.find('button[aria-label="Revert"]').invoke('onClick')(); + }); + wrapper.update(); + expect(wrapper.find('input#mock_file-filename').prop('value')).toEqual(''); + }); }); From bbde149ab1d65d09019ac5c83defbbcbb90506b1 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 4 Dec 2020 15:19:04 -0500 Subject: [PATCH 11/15] Add UI settings form --- .../src/screens/Setting/UI/UI.test.jsx | 30 +++- .../src/screens/Setting/UI/UIEdit/UIEdit.jsx | 136 ++++++++++++++--- .../screens/Setting/UI/UIEdit/UIEdit.test.jsx | 143 +++++++++++++++++- .../screens/Setting/shared/SharedFields.jsx | 73 ++++++++- .../Setting/shared/SharedFields.test.jsx | 33 ++++ .../shared/data.allSettingOptions.json | 23 +++ 6 files changed, 405 insertions(+), 33 deletions(-) diff --git a/awx/ui_next/src/screens/Setting/UI/UI.test.jsx b/awx/ui_next/src/screens/Setting/UI/UI.test.jsx index ac7a31d608..fc5aafadcd 100644 --- a/awx/ui_next/src/screens/Setting/UI/UI.test.jsx +++ b/awx/ui_next/src/screens/Setting/UI/UI.test.jsx @@ -6,11 +6,17 @@ import { waitForElement, } from '../../../../testUtils/enzymeHelpers'; import { SettingsAPI } from '../../../api'; +import { SettingsProvider } from '../../../contexts/Settings'; +import mockAllOptions from '../shared/data.allSettingOptions.json'; import UI from './UI'; jest.mock('../../../api/models/Settings'); SettingsAPI.readCategory.mockResolvedValue({ - data: {}, + data: { + CUSTOM_LOGIN_INFO: '', + CUSTOM_LOGO: '', + PENDO_TRACKING_STATE: 'off', + }, }); describe('', () => { @@ -26,9 +32,14 @@ describe('', () => { initialEntries: ['/settings/ui/details'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); expect(wrapper.find('UIDetail').length).toBe(1); @@ -39,9 +50,14 @@ describe('', () => { initialEntries: ['/settings/ui/edit'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); expect(wrapper.find('UIEdit').length).toBe(1); diff --git a/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx index c8d0f4df78..348abf4294 100644 --- a/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx +++ b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx @@ -1,25 +1,125 @@ -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, + FileUploadField, + TextAreaField, +} from '../../shared/SharedFields'; +import useModal from '../../../../util/useModal'; +import useRequest from '../../../../util/useRequest'; +import { SettingsAPI } from '../../../../api'; + +function UIEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { isLoading, error, request: fetchUI, result: uiData } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('ui'); + const mergedData = {}; + Object.keys(data).forEach(key => { + if (!options[key]) { + return; + } + mergedData[key] = options[key]; + mergedData[key].value = data[key]; + }); + return mergedData; + }, [options]), + null + ); + + useEffect(() => { + fetchUI(); + }, [fetchUI]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async values => { + await SettingsAPI.updateAll(values); + history.push('/settings/ui/details'); + }, + [history] + ), + null + ); + + const handleSubmit = async form => { + await submitForm(form); + }; + + const handleRevertAll = async () => { + const defaultValues = Object.assign( + ...Object.entries(uiData).map(([key, value]) => ({ + [key]: value.default, + })) + ); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/ui/details'); + }; + + const initialValues = fields => + Object.keys(fields).reduce((acc, key) => { + acc[key] = fields[key].value ?? ''; + return acc; + }, {}); -function UIEdit({ i18n }) { return ( - {i18n._(t`Edit form coming soon :)`)} - - - + {isLoading && } + {!isLoading && error && } + {!isLoading && uiData && ( + + {formik => ( +
+ + + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} +
+ )}
); } -export default withI18n()(UIEdit); +export default UIEdit; diff --git a/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.test.jsx b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.test.jsx index c51fb06fa7..adb43a788c 100644 --- a/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.test.jsx @@ -1,16 +1,151 @@ 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 UIEdit from './UIEdit'; +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: { + CUSTOM_LOGIN_INFO: 'mock info', + CUSTOM_LOGO: 'data:mock/jpeg;', + PENDO_TRACKING_STATE: 'detailed', + }, +}); + describe('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/ui/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('UIEdit').length).toBe(1); }); + + test('should display expected form fields', async () => { + expect(wrapper.find('FormGroup[label="Custom Login Info"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Custom Logo"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="User Analytics Tracking State"]').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({ + CUSTOM_LOGIN_INFO: '', + CUSTOM_LOGO: '', + PENDO_TRACKING_STATE: 'off', + }); + }); + + test('should successfully send request to api on form submission', async () => { + act(() => { + wrapper.find('textarea#CUSTOM_LOGIN_INFO').simulate('change', { + target: { value: 'new login info', name: 'CUSTOM_LOGIN_INFO' }, + }); + wrapper + .find('FormGroup[fieldId="CUSTOM_LOGO"] 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({ + CUSTOM_LOGIN_INFO: 'new login info', + CUSTOM_LOGO: '', + PENDO_TRACKING_STATE: 'detailed', + }); + }); + + test('should navigate to ui detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual('/settings/ui/details'); + }); + + test('should navigate to ui detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/ui/details'); + }); + + test('should display error message on unsuccessful submission', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error)); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + }); + + test('should display ContentError on throw', async () => { + SettingsAPI.readCategory.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx index f668289976..7338efb8a2 100644 --- a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx +++ b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx @@ -7,9 +7,11 @@ import { FileUpload, FormGroup as PFFormGroup, InputGroup, - TextInput, Switch, + TextArea, + TextInput, } from '@patternfly/react-core'; +import FileUploadIcon from '@patternfly/react-icons/dist/js/icons/file-upload-icon'; import styled from 'styled-components'; import AnsibleSelect from '../../../components/AnsibleSelect'; import CodeMirrorInput from '../../../components/CodeMirrorInput'; @@ -223,6 +225,44 @@ InputField.propTypes = { isRequired: bool, }; +const TextAreaField = withI18n()( + ({ i18n, name, config, isRequired = false }) => { + const validate = isRequired ? required(null, i18n) : null; + const [field, meta] = useField({ name, validate }); + const isValid = !(meta.touched && meta.error); + + return config ? ( + +