diff options
163 files changed, 3350 insertions, 1257 deletions
diff --git a/.dockerignore b/.dockerignore index f5faf1f0e3..46c83b0467 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1 @@ -.git awx/ui/node_modules diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd3da38b51..e311ecfa1c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,7 +85,7 @@ If you're not using Docker for Mac, or Docker for Windows, you may need, or choo #### Frontend Development -See [the ui development documentation](awx/ui/README.md). +See [the ui development documentation](awx/ui_next/CONTRIBUTING.md). ### Build the environment @@ -158,7 +158,7 @@ $ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 44251b476f98 gcr.io/ansible-tower-engineering/awx_devel:devel "/entrypoint.sh /bin…" 27 seconds ago Up 23 seconds 0.0.0.0:6899->6899/tcp, 0.0.0.0:7899-7999->7899-7999/tcp, 0.0.0.0:8013->8013/tcp, 0.0.0.0:8043->8043/tcp, 0.0.0.0:8080->8080/tcp, 22/tcp, 0.0.0.0:8888->8888/tcp tools_awx_run_9e820694d57e 40de380e3c2e redis:latest "docker-entrypoint.s…" 28 seconds ago Up 26 seconds -b66a506d3007 postgres:10 "docker-entrypoint.s…" 28 seconds ago Up 26 seconds 0.0.0.0:5432->5432/tcp tools_postgres_1 +b66a506d3007 postgres:12 "docker-entrypoint.s…" 28 seconds ago Up 26 seconds 0.0.0.0:5432->5432/tcp tools_postgres_1 ``` **NOTE** @@ -19,7 +19,8 @@ PYCURL_SSL_LIBRARY ?= openssl COMPOSE_TAG ?= $(GIT_BRANCH) COMPOSE_HOST ?= $(shell hostname) -VENV_BASE ?= /venv +VENV_BASE ?= /var/lib/awx/venv/ +COLLECTION_BASE ?= /var/lib/awx/vendor/awx_ansible_collections SCL_PREFIX ?= CELERY_SCHEDULE_FILE ?= /var/lib/awx/beat.db @@ -270,7 +271,7 @@ 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=/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:supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver" daphne: @if [ "$(VENV_BASE)" ]; then \ @@ -340,7 +341,7 @@ check: flake8 pep8 # pyflakes pylint awx-link: [ -d "/awx_devel/awx.egg-info" ] || python3 /awx_devel/setup.py egg_info_dev - cp -f /tmp/awx.egg-link /venv/awx/lib/python$(PYTHON_VERSION)/site-packages/awx.egg-link + cp -f /tmp/awx.egg-link /var/lib/awx/venv/awx/lib/python$(PYTHON_VERSION)/site-packages/awx.egg-link TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests @@ -618,7 +619,10 @@ clean-elk: docker rm tools_kibana_1 psql-container: - docker run -it --net tools_default --rm postgres:10 sh -c 'exec psql -h "postgres" -p "5432" -U postgres' + docker run -it --net tools_default --rm postgres:12 sh -c 'exec psql -h "postgres" -p "5432" -U postgres' 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" @@ -1,7 +1,5 @@ [![Gated by Zuul](https://zuul-ci.org/gated.svg)](https://ansible.softwarefactory-project.io/zuul/status) -<img src="https://raw.githubusercontent.com/ansible/awx-logos/master/awx/ui/client/assets/logo-login.svg?sanitize=true" width=200 alt="AWX" /> - AWX provides a web-based user interface, REST API, and task engine built on top of [Ansible](https://github.com/ansible/ansible). It is the upstream project for [Tower](https://www.ansible.com/tower), a commercial derivative of AWX. To install AWX, please view the [Install guide](./INSTALL.md). diff --git a/awx/conf/settings.py b/awx/conf/settings.py index d2733ce879..4b18e3d9f6 100644 --- a/awx/conf/settings.py +++ b/awx/conf/settings.py @@ -4,6 +4,7 @@ import logging import sys import threading import time +import os # Django from django.conf import LazySettings @@ -247,6 +248,7 @@ class SettingsWrapper(UserSettingsHolder): # These values have to be stored via self.__dict__ in this way to get # around the magic __setattr__ method on this class (which is used to # store API-assigned settings in the database). + self.__dict__['__forks__'] = {} self.__dict__['default_settings'] = default_settings self.__dict__['_awx_conf_settings'] = self self.__dict__['_awx_conf_preload_expires'] = None @@ -255,6 +257,26 @@ class SettingsWrapper(UserSettingsHolder): self.__dict__['cache'] = EncryptedCacheProxy(cache, registry) self.__dict__['registry'] = registry + # record the current pid so we compare it post-fork for + # processes like the dispatcher and callback receiver + self.__dict__['pid'] = os.getpid() + + def __clean_on_fork__(self): + pid = os.getpid() + # if the current pid does *not* match the value on self, it means + # that value was copied on fork, and we're now in a *forked* process; + # the *first* time we enter this code path (on setting access), + # forcibly close DB/cache sockets and set a marker so we don't run + # this code again _in this process_ + # + if pid != self.__dict__['pid'] and pid not in self.__dict__['__forks__']: + self.__dict__['__forks__'][pid] = True + # It's important to close these post-fork, because we + # don't want the forked processes to inherit the open sockets + # for the DB and cache connections (that way lies race conditions) + connection.close() + django_cache.close() + @cached_property def all_supported_settings(self): return self.registry.get_registered_settings() @@ -330,6 +352,7 @@ class SettingsWrapper(UserSettingsHolder): self.cache.set_many(settings_to_cache, timeout=SETTING_CACHE_TIMEOUT) def _get_local(self, name, validate=True): + self.__clean_on_fork__() self._preload_cache() cache_key = Setting.get_cache_key(name) try: diff --git a/awx/locale/django.pot b/awx/locale/django.pot index 3d2cf41999..e5fbe05390 100644 --- a/awx/locale/django.pot +++ b/awx/locale/django.pot @@ -3355,6 +3355,15 @@ msgid "" msgstr "" #: awx/main/models/credential/__init__.py:824 +msgid "Region Name" +msgstr "" + +#: awx/main/models/credential/__init__.py:826 +msgid "" +"For some cloud providers, like OVH, region must be specified." +msgstr "" + +#: awx/main/models/credential/__init__.py:824 #: awx/main/models/credential/__init__.py:1131 #: awx/main/models/credential/__init__.py:1166 msgid "Verify SSL" diff --git a/awx/locale/en-us/LC_MESSAGES/django.po b/awx/locale/en-us/LC_MESSAGES/django.po index 3d2cf41999..e5fbe05390 100644 --- a/awx/locale/en-us/LC_MESSAGES/django.po +++ b/awx/locale/en-us/LC_MESSAGES/django.po @@ -3355,6 +3355,15 @@ msgid "" msgstr "" #: awx/main/models/credential/__init__.py:824 +msgid "Region Name" +msgstr "" + +#: awx/main/models/credential/__init__.py:826 +msgid "" +"For some cloud providers, like OVH, region must be specified." +msgstr "" + +#: awx/main/models/credential/__init__.py:824 #: awx/main/models/credential/__init__.py:1131 #: awx/main/models/credential/__init__.py:1166 msgid "Verify SSL" diff --git a/awx/locale/fr/LC_MESSAGES/django.po b/awx/locale/fr/LC_MESSAGES/django.po index 62c2ba7292..bcb54c548b 100644 --- a/awx/locale/fr/LC_MESSAGES/django.po +++ b/awx/locale/fr/LC_MESSAGES/django.po @@ -3294,6 +3294,16 @@ msgid "" "common scenarios." msgstr "Les domaines OpenStack définissent les limites administratives. Ils sont nécessaires uniquement pour les URL d’authentification Keystone v3. Voir la documentation Ansible Tower pour les scénarios courants." +#: awx/main/models/credential/__init__.py:824 +msgid "Region Name" +msgstr "Nom de la region" + +#: awx/main/models/credential/__init__.py:826 +msgid "" +"For some cloud providers, like OVH, region must be specified." +msgstr "" +"Chez certains fournisseurs, comme OVH, vous devez spécifier le nom de la région" + #: awx/main/models/credential/__init__.py:812 #: awx/main/models/credential/__init__.py:1110 #: awx/main/models/credential/__init__.py:1144 diff --git a/awx/main/isolated/manager.py b/awx/main/isolated/manager.py index 1c0978f432..de4783e277 100644 --- a/awx/main/isolated/manager.py +++ b/awx/main/isolated/manager.py @@ -7,6 +7,7 @@ import tempfile import time import logging import yaml +import datetime from django.conf import settings import ansible_runner @@ -123,6 +124,7 @@ class IsolatedManager(object): dir=private_data_dir ) params = self.runner_params.copy() + params.get('envvars', dict())['ANSIBLE_CALLBACK_WHITELIST'] = 'profile_tasks' params['playbook'] = playbook params['private_data_dir'] = iso_dir if idle_timeout: @@ -168,7 +170,8 @@ class IsolatedManager(object): extravars = { 'src': self.private_data_dir, 'dest': settings.AWX_PROOT_BASE_PATH, - 'ident': self.ident + 'ident': self.ident, + 'job_id': self.instance.id, } if playbook: extravars['playbook'] = playbook @@ -204,7 +207,10 @@ class IsolatedManager(object): :param interval: an interval (in seconds) to wait between status polls """ interval = interval if interval is not None else settings.AWX_ISOLATED_CHECK_INTERVAL - extravars = {'src': self.private_data_dir} + extravars = { + 'src': self.private_data_dir, + 'job_id': self.instance.id + } status = 'failed' rc = None last_check = time.time() @@ -220,9 +226,13 @@ class IsolatedManager(object): logger.warning('Isolated job {} was manually canceled.'.format(self.instance.id)) logger.debug('Checking on isolated job {} with `check_isolated.yml`.'.format(self.instance.id)) + time_start = datetime.datetime.now() runner_obj = self.run_management_playbook('check_isolated.yml', self.private_data_dir, extravars=extravars) + time_end = datetime.datetime.now() + time_diff = time_end - time_start + logger.debug('Finished checking on isolated job {} with `check_isolated.yml` took {} seconds.'.format(self.instance.id, time_diff.total_seconds())) status, rc = runner_obj.status, runner_obj.rc if self.check_callback is not None and not self.captured_command_artifact: diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index 30529cdf72..a86cc3db48 100644 --- a/awx/main/management/commands/inventory_import.py +++ b/awx/main/management/commands/inventory_import.py @@ -133,7 +133,7 @@ class AnsibleInventoryLoader(object): # NOTE: why do we add "python" to the start of these args? # the script that runs ansible-inventory specifies a python interpreter # that makes no sense in light of the fact that we put all the dependencies - # inside of /venv/ansible, so we override the specified interpreter + # inside of /var/lib/awx/venv/ansible, so we override the specified interpreter # https://github.com/ansible/ansible/issues/50714 bargs = ['python', ansible_inventory_path, '-i', self.source] bargs.extend(['--playbook-dir', functioning_dir(self.source)]) diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 66db962430..e8a2884083 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -820,6 +820,11 @@ ManagedCredentialType( 'URLs. Refer to Ansible Tower documentation for ' 'common scenarios.') }, { + 'id': 'region', + 'label': ugettext_noop('Region Name'), + 'type': 'string', + 'help_text': ugettext_noop('For some cloud providers, like OVH, region must be specified'), + }, { 'id': 'verify_ssl', 'label': ugettext_noop('Verify SSL'), 'type': 'boolean', diff --git a/awx/main/models/credential/injectors.py b/awx/main/models/credential/injectors.py index 75d1f17bfe..ef30b91945 100644 --- a/awx/main/models/credential/injectors.py +++ b/awx/main/models/credential/injectors.py @@ -82,6 +82,7 @@ def _openstack_data(cred): if cred.has_input('domain'): openstack_auth['domain_name'] = cred.get_input('domain', default='') verify_state = cred.get_input('verify_ssl', default=True) + openstack_data = { 'clouds': { 'devstack': { @@ -90,6 +91,10 @@ def _openstack_data(cred): }, }, } + + if cred.has_input('project_region_name'): + openstack_data['clouds']['devstack']['region_name'] = cred.get_input('project_region_name', default='') + return openstack_data diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index 11d97c7690..33562e7fca 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -12,7 +12,7 @@ from django.core.mail.message import EmailMessage from django.db import connection from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import smart_str, force_text -from jinja2 import sandbox +from jinja2 import sandbox, ChainableUndefined from jinja2.exceptions import TemplateSyntaxError, UndefinedError, SecurityError # AWX @@ -429,7 +429,7 @@ class JobNotificationMixin(object): raise RuntimeError("Define me") def build_notification_message(self, nt, status): - env = sandbox.ImmutableSandboxedEnvironment() + env = sandbox.ImmutableSandboxedEnvironment(undefined=ChainableUndefined) from awx.api.serializers import UnifiedJobSerializer job_serialization = UnifiedJobSerializer(self).to_representation(self) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 3bf67d9e65..1fb7d62cef 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -378,6 +378,7 @@ def gather_analytics(): from awx.conf.models import Setting from rest_framework.fields import DateTimeField + from awx.main.signals import disable_activity_stream if not settings.INSIGHTS_TRACKING_STATE: return if not (settings.AUTOMATION_ANALYTICS_URL and settings.REDHAT_USERNAME and settings.REDHAT_PASSWORD): @@ -414,7 +415,8 @@ def gather_analytics(): if not _gather_and_ship(incremental_collectors, since=start, until=until): break start = until - settings.AUTOMATION_ANALYTICS_LAST_GATHER = until + with disable_activity_stream(): + settings.AUTOMATION_ANALYTICS_LAST_GATHER = until if subset: _gather_and_ship(subset, since=since, until=gather_time) diff --git a/awx/main/tests/functional/models/test_job.py b/awx/main/tests/functional/models/test_job.py index ac8912506f..c6c4d2d6e6 100644 --- a/awx/main/tests/functional/models/test_job.py +++ b/awx/main/tests/functional/models/test_job.py @@ -16,7 +16,7 @@ def test_awx_virtualenv_from_settings(inventory, project, machine_credential): ) jt.credentials.add(machine_credential) job = jt.create_unified_job() - assert job.ansible_virtualenv_path == '/venv/ansible' + assert job.ansible_virtualenv_path == '/var/lib/awx/venv/ansible' @pytest.mark.django_db @@ -43,28 +43,28 @@ def test_awx_custom_virtualenv(inventory, project, machine_credential, organizat jt.credentials.add(machine_credential) job = jt.create_unified_job() - job.organization.custom_virtualenv = '/venv/fancy-org' + job.organization.custom_virtualenv = '/var/lib/awx/venv/fancy-org' job.organization.save() - assert job.ansible_virtualenv_path == '/venv/fancy-org' + assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-org' - job.project.custom_virtualenv = '/venv/fancy-proj' + job.project.custom_virtualenv = '/var/lib/awx/venv/fancy-proj' job.project.save() - assert job.ansible_virtualenv_path == '/venv/fancy-proj' + assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-proj' - job.job_template.custom_virtualenv = '/venv/fancy-jt' + job.job_template.custom_virtualenv = '/var/lib/awx/venv/fancy-jt' job.job_template.save() - assert job.ansible_virtualenv_path == '/venv/fancy-jt' + assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-jt' @pytest.mark.django_db def test_awx_custom_virtualenv_without_jt(project): - project.custom_virtualenv = '/venv/fancy-proj' + project.custom_virtualenv = '/var/lib/awx/venv/fancy-proj' project.save() job = Job(project=project) job.save() job = Job.objects.get(pk=job.id) - assert job.ansible_virtualenv_path == '/venv/fancy-proj' + assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-proj' @pytest.mark.django_db diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index f94c70c739..166ea95f19 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -180,7 +180,7 @@ def test_openstack_client_config_generation(mocker, source, expected, private_da 'source_vars_dict': {}, 'get_cloud_credential': mocker.Mock(return_value=credential), 'get_extra_credentials': lambda x: [], - 'ansible_virtualenv_path': '/venv/foo' + 'ansible_virtualenv_path': '/var/lib/awx/venv/foo' }) cloud_config = update.build_private_data(inventory_update, private_data_dir) cloud_credential = yaml.safe_load( @@ -224,6 +224,52 @@ def test_openstack_client_config_generation_with_project_domain_name(mocker, sou 'source_vars_dict': {}, 'get_cloud_credential': mocker.Mock(return_value=credential), 'get_extra_credentials': lambda x: [], + 'ansible_virtualenv_path': '/var/lib/awx/venv/foo' + }) + cloud_config = update.build_private_data(inventory_update, private_data_dir) + cloud_credential = yaml.safe_load( + cloud_config.get('credentials')[credential] + ) + assert cloud_credential['clouds'] == { + 'devstack': { + 'auth': { + 'auth_url': 'https://keystone.openstack.example.org', + 'password': 'secrete', + 'project_name': 'demo-project', + 'username': 'demo', + 'domain_name': 'my-demo-domain', + 'project_domain_name': 'project-domain', + }, + 'verify': expected, + 'private': True, + } + } + + +@pytest.mark.parametrize("source,expected", [ + (None, True), (False, False), (True, True) +]) +def test_openstack_client_config_generation_with_project_region_name(mocker, source, expected, private_data_dir): + update = tasks.RunInventoryUpdate() + credential_type = CredentialType.defaults['openstack']() + inputs = { + 'host': 'https://keystone.openstack.example.org', + 'username': 'demo', + 'password': 'secrete', + 'project': 'demo-project', + 'domain': 'my-demo-domain', + 'project_domain_name': 'project-domain', + 'project_region_name': 'region-name', + } + if source is not None: + inputs['verify_ssl'] = source + credential = Credential(pk=1, credential_type=credential_type, inputs=inputs) + + inventory_update = mocker.Mock(**{ + 'source': 'openstack', + 'source_vars_dict': {}, + 'get_cloud_credential': mocker.Mock(return_value=credential), + 'get_extra_credentials': lambda x: [], 'ansible_virtualenv_path': '/venv/foo' }) cloud_config = update.build_private_data(inventory_update, private_data_dir) @@ -242,6 +288,7 @@ def test_openstack_client_config_generation_with_project_domain_name(mocker, sou }, 'verify': expected, 'private': True, + 'region_name': 'region-name', } } @@ -267,7 +314,7 @@ def test_openstack_client_config_generation_with_private_source_vars(mocker, sou 'source_vars_dict': {'private': source}, 'get_cloud_credential': mocker.Mock(return_value=credential), 'get_extra_credentials': lambda x: [], - 'ansible_virtualenv_path': '/venv/foo' + 'ansible_virtualenv_path': '/var/lib/awx/venv/foo' }) cloud_config = update.build_private_data(inventory_update, private_data_dir) cloud_credential = yaml.load( @@ -625,13 +672,13 @@ class TestGenericRun(): def test_invalid_custom_virtualenv(self, patch_Job, private_data_dir): job = Job(project=Project(), inventory=Inventory()) - job.project.custom_virtualenv = '/venv/missing' + job.project.custom_virtualenv = '/var/lib/awx/venv/missing' task = tasks.RunJob() with pytest.raises(tasks.InvalidVirtualenvError) as e: task.build_env(job, private_data_dir) - assert 'Invalid virtual environment selected: /venv/missing' == str(e.value) + assert 'Invalid virtual environment selected: /var/lib/awx/venv/missing' == str(e.value) class TestAdhocRun(TestJobExecution): diff --git a/awx/playbooks/check_isolated.yml b/awx/playbooks/check_isolated.yml index 18b3305846..472b772fbb 100644 --- a/awx/playbooks/check_isolated.yml +++ b/awx/playbooks/check_isolated.yml @@ -9,6 +9,9 @@ - ansible.posix tasks: + - name: "Output job the playbook is running for" + debug: + msg: "Checking on job {{ job_id }}" - name: Determine if daemon process is alive. shell: "ansible-runner is-alive {{src}}" diff --git a/awx/playbooks/run_isolated.yml b/awx/playbooks/run_isolated.yml index 4e3b7b54ee..76ea42d17c 100644 --- a/awx/playbooks/run_isolated.yml +++ b/awx/playbooks/run_isolated.yml @@ -13,6 +13,10 @@ - ansible.posix tasks: + - name: "Output job the playbook is running for" + debug: + msg: "Checking on job {{ job_id }}" + - name: synchronize job environment with isolated host synchronize: copy_links: true diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index b9e7ecddb7..05c8a42f20 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -116,7 +116,7 @@ LOGIN_URL = '/api/login/' # Absolute filesystem path to the directory to host projects (with playbooks). # This directory should not be web-accessible. -PROJECTS_ROOT = os.path.join(BASE_DIR, 'projects') +PROJECTS_ROOT = '/var/lib/awx/projects/' # Absolute filesystem path to the directory to host collections for # running inventory imports, isolated playbooks @@ -125,10 +125,10 @@ AWX_ANSIBLE_COLLECTIONS_PATHS = os.path.join(BASE_DIR, 'vendor', 'awx_ansible_co # Absolute filesystem path to the directory for job status stdout (default for # development and tests, default for production defined in production.py). This # directory should not be web-accessible -JOBOUTPUT_ROOT = os.path.join(BASE_DIR, 'job_output') +JOBOUTPUT_ROOT = '/var/lib/awx/job_status/' # Absolute filesystem path to the directory to store logs -LOG_ROOT = os.path.join(BASE_DIR) +LOG_ROOT = '/var/log/tower/' # The heartbeat file for the tower scheduler SCHEDULE_METADATA_LOCATION = os.path.join(BASE_DIR, '.tower_cycle') @@ -932,6 +932,14 @@ LOGGING = { 'backupCount': 5, 'formatter':'simple', }, + 'isolated_manager': { + 'level': 'WARNING', + 'class':'logging.handlers.RotatingFileHandler', + 'filename': os.path.join(LOG_ROOT, 'isolated_manager.log'), + 'maxBytes': 1024 * 1024 * 5, # 5 MB + 'backupCount': 5, + 'formatter':'simple', + }, }, 'loggers': { 'django': { @@ -981,6 +989,11 @@ LOGGING = { 'awx.main.wsbroadcast': { 'handlers': ['wsbroadcast'], }, + 'awx.isolated.manager': { + 'level': 'WARNING', + 'handlers': ['console', 'file', 'isolated_manager'], + 'propagate': True + }, 'awx.isolated.manager.playbooks': { 'handlers': ['management_playbooks'], 'propagate': False diff --git a/awx/settings/development.py b/awx/settings/development.py index 108767b98c..9846705fa5 100644 --- a/awx/settings/development.py +++ b/awx/settings/development.py @@ -148,9 +148,9 @@ include(optional('/etc/tower/settings.py'), scope=locals()) include(optional('/etc/tower/conf.d/*.py'), scope=locals()) # Installed differently in Dockerfile compared to production versions -AWX_ANSIBLE_COLLECTIONS_PATHS = '/vendor/awx_ansible_collections' +AWX_ANSIBLE_COLLECTIONS_PATHS = '/var/lib/awx/vendor/awx_ansible_collections' -BASE_VENV_PATH = "/venv/" +BASE_VENV_PATH = "/var/lib/awx/venv/" ANSIBLE_VENV_PATH = os.path.join(BASE_VENV_PATH, "ansible") AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx") diff --git a/awx/settings/local_settings.py.docker_compose b/awx/settings/local_settings.py.docker_compose index 213f4efe4b..88ef90fd64 100644 --- a/awx/settings/local_settings.py.docker_compose +++ b/awx/settings/local_settings.py.docker_compose @@ -48,56 +48,12 @@ if "pytest" in sys.modules: } } -# Absolute filesystem path to the directory to host projects (with playbooks). -# This directory should NOT be web-accessible. -PROJECTS_ROOT = '/var/lib/awx/projects/' - # Location for cross-development of inventory plugins -AWX_ANSIBLE_COLLECTIONS_PATHS = '/vendor/awx_ansible_collections' - -# Absolute filesystem path to the directory for job status stdout -# This directory should not be web-accessible -JOBOUTPUT_ROOT = os.path.join(BASE_DIR, 'job_status') +AWX_ANSIBLE_COLLECTIONS_PATHS = '/var/lib/awx/vendor/awx_ansible_collections' # The UUID of the system, for HA. SYSTEM_UUID = '00000000-0000-0000-0000-000000000000' -# Local time zone for this installation. Choices can be found here: -# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name -# although not all choices may be available on all operating systems. -# On Unix systems, a value of None will cause Django to use the same -# timezone as the operating system. -# If running in a Windows environment this must be set to the same as your -# system time zone. -USE_TZ = True -TIME_ZONE = 'UTC' - -# Language code for this installation. All choices can be found here: -# http://www.i18nguy.com/unicode/language-identifiers.html -LANGUAGE_CODE = 'en-us' - -# SECURITY WARNING: keep the secret key used in production secret! -# Hardcoded values can leak through source control. Consider loading -# the secret key from an environment variable or a file instead. -SECRET_KEY = 'p7z7g1ql4%6+(6nlebb6hdk7sd^&fnjpal308%n%+p^_e6vo1y' - -# HTTP headers and meta keys to search to determine remote host name or IP. Add -# additional items to this list, such as "HTTP_X_FORWARDED_FOR", if behind a -# reverse proxy. -REMOTE_HOST_HEADERS = ['REMOTE_ADDR', 'REMOTE_HOST'] - -# If Tower is behind a reverse proxy/load balancer, use this setting to -# whitelist the proxy IP addresses from which Tower should trust custom -# REMOTE_HOST_HEADERS header values -# REMOTE_HOST_HEADERS = ['HTTP_X_FORWARDED_FOR', ''REMOTE_ADDR', 'REMOTE_HOST'] -# PROXY_IP_WHITELIST = ['10.0.1.100', '10.0.1.101'] -# If this setting is an empty list (the default), the headers specified by -# REMOTE_HOST_HEADERS will be trusted unconditionally') -PROXY_IP_WHITELIST = [] - -# Define additional environment variables to be passed to ansible subprocesses -#AWX_TASK_ENV['FOO'] = 'BAR' - # If set, use -vvv for project updates instead of -v for more output. # PROJECT_UPDATE_VVV=True @@ -108,40 +64,6 @@ PROXY_IP_WHITELIST = [] # Enable logging to syslog. Setting level to ERROR captures 500 errors, # WARNING also logs 4xx responses. -LOGGING['handlers']['syslog'] = { - 'level': 'WARNING', - 'filters': ['require_debug_false'], - 'class': 'logging.NullHandler', - 'formatter': 'simple', -} - -LOGGING['loggers']['django.request']['handlers'] = ['console'] -LOGGING['loggers']['rest_framework.request']['handlers'] = ['console'] -LOGGING['loggers']['awx']['handlers'] = ['console', 'external_logger'] -LOGGING['loggers']['awx.main.commands.run_callback_receiver']['handlers'] = [] # propogates to awx -LOGGING['loggers']['awx.main.tasks']['handlers'] = ['console', 'external_logger'] -LOGGING['loggers']['awx.main.scheduler']['handlers'] = ['console', 'external_logger'] -LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console'] -LOGGING['loggers']['social']['handlers'] = ['console'] -LOGGING['loggers']['system_tracking_migrations']['handlers'] = ['console'] -LOGGING['loggers']['rbac_migrations']['handlers'] = ['console'] -LOGGING['loggers']['awx.isolated.manager.playbooks']['handlers'] = ['console'] -LOGGING['handlers']['callback_receiver'] = {'class': 'logging.NullHandler'} -LOGGING['handlers']['fact_receiver'] = {'class': 'logging.NullHandler'} -LOGGING['handlers']['task_system'] = {'class': 'logging.NullHandler'} -LOGGING['handlers']['tower_warnings'] = {'class': 'logging.NullHandler'} -LOGGING['handlers']['rbac_migrations'] = {'class': 'logging.NullHandler'} -LOGGING['handlers']['system_tracking_migrations'] = {'class': 'logging.NullHandler'} -LOGGING['handlers']['management_playbooks'] = {'class': 'logging.NullHandler'} - - -# Enable the following lines to also log to a file. -#LOGGING['handlers']['file'] = { -# 'class': 'logging.FileHandler', -# 'filename': os.path.join(BASE_DIR, 'awx.log'), -# 'formatter': 'simple', -#} - # Enable the following lines to turn on lots of permissions-related logging. #LOGGING['loggers']['awx.main.access']['level'] = 'DEBUG' #LOGGING['loggers']['awx.main.signals']['level'] = 'DEBUG' @@ -154,74 +76,6 @@ LOGGING['handlers']['management_playbooks'] = {'class': 'logging.NullHandler'} #LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console'] #LOGGING['loggers']['django_auth_ldap']['level'] = 'DEBUG' -############################################################################### -# SCM TEST SETTINGS -############################################################################### - -# Define these variables to enable more complete testing of project support for -# SCM updates. The test repositories listed do not have to contain any valid -# playbooks. - -try: - path = os.path.expanduser(os.path.expandvars('~/.ssh/id_rsa')) - TEST_SSH_KEY_DATA = open(path, 'rb').read() -except IOError: - TEST_SSH_KEY_DATA = '' - -TEST_GIT_USERNAME = '' -TEST_GIT_PASSWORD = '' -TEST_GIT_KEY_DATA = TEST_SSH_KEY_DATA -TEST_GIT_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com.git' -TEST_GIT_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs.git' -TEST_GIT_PRIVATE_SSH = 'git@github.com:ansible/product-docs.git' - -TEST_SVN_USERNAME = '' -TEST_SVN_PASSWORD = '' -TEST_SVN_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com' -TEST_SVN_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs' - -# To test repo access via SSH login to localhost. -import getpass -try: - TEST_SSH_LOOPBACK_USERNAME = getpass.getuser() -except KeyError: - TEST_SSH_LOOPBACK_USERNAME = 'root' -TEST_SSH_LOOPBACK_PASSWORD = '' - -############################################################################### -# INVENTORY IMPORT TEST SETTINGS -############################################################################### - -# Define these variables to enable more complete testing of inventory import -# from cloud providers. - -# EC2 credentials -TEST_AWS_ACCESS_KEY_ID = '' -TEST_AWS_SECRET_ACCESS_KEY = '' -TEST_AWS_REGIONS = 'all' -# Check IAM STS credentials -TEST_AWS_SECURITY_TOKEN = '' - -# Rackspace credentials -TEST_RACKSPACE_USERNAME = '' -TEST_RACKSPACE_API_KEY = '' -TEST_RACKSPACE_REGIONS = 'all' - -# VMware credentials -TEST_VMWARE_HOST = '' -TEST_VMWARE_USER = '' -TEST_VMWARE_PASSWORD = '' - -# OpenStack credentials -TEST_OPENSTACK_HOST = '' -TEST_OPENSTACK_USER = '' -TEST_OPENSTACK_PASSWORD = '' -TEST_OPENSTACK_PROJECT = '' - -# Azure credentials. -TEST_AZURE_USERNAME = '' -TEST_AZURE_KEY_DATA = '' - BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖' BROADCAST_WEBSOCKET_PORT = 8013 BROADCAST_WEBSOCKET_VERIFY_CERT = False diff --git a/awx/settings/local_settings.py.example b/awx/settings/local_settings.py.example deleted file mode 100644 index 59f3bdfa6a..0000000000 --- a/awx/settings/local_settings.py.example +++ /dev/null @@ -1,192 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. (formerly AnsibleWorks, Inc.) -# All Rights Reserved. - -# Local Django settings for AWX project. Rename to "local_settings.py" and -# edit as needed for your development environment. - -# All variables defined in awx/settings/development.py will already be loaded -# into the global namespace before this file is loaded, to allow for reading -# and updating the default settings as needed. - -############################################################################### -# MISC PROJECT SETTINGS -############################################################################### - -# Database settings to use PostgreSQL for development. -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'awx-dev', - 'USER': 'awx-dev', - 'PASSWORD': 'AWXsome1', - 'HOST': 'localhost', - 'PORT': '', - } -} - -# Use SQLite for unit tests instead of PostgreSQL. If the lines below are -# commented out, Django will create the test_awx-dev database in PostgreSQL to -# run unit tests. -if is_testing(sys.argv): - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'awx.sqlite3'), - 'TEST': { - # Test database cannot be :memory: for tests. - 'NAME': os.path.join(BASE_DIR, 'awx_test.sqlite3'), - }, - } - } - -# AMQP configuration. -BROKER_URL = 'amqp://guest:guest@localhost:5672' - -# Absolute filesystem path to the directory to host projects (with playbooks). -# This directory should NOT be web-accessible. -PROJECTS_ROOT = os.path.join(BASE_DIR, 'projects') - -# Absolute filesystem path to the directory for job status stdout -# This directory should not be web-accessible -JOBOUTPUT_ROOT = os.path.join(BASE_DIR, 'job_status') - -# The UUID of the system, for HA. -SYSTEM_UUID = '00000000-0000-0000-0000-000000000000' - -# Local time zone for this installation. Choices can be found here: -# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name -# although not all choices may be available on all operating systems. -# On Unix systems, a value of None will cause Django to use the same -# timezone as the operating system. -# If running in a Windows environment this must be set to the same as your -# system time zone. -TIME_ZONE = None - -# Language code for this installation. All choices can be found here: -# http://www.i18nguy.com/unicode/language-identifiers.html -LANGUAGE_CODE = 'en-us' - -# SECURITY WARNING: keep the secret key used in production secret! -# Hardcoded values can leak through source control. Consider loading -# the secret key from an environment variable or a file instead. -SECRET_KEY = 'p7z7g1ql4%6+(6nlebb6hdk7sd^&fnjpal308%n%+p^_e6vo1y' - -# HTTP headers and meta keys to search to determine remote host name or IP. Add -# additional items to this list, such as "HTTP_X_FORWARDED_FOR", if behind a -# reverse proxy. -REMOTE_HOST_HEADERS = ['REMOTE_ADDR', 'REMOTE_HOST'] - -# If Tower is behind a reverse proxy/load balancer, use this setting to -# whitelist the proxy IP addresses from which Tower should trust custom -# REMOTE_HOST_HEADERS header values -# REMOTE_HOST_HEADERS = ['HTTP_X_FORWARDED_FOR', ''REMOTE_ADDR', 'REMOTE_HOST'] -# PROXY_IP_WHITELIST = ['10.0.1.100', '10.0.1.101'] -# If this setting is an empty list (the default), the headers specified by -# REMOTE_HOST_HEADERS will be trusted unconditionally') -PROXY_IP_WHITELIST = [] - -# Define additional environment variables to be passed to ansible subprocesses -#AWX_TASK_ENV['FOO'] = 'BAR' - -# If set, use -vvv for project updates instead of -v for more output. -# PROJECT_UPDATE_VVV=True - -############################################################################### -# LOGGING SETTINGS -############################################################################### - -# Enable logging to syslog. Setting level to ERROR captures 500 errors, -# WARNING also logs 4xx responses. -LOGGING['handlers']['syslog'] = { - 'level': 'WARNING', - 'filters': [], - 'class': 'logging.handlers.SysLogHandler', - 'address': '/dev/log', - 'facility': 'local0', - 'formatter': 'simple', -} - -# Enable the following lines to also log to a file. -#LOGGING['handlers']['file'] = { -# 'class': 'logging.FileHandler', -# 'filename': os.path.join(BASE_DIR, 'awx.log'), -# 'formatter': 'simple', -#} - -# Enable the following lines to turn on lots of permissions-related logging. -#LOGGING['loggers']['awx.main.access']['level'] = 'DEBUG' -#LOGGING['loggers']['awx.main.signals']['level'] = 'DEBUG' -#LOGGING['loggers']['awx.main.permissions']['level'] = 'DEBUG' - -# Enable the following line to turn on database settings logging. -#LOGGING['loggers']['awx.conf']['level'] = 'DEBUG' - -# Enable the following lines to turn on LDAP auth logging. -#LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console'] -#LOGGING['loggers']['django_auth_ldap']['level'] = 'DEBUG' - -############################################################################### -# SCM TEST SETTINGS -############################################################################### - -# Define these variables to enable more complete testing of project support for -# SCM updates. The test repositories listed do not have to contain any valid -# playbooks. - -try: - path = os.path.expanduser(os.path.expandvars('~/.ssh/id_rsa')) - TEST_SSH_KEY_DATA = file(path, 'rb').read() -except IOError: - TEST_SSH_KEY_DATA = '' - -TEST_GIT_USERNAME = '' -TEST_GIT_PASSWORD = '' -TEST_GIT_KEY_DATA = TEST_SSH_KEY_DATA -TEST_GIT_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com.git' -TEST_GIT_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs.git' -TEST_GIT_PRIVATE_SSH = 'git@github.com:ansible/product-docs.git' - -TEST_SVN_USERNAME = '' -TEST_SVN_PASSWORD = '' -TEST_SVN_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com' -TEST_SVN_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs' - -# To test repo access via SSH login to localhost. -import getpass -TEST_SSH_LOOPBACK_USERNAME = getpass.getuser() -TEST_SSH_LOOPBACK_PASSWORD = '' - -############################################################################### -# INVENTORY IMPORT TEST SETTINGS -############################################################################### - -# Define these variables to enable more complete testing of inventory import -# from cloud providers. - -# EC2 credentials -TEST_AWS_ACCESS_KEY_ID = '' -TEST_AWS_SECRET_ACCESS_KEY = '' -TEST_AWS_REGIONS = 'all' -# Check IAM STS credentials -TEST_AWS_SECURITY_TOKEN = '' - - -# Rackspace credentials -TEST_RACKSPACE_USERNAME = '' -TEST_RACKSPACE_API_KEY = '' -TEST_RACKSPACE_REGIONS = 'all' - -# VMware credentials -TEST_VMWARE_HOST = '' -TEST_VMWARE_USER = '' -TEST_VMWARE_PASSWORD = '' - -# OpenStack credentials -TEST_OPENSTACK_HOST = '' -TEST_OPENSTACK_USER = '' -TEST_OPENSTACK_PASSWORD = '' -TEST_OPENSTACK_PROJECT = '' - -# Azure credentials. -TEST_AZURE_USERNAME = '' -TEST_AZURE_KEY_DATA = '' diff --git a/awx/settings/production.py b/awx/settings/production.py index fb24b7087f..02681265e6 100644 --- a/awx/settings/production.py +++ b/awx/settings/production.py @@ -30,10 +30,6 @@ SECRET_KEY = None # See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts ALLOWED_HOSTS = [] -# Absolute filesystem path to the directory for job status stdout -# This directory should not be web-accessible -JOBOUTPUT_ROOT = '/var/lib/awx/job_status/' - # The heartbeat file for the tower scheduler SCHEDULE_METADATA_LOCATION = '/var/lib/awx/.tower_cycle' @@ -46,15 +42,6 @@ AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx") AWX_ISOLATED_USERNAME = 'awx' -LOGGING['handlers']['tower_warnings']['filename'] = '/var/log/tower/tower.log' # noqa -LOGGING['handlers']['callback_receiver']['filename'] = '/var/log/tower/callback_receiver.log' # noqa -LOGGING['handlers']['dispatcher']['filename'] = '/var/log/tower/dispatcher.log' # noqa -LOGGING['handlers']['wsbroadcast']['filename'] = '/var/log/tower/wsbroadcast.log' # noqa -LOGGING['handlers']['task_system']['filename'] = '/var/log/tower/task_system.log' # noqa -LOGGING['handlers']['management_playbooks']['filename'] = '/var/log/tower/management_playbooks.log' # noqa -LOGGING['handlers']['system_tracking_migrations']['filename'] = '/var/log/tower/tower_system_tracking_migrations.log' # noqa -LOGGING['handlers']['rbac_migrations']['filename'] = '/var/log/tower/tower_rbac_migrations.log' # noqa - # Store a snapshot of default settings at this point before loading any # customizable config files. DEFAULTS_SNAPSHOT = {} diff --git a/awx/ui_next/CONTRIBUTING.md b/awx/ui_next/CONTRIBUTING.md index 575e08e913..c0a3eaefc4 100644 --- a/awx/ui_next/CONTRIBUTING.md +++ b/awx/ui_next/CONTRIBUTING.md @@ -57,7 +57,7 @@ The UI is built using [ReactJS](https://reactjs.org/docs/getting-started.html) a The AWX UI requires the following: -- Node 10.x LTS +- Node 14.x LTS - NPM 6.x LTS Run the following to install all the dependencies: diff --git a/awx/ui_next/package-lock.json b/awx/ui_next/package-lock.json index aa83635125..a64e834f55 100644 --- a/awx/ui_next/package-lock.json +++ b/awx/ui_next/package-lock.json @@ -3387,12 +3387,18 @@ "dev": true }, "axios": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz", - "integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", "requires": { - "follow-redirects": "1.5.10", - "is-buffer": "^2.0.2" + "follow-redirects": "^1.10.0" + }, + "dependencies": { + "follow-redirects": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.1.tgz", + "integrity": "sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg==" + } } }, "axobject-query": { @@ -4195,6 +4201,16 @@ "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==", "dev": true }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "optional": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, "bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -5961,6 +5977,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, "requires": { "ms": "2.0.0" } @@ -7911,6 +7928,13 @@ } } }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "optional": true + }, "filesize": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/filesize/-/filesize-6.0.1.tgz", @@ -8110,6 +8134,7 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "dev": true, "requires": { "debug": "=3.1.0" } @@ -9500,11 +9525,6 @@ "call-bind": "^1.0.0" } }, - "is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==" - }, "is-callable": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz", @@ -10315,7 +10335,11 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "dev": true, - "optional": true + "optional": true, + "requires": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + } }, "is-buffer": { "version": "1.1.6", @@ -11731,7 +11755,8 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true }, "multicast-dns": { "version": "6.2.3", @@ -11755,6 +11780,13 @@ "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=", "dev": true }, + "nan": { + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", + "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==", + "dev": true, + "optional": true + }, "nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -17683,7 +17715,11 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "dev": true, - "optional": true + "optional": true, + "requires": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + } }, "glob-parent": { "version": "3.1.0", @@ -18364,7 +18400,11 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "dev": true, - "optional": true + "optional": true, + "requires": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + } }, "glob-parent": { "version": "3.1.0", diff --git a/awx/ui_next/package.json b/awx/ui_next/package.json index 551f0cb543..b052a3183f 100644 --- a/awx/ui_next/package.json +++ b/awx/ui_next/package.json @@ -12,7 +12,7 @@ "@patternfly/react-icons": "4.7.22", "@patternfly/react-table": "^4.19.15", "ansi-to-html": "^0.6.11", - "axios": "^0.18.1", + "axios": "^0.21.1", "codemirror": "^5.47.0", "d3": "^5.12.0", "dagre": "^0.8.4", diff --git a/awx/ui_next/src/api/models/Jobs.js b/awx/ui_next/src/api/models/Jobs.js index 9c43509f9e..fc9bbb2334 100644 --- a/awx/ui_next/src/api/models/Jobs.js +++ b/awx/ui_next/src/api/models/Jobs.js @@ -36,6 +36,10 @@ class Jobs extends RelaunchMixin(Base) { return this.http.post(`/api/v2${getBaseURL(type)}${id}/cancel/`); } + readCredentials(id, type) { + return this.http.get(`/api/v2${getBaseURL(type)}${id}/credentials/`); + } + readDetail(id, type) { return this.http.get(`/api/v2${getBaseURL(type)}${id}/`); } diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx index 48fc566e2c..89387b9e8b 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx @@ -57,7 +57,7 @@ function AdHocCommands({ adHocItems, i18n, hasListItems }) { fetchData(); }, [fetchData]); const { - isloading: isLaunchLoading, + isLoading: isLaunchLoading, error: launchError, request: launchAdHocCommands, } = useRequest( diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx index fa6f931c24..e95f0b05cb 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx @@ -58,7 +58,7 @@ function AdHocCredentialStep({ i18n, credentialTypeId, onEnableLaunch }) { return <ContentError error={error} />; } if (isLoading) { - return <ContentLoading error={error} />; + return <ContentLoading />; } return ( <Form> diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 95cb910295..2f12953afa 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx @@ -144,7 +144,7 @@ class AddResourceRole extends React.Component { currentStepId, maxEnabledStep, } = this.state; - const { onClose, roles, i18n } = this.props; + const { onClose, roles, i18n, resource } = this.props; // Object roles can be user only, so we remove them when // showing role choices for team access @@ -235,18 +235,24 @@ class AddResourceRole extends React.Component { t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.` )} </div> + <SelectableCard isSelected={selectedResource === 'users'} label={i18n._(t`Users`)} dataCy="add-role-users" + ariaLabel={i18n._(t`Users`)} onClick={() => this.handleResourceSelect('users')} /> - <SelectableCard - isSelected={selectedResource === 'teams'} - label={i18n._(t`Teams`)} - dataCy="add-role-teams" - onClick={() => this.handleResourceSelect('teams')} - /> + {resource?.type === 'credential' && + !resource?.organization ? null : ( + <SelectableCard + isSelected={selectedResource === 'teams'} + label={i18n._(t`Teams`)} + dataCy="add-role-teams" + ariaLabel={i18n._(t`Teams`)} + onClick={() => this.handleResourceSelect('teams')} + /> + )} </div> ), enableNext: selectedResource !== null, @@ -329,10 +335,12 @@ AddResourceRole.propTypes = { onClose: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired, roles: PropTypes.shape(), + resource: PropTypes.shape(), }; AddResourceRole.defaultProps = { roles: {}, + resource: {}, }; export { AddResourceRole as _AddResourceRole }; diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx index 76f5dbb87e..a681999391 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx @@ -221,4 +221,22 @@ describe('<_AddResourceRole />', () => { expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2); expect(handleSave).toHaveBeenCalled(); }); + + test('should not display team as a choice in case credential does not have organization', () => { + const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect'); + const wrapper = mountWithContexts( + <AddResourceRole + onClose={() => {}} + onSave={() => {}} + roles={roles} + resource={{ type: 'credential', organization: null }} + />, + { context: { network: { handleHttpError: () => {} } } } + ).find('AddResourceRole'); + const selectableCardWrapper = wrapper.find('SelectableCard'); + expect(selectableCardWrapper.length).toBe(1); + selectableCardWrapper.first().simulate('click'); + expect(spy).toHaveBeenCalledWith('users'); + expect(wrapper.state('selectedResource')).toBe('users'); + }); }); diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx index d9bd7c669d..ced058754a 100644 --- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx +++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx @@ -6,12 +6,12 @@ const mockData = [ { key: 'baz', label: 'Baz', - value: '/venv/baz/', + value: '/var/lib/awx/venv/baz/', }, { key: 'default', label: 'Default', - value: '/venv/ansible/', + value: '/var/lib/awx/venv/ansible/', }, ]; diff --git a/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx b/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx index 92a9071332..a07b6feca5 100644 --- a/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx +++ b/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx @@ -6,6 +6,7 @@ import 'codemirror/mode/javascript/javascript'; import 'codemirror/mode/yaml/yaml'; import 'codemirror/mode/jinja2/jinja2'; import 'codemirror/lib/codemirror.css'; +import 'codemirror/addon/display/placeholder'; const LINE_HEIGHT = 24; const PADDING = 12; @@ -55,6 +56,17 @@ const CodeMirror = styled(ReactCodeMirror)` background-color: var(--pf-c-form-control--disabled--BackgroundColor); } `} + ${props => + props.options && + props.options.placeholder && + ` + .CodeMirror-empty { + pre.CodeMirror-placeholder { + color: var(--pf-c-form-control--placeholder--Color); + height: 100% !important; + } + } + `} `; function CodeMirrorInput({ @@ -66,6 +78,7 @@ function CodeMirrorInput({ rows, fullHeight, className, + placeholder, }) { // Workaround for CodeMirror bug: If CodeMirror renders in a modal on the // modal's initial render, it appears as an empty box due to mis-calculated @@ -92,6 +105,7 @@ function CodeMirrorInput({ smartIndent: false, lineNumbers: true, lineWrapping: true, + placeholder, readOnly, }} fullHeight={fullHeight} diff --git a/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx b/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx index b1c51a6b8f..9808cc02af 100644 --- a/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx +++ b/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx @@ -1,22 +1,25 @@ import React from 'react'; -import { t } from '@lingui/macro'; -import { withI18n } from '@lingui/react'; + import styled from 'styled-components'; import { EmptyState as PFEmptyState, - EmptyStateBody, + EmptyStateIcon, + Spinner, } from '@patternfly/react-core'; const EmptyState = styled(PFEmptyState)` --pf-c-empty-state--m-lg--MaxWidth: none; + min-height: 250px; `; // TODO: Better loading state - skeleton lines / spinner, etc. -const ContentLoading = ({ className, i18n }) => ( - <EmptyState variant="full" className={className}> - <EmptyStateBody>{i18n._(t`Loading...`)}</EmptyStateBody> - </EmptyState> -); +const ContentLoading = ({ className }) => { + return ( + <EmptyState variant="full" className={className}> + <EmptyStateIcon variant="container" component={Spinner} /> + </EmptyState> + ); +}; export { ContentLoading as _ContentLoading }; -export default withI18n()(ContentLoading); +export default ContentLoading; diff --git a/awx/ui_next/src/components/CredentialChip/CredentialChip.jsx b/awx/ui_next/src/components/CredentialChip/CredentialChip.jsx index 11e00ee7ed..7dd5b055b5 100644 --- a/awx/ui_next/src/components/CredentialChip/CredentialChip.jsx +++ b/awx/ui_next/src/components/CredentialChip/CredentialChip.jsx @@ -16,10 +16,17 @@ function CredentialChip({ credential, i18n, i18nHash, ...props }) { type = toTitleCase(credential.kind); } + const buildCredentialName = () => { + if (credential.kind === 'vault' && credential.inputs?.vault_id) { + return `${credential.name} | ${credential.inputs.vault_id}`; + } + return `${credential.name}`; + }; + return ( <Chip {...props}> <strong>{type}: </strong> - {credential.name} + {buildCredentialName()} </Chip> ); } diff --git a/awx/ui_next/src/components/LoadingSpinner/LoadingSpinner.jsx b/awx/ui_next/src/components/LoadingSpinner/LoadingSpinner.jsx new file mode 100644 index 0000000000..36ed8adf5e --- /dev/null +++ b/awx/ui_next/src/components/LoadingSpinner/LoadingSpinner.jsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { Spinner } from '@patternfly/react-core'; +import styled from 'styled-components'; + +const UpdatingContent = styled.div` + position: fixed; + top: 50%; + left: 50%; + z-index: 300; + width: 100%; + height: 100%; + & + * { + opacity: 0.5; + } +`; + +const LoadingSpinner = () => ( + <UpdatingContent> + <Spinner /> + </UpdatingContent> +); +export default LoadingSpinner; diff --git a/awx/ui_next/src/components/LoadingSpinner/index.js b/awx/ui_next/src/components/LoadingSpinner/index.js new file mode 100644 index 0000000000..6513c5cb53 --- /dev/null +++ b/awx/ui_next/src/components/LoadingSpinner/index.js @@ -0,0 +1 @@ +export { default } from './LoadingSpinner'; diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index 2b398abcbe..df9964440b 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; import { arrayOf, bool, @@ -8,7 +9,6 @@ import { string, oneOfType, } from 'prop-types'; -import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { FormGroup } from '@patternfly/react-core'; @@ -39,13 +39,13 @@ function CredentialLookup({ credentialTypeKind, credentialTypeNamespace, value, - history, i18n, tooltip, isDisabled, autoPopulate, multiple, }) { + const history = useHistory(); const autoPopulateLookup = useAutoPopulateLookup(onChange); const { result: { count, credentials, relatedSearchableKeys, searchableKeys }, @@ -72,22 +72,28 @@ function CredentialLookup({ ...typeNamespaceParams, }) ), - CredentialsAPI.readOptions, + CredentialsAPI.readOptions(), ]); if (autoPopulate) { autoPopulateLookup(data.results); } + const searchKeys = Object.keys( + actionsResponse.data.actions?.GET || {} + ).filter(key => actionsResponse.data.actions?.GET[key].filterable); + const item = searchKeys.indexOf('type'); + if (item) { + searchKeys[item] = 'credential_type__kind'; + } + return { count: data.count, credentials: data.results, 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), + searchableKeys: searchKeys, }; }, [ autoPopulate, @@ -222,4 +228,4 @@ CredentialLookup.defaultProps = { }; export { CredentialLookup as _CredentialLookup }; -export default withI18n()(withRouter(CredentialLookup)); +export default withI18n()(CredentialLookup); diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index 40d7b87d33..1b4bfb5e59 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -13,7 +13,7 @@ import useRequest from '../../util/useRequest'; import Lookup from './Lookup'; import LookupErrorMessage from './shared/LookupErrorMessage'; -const QS_CONFIG = getQSConfig('instance_groups', { +const QS_CONFIG = getQSConfig('instance-groups', { page: 1, page_size: 5, order_by: 'name', diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx index 9c42d521de..f55d669880 100644 --- a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx @@ -16,6 +16,7 @@ const QS_CONFIG = getQSConfig('inventory', { page: 1, page_size: 5, order_by: 'name', + role_level: 'use_role', }); function InventoryLookup({ @@ -29,6 +30,7 @@ function InventoryLookup({ fieldId, promptId, promptName, + isOverrideDisabled, }) { const { result: { @@ -57,8 +59,10 @@ function InventoryLookup({ searchableKeys: Object.keys( actionsResponse.data.actions?.GET || {} ).filter(key => actionsResponse.data.actions?.GET[key].filterable), - canEdit: Boolean(actionsResponse.data.actions.POST), + canEdit: + Boolean(actionsResponse.data.actions.POST) || isOverrideDisabled, }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [history.location]), { inventories: [], @@ -195,11 +199,13 @@ InventoryLookup.propTypes = { value: Inventory, onChange: func.isRequired, required: bool, + isOverrideDisabled: bool, }; InventoryLookup.defaultProps = { value: null, required: false, + isOverrideDisabled: false, }; export default withI18n()(withRouter(InventoryLookup)); diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.test.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.test.jsx new file mode 100644 index 0000000000..1c7d13f488 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/InventoryLookup.test.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import InventoryLookup from './InventoryLookup'; +import { InventoriesAPI } from '../../api'; + +jest.mock('../../api'); + +const mockedInventories = { + data: { + count: 2, + results: [ + { id: 2, name: 'Bar' }, + { id: 3, name: 'Baz' }, + ], + }, +}; + +describe('InventoryLookup', () => { + let wrapper; + + beforeEach(() => { + InventoriesAPI.read.mockResolvedValue(mockedInventories); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should render successfully and fetch data', async () => { + InventoriesAPI.readOptions.mockReturnValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [], + }, + }); + await act(async () => { + wrapper = mountWithContexts(<InventoryLookup onChange={() => {}} />); + }); + wrapper.update(); + expect(InventoriesAPI.read).toHaveBeenCalledTimes(1); + expect(wrapper.find('InventoryLookup')).toHaveLength(1); + expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false); + }); + + test('inventory lookup should be enabled', async () => { + InventoriesAPI.readOptions.mockReturnValue({ + data: { + actions: { + GET: {}, + }, + related_search_fields: [], + }, + }); + await act(async () => { + wrapper = mountWithContexts( + <InventoryLookup isOverrideDisabled onChange={() => {}} /> + ); + }); + wrapper.update(); + expect(InventoriesAPI.read).toHaveBeenCalledTimes(1); + expect(wrapper.find('InventoryLookup')).toHaveLength(1); + expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false); + }); + + test('inventory lookup should be disabled', async () => { + InventoriesAPI.readOptions.mockReturnValue({ + data: { + actions: { + GET: {}, + }, + related_search_fields: [], + }, + }); + await act(async () => { + wrapper = mountWithContexts(<InventoryLookup onChange={() => {}} />); + }); + wrapper.update(); + expect(InventoriesAPI.read).toHaveBeenCalledTimes(1); + expect(wrapper.find('InventoryLookup')).toHaveLength(1); + expect(wrapper.find('Lookup').prop('isDisabled')).toBe(true); + }); +}); diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index b31efb35c5..ecc1a268c4 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -71,6 +71,16 @@ function MultiCredentialsLookup(props) { loadCredentials(params, selectedType.id), CredentialsAPI.readOptions(), ]); + + results.map(result => { + if (result.kind === 'vault' && result.inputs?.vault_id) { + result.label = `${result.name} | ${result.inputs.vault_id}`; + return result; + } + result.label = `${result.name}`; + return result; + }); + return { credentials: results, credentialsCount: count, @@ -108,7 +118,6 @@ function MultiCredentialsLookup(props) { credential={item} /> ); - const isVault = selectedType?.kind === 'vault'; return ( @@ -187,6 +196,7 @@ function MultiCredentialsLookup(props) { relatedSearchableKeys={relatedSearchableKeys} multiple={isVault} header={i18n._(t`Credentials`)} + displayKey={isVault ? 'label' : 'name'} name="credentials" qsConfig={QS_CONFIG} readOnly={!canDelete} diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx index d0a3738171..a020d56345 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx @@ -87,6 +87,23 @@ describe('<MultiCredentialsLookup />', () => { name: 'Cred 5', url: 'www.google.com', }, + + { + id: 6, + credential_type: 5, + kind: 'vault', + name: 'Cred 6', + url: 'www.google.com', + inputs: { vault_id: 'vault ID' }, + }, + { + id: 7, + credential_type: 5, + kind: 'vault', + name: 'Cred 7', + url: 'www.google.com', + inputs: {}, + }, ], count: 3, }, @@ -196,7 +213,13 @@ describe('<MultiCredentialsLookup />', () => { wrapper.update(); expect(CredentialsAPI.read).toHaveBeenCalledTimes(2); expect(wrapper.find('OptionsList').prop('options')).toEqual([ - { id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' }, + { + id: 1, + kind: 'cloud', + name: 'New Cred', + url: 'www.google.com', + label: 'New Cred', + }, ]); }); @@ -268,6 +291,36 @@ describe('<MultiCredentialsLookup />', () => { ]); }); + test('should properly render vault credential labels', async () => { + await act(async () => { + wrapper = mountWithContexts( + <MultiCredentialsLookup + value={credentials} + tooltip="This is credentials look up" + onChange={() => {}} + onError={() => {}} + /> + ); + }); + const searchButton = await waitForElement( + wrapper, + 'Button[aria-label="Search"]' + ); + await act(async () => { + searchButton.invoke('onClick')(); + }); + wrapper.update(); + const typeSelect = wrapper.find('AnsibleSelect'); + act(() => { + typeSelect.invoke('onChange')({}, 500); + }); + wrapper.update(); + const optionsList = wrapper.find('OptionsList'); + expect(optionsList.prop('multiple')).toEqual(true); + expect(wrapper.find('CheckboxListItem[label="Cred 6 | vault ID"]')); + expect(wrapper.find('CheckboxListItem[label="Cred 7"]')); + }); + test('should allow multiple vault credentials with no vault id', async () => { const onChange = jest.fn(); await act(async () => { diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index dee802c2aa..a02ed40d84 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -18,6 +18,7 @@ const QS_CONFIG = getQSConfig('project', { page: 1, page_size: 5, order_by: 'name', + role_level: 'use_role', }); function ProjectLookup({ @@ -31,6 +32,7 @@ function ProjectLookup({ value, onBlur, history, + isOverrideDisabled, }) { const autoPopulateLookup = useAutoPopulateLookup(onChange); const { @@ -57,8 +59,10 @@ function ProjectLookup({ searchableKeys: Object.keys( actionsResponse.data.actions?.GET || {} ).filter(key => actionsResponse.data.actions?.GET[key].filterable), - canEdit: Boolean(actionsResponse.data.actions.POST), + canEdit: + Boolean(actionsResponse.data.actions.POST) || isOverrideDisabled, }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoPopulate, autoPopulateLookup, history.location.search]), { count: 0, @@ -160,6 +164,7 @@ ProjectLookup.propTypes = { required: bool, tooltip: string, value: Project, + isOverrideDisabled: bool, }; ProjectLookup.defaultProps = { @@ -170,6 +175,7 @@ ProjectLookup.defaultProps = { required: false, tooltip: '', value: null, + isOverrideDisabled: false, }; export { ProjectLookup as _ProjectLookup }; diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx index 04ccad63fe..88060c2699 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx @@ -7,6 +7,10 @@ import ProjectLookup from './ProjectLookup'; jest.mock('../../api'); describe('<ProjectLookup />', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + test('should auto-select project when only one available and autoPopulate prop is true', async () => { ProjectsAPI.read.mockReturnValue({ data: { @@ -48,4 +52,46 @@ describe('<ProjectLookup />', () => { }); expect(onChange).not.toHaveBeenCalled(); }); + + test('project lookup should be enabled', async () => { + let wrapper; + + ProjectsAPI.readOptions.mockReturnValue({ + data: { + actions: { + GET: {}, + }, + related_search_fields: [], + }, + }); + await act(async () => { + wrapper = mountWithContexts( + <ProjectLookup isOverrideDisabled onChange={() => {}} /> + ); + }); + wrapper.update(); + expect(ProjectsAPI.read).toHaveBeenCalledTimes(1); + expect(wrapper.find('ProjectLookup')).toHaveLength(1); + expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false); + }); + + test('project lookup should be disabled', async () => { + let wrapper; + + ProjectsAPI.readOptions.mockReturnValue({ + data: { + actions: { + GET: {}, + }, + related_search_fields: [], + }, + }); + await act(async () => { + wrapper = mountWithContexts(<ProjectLookup onChange={() => {}} />); + }); + wrapper.update(); + expect(ProjectsAPI.read).toHaveBeenCalledTimes(1); + expect(wrapper.find('ProjectLookup')).toHaveLength(1); + expect(wrapper.find('Lookup').prop('isDisabled')).toBe(true); + }); }); diff --git a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx index 8bd449e851..7f5fe9afdd 100644 --- a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx @@ -1,9 +1,10 @@ import React, { Fragment } from 'react'; + import PropTypes from 'prop-types'; import { DataList } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { withRouter } from 'react-router-dom'; +import { withRouter, useHistory, useLocation } from 'react-router-dom'; import ListHeader from '../ListHeader'; import ContentEmpty from '../ContentEmpty'; @@ -21,167 +22,155 @@ import { import { QSConfig, SearchColumns, SortColumns } from '../../types'; import PaginatedDataListItem from './PaginatedDataListItem'; - -class PaginatedDataList extends React.Component { - constructor(props) { - super(props); - this.handleSetPage = this.handleSetPage.bind(this); - this.handleSetPageSize = this.handleSetPageSize.bind(this); - this.handleListItemSelect = this.handleListItemSelect.bind(this); - } - - handleListItemSelect = (id = 0) => { - const { items, onRowClick } = this.props; +import LoadingSpinner from '../LoadingSpinner'; + +function PaginatedDataList({ + items, + onRowClick, + contentError, + hasContentLoading, + emptyStateControls, + itemCount, + qsConfig, + renderItem, + toolbarSearchColumns, + toolbarSearchableKeys, + toolbarRelatedSearchableKeys, + toolbarSortColumns, + pluralizedItemName, + showPageSizeOptions, + location, + i18n, + renderToolbar, +}) { + const { search, pathname } = useLocation(); + const history = useHistory(); + const handleListItemSelect = (id = 0) => { const match = items.find(item => item.id === Number(id)); onRowClick(match); }; - handleSetPage(event, pageNumber) { - const { history, qsConfig } = this.props; - const { search } = history.location; + const handleSetPage = (event, pageNumber) => { const oldParams = parseQueryString(qsConfig, search); - this.pushHistoryState(replaceParams(oldParams, { page: pageNumber })); - } + pushHistoryState(replaceParams(oldParams, { page: pageNumber })); + }; - handleSetPageSize(event, pageSize, page) { - const { history, qsConfig } = this.props; - const { search } = history.location; + const handleSetPageSize = (event, pageSize, page) => { const oldParams = parseQueryString(qsConfig, search); - this.pushHistoryState( - replaceParams(oldParams, { page_size: pageSize, page }) - ); - } + pushHistoryState(replaceParams(oldParams, { page_size: pageSize, page })); + }; - pushHistoryState(params) { - const { history, qsConfig } = this.props; - const { pathname } = history.location; + const pushHistoryState = params => { const encodedParams = encodeNonDefaultQueryString(qsConfig, params); history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname); - } + }; - render() { - const { - contentError, - hasContentLoading, - emptyStateControls, - items, - itemCount, - qsConfig, - renderItem, - toolbarSearchColumns, - toolbarSearchableKeys, - toolbarRelatedSearchableKeys, - toolbarSortColumns, - pluralizedItemName, - showPageSizeOptions, - location, - i18n, - renderToolbar, - } = this.props; - const searchColumns = toolbarSearchColumns.length - ? toolbarSearchColumns - : [ - { - name: i18n._(t`Name`), - key: 'name', - isDefault: true, - }, - ]; - const sortColumns = toolbarSortColumns.length - ? toolbarSortColumns - : [ - { - name: i18n._(t`Name`), - key: 'name', - }, - ]; - const queryParams = parseQueryString(qsConfig, location.search); - - const dataListLabel = i18n._(t`${pluralizedItemName} List`); - const emptyContentMessage = i18n._( - t`Please add ${pluralizedItemName} to populate this list ` + const searchColumns = toolbarSearchColumns.length + ? toolbarSearchColumns + : [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + ]; + const sortColumns = toolbarSortColumns.length + ? toolbarSortColumns + : [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ]; + const queryParams = parseQueryString(qsConfig, location.search); + + const dataListLabel = i18n._(t`${pluralizedItemName} List`); + const emptyContentMessage = i18n._( + t`Please add ${pluralizedItemName} to populate this list ` + ); + const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `); + + let Content; + if (hasContentLoading && items.length <= 0) { + Content = <ContentLoading />; + } else if (contentError) { + Content = <ContentError error={contentError} />; + } else if (items.length <= 0) { + Content = ( + <ContentEmpty title={emptyContentTitle} message={emptyContentMessage} /> ); - const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `); - - let Content; - if (hasContentLoading && items.length <= 0) { - Content = <ContentLoading />; - } else if (contentError) { - Content = <ContentError error={contentError} />; - } else if (items.length <= 0) { - Content = ( - <ContentEmpty title={emptyContentTitle} message={emptyContentMessage} /> - ); - } else { - Content = ( + } else { + Content = ( + <> + {hasContentLoading && <LoadingSpinner />} <DataList aria-label={dataListLabel} - onSelectDataListItem={id => this.handleListItemSelect(id)} + onSelectDataListItem={id => handleListItemSelect(id)} > {items.map(renderItem)} </DataList> - ); - } + </> + ); + } - const ToolbarPagination = ( - <Pagination - isCompact - dropDirection="down" + const ToolbarPagination = ( + <Pagination + isCompact + dropDirection="down" + itemCount={itemCount} + page={queryParams.page || 1} + perPage={queryParams.page_size} + perPageOptions={ + showPageSizeOptions + ? [ + { title: '5', value: 5 }, + { title: '10', value: 10 }, + { title: '20', value: 20 }, + { title: '50', value: 50 }, + ] + : [] + } + onSetPage={handleSetPage} + onPerPageSelect={handleSetPageSize} + /> + ); + + return ( + <Fragment> + <ListHeader itemCount={itemCount} - page={queryParams.page || 1} - perPage={queryParams.page_size} - perPageOptions={ - showPageSizeOptions - ? [ - { title: '5', value: 5 }, - { title: '10', value: 10 }, - { title: '20', value: 20 }, - { title: '50', value: 50 }, - ] - : [] - } - onSetPage={this.handleSetPage} - onPerPageSelect={this.handleSetPageSize} + renderToolbar={renderToolbar} + emptyStateControls={emptyStateControls} + searchColumns={searchColumns} + sortColumns={sortColumns} + searchableKeys={toolbarSearchableKeys} + relatedSearchableKeys={toolbarRelatedSearchableKeys} + qsConfig={qsConfig} + pagination={ToolbarPagination} /> - ); - - return ( - <Fragment> - <ListHeader + {Content} + {items.length ? ( + <Pagination + variant="bottom" itemCount={itemCount} - renderToolbar={renderToolbar} - emptyStateControls={emptyStateControls} - searchColumns={searchColumns} - sortColumns={sortColumns} - searchableKeys={toolbarSearchableKeys} - relatedSearchableKeys={toolbarRelatedSearchableKeys} - qsConfig={qsConfig} - pagination={ToolbarPagination} + page={queryParams.page || 1} + perPage={queryParams.page_size} + perPageOptions={ + showPageSizeOptions + ? [ + { title: '5', value: 5 }, + { title: '10', value: 10 }, + { title: '20', value: 20 }, + { title: '50', value: 50 }, + ] + : [] + } + onSetPage={handleSetPage} + onPerPageSelect={handleSetPageSize} /> - {Content} - {items.length ? ( - <Pagination - variant="bottom" - itemCount={itemCount} - page={queryParams.page || 1} - perPage={queryParams.page_size} - perPageOptions={ - showPageSizeOptions - ? [ - { title: '5', value: 5 }, - { title: '10', value: 10 }, - { title: '20', value: 20 }, - { title: '50', value: 50 }, - ] - : [] - } - onSetPage={this.handleSetPage} - onPerPageSelect={this.handleSetPageSize} - /> - ) : null} - </Fragment> - ); - } + ) : null} + </Fragment> + ); } const Item = PropTypes.shape({ diff --git a/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx b/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx index 2176bd3b57..9892df34fe 100644 --- a/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx +++ b/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx @@ -11,6 +11,7 @@ import ContentError from '../ContentError'; import ContentLoading from '../ContentLoading'; import Pagination from '../Pagination'; import DataListToolbar from '../DataListToolbar'; +import LoadingSpinner from '../LoadingSpinner'; import { encodeNonDefaultQueryString, @@ -82,10 +83,13 @@ function PaginatedTable({ ); } else { Content = ( - <TableComposable aria-label={dataListLabel}> - {headerRow} - <Tbody>{items.map(renderRow)}</Tbody> - </TableComposable> + <> + {hasContentLoading && <LoadingSpinner />} + <TableComposable aria-label={dataListLabel}> + {headerRow} + <Tbody>{items.map(renderRow)}</Tbody> + </TableComposable> + </> ); } diff --git a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx index 4568c477e4..b5b1765d45 100644 --- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx @@ -155,6 +155,7 @@ function ResourceAccessList({ i18n, apiModel, resource }) { fetchAccessRecords(); }} roles={resource.summary_fields.object_roles} + resource={resource} /> )} {showDeleteModal && ( diff --git a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx index 957006022e..0bac50fdbc 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx @@ -62,7 +62,7 @@ function ScheduleList({ scheduleActions.data.actions?.GET || {} ).filter(key => scheduleActions.data.actions?.GET[key].filterable), }; - }, [location, loadSchedules, loadScheduleOptions]), + }, [location.search, loadSchedules, loadScheduleOptions]), { schedules: [], itemCount: 0, diff --git a/awx/ui_next/src/components/SelectableCard/SelectableCard.jsx b/awx/ui_next/src/components/SelectableCard/SelectableCard.jsx index db9f5008ed..338c089c53 100644 --- a/awx/ui_next/src/components/SelectableCard/SelectableCard.jsx +++ b/awx/ui_next/src/components/SelectableCard/SelectableCard.jsx @@ -31,7 +31,14 @@ const Description = styled.p` font-size: 14px; `; -function SelectableCard({ label, description, onClick, isSelected, dataCy }) { +function SelectableCard({ + label, + description, + onClick, + isSelected, + dataCy, + ariaLabel, +}) { return ( <SelectableItem onClick={onClick} @@ -40,6 +47,7 @@ function SelectableCard({ label, description, onClick, isSelected, dataCy }) { tabIndex="0" data-cy={dataCy} isSelected={isSelected} + aria-label={ariaLabel} > <Indicator isSelected={isSelected} /> <Contents> @@ -55,12 +63,14 @@ SelectableCard.propTypes = { description: PropTypes.string, onClick: PropTypes.func.isRequired, isSelected: PropTypes.bool, + ariaLabel: PropTypes.string, }; SelectableCard.defaultProps = { label: '', description: '', isSelected: false, + ariaLabel: '', }; export default SelectableCard; diff --git a/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx b/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx index 5e55cb1611..80d7a8c916 100644 --- a/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx @@ -7,7 +7,11 @@ import { Button } from '@patternfly/react-core'; import useRequest, { useDismissableError } from '../../../util/useRequest'; import AlertModal from '../../../components/AlertModal'; import { CardBody, CardActionsRow } from '../../../components/Card'; -import { Detail, DetailList } from '../../../components/DetailList'; +import { + Detail, + DetailList, + UserDateDetail, +} from '../../../components/DetailList'; import { ApplicationsAPI } from '../../../api'; import DeleteButton from '../../../components/DeleteButton'; import ErrorDetail from '../../../components/ErrorDetail'; @@ -98,6 +102,11 @@ function ApplicationDetails({ value={getClientType(application.client_type)} dataCy="app-detail-client-type" /> + <UserDateDetail label={i18n._(t`Created`)} date={application.created} /> + <UserDateDetail + label={i18n._(t`Last Modified`)} + date={application.modified} + /> </DetailList> <CardActionsRow> {application.summary_fields.user_capabilities && diff --git a/awx/ui_next/src/screens/Credential/Credential.jsx b/awx/ui_next/src/screens/Credential/Credential.jsx index e06dce231e..42c18a0d41 100644 --- a/awx/ui_next/src/screens/Credential/Credential.jsx +++ b/awx/ui_next/src/screens/Credential/Credential.jsx @@ -56,15 +56,12 @@ function Credential({ i18n, setBreadcrumb }) { id: 99, }, { name: i18n._(t`Details`), link: `/credentials/${id}/details`, id: 0 }, - ]; - - if (credential && credential.organization) { - tabsArray.push({ + { name: i18n._(t`Access`), link: `/credentials/${id}/access`, id: 1, - }); - } + }, + ]; let showCardHeader = true; @@ -108,14 +105,12 @@ function Credential({ i18n, setBreadcrumb }) { <Route key="edit" path="/credentials/:id/edit"> <CredentialEdit credential={credential} /> </Route>, - credential.organization && ( - <Route key="access" path="/credentials/:id/access"> - <ResourceAccessList - resource={credential} - apiModel={CredentialsAPI} - /> - </Route> - ), + <Route key="access" path="/credentials/:id/access"> + <ResourceAccessList + resource={credential} + apiModel={CredentialsAPI} + /> + </Route>, <Route key="not-found" path="*"> {!hasContentLoading && ( <ContentError isNotFound> diff --git a/awx/ui_next/src/screens/Credential/Credential.test.jsx b/awx/ui_next/src/screens/Credential/Credential.test.jsx index 7cb192ac0c..ed3404c1a6 100644 --- a/awx/ui_next/src/screens/Credential/Credential.test.jsx +++ b/awx/ui_next/src/screens/Credential/Credential.test.jsx @@ -31,7 +31,7 @@ describe('<Credential />', () => { wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); - await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 2); + await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 3); }); test('initially renders org-based credential succesfully', async () => { diff --git a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx index 5f8066b904..55190e3a5f 100644 --- a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx @@ -78,7 +78,7 @@ function CredentialDetail({ i18n, credential }) { {} ), }; - }, [credentialId, credential_type]), + }, [credentialId, credential_type.id]), { fields: [], managedByTower: true, diff --git a/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx b/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx index 26272ee4ad..e4a10ed86c 100644 --- a/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx @@ -26,7 +26,13 @@ function CredentialList({ i18n }) { const location = useLocation(); const { - result: { credentials, credentialCount, actions }, + result: { + credentials, + credentialCount, + actions, + relatedSearchableKeys, + searchableKeys, + }, error: contentError, isLoading, request: fetchCredentials, @@ -37,16 +43,29 @@ function CredentialList({ i18n }) { CredentialsAPI.read(params), CredentialsAPI.readOptions(), ]); + const searchKeys = Object.keys( + credActions.data.actions?.GET || {} + ).filter(key => credActions.data.actions?.GET[key].filterable); + const item = searchKeys.indexOf('type'); + if (item) { + searchKeys[item] = 'credential_type__kind'; + } return { credentials: creds.data.results, credentialCount: creds.data.count, actions: credActions.data.actions, + relatedSearchableKeys: ( + credActions?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: searchKeys, }; }, [location]), { credentials: [], credentialCount: 0, actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -102,6 +121,8 @@ function CredentialList({ i18n }) { itemCount={credentialCount} qsConfig={QS_CONFIG} onRowClick={handleSelect} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} toolbarSearchColumns={[ { name: i18n._(t`Name`), diff --git a/awx/ui_next/src/screens/Credential/shared/data.credentialTypes.json b/awx/ui_next/src/screens/Credential/shared/data.credentialTypes.json index b7b3189951..6281f15024 100644 --- a/awx/ui_next/src/screens/Credential/shared/data.credentialTypes.json +++ b/awx/ui_next/src/screens/Credential/shared/data.credentialTypes.json @@ -276,6 +276,11 @@ "help_text": "OpenStack domains define administrative boundaries. It is only needed for Keystone v3 authentication URLs. Refer to Ansible Tower documentation for common scenarios." }, { + "id": "project_region_name", + "label": "Region Name", + "type": "string" + }, + { "id": "verify_ssl", "label": "Verify SSL", "type": "boolean", diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx index e22112e9ba..ee47d2fdbc 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx @@ -18,7 +18,7 @@ import DatalistToolbar from '../../../components/DataListToolbar'; import CredentialTypeListItem from './CredentialTypeListItem'; -const QS_CONFIG = getQSConfig('credential_type', { +const QS_CONFIG = getQSConfig('credential-type', { page: 1, page_size: 20, managed_by_tower: false, diff --git a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx index d348ce28f7..f60e049631 100644 --- a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx +++ b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx @@ -20,7 +20,7 @@ import useRequest from '../../util/useRequest'; import { DashboardAPI } from '../../api'; import Breadcrumbs from '../../components/Breadcrumbs'; import JobList from '../../components/JobList'; - +import ContentLoading from '../../components/ContentLoading'; import LineChart from './shared/LineChart'; import Count from './shared/Count'; import DashboardTemplateList from './shared/DashboardTemplateList'; @@ -62,6 +62,7 @@ function Dashboard({ i18n }) { const [activeTabId, setActiveTabId] = useState(0); const { + isLoading, result: { jobGraphData, countData }, request: fetchDashboardGraph, } = useRequest( @@ -105,7 +106,15 @@ function Dashboard({ i18n }) { useEffect(() => { fetchDashboardGraph(); }, [fetchDashboardGraph, periodSelection, jobTypeSelection]); - + if (isLoading) { + return ( + <PageSection> + <Card> + <ContentLoading /> + </Card> + </PageSection> + ); + } return ( <Fragment> <Breadcrumbs breadcrumbConfig={{ '/home': i18n._(t`Dashboard`) }} /> diff --git a/awx/ui_next/src/screens/Host/Host.jsx b/awx/ui_next/src/screens/Host/Host.jsx index 33832e71ee..18a7f80edd 100644 --- a/awx/ui_next/src/screens/Host/Host.jsx +++ b/awx/ui_next/src/screens/Host/Host.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { @@ -20,29 +20,22 @@ import HostDetail from './HostDetail'; import HostEdit from './HostEdit'; import HostGroups from './HostGroups'; import { HostsAPI } from '../../api'; +import useRequest from '../../util/useRequest'; function Host({ i18n, setBreadcrumb }) { - const [host, setHost] = useState(null); - const [contentError, setContentError] = useState(null); - const [hasContentLoading, setHasContentLoading] = useState(true); - const location = useLocation(); const match = useRouteMatch('/hosts/:id'); + const { error, isLoading, result: host, request: fetchHost } = useRequest( + useCallback(async () => { + const { data } = await HostsAPI.readDetail(match.params.id); + setBreadcrumb(data); + return data; + }, [match.params.id, setBreadcrumb]) + ); useEffect(() => { - (async () => { - setContentError(null); - try { - const { data } = await HostsAPI.readDetail(match.params.id); - setHost(data); - setBreadcrumb(data); - } catch (error) { - setContentError(error); - } finally { - setHasContentLoading(false); - } - })(); - }, [match.params.id, location, setBreadcrumb]); + fetchHost(); + }, [fetchHost, location]); const tabsArray = [ { @@ -77,7 +70,7 @@ function Host({ i18n, setBreadcrumb }) { }, ]; - if (hasContentLoading) { + if (isLoading) { return ( <PageSection> <Card> @@ -87,12 +80,12 @@ function Host({ i18n, setBreadcrumb }) { ); } - if (contentError) { + if (error) { return ( <PageSection> <Card> - <ContentError error={contentError}> - {contentError?.response?.status === 404 && ( + <ContentError error={error}> + {error?.response?.status === 404 && ( <span> {i18n._(t`Host not found.`)}{' '} <Link to="/hosts">{i18n._(t`View all Hosts.`)}</Link> diff --git a/awx/ui_next/src/screens/Host/Host.test.jsx b/awx/ui_next/src/screens/Host/Host.test.jsx index 26862760e9..a875d8777e 100644 --- a/awx/ui_next/src/screens/Host/Host.test.jsx +++ b/awx/ui_next/src/screens/Host/Host.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Route } from 'react-router-dom'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { HostsAPI } from '../../api'; @@ -28,7 +29,11 @@ describe('<Host />', () => { beforeEach(async () => { await act(async () => { - wrapper = mountWithContexts(<Host setBreadcrumb={() => {}} />); + wrapper = mountWithContexts( + <Route path="/hosts/:id/details"> + <Host setBreadcrumb={() => {}} /> + </Route> + ); }); }); diff --git a/awx/ui_next/src/screens/Host/data.hostFacts.json b/awx/ui_next/src/screens/Host/data.hostFacts.json index a8427e0003..2507d267e3 100644 --- a/awx/ui_next/src/screens/Host/data.hostFacts.json +++ b/awx/ui_next/src/screens/Host/data.hostFacts.json @@ -83,7 +83,7 @@ "PWD": "/tmp/awx_13_r1ffeqze/project", "HOME": "/var/lib/awx", "LANG": "\"en-us\"", - "PATH": "/venv/ansible/bin:/venv/awx/bin:/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "PATH": "/var/lib/awx/venv/ansible/bin:/var/lib/awx/venv/awx/bin:/var/lib/awx/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "SHLVL": "4", "JOB_ID": "13", "LC_ALL": "en_US.UTF-8", @@ -96,9 +96,9 @@ "SDB_PORT": "7899", "MAKEFLAGS": "w", "MAKELEVEL": "2", - "PYTHONPATH": "/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks", + "PYTHONPATH": "/var/lib/awx/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks", "CURRENT_UID": "501", - "VIRTUAL_ENV": "/venv/ansible", + "VIRTUAL_ENV": "/var/lib/awx/venv/ansible", "INVENTORY_ID": "1", "MAX_EVENT_RES": "700000", "PROOT_TMP_DIR": "/tmp", @@ -106,7 +106,7 @@ "SDB_NOTIFY_HOST": "docker.for.mac.host.internal", "AWX_GROUP_QUEUES": "tower", "PROJECT_REVISION": "9e2cd25bfb26ba82f40cf31276e1942bf38b3a30", - "ANSIBLE_VENV_PATH": "/venv/ansible", + "ANSIBLE_VENV_PATH": "/var/lib/awx/venv/ansible", "ANSIBLE_ROLES_PATH": "/tmp/awx_13_r1ffeqze/requirements_roles:~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles", "RUNNER_OMIT_EVENTS": "False", "SUPERVISOR_ENABLED": "1", @@ -119,7 +119,7 @@ "DJANGO_SETTINGS_MODULE": "awx.settings.development", "ANSIBLE_STDOUT_CALLBACK": "awx_display", "SUPERVISOR_PROCESS_NAME": "awx-dispatcher", - "ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks", + "ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks", "ANSIBLE_COLLECTIONS_PATHS": "/tmp/awx_13_r1ffeqze/requirements_collections:~/.ansible/collections:/usr/share/ansible/collections", "ANSIBLE_HOST_KEY_CHECKING": "False", "RUNNER_ONLY_FAILED_EVENTS": "False", diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.test.jsx b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.test.jsx index d63996dde8..937aa15adb 100644 --- a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.test.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.test.jsx @@ -123,7 +123,7 @@ describe('<ContainerGroupEdit/>', () => { }); test('called InstanceGroupsAPI.readOptions', async () => { - expect(InstanceGroupsAPI.readOptions).toHaveBeenCalledTimes(1); + expect(InstanceGroupsAPI.readOptions).toHaveBeenCalled(); }); test('handleCancel returns the user to container group detail', async () => { diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx index 5e56d18f73..6b3b43f637 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx @@ -18,7 +18,7 @@ import AddDropDownButton from '../../../components/AddDropDownButton'; import InstanceGroupListItem from './InstanceGroupListItem'; -const QS_CONFIG = getQSConfig('instance_group', { +const QS_CONFIG = getQSConfig('instance-group', { page: 1, page_size: 20, }); diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx index 1071b91cc3..50a0f13f67 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx @@ -58,7 +58,7 @@ describe('InventorySourceDetail', () => { assertDetail(wrapper, 'Description', 'mock description'); assertDetail(wrapper, 'Source', 'Sourced from a Project'); assertDetail(wrapper, 'Organization', 'Mock Org'); - assertDetail(wrapper, 'Ansible environment', '/venv/custom'); + assertDetail(wrapper, 'Ansible environment', '/var/lib/awx/venv/custom'); assertDetail(wrapper, 'Project', 'Mock Project'); assertDetail(wrapper, 'Inventory file', 'foo'); assertDetail(wrapper, 'Verbosity', '2 (Debug)'); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx index f1286e9a2b..2b1cff9115 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx @@ -55,7 +55,7 @@ const InventorySourceFormFields = ({ source, sourceOptions, i18n }) => { const [venvField] = useField('custom_virtualenv'); const defaultVenv = { label: i18n._(t`Use Default Ansible Environment`), - value: '/venv/ansible/', + value: '/var/lib/awx/venv/ansible/', key: 'default', }; diff --git a/awx/ui_next/src/screens/Inventory/shared/data.hostFacts.json b/awx/ui_next/src/screens/Inventory/shared/data.hostFacts.json index a8427e0003..2507d267e3 100644 --- a/awx/ui_next/src/screens/Inventory/shared/data.hostFacts.json +++ b/awx/ui_next/src/screens/Inventory/shared/data.hostFacts.json @@ -83,7 +83,7 @@ "PWD": "/tmp/awx_13_r1ffeqze/project", "HOME": "/var/lib/awx", "LANG": "\"en-us\"", - "PATH": "/venv/ansible/bin:/venv/awx/bin:/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "PATH": "/var/lib/awx/venv/ansible/bin:/var/lib/awx/venv/awx/bin:/var/lib/awx/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "SHLVL": "4", "JOB_ID": "13", "LC_ALL": "en_US.UTF-8", @@ -96,9 +96,9 @@ "SDB_PORT": "7899", "MAKEFLAGS": "w", "MAKELEVEL": "2", - "PYTHONPATH": "/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks", + "PYTHONPATH": "/var/lib/awx/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks", "CURRENT_UID": "501", - "VIRTUAL_ENV": "/venv/ansible", + "VIRTUAL_ENV": "/var/lib/awx/venv/ansible", "INVENTORY_ID": "1", "MAX_EVENT_RES": "700000", "PROOT_TMP_DIR": "/tmp", @@ -106,7 +106,7 @@ "SDB_NOTIFY_HOST": "docker.for.mac.host.internal", "AWX_GROUP_QUEUES": "tower", "PROJECT_REVISION": "9e2cd25bfb26ba82f40cf31276e1942bf38b3a30", - "ANSIBLE_VENV_PATH": "/venv/ansible", + "ANSIBLE_VENV_PATH": "/var/lib/awx/venv/ansible", "ANSIBLE_ROLES_PATH": "/tmp/awx_13_r1ffeqze/requirements_roles:~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles", "RUNNER_OMIT_EVENTS": "False", "SUPERVISOR_ENABLED": "1", @@ -119,7 +119,7 @@ "DJANGO_SETTINGS_MODULE": "awx.settings.development", "ANSIBLE_STDOUT_CALLBACK": "awx_display", "SUPERVISOR_PROCESS_NAME": "awx-dispatcher", - "ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks", + "ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks", "ANSIBLE_COLLECTIONS_PATHS": "/tmp/awx_13_r1ffeqze/requirements_collections:~/.ansible/collections:/usr/share/ansible/collections", "ANSIBLE_HOST_KEY_CHECKING": "False", "RUNNER_ONLY_FAILED_EVENTS": "False", diff --git a/awx/ui_next/src/screens/Inventory/shared/data.inventory_source.json b/awx/ui_next/src/screens/Inventory/shared/data.inventory_source.json index ad1e313611..550cb8138e 100644 --- a/awx/ui_next/src/screens/Inventory/shared/data.inventory_source.json +++ b/awx/ui_next/src/screens/Inventory/shared/data.inventory_source.json @@ -98,7 +98,7 @@ "credential": 8, "overwrite":true, "overwrite_vars":true, - "custom_virtualenv":"/venv/custom", + "custom_virtualenv":"/var/lib/awx/venv/custom", "timeout":0, "verbosity":2, "last_job_run":null, diff --git a/awx/ui_next/src/screens/Job/Job.jsx b/awx/ui_next/src/screens/Job/Job.jsx index ce62e7338c..479eeb6e49 100644 --- a/awx/ui_next/src/screens/Job/Job.jsx +++ b/awx/ui_next/src/screens/Job/Job.jsx @@ -29,10 +29,18 @@ function Job({ i18n, setBreadcrumb }) { const { isLoading, error, request: fetchJob, result } = useRequest( useCallback(async () => { const { data } = await JobsAPI.readDetail(id, type); + if ( + data?.summary_fields?.credentials?.find(cred => cred.kind === 'vault') + ) { + const { + data: { results }, + } = await JobsAPI.readCredentials(data.id, type); + + data.summary_fields.credentials = results; + } setBreadcrumb(data); return data; - }, [id, type, setBreadcrumb]), - null + }, [id, type, setBreadcrumb]) ); useEffect(() => { diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx index 9963fbbba8..9255b27af1 100644 --- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx +++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx @@ -7,7 +7,11 @@ import { Button, Chip, Label } from '@patternfly/react-core'; import styled from 'styled-components'; import AlertModal from '../../../components/AlertModal'; -import { DetailList, Detail } from '../../../components/DetailList'; +import { + DetailList, + Detail, + UserDateDetail, +} from '../../../components/DetailList'; import { CardBody, CardActionsRow } from '../../../components/Card'; import ChipGroup from '../../../components/ChipGroup'; import CredentialChip from '../../../components/CredentialChip'; @@ -80,6 +84,7 @@ const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => { function JobDetail({ job, i18n }) { const { + created_by, credential, credentials, instance_group: instanceGroup, @@ -289,6 +294,12 @@ function JobDetail({ job, i18n }) { } /> )} + <UserDateDetail + label={i18n._(t`Created`)} + date={job.created} + user={created_by} + /> + <UserDateDetail label={i18n._(t`Last Modified`)} date={job.modified} /> </DetailList> {job.extra_vars && ( <VariablesInput diff --git a/awx/ui_next/src/screens/Job/shared/data.job.json b/awx/ui_next/src/screens/Job/shared/data.job.json index 8bebbbda67..98d071c876 100644 --- a/awx/ui_next/src/screens/Job/shared/data.job.json +++ b/awx/ui_next/src/screens/Job/shared/data.job.json @@ -114,7 +114,7 @@ "started": "2019-08-08T19:24:18.329589Z", "finished": "2019-08-08T19:24:50.119995Z", "elapsed": 31.79, - "job_args": "[\"bwrap\", \"--unshare-pid\", \"--dev-bind\", \"/\", \"/\", \"--proc\", \"/proc\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpvsg8ly2y\", \"/etc/ssh\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq_grmdym\", \"/projects\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpfq8ea2z6\", \"/tmp\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq6v4y_tt\", \"/var/lib/awx\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpupj_jhhb\", \"/var/log\", \"--ro-bind\", \"/venv/ansible\", \"/venv/ansible\", \"--ro-bind\", \"/venv/awx\", \"/venv/awx\", \"--bind\", \"/projects/_6__demo_project\", \"/projects/_6__demo_project\", \"--bind\", \"/tmp/awx_2_a4b1afiw\", \"/tmp/awx_2_a4b1afiw\", \"--chdir\", \"/projects/_6__demo_project\", \"ansible-playbook\", \"-u\", \"admin\", \"-i\", \"/tmp/awx_2_a4b1afiw/tmppb57i4_e\", \"-e\", \"@/tmp/awx_2_a4b1afiw/env/extravars\", \"chatty_tasks.yml\"]", + "job_args": "[\"bwrap\", \"--unshare-pid\", \"--dev-bind\", \"/\", \"/\", \"--proc\", \"/proc\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpvsg8ly2y\", \"/etc/ssh\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq_grmdym\", \"/projects\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpfq8ea2z6\", \"/tmp\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq6v4y_tt\", \"/var/lib/awx\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpupj_jhhb\", \"/var/log\", \"--ro-bind\", \"/var/lib/awx/venv/ansible\", \"/var/lib/awx/venv/ansible\", \"--ro-bind\", \"/var/lib/awx/venv/awx\", \"/var/lib/awx/venv/awx\", \"--bind\", \"/projects/_6__demo_project\", \"/projects/_6__demo_project\", \"--bind\", \"/tmp/awx_2_a4b1afiw\", \"/tmp/awx_2_a4b1afiw\", \"--chdir\", \"/projects/_6__demo_project\", \"ansible-playbook\", \"-u\", \"admin\", \"-i\", \"/tmp/awx_2_a4b1afiw/tmppb57i4_e\", \"-e\", \"@/tmp/awx_2_a4b1afiw/env/extravars\", \"chatty_tasks.yml\"]", "job_cwd": "/projects/_6__demo_project", "job_env": { "HOSTNAME": "awx", @@ -123,9 +123,9 @@ "LC_ALL": "en_US.UTF-8", "SDB_HOST": "0.0.0.0", "MAKELEVEL": "2", - "VIRTUAL_ENV": "/venv/ansible", + "VIRTUAL_ENV": "/var/lib/awx/venv/ansible", "MFLAGS": "-w", - "PATH": "/venv/ansible/bin:/venv/awx/bin:/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "PATH": "/var/lib/awx/venv/ansible/bin:/var/lib/awx/venv/awx/bin:/var/lib/awx/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "SUPERVISOR_GROUP_NAME": "tower-processes", "PWD": "/awx_devel", "LANG": "\"en-us\"", @@ -138,7 +138,7 @@ "SUPERVISOR_SERVER_URL": "unix:///tmp/supervisor.sock", "SUPERVISOR_PROCESS_NAME": "awx-dispatcher", "CURRENT_UID": "501", - "_": "/venv/awx/bin/python3", + "_": "/var/lib/awx/venv/awx/bin/python3", "DJANGO_SETTINGS_MODULE": "awx.settings.development", "DJANGO_LIVE_TEST_SERVER_ADDRESS": "localhost:9013-9199", "SDB_NOTIFY_HOST": "docker.for.mac.host.internal", @@ -147,11 +147,11 @@ "ANSIBLE_HOST_KEY_CHECKING": "False", "ANSIBLE_INVENTORY_UNPARSED_FAILED": "True", "ANSIBLE_PARAMIKO_RECORD_HOST_KEYS": "False", - "ANSIBLE_VENV_PATH": "/venv/ansible", + "ANSIBLE_VENV_PATH": "/var/lib/awx/venv/ansible", "PROOT_TMP_DIR": "/tmp", "AWX_PRIVATE_DATA_DIR": "/tmp/awx_2_a4b1afiw", "ANSIBLE_COLLECTIONS_PATHS": "/tmp/collections", - "PYTHONPATH": "/venv/ansible/lib/python2.7/site-packages:/awx_devel/awx/lib:", + "PYTHONPATH": "/var/lib/awx/venv/ansible/lib/python2.7/site-packages:/awx_devel/awx/lib:", "JOB_ID": "2", "INVENTORY_ID": "1", "PROJECT_REVISION": "23f070aad8e2da131d97ea98b42b553ccf0b0b82", @@ -184,5 +184,5 @@ "play_count": 1, "task_count": 1 }, - "custom_virtualenv": "/venv/ansible" + "custom_virtualenv": "/var/lib/awx/venv/ansible" } diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx index 5761e737a3..b9becea72f 100644 --- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx @@ -10,6 +10,7 @@ import { ArrayDetail, DetailList, DeletedDetail, + UserDateDetail, } from '../../../components/DetailList'; import CodeDetail from '../../../components/DetailList/CodeDetail'; import DeleteButton from '../../../components/DeleteButton'; @@ -23,6 +24,8 @@ function NotificationTemplateDetail({ i18n, template, defaultMessages }) { const history = useHistory(); const { + created, + modified, notification_configuration: configuration, summary_fields, messages, @@ -324,6 +327,16 @@ function NotificationTemplateDetail({ i18n, template, defaultMessages }) { /> </> )} + <UserDateDetail + label={i18n._(t`Created`)} + date={created} + user={summary_fields?.created_by} + /> + <UserDateDetail + label={i18n._(t`Last Modified`)} + date={modified} + user={summary_fields?.modified_by} + /> {hasCustomMessages(messages, typeMessageDefaults) && ( <CustomMessageDetails messages={messages} diff --git a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx index ff969b86b5..8fa4e2cbc2 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx @@ -153,7 +153,7 @@ describe('<OrganizationAdd />', () => { .find('FormSelectOption') .first() .prop('value') - ).toEqual('/venv/ansible/'); + ).toEqual('/var/lib/awx/venv/ansible/'); }); test('AnsibleSelect component does not render if there are 0 virtual environments', async () => { diff --git a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx index c78b178943..094e6ac5b6 100644 --- a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx +++ b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx @@ -31,7 +31,7 @@ function OrganizationFormFields({ i18n, instanceGroups, setInstanceGroups }) { const defaultVenv = { label: i18n._(t`Use Default Ansible Environment`), - value: '/venv/ansible/', + value: '/var/lib/awx/venv/ansible/', key: 'default', }; const { custom_virtualenvs } = useContext(ConfigContext); diff --git a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx index 004c7d1577..67cf0a60d6 100644 --- a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx +++ b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx @@ -200,7 +200,7 @@ describe('<OrganizationForm />', () => { .find('FormSelectOption') .first() .prop('value') - ).toEqual('/venv/ansible/'); + ).toEqual('/var/lib/awx/venv/ansible/'); }); test('onSubmit associates and disassociates instance groups', async () => { diff --git a/awx/ui_next/src/screens/Project/Project.jsx b/awx/ui_next/src/screens/Project/Project.jsx index f87aee15dc..72341a5de9 100644 --- a/awx/ui_next/src/screens/Project/Project.jsx +++ b/awx/ui_next/src/screens/Project/Project.jsx @@ -44,6 +44,19 @@ function Project({ i18n, setBreadcrumb }) { role_level: 'notification_admin_role', }), ]); + + if (data.summary_fields.credentials) { + const params = { + page: 1, + page_size: 200, + order_by: 'name', + }; + const { + data: { results }, + } = await ProjectsAPI.readCredentials(data.id, params); + + data.summary_fields.credentials = results; + } return { project: data, isNotifAdmin: notifAdminRes.data.results.length > 0, diff --git a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx index e6141bebb7..8bc136b889 100644 --- a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx @@ -24,7 +24,7 @@ describe('<ProjectAdd />', () => { scm_update_on_launch: true, scm_update_cache_timeout: 3, allow_override: false, - custom_virtualenv: '/venv/custom-env', + custom_virtualenv: '/var/lib/awx/venv/custom-env', }; const projectOptionsResolve = { diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx index 380196d950..4c92c9695e 100644 --- a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx +++ b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx @@ -19,6 +19,7 @@ import CredentialChip from '../../../components/CredentialChip'; import { ProjectsAPI } from '../../../api'; import { toTitleCase } from '../../../util/strings'; import useRequest, { useDismissableError } from '../../../util/useRequest'; +import ProjectSyncButton from '../shared/ProjectSyncButton'; function ProjectDetail({ project, i18n }) { const { @@ -148,27 +149,28 @@ function ProjectDetail({ project, i18n }) { /> </DetailList> <CardActionsRow> - {summary_fields.user_capabilities && - summary_fields.user_capabilities.edit && ( - <Button - aria-label={i18n._(t`edit`)} - component={Link} - to={`/projects/${id}/edit`} - > - {i18n._(t`Edit`)} - </Button> - )} - {summary_fields.user_capabilities && - summary_fields.user_capabilities.delete && ( - <DeleteButton - name={name} - modalTitle={i18n._(t`Delete Project`)} - onConfirm={deleteProject} - isDisabled={isLoading} - > - {i18n._(t`Delete`)} - </DeleteButton> - )} + {summary_fields.user_capabilities?.edit && ( + <Button + aria-label={i18n._(t`edit`)} + component={Link} + to={`/projects/${id}/edit`} + > + {i18n._(t`Edit`)} + </Button> + )} + {summary_fields.user_capabilities?.start && ( + <ProjectSyncButton projectId={project.id} /> + )} + {summary_fields.user_capabilities?.delete && ( + <DeleteButton + name={name} + modalTitle={i18n._(t`Delete Project`)} + onConfirm={deleteProject} + isDisabled={isLoading} + > + {i18n._(t`Delete`)} + </DeleteButton> + )} </CardActionsRow> {/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */} {error && ( diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx index 3139e2b14c..52e45e7d28 100644 --- a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx @@ -9,7 +9,12 @@ import { ProjectsAPI } from '../../../api'; import ProjectDetail from './ProjectDetail'; jest.mock('../../../api'); - +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useRouteMatch: () => ({ + url: '/projects/1/details', + }), +})); describe('<ProjectDetail />', () => { const mockProject = { id: 1, @@ -139,13 +144,19 @@ describe('<ProjectDetail />', () => { ); }); - test('should show edit button for users with edit permission', async () => { + test('should show edit and sync button for users with edit permission', async () => { const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />); const editButton = await waitForElement( wrapper, 'ProjectDetail Button[aria-label="edit"]' ); + + const syncButton = await waitForElement( + wrapper, + 'ProjectDetail Button[aria-label="Sync Project"]' + ); expect(editButton.text()).toEqual('Edit'); + expect(syncButton.text()).toEqual('Sync'); expect(editButton.prop('to')).toBe(`/projects/${mockProject.id}/edit`); }); @@ -166,6 +177,9 @@ describe('<ProjectDetail />', () => { expect(wrapper.find('ProjectDetail Button[aria-label="edit"]').length).toBe( 0 ); + expect(wrapper.find('ProjectDetail Button[aria-label="sync"]').length).toBe( + 0 + ); }); test('edit button should navigate to project edit', () => { @@ -180,6 +194,17 @@ describe('<ProjectDetail />', () => { expect(history.location.pathname).toEqual('/projects/1/edit'); }); + test('sync button should call api to syn project', async () => { + ProjectsAPI.readSync.mockResolvedValue({ data: { can_update: true } }); + const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />); + await act(() => + wrapper + .find('ProjectDetail Button[aria-label="Sync Project"]') + .prop('onClick')(1) + ); + expect(ProjectsAPI.sync).toHaveBeenCalledTimes(1); + }); + test('expected api calls are made for delete', async () => { const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />); await waitForElement(wrapper, 'ProjectDetail Button[aria-label="Delete"]'); diff --git a/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx b/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx index dc1a49eb78..1a62a3f2f0 100644 --- a/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx @@ -25,7 +25,7 @@ describe('<ProjectEdit />', () => { scm_update_on_launch: true, scm_update_cache_timeout: 3, allow_override: false, - custom_virtualenv: '/venv/custom-env', + custom_virtualenv: '/var/lib/awx/venv/custom-env', summary_fields: { credential: { id: 100, diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx index b2539e5f87..dba55552d4 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx @@ -14,7 +14,7 @@ import { import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; -import { PencilAltIcon, SyncIcon } from '@patternfly/react-icons'; +import { PencilAltIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; import { formatDateString, timeOfDay } from '../../../util/dates'; import { ProjectsAPI } from '../../../api'; @@ -153,23 +153,10 @@ function ProjectListItem({ aria-labelledby={labelId} id={labelId} > - {project.summary_fields.user_capabilities.start ? ( + {project.summary_fields.user_capabilities.start && ( <Tooltip content={i18n._(t`Sync Project`)} position="top"> - <ProjectSyncButton projectId={project.id}> - {handleSync => ( - <Button - isDisabled={isDisabled} - aria-label={i18n._(t`Sync Project`)} - variant="plain" - onClick={handleSync} - > - <SyncIcon /> - </Button> - )} - </ProjectSyncButton> + <ProjectSyncButton projectId={project.id} /> </Tooltip> - ) : ( - '' )} {project.summary_fields.user_capabilities.edit ? ( <Tooltip content={i18n._(t`Edit Project`)} position="top"> diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx index fe0c8b1bb2..c5b454246f 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx @@ -284,11 +284,11 @@ function ProjectFormFields({ data={[ { label: i18n._(t`Use Default Ansible Environment`), - value: '/venv/ansible/', + value: '/var/lib/awx/venv/ansible/', key: 'default', }, ...custom_virtualenvs - .filter(datum => datum !== '/venv/ansible/') + .filter(datum => datum !== '/var/lib/awx/venv/ansible/') .map(datum => ({ label: datum, value: datum, diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx index 03defe391a..7e88bc7f10 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx @@ -22,7 +22,7 @@ describe('<ProjectForm />', () => { scm_update_on_launch: true, scm_update_cache_timeout: 3, allow_override: false, - custom_virtualenv: '/venv/custom-env', + custom_virtualenv: '/var/lib/awx/venv/custom-env', summary_fields: { credential: { id: 100, diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx index b65aecae68..864142b046 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx @@ -1,4 +1,8 @@ import React, { useCallback } from 'react'; +import { useRouteMatch } from 'react-router-dom'; +import { Button } from '@patternfly/react-core'; +import { SyncIcon } from '@patternfly/react-icons'; + import { number } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -8,28 +12,27 @@ import AlertModal from '../../../components/AlertModal'; import ErrorDetail from '../../../components/ErrorDetail'; import { ProjectsAPI } from '../../../api'; -function ProjectSyncButton({ i18n, children, projectId }) { +function ProjectSyncButton({ i18n, projectId }) { + const match = useRouteMatch(); + const { request: handleSync, error: syncError } = useRequest( useCallback(async () => { - const { data } = await ProjectsAPI.readSync(projectId); - if (data.can_update) { - await ProjectsAPI.sync(projectId); - } else { - throw new Error( - i18n._( - t`You don't have the necessary permissions to sync this project.` - ) - ); - } - }, [i18n, projectId]), + await ProjectsAPI.sync(projectId); + }, [projectId]), null ); const { error, dismissError } = useDismissableError(syncError); - + const isDetailsView = match.url.endsWith('/details'); return ( <> - {children(handleSync)} + <Button + aria-label={i18n._(t`Sync Project`)} + variant={isDetailsView ? 'secondary' : 'plain'} + onClick={handleSync} + > + {match.url.endsWith('/details') ? i18n._(t`Sync`) : <SyncIcon />} + </Button> {error && ( <AlertModal isOpen={error} diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.test.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.test.jsx index 4bc302f09b..9f53dfb339 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.test.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.test.jsx @@ -10,11 +10,6 @@ jest.mock('../../../api'); describe('ProjectSyncButton', () => { let wrapper; - ProjectsAPI.readSync.mockResolvedValue({ - data: { - can_update: true, - }, - }); const children = handleSync => ( <button type="submit" onClick={() => handleSync()} /> @@ -43,8 +38,7 @@ describe('ProjectSyncButton', () => { await act(async () => { button.prop('onClick')(); }); - expect(ProjectsAPI.readSync).toHaveBeenCalledWith(1); - await sleep(0); + expect(ProjectsAPI.sync).toHaveBeenCalledWith(1); }); test('displays error modal after unsuccessful sync', async () => { diff --git a/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/ActivityStreamDetail.jsx b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/ActivityStreamDetail.jsx index d5e6afe101..55bcb74db0 100644 --- a/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/ActivityStreamDetail.jsx +++ b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/ActivityStreamDetail.jsx @@ -86,6 +86,7 @@ function ActivityStreamDetail({ i18n }) { <Button aria-label={i18n._(t`Edit`)} component={Link} + ouiaId="edit-button" to="/settings/activity_stream/edit" > {i18n._(t`Edit`)} diff --git a/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx b/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx index 889ac19163..aa3528f3a3 100644 --- a/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx +++ b/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx @@ -78,6 +78,7 @@ function AzureADDetail({ i18n }) { <Button aria-label={i18n._(t`Edit`)} component={Link} + ouiaId="edit-button" to="/settings/azure/edit" > {i18n._(t`Edit`)} diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx index c134adca9c..03d92e5899 100644 --- a/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx @@ -6,6 +6,8 @@ import { PageSection, Card } from '@patternfly/react-core'; import ContentError from '../../../components/ContentError'; import GitHubDetail from './GitHubDetail'; import GitHubEdit from './GitHubEdit'; +import GitHubOrgEdit from './GitHubOrgEdit'; +import GitHubTeamEdit from './GitHubTeamEdit'; function GitHub({ i18n }) { const baseURL = '/settings/github'; @@ -29,9 +31,15 @@ function GitHub({ i18n }) { <Route path={`${baseURL}/:category/details`}> <GitHubDetail /> </Route> - <Route path={`${baseURL}/:category/edit`}> + <Route path={`${baseURL}/default/edit`}> <GitHubEdit /> </Route> + <Route path={`${baseURL}/organization/edit`}> + <GitHubOrgEdit /> + </Route> + <Route path={`${baseURL}/team/edit`}> + <GitHubTeamEdit /> + </Route> <Route key="not-found" path={`${baseURL}/*`}> <ContentError isNotFound> <Link to={`${baseURL}/default/details`}> diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx index 37a1c62a67..68572d6c35 100644 --- a/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx @@ -5,33 +5,94 @@ import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; -import GitHub from './GitHub'; import { SettingsAPI } from '../../../api'; +import { SettingsProvider } from '../../../contexts/Settings'; +import mockAllOptions from '../shared/data.allSettingOptions.json'; +import GitHub from './GitHub'; jest.mock('../../../api/models/Settings'); -SettingsAPI.readCategory.mockResolvedValue({ - data: {}, -}); describe('<GitHub />', () => { let wrapper; + beforeEach(() => { + SettingsAPI.readCategory.mockResolvedValueOnce({ + data: { + SOCIAL_AUTH_GITHUB_CALLBACK_URL: + 'https://towerhost/sso/complete/github/', + SOCIAL_AUTH_GITHUB_KEY: 'mock github key', + SOCIAL_AUTH_GITHUB_SECRET: '$encrypted$', + SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: null, + SOCIAL_AUTH_GITHUB_TEAM_MAP: null, + }, + }); + SettingsAPI.readCategory.mockResolvedValueOnce({ + data: { + SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL: + 'https://towerhost/sso/complete/github-org/', + SOCIAL_AUTH_GITHUB_ORG_KEY: '', + SOCIAL_AUTH_GITHUB_ORG_SECRET: '$encrypted$', + SOCIAL_AUTH_GITHUB_ORG_NAME: '', + SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null, + SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null, + }, + }); + SettingsAPI.readCategory.mockResolvedValueOnce({ + data: { + SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL: + 'https://towerhost/sso/complete/github-team/', + SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)', + SOCIAL_AUTH_GITHUB_TEAM_SECRET: '$encrypted$', + SOCIAL_AUTH_GITHUB_TEAM_ID: 'team_id', + SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {}, + SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {}, + }, + }); + }); + afterEach(() => { wrapper.unmount(); jest.clearAllMocks(); }); - test('should render github details', async () => { + test('should render github default details', async () => { const history = createMemoryHistory({ initialEntries: ['/settings/github/'], }); await act(async () => { - wrapper = mountWithContexts(<GitHub />, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + <SettingsProvider value={mockAllOptions.actions}> + <GitHub /> + </SettingsProvider>, + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('GitHubDetail').length).toBe(1); + expect(wrapper.find('Detail[label="GitHub OAuth2 Key"]').length).toBe(1); + }); + + test('should redirect to github organization category details', async () => { + const history = createMemoryHistory({ + initialEntries: ['/settings/github/organization'], + }); + await act(async () => { + wrapper = mountWithContexts( + <SettingsProvider value={mockAllOptions.actions}> + <GitHub /> + </SettingsProvider>, + { + context: { router: { history } }, + } + ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); expect(wrapper.find('GitHubDetail').length).toBe(1); + expect( + wrapper.find('Detail[label="GitHub Organization OAuth2 Key"]').length + ).toBe(1); }); test('should render github edit', async () => { @@ -39,9 +100,14 @@ describe('<GitHub />', () => { initialEntries: ['/settings/github/default/edit'], }); await act(async () => { - wrapper = mountWithContexts(<GitHub />, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + <SettingsProvider value={mockAllOptions.actions}> + <GitHub /> + </SettingsProvider>, + { + context: { router: { history } }, + } + ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); expect(wrapper.find('GitHubEdit').length).toBe(1); @@ -52,9 +118,14 @@ describe('<GitHub />', () => { initialEntries: ['/settings/github/foo/bar'], }); await act(async () => { - wrapper = mountWithContexts(<GitHub />, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + <SettingsProvider value={mockAllOptions.actions}> + <GitHub /> + </SettingsProvider>, + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('ContentError').length).toBe(1); }); diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx index 1fd6da3f0d..5dc76348fb 100644 --- a/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx @@ -114,6 +114,7 @@ function GitHubDetail({ i18n }) { <Button aria-label={i18n._(t`Edit`)} component={Link} + ouiaId="edit-button" to={`${baseURL}/${category}/edit`} > {i18n._(t`Edit`)} diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx index 07a6f45015..f1b562043d 100644 --- a/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx @@ -1,25 +1,141 @@ -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'; - -function GitHubEdit({ i18n }) { +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, + ObjectField, +} from '../../shared/SharedFields'; +import { formatJson } from '../../shared/settingUtils'; +import useModal from '../../../../util/useModal'; +import useRequest from '../../../../util/useRequest'; +import { SettingsAPI } from '../../../../api'; + +function GitHubEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { isLoading, error, request: fetchGithub, result: github } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('github'); + 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(() => { + fetchGithub(); + }, [fetchGithub]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async values => { + await SettingsAPI.updateAll(values); + history.push('/settings/github/details'); + }, + [history] + ), + null + ); + + const handleSubmit = async form => { + await submitForm({ + ...form, + SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: formatJson( + form.SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP + ), + SOCIAL_AUTH_GITHUB_TEAM_MAP: formatJson(form.SOCIAL_AUTH_GITHUB_TEAM_MAP), + }); + }; + + const handleRevertAll = async () => { + const defaultValues = Object.assign( + ...Object.entries(github).map(([key, value]) => ({ + [key]: value.default, + })) + ); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/github/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; + }, {}); + return ( <CardBody> - {i18n._(t`Edit form coming soon :)`)} - <CardActionsRow> - <Button - aria-label={i18n._(t`Cancel`)} - component={Link} - to="/settings/github/details" - > - {i18n._(t`Cancel`)} - </Button> - </CardActionsRow> + {isLoading && <ContentLoading />} + {!isLoading && error && <ContentError error={error} />} + {!isLoading && github && ( + <Formik initialValues={initialValues(github)} onSubmit={handleSubmit}> + {formik => ( + <Form autoComplete="off" onSubmit={formik.handleSubmit}> + <FormColumnLayout> + <InputField + name="SOCIAL_AUTH_GITHUB_KEY" + config={github.SOCIAL_AUTH_GITHUB_KEY} + /> + <EncryptedField + name="SOCIAL_AUTH_GITHUB_SECRET" + config={github.SOCIAL_AUTH_GITHUB_SECRET} + /> + <ObjectField + name="SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP" + config={github.SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP} + /> + <ObjectField + name="SOCIAL_AUTH_GITHUB_TEAM_MAP" + config={github.SOCIAL_AUTH_GITHUB_TEAM_MAP} + /> + {submitError && <FormSubmitError error={submitError} />} + </FormColumnLayout> + <RevertFormActionGroup + onCancel={handleCancel} + onSubmit={formik.handleSubmit} + onRevert={toggleModal} + /> + {isModalOpen && ( + <RevertAllAlert + onClose={closeModal} + onRevertAll={handleRevertAll} + /> + )} + </Form> + )} + </Formik> + )} </CardBody> ); } -export default withI18n()(GitHubEdit); +export default GitHubEdit; diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx index 539932c99a..f864f1f6ca 100644 --- a/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx @@ -1,16 +1,173 @@ 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 GitHubEdit from './GitHubEdit'; +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: { + SOCIAL_AUTH_GITHUB_CALLBACK_URL: 'https://foo/complete/github/', + SOCIAL_AUTH_GITHUB_KEY: 'mock github key', + SOCIAL_AUTH_GITHUB_SECRET: '$encrypted$', + SOCIAL_AUTH_GITHUB_TEAM_MAP: {}, + SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: { + Default: { + users: true, + }, + }, + }, +}); + describe('<GitHubEdit />', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(<GitHubEdit />); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); + }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/github/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + <SettingsProvider value={mockAllOptions.actions}> + <GitHubEdit /> + </SettingsProvider>, + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); }); + test('initially renders without crashing', () => { expect(wrapper.find('GitHubEdit').length).toBe(1); }); + + test('should display expected form fields', async () => { + expect(wrapper.find('FormGroup[label="GitHub OAuth2 Key"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="GitHub OAuth2 Secret"]').length).toBe( + 1 + ); + expect( + wrapper.find('FormGroup[label="GitHub OAuth2 Organization Map"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="GitHub OAuth2 Team Map"]').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({ + SOCIAL_AUTH_GITHUB_KEY: '', + SOCIAL_AUTH_GITHUB_SECRET: '', + SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: null, + SOCIAL_AUTH_GITHUB_TEAM_MAP: null, + }); + }); + + test('should successfully send request to api on form submission', async () => { + act(() => { + wrapper + .find( + 'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_SECRET"] button[aria-label="Revert"]' + ) + .invoke('onClick')(); + wrapper.find('input#SOCIAL_AUTH_GITHUB_KEY').simulate('change', { + target: { value: 'new key', name: 'SOCIAL_AUTH_GITHUB_KEY' }, + }); + wrapper + .find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP') + .invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}'); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + SOCIAL_AUTH_GITHUB_KEY: 'new key', + SOCIAL_AUTH_GITHUB_SECRET: '', + SOCIAL_AUTH_GITHUB_TEAM_MAP: {}, + SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: { + Default: { + users: false, + }, + }, + }); + }); + + test('should navigate to github default detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual('/settings/github/details'); + }); + + test('should navigate to github default detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/github/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( + <SettingsProvider value={mockAllOptions.actions}> + <GitHubEdit /> + </SettingsProvider> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.jsx new file mode 100644 index 0000000000..6224acb5b7 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.jsx @@ -0,0 +1,147 @@ +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, + ObjectField, +} from '../../shared/SharedFields'; +import { formatJson } from '../../shared/settingUtils'; +import useModal from '../../../../util/useModal'; +import useRequest from '../../../../util/useRequest'; +import { SettingsAPI } from '../../../../api'; + +function GitHubOrgEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { isLoading, error, request: fetchGithub, result: github } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('github-org'); + 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(() => { + fetchGithub(); + }, [fetchGithub]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async values => { + await SettingsAPI.updateAll(values); + history.push('/settings/github/organization/details'); + }, + [history] + ), + null + ); + + const handleSubmit = async form => { + await submitForm({ + ...form, + SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: formatJson( + form.SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP + ), + SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: formatJson( + form.SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP + ), + }); + }; + + const handleRevertAll = async () => { + const defaultValues = Object.assign( + ...Object.entries(github).map(([key, value]) => ({ + [key]: value.default, + })) + ); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/github/organization/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; + }, {}); + + return ( + <CardBody> + {isLoading && <ContentLoading />} + {!isLoading && error && <ContentError error={error} />} + {!isLoading && github && ( + <Formik initialValues={initialValues(github)} onSubmit={handleSubmit}> + {formik => ( + <Form autoComplete="off" onSubmit={formik.handleSubmit}> + <FormColumnLayout> + <InputField + name="SOCIAL_AUTH_GITHUB_ORG_KEY" + config={github.SOCIAL_AUTH_GITHUB_ORG_KEY} + /> + <EncryptedField + name="SOCIAL_AUTH_GITHUB_ORG_SECRET" + config={github.SOCIAL_AUTH_GITHUB_ORG_SECRET} + /> + <InputField + name="SOCIAL_AUTH_GITHUB_ORG_NAME" + config={github.SOCIAL_AUTH_GITHUB_ORG_NAME} + /> + <ObjectField + name="SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP" + config={github.SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP} + /> + <ObjectField + name="SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP" + config={github.SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP} + /> + {submitError && <FormSubmitError error={submitError} />} + </FormColumnLayout> + <RevertFormActionGroup + onCancel={handleCancel} + onSubmit={formik.handleSubmit} + onRevert={toggleModal} + /> + {isModalOpen && ( + <RevertAllAlert + onClose={closeModal} + onRevertAll={handleRevertAll} + /> + )} + </Form> + )} + </Formik> + )} + </CardBody> + ); +} + +export default GitHubOrgEdit; diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.test.jsx new file mode 100644 index 0000000000..57396c43d1 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.test.jsx @@ -0,0 +1,186 @@ +import React from 'react'; +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 GitHubOrgEdit from './GitHubOrgEdit'; + +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: { + SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL: + 'https://towerhost/sso/complete/github-org/', + SOCIAL_AUTH_GITHUB_ORG_KEY: '', + SOCIAL_AUTH_GITHUB_ORG_SECRET: '$encrypted$', + SOCIAL_AUTH_GITHUB_ORG_NAME: '', + SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null, + SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null, + }, +}); + +describe('<GitHubOrgEdit />', () => { + let wrapper; + let history; + + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/github/organization/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + <SettingsProvider value={mockAllOptions.actions}> + <GitHubOrgEdit /> + </SettingsProvider>, + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + test('initially renders without crashing', () => { + expect(wrapper.find('GitHubOrgEdit').length).toBe(1); + }); + + test('should display expected form fields', async () => { + expect( + wrapper.find('FormGroup[label="GitHub Organization OAuth2 Key"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="GitHub Organization OAuth2 Secret"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="GitHub Organization Name"]').length + ).toBe(1); + expect( + wrapper.find( + 'FormGroup[label="GitHub Organization OAuth2 Organization Map"]' + ).length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="GitHub Organization OAuth2 Team Map"]') + .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({ + SOCIAL_AUTH_GITHUB_ORG_KEY: '', + SOCIAL_AUTH_GITHUB_ORG_SECRET: '', + SOCIAL_AUTH_GITHUB_ORG_NAME: '', + SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null, + SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null, + }); + }); + + test('should successfully send request to api on form submission', async () => { + act(() => { + wrapper + .find( + 'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_ORG_SECRET"] button[aria-label="Revert"]' + ) + .invoke('onClick')(); + wrapper.find('input#SOCIAL_AUTH_GITHUB_ORG_NAME').simulate('change', { + target: { value: 'new org', name: 'SOCIAL_AUTH_GITHUB_ORG_NAME' }, + }); + wrapper + .find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP') + .invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}'); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + SOCIAL_AUTH_GITHUB_ORG_KEY: '', + SOCIAL_AUTH_GITHUB_ORG_SECRET: '', + SOCIAL_AUTH_GITHUB_ORG_NAME: 'new org', + SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: {}, + SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: { + Default: { + users: false, + }, + }, + }); + }); + + test('should navigate to github organization detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual( + '/settings/github/organization/details' + ); + }); + + test('should navigate to github organization detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual( + '/settings/github/organization/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( + <SettingsProvider value={mockAllOptions.actions}> + <GitHubOrgEdit /> + </SettingsProvider> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/index.js b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/index.js new file mode 100644 index 0000000000..1652804b44 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/index.js @@ -0,0 +1 @@ +export { default } from './GitHubOrgEdit'; diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.jsx new file mode 100644 index 0000000000..f898539283 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.jsx @@ -0,0 +1,147 @@ +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, + ObjectField, +} from '../../shared/SharedFields'; +import { formatJson } from '../../shared/settingUtils'; +import useModal from '../../../../util/useModal'; +import useRequest from '../../../../util/useRequest'; +import { SettingsAPI } from '../../../../api'; + +function GitHubTeamEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { isLoading, error, request: fetchGithub, result: github } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('github-team'); + 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(() => { + fetchGithub(); + }, [fetchGithub]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async values => { + await SettingsAPI.updateAll(values); + history.push('/settings/github/team/details'); + }, + [history] + ), + null + ); + + const handleSubmit = async form => { + await submitForm({ + ...form, + SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: formatJson( + form.SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP + ), + SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: formatJson( + form.SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP + ), + }); + }; + + const handleRevertAll = async () => { + const defaultValues = Object.assign( + ...Object.entries(github).map(([key, value]) => ({ + [key]: value.default, + })) + ); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/github/team/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; + }, {}); + + return ( + <CardBody> + {isLoading && <ContentLoading />} + {!isLoading && error && <ContentError error={error} />} + {!isLoading && github && ( + <Formik initialValues={initialValues(github)} onSubmit={handleSubmit}> + {formik => ( + <Form autoComplete="off" onSubmit={formik.handleSubmit}> + <FormColumnLayout> + <InputField + name="SOCIAL_AUTH_GITHUB_TEAM_KEY" + config={github.SOCIAL_AUTH_GITHUB_TEAM_KEY} + /> + <EncryptedField + name="SOCIAL_AUTH_GITHUB_TEAM_SECRET" + config={github.SOCIAL_AUTH_GITHUB_TEAM_SECRET} + /> + <InputField + name="SOCIAL_AUTH_GITHUB_TEAM_ID" + config={github.SOCIAL_AUTH_GITHUB_TEAM_ID} + /> + <ObjectField + name="SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP" + config={github.SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP} + /> + <ObjectField + name="SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP" + config={github.SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP} + /> + {submitError && <FormSubmitError error={submitError} />} + </FormColumnLayout> + <RevertFormActionGroup + onCancel={handleCancel} + onSubmit={formik.handleSubmit} + onRevert={toggleModal} + /> + {isModalOpen && ( + <RevertAllAlert + onClose={closeModal} + onRevertAll={handleRevertAll} + /> + )} + </Form> + )} + </Formik> + )} + </CardBody> + ); +} + +export default GitHubTeamEdit; diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.test.jsx new file mode 100644 index 0000000000..bbc36f8948 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.test.jsx @@ -0,0 +1,177 @@ +import React from 'react'; +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 GitHubTeamEdit from './GitHubTeamEdit'; + +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: { + SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL: + 'https://towerhost/sso/complete/github-team/', + SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)', + SOCIAL_AUTH_GITHUB_TEAM_SECRET: '$encrypted$', + SOCIAL_AUTH_GITHUB_TEAM_ID: 'team_id', + SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {}, + SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {}, + }, +}); + +describe('<GitHubTeamEdit />', () => { + let wrapper; + let history; + + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/github/team/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + <SettingsProvider value={mockAllOptions.actions}> + <GitHubTeamEdit /> + </SettingsProvider>, + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + test('initially renders without crashing', () => { + expect(wrapper.find('GitHubTeamEdit').length).toBe(1); + }); + + test('should display expected form fields', async () => { + expect( + wrapper.find('FormGroup[label="GitHub Team OAuth2 Key"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="GitHub Team OAuth2 Secret"]').length + ).toBe(1); + expect(wrapper.find('FormGroup[label="GitHub Team ID"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="GitHub Team OAuth2 Organization Map"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="GitHub Team OAuth2 Team Map"]').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({ + SOCIAL_AUTH_GITHUB_TEAM_KEY: '', + SOCIAL_AUTH_GITHUB_TEAM_SECRET: '', + SOCIAL_AUTH_GITHUB_TEAM_ID: '', + SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: null, + SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: null, + }); + }); + + test('should successfully send request to api on form submission', async () => { + act(() => { + wrapper + .find( + 'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_TEAM_SECRET"] button[aria-label="Revert"]' + ) + .invoke('onClick')(); + wrapper.find('input#SOCIAL_AUTH_GITHUB_TEAM_ID').simulate('change', { + target: { value: '12345', name: 'SOCIAL_AUTH_GITHUB_TEAM_ID' }, + }); + wrapper + .find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP') + .invoke('onChange')('{\n"Default":{\n"users":\ntrue\n}\n}'); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)', + SOCIAL_AUTH_GITHUB_TEAM_SECRET: '', + SOCIAL_AUTH_GITHUB_TEAM_ID: '12345', + SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {}, + SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: { + Default: { + users: true, + }, + }, + }); + }); + + test('should navigate to github team detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual('/settings/github/team/details'); + }); + + test('should navigate to github team detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/github/team/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( + <SettingsProvider value={mockAllOptions.actions}> + <GitHubTeamEdit /> + </SettingsProvider> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/index.js b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/index.js new file mode 100644 index 0000000000..ba00e5355b --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/index.js @@ -0,0 +1 @@ +export { default } from './GitHubTeamEdit'; diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2.test.jsx b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2.test.jsx index 7b7b330aec..4455605098 100644 --- a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2.test.jsx +++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2.test.jsx @@ -2,13 +2,28 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import GoogleOAuth2 from './GoogleOAuth2'; - +import { SettingsProvider } from '../../../contexts/Settings'; import { SettingsAPI } from '../../../api'; +import mockAllOptions from '../shared/data.allSettingOptions.json'; +import GoogleOAuth2 from './GoogleOAuth2'; jest.mock('../../../api/models/Settings'); SettingsAPI.readCategory.mockResolvedValue({ - data: {}, + data: { + SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL: + 'https://towerhost/sso/complete/google-oauth2/', + SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: 'mock key', + SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: '$encrypted$', + SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: [ + 'example.com', + 'example_2.com', + ], + SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: {}, + SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: { + Default: {}, + }, + SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: {}, + }, }); describe('<GoogleOAuth2 />', () => { @@ -24,9 +39,14 @@ describe('<GoogleOAuth2 />', () => { initialEntries: ['/settings/google_oauth2/details'], }); await act(async () => { - wrapper = mountWithContexts(<GoogleOAuth2 />, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + <SettingsProvider value={mockAllOptions.actions}> + <GoogleOAuth2 /> + </SettingsProvider>, + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('GoogleOAuth2Detail').length).toBe(1); }); @@ -36,9 +56,14 @@ describe('<GoogleOAuth2 />', () => { initialEntries: ['/settings/google_oauth2/edit'], }); await act(async () => { - wrapper = mountWithContexts(<GoogleOAuth2 />, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + <SettingsProvider value={mockAllOptions.actions}> + <GoogleOAuth2 /> + </SettingsProvider>, + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('GoogleOAuth2Edit').length).toBe(1); }); diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx index f19f87378f..98a241f801 100644 --- a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx +++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx @@ -78,6 +78,7 @@ function GoogleOAuth2Detail({ i18n }) { <Button aria-label={i18n._(t`Edit`)} component={Link} + ouiaId="edit-button" to="/settings/google_oauth2/edit" > {i18n._(t`Edit`)} diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.jsx b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.jsx index 50a546334d..ebc6f3d662 100644 --- a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.jsx +++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.jsx @@ -1,25 +1,171 @@ -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'; - -function GoogleOAuth2Edit({ i18n }) { +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, + ObjectField, +} from '../../shared/SharedFields'; +import { formatJson } from '../../shared/settingUtils'; +import useModal from '../../../../util/useModal'; +import useRequest from '../../../../util/useRequest'; +import { SettingsAPI } from '../../../../api'; + +function GoogleOAuth2Edit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { + isLoading, + error, + request: fetchGoogleOAuth2, + result: googleOAuth2, + } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('google-oauth2'); + 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(() => { + fetchGoogleOAuth2(); + }, [fetchGoogleOAuth2]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async values => { + await SettingsAPI.updateAll(values); + history.push('/settings/google_oauth2/details'); + }, + [history] + ), + null + ); + + const handleSubmit = async form => { + await submitForm({ + ...form, + SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: formatJson( + form.SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS + ), + SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: formatJson( + form.SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS + ), + SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: formatJson( + form.SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP + ), + SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: formatJson( + form.SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP + ), + }); + }; + + const handleRevertAll = async () => { + const defaultValues = Object.assign( + ...Object.entries(googleOAuth2).map(([key, value]) => ({ + [key]: value.default, + })) + ); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/google_oauth2/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; + }, {}); + return ( <CardBody> - {i18n._(t`Edit form coming soon :)`)} - <CardActionsRow> - <Button - aria-label={i18n._(t`Cancel`)} - component={Link} - to="/settings/google_oauth2/details" + {isLoading && <ContentLoading />} + {!isLoading && error && <ContentError error={error} />} + {!isLoading && googleOAuth2 && ( + <Formik + initialValues={initialValues(googleOAuth2)} + onSubmit={handleSubmit} > - {i18n._(t`Cancel`)} - </Button> - </CardActionsRow> + {formik => ( + <Form autoComplete="off" onSubmit={formik.handleSubmit}> + <FormColumnLayout> + <InputField + name="SOCIAL_AUTH_GOOGLE_OAUTH2_KEY" + config={googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY} + /> + <EncryptedField + name="SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET" + config={googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET} + /> + <ObjectField + name="SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS" + config={ + googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS + } + /> + <ObjectField + name="SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS" + config={ + googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS + } + /> + <ObjectField + name="SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP" + config={ + googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP + } + /> + <ObjectField + name="SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP" + config={googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP} + /> + {submitError && <FormSubmitError error={submitError} />} + </FormColumnLayout> + <RevertFormActionGroup + onCancel={handleCancel} + onSubmit={formik.handleSubmit} + onRevert={toggleModal} + /> + {isModalOpen && ( + <RevertAllAlert + onClose={closeModal} + onRevertAll={handleRevertAll} + /> + )} + </Form> + )} + </Formik> + )} </CardBody> ); } -export default withI18n()(GoogleOAuth2Edit); +export default GoogleOAuth2Edit; diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.test.jsx b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.test.jsx index 034a0def4e..68a292232c 100644 --- a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.test.jsx +++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.test.jsx @@ -1,16 +1,196 @@ 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 { SettingsProvider } from '../../../../contexts/Settings'; +import { SettingsAPI } from '../../../../api'; +import mockAllOptions from '../../shared/data.allSettingOptions.json'; import GoogleOAuth2Edit from './GoogleOAuth2Edit'; +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: { + SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL: + 'https://towerhost/sso/complete/google-oauth2/', + SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: 'mock key', + SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: '$encrypted$', + SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: [ + 'example.com', + 'example_2.com', + ], + SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: {}, + SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: { + Default: {}, + }, + SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: {}, + }, +}); + describe('<GoogleOAuth2Edit />', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(<GoogleOAuth2Edit />); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); + }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/google_oauth2/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + <SettingsProvider value={mockAllOptions.actions}> + <GoogleOAuth2Edit /> + </SettingsProvider>, + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); }); + test('initially renders without crashing', () => { expect(wrapper.find('GoogleOAuth2Edit').length).toBe(1); }); + + test('should display expected form fields', async () => { + expect(wrapper.find('FormGroup[label="Google OAuth2 Key"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Google OAuth2 Secret"]').length).toBe( + 1 + ); + expect( + wrapper.find('FormGroup[label="Google OAuth2 Allowed Domains"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="Google OAuth2 Extra Arguments"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="Google OAuth2 Organization Map"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="Google OAuth2 Team Map"]').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({ + SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: '', + SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: '', + SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: [], + SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: {}, + SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: null, + SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: null, + }); + }); + + test('should successfully send request to api on form submission', async () => { + act(() => { + wrapper + .find( + 'FormGroup[fieldId="SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS"] button[aria-label="Revert"]' + ) + .invoke('onClick')(); + wrapper + .find( + 'FormGroup[fieldId="SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET"] button[aria-label="Revert"]' + ) + .invoke('onClick')(); + wrapper.find('input#SOCIAL_AUTH_GOOGLE_OAUTH2_KEY').simulate('change', { + target: { value: 'new key', name: 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY' }, + }); + wrapper + .find('CodeMirrorInput#SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP') + .invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}'); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: 'new key', + SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: '', + SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: [], + SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: {}, + SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: {}, + SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: { + Default: { + users: false, + }, + }, + }); + }); + + test('should navigate to Google OAuth 2.0 detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual( + '/settings/google_oauth2/details' + ); + }); + + test('should navigate to Google OAuth 2.0 detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual( + '/settings/google_oauth2/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( + <SettingsProvider value={mockAllOptions.actions}> + <GoogleOAuth2Edit /> + </SettingsProvider> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx b/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx index c25c221518..a29a633a2d 100644 --- a/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx +++ b/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx @@ -95,6 +95,7 @@ function JobsDetail({ i18n }) { <Button aria-label={i18n._(t`Edit`)} component={Link} + ouiaId="edit-button" to="/settings/jobs/edit" > {i18n._(t`Edit`)} diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAP.test.jsx b/awx/ui_next/src/screens/Setting/LDAP/LDAP.test.jsx index bcc6c60be0..88ca337048 100644 --- a/awx/ui_next/src/screens/Setting/LDAP/LDAP.test.jsx +++ b/awx/ui_next/src/screens/Setting/LDAP/LDAP.test.jsx @@ -6,12 +6,13 @@ import { waitForElement, } from '../../../../testUtils/enzymeHelpers'; import { SettingsAPI } from '../../../api'; +import { SettingsProvider } from '../../../contexts/Settings'; +import mockAllOptions from '../shared/data.allSettingOptions.json'; +import mockLDAP from '../shared/data.ldapSettings.json'; import LDAP from './LDAP'; jest.mock('../../../api/models/Settings'); -SettingsAPI.readCategory.mockResolvedValue({ - data: {}, -}); +SettingsAPI.readCategory.mockResolvedValue({ data: mockLDAP }); describe('<LDAP />', () => { let wrapper; @@ -39,9 +40,14 @@ describe('<LDAP />', () => { initialEntries: ['/settings/ldap/default/edit'], }); await act(async () => { - wrapper = mountWithContexts(<LDAP />, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + <SettingsProvider value={mockAllOptions.actions}> + <LDAP /> + </SettingsProvider>, + { + context: { router: { history } }, + } + ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); expect(wrapper.find('LDAPEdit').length).toBe(1); diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx b/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx index 120b00fa44..74850dfbe4 100644 --- a/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx +++ b/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx @@ -159,6 +159,7 @@ function LDAPDetail({ i18n }) { <Button aria-label={i18n._(t`Edit`)} component={Link} + ouiaId="edit-button" to={`${baseURL}/${category}/edit`} > {i18n._(t`Edit`)} diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.jsx b/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.jsx index 084df200ed..af1764299a 100644 --- a/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.jsx +++ b/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.jsx @@ -1,25 +1,250 @@ -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'; - -function LDAPEdit({ i18n }) { +import React, { useCallback, useEffect } from 'react'; +import { useHistory, useRouteMatch } 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, + FormFullWidthLayout, +} from '../../../../components/FormLayout'; +import { useSettings } from '../../../../contexts/Settings'; +import { RevertAllAlert, RevertFormActionGroup } from '../../shared'; +import { + BooleanField, + ChoiceField, + EncryptedField, + 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 filterByPrefix(data, prefix) { + return Object.keys(data) + .filter(key => key.includes(prefix)) + .reduce((obj, key) => { + obj[key] = data[key]; + return obj; + }, {}); +} + +function LDAPEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + const { + params: { category }, + } = useRouteMatch('/settings/ldap/:category/edit'); + const ldapCategory = + category === 'default' ? 'AUTH_LDAP_' : `AUTH_LDAP_${category}_`; + + const { isLoading, error, request: fetchLDAP, result: ldap } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('ldap'); + + const mergedData = {}; + Object.keys(data).forEach(key => { + if (!options[key]) { + return; + } + mergedData[key] = options[key]; + mergedData[key].value = data[key]; + }); + + const allCategories = { + AUTH_LDAP_1_: filterByPrefix(mergedData, 'AUTH_LDAP_1_'), + AUTH_LDAP_2_: filterByPrefix(mergedData, 'AUTH_LDAP_2_'), + AUTH_LDAP_3_: filterByPrefix(mergedData, 'AUTH_LDAP_3_'), + AUTH_LDAP_4_: filterByPrefix(mergedData, 'AUTH_LDAP_4_'), + AUTH_LDAP_5_: filterByPrefix(mergedData, 'AUTH_LDAP_5_'), + AUTH_LDAP_: Object.assign({}, mergedData), + }; + Object.keys({ + ...allCategories.AUTH_LDAP_1_, + ...allCategories.AUTH_LDAP_2_, + ...allCategories.AUTH_LDAP_3_, + ...allCategories.AUTH_LDAP_4_, + ...allCategories.AUTH_LDAP_5_, + }).forEach(keyToOmit => { + delete allCategories.AUTH_LDAP_[keyToOmit]; + }); + + return allCategories[ldapCategory]; + }, [options, ldapCategory]), + null + ); + + useEffect(() => { + fetchLDAP(); + }, [fetchLDAP]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async values => { + await SettingsAPI.updateAll(values); + history.push(`/settings/ldap/${category}/details`); + }, + [history, category] + ), + null + ); + + const handleSubmit = async form => { + await submitForm({ + [`${ldapCategory}BIND_DN`]: form[`${ldapCategory}BIND_DN`], + [`${ldapCategory}BIND_PASSWORD`]: form[`${ldapCategory}BIND_PASSWORD`], + [`${ldapCategory}DENY_GROUP`]: form[`${ldapCategory}DENY_GROUP`], + [`${ldapCategory}GROUP_TYPE`]: form[`${ldapCategory}GROUP_TYPE`], + [`${ldapCategory}REQUIRE_GROUP`]: form[`${ldapCategory}REQUIRE_GROUP`], + [`${ldapCategory}SERVER_URI`]: form[`${ldapCategory}SERVER_URI`], + [`${ldapCategory}START_TLS`]: form[`${ldapCategory}START_TLS`], + [`${ldapCategory}USER_DN_TEMPLATE`]: form[ + `${ldapCategory}USER_DN_TEMPLATE` + ], + [`${ldapCategory}GROUP_SEARCH`]: formatJson( + form[`${ldapCategory}GROUP_SEARCH`] + ), + [`${ldapCategory}GROUP_TYPE_PARAMS`]: formatJson( + form[`${ldapCategory}GROUP_TYPE_PARAMS`] + ), + [`${ldapCategory}ORGANIZATION_MAP`]: formatJson( + form[`${ldapCategory}ORGANIZATION_MAP`] + ), + [`${ldapCategory}TEAM_MAP`]: formatJson(form[`${ldapCategory}TEAM_MAP`]), + [`${ldapCategory}USER_ATTR_MAP`]: formatJson( + form[`${ldapCategory}USER_ATTR_MAP`] + ), + [`${ldapCategory}USER_FLAGS_BY_GROUP`]: formatJson( + form[`${ldapCategory}USER_FLAGS_BY_GROUP`] + ), + [`${ldapCategory}USER_SEARCH`]: formatJson( + form[`${ldapCategory}USER_SEARCH`] + ), + }); + }; + + const handleRevertAll = async () => { + const defaultValues = Object.assign( + ...Object.entries(ldap).map(([key, value]) => ({ + [key]: value.default, + })) + ); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push(`/settings/ldap/${category}/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; + }, {}); + return ( <CardBody> - {i18n._(t`Edit form coming soon :)`)} - <CardActionsRow> - <Button - aria-label={i18n._(t`Cancel`)} - component={Link} - to="/settings/ldap/details" - > - {i18n._(t`Cancel`)} - </Button> - </CardActionsRow> + {isLoading && <ContentLoading />} + {!isLoading && error && <ContentError error={error} />} + {!isLoading && ldap && ( + <Formik initialValues={initialValues(ldap)} onSubmit={handleSubmit}> + {formik => ( + <Form autoComplete="off" onSubmit={formik.handleSubmit}> + <FormColumnLayout> + <InputField + name={`${ldapCategory}SERVER_URI`} + config={ldap[`${ldapCategory}SERVER_URI`]} + /> + <EncryptedField + name={`${ldapCategory}BIND_PASSWORD`} + config={ldap[`${ldapCategory}BIND_PASSWORD`]} + /> + <ChoiceField + name={`${ldapCategory}GROUP_TYPE`} + config={ldap[`${ldapCategory}GROUP_TYPE`]} + /> + <BooleanField + name={`${ldapCategory}START_TLS`} + config={ldap[`${ldapCategory}START_TLS`]} + /> + <FormFullWidthLayout> + <InputField + name={`${ldapCategory}BIND_DN`} + config={ldap[`${ldapCategory}BIND_DN`]} + /> + <InputField + name={`${ldapCategory}USER_DN_TEMPLATE`} + config={ldap[`${ldapCategory}USER_DN_TEMPLATE`]} + /> + <InputField + name={`${ldapCategory}REQUIRE_GROUP`} + config={ldap[`${ldapCategory}REQUIRE_GROUP`]} + /> + <InputField + name={`${ldapCategory}DENY_GROUP`} + config={ldap[`${ldapCategory}DENY_GROUP`]} + /> + </FormFullWidthLayout> + <ObjectField + name={`${ldapCategory}USER_SEARCH`} + config={ldap[`${ldapCategory}USER_SEARCH`]} + /> + <ObjectField + name={`${ldapCategory}GROUP_SEARCH`} + config={ldap[`${ldapCategory}GROUP_SEARCH`]} + /> + <ObjectField + name={`${ldapCategory}USER_ATTR_MAP`} + config={ldap[`${ldapCategory}USER_ATTR_MAP`]} + /> + <ObjectField + name={`${ldapCategory}GROUP_TYPE_PARAMS`} + config={ldap[`${ldapCategory}GROUP_TYPE_PARAMS`]} + /> + <ObjectField + name={`${ldapCategory}USER_FLAGS_BY_GROUP`} + config={ldap[`${ldapCategory}USER_FLAGS_BY_GROUP`]} + /> + <ObjectField + name={`${ldapCategory}ORGANIZATION_MAP`} + config={ldap[`${ldapCategory}ORGANIZATION_MAP`]} + /> + <ObjectField + name={`${ldapCategory}TEAM_MAP`} + config={ldap[`${ldapCategory}TEAM_MAP`]} + /> + {submitError && <FormSubmitError error={submitError} />} + </FormColumnLayout> + <RevertFormActionGroup + onCancel={handleCancel} + onSubmit={formik.handleSubmit} + onRevert={toggleModal} + /> + {isModalOpen && ( + <RevertAllAlert + onClose={closeModal} + onRevertAll={handleRevertAll} + /> + )} + </Form> + )} + </Formik> + )} </CardBody> ); } -export default withI18n()(LDAPEdit); +export default LDAPEdit; diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.test.jsx b/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.test.jsx index 12ac75a6ed..71f998e341 100644 --- a/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.test.jsx @@ -1,16 +1,265 @@ import React from 'react'; -import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { useRouteMatch } from 'react-router-dom'; +import { + mountWithContexts, + waitForElement, +} from '../../../../../testUtils/enzymeHelpers'; +import mockAllOptions from '../../shared/data.allSettingOptions.json'; +import mockLDAP from '../../shared/data.ldapSettings.json'; +import { SettingsProvider } from '../../../../contexts/Settings'; +import { SettingsAPI } from '../../../../api'; import LDAPEdit from './LDAPEdit'; +jest.mock('../../../../api/models/Settings'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useRouteMatch: jest.fn(), +})); +SettingsAPI.readCategory.mockResolvedValue({ data: mockLDAP }); + describe('<LDAPEdit />', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(<LDAPEdit />); + let history; + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/ldap/default/edit'], + }); + useRouteMatch.mockImplementation(() => ({ + url: '/settings/ldap/default/edit', + path: '/settings/ldap/:category/edit', + params: { category: 'default' }, + })); + await act(async () => { + wrapper = mountWithContexts( + <SettingsProvider value={mockAllOptions.actions}> + <LDAPEdit /> + </SettingsProvider>, + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); }); + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + test('initially renders without crashing', () => { expect(wrapper.find('LDAPEdit').length).toBe(1); }); + + test('should display expected form fields', async () => { + expect(wrapper.find('FormGroup[label="LDAP Server URI"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="LDAP Bind DN"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="LDAP Bind Password"]').length).toBe( + 1 + ); + expect(wrapper.find('FormGroup[label="LDAP User Search"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="LDAP User DN Template"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="LDAP User Attribute Map"]').length + ).toBe(1); + expect(wrapper.find('FormGroup[label="LDAP Group Search"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="LDAP Group Type"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="LDAP Group Type Parameters"]').length + ).toBe(1); + expect(wrapper.find('FormGroup[label="LDAP Require Group"]').length).toBe( + 1 + ); + expect(wrapper.find('FormGroup[label="LDAP Deny Group"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="LDAP Start TLS"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="LDAP User Flags By Group"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="LDAP Organization Map"]').length + ).toBe(1); + expect(wrapper.find('FormGroup[label="LDAP Team Map"]').length).toBe(1); + expect( + wrapper.find('FormGroup[fieldId="AUTH_LDAP_SERVER_URI"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[fieldId="AUTH_LDAP_5_SERVER_URI"]').length + ).toBe(0); + }); + + 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({ + AUTH_LDAP_BIND_DN: '', + AUTH_LDAP_BIND_PASSWORD: '', + AUTH_LDAP_CONNECTION_OPTIONS: { + OPT_NETWORK_TIMEOUT: 30, + OPT_REFERRALS: 0, + }, + AUTH_LDAP_DENY_GROUP: null, + AUTH_LDAP_GROUP_SEARCH: [], + AUTH_LDAP_GROUP_TYPE: 'MemberDNGroupType', + AUTH_LDAP_GROUP_TYPE_PARAMS: { + member_attr: 'member', + name_attr: 'cn', + }, + AUTH_LDAP_ORGANIZATION_MAP: {}, + AUTH_LDAP_REQUIRE_GROUP: null, + AUTH_LDAP_SERVER_URI: '', + AUTH_LDAP_START_TLS: false, + AUTH_LDAP_TEAM_MAP: {}, + AUTH_LDAP_USER_ATTR_MAP: {}, + AUTH_LDAP_USER_DN_TEMPLATE: null, + AUTH_LDAP_USER_FLAGS_BY_GROUP: {}, + AUTH_LDAP_USER_SEARCH: [], + }); + }); + + test('should successfully send request to api on form submission', async () => { + act(() => { + wrapper + .find( + 'FormGroup[fieldId="AUTH_LDAP_BIND_PASSWORD"] button[aria-label="Revert"]' + ) + .invoke('onClick')(); + wrapper + .find( + 'FormGroup[fieldId="AUTH_LDAP_BIND_DN"] button[aria-label="Revert"]' + ) + .invoke('onClick')(); + wrapper.find('input#AUTH_LDAP_SERVER_URI').simulate('change', { + target: { + value: 'ldap://mock.example.com', + name: 'AUTH_LDAP_SERVER_URI', + }, + }); + wrapper.find('CodeMirrorInput#AUTH_LDAP_TEAM_MAP').invoke('onChange')( + '{\n"LDAP Sales":{\n"organization":\n"mock org"\n}\n}' + ); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + AUTH_LDAP_BIND_DN: '', + AUTH_LDAP_BIND_PASSWORD: '', + AUTH_LDAP_DENY_GROUP: '', + AUTH_LDAP_GROUP_SEARCH: [], + AUTH_LDAP_GROUP_TYPE: 'MemberDNGroupType', + AUTH_LDAP_GROUP_TYPE_PARAMS: { name_attr: 'cn', member_attr: 'member' }, + AUTH_LDAP_ORGANIZATION_MAP: {}, + AUTH_LDAP_REQUIRE_GROUP: 'CN=Tower Users,OU=Users,DC=example,DC=com', + AUTH_LDAP_SERVER_URI: 'ldap://mock.example.com', + AUTH_LDAP_START_TLS: false, + AUTH_LDAP_USER_ATTR_MAP: {}, + AUTH_LDAP_USER_DN_TEMPLATE: 'uid=%(user)s,OU=Users,DC=example,DC=com', + AUTH_LDAP_USER_FLAGS_BY_GROUP: {}, + AUTH_LDAP_USER_SEARCH: [], + AUTH_LDAP_TEAM_MAP: { + 'LDAP Sales': { + organization: 'mock org', + }, + }, + }); + }); + + test('should navigate to ldap default detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual('/settings/ldap/default/details'); + }); + + test('should navigate to ldap default detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/ldap/default/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( + <SettingsProvider value={mockAllOptions.actions}> + <LDAPEdit /> + </SettingsProvider> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); + + test('should display ldap category 5 edit form', async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/ldap/5/edit'], + }); + useRouteMatch.mockImplementation(() => ({ + url: '/settings/ldap/5/edit', + path: '/settings/ldap/:category/edit', + params: { category: '5' }, + })); + await act(async () => { + wrapper = mountWithContexts( + <SettingsProvider value={mockAllOptions.actions}> + <LDAPEdit /> + </SettingsProvider>, + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect( + wrapper.find('FormGroup[fieldId="AUTH_LDAP_SERVER_URI"]').length + ).toBe(0); + expect( + wrapper.find('FormGroup[fieldId="AUTH_LDAP_5_SERVER_URI"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[fieldId="AUTH_LDAP_5_SERVER_URI"] input').props() + .value + ).toEqual('ldap://ldap5.example.com'); + }); }); diff --git a/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx b/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx index 233fb40895..05f551c512 100644 --- a/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx +++ b/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx @@ -13,6 +13,7 @@ function LicenseDetail({ i18n }) { <Button aria-label={i18n._(t`Edit`)} component={Link} + ouiaId="edit-button" to="/settings/license/edit" > {i18n._(t`Edit`)} diff --git a/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx b/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx index 5ede6a1839..54643f1773 100644 --- a/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx +++ b/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx @@ -99,6 +99,7 @@ function LoggingDetail({ i18n }) { <Button aria-label={i18n._(t`Edit`)} component={Link} + ouiaId="edit-button" to="/settings/logging/edit" > {i18n._(t`Edit`)} diff --git a/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx b/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx index 074e6ab318..df488c8d3b 100644 --- a/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx +++ b/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx @@ -246,6 +246,7 @@ function LoggingEdit({ i18n }) { <div> <Button aria-label={i18n._(t`Test logging`)} + ouiaId="test-logging-button" variant="secondary" type="button" onClick={handleTest} diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx index 146e9d82a7..f92b3e00cf 100644 --- a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx @@ -140,6 +140,7 @@ function MiscSystemDetail({ i18n }) { <Button aria-label={i18n._(t`Edit`)} component={Link} + ouiaId="edit-button" to="/settings/miscellaneous_system/edit" > {i18n._(t`Edit`)} diff --git a/awx/ui_next/src/screens/Setting/RADIUS/RADIUSDetail/RADIUSDetail.jsx b/awx/ui_next/src/screens/Setting/RADIUS/RADIUSDetail/RADIUSDetail.jsx index 52b313bbe0..d776f177c7 100644 --- a/awx/ui_next/src/screens/Setting/RADIUS/RADIUSDetail/RADIUSDetail.jsx +++ b/awx/ui_next/src/screens/Setting/RADIUS/RADIUSDetail/RADIUSDetail.jsx @@ -78,6 +78,7 @@ function RADIUSDetail({ i18n }) { <Button aria-label={i18n._(t`Edit`)} component={Link} + ouiaId="edit-button" to="/settings/radius/edit" > {i18n._(t`Edit`)} diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx index 0a7fac5806..5d2e54497e 100644 --- a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx @@ -78,6 +78,7 @@ function SAMLDetail({ i18n }) { <Button aria-label={i18n._(t`Edit`)} component={Link} + ouiaId="edit-button" to="/settings/saml/edit" > {i18n._(t`Edit`)} diff --git a/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.jsx b/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.jsx index 6c9f1a85de..094dbf72bd 100644 --- a/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.jsx +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.jsx @@ -79,6 +79,7 @@ function TACACSDetail({ i18n }) { aria-label={i18n._(t`Edit`)} component={Link} to="/settings/tacacs/edit" + ouiaId="edit-button" > {i18n._(t`Edit`)} </Button> diff --git a/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx b/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx index ef458e5163..e65f176b83 100644 --- a/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx +++ b/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx @@ -94,6 +94,7 @@ function UIDetail({ i18n }) { aria-label={i18n._(t`Edit`)} component={Link} to="/settings/ui/edit" + ouiaId="edit-button" > {i18n._(t`Edit`)} </Button> diff --git a/awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.jsx b/awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.jsx index a1c3903f53..4ec68182fc 100644 --- a/awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.jsx +++ b/awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.jsx @@ -33,6 +33,7 @@ function LoggingTestAlert({ i18n, successResponse, errorResponse, onClose }) { {testMessage && ( <Alert actionClose={<AlertActionCloseButton onClose={onClose} />} + ouiaId="logging-test-alert" title={successResponse ? i18n._(t`Success`) : i18n._(t`Error`)} variant={successResponse ? 'success' : 'danger'} > diff --git a/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx b/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx index 55b23437b9..46ed00e8d6 100644 --- a/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx +++ b/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx @@ -11,12 +11,14 @@ function RevertAllAlert({ i18n, onClose, onRevertAll }) { title={i18n._(t`Revert settings`)} variant="info" onClose={onClose} + ouiaId="revert-all-modal" actions={[ <Button key="revert" variant="primary" aria-label={i18n._(t`Confirm revert all`)} onClick={onRevertAll} + ouiaId="confirm-revert-all-button" > {i18n._(t`Revert all`)} </Button>, @@ -25,6 +27,7 @@ function RevertAllAlert({ i18n, onClose, onRevertAll }) { variant="secondary" aria-label={i18n._(t`Cancel revert`)} onClick={onClose} + ouiaId="cancel-revert-all-button" > {i18n._(t`Cancel`)} </Button>, diff --git a/awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.jsx b/awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.jsx index 03e80d4eb1..1e707090e2 100644 --- a/awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.jsx +++ b/awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.jsx @@ -20,6 +20,7 @@ const RevertFormActionGroup = ({ variant="primary" type="button" onClick={onSubmit} + ouiaId="save-button" > {i18n._(t`Save`)} </Button> @@ -28,6 +29,7 @@ const RevertFormActionGroup = ({ variant="secondary" type="button" onClick={onRevert} + ouiaId="revert-all-button" > {i18n._(t`Revert all to default`)} </Button> @@ -37,6 +39,7 @@ const RevertFormActionGroup = ({ variant="secondary" type="button" onClick={onCancel} + ouiaId="cancel-button" > {i18n._(t`Cancel`)} </Button> diff --git a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx index c23a9da17a..13ec5dc11c 100644 --- a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx +++ b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx @@ -17,10 +17,10 @@ import { FormFullWidthLayout } from '../../../components/FormLayout'; import Popover from '../../../components/Popover'; import { combine, - required, - url, integer, minMaxValue, + required, + url, } from '../../../util/validators'; import RevertButton from './RevertButton'; @@ -51,6 +51,7 @@ const SettingGroup = withI18n()( isRequired={isRequired} label={label} validated={validated} + id={fieldId} labelIcon={ <> <Popover @@ -84,6 +85,7 @@ const BooleanField = withI18n()( > <Switch id={name} + ouiaId={name} isChecked={field.value} isDisabled={disabled} label={i18n._(t`On`)} @@ -241,11 +243,13 @@ const ObjectField = withI18n()(({ i18n, name, config, isRequired = false }) => { > <CodeMirrorInput {...field} + fullHeight id={name} + mode="javascript" onChange={value => { helpers.setValue(value); }} - mode="javascript" + placeholder={JSON.stringify(config?.placeholder, null, 2)} /> </SettingGroup> </FormFullWidthLayout> diff --git a/awx/ui_next/src/screens/Setting/shared/data.ldapSettings.json b/awx/ui_next/src/screens/Setting/shared/data.ldapSettings.json index 161a96b8c5..dce6765491 100644 --- a/awx/ui_next/src/screens/Setting/shared/data.ldapSettings.json +++ b/awx/ui_next/src/screens/Setting/shared/data.ldapSettings.json @@ -109,7 +109,7 @@ "AUTH_LDAP_4_USER_FLAGS_BY_GROUP": {}, "AUTH_LDAP_4_ORGANIZATION_MAP": {}, "AUTH_LDAP_4_TEAM_MAP": {}, - "AUTH_LDAP_5_SERVER_URI": "", + "AUTH_LDAP_5_SERVER_URI": "ldap://ldap5.example.com", "AUTH_LDAP_5_BIND_DN": "", "AUTH_LDAP_5_BIND_PASSWORD": "", "AUTH_LDAP_5_START_TLS": false, diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx index e09a4b98f2..562987ba4b 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx @@ -83,6 +83,7 @@ function JobTemplateAdd() { handleCancel={handleCancel} handleSubmit={handleSubmit} submitError={formSubmitError} + isOverrideDisabledLookup /> </CardBody> </Card> diff --git a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx index e25032e527..db3e45bc91 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx @@ -1,4 +1,4 @@ -import React, { Fragment, useState, useEffect, useCallback } from 'react'; +import React, { Fragment, useCallback, useEffect } from 'react'; import { Link, useHistory, useParams } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { @@ -59,32 +59,31 @@ function JobTemplateDetail({ i18n, template }) { related: { webhook_receiver }, webhook_key, } = template; - const [contentError, setContentError] = useState(null); - const [hasContentLoading, setHasContentLoading] = useState(false); - const [instanceGroups, setInstanceGroups] = useState([]); const { id: templateId } = useParams(); const history = useHistory(); + const { + isLoading: isLoadingInstanceGroups, + request: fetchInstanceGroups, + error: instanceGroupsError, + result: { instanceGroups }, + } = useRequest( + useCallback(async () => { + const { + data: { results }, + } = await JobTemplatesAPI.readInstanceGroups(templateId); + return { instanceGroups: results }; + }, [templateId]), + { instanceGroups: [] } + ); + useEffect(() => { - (async () => { - setContentError(null); - setHasContentLoading(true); - try { - const { - data: { results = [] }, - } = await JobTemplatesAPI.readInstanceGroups(templateId); - setInstanceGroups(results); - } catch (error) { - setContentError(error); - } finally { - setHasContentLoading(false); - } - })(); - }, [templateId]); + fetchInstanceGroups(); + }, [fetchInstanceGroups]); const { request: deleteJobTemplate, - isLoading, + isLoading: isDeleteLoading, error: deleteError, } = useRequest( useCallback(async () => { @@ -154,11 +153,11 @@ function JobTemplateDetail({ i18n, template }) { ); }; - if (contentError) { - return <ContentError error={contentError} />; + if (instanceGroupsError) { + return <ContentError error={instanceGroupsError} />; } - if (hasContentLoading) { + if (isLoadingInstanceGroups || isDeleteLoading) { return <ContentLoading />; } @@ -219,16 +218,6 @@ function JobTemplateDetail({ i18n, template }) { value={verbosityDetails[0].details} /> <Detail label={i18n._(t`Timeout`)} value={timeout || '0'} /> - <UserDateDetail - label={i18n._(t`Created`)} - date={created} - user={summary_fields.created_by} - /> - <UserDateDetail - label={i18n._(t`Last Modified`)} - date={modified} - user={summary_fields.modified_by} - /> <Detail label={i18n._(t`Show Changes`)} value={diff_mode ? i18n._(t`On`) : i18n._(t`Off`)} @@ -278,6 +267,16 @@ function JobTemplateDetail({ i18n, template }) { {renderOptionsField && ( <Detail label={i18n._(t`Options`)} value={renderOptions} /> )} + <UserDateDetail + label={i18n._(t`Created`)} + date={created} + user={summary_fields.created_by} + /> + <UserDateDetail + label={i18n._(t`Last Modified`)} + date={modified} + user={summary_fields.modified_by} + /> {summary_fields.credentials && summary_fields.credentials.length > 0 && ( <Detail fullWidth @@ -389,7 +388,7 @@ function JobTemplateDetail({ i18n, template }) { name={name} modalTitle={i18n._(t`Delete Job Template`)} onConfirm={deleteJobTemplate} - isDisabled={isLoading} + isDisabled={isDeleteLoading} > {i18n._(t`Delete`)} </DeleteButton> diff --git a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx index 3f147ebaa0..1ed86103ab 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx @@ -61,7 +61,6 @@ describe('<JobTemplateDetail />', () => { }); test('should hide edit button for users without edit permission', async () => { - JobTemplatesAPI.readInstanceGroups.mockResolvedValue({ data: {} }); await act(async () => { wrapper = mountWithContexts( <JobTemplateDetail diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx index 182c4918f1..213900d40d 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx @@ -1,22 +1,45 @@ /* eslint react/no-unused-state: 0 */ -import React, { useState } from 'react'; -import { withRouter, Redirect, useHistory } from 'react-router-dom'; -import { CardBody } from '../../../components/Card'; +import React, { useState, useCallback, useEffect } from 'react'; +import { Redirect, useHistory } from 'react-router-dom'; -import { JobTemplatesAPI } from '../../../api'; import { JobTemplate } from '../../../types'; +import { JobTemplatesAPI, ProjectsAPI } from '../../../api'; import { getAddedAndRemoved } from '../../../util/lists'; +import useRequest from '../../../util/useRequest'; import JobTemplateForm from '../shared/JobTemplateForm'; - import ContentLoading from '../../../components/ContentLoading'; +import { CardBody } from '../../../components/Card'; function JobTemplateEdit({ template }) { const history = useHistory(); const [formSubmitError, setFormSubmitError] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [isDisabled, setIsDisabled] = useState(false); const detailsUrl = `/templates/${template.type}/${template.id}/details`; + const { + request: fetchProject, + error: fetchProjectError, + isLoading: projectLoading, + } = useRequest( + useCallback(async () => { + await ProjectsAPI.readDetail(template.project); + }, [template.project]) + ); + + useEffect(() => { + fetchProject(); + }, [fetchProject]); + + useEffect(() => { + if (fetchProjectError) { + if (fetchProjectError.response.status === 403) { + setIsDisabled(true); + } + } + }, [fetchProjectError]); + const handleSubmit = async values => { const { labels, @@ -91,22 +114,21 @@ function JobTemplateEdit({ template }) { const associateCredentials = added.map(cred => JobTemplatesAPI.associateCredentials(template.id, cred.id) ); - const associatePromise = Promise.all(associateCredentials); + const associatePromise = await Promise.all(associateCredentials); return Promise.all([disassociatePromise, associatePromise]); }; - const handleCancel = () => { - history.push(detailsUrl); - }; + const handleCancel = () => history.push(detailsUrl); const canEdit = template?.summary_fields?.user_capabilities?.edit; if (!canEdit) { return <Redirect to={detailsUrl} />; } - if (isLoading) { + if (isLoading || projectLoading) { return <ContentLoading />; } + return ( <CardBody> <JobTemplateForm @@ -114,6 +136,7 @@ function JobTemplateEdit({ template }) { handleCancel={handleCancel} handleSubmit={handleSubmit} submitError={formSubmitError} + isOverrideDisabledLookup={!isDisabled} /> </CardBody> ); @@ -122,5 +145,4 @@ function JobTemplateEdit({ template }) { JobTemplateEdit.propTypes = { template: JobTemplate.isRequired, }; - -export default withRouter(JobTemplateEdit); +export default JobTemplateEdit; diff --git a/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx index dd06e0bca9..399efe9922 100644 --- a/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx +++ b/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx @@ -85,6 +85,48 @@ function SurveyList({ const end = questions.slice(index + 2); updateSurvey([...beginning, swapWith, question, ...end]); }; + const deleteModal = ( + <AlertModal + variant="danger" + title={ + isAllSelected ? i18n._(t`Delete Survey`) : i18n._(t`Delete Questions`) + } + isOpen={isDeleteModalOpen} + onClose={() => { + setIsDeleteModalOpen(false); + setSelected([]); + }} + actions={[ + <Button + key="delete" + variant="danger" + aria-label={i18n._(t`confirm delete`)} + onClick={handleDelete} + > + {i18n._(t`Delete`)} + </Button>, + <Button + key="cancel" + variant="secondary" + aria-label={i18n._(t`cancel delete`)} + onClick={() => { + setIsDeleteModalOpen(false); + setSelected([]); + }} + > + {i18n._(t`Cancel`)} + </Button>, + ]} + > + <div>{i18n._(t`This action will delete the following:`)}</div> + {selected.map(question => ( + <span key={question.variable}> + <strong>{question.question_name}</strong> + <br /> + </span> + ))} + </AlertModal> + ); let content; if (isLoading) { @@ -105,6 +147,7 @@ function SurveyList({ canEdit={canEdit} /> ))} + {isDeleteModalOpen && deleteModal} {isPreviewModalOpen && ( <SurveyPreviewModal isPreviewModalOpen={isPreviewModalOpen} @@ -112,7 +155,6 @@ function SurveyList({ questions={questions} /> )} - <Button onClick={() => setIsPreviewModalOpen(true)} variant="primary" @@ -123,51 +165,8 @@ function SurveyList({ </DataList> ); } - if (isDeleteModalOpen) { - return ( - <AlertModal - variant="danger" - title={ - isAllSelected ? i18n._(t`Delete Survey`) : i18n._(t`Delete Questions`) - } - isOpen={isDeleteModalOpen} - onClose={() => { - setIsDeleteModalOpen(false); - setSelected([]); - }} - actions={[ - <Button - key="delete" - variant="danger" - aria-label={i18n._(t`confirm delete`)} - onClick={handleDelete} - > - {i18n._(t`Delete`)} - </Button>, - <Button - key="cancel" - variant="secondary" - aria-label={i18n._(t`cancel delete`)} - onClick={() => { - setIsDeleteModalOpen(false); - setSelected([]); - }} - > - {i18n._(t`Cancel`)} - </Button>, - ]} - > - <div>{i18n._(t`This action will delete the following:`)}</div> - {selected.map(question => ( - <span key={question.variable}> - <strong>{question.question_name}</strong> - <br /> - </span> - ))} - </AlertModal> - ); - } - if (!questions || questions?.length <= 0) { + + if ((!questions || questions?.length <= 0) && !isLoading) { return ( <EmptyState variant="full"> <EmptyStateIcon icon={CubesIcon} /> @@ -193,49 +192,6 @@ function SurveyList({ onToggleDeleteModal={() => setIsDeleteModalOpen(true)} /> {content} - {isDeleteModalOpen && ( - <AlertModal - variant="danger" - title={ - isAllSelected - ? i18n._(t`Delete Survey`) - : i18n._(t`Delete Questions`) - } - isOpen={isDeleteModalOpen} - onClose={() => { - setIsDeleteModalOpen(false); - }} - actions={[ - <Button - key="delete" - variant="danger" - aria-label={i18n._(t`confirm delete`)} - onClick={handleDelete} - > - {i18n._(t`Delete`)} - </Button>, - <Button - key="cancel" - variant="secondary" - aria-label={i18n._(t`cancel delete`)} - onClick={() => { - setIsDeleteModalOpen(false); - }} - > - {i18n._(t`Cancel`)} - </Button>, - ]} - > - <div>{i18n._(t`This action will delete the following:`)}</div> - <ul> - {selected.map(question => ( - <li key={question.variable}> - <strong>{question.question_name}</strong> - </li> - ))} - </ul> - </AlertModal> - )} </> ); } diff --git a/awx/ui_next/src/screens/Template/Template.jsx b/awx/ui_next/src/screens/Template/Template.jsx index 0d9f84288d..50b238b3b7 100644 --- a/awx/ui_next/src/screens/Template/Template.jsx +++ b/awx/ui_next/src/screens/Template/Template.jsx @@ -46,8 +46,21 @@ function Template({ i18n, setBreadcrumb }) { role_level: 'notification_admin_role', }), ]); - if (actions?.data?.actions?.PUT) { - if (data?.webhook_service && data?.related?.webhook_key) { + if (data.summary_fields.credentials) { + const params = { + page: 1, + page_size: 200, + order_by: 'name', + }; + const { + data: { results }, + } = await JobTemplatesAPI.readCredentials(data.id, params); + + data.summary_fields.credentials = results; + } + + if (actions.data.actions.PUT) { + if (data.webhook_service && data?.related?.webhook_key) { const { data: { webhook_key }, } = await JobTemplatesAPI.readWebhookKey(templateId); @@ -78,14 +91,14 @@ function Template({ i18n, setBreadcrumb }) { }; const loadScheduleOptions = useCallback(() => { - return JobTemplatesAPI.readScheduleOptions(template.id); - }, [template]); + return JobTemplatesAPI.readScheduleOptions(templateId); + }, [templateId]); const loadSchedules = useCallback( params => { - return JobTemplatesAPI.readSchedules(template.id, params); + return JobTemplatesAPI.readSchedules(templateId, params); }, - [template] + [templateId] ); const canSeeNotificationsTab = me?.is_system_auditor || isNotifAdmin; @@ -142,7 +155,7 @@ function Template({ i18n, setBreadcrumb }) { <PageSection> <Card> <ContentError error={contentError}> - {contentError.response.status === 404 && ( + {contentError.response?.status === 404 && ( <span> {i18n._(t`Template not found.`)}{' '} <Link to="/templates">{i18n._(t`View all Templates.`)}</Link> diff --git a/awx/ui_next/src/screens/Template/Template.test.jsx b/awx/ui_next/src/screens/Template/Template.test.jsx index a38209db75..afb7221f06 100644 --- a/awx/ui_next/src/screens/Template/Template.test.jsx +++ b/awx/ui_next/src/screens/Template/Template.test.jsx @@ -28,6 +28,22 @@ describe('<Template />', () => { actions: { PUT: true }, }, }); + JobTemplatesAPI.readCredentials.mockResolvedValue({ + data: { + results: [ + { + id: 3, + type: 'credential', + url: '/api/v2/credentials/3/', + name: 'Vault1Id1', + inputs: { + vault_id: '1', + }, + kind: 'vault', + }, + ], + }, + }); OrganizationsAPI.read.mockResolvedValue({ data: { count: 1, diff --git a/awx/ui_next/src/screens/Template/TemplateSurvey.jsx b/awx/ui_next/src/screens/Template/TemplateSurvey.jsx index 0badeffbf5..37b24cea67 100644 --- a/awx/ui_next/src/screens/Template/TemplateSurvey.jsx +++ b/awx/ui_next/src/screens/Template/TemplateSurvey.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { Switch, Route, useParams, useLocation } from 'react-router-dom'; +import { Switch, Route, useParams } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api'; @@ -12,8 +12,7 @@ import { SurveyList, SurveyQuestionAdd, SurveyQuestionEdit } from './Survey'; function TemplateSurvey({ template, canEdit, i18n }) { const [surveyEnabled, setSurveyEnabled] = useState(template.survey_enabled); - const { templateType } = useParams(); - const location = useLocation(); + const { templateType, id: templateId } = useParams(); const { result: survey, @@ -25,30 +24,31 @@ function TemplateSurvey({ template, canEdit, i18n }) { useCallback(async () => { const { data } = templateType === 'workflow_job_template' - ? await WorkflowJobTemplatesAPI.readSurvey(template.id) - : await JobTemplatesAPI.readSurvey(template.id); + ? await WorkflowJobTemplatesAPI.readSurvey(templateId) + : await JobTemplatesAPI.readSurvey(templateId); return data; - }, [template.id, templateType]) + }, [templateId, templateType]) ); useEffect(() => { fetchSurvey(); - }, [fetchSurvey, location]); + }, [fetchSurvey]); - const { request: updateSurvey, error: updateError } = useRequest( + const { + request: updateSurvey, + error: updateError, + isLoading: updateLoading, + } = useRequest( useCallback( async updatedSurvey => { if (templateType === 'workflow_job_template') { - await WorkflowJobTemplatesAPI.updateSurvey( - template.id, - updatedSurvey - ); + await WorkflowJobTemplatesAPI.updateSurvey(templateId, updatedSurvey); } else { - await JobTemplatesAPI.updateSurvey(template.id, updatedSurvey); + await JobTemplatesAPI.updateSurvey(templateId, updatedSurvey); } setSurvey(updatedSurvey); }, - [template.id, setSurvey, templateType] + [templateId, setSurvey, templateType] ) ); const updateSurveySpec = spec => { @@ -61,24 +61,24 @@ function TemplateSurvey({ template, canEdit, i18n }) { const { request: deleteSurvey, error: deleteError } = useRequest( useCallback(async () => { - await JobTemplatesAPI.destroySurvey(template.id); + await JobTemplatesAPI.destroySurvey(templateId); setSurvey(null); - }, [template.id, setSurvey]) + }, [templateId, setSurvey]) ); const { request: toggleSurvey, error: toggleError } = useRequest( useCallback(async () => { if (templateType === 'workflow_job_template') { - await WorkflowJobTemplatesAPI.update(template.id, { + await WorkflowJobTemplatesAPI.update(templateId, { survey_enabled: !surveyEnabled, }); } else { - await JobTemplatesAPI.update(template.id, { + await JobTemplatesAPI.update(templateId, { survey_enabled: !surveyEnabled, }); } setSurveyEnabled(!surveyEnabled); - }, [template.id, templateType, surveyEnabled]) + }, [templateId, templateType, surveyEnabled]) ); const { error, dismissError } = useDismissableError( @@ -109,7 +109,7 @@ function TemplateSurvey({ template, canEdit, i18n }) { )} <Route path="/templates/:templateType/:id/survey" exact> <SurveyList - isLoading={isLoading} + isLoading={isLoading || updateLoading} survey={survey} surveyEnabled={surveyEnabled} toggleSurvey={toggleSurvey} diff --git a/awx/ui_next/src/screens/Template/TemplateSurvey.test.jsx b/awx/ui_next/src/screens/Template/TemplateSurvey.test.jsx index 0d21bc15e0..ae0a5206b2 100644 --- a/awx/ui_next/src/screens/Template/TemplateSurvey.test.jsx +++ b/awx/ui_next/src/screens/Template/TemplateSurvey.test.jsx @@ -1,11 +1,13 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; + import { Route } from 'react-router-dom'; import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import TemplateSurvey from './TemplateSurvey'; import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api'; import mockJobTemplateData from './shared/data.job_template.json'; +import mockWorkflowJobTemplateData from './shared/data.workflow_job_template.json'; jest.mock('../../api/models/JobTemplates'); jest.mock('../../api/models/WorkflowJobTemplates'); @@ -27,19 +29,31 @@ describe('<TemplateSurvey />', () => { test('should fetch survey from API', async () => { const history = createMemoryHistory({ - initialEntries: ['/templates/job_template/1/survey'], + initialEntries: ['/templates/job_template/7/survey'], }); let wrapper; await act(async () => { wrapper = mountWithContexts( - <TemplateSurvey template={mockJobTemplateData} />, + <Route path="/templates/:templateType/:id/survey"> + <TemplateSurvey template={mockJobTemplateData} canEdit /> + </Route>, { - context: { router: { history } }, + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { templateType: 'job_template', id: 7 }, + }, + }, + }, + }, } ); }); wrapper.update(); - expect(JobTemplatesAPI.readSurvey).toBeCalledWith(7); + expect(JobTemplatesAPI.readSurvey).toBeCalledWith('7'); expect(wrapper.find('SurveyList').prop('survey')).toEqual(surveyData); }); @@ -47,9 +61,27 @@ describe('<TemplateSurvey />', () => { test('should display error in retrieving survey', async () => { JobTemplatesAPI.readSurvey.mockRejectedValue(new Error()); let wrapper; + const history = createMemoryHistory({ + initialEntries: ['/templates/job_template/7/survey'], + }); await act(async () => { wrapper = mountWithContexts( - <TemplateSurvey template={{ ...mockJobTemplateData, id: 'a' }} /> + <Route path="/templates/:templateType/:id/survey"> + <TemplateSurvey template={{ ...mockJobTemplateData, id: 'a' }} /> + </Route>, + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { templateType: 'job_template', id: 7 }, + }, + }, + }, + }, + } ); }); @@ -60,14 +92,26 @@ describe('<TemplateSurvey />', () => { test('should update API with survey changes', async () => { const history = createMemoryHistory({ - initialEntries: ['/templates/job_template/1/survey'], + initialEntries: ['/templates/job_template/7/survey'], }); let wrapper; await act(async () => { wrapper = mountWithContexts( - <TemplateSurvey template={mockJobTemplateData} />, + <Route path="/templates/:templateType/:id/survey"> + <TemplateSurvey template={mockJobTemplateData} canEdit /> + </Route>, { - context: { router: { history } }, + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { templateType: 'job_template', id: 7 }, + }, + }, + }, + }, } ); }); @@ -79,7 +123,7 @@ describe('<TemplateSurvey />', () => { { question_name: 'Bar', type: 'text', default: 'Two', variable: 'bar' }, ]); }); - expect(JobTemplatesAPI.updateSurvey).toHaveBeenCalledWith(7, { + expect(JobTemplatesAPI.updateSurvey).toHaveBeenCalledWith('7', { name: 'Survey', description: 'description for survey', spec: [ @@ -91,14 +135,26 @@ describe('<TemplateSurvey />', () => { test('should toggle jt survery on', async () => { const history = createMemoryHistory({ - initialEntries: ['/templates/job_template/1/survey'], + initialEntries: ['/templates/job_template/7/survey'], }); let wrapper; await act(async () => { wrapper = mountWithContexts( - <TemplateSurvey template={mockJobTemplateData} canEdit />, + <Route path="/templates/:templateType/:id/survey"> + <TemplateSurvey template={mockJobTemplateData} canEdit /> + </Route>, { - context: { router: { history } }, + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { templateType: 'job_template', id: 7 }, + }, + }, + }, + }, } ); }); @@ -108,12 +164,14 @@ describe('<TemplateSurvey />', () => { ); wrapper.update(); - expect(JobTemplatesAPI.update).toBeCalledWith(7, { survey_enabled: false }); + expect(JobTemplatesAPI.update).toBeCalledWith('7', { + survey_enabled: false, + }); }); test('should toggle wfjt survey on', async () => { const history = createMemoryHistory({ - initialEntries: ['/templates/workflow_job_template/1/survey'], + initialEntries: ['/templates/workflow_job_template/15/survey'], }); WorkflowJobTemplatesAPI.readSurvey.mockResolvedValueOnce({ @@ -124,7 +182,7 @@ describe('<TemplateSurvey />', () => { await act(async () => { wrapper = mountWithContexts( <Route path="/templates/:templateType/:id/survey"> - <TemplateSurvey template={mockJobTemplateData} canEdit /> + <TemplateSurvey template={mockWorkflowJobTemplateData} canEdit /> </Route>, { context: { @@ -132,7 +190,9 @@ describe('<TemplateSurvey />', () => { history, route: { location: history.location, - match: { params: { templateType: 'workflow_job_template' } }, + match: { + params: { templateType: 'workflow_job_template', id: 15 }, + }, }, }, }, @@ -143,8 +203,9 @@ describe('<TemplateSurvey />', () => { await act(() => wrapper.find('Switch[aria-label="Survey Toggle"]').prop('onChange')() ); + wrapper.update(); - expect(WorkflowJobTemplatesAPI.update).toBeCalledWith(7, { + expect(WorkflowJobTemplatesAPI.update).toBeCalledWith('15', { survey_enabled: false, }); }); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx index 4b997fa9b9..ee22010983 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx @@ -27,6 +27,7 @@ import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit'; import { WorkflowJobTemplatesAPI, OrganizationsAPI } from '../../api'; import TemplateSurvey from './TemplateSurvey'; import { Visualizer } from './WorkflowJobTemplateVisualizer'; +import ContentLoading from '../../components/ContentLoading'; function WorkflowJobTemplate({ i18n, setBreadcrumb }) { const location = useLocation(); @@ -150,6 +151,10 @@ function WorkflowJobTemplate({ i18n, setBreadcrumb }) { } const contentError = rolesAndTemplateError; + + if (hasRolesandTemplateLoading) { + return <ContentLoading />; + } if (!hasRolesandTemplateLoading && contentError) { return ( <PageSection> diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx index 2f6b99821a..2a5242b9a0 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx @@ -135,9 +135,6 @@ function WorkflowJobTemplateDetail({ template, i18n }) { )} /> )} - {renderOptionsField && ( - <Detail label={i18n._(t`Options`)} value={renderOptions} /> - )} <Detail label={i18n._(t`Webhook Service`)} value={toTitleCase(template.webhook_service)} @@ -162,6 +159,19 @@ function WorkflowJobTemplateDetail({ template, i18n }) { } /> )} + {renderOptionsField && ( + <Detail label={i18n._(t`Options`)} value={renderOptions} /> + )} + <UserDateDetail + label={i18n._(t`Created`)} + date={created} + user={summary_fields.created_by} + /> + <UserDateDetail + label={i18n._(t`Modified`)} + date={modified} + user={summary_fields.modified_by} + /> {summary_fields.labels?.results?.length > 0 && ( <Detail fullWidth @@ -185,16 +195,6 @@ function WorkflowJobTemplateDetail({ template, i18n }) { value={extra_vars} rows={4} /> - <UserDateDetail - label={i18n._(t`Created`)} - date={created} - user={summary_fields.created_by} - /> - <UserDateDetail - label={i18n._(t`Modified`)} - date={modified} - user={summary_fields.modified_by} - /> </DetailList> <CardActionsRow> {summary_fields.user_capabilities && diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx index 47e400f716..f5d0ce4dda 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx @@ -10,7 +10,7 @@ import PaginatedDataList from '../../../../../../components/PaginatedDataList'; import DataListToolbar from '../../../../../../components/DataListToolbar'; import CheckboxListItem from '../../../../../../components/CheckboxListItem'; -const QS_CONFIG = getQSConfig('inventory_sources', { +const QS_CONFIG = getQSConfig('inventory-sources', { page: 1, page_size: 5, order_by: 'name', diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx index 3a00f86d64..3de9f280b5 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx @@ -10,7 +10,7 @@ import PaginatedDataList from '../../../../../../components/PaginatedDataList'; import DataListToolbar from '../../../../../../components/DataListToolbar'; import CheckboxListItem from '../../../../../../components/CheckboxListItem'; -const QS_CONFIG = getQSConfig('job_templates', { +const QS_CONFIG = getQSConfig('job-templates', { page: 1, page_size: 5, order_by: 'name', diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx index f8338ed25f..aac3f669d0 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx @@ -10,7 +10,7 @@ import PaginatedDataList from '../../../../../../components/PaginatedDataList'; import DataListToolbar from '../../../../../../components/DataListToolbar'; import CheckboxListItem from '../../../../../../components/CheckboxListItem'; -const QS_CONFIG = getQSConfig('workflow_job_templates', { +const QS_CONFIG = getQSConfig('workflow-job-templates', { page: 1, page_size: 5, order_by: 'name', diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 805e201e13..e3bd18ec19 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -53,6 +53,7 @@ function JobTemplateForm({ setFieldValue, submitError, i18n, + isOverrideDisabledLookup, }) { const [contentError, setContentError] = useState(false); const [inventory, setInventory] = useState( @@ -254,6 +255,7 @@ function JobTemplateForm({ required={!askInventoryOnLaunchField.value} touched={inventoryMeta.touched} error={inventoryMeta.error} + isOverrideDisabled={isOverrideDisabledLookup} /> </FormGroup> <ProjectLookup @@ -266,6 +268,7 @@ function JobTemplateForm({ onChange={handleProjectUpdate} required autoPopulate={!template?.id} + isOverrideDisabled={isOverrideDisabledLookup} /> {projectField.value?.allow_override && ( <FieldWithPrompt @@ -433,7 +436,7 @@ function JobTemplateForm({ min="0" label={i18n._(t`Timeout`)} tooltip={i18n._(t`The amount of time (in seconds) to run - before the task is canceled. Defaults to 0 for no job + before the job is canceled. Defaults to 0 for no job timeout.`)} /> <FieldWithPrompt @@ -623,7 +626,9 @@ JobTemplateForm.propTypes = { handleCancel: PropTypes.func.isRequired, handleSubmit: PropTypes.func.isRequired, submitError: PropTypes.shape({}), + isOverrideDisabledLookup: PropTypes.bool, }; + JobTemplateForm.defaultProps = { template: { name: '', @@ -641,6 +646,7 @@ JobTemplateForm.defaultProps = { isNew: true, }, submitError: null, + isOverrideDisabledLookup: false, }; const FormikApp = withFormik({ diff --git a/awx/ui_next/src/screens/Template/shared/data.workflow_job_template.json b/awx/ui_next/src/screens/Template/shared/data.workflow_job_template.json index b120d7c892..ae4e6fb6d4 100644 --- a/awx/ui_next/src/screens/Template/shared/data.workflow_job_template.json +++ b/awx/ui_next/src/screens/Template/shared/data.workflow_job_template.json @@ -81,7 +81,7 @@ "status": "never updated", "extra_vars": "", "organization": null, - "survey_enabled": false, + "survey_enabled": true, "allow_simultaneous": false, "ask_variables_on_launch": false, "inventory": null, diff --git a/awx_collection/plugins/modules/tower_project.py b/awx_collection/plugins/modules/tower_project.py index 3e6d6ab442..ca848e7d69 100644 --- a/awx_collection/plugins/modules/tower_project.py +++ b/awx_collection/plugins/modules/tower_project.py @@ -153,7 +153,7 @@ EXAMPLES = ''' organization: "test" scm_update_on_launch: True scm_update_cache_timeout: 60 - custom_virtualenv: "/var/lib/awx/venv/ansible-2.2" + custom_virtualenv: "/var/lib/awx/var/lib/awx/venv/ansible-2.2" state: present tower_config_file: "~/tower_cli.cfg" ''' diff --git a/awx_collection/test/awx/test_inventory_source.py b/awx_collection/test/awx/test_inventory_source.py index 1bced2eb67..3e65feaddb 100644 --- a/awx_collection/test/awx/test_inventory_source.py +++ b/awx_collection/test/awx/test_inventory_source.py @@ -133,10 +133,10 @@ def test_custom_venv_no_op(run_module, admin_user, base_inventory, mocker, proje inventory=base_inventory, source_project=project, source='scm', - custom_virtualenv='/venv/foobar/' + custom_virtualenv='/var/lib/awx/venv/foobar/' ) # mock needed due to API behavior, not incorrect client behavior - with mocker.patch('awx.main.models.mixins.get_custom_venv_choices', return_value=['/venv/foobar/']): + with mocker.patch('awx.main.models.mixins.get_custom_venv_choices', return_value=['/var/lib/awx/venv/foobar/']): result = run_module('tower_inventory_source', dict( name='foo', description='this is the changed description', @@ -148,7 +148,7 @@ def test_custom_venv_no_op(run_module, admin_user, base_inventory, mocker, proje ), admin_user) assert result.pop('changed', None), result inv_src.refresh_from_db() - assert inv_src.custom_virtualenv == '/venv/foobar/' + assert inv_src.custom_virtualenv == '/var/lib/awx/venv/foobar/' assert inv_src.description == 'this is the changed description' diff --git a/awx_collection/test/awx/test_notification_template.py b/awx_collection/test/awx/test_notification_template.py index 28f7c4ecee..96fbd5e56c 100644 --- a/awx_collection/test/awx/test_notification_template.py +++ b/awx_collection/test/awx/test_notification_template.py @@ -3,7 +3,7 @@ __metaclass__ = type import pytest -from awx.main.models import NotificationTemplate +from awx.main.models import NotificationTemplate, Job def compare_with_encrypted(model_config, param_config): @@ -109,3 +109,32 @@ def test_deprecated_to_modern_no_op(run_module, admin_user, organization): ), admin_user) assert not result.get('failed', False), result.get('msg', result) assert not result.pop('changed', None), result + + +@pytest.mark.django_db +def test_build_notification_message_undefined(run_module, admin_user, organization): + """Job notification templates may encounter undefined values in the context when they are + rendered. Make sure that accessing attributes or items of an undefined value returns another + instance of Undefined, rather than raising an UndefinedError. This enables the use of expressions + like "{{ job.created_by.first_name | default('unknown') }}".""" + job = Job.objects.create(name='foobar') + + nt_config = { + 'url': 'http://www.example.com/hook', + 'headers': { + 'X-Custom-Header': 'value123' + } + } + custom_start_template = {'body': '{"started_by": "{{ job.summary_fields.created_by.username | default(\'My Placeholder\') }}"}'} + messages = {'started': custom_start_template, 'success': None, 'error': None, 'workflow_approval': None} + result = run_module('tower_notification_template', dict( + name='foo-notification-template', + organization=organization.name, + notification_type='webhook', + notification_configuration=nt_config, + messages=messages, + ), admin_user) + nt = NotificationTemplate.objects.get(id=result['id']) + + _, body = job.build_notification_message(nt, 'running') + assert '{"started_by": "My Placeholder"}' in body diff --git a/awx_collection/tools/roles/template_galaxy/templates/README.md.j2 b/awx_collection/tools/roles/template_galaxy/templates/README.md.j2 index 8a5743d34f..ed02006c3d 100644 --- a/awx_collection/tools/roles/template_galaxy/templates/README.md.j2 +++ b/awx_collection/tools/roles/template_galaxy/templates/README.md.j2 @@ -31,7 +31,7 @@ with the current AWX version, for example: `awx_collection/awx-awx-9.2.0.tar.gz` Installing the `tar.gz` involves no special instructions. {% else %} -This collection should be installed from [Content Hub][https://cloud.redhat.com/ansible/automation-hub/ansible/tower/] +This collection should be installed from [Content Hub](https://cloud.redhat.com/ansible/automation-hub/ansible/tower/) {% endif %} ## Running diff --git a/awxkit/awxkit/api/mixins/has_status.py b/awxkit/awxkit/api/mixins/has_status.py index bd76400baa..db14874b6c 100644 --- a/awxkit/awxkit/api/mixins/has_status.py +++ b/awxkit/awxkit/api/mixins/has_status.py @@ -47,6 +47,13 @@ class HasStatus(object): def wait_until_started(self, interval=1, timeout=60): return self.wait_until_status(self.started_statuses, interval=interval, timeout=timeout) + def failure_output_details(self): + if getattr(self, 'result_stdout', ''): + output = bytes_to_str(self.result_stdout) + if output: + return '\nstdout:\n{}'.format(output) + return '' + def assert_status(self, status_list, msg=None): if isinstance(status_list, str): status_list = [status_list] @@ -65,10 +72,9 @@ class HasStatus(object): msg += '\njob_explanation: {}'.format(bytes_to_str(self.job_explanation)) if getattr(self, 'result_traceback', ''): msg += '\nresult_traceback:\n{}'.format(bytes_to_str(self.result_traceback)) - if getattr(self, 'result_stdout', ''): - output = bytes_to_str(self.result_stdout) - if output: - msg = msg + '\nstdout:\n{}'.format(output) + + msg += self.failure_output_details() + if getattr(self, 'job_explanation', '').startswith('Previous Task Failed'): try: data = json.loads(self.job_explanation.replace('Previous Task Failed: ', '')) diff --git a/awxkit/awxkit/api/pages/api.py b/awxkit/awxkit/api/pages/api.py index a4566d3015..3209232352 100644 --- a/awxkit/awxkit/api/pages/api.py +++ b/awxkit/awxkit/api/pages/api.py @@ -83,9 +83,6 @@ class ApiV2(base.Base): if _page.json.get('managed_by_tower'): log.debug("%s is managed by Tower, skipping.", _page.endpoint) return None - # Drop any hosts, groups, or inventories that were pulled in programmatically by an inventory source. - if _page.json.get('has_inventory_sources'): - return None if post_fields is None: # Deprecated endpoint or insufficient permissions log.error("Object export failed: %s", _page.endpoint) return None diff --git a/awxkit/awxkit/api/pages/base.py b/awxkit/awxkit/api/pages/base.py index e3e64d7db7..f3c3957c9d 100644 --- a/awxkit/awxkit/api/pages/base.py +++ b/awxkit/awxkit/api/pages/base.py @@ -25,6 +25,13 @@ class Base(Page): return self.delete() except (exc.NoContent, exc.NotFound, exc.Forbidden): pass + except (exc.BadRequest, exc.Conflict) as e: + if 'Job has not finished processing events' in e.msg: + pass + if 'Resource is being used' in e.msg: + pass + else: + raise e def get_object_role(self, role, by_name=False): """Lookup and return a related object role by its role field or name. diff --git a/awxkit/awxkit/api/pages/workflow_jobs.py b/awxkit/awxkit/api/pages/workflow_jobs.py index d7fe487030..36afc94460 100644 --- a/awxkit/awxkit/api/pages/workflow_jobs.py +++ b/awxkit/awxkit/api/pages/workflow_jobs.py @@ -13,11 +13,35 @@ class WorkflowJob(UnifiedJob): result = self.related.relaunch.post(payload) return self.walk(result.url) + def failure_output_details(self): + """Special implementation of this part of assert_status so that + workflow_job.assert_successful() will give a breakdown of failure + """ + node_list = self.related.workflow_nodes.get().results + + msg = '\nNode summary:' + for node in node_list: + msg += '\n{}: {}'.format(node.id, node.summary_fields.get('job')) + for rel in ('failure_nodes', 'always_nodes', 'success_nodes'): + val = getattr(node, rel, []) + if val: + msg += ' {} {}'.format(rel, val) + + msg += '\n\nUnhandled individual job failures:\n' + for node in node_list: + # nodes without always or failure paths consider failures unhandled + if node.job and not (node.failure_nodes or node.always_nodes): + job = node.related.job.get() + try: + job.assert_successful() + except Exception as e: + msg += str(e) + + return msg + @property def result_stdout(self): # workflow jobs do not have result_stdout - # which is problematic for the UnifiedJob.is_successful reliance on - # related stdout endpoint. if 'result_stdout' not in self.json: return 'Unprovided AWX field.' else: diff --git a/installer/build.yml b/installer/build.yml index 8ef6f2b1ce..0bea5821e3 100644 --- a/installer/build.yml +++ b/installer/build.yml @@ -1,6 +1,6 @@ --- - name: Build AWX Docker Images - hosts: all + hosts: localhost gather_facts: true roles: - {role: image_build} diff --git a/installer/inventory b/installer/inventory index 89d0684a70..d4596f5d96 100644 --- a/installer/inventory +++ b/installer/inventory @@ -101,17 +101,6 @@ pg_port=5432 # containerized postgres deployment on OpenShift # pg_admin_password=postgrespass -# Use a local distribution build container image for building the AWX package -# This is helpful if you don't want to bother installing the build-time dependencies as -# it is taken care of already. -# NOTE: IMPORTANT: If you are running a mininshift install, using this container might not work -# if you are using certain drivers like KVM where the source tree can't be mapped -# into the build container. -# Thus this setting must be set to False which will trigger a local build. To view the -# typical dependencies that you might need to install see: -# installer/image_build/files/Dockerfile.sdist -# use_container_for_build=true - # This will create or update a default admin (superuser) account in AWX, if not provided # then these default values are used admin_user=admin diff --git a/installer/roles/image_build/files/Dockerfile.sdist b/installer/roles/image_build/files/Dockerfile.sdist deleted file mode 100644 index c4ed45477f..0000000000 --- a/installer/roles/image_build/files/Dockerfile.sdist +++ /dev/null @@ -1,22 +0,0 @@ -FROM centos:8 - -RUN dnf -y update && dnf -y install epel-release && \ - dnf install -y bzip2 \ - gcc-c++ \ - gettext \ - git \ - make \ - nodejs \ - python3 \ - python3-setuptools - -# 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 - -RUN mkdir -p /.npm && chmod g+rwx /.npm - -ENV PATH=/usr/local/n/versions/node/14.15.1/bin:$PATH - -WORKDIR "/awx" - -CMD ["make", "sdist"] diff --git a/installer/roles/image_build/tasks/main.yml b/installer/roles/image_build/tasks/main.yml index 46add2552c..463e12ec73 100644 --- a/installer/roles/image_build/tasks/main.yml +++ b/installer/roles/image_build/tasks/main.yml @@ -7,7 +7,6 @@ - name: Verify awx-logos directory exists for official install stat: path: "../../awx-logos" - delegate_to: localhost register: logosdir failed_when: logosdir.stat.isdir is not defined or not logosdir.stat.isdir when: awx_official|default(false)|bool @@ -16,79 +15,8 @@ copy: src: "../../awx-logos/awx/ui/client/assets/" dest: "../awx/ui_next/public/static/media/" - delegate_to: localhost when: awx_official|default(false)|bool -- name: Set sdist file name - set_fact: - awx_sdist_file: "awx-{{ awx_version }}.tar.gz" - -- name: AWX Distribution - debug: - msg: "{{ awx_sdist_file }}" - -- name: Stat distribution file - stat: - path: "../dist/{{ awx_sdist_file }}" - delegate_to: localhost - register: sdist - -- name: Clean distribution - command: make clean - args: - chdir: .. - ignore_errors: true - when: not sdist.stat.exists - delegate_to: localhost - -- name: Build sdist builder image - docker_image: - build: - path: "{{ role_path }}/files" - dockerfile: Dockerfile.sdist - pull: false - args: - http_proxy: "{{ http_proxy | default('') }}" - https_proxy: "{{ https_proxy | default('') }}" - no_proxy: "{{ no_proxy | default('') }}" - name: awx_sdist_builder - tag: "{{ awx_version }}" - source: 'build' - force_source: true - delegate_to: localhost - when: use_container_for_build|default(true)|bool - -- name: Get current uid - command: id -u - register: uid - -- name: Build AWX distribution using container - docker_container: - env: - http_proxy: "{{ http_proxy | default('') }}" - https_proxy: "{{ https_proxy | default('') }}" - no_proxy: "{{ no_proxy | default('') }}" - image: "awx_sdist_builder:{{ awx_version }}" - name: awx_sdist_builder - state: started - user: "{{ uid.stdout }}" - detach: false - volumes: - - ../:/awx:Z - delegate_to: localhost - when: use_container_for_build|default(true)|bool - -- name: Build AWX distribution locally - command: make sdist - args: - chdir: .. - delegate_to: localhost - when: not use_container_for_build|default(true)|bool - -- name: Set docker build base path - set_fact: - docker_base_path: "{{ awx_local_base_config_path|default('/tmp') }}/docker-image" - - name: Set awx image name set_fact: awx_image: "{{ awx_image|default('awx') }}" @@ -98,31 +26,11 @@ src: Dockerfile.j2 dest: ../Dockerfile -- name: Build base awx image - docker_image: - build: - path: ".." - dockerfile: Dockerfile - pull: false - args: - http_proxy: "{{ http_proxy | default('') }}" - https_proxy: "{{ https_proxy | default('') }}" - no_proxy: "{{ no_proxy | default('') }}" - name: "{{ awx_image }}" - tag: "{{ awx_version }}" - source: 'build' - force_source: true - delegate_to: localhost +# Calling Docker directly because docker-py doesnt support BuildKit +- name: Build AWX image + command: docker build -t {{ awx_image }}:{{ awx_version }} .. - name: Tag awx images as latest command: "docker tag {{ item }}:{{ awx_version }} {{ item }}:latest" - delegate_to: localhost with_items: - "{{ awx_image }}" - -- name: Clean docker base directory - file: - path: "{{ docker_base_path }}" - state: absent - when: cleanup_docker_base|default(True)|bool - delegate_to: localhost diff --git a/installer/roles/image_build/templates/Dockerfile.j2 b/installer/roles/image_build/templates/Dockerfile.j2 index 64417060c7..7572c6219f 100644 --- a/installer/roles/image_build/templates/Dockerfile.j2 +++ b/installer/roles/image_build/templates/Dockerfile.j2 @@ -1,21 +1,19 @@ -{% if build_dev|bool %} +{% if build_dev|default(False)|bool %} ### This file is generated from ### installer/roles/image_build/templates/Dockerfile.j2 ### ### DO NOT EDIT ### +{% else %} + {% set build_dev = False %} {% endif %} # Locations - set globally to be used across stages -ARG VENV_BASE="{% if not build_dev|bool %}/var/lib/awx{% endif %}/venv" -ARG COLLECTION_BASE="{% if not build_dev|bool %}/var/lib/awx{% endif %}/vendor/awx_ansible_collections" +ARG COLLECTION_BASE="/var/lib/awx/vendor/awx_ansible_collections" # Build container FROM centos:8 as builder -ARG VENV_BASE -ARG COLLECTION_BASE - ENV LANG en_US.UTF-8 ENV LANGUAGE en_US:en ENV LC_ALL en_US.UTF-8 @@ -23,9 +21,10 @@ ENV LC_ALL en_US.UTF-8 USER root # Install build dependencies +RUN dnf -y module enable 'postgresql:12' RUN dnf -y update && \ dnf -y install epel-release 'dnf-command(config-manager)' && \ - dnf module -y enable 'postgresql:10' && \ + dnf module -y enable 'postgresql:12' && \ dnf config-manager --set-enabled powertools && \ dnf -y install ansible \ gcc \ @@ -40,7 +39,7 @@ RUN dnf -y update && \ nss \ openldap-devel \ patch \ - @postgresql:10 \ + @postgresql:12 \ postgresql-devel \ python3-devel \ python3-pip \ @@ -72,16 +71,21 @@ RUN cd /tmp && make requirements_collections ADD requirements/requirements_dev.txt /tmp/requirements RUN cd /tmp && make requirements_awx_dev requirements_ansible_dev {% endif %} + {% if not build_dev|bool %} -COPY dist/{{ awx_sdist_file }} /tmp/{{ awx_sdist_file }} -RUN mkdir -p -m 755 /var/lib/awx && \ - OFFICIAL=yes /var/lib/awx/venv/awx/bin/pip install /tmp/{{ awx_sdist_file }} +# 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 + +# Copy source into builder, build sdist, install it into awx venv +COPY . /tmp/src/ +WORKDIR /tmp/src/ +RUN make sdist && \ + /var/lib/awx/venv/awx/bin/pip install dist/awx-$(cat VERSION).tar.gz {% endif %} # Final container(s) FROM centos:8 -ARG VENV_BASE ARG COLLECTION_BASE ENV LANG en_US.UTF-8 @@ -90,32 +94,11 @@ ENV LC_ALL en_US.UTF-8 USER root -{% if build_dev|bool %} -# Install development/test requirements -RUN dnf -y install \ - gtk3 \ - gettext \ - alsa-lib \ - libX11-xcb \ - libXScrnSaver \ - strace \ - vim \ - nmap-ncat \ - nodejs \ - nss \ - make \ - patch \ - tmux \ - wget \ - diffutils \ - unzip && \ - npm install -g n && n 14.15.1 && dnf remove -y nodejs -{% endif %} - # Install runtime requirements +RUN dnf -y module enable 'postgresql:12' RUN dnf -y update && \ dnf -y install epel-release 'dnf-command(config-manager)' && \ - dnf module -y enable 'postgresql:10' && \ + dnf module -y enable 'postgresql:12' && \ dnf config-manager --set-enabled powertools && \ dnf -y install acl \ ansible \ @@ -126,7 +109,7 @@ RUN dnf -y update && \ krb5-workstation \ libcgroup-tools \ nginx \ - @postgresql:10 \ + @postgresql:12 \ python3-devel \ python3-libselinux \ python3-pip \ @@ -163,16 +146,40 @@ 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 %} +# Install development/test requirements +RUN dnf --enablerepo=debuginfo -y install \ + gdb \ + gtk3 \ + gettext \ + alsa-lib \ + libX11-xcb \ + libXScrnSaver \ + strace \ + vim \ + nmap-ncat \ + nodejs \ + nss \ + make \ + patch \ + python3-debuginfo \ + socat \ + tmux \ + wget \ + diffutils \ + unzip && \ + npm install -g n && n 14.15.1 && dnf remove -y nodejs +{% endif %} + # Copy app from builder +COPY --from=builder /var/lib/awx /var/lib/awx + {%if build_dev|bool %} -COPY --from=builder /venv /venv -COPY --from=builder /vendor /vendor RUN openssl req -nodes -newkey rsa:2048 -keyout /etc/nginx/nginx.key -out /etc/nginx/nginx.csr \ -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 %} -COPY --from=builder /var/lib/awx /var/lib/awx RUN ln -s /var/lib/awx/venv/awx/bin/awx-manage /usr/bin/awx-manage {% endif %} @@ -221,17 +228,17 @@ RUN chmod u+s /usr/bin/bwrap ; \ {% if build_dev|bool %} RUN for dir in \ - /venv \ - /venv/awx/lib/python3.6 \ + /var/lib/awx/venv \ + /var/lib/awx/venv/awx/lib/python3.6 \ /var/lib/awx/projects \ /var/lib/awx/rsyslog \ /var/run/awx-rsyslog \ /.ansible \ - /vendor ; \ + /var/lib/awx/vendor ; \ do mkdir -m 0775 -p $dir ; chmod g+rw $dir ; chgrp root $dir ; done && \ for file in \ /var/run/nginx.pid \ - /venv/awx/lib/python3.6/site-packages/awx.egg-link ; \ + /var/lib/awx/venv/awx/lib/python3.6/site-packages/awx.egg-link ; \ do touch $file ; chmod g+rw $file ; done {% endif %} diff --git a/installer/roles/image_push/tasks/main.yml b/installer/roles/image_push/tasks/main.yml index e005af1096..9561af8ac8 100644 --- a/installer/roles/image_push/tasks/main.yml +++ b/installer/roles/image_push/tasks/main.yml @@ -6,7 +6,6 @@ password: "{{ docker_registry_password }}" reauthorize: true when: docker_registry is defined and docker_registry_password is defined - delegate_to: localhost - name: Remove local images to ensure proper push behavior block: @@ -15,7 +14,6 @@ name: "{{ docker_registry }}/{{ docker_registry_repository }}/{{ awx_image }}" tag: "{{ awx_version }}" state: absent - delegate_to: localhost - name: Tag and Push Container Images block: @@ -28,7 +26,6 @@ with_items: - "latest" - "{{ awx_version }}" - delegate_to: localhost - name: Set full image path for Registry set_fact: diff --git a/installer/roles/kubernetes/tasks/main.yml b/installer/roles/kubernetes/tasks/main.yml index 7c23e1bcaf..dc6639b56b 100644 --- a/installer/roles/kubernetes/tasks/main.yml +++ b/installer/roles/kubernetes/tasks/main.yml @@ -76,7 +76,7 @@ -e POSTGRESQL_USER={{ pg_username }} \ -e POSTGRESQL_PASSWORD={{ pg_password | quote }} \ -e POSTGRESQL_DATABASE={{ pg_database | quote }} \ - -e POSTGRESQL_VERSION=10 \ + -e POSTGRESQL_VERSION=12 \ -n {{ kubernetes_namespace }} register: openshift_pg_activate no_log: true @@ -133,9 +133,9 @@ seconds: "{{ postgress_activate_wait }}" when: openshift_pg_activate.changed or kubernetes_pg_activate.changed -- name: Check postgres version and upgrade Postgres if necessary +- name: Check postgres version and upgrade Postgres if necessary (Openshift) block: - - name: Check if Postgres 9.6 is being used + - name: Check if Postgres 10 is being used shell: | POD=$({{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ get pods -l=name=postgresql --field-selector status.phase=Running -o jsonpath="{.items[0].metadata.name}") @@ -145,7 +145,7 @@ block: - name: Set new pg image shell: | - IMAGE=registry.redhat.io/rhel-8/postgresql-10 + IMAGE=registry.redhat.io/rhel-8/postgresql-12 {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set image dc/postgresql postgresql=$IMAGE - name: Wait for change to take affect @@ -162,7 +162,7 @@ - name: Set env var for new pg version shell: | - {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set env dc/postgresql POSTGRESQL_VERSION=10 + {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set env dc/postgresql POSTGRESQL_VERSION=12 - name: Wait for Postgres to redeploy pause: @@ -185,9 +185,11 @@ - name: Wait for Postgres to redeploy pause: seconds: "{{ postgress_activate_wait }}" - when: "pg_version is success and '9.6' in pg_version.stdout" + when: "pg_version is success and '10' in pg_version.stdout" when: - pg_hostname is not defined or pg_hostname == '' + - postgres_svc_details is defined and postgres_svc_details.rc != 0 + - openshift_host is defined - name: Set image names if using custom registry block: diff --git a/installer/roles/kubernetes/templates/deployment.yml.j2 b/installer/roles/kubernetes/templates/deployment.yml.j2 index b82c30b069..5902d8d3ae 100644 --- a/installer/roles/kubernetes/templates/deployment.yml.j2 +++ b/installer/roles/kubernetes/templates/deployment.yml.j2 @@ -448,22 +448,6 @@ spec: - key: environment_sh path: 'environment.sh' - - name: {{ kubernetes_deployment_name }}-launch-awx-web - configMap: - name: {{ kubernetes_deployment_name }}-launch-awx - items: - - key: launch-awx-web - path: 'launch_awx.sh' - defaultMode: 0755 - - - name: {{ kubernetes_deployment_name }}-launch-awx-task - configMap: - name: {{ kubernetes_deployment_name }}-launch-awx - items: - - key: launch-awx-task - path: 'launch_awx_task.sh' - defaultMode: 0755 - - name: {{ kubernetes_deployment_name }}-supervisor-web-config configMap: name: {{ kubernetes_deployment_name }}-supervisor-config diff --git a/installer/roles/kubernetes/templates/postgresql-persistent.yml.j2 b/installer/roles/kubernetes/templates/postgresql-persistent.yml.j2 index febbc3402a..c688083cbb 100644 --- a/installer/roles/kubernetes/templates/postgresql-persistent.yml.j2 +++ b/installer/roles/kubernetes/templates/postgresql-persistent.yml.j2 @@ -99,7 +99,7 @@ objects: name: ${DATABASE_SERVICE_NAME} - name: POSTGRESQL_MAX_CONNECTIONS value: ${POSTGRESQL_MAX_CONNECTIONS} - image: registry.redhat.io/rhel8/postgresql-10 + image: registry.redhat.io/rhel8/postgresql-12 imagePullPolicy: IfNotPresent livenessProbe: exec: diff --git a/installer/roles/kubernetes/templates/postgresql-values.yml.j2 b/installer/roles/kubernetes/templates/postgresql-values.yml.j2 index ea6ba29230..4fca12a7b7 100644 --- a/installer/roles/kubernetes/templates/postgresql-values.yml.j2 +++ b/installer/roles/kubernetes/templates/postgresql-values.yml.j2 @@ -36,11 +36,14 @@ master: {% endif %} image: {% if pg_image_registry is defined %} +# The default bitnami image from the chart doesn't work on ARM registry: {{ pg_image_registry }} {% endif %} - # The default bitnami image from the chart doesn't work on ARM - repository: postgres - tag: '11' +{% if pg_image_registry is not defined %} + registry: docker.io/bitnami +{% endif %} + repository: postgresql + tag: '12.5.0' volumePermissions: image: {% if pg_image_registry is defined %} diff --git a/installer/roles/local_docker/defaults/main.yml b/installer/roles/local_docker/defaults/main.yml index f8e1304702..03742f5e14 100644 --- a/installer/roles/local_docker/defaults/main.yml +++ b/installer/roles/local_docker/defaults/main.yml @@ -4,7 +4,7 @@ dockerhub_version: "{{ lookup('file', playbook_dir + '/../VERSION') }}" awx_image: "awx" redis_image: "redis" -postgresql_version: "10" +postgresql_version: "12" postgresql_image: "postgres:{{postgresql_version}}" compose_start_containers: true diff --git a/installer/roles/local_docker/tasks/upgrade_postgres.yml b/installer/roles/local_docker/tasks/upgrade_postgres.yml index ae9801bae6..0a2b3afd33 100644 --- a/installer/roles/local_docker/tasks/upgrade_postgres.yml +++ b/installer/roles/local_docker/tasks/upgrade_postgres.yml @@ -31,20 +31,20 @@ - name: Upgrade Postgres shell: | docker run --rm \ - -v {{ postgres_data_dir }}/pgdata:/var/lib/postgresql/9.6/data \ -v {{ postgres_data_dir }}/10/data:/var/lib/postgresql/10/data \ + -v {{ postgres_data_dir }}/12/data:/var/lib/postgresql/12/data \ -e PGUSER={{ pg_username }} -e POSTGRES_INITDB_ARGS="-U {{ pg_username }}" \ - tianon/postgres-upgrade:9.6-to-10 --username={{ 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 + '/10/data/' }}" + dest: "{{ postgres_data_dir + '/12/data/' }}" when: upgrade_postgres | bool - name: Remove old data directory file: - path: "{{ postgres_data_dir + '/pgdata' }}" + path: "{{ postgres_data_dir + '/10/data' }}" state: absent when: compose_start_containers|bool diff --git a/pytest.ini b/pytest.ini index ff89dc85f3..fc407b5f17 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,7 +1,7 @@ [pytest] DJANGO_SETTINGS_MODULE = awx.settings.development -python_paths = /venv/tower/lib/python3.6/site-packages -site_dirs = /venv/tower/lib/python3.6/site-packages +python_paths = /var/lib/awx/venv/tower/lib/python3.6/site-packages +site_dirs = /var/lib/awx/venv/tower/lib/python3.6/site-packages python_files = *.py addopts = --reuse-db --nomigrations --tb=native markers = diff --git a/requirements/README.md b/requirements/README.md index 412ac93d8d..22dfe8d62d 100644 --- a/requirements/README.md +++ b/requirements/README.md @@ -140,6 +140,10 @@ The offline installer needs to have functionality confirmed before upgrading the Versions need to match the versions used in the pip bootstrapping step in the top-level Makefile. +### cryptography + +The offline installer needs to have functionality confirmed before upgrading these. + ## Library Notes ### pexpect diff --git a/requirements/requirements.in b/requirements/requirements.in index c4466dedc1..f13ccb21b3 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -2,9 +2,11 @@ aiohttp ansible-runner>=1.4.6 ansiconv==1.0.0 # UPGRADE BLOCKER: from 2013, consider replacing instead of upgrading asciichartpy +autobahn>=20.12.3 # CVE-2020-35678 azure-keyvault==1.1.0 # see UPGRADE BLOCKERs channels channels-redis>=3.1.0 # https://github.com/django/channels_redis/issues/212 +cryptography<3.0.0 daphne django==2.2.16 # see UPGRADE BLOCKERs django-auth-ldap @@ -25,7 +27,7 @@ djangorestframework>=3.12.1 djangorestframework-yaml GitPython>=3.1.1 # minimum to fix https://github.com/ansible/awx/issues/6119 irc -jinja2 +jinja2>=2.11.0 # required for ChainableUndefined jsonschema Markdown # used for formatting API help openshift>=0.11.0 # minimum version to pull in new pyyaml for CVE-2017-18342 diff --git a/requirements/requirements.txt b/requirements/requirements.txt index a9c25d307d..1c968ea264 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -7,7 +7,7 @@ asciichartpy==1.5.25 # via -r /awx_devel/requirements/requirements.in asgiref==3.2.5 # via channels, channels-redis, daphne async-timeout==3.0.1 # via aiohttp, aioredis attrs==19.3.0 # via aiohttp, automat, jsonschema, service-identity, twisted -autobahn==20.3.1 # via daphne +autobahn==20.12.3 # via -r /awx_devel/requirements/requirements.in, daphne automat==20.2.0 # via twisted azure-common==1.1.25 # via azure-keyvault azure-keyvault==1.1.0 # via -r /awx_devel/requirements/requirements.in @@ -19,7 +19,7 @@ channels-redis==3.1.0 # via -r /awx_devel/requirements/requirements.in channels==2.4.0 # via -r /awx_devel/requirements/requirements.in, channels-redis chardet==3.0.4 # via aiohttp, requests constantly==15.1.0 # via twisted -cryptography==2.8 # via adal, autobahn, azure-keyvault, pyopenssl, service-identity, social-auth-core +cryptography==2.9.2 # via adal, autobahn, azure-keyvault, pyopenssl, service-identity, social-auth-core daphne==2.4.1 # via -r /awx_devel/requirements/requirements.in, channels defusedxml==0.6.0 # via python3-openid, python3-saml, social-auth-core dictdiffer==0.8.1 # via openshift @@ -46,7 +46,7 @@ gitdb==4.0.2 # via gitpython gitpython==3.1.7 # via -r /awx_devel/requirements/requirements.in google-auth==1.11.3 # via kubernetes hiredis==1.0.1 # via aioredis -hyperlink==19.0.0 # via twisted +hyperlink==20.0.1 # via autobahn, twisted idna-ssl==1.1.0 # via aiohttp idna==2.9 # via hyperlink, idna-ssl, requests, twisted, yarl importlib-metadata==1.5.0 # via importlib-resources, irc, jsonschema @@ -107,7 +107,7 @@ ruamel.yaml.clib==0.2.0 # via ruamel.yaml ruamel.yaml==0.16.10 # via openshift schedule==0.6.0 # via -r /awx_devel/requirements/requirements.in service-identity==18.1.0 # via twisted -six==1.14.0 # via ansible-runner, automat, cryptography, django-extensions, django-pglocks, google-auth, isodate, jaraco.collections, jaraco.logging, jaraco.text, jsonschema, kubernetes, openshift, pygerduty, pyopenssl, pyrad, pyrsistent, python-dateutil, slackclient, social-auth-app-django, social-auth-core, tacacs-plus, twilio, txaio, websocket-client +six==1.14.0 # via ansible-runner, automat, cryptography, django-extensions, django-pglocks, google-auth, isodate, jaraco.collections, jaraco.logging, jaraco.text, jsonschema, kubernetes, openshift, pygerduty, pyopenssl, pyrad, pyrsistent, python-dateutil, slackclient, social-auth-app-django, social-auth-core, tacacs-plus, twilio, websocket-client slackclient==1.1.2 # via -r /awx_devel/requirements/requirements.in smmap==3.0.1 # via gitdb social-auth-app-django==3.1.0 # via -r /awx_devel/requirements/requirements.in @@ -117,7 +117,7 @@ tacacs_plus==1.0 # via -r /awx_devel/requirements/requirements.in tempora==2.1.0 # via irc, jaraco.logging twilio==6.37.0 # via -r /awx_devel/requirements/requirements.in twisted[tls]==20.3.0 # via -r /awx_devel/requirements/requirements.in, daphne -txaio==20.1.1 # via autobahn +txaio==20.12.1 # via autobahn typing-extensions==3.7.4.1 # via aiohttp urllib3==1.25.8 # via kubernetes, requests uwsgi==2.0.18 # via -r /awx_devel/requirements/requirements.in diff --git a/requirements/updater.sh b/requirements/updater.sh index 3d93b4d815..c58f1a0f62 100755 --- a/requirements/updater.sh +++ b/requirements/updater.sh @@ -21,7 +21,7 @@ _cleanup() { install_deps() { pip install pip --upgrade - pip install pip-tools + pip install "pip-tools==5.4.0" # see https://github.com/jazzband/pip-tools/pull/1237 } generate_requirements_v3() { diff --git a/tools/docker-compose-cluster.yml b/tools/docker-compose-cluster.yml index 7aec34d8e4..8532b6e942 100644 --- a/tools/docker-compose-cluster.yml +++ b/tools/docker-compose-cluster.yml @@ -96,5 +96,5 @@ services: - "./redis/redis.conf:/usr/local/etc/redis/redis.conf" - "./redis/redis_socket_ha_3:/var/run/redis/" postgres: - image: postgres:10 + image: postgres:12 container_name: tools_postgres_1 diff --git a/tools/docker-compose.yml b/tools/docker-compose.yml index 19ead50661..27261cd1e6 100644 --- a/tools/docker-compose.yml +++ b/tools/docker-compose.yml @@ -44,7 +44,7 @@ services: # Postgres Database Container postgres: - image: postgres:10 + image: postgres:12 container_name: tools_postgres_1 environment: POSTGRES_HOST_AUTH_METHOD: trust diff --git a/tools/scripts/awx-python b/tools/scripts/awx-python index f2116c574c..7b64af02e3 100755 --- a/tools/scripts/awx-python +++ b/tools/scripts/awx-python @@ -1,15 +1,7 @@ #!/usr/bin/env bash -# Enable needed Software Collections, if installed -for scl in rh-postgresql10; do - if [ -f /etc/scl/prefixes/$scl ]; then - if [ -f `cat /etc/scl/prefixes/$scl`/$scl/enable ]; then - . `cat /etc/scl/prefixes/$scl`/$scl/enable - fi - fi -done # Enable Tower virtualenv -for venv_path in /var/lib/awx/venv/awx /venv/awx; do +for venv_path in /var/lib/awx/venv/awx; do if [ -f $venv_path/bin/activate ]; then . $venv_path/bin/activate fi |