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** diff --git a/Makefile b/Makefile index 783547930f..9c7ccd028b 100644 --- a/Makefile +++ b/Makefile @@ -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" diff --git a/README.md b/README.md index 23249c35e6..e24e851ce1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ [![Gated by Zuul](https://zuul-ci.org/gated.svg)](https://ansible.softwarefactory-project.io/zuul/status) -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 @@ -3354,6 +3354,15 @@ msgid "" "common scenarios." 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 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 @@ -3354,6 +3354,15 @@ msgid "" "common scenarios." 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 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 @@ -819,6 +819,11 @@ ManagedCredentialType( 'It is only needed for Keystone v3 authentication ' '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'), 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 ; } if (isLoading) { - return ; + return ; } return (
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.` )} + this.handleResourceSelect('users')} /> - this.handleResourceSelect('teams')} - /> + {resource?.type === 'credential' && + !resource?.organization ? null : ( + this.handleResourceSelect('teams')} + /> + )} ), 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( + {}} + 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 }) => ( - - {i18n._(t`Loading...`)} - -); +const ContentLoading = ({ className }) => { + return ( + + + + ); +}; 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 ( {type}: - {credential.name} + {buildCredentialName()} ); } 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 = () => ( + + + +); +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( {}} />); + }); + 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( + {}} /> + ); + }); + 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( {}} />); + }); + 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('', () => { 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('', () => { 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('', () => { ]); }); + test('should properly render vault credential labels', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + 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('', () => { + 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('', () => { }); 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( + {}} /> + ); + }); + 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( {}} />); + }); + 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'; +import LoadingSpinner from '../LoadingSpinner'; -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; +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 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 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 = ; + } else if (contentError) { + Content = ; + } else if (items.length <= 0) { + Content = ( + ); - const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `); - - let Content; - if (hasContentLoading && items.length <= 0) { - Content = ; - } else if (contentError) { - Content = ; - } else if (items.length <= 0) { - Content = ( - - ); - } else { - Content = ( + } else { + Content = ( + <> + {hasContentLoading && } this.handleListItemSelect(id)} + onSelectDataListItem={id => handleListItemSelect(id)} > {items.map(renderItem)} - ); - } - - const ToolbarPagination = ( - - ); - - return ( - - - {Content} - {items.length ? ( - - ) : null} - + ); } + + const ToolbarPagination = ( + + ); + + return ( + + + {Content} + {items.length ? ( + + ) : null} + + ); } 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 = ( - - {headerRow} - {items.map(renderRow)} - + <> + {hasContentLoading && } + + {headerRow} + {items.map(renderRow)} + + ); } 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 ( @@ -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" /> + + {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 }) { , - credential.organization && ( - - - - ), + + + , {!hasContentLoading && ( 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('', () => { wrapper = mountWithContexts( {}} />); }); 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 @@ -275,6 +275,11 @@ "type": "string", "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", 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 ( + + + + + + ); + } return ( 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 ( @@ -87,12 +80,12 @@ function Host({ i18n, setBreadcrumb }) { ); } - if (contentError) { + if (error) { return ( - - {contentError?.response?.status === 404 && ( + + {error?.response?.status === 404 && ( {i18n._(t`Host not found.`)}{' '} {i18n._(t`View all Hosts.`)} 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('', () => { beforeEach(async () => { await act(async () => { - wrapper = mountWithContexts( {}} />); + wrapper = mountWithContexts( + + {}} /> + + ); }); }); 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('', () => { }); 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 }) { } /> )} + + {job.extra_vars && ( )} + + {hasCustomMessages(messages, typeMessageDefaults) && ( ', () => { .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('', () => { .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('', () => { 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 }) { /> - {summary_fields.user_capabilities && - summary_fields.user_capabilities.edit && ( - - )} - {summary_fields.user_capabilities && - summary_fields.user_capabilities.delete && ( - - {i18n._(t`Delete`)} - - )} + {summary_fields.user_capabilities?.edit && ( + + )} + {summary_fields.user_capabilities?.start && ( + + )} + {summary_fields.user_capabilities?.delete && ( + + {i18n._(t`Delete`)} + + )} {/* 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('', () => { const mockProject = { id: 1, @@ -139,13 +144,19 @@ describe('', () => { ); }); - 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(); 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('', () => { 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('', () => { 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(); + 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(); 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('', () => { 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 && ( - - {handleSync => ( - - )} - + - ) : ( - '' )} {project.summary_fields.user_capabilities.edit ? ( 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('', () => { 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)} + {error && ( { let wrapper; - ProjectsAPI.readSync.mockResolvedValue({ - data: { - can_update: true, - }, - }); const children = handleSync => ( - + {isLoading && } + {!isLoading && error && } + {!isLoading && github && ( + + {formik => ( + + + + + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} + + )} ); } -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('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/github/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + test('initially renders without crashing', () => { expect(wrapper.find('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( + + + + ); + }); + 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 ( + + {isLoading && } + {!isLoading && error && } + {!isLoading && github && ( + + {formik => ( +
+ + + + + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} +
+ )} +
+ ); +} + +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('', () => { + let wrapper; + let history; + + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/github/organization/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + test('initially renders without crashing', () => { + expect(wrapper.find('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( + + + + ); + }); + 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 ( + + {isLoading && } + {!isLoading && error && } + {!isLoading && github && ( + + {formik => ( +
+ + + + + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} +
+ )} +
+ ); +} + +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('', () => { + let wrapper; + let history; + + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/github/team/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + test('initially renders without crashing', () => { + expect(wrapper.find('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( + + + + ); + }); + 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('', () => { @@ -24,9 +39,14 @@ describe('', () => { initialEntries: ['/settings/google_oauth2/details'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('GoogleOAuth2Detail').length).toBe(1); }); @@ -36,9 +56,14 @@ describe('', () => { initialEntries: ['/settings/google_oauth2/edit'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + 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 }) { - + {formik => ( +
+ + + + + + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} + + )} ); } -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('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/google_oauth2/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + test('initially renders without crashing', () => { expect(wrapper.find('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( + + + + ); + }); + 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 }) { - + {isLoading && } + {!isLoading && error && } + {!isLoading && ldap && ( + + {formik => ( +
+ + + + + + + + + + + + + + + + + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} +
+ )} ); } -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('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); + 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( + + + , + { + 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( + + + + ); + }); + 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( + + + , + { + 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 }) { 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`)} 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 && ( } + 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={[ , @@ -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`)} , 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`)} @@ -28,6 +29,7 @@ const RevertFormActionGroup = ({ variant="secondary" type="button" onClick={onRevert} + ouiaId="revert-all-button" > {i18n._(t`Revert all to default`)} @@ -37,6 +39,7 @@ const RevertFormActionGroup = ({ variant="secondary" type="button" onClick={onCancel} + ouiaId="cancel-button" > {i18n._(t`Cancel`)} 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={ <> { > { helpers.setValue(value); }} - mode="javascript" + placeholder={JSON.stringify(config?.placeholder, null, 2)} /> 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 /> 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 ; + if (instanceGroupsError) { + return ; } - if (hasContentLoading) { + if (isLoadingInstanceGroups || isDeleteLoading) { return ; } @@ -219,16 +218,6 @@ function JobTemplateDetail({ i18n, template }) { value={verbosityDetails[0].details} /> - - )} + + {summary_fields.credentials && summary_fields.credentials.length > 0 && ( {i18n._(t`Delete`)} 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('', () => { }); test('should hide edit button for users without edit permission', async () => { - JobTemplatesAPI.readInstanceGroups.mockResolvedValue({ data: {} }); await act(async () => { wrapper = mountWithContexts( { + 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 ; } - if (isLoading) { + if (isLoading || projectLoading) { return ; } + return ( ); @@ -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 = ( + { + setIsDeleteModalOpen(false); + setSelected([]); + }} + actions={[ + , + , + ]} + > +
{i18n._(t`This action will delete the following:`)}
+ {selected.map(question => ( + + {question.question_name} +
+
+ ))} +
+ ); let content; if (isLoading) { @@ -105,6 +147,7 @@ function SurveyList({ canEdit={canEdit} /> ))} + {isDeleteModalOpen && deleteModal} {isPreviewModalOpen && ( )} - , - , - ]} - > -
{i18n._(t`This action will delete the following:`)}
- {selected.map(question => ( - - {question.question_name} -
-
- ))} - - ); - } - if (!questions || questions?.length <= 0) { + + if ((!questions || questions?.length <= 0) && !isLoading) { return ( @@ -193,49 +192,6 @@ function SurveyList({ onToggleDeleteModal={() => setIsDeleteModalOpen(true)} /> {content} - {isDeleteModalOpen && ( - { - setIsDeleteModalOpen(false); - }} - actions={[ - , - , - ]} - > -
{i18n._(t`This action will delete the following:`)}
-
    - {selected.map(question => ( -
  • - {question.question_name} -
  • - ))} -
-
- )} ); } 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 }) { - {contentError.response.status === 404 && ( + {contentError.response?.status === 404 && ( {i18n._(t`Template not found.`)}{' '} {i18n._(t`View all Templates.`)} 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('