Merge pull request #27 from ansible/devel

Rebase
This commit is contained in:
Sean Sullivan
2021-01-16 22:51:21 -06:00
committed by GitHub
163 changed files with 3425 additions and 1332 deletions

View File

@@ -1,2 +1 @@
.git
awx/ui/node_modules awx/ui/node_modules

View File

@@ -85,7 +85,7 @@ If you're not using Docker for Mac, or Docker for Windows, you may need, or choo
#### Frontend Development #### Frontend Development
See [the ui development documentation](awx/ui/README.md). See [the ui development documentation](awx/ui_next/CONTRIBUTING.md).
### Build the environment ### Build the environment
@@ -158,7 +158,7 @@ $ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 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 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 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** **NOTE**

View File

@@ -19,7 +19,8 @@ PYCURL_SSL_LIBRARY ?= openssl
COMPOSE_TAG ?= $(GIT_BRANCH) COMPOSE_TAG ?= $(GIT_BRANCH)
COMPOSE_HOST ?= $(shell hostname) COMPOSE_HOST ?= $(shell hostname)
VENV_BASE ?= /venv VENV_BASE ?= /var/lib/awx/venv/
COLLECTION_BASE ?= /var/lib/awx/vendor/awx_ansible_collections
SCL_PREFIX ?= SCL_PREFIX ?=
CELERY_SCHEDULE_FILE ?= /var/lib/awx/beat.db CELERY_SCHEDULE_FILE ?= /var/lib/awx/beat.db
@@ -270,7 +271,7 @@ uwsgi: collectstatic
@if [ "$(VENV_BASE)" ]; then \ @if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/awx/bin/activate; \ . $(VENV_BASE)/awx/bin/activate; \
fi; \ 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: daphne:
@if [ "$(VENV_BASE)" ]; then \ @if [ "$(VENV_BASE)" ]; then \
@@ -340,7 +341,7 @@ check: flake8 pep8 # pyflakes pylint
awx-link: awx-link:
[ -d "/awx_devel/awx.egg-info" ] || python3 /awx_devel/setup.py egg_info_dev [ -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 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 docker rm tools_kibana_1
psql-container: 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: VERSION:
@echo "awx: $(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"

View File

@@ -1,7 +1,5 @@
[![Gated by Zuul](https://zuul-ci.org/gated.svg)](https://ansible.softwarefactory-project.io/zuul/status) [![Gated by Zuul](https://zuul-ci.org/gated.svg)](https://ansible.softwarefactory-project.io/zuul/status)
<img src="https://raw.githubusercontent.com/ansible/awx-logos/master/awx/ui/client/assets/logo-login.svg?sanitize=true" width=200 alt="AWX" />
AWX provides a web-based user interface, REST API, and task engine built on top of [Ansible](https://github.com/ansible/ansible). It is the upstream project for [Tower](https://www.ansible.com/tower), a commercial derivative of AWX. 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). To install AWX, please view the [Install guide](./INSTALL.md).

View File

@@ -4,6 +4,7 @@ import logging
import sys import sys
import threading import threading
import time import time
import os
# Django # Django
from django.conf import LazySettings 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 # 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 # around the magic __setattr__ method on this class (which is used to
# store API-assigned settings in the database). # store API-assigned settings in the database).
self.__dict__['__forks__'] = {}
self.__dict__['default_settings'] = default_settings self.__dict__['default_settings'] = default_settings
self.__dict__['_awx_conf_settings'] = self self.__dict__['_awx_conf_settings'] = self
self.__dict__['_awx_conf_preload_expires'] = None self.__dict__['_awx_conf_preload_expires'] = None
@@ -255,6 +257,26 @@ class SettingsWrapper(UserSettingsHolder):
self.__dict__['cache'] = EncryptedCacheProxy(cache, registry) self.__dict__['cache'] = EncryptedCacheProxy(cache, registry)
self.__dict__['registry'] = 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 @cached_property
def all_supported_settings(self): def all_supported_settings(self):
return self.registry.get_registered_settings() return self.registry.get_registered_settings()
@@ -330,6 +352,7 @@ class SettingsWrapper(UserSettingsHolder):
self.cache.set_many(settings_to_cache, timeout=SETTING_CACHE_TIMEOUT) self.cache.set_many(settings_to_cache, timeout=SETTING_CACHE_TIMEOUT)
def _get_local(self, name, validate=True): def _get_local(self, name, validate=True):
self.__clean_on_fork__()
self._preload_cache() self._preload_cache()
cache_key = Setting.get_cache_key(name) cache_key = Setting.get_cache_key(name)
try: try:

View File

@@ -3354,6 +3354,15 @@ msgid ""
"common scenarios." "common scenarios."
msgstr "" 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:824
#: awx/main/models/credential/__init__.py:1131 #: awx/main/models/credential/__init__.py:1131
#: awx/main/models/credential/__init__.py:1166 #: awx/main/models/credential/__init__.py:1166

View File

@@ -3354,6 +3354,15 @@ msgid ""
"common scenarios." "common scenarios."
msgstr "" 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:824
#: awx/main/models/credential/__init__.py:1131 #: awx/main/models/credential/__init__.py:1131
#: awx/main/models/credential/__init__.py:1166 #: awx/main/models/credential/__init__.py:1166

View File

@@ -3294,6 +3294,16 @@ msgid ""
"common scenarios." "common scenarios."
msgstr "Les domaines OpenStack définissent les limites administratives. Ils sont nécessaires uniquement pour les URL dauthentification Keystone v3. Voir la documentation Ansible Tower pour les scénarios courants." msgstr "Les domaines OpenStack définissent les limites administratives. Ils sont nécessaires uniquement pour les URL dauthentification 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:812
#: awx/main/models/credential/__init__.py:1110 #: awx/main/models/credential/__init__.py:1110
#: awx/main/models/credential/__init__.py:1144 #: awx/main/models/credential/__init__.py:1144

View File

@@ -7,6 +7,7 @@ import tempfile
import time import time
import logging import logging
import yaml import yaml
import datetime
from django.conf import settings from django.conf import settings
import ansible_runner import ansible_runner
@@ -123,6 +124,7 @@ class IsolatedManager(object):
dir=private_data_dir dir=private_data_dir
) )
params = self.runner_params.copy() params = self.runner_params.copy()
params.get('envvars', dict())['ANSIBLE_CALLBACK_WHITELIST'] = 'profile_tasks'
params['playbook'] = playbook params['playbook'] = playbook
params['private_data_dir'] = iso_dir params['private_data_dir'] = iso_dir
if idle_timeout: if idle_timeout:
@@ -168,7 +170,8 @@ class IsolatedManager(object):
extravars = { extravars = {
'src': self.private_data_dir, 'src': self.private_data_dir,
'dest': settings.AWX_PROOT_BASE_PATH, 'dest': settings.AWX_PROOT_BASE_PATH,
'ident': self.ident 'ident': self.ident,
'job_id': self.instance.id,
} }
if playbook: if playbook:
extravars['playbook'] = playbook extravars['playbook'] = playbook
@@ -204,7 +207,10 @@ class IsolatedManager(object):
:param interval: an interval (in seconds) to wait between status polls :param interval: an interval (in seconds) to wait between status polls
""" """
interval = interval if interval is not None else settings.AWX_ISOLATED_CHECK_INTERVAL 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' status = 'failed'
rc = None rc = None
last_check = time.time() last_check = time.time()
@@ -220,9 +226,13 @@ class IsolatedManager(object):
logger.warning('Isolated job {} was manually canceled.'.format(self.instance.id)) 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)) 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', runner_obj = self.run_management_playbook('check_isolated.yml',
self.private_data_dir, self.private_data_dir,
extravars=extravars) 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 status, rc = runner_obj.status, runner_obj.rc
if self.check_callback is not None and not self.captured_command_artifact: if self.check_callback is not None and not self.captured_command_artifact:

View File

@@ -133,7 +133,7 @@ class AnsibleInventoryLoader(object):
# NOTE: why do we add "python" to the start of these args? # NOTE: why do we add "python" to the start of these args?
# the script that runs ansible-inventory specifies a python interpreter # 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 # 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 # https://github.com/ansible/ansible/issues/50714
bargs = ['python', ansible_inventory_path, '-i', self.source] bargs = ['python', ansible_inventory_path, '-i', self.source]
bargs.extend(['--playbook-dir', functioning_dir(self.source)]) bargs.extend(['--playbook-dir', functioning_dir(self.source)])

View File

@@ -819,6 +819,11 @@ ManagedCredentialType(
'It is only needed for Keystone v3 authentication ' 'It is only needed for Keystone v3 authentication '
'URLs. Refer to Ansible Tower documentation for ' 'URLs. Refer to Ansible Tower documentation for '
'common scenarios.') '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', 'id': 'verify_ssl',
'label': ugettext_noop('Verify SSL'), 'label': ugettext_noop('Verify SSL'),

View File

@@ -82,6 +82,7 @@ def _openstack_data(cred):
if cred.has_input('domain'): if cred.has_input('domain'):
openstack_auth['domain_name'] = cred.get_input('domain', default='') openstack_auth['domain_name'] = cred.get_input('domain', default='')
verify_state = cred.get_input('verify_ssl', default=True) verify_state = cred.get_input('verify_ssl', default=True)
openstack_data = { openstack_data = {
'clouds': { 'clouds': {
'devstack': { '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 return openstack_data

View File

@@ -12,7 +12,7 @@ from django.core.mail.message import EmailMessage
from django.db import connection from django.db import connection
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils.encoding import smart_str, force_text 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 from jinja2.exceptions import TemplateSyntaxError, UndefinedError, SecurityError
# AWX # AWX
@@ -429,7 +429,7 @@ class JobNotificationMixin(object):
raise RuntimeError("Define me") raise RuntimeError("Define me")
def build_notification_message(self, nt, status): def build_notification_message(self, nt, status):
env = sandbox.ImmutableSandboxedEnvironment() env = sandbox.ImmutableSandboxedEnvironment(undefined=ChainableUndefined)
from awx.api.serializers import UnifiedJobSerializer from awx.api.serializers import UnifiedJobSerializer
job_serialization = UnifiedJobSerializer(self).to_representation(self) job_serialization = UnifiedJobSerializer(self).to_representation(self)

View File

@@ -378,6 +378,7 @@ def gather_analytics():
from awx.conf.models import Setting from awx.conf.models import Setting
from rest_framework.fields import DateTimeField from rest_framework.fields import DateTimeField
from awx.main.signals import disable_activity_stream
if not settings.INSIGHTS_TRACKING_STATE: if not settings.INSIGHTS_TRACKING_STATE:
return return
if not (settings.AUTOMATION_ANALYTICS_URL and settings.REDHAT_USERNAME and settings.REDHAT_PASSWORD): 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): if not _gather_and_ship(incremental_collectors, since=start, until=until):
break break
start = until start = until
settings.AUTOMATION_ANALYTICS_LAST_GATHER = until with disable_activity_stream():
settings.AUTOMATION_ANALYTICS_LAST_GATHER = until
if subset: if subset:
_gather_and_ship(subset, since=since, until=gather_time) _gather_and_ship(subset, since=since, until=gather_time)

View File

@@ -16,7 +16,7 @@ def test_awx_virtualenv_from_settings(inventory, project, machine_credential):
) )
jt.credentials.add(machine_credential) jt.credentials.add(machine_credential)
job = jt.create_unified_job() 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 @pytest.mark.django_db
@@ -43,28 +43,28 @@ def test_awx_custom_virtualenv(inventory, project, machine_credential, organizat
jt.credentials.add(machine_credential) jt.credentials.add(machine_credential)
job = jt.create_unified_job() 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() 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() 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() 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 @pytest.mark.django_db
def test_awx_custom_virtualenv_without_jt(project): 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() project.save()
job = Job(project=project) job = Job(project=project)
job.save() job.save()
job = Job.objects.get(pk=job.id) 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 @pytest.mark.django_db

View File

@@ -180,7 +180,7 @@ def test_openstack_client_config_generation(mocker, source, expected, private_da
'source_vars_dict': {}, 'source_vars_dict': {},
'get_cloud_credential': mocker.Mock(return_value=credential), 'get_cloud_credential': mocker.Mock(return_value=credential),
'get_extra_credentials': lambda x: [], '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_config = update.build_private_data(inventory_update, private_data_dir)
cloud_credential = yaml.safe_load( cloud_credential = yaml.safe_load(
@@ -224,6 +224,52 @@ def test_openstack_client_config_generation_with_project_domain_name(mocker, sou
'source_vars_dict': {}, 'source_vars_dict': {},
'get_cloud_credential': mocker.Mock(return_value=credential), 'get_cloud_credential': mocker.Mock(return_value=credential),
'get_extra_credentials': lambda x: [], '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' 'ansible_virtualenv_path': '/venv/foo'
}) })
cloud_config = update.build_private_data(inventory_update, private_data_dir) 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, 'verify': expected,
'private': True, '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}, 'source_vars_dict': {'private': source},
'get_cloud_credential': mocker.Mock(return_value=credential), 'get_cloud_credential': mocker.Mock(return_value=credential),
'get_extra_credentials': lambda x: [], '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_config = update.build_private_data(inventory_update, private_data_dir)
cloud_credential = yaml.load( cloud_credential = yaml.load(
@@ -625,13 +672,13 @@ class TestGenericRun():
def test_invalid_custom_virtualenv(self, patch_Job, private_data_dir): def test_invalid_custom_virtualenv(self, patch_Job, private_data_dir):
job = Job(project=Project(), inventory=Inventory()) job = Job(project=Project(), inventory=Inventory())
job.project.custom_virtualenv = '/venv/missing' job.project.custom_virtualenv = '/var/lib/awx/venv/missing'
task = tasks.RunJob() task = tasks.RunJob()
with pytest.raises(tasks.InvalidVirtualenvError) as e: with pytest.raises(tasks.InvalidVirtualenvError) as e:
task.build_env(job, private_data_dir) 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): class TestAdhocRun(TestJobExecution):

View File

@@ -9,6 +9,9 @@
- ansible.posix - ansible.posix
tasks: tasks:
- name: "Output job the playbook is running for"
debug:
msg: "Checking on job {{ job_id }}"
- name: Determine if daemon process is alive. - name: Determine if daemon process is alive.
shell: "ansible-runner is-alive {{src}}" shell: "ansible-runner is-alive {{src}}"

View File

@@ -13,6 +13,10 @@
- ansible.posix - ansible.posix
tasks: tasks:
- name: "Output job the playbook is running for"
debug:
msg: "Checking on job {{ job_id }}"
- name: synchronize job environment with isolated host - name: synchronize job environment with isolated host
synchronize: synchronize:
copy_links: true copy_links: true

View File

@@ -116,7 +116,7 @@ LOGIN_URL = '/api/login/'
# Absolute filesystem path to the directory to host projects (with playbooks). # Absolute filesystem path to the directory to host projects (with playbooks).
# This directory should not be web-accessible. # 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 # Absolute filesystem path to the directory to host collections for
# running inventory imports, isolated playbooks # 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 # Absolute filesystem path to the directory for job status stdout (default for
# development and tests, default for production defined in production.py). This # development and tests, default for production defined in production.py). This
# directory should not be web-accessible # 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 # 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 # The heartbeat file for the tower scheduler
SCHEDULE_METADATA_LOCATION = os.path.join(BASE_DIR, '.tower_cycle') SCHEDULE_METADATA_LOCATION = os.path.join(BASE_DIR, '.tower_cycle')
@@ -932,6 +932,14 @@ LOGGING = {
'backupCount': 5, 'backupCount': 5,
'formatter':'simple', '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': { 'loggers': {
'django': { 'django': {
@@ -981,6 +989,11 @@ LOGGING = {
'awx.main.wsbroadcast': { 'awx.main.wsbroadcast': {
'handlers': ['wsbroadcast'], 'handlers': ['wsbroadcast'],
}, },
'awx.isolated.manager': {
'level': 'WARNING',
'handlers': ['console', 'file', 'isolated_manager'],
'propagate': True
},
'awx.isolated.manager.playbooks': { 'awx.isolated.manager.playbooks': {
'handlers': ['management_playbooks'], 'handlers': ['management_playbooks'],
'propagate': False 'propagate': False

View File

@@ -148,9 +148,9 @@ include(optional('/etc/tower/settings.py'), scope=locals())
include(optional('/etc/tower/conf.d/*.py'), scope=locals()) include(optional('/etc/tower/conf.d/*.py'), scope=locals())
# Installed differently in Dockerfile compared to production versions # 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") ANSIBLE_VENV_PATH = os.path.join(BASE_VENV_PATH, "ansible")
AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx") AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx")

View File

@@ -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 # Location for cross-development of inventory plugins
AWX_ANSIBLE_COLLECTIONS_PATHS = '/vendor/awx_ansible_collections' AWX_ANSIBLE_COLLECTIONS_PATHS = '/var/lib/awx/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')
# The UUID of the system, for HA. # The UUID of the system, for HA.
SYSTEM_UUID = '00000000-0000-0000-0000-000000000000' 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. # If set, use -vvv for project updates instead of -v for more output.
# PROJECT_UPDATE_VVV=True # PROJECT_UPDATE_VVV=True
@@ -108,40 +64,6 @@ PROXY_IP_WHITELIST = []
# Enable logging to syslog. Setting level to ERROR captures 500 errors, # Enable logging to syslog. Setting level to ERROR captures 500 errors,
# WARNING also logs 4xx responses. # 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. # Enable the following lines to turn on lots of permissions-related logging.
#LOGGING['loggers']['awx.main.access']['level'] = 'DEBUG' #LOGGING['loggers']['awx.main.access']['level'] = 'DEBUG'
#LOGGING['loggers']['awx.main.signals']['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']['handlers'] = ['console']
#LOGGING['loggers']['django_auth_ldap']['level'] = 'DEBUG' #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_SECRET = '🤖starscream🤖'
BROADCAST_WEBSOCKET_PORT = 8013 BROADCAST_WEBSOCKET_PORT = 8013
BROADCAST_WEBSOCKET_VERIFY_CERT = False BROADCAST_WEBSOCKET_VERIFY_CERT = False

View File

@@ -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 = ''

View File

@@ -30,10 +30,6 @@ SECRET_KEY = None
# See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts # See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
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 # The heartbeat file for the tower scheduler
SCHEDULE_METADATA_LOCATION = '/var/lib/awx/.tower_cycle' 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' 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 # Store a snapshot of default settings at this point before loading any
# customizable config files. # customizable config files.
DEFAULTS_SNAPSHOT = {} DEFAULTS_SNAPSHOT = {}

View File

@@ -57,7 +57,7 @@ The UI is built using [ReactJS](https://reactjs.org/docs/getting-started.html) a
The AWX UI requires the following: The AWX UI requires the following:
- Node 10.x LTS - Node 14.x LTS
- NPM 6.x LTS - NPM 6.x LTS
Run the following to install all the dependencies: Run the following to install all the dependencies:

View File

@@ -3387,12 +3387,18 @@
"dev": true "dev": true
}, },
"axios": { "axios": {
"version": "0.18.1", "version": "0.21.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
"integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==", "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
"requires": { "requires": {
"follow-redirects": "1.5.10", "follow-redirects": "^1.10.0"
"is-buffer": "^2.0.2" },
"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": { "axobject-query": {
@@ -4195,6 +4201,16 @@
"integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==", "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==",
"dev": true "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": { "bluebird": {
"version": "3.7.2", "version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@@ -5961,6 +5977,7 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"dev": true,
"requires": { "requires": {
"ms": "2.0.0" "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": { "filesize": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-6.0.1.tgz", "resolved": "https://registry.npmjs.org/filesize/-/filesize-6.0.1.tgz",
@@ -8110,6 +8134,7 @@
"version": "1.5.10", "version": "1.5.10",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
"dev": true,
"requires": { "requires": {
"debug": "=3.1.0" "debug": "=3.1.0"
} }
@@ -9500,11 +9525,6 @@
"call-bind": "^1.0.0" "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": { "is-callable": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz", "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", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"dev": true, "dev": true,
"optional": true "optional": true,
"requires": {
"bindings": "^1.5.0",
"nan": "^2.12.1"
}
}, },
"is-buffer": { "is-buffer": {
"version": "1.1.6", "version": "1.1.6",
@@ -11731,7 +11755,8 @@
"ms": { "ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
"dev": true
}, },
"multicast-dns": { "multicast-dns": {
"version": "6.2.3", "version": "6.2.3",
@@ -11755,6 +11780,13 @@
"integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=", "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=",
"dev": true "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": { "nanomatch": {
"version": "1.2.13", "version": "1.2.13",
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", "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", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"dev": true, "dev": true,
"optional": true "optional": true,
"requires": {
"bindings": "^1.5.0",
"nan": "^2.12.1"
}
}, },
"glob-parent": { "glob-parent": {
"version": "3.1.0", "version": "3.1.0",
@@ -18364,7 +18400,11 @@
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"dev": true, "dev": true,
"optional": true "optional": true,
"requires": {
"bindings": "^1.5.0",
"nan": "^2.12.1"
}
}, },
"glob-parent": { "glob-parent": {
"version": "3.1.0", "version": "3.1.0",

View File

@@ -12,7 +12,7 @@
"@patternfly/react-icons": "4.7.22", "@patternfly/react-icons": "4.7.22",
"@patternfly/react-table": "^4.19.15", "@patternfly/react-table": "^4.19.15",
"ansi-to-html": "^0.6.11", "ansi-to-html": "^0.6.11",
"axios": "^0.18.1", "axios": "^0.21.1",
"codemirror": "^5.47.0", "codemirror": "^5.47.0",
"d3": "^5.12.0", "d3": "^5.12.0",
"dagre": "^0.8.4", "dagre": "^0.8.4",

View File

@@ -36,6 +36,10 @@ class Jobs extends RelaunchMixin(Base) {
return this.http.post(`/api/v2${getBaseURL(type)}${id}/cancel/`); 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) { readDetail(id, type) {
return this.http.get(`/api/v2${getBaseURL(type)}${id}/`); return this.http.get(`/api/v2${getBaseURL(type)}${id}/`);
} }

View File

@@ -57,7 +57,7 @@ function AdHocCommands({ adHocItems, i18n, hasListItems }) {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
const { const {
isloading: isLaunchLoading, isLoading: isLaunchLoading,
error: launchError, error: launchError,
request: launchAdHocCommands, request: launchAdHocCommands,
} = useRequest( } = useRequest(

View File

@@ -58,7 +58,7 @@ function AdHocCredentialStep({ i18n, credentialTypeId, onEnableLaunch }) {
return <ContentError error={error} />; return <ContentError error={error} />;
} }
if (isLoading) { if (isLoading) {
return <ContentLoading error={error} />; return <ContentLoading />;
} }
return ( return (
<Form> <Form>

View File

@@ -144,7 +144,7 @@ class AddResourceRole extends React.Component {
currentStepId, currentStepId,
maxEnabledStep, maxEnabledStep,
} = this.state; } = 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 // Object roles can be user only, so we remove them when
// showing role choices for team access // 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.` t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.`
)} )}
</div> </div>
<SelectableCard <SelectableCard
isSelected={selectedResource === 'users'} isSelected={selectedResource === 'users'}
label={i18n._(t`Users`)} label={i18n._(t`Users`)}
dataCy="add-role-users" dataCy="add-role-users"
ariaLabel={i18n._(t`Users`)}
onClick={() => this.handleResourceSelect('users')} onClick={() => this.handleResourceSelect('users')}
/> />
<SelectableCard {resource?.type === 'credential' &&
isSelected={selectedResource === 'teams'} !resource?.organization ? null : (
label={i18n._(t`Teams`)} <SelectableCard
dataCy="add-role-teams" isSelected={selectedResource === 'teams'}
onClick={() => this.handleResourceSelect('teams')} label={i18n._(t`Teams`)}
/> dataCy="add-role-teams"
ariaLabel={i18n._(t`Teams`)}
onClick={() => this.handleResourceSelect('teams')}
/>
)}
</div> </div>
), ),
enableNext: selectedResource !== null, enableNext: selectedResource !== null,
@@ -329,10 +335,12 @@ AddResourceRole.propTypes = {
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired,
roles: PropTypes.shape(), roles: PropTypes.shape(),
resource: PropTypes.shape(),
}; };
AddResourceRole.defaultProps = { AddResourceRole.defaultProps = {
roles: {}, roles: {},
resource: {},
}; };
export { AddResourceRole as _AddResourceRole }; export { AddResourceRole as _AddResourceRole };

View File

@@ -221,4 +221,22 @@ describe('<_AddResourceRole />', () => {
expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2); expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2);
expect(handleSave).toHaveBeenCalled(); expect(handleSave).toHaveBeenCalled();
}); });
test('should not display team as a choice in case credential does not have organization', () => {
const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect');
const wrapper = mountWithContexts(
<AddResourceRole
onClose={() => {}}
onSave={() => {}}
roles={roles}
resource={{ type: 'credential', organization: null }}
/>,
{ context: { network: { handleHttpError: () => {} } } }
).find('AddResourceRole');
const selectableCardWrapper = wrapper.find('SelectableCard');
expect(selectableCardWrapper.length).toBe(1);
selectableCardWrapper.first().simulate('click');
expect(spy).toHaveBeenCalledWith('users');
expect(wrapper.state('selectedResource')).toBe('users');
});
}); });

View File

@@ -6,12 +6,12 @@ const mockData = [
{ {
key: 'baz', key: 'baz',
label: 'Baz', label: 'Baz',
value: '/venv/baz/', value: '/var/lib/awx/venv/baz/',
}, },
{ {
key: 'default', key: 'default',
label: 'Default', label: 'Default',
value: '/venv/ansible/', value: '/var/lib/awx/venv/ansible/',
}, },
]; ];

View File

@@ -6,6 +6,7 @@ import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/yaml/yaml'; import 'codemirror/mode/yaml/yaml';
import 'codemirror/mode/jinja2/jinja2'; import 'codemirror/mode/jinja2/jinja2';
import 'codemirror/lib/codemirror.css'; import 'codemirror/lib/codemirror.css';
import 'codemirror/addon/display/placeholder';
const LINE_HEIGHT = 24; const LINE_HEIGHT = 24;
const PADDING = 12; const PADDING = 12;
@@ -55,6 +56,17 @@ const CodeMirror = styled(ReactCodeMirror)`
background-color: var(--pf-c-form-control--disabled--BackgroundColor); 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({ function CodeMirrorInput({
@@ -66,6 +78,7 @@ function CodeMirrorInput({
rows, rows,
fullHeight, fullHeight,
className, className,
placeholder,
}) { }) {
// Workaround for CodeMirror bug: If CodeMirror renders in a modal on the // 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 // modal's initial render, it appears as an empty box due to mis-calculated
@@ -92,6 +105,7 @@ function CodeMirrorInput({
smartIndent: false, smartIndent: false,
lineNumbers: true, lineNumbers: true,
lineWrapping: true, lineWrapping: true,
placeholder,
readOnly, readOnly,
}} }}
fullHeight={fullHeight} fullHeight={fullHeight}

View File

@@ -1,22 +1,25 @@
import React from 'react'; import React from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import styled from 'styled-components'; import styled from 'styled-components';
import { import {
EmptyState as PFEmptyState, EmptyState as PFEmptyState,
EmptyStateBody, EmptyStateIcon,
Spinner,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
const EmptyState = styled(PFEmptyState)` const EmptyState = styled(PFEmptyState)`
--pf-c-empty-state--m-lg--MaxWidth: none; --pf-c-empty-state--m-lg--MaxWidth: none;
min-height: 250px;
`; `;
// TODO: Better loading state - skeleton lines / spinner, etc. // TODO: Better loading state - skeleton lines / spinner, etc.
const ContentLoading = ({ className, i18n }) => ( const ContentLoading = ({ className }) => {
<EmptyState variant="full" className={className}> return (
<EmptyStateBody>{i18n._(t`Loading...`)}</EmptyStateBody> <EmptyState variant="full" className={className}>
</EmptyState> <EmptyStateIcon variant="container" component={Spinner} />
); </EmptyState>
);
};
export { ContentLoading as _ContentLoading }; export { ContentLoading as _ContentLoading };
export default withI18n()(ContentLoading); export default ContentLoading;

View File

@@ -16,10 +16,17 @@ function CredentialChip({ credential, i18n, i18nHash, ...props }) {
type = toTitleCase(credential.kind); 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 ( return (
<Chip {...props}> <Chip {...props}>
<strong>{type}: </strong> <strong>{type}: </strong>
{credential.name} {buildCredentialName()}
</Chip> </Chip>
); );
} }

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { Spinner } from '@patternfly/react-core';
import styled from 'styled-components';
const UpdatingContent = styled.div`
position: fixed;
top: 50%;
left: 50%;
z-index: 300;
width: 100%;
height: 100%;
& + * {
opacity: 0.5;
}
`;
const LoadingSpinner = () => (
<UpdatingContent>
<Spinner />
</UpdatingContent>
);
export default LoadingSpinner;

View File

@@ -0,0 +1 @@
export { default } from './LoadingSpinner';

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { import {
arrayOf, arrayOf,
bool, bool,
@@ -8,7 +9,6 @@ import {
string, string,
oneOfType, oneOfType,
} from 'prop-types'; } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
@@ -39,13 +39,13 @@ function CredentialLookup({
credentialTypeKind, credentialTypeKind,
credentialTypeNamespace, credentialTypeNamespace,
value, value,
history,
i18n, i18n,
tooltip, tooltip,
isDisabled, isDisabled,
autoPopulate, autoPopulate,
multiple, multiple,
}) { }) {
const history = useHistory();
const autoPopulateLookup = useAutoPopulateLookup(onChange); const autoPopulateLookup = useAutoPopulateLookup(onChange);
const { const {
result: { count, credentials, relatedSearchableKeys, searchableKeys }, result: { count, credentials, relatedSearchableKeys, searchableKeys },
@@ -72,22 +72,28 @@ function CredentialLookup({
...typeNamespaceParams, ...typeNamespaceParams,
}) })
), ),
CredentialsAPI.readOptions, CredentialsAPI.readOptions(),
]); ]);
if (autoPopulate) { if (autoPopulate) {
autoPopulateLookup(data.results); 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 { return {
count: data.count, count: data.count,
credentials: data.results, credentials: data.results,
relatedSearchableKeys: ( relatedSearchableKeys: (
actionsResponse?.data?.related_search_fields || [] actionsResponse?.data?.related_search_fields || []
).map(val => val.slice(0, -8)), ).map(val => val.slice(0, -8)),
searchableKeys: Object.keys( searchableKeys: searchKeys,
actionsResponse.data?.actions?.GET || {}
).filter(key => actionsResponse.data?.actions?.GET[key]?.filterable),
}; };
}, [ }, [
autoPopulate, autoPopulate,
@@ -222,4 +228,4 @@ CredentialLookup.defaultProps = {
}; };
export { CredentialLookup as _CredentialLookup }; export { CredentialLookup as _CredentialLookup };
export default withI18n()(withRouter(CredentialLookup)); export default withI18n()(CredentialLookup);

View File

@@ -13,7 +13,7 @@ import useRequest from '../../util/useRequest';
import Lookup from './Lookup'; import Lookup from './Lookup';
import LookupErrorMessage from './shared/LookupErrorMessage'; import LookupErrorMessage from './shared/LookupErrorMessage';
const QS_CONFIG = getQSConfig('instance_groups', { const QS_CONFIG = getQSConfig('instance-groups', {
page: 1, page: 1,
page_size: 5, page_size: 5,
order_by: 'name', order_by: 'name',

View File

@@ -16,6 +16,7 @@ const QS_CONFIG = getQSConfig('inventory', {
page: 1, page: 1,
page_size: 5, page_size: 5,
order_by: 'name', order_by: 'name',
role_level: 'use_role',
}); });
function InventoryLookup({ function InventoryLookup({
@@ -29,6 +30,7 @@ function InventoryLookup({
fieldId, fieldId,
promptId, promptId,
promptName, promptName,
isOverrideDisabled,
}) { }) {
const { const {
result: { result: {
@@ -57,8 +59,10 @@ function InventoryLookup({
searchableKeys: Object.keys( searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {} actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable), ).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]), }, [history.location]),
{ {
inventories: [], inventories: [],
@@ -195,11 +199,13 @@ InventoryLookup.propTypes = {
value: Inventory, value: Inventory,
onChange: func.isRequired, onChange: func.isRequired,
required: bool, required: bool,
isOverrideDisabled: bool,
}; };
InventoryLookup.defaultProps = { InventoryLookup.defaultProps = {
value: null, value: null,
required: false, required: false,
isOverrideDisabled: false,
}; };
export default withI18n()(withRouter(InventoryLookup)); export default withI18n()(withRouter(InventoryLookup));

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import InventoryLookup from './InventoryLookup';
import { InventoriesAPI } from '../../api';
jest.mock('../../api');
const mockedInventories = {
data: {
count: 2,
results: [
{ id: 2, name: 'Bar' },
{ id: 3, name: 'Baz' },
],
},
};
describe('InventoryLookup', () => {
let wrapper;
beforeEach(() => {
InventoriesAPI.read.mockResolvedValue(mockedInventories);
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render successfully and fetch data', async () => {
InventoriesAPI.readOptions.mockReturnValue({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [],
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryLookup onChange={() => {}} />);
});
wrapper.update();
expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);
expect(wrapper.find('InventoryLookup')).toHaveLength(1);
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
});
test('inventory lookup should be enabled', async () => {
InventoriesAPI.readOptions.mockReturnValue({
data: {
actions: {
GET: {},
},
related_search_fields: [],
},
});
await act(async () => {
wrapper = mountWithContexts(
<InventoryLookup isOverrideDisabled onChange={() => {}} />
);
});
wrapper.update();
expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);
expect(wrapper.find('InventoryLookup')).toHaveLength(1);
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
});
test('inventory lookup should be disabled', async () => {
InventoriesAPI.readOptions.mockReturnValue({
data: {
actions: {
GET: {},
},
related_search_fields: [],
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryLookup onChange={() => {}} />);
});
wrapper.update();
expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);
expect(wrapper.find('InventoryLookup')).toHaveLength(1);
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(true);
});
});

View File

@@ -71,6 +71,16 @@ function MultiCredentialsLookup(props) {
loadCredentials(params, selectedType.id), loadCredentials(params, selectedType.id),
CredentialsAPI.readOptions(), 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 { return {
credentials: results, credentials: results,
credentialsCount: count, credentialsCount: count,
@@ -108,7 +118,6 @@ function MultiCredentialsLookup(props) {
credential={item} credential={item}
/> />
); );
const isVault = selectedType?.kind === 'vault'; const isVault = selectedType?.kind === 'vault';
return ( return (
@@ -187,6 +196,7 @@ function MultiCredentialsLookup(props) {
relatedSearchableKeys={relatedSearchableKeys} relatedSearchableKeys={relatedSearchableKeys}
multiple={isVault} multiple={isVault}
header={i18n._(t`Credentials`)} header={i18n._(t`Credentials`)}
displayKey={isVault ? 'label' : 'name'}
name="credentials" name="credentials"
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
readOnly={!canDelete} readOnly={!canDelete}

View File

@@ -87,6 +87,23 @@ describe('<MultiCredentialsLookup />', () => {
name: 'Cred 5', name: 'Cred 5',
url: 'www.google.com', 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, count: 3,
}, },
@@ -196,7 +213,13 @@ describe('<MultiCredentialsLookup />', () => {
wrapper.update(); wrapper.update();
expect(CredentialsAPI.read).toHaveBeenCalledTimes(2); expect(CredentialsAPI.read).toHaveBeenCalledTimes(2);
expect(wrapper.find('OptionsList').prop('options')).toEqual([ expect(wrapper.find('OptionsList').prop('options')).toEqual([
{ id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' }, {
id: 1,
kind: 'cloud',
name: 'New Cred',
url: 'www.google.com',
label: 'New Cred',
},
]); ]);
}); });
@@ -268,6 +291,36 @@ describe('<MultiCredentialsLookup />', () => {
]); ]);
}); });
test('should properly render vault credential labels', async () => {
await act(async () => {
wrapper = mountWithContexts(
<MultiCredentialsLookup
value={credentials}
tooltip="This is credentials look up"
onChange={() => {}}
onError={() => {}}
/>
);
});
const searchButton = await waitForElement(
wrapper,
'Button[aria-label="Search"]'
);
await act(async () => {
searchButton.invoke('onClick')();
});
wrapper.update();
const typeSelect = wrapper.find('AnsibleSelect');
act(() => {
typeSelect.invoke('onChange')({}, 500);
});
wrapper.update();
const optionsList = wrapper.find('OptionsList');
expect(optionsList.prop('multiple')).toEqual(true);
expect(wrapper.find('CheckboxListItem[label="Cred 6 | vault ID"]'));
expect(wrapper.find('CheckboxListItem[label="Cred 7"]'));
});
test('should allow multiple vault credentials with no vault id', async () => { test('should allow multiple vault credentials with no vault id', async () => {
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {

View File

@@ -18,6 +18,7 @@ const QS_CONFIG = getQSConfig('project', {
page: 1, page: 1,
page_size: 5, page_size: 5,
order_by: 'name', order_by: 'name',
role_level: 'use_role',
}); });
function ProjectLookup({ function ProjectLookup({
@@ -31,6 +32,7 @@ function ProjectLookup({
value, value,
onBlur, onBlur,
history, history,
isOverrideDisabled,
}) { }) {
const autoPopulateLookup = useAutoPopulateLookup(onChange); const autoPopulateLookup = useAutoPopulateLookup(onChange);
const { const {
@@ -57,8 +59,10 @@ function ProjectLookup({
searchableKeys: Object.keys( searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {} actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable), ).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]), }, [autoPopulate, autoPopulateLookup, history.location.search]),
{ {
count: 0, count: 0,
@@ -160,6 +164,7 @@ ProjectLookup.propTypes = {
required: bool, required: bool,
tooltip: string, tooltip: string,
value: Project, value: Project,
isOverrideDisabled: bool,
}; };
ProjectLookup.defaultProps = { ProjectLookup.defaultProps = {
@@ -170,6 +175,7 @@ ProjectLookup.defaultProps = {
required: false, required: false,
tooltip: '', tooltip: '',
value: null, value: null,
isOverrideDisabled: false,
}; };
export { ProjectLookup as _ProjectLookup }; export { ProjectLookup as _ProjectLookup };

View File

@@ -7,6 +7,10 @@ import ProjectLookup from './ProjectLookup';
jest.mock('../../api'); jest.mock('../../api');
describe('<ProjectLookup />', () => { describe('<ProjectLookup />', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('should auto-select project when only one available and autoPopulate prop is true', async () => { test('should auto-select project when only one available and autoPopulate prop is true', async () => {
ProjectsAPI.read.mockReturnValue({ ProjectsAPI.read.mockReturnValue({
data: { data: {
@@ -48,4 +52,46 @@ describe('<ProjectLookup />', () => {
}); });
expect(onChange).not.toHaveBeenCalled(); expect(onChange).not.toHaveBeenCalled();
}); });
test('project lookup should be enabled', async () => {
let wrapper;
ProjectsAPI.readOptions.mockReturnValue({
data: {
actions: {
GET: {},
},
related_search_fields: [],
},
});
await act(async () => {
wrapper = mountWithContexts(
<ProjectLookup isOverrideDisabled onChange={() => {}} />
);
});
wrapper.update();
expect(ProjectsAPI.read).toHaveBeenCalledTimes(1);
expect(wrapper.find('ProjectLookup')).toHaveLength(1);
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
});
test('project lookup should be disabled', async () => {
let wrapper;
ProjectsAPI.readOptions.mockReturnValue({
data: {
actions: {
GET: {},
},
related_search_fields: [],
},
});
await act(async () => {
wrapper = mountWithContexts(<ProjectLookup onChange={() => {}} />);
});
wrapper.update();
expect(ProjectsAPI.read).toHaveBeenCalledTimes(1);
expect(wrapper.find('ProjectLookup')).toHaveLength(1);
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(true);
});
}); });

View File

@@ -1,9 +1,10 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { DataList } from '@patternfly/react-core'; import { DataList } from '@patternfly/react-core';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { withRouter } from 'react-router-dom'; import { withRouter, useHistory, useLocation } from 'react-router-dom';
import ListHeader from '../ListHeader'; import ListHeader from '../ListHeader';
import ContentEmpty from '../ContentEmpty'; import ContentEmpty from '../ContentEmpty';
@@ -21,167 +22,155 @@ import {
import { QSConfig, SearchColumns, SortColumns } from '../../types'; import { QSConfig, SearchColumns, SortColumns } from '../../types';
import PaginatedDataListItem from './PaginatedDataListItem'; import PaginatedDataListItem from './PaginatedDataListItem';
import LoadingSpinner from '../LoadingSpinner';
class PaginatedDataList extends React.Component { function PaginatedDataList({
constructor(props) { items,
super(props); onRowClick,
this.handleSetPage = this.handleSetPage.bind(this); contentError,
this.handleSetPageSize = this.handleSetPageSize.bind(this); hasContentLoading,
this.handleListItemSelect = this.handleListItemSelect.bind(this); emptyStateControls,
} itemCount,
qsConfig,
handleListItemSelect = (id = 0) => { renderItem,
const { items, onRowClick } = this.props; 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)); const match = items.find(item => item.id === Number(id));
onRowClick(match); onRowClick(match);
}; };
handleSetPage(event, pageNumber) { const handleSetPage = (event, pageNumber) => {
const { history, qsConfig } = this.props;
const { search } = history.location;
const oldParams = parseQueryString(qsConfig, search); const oldParams = parseQueryString(qsConfig, search);
this.pushHistoryState(replaceParams(oldParams, { page: pageNumber })); pushHistoryState(replaceParams(oldParams, { page: pageNumber }));
} };
handleSetPageSize(event, pageSize, page) { const handleSetPageSize = (event, pageSize, page) => {
const { history, qsConfig } = this.props;
const { search } = history.location;
const oldParams = parseQueryString(qsConfig, search); const oldParams = parseQueryString(qsConfig, search);
this.pushHistoryState( pushHistoryState(replaceParams(oldParams, { page_size: pageSize, page }));
replaceParams(oldParams, { page_size: pageSize, page }) };
);
}
pushHistoryState(params) { const pushHistoryState = params => {
const { history, qsConfig } = this.props;
const { pathname } = history.location;
const encodedParams = encodeNonDefaultQueryString(qsConfig, params); const encodedParams = encodeNonDefaultQueryString(qsConfig, params);
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname); history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
} };
render() { const searchColumns = toolbarSearchColumns.length
const { ? toolbarSearchColumns
contentError, : [
hasContentLoading, {
emptyStateControls, name: i18n._(t`Name`),
items, key: 'name',
itemCount, isDefault: true,
qsConfig, },
renderItem, ];
toolbarSearchColumns, const sortColumns = toolbarSortColumns.length
toolbarSearchableKeys, ? toolbarSortColumns
toolbarRelatedSearchableKeys, : [
toolbarSortColumns, {
pluralizedItemName, name: i18n._(t`Name`),
showPageSizeOptions, key: 'name',
location, },
i18n, ];
renderToolbar, const queryParams = parseQueryString(qsConfig, location.search);
} = this.props;
const searchColumns = toolbarSearchColumns.length
? toolbarSearchColumns
: [
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
];
const sortColumns = toolbarSortColumns.length
? toolbarSortColumns
: [
{
name: i18n._(t`Name`),
key: 'name',
},
];
const queryParams = parseQueryString(qsConfig, location.search);
const dataListLabel = i18n._(t`${pluralizedItemName} List`); const dataListLabel = i18n._(t`${pluralizedItemName} List`);
const emptyContentMessage = i18n._( const emptyContentMessage = i18n._(
t`Please add ${pluralizedItemName} to populate this list ` t`Please add ${pluralizedItemName} to populate this list `
);
const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `);
let Content;
if (hasContentLoading && items.length <= 0) {
Content = <ContentLoading />;
} else if (contentError) {
Content = <ContentError error={contentError} />;
} else if (items.length <= 0) {
Content = (
<ContentEmpty title={emptyContentTitle} message={emptyContentMessage} />
); );
const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `); } else {
Content = (
let Content; <>
if (hasContentLoading && items.length <= 0) { {hasContentLoading && <LoadingSpinner />}
Content = <ContentLoading />;
} else if (contentError) {
Content = <ContentError error={contentError} />;
} else if (items.length <= 0) {
Content = (
<ContentEmpty title={emptyContentTitle} message={emptyContentMessage} />
);
} else {
Content = (
<DataList <DataList
aria-label={dataListLabel} aria-label={dataListLabel}
onSelectDataListItem={id => this.handleListItemSelect(id)} onSelectDataListItem={id => handleListItemSelect(id)}
> >
{items.map(renderItem)} {items.map(renderItem)}
</DataList> </DataList>
); </>
}
const ToolbarPagination = (
<Pagination
isCompact
dropDirection="down"
itemCount={itemCount}
page={queryParams.page || 1}
perPage={queryParams.page_size}
perPageOptions={
showPageSizeOptions
? [
{ title: '5', value: 5 },
{ title: '10', value: 10 },
{ title: '20', value: 20 },
{ title: '50', value: 50 },
]
: []
}
onSetPage={this.handleSetPage}
onPerPageSelect={this.handleSetPageSize}
/>
);
return (
<Fragment>
<ListHeader
itemCount={itemCount}
renderToolbar={renderToolbar}
emptyStateControls={emptyStateControls}
searchColumns={searchColumns}
sortColumns={sortColumns}
searchableKeys={toolbarSearchableKeys}
relatedSearchableKeys={toolbarRelatedSearchableKeys}
qsConfig={qsConfig}
pagination={ToolbarPagination}
/>
{Content}
{items.length ? (
<Pagination
variant="bottom"
itemCount={itemCount}
page={queryParams.page || 1}
perPage={queryParams.page_size}
perPageOptions={
showPageSizeOptions
? [
{ title: '5', value: 5 },
{ title: '10', value: 10 },
{ title: '20', value: 20 },
{ title: '50', value: 50 },
]
: []
}
onSetPage={this.handleSetPage}
onPerPageSelect={this.handleSetPageSize}
/>
) : null}
</Fragment>
); );
} }
const ToolbarPagination = (
<Pagination
isCompact
dropDirection="down"
itemCount={itemCount}
page={queryParams.page || 1}
perPage={queryParams.page_size}
perPageOptions={
showPageSizeOptions
? [
{ title: '5', value: 5 },
{ title: '10', value: 10 },
{ title: '20', value: 20 },
{ title: '50', value: 50 },
]
: []
}
onSetPage={handleSetPage}
onPerPageSelect={handleSetPageSize}
/>
);
return (
<Fragment>
<ListHeader
itemCount={itemCount}
renderToolbar={renderToolbar}
emptyStateControls={emptyStateControls}
searchColumns={searchColumns}
sortColumns={sortColumns}
searchableKeys={toolbarSearchableKeys}
relatedSearchableKeys={toolbarRelatedSearchableKeys}
qsConfig={qsConfig}
pagination={ToolbarPagination}
/>
{Content}
{items.length ? (
<Pagination
variant="bottom"
itemCount={itemCount}
page={queryParams.page || 1}
perPage={queryParams.page_size}
perPageOptions={
showPageSizeOptions
? [
{ title: '5', value: 5 },
{ title: '10', value: 10 },
{ title: '20', value: 20 },
{ title: '50', value: 50 },
]
: []
}
onSetPage={handleSetPage}
onPerPageSelect={handleSetPageSize}
/>
) : null}
</Fragment>
);
} }
const Item = PropTypes.shape({ const Item = PropTypes.shape({

View File

@@ -11,6 +11,7 @@ import ContentError from '../ContentError';
import ContentLoading from '../ContentLoading'; import ContentLoading from '../ContentLoading';
import Pagination from '../Pagination'; import Pagination from '../Pagination';
import DataListToolbar from '../DataListToolbar'; import DataListToolbar from '../DataListToolbar';
import LoadingSpinner from '../LoadingSpinner';
import { import {
encodeNonDefaultQueryString, encodeNonDefaultQueryString,
@@ -82,10 +83,13 @@ function PaginatedTable({
); );
} else { } else {
Content = ( Content = (
<TableComposable aria-label={dataListLabel}> <>
{headerRow} {hasContentLoading && <LoadingSpinner />}
<Tbody>{items.map(renderRow)}</Tbody> <TableComposable aria-label={dataListLabel}>
</TableComposable> {headerRow}
<Tbody>{items.map(renderRow)}</Tbody>
</TableComposable>
</>
); );
} }

View File

@@ -155,6 +155,7 @@ function ResourceAccessList({ i18n, apiModel, resource }) {
fetchAccessRecords(); fetchAccessRecords();
}} }}
roles={resource.summary_fields.object_roles} roles={resource.summary_fields.object_roles}
resource={resource}
/> />
)} )}
{showDeleteModal && ( {showDeleteModal && (

View File

@@ -62,7 +62,7 @@ function ScheduleList({
scheduleActions.data.actions?.GET || {} scheduleActions.data.actions?.GET || {}
).filter(key => scheduleActions.data.actions?.GET[key].filterable), ).filter(key => scheduleActions.data.actions?.GET[key].filterable),
}; };
}, [location, loadSchedules, loadScheduleOptions]), }, [location.search, loadSchedules, loadScheduleOptions]),
{ {
schedules: [], schedules: [],
itemCount: 0, itemCount: 0,

View File

@@ -31,7 +31,14 @@ const Description = styled.p`
font-size: 14px; font-size: 14px;
`; `;
function SelectableCard({ label, description, onClick, isSelected, dataCy }) { function SelectableCard({
label,
description,
onClick,
isSelected,
dataCy,
ariaLabel,
}) {
return ( return (
<SelectableItem <SelectableItem
onClick={onClick} onClick={onClick}
@@ -40,6 +47,7 @@ function SelectableCard({ label, description, onClick, isSelected, dataCy }) {
tabIndex="0" tabIndex="0"
data-cy={dataCy} data-cy={dataCy}
isSelected={isSelected} isSelected={isSelected}
aria-label={ariaLabel}
> >
<Indicator isSelected={isSelected} /> <Indicator isSelected={isSelected} />
<Contents> <Contents>
@@ -55,12 +63,14 @@ SelectableCard.propTypes = {
description: PropTypes.string, description: PropTypes.string,
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
isSelected: PropTypes.bool, isSelected: PropTypes.bool,
ariaLabel: PropTypes.string,
}; };
SelectableCard.defaultProps = { SelectableCard.defaultProps = {
label: '', label: '',
description: '', description: '',
isSelected: false, isSelected: false,
ariaLabel: '',
}; };
export default SelectableCard; export default SelectableCard;

View File

@@ -7,7 +7,11 @@ import { Button } from '@patternfly/react-core';
import useRequest, { useDismissableError } from '../../../util/useRequest'; import useRequest, { useDismissableError } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import { CardBody, CardActionsRow } from '../../../components/Card'; import { CardBody, CardActionsRow } from '../../../components/Card';
import { Detail, DetailList } from '../../../components/DetailList'; import {
Detail,
DetailList,
UserDateDetail,
} from '../../../components/DetailList';
import { ApplicationsAPI } from '../../../api'; import { ApplicationsAPI } from '../../../api';
import DeleteButton from '../../../components/DeleteButton'; import DeleteButton from '../../../components/DeleteButton';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
@@ -98,6 +102,11 @@ function ApplicationDetails({
value={getClientType(application.client_type)} value={getClientType(application.client_type)}
dataCy="app-detail-client-type" dataCy="app-detail-client-type"
/> />
<UserDateDetail label={i18n._(t`Created`)} date={application.created} />
<UserDateDetail
label={i18n._(t`Last Modified`)}
date={application.modified}
/>
</DetailList> </DetailList>
<CardActionsRow> <CardActionsRow>
{application.summary_fields.user_capabilities && {application.summary_fields.user_capabilities &&

View File

@@ -56,15 +56,12 @@ function Credential({ i18n, setBreadcrumb }) {
id: 99, id: 99,
}, },
{ name: i18n._(t`Details`), link: `/credentials/${id}/details`, id: 0 }, { name: i18n._(t`Details`), link: `/credentials/${id}/details`, id: 0 },
]; {
if (credential && credential.organization) {
tabsArray.push({
name: i18n._(t`Access`), name: i18n._(t`Access`),
link: `/credentials/${id}/access`, link: `/credentials/${id}/access`,
id: 1, id: 1,
}); },
} ];
let showCardHeader = true; let showCardHeader = true;
@@ -108,14 +105,12 @@ function Credential({ i18n, setBreadcrumb }) {
<Route key="edit" path="/credentials/:id/edit"> <Route key="edit" path="/credentials/:id/edit">
<CredentialEdit credential={credential} /> <CredentialEdit credential={credential} />
</Route>, </Route>,
credential.organization && ( <Route key="access" path="/credentials/:id/access">
<Route key="access" path="/credentials/:id/access"> <ResourceAccessList
<ResourceAccessList resource={credential}
resource={credential} apiModel={CredentialsAPI}
apiModel={CredentialsAPI} />
/> </Route>,
</Route>
),
<Route key="not-found" path="*"> <Route key="not-found" path="*">
{!hasContentLoading && ( {!hasContentLoading && (
<ContentError isNotFound> <ContentError isNotFound>

View File

@@ -31,7 +31,7 @@ describe('<Credential />', () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />); wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); 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 () => { test('initially renders org-based credential succesfully', async () => {

View File

@@ -78,7 +78,7 @@ function CredentialDetail({ i18n, credential }) {
{} {}
), ),
}; };
}, [credentialId, credential_type]), }, [credentialId, credential_type.id]),
{ {
fields: [], fields: [],
managedByTower: true, managedByTower: true,

View File

@@ -26,7 +26,13 @@ function CredentialList({ i18n }) {
const location = useLocation(); const location = useLocation();
const { const {
result: { credentials, credentialCount, actions }, result: {
credentials,
credentialCount,
actions,
relatedSearchableKeys,
searchableKeys,
},
error: contentError, error: contentError,
isLoading, isLoading,
request: fetchCredentials, request: fetchCredentials,
@@ -37,16 +43,29 @@ function CredentialList({ i18n }) {
CredentialsAPI.read(params), CredentialsAPI.read(params),
CredentialsAPI.readOptions(), 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 { return {
credentials: creds.data.results, credentials: creds.data.results,
credentialCount: creds.data.count, credentialCount: creds.data.count,
actions: credActions.data.actions, actions: credActions.data.actions,
relatedSearchableKeys: (
credActions?.data?.related_search_fields || []
).map(val => val.slice(0, -8)),
searchableKeys: searchKeys,
}; };
}, [location]), }, [location]),
{ {
credentials: [], credentials: [],
credentialCount: 0, credentialCount: 0,
actions: {}, actions: {},
relatedSearchableKeys: [],
searchableKeys: [],
} }
); );
@@ -102,6 +121,8 @@ function CredentialList({ i18n }) {
itemCount={credentialCount} itemCount={credentialCount}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
onRowClick={handleSelect} onRowClick={handleSelect}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
toolbarSearchColumns={[ toolbarSearchColumns={[
{ {
name: i18n._(t`Name`), name: i18n._(t`Name`),

View File

@@ -275,6 +275,11 @@
"type": "string", "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." "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", "id": "verify_ssl",
"label": "Verify SSL", "label": "Verify SSL",

View File

@@ -18,7 +18,7 @@ import DatalistToolbar from '../../../components/DataListToolbar';
import CredentialTypeListItem from './CredentialTypeListItem'; import CredentialTypeListItem from './CredentialTypeListItem';
const QS_CONFIG = getQSConfig('credential_type', { const QS_CONFIG = getQSConfig('credential-type', {
page: 1, page: 1,
page_size: 20, page_size: 20,
managed_by_tower: false, managed_by_tower: false,

View File

@@ -20,7 +20,7 @@ import useRequest from '../../util/useRequest';
import { DashboardAPI } from '../../api'; import { DashboardAPI } from '../../api';
import Breadcrumbs from '../../components/Breadcrumbs'; import Breadcrumbs from '../../components/Breadcrumbs';
import JobList from '../../components/JobList'; import JobList from '../../components/JobList';
import ContentLoading from '../../components/ContentLoading';
import LineChart from './shared/LineChart'; import LineChart from './shared/LineChart';
import Count from './shared/Count'; import Count from './shared/Count';
import DashboardTemplateList from './shared/DashboardTemplateList'; import DashboardTemplateList from './shared/DashboardTemplateList';
@@ -62,6 +62,7 @@ function Dashboard({ i18n }) {
const [activeTabId, setActiveTabId] = useState(0); const [activeTabId, setActiveTabId] = useState(0);
const { const {
isLoading,
result: { jobGraphData, countData }, result: { jobGraphData, countData },
request: fetchDashboardGraph, request: fetchDashboardGraph,
} = useRequest( } = useRequest(
@@ -105,7 +106,15 @@ function Dashboard({ i18n }) {
useEffect(() => { useEffect(() => {
fetchDashboardGraph(); fetchDashboardGraph();
}, [fetchDashboardGraph, periodSelection, jobTypeSelection]); }, [fetchDashboardGraph, periodSelection, jobTypeSelection]);
if (isLoading) {
return (
<PageSection>
<Card>
<ContentLoading />
</Card>
</PageSection>
);
}
return ( return (
<Fragment> <Fragment>
<Breadcrumbs breadcrumbConfig={{ '/home': i18n._(t`Dashboard`) }} /> <Breadcrumbs breadcrumbConfig={{ '/home': i18n._(t`Dashboard`) }} />

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { import {
@@ -20,29 +20,22 @@ import HostDetail from './HostDetail';
import HostEdit from './HostEdit'; import HostEdit from './HostEdit';
import HostGroups from './HostGroups'; import HostGroups from './HostGroups';
import { HostsAPI } from '../../api'; import { HostsAPI } from '../../api';
import useRequest from '../../util/useRequest';
function Host({ i18n, setBreadcrumb }) { function Host({ i18n, setBreadcrumb }) {
const [host, setHost] = useState(null);
const [contentError, setContentError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true);
const location = useLocation(); const location = useLocation();
const match = useRouteMatch('/hosts/:id'); 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(() => { useEffect(() => {
(async () => { fetchHost();
setContentError(null); }, [fetchHost, location]);
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]);
const tabsArray = [ const tabsArray = [
{ {
@@ -77,7 +70,7 @@ function Host({ i18n, setBreadcrumb }) {
}, },
]; ];
if (hasContentLoading) { if (isLoading) {
return ( return (
<PageSection> <PageSection>
<Card> <Card>
@@ -87,12 +80,12 @@ function Host({ i18n, setBreadcrumb }) {
); );
} }
if (contentError) { if (error) {
return ( return (
<PageSection> <PageSection>
<Card> <Card>
<ContentError error={contentError}> <ContentError error={error}>
{contentError?.response?.status === 404 && ( {error?.response?.status === 404 && (
<span> <span>
{i18n._(t`Host not found.`)}{' '} {i18n._(t`Host not found.`)}{' '}
<Link to="/hosts">{i18n._(t`View all Hosts.`)}</Link> <Link to="/hosts">{i18n._(t`View all Hosts.`)}</Link>

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { Route } from 'react-router-dom';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { HostsAPI } from '../../api'; import { HostsAPI } from '../../api';
@@ -28,7 +29,11 @@ describe('<Host />', () => {
beforeEach(async () => { beforeEach(async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<Host setBreadcrumb={() => {}} />); wrapper = mountWithContexts(
<Route path="/hosts/:id/details">
<Host setBreadcrumb={() => {}} />
</Route>
);
}); });
}); });

View File

@@ -83,7 +83,7 @@
"PWD": "/tmp/awx_13_r1ffeqze/project", "PWD": "/tmp/awx_13_r1ffeqze/project",
"HOME": "/var/lib/awx", "HOME": "/var/lib/awx",
"LANG": "\"en-us\"", "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", "SHLVL": "4",
"JOB_ID": "13", "JOB_ID": "13",
"LC_ALL": "en_US.UTF-8", "LC_ALL": "en_US.UTF-8",
@@ -96,9 +96,9 @@
"SDB_PORT": "7899", "SDB_PORT": "7899",
"MAKEFLAGS": "w", "MAKEFLAGS": "w",
"MAKELEVEL": "2", "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", "CURRENT_UID": "501",
"VIRTUAL_ENV": "/venv/ansible", "VIRTUAL_ENV": "/var/lib/awx/venv/ansible",
"INVENTORY_ID": "1", "INVENTORY_ID": "1",
"MAX_EVENT_RES": "700000", "MAX_EVENT_RES": "700000",
"PROOT_TMP_DIR": "/tmp", "PROOT_TMP_DIR": "/tmp",
@@ -106,7 +106,7 @@
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal", "SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
"AWX_GROUP_QUEUES": "tower", "AWX_GROUP_QUEUES": "tower",
"PROJECT_REVISION": "9e2cd25bfb26ba82f40cf31276e1942bf38b3a30", "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", "ANSIBLE_ROLES_PATH": "/tmp/awx_13_r1ffeqze/requirements_roles:~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles",
"RUNNER_OMIT_EVENTS": "False", "RUNNER_OMIT_EVENTS": "False",
"SUPERVISOR_ENABLED": "1", "SUPERVISOR_ENABLED": "1",
@@ -119,7 +119,7 @@
"DJANGO_SETTINGS_MODULE": "awx.settings.development", "DJANGO_SETTINGS_MODULE": "awx.settings.development",
"ANSIBLE_STDOUT_CALLBACK": "awx_display", "ANSIBLE_STDOUT_CALLBACK": "awx_display",
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher", "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_COLLECTIONS_PATHS": "/tmp/awx_13_r1ffeqze/requirements_collections:~/.ansible/collections:/usr/share/ansible/collections",
"ANSIBLE_HOST_KEY_CHECKING": "False", "ANSIBLE_HOST_KEY_CHECKING": "False",
"RUNNER_ONLY_FAILED_EVENTS": "False", "RUNNER_ONLY_FAILED_EVENTS": "False",

View File

@@ -123,7 +123,7 @@ describe('<ContainerGroupEdit/>', () => {
}); });
test('called InstanceGroupsAPI.readOptions', async () => { test('called InstanceGroupsAPI.readOptions', async () => {
expect(InstanceGroupsAPI.readOptions).toHaveBeenCalledTimes(1); expect(InstanceGroupsAPI.readOptions).toHaveBeenCalled();
}); });
test('handleCancel returns the user to container group detail', async () => { test('handleCancel returns the user to container group detail', async () => {

View File

@@ -18,7 +18,7 @@ import AddDropDownButton from '../../../components/AddDropDownButton';
import InstanceGroupListItem from './InstanceGroupListItem'; import InstanceGroupListItem from './InstanceGroupListItem';
const QS_CONFIG = getQSConfig('instance_group', { const QS_CONFIG = getQSConfig('instance-group', {
page: 1, page: 1,
page_size: 20, page_size: 20,
}); });

View File

@@ -58,7 +58,7 @@ describe('InventorySourceDetail', () => {
assertDetail(wrapper, 'Description', 'mock description'); assertDetail(wrapper, 'Description', 'mock description');
assertDetail(wrapper, 'Source', 'Sourced from a Project'); assertDetail(wrapper, 'Source', 'Sourced from a Project');
assertDetail(wrapper, 'Organization', 'Mock Org'); 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, 'Project', 'Mock Project');
assertDetail(wrapper, 'Inventory file', 'foo'); assertDetail(wrapper, 'Inventory file', 'foo');
assertDetail(wrapper, 'Verbosity', '2 (Debug)'); assertDetail(wrapper, 'Verbosity', '2 (Debug)');

View File

@@ -55,7 +55,7 @@ const InventorySourceFormFields = ({ source, sourceOptions, i18n }) => {
const [venvField] = useField('custom_virtualenv'); const [venvField] = useField('custom_virtualenv');
const defaultVenv = { const defaultVenv = {
label: i18n._(t`Use Default Ansible Environment`), label: i18n._(t`Use Default Ansible Environment`),
value: '/venv/ansible/', value: '/var/lib/awx/venv/ansible/',
key: 'default', key: 'default',
}; };

View File

@@ -83,7 +83,7 @@
"PWD": "/tmp/awx_13_r1ffeqze/project", "PWD": "/tmp/awx_13_r1ffeqze/project",
"HOME": "/var/lib/awx", "HOME": "/var/lib/awx",
"LANG": "\"en-us\"", "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", "SHLVL": "4",
"JOB_ID": "13", "JOB_ID": "13",
"LC_ALL": "en_US.UTF-8", "LC_ALL": "en_US.UTF-8",
@@ -96,9 +96,9 @@
"SDB_PORT": "7899", "SDB_PORT": "7899",
"MAKEFLAGS": "w", "MAKEFLAGS": "w",
"MAKELEVEL": "2", "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", "CURRENT_UID": "501",
"VIRTUAL_ENV": "/venv/ansible", "VIRTUAL_ENV": "/var/lib/awx/venv/ansible",
"INVENTORY_ID": "1", "INVENTORY_ID": "1",
"MAX_EVENT_RES": "700000", "MAX_EVENT_RES": "700000",
"PROOT_TMP_DIR": "/tmp", "PROOT_TMP_DIR": "/tmp",
@@ -106,7 +106,7 @@
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal", "SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
"AWX_GROUP_QUEUES": "tower", "AWX_GROUP_QUEUES": "tower",
"PROJECT_REVISION": "9e2cd25bfb26ba82f40cf31276e1942bf38b3a30", "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", "ANSIBLE_ROLES_PATH": "/tmp/awx_13_r1ffeqze/requirements_roles:~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles",
"RUNNER_OMIT_EVENTS": "False", "RUNNER_OMIT_EVENTS": "False",
"SUPERVISOR_ENABLED": "1", "SUPERVISOR_ENABLED": "1",
@@ -119,7 +119,7 @@
"DJANGO_SETTINGS_MODULE": "awx.settings.development", "DJANGO_SETTINGS_MODULE": "awx.settings.development",
"ANSIBLE_STDOUT_CALLBACK": "awx_display", "ANSIBLE_STDOUT_CALLBACK": "awx_display",
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher", "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_COLLECTIONS_PATHS": "/tmp/awx_13_r1ffeqze/requirements_collections:~/.ansible/collections:/usr/share/ansible/collections",
"ANSIBLE_HOST_KEY_CHECKING": "False", "ANSIBLE_HOST_KEY_CHECKING": "False",
"RUNNER_ONLY_FAILED_EVENTS": "False", "RUNNER_ONLY_FAILED_EVENTS": "False",

View File

@@ -98,7 +98,7 @@
"credential": 8, "credential": 8,
"overwrite":true, "overwrite":true,
"overwrite_vars":true, "overwrite_vars":true,
"custom_virtualenv":"/venv/custom", "custom_virtualenv":"/var/lib/awx/venv/custom",
"timeout":0, "timeout":0,
"verbosity":2, "verbosity":2,
"last_job_run":null, "last_job_run":null,

View File

@@ -29,10 +29,18 @@ function Job({ i18n, setBreadcrumb }) {
const { isLoading, error, request: fetchJob, result } = useRequest( const { isLoading, error, request: fetchJob, result } = useRequest(
useCallback(async () => { useCallback(async () => {
const { data } = await JobsAPI.readDetail(id, type); 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); setBreadcrumb(data);
return data; return data;
}, [id, type, setBreadcrumb]), }, [id, type, setBreadcrumb])
null
); );
useEffect(() => { useEffect(() => {

View File

@@ -7,7 +7,11 @@ import { Button, Chip, Label } from '@patternfly/react-core';
import styled from 'styled-components'; import styled from 'styled-components';
import AlertModal from '../../../components/AlertModal'; 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 { CardBody, CardActionsRow } from '../../../components/Card';
import ChipGroup from '../../../components/ChipGroup'; import ChipGroup from '../../../components/ChipGroup';
import CredentialChip from '../../../components/CredentialChip'; import CredentialChip from '../../../components/CredentialChip';
@@ -80,6 +84,7 @@ const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => {
function JobDetail({ job, i18n }) { function JobDetail({ job, i18n }) {
const { const {
created_by,
credential, credential,
credentials, credentials,
instance_group: instanceGroup, instance_group: instanceGroup,
@@ -289,6 +294,12 @@ function JobDetail({ job, i18n }) {
} }
/> />
)} )}
<UserDateDetail
label={i18n._(t`Created`)}
date={job.created}
user={created_by}
/>
<UserDateDetail label={i18n._(t`Last Modified`)} date={job.modified} />
</DetailList> </DetailList>
{job.extra_vars && ( {job.extra_vars && (
<VariablesInput <VariablesInput

View File

@@ -114,7 +114,7 @@
"started": "2019-08-08T19:24:18.329589Z", "started": "2019-08-08T19:24:18.329589Z",
"finished": "2019-08-08T19:24:50.119995Z", "finished": "2019-08-08T19:24:50.119995Z",
"elapsed": 31.79, "elapsed": 31.79,
"job_args": "[\"bwrap\", \"--unshare-pid\", \"--dev-bind\", \"/\", \"/\", \"--proc\", \"/proc\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpvsg8ly2y\", \"/etc/ssh\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq_grmdym\", \"/projects\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpfq8ea2z6\", \"/tmp\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq6v4y_tt\", \"/var/lib/awx\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpupj_jhhb\", \"/var/log\", \"--ro-bind\", \"/venv/ansible\", \"/venv/ansible\", \"--ro-bind\", \"/venv/awx\", \"/venv/awx\", \"--bind\", \"/projects/_6__demo_project\", \"/projects/_6__demo_project\", \"--bind\", \"/tmp/awx_2_a4b1afiw\", \"/tmp/awx_2_a4b1afiw\", \"--chdir\", \"/projects/_6__demo_project\", \"ansible-playbook\", \"-u\", \"admin\", \"-i\", \"/tmp/awx_2_a4b1afiw/tmppb57i4_e\", \"-e\", \"@/tmp/awx_2_a4b1afiw/env/extravars\", \"chatty_tasks.yml\"]", "job_args": "[\"bwrap\", \"--unshare-pid\", \"--dev-bind\", \"/\", \"/\", \"--proc\", \"/proc\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpvsg8ly2y\", \"/etc/ssh\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq_grmdym\", \"/projects\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpfq8ea2z6\", \"/tmp\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq6v4y_tt\", \"/var/lib/awx\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpupj_jhhb\", \"/var/log\", \"--ro-bind\", \"/var/lib/awx/venv/ansible\", \"/var/lib/awx/venv/ansible\", \"--ro-bind\", \"/var/lib/awx/venv/awx\", \"/var/lib/awx/venv/awx\", \"--bind\", \"/projects/_6__demo_project\", \"/projects/_6__demo_project\", \"--bind\", \"/tmp/awx_2_a4b1afiw\", \"/tmp/awx_2_a4b1afiw\", \"--chdir\", \"/projects/_6__demo_project\", \"ansible-playbook\", \"-u\", \"admin\", \"-i\", \"/tmp/awx_2_a4b1afiw/tmppb57i4_e\", \"-e\", \"@/tmp/awx_2_a4b1afiw/env/extravars\", \"chatty_tasks.yml\"]",
"job_cwd": "/projects/_6__demo_project", "job_cwd": "/projects/_6__demo_project",
"job_env": { "job_env": {
"HOSTNAME": "awx", "HOSTNAME": "awx",
@@ -123,9 +123,9 @@
"LC_ALL": "en_US.UTF-8", "LC_ALL": "en_US.UTF-8",
"SDB_HOST": "0.0.0.0", "SDB_HOST": "0.0.0.0",
"MAKELEVEL": "2", "MAKELEVEL": "2",
"VIRTUAL_ENV": "/venv/ansible", "VIRTUAL_ENV": "/var/lib/awx/venv/ansible",
"MFLAGS": "-w", "MFLAGS": "-w",
"PATH": "/venv/ansible/bin:/venv/awx/bin:/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "PATH": "/var/lib/awx/venv/ansible/bin:/var/lib/awx/venv/awx/bin:/var/lib/awx/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"SUPERVISOR_GROUP_NAME": "tower-processes", "SUPERVISOR_GROUP_NAME": "tower-processes",
"PWD": "/awx_devel", "PWD": "/awx_devel",
"LANG": "\"en-us\"", "LANG": "\"en-us\"",
@@ -138,7 +138,7 @@
"SUPERVISOR_SERVER_URL": "unix:///tmp/supervisor.sock", "SUPERVISOR_SERVER_URL": "unix:///tmp/supervisor.sock",
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher", "SUPERVISOR_PROCESS_NAME": "awx-dispatcher",
"CURRENT_UID": "501", "CURRENT_UID": "501",
"_": "/venv/awx/bin/python3", "_": "/var/lib/awx/venv/awx/bin/python3",
"DJANGO_SETTINGS_MODULE": "awx.settings.development", "DJANGO_SETTINGS_MODULE": "awx.settings.development",
"DJANGO_LIVE_TEST_SERVER_ADDRESS": "localhost:9013-9199", "DJANGO_LIVE_TEST_SERVER_ADDRESS": "localhost:9013-9199",
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal", "SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
@@ -147,11 +147,11 @@
"ANSIBLE_HOST_KEY_CHECKING": "False", "ANSIBLE_HOST_KEY_CHECKING": "False",
"ANSIBLE_INVENTORY_UNPARSED_FAILED": "True", "ANSIBLE_INVENTORY_UNPARSED_FAILED": "True",
"ANSIBLE_PARAMIKO_RECORD_HOST_KEYS": "False", "ANSIBLE_PARAMIKO_RECORD_HOST_KEYS": "False",
"ANSIBLE_VENV_PATH": "/venv/ansible", "ANSIBLE_VENV_PATH": "/var/lib/awx/venv/ansible",
"PROOT_TMP_DIR": "/tmp", "PROOT_TMP_DIR": "/tmp",
"AWX_PRIVATE_DATA_DIR": "/tmp/awx_2_a4b1afiw", "AWX_PRIVATE_DATA_DIR": "/tmp/awx_2_a4b1afiw",
"ANSIBLE_COLLECTIONS_PATHS": "/tmp/collections", "ANSIBLE_COLLECTIONS_PATHS": "/tmp/collections",
"PYTHONPATH": "/venv/ansible/lib/python2.7/site-packages:/awx_devel/awx/lib:", "PYTHONPATH": "/var/lib/awx/venv/ansible/lib/python2.7/site-packages:/awx_devel/awx/lib:",
"JOB_ID": "2", "JOB_ID": "2",
"INVENTORY_ID": "1", "INVENTORY_ID": "1",
"PROJECT_REVISION": "23f070aad8e2da131d97ea98b42b553ccf0b0b82", "PROJECT_REVISION": "23f070aad8e2da131d97ea98b42b553ccf0b0b82",
@@ -184,5 +184,5 @@
"play_count": 1, "play_count": 1,
"task_count": 1 "task_count": 1
}, },
"custom_virtualenv": "/venv/ansible" "custom_virtualenv": "/var/lib/awx/venv/ansible"
} }

View File

@@ -10,6 +10,7 @@ import {
ArrayDetail, ArrayDetail,
DetailList, DetailList,
DeletedDetail, DeletedDetail,
UserDateDetail,
} from '../../../components/DetailList'; } from '../../../components/DetailList';
import CodeDetail from '../../../components/DetailList/CodeDetail'; import CodeDetail from '../../../components/DetailList/CodeDetail';
import DeleteButton from '../../../components/DeleteButton'; import DeleteButton from '../../../components/DeleteButton';
@@ -23,6 +24,8 @@ function NotificationTemplateDetail({ i18n, template, defaultMessages }) {
const history = useHistory(); const history = useHistory();
const { const {
created,
modified,
notification_configuration: configuration, notification_configuration: configuration,
summary_fields, summary_fields,
messages, messages,
@@ -324,6 +327,16 @@ function NotificationTemplateDetail({ i18n, template, defaultMessages }) {
/> />
</> </>
)} )}
<UserDateDetail
label={i18n._(t`Created`)}
date={created}
user={summary_fields?.created_by}
/>
<UserDateDetail
label={i18n._(t`Last Modified`)}
date={modified}
user={summary_fields?.modified_by}
/>
{hasCustomMessages(messages, typeMessageDefaults) && ( {hasCustomMessages(messages, typeMessageDefaults) && (
<CustomMessageDetails <CustomMessageDetails
messages={messages} messages={messages}

View File

@@ -153,7 +153,7 @@ describe('<OrganizationAdd />', () => {
.find('FormSelectOption') .find('FormSelectOption')
.first() .first()
.prop('value') .prop('value')
).toEqual('/venv/ansible/'); ).toEqual('/var/lib/awx/venv/ansible/');
}); });
test('AnsibleSelect component does not render if there are 0 virtual environments', async () => { test('AnsibleSelect component does not render if there are 0 virtual environments', async () => {

View File

@@ -31,7 +31,7 @@ function OrganizationFormFields({ i18n, instanceGroups, setInstanceGroups }) {
const defaultVenv = { const defaultVenv = {
label: i18n._(t`Use Default Ansible Environment`), label: i18n._(t`Use Default Ansible Environment`),
value: '/venv/ansible/', value: '/var/lib/awx/venv/ansible/',
key: 'default', key: 'default',
}; };
const { custom_virtualenvs } = useContext(ConfigContext); const { custom_virtualenvs } = useContext(ConfigContext);

View File

@@ -200,7 +200,7 @@ describe('<OrganizationForm />', () => {
.find('FormSelectOption') .find('FormSelectOption')
.first() .first()
.prop('value') .prop('value')
).toEqual('/venv/ansible/'); ).toEqual('/var/lib/awx/venv/ansible/');
}); });
test('onSubmit associates and disassociates instance groups', async () => { test('onSubmit associates and disassociates instance groups', async () => {

View File

@@ -44,6 +44,19 @@ function Project({ i18n, setBreadcrumb }) {
role_level: 'notification_admin_role', 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 { return {
project: data, project: data,
isNotifAdmin: notifAdminRes.data.results.length > 0, isNotifAdmin: notifAdminRes.data.results.length > 0,

View File

@@ -24,7 +24,7 @@ describe('<ProjectAdd />', () => {
scm_update_on_launch: true, scm_update_on_launch: true,
scm_update_cache_timeout: 3, scm_update_cache_timeout: 3,
allow_override: false, allow_override: false,
custom_virtualenv: '/venv/custom-env', custom_virtualenv: '/var/lib/awx/venv/custom-env',
}; };
const projectOptionsResolve = { const projectOptionsResolve = {

View File

@@ -19,6 +19,7 @@ import CredentialChip from '../../../components/CredentialChip';
import { ProjectsAPI } from '../../../api'; import { ProjectsAPI } from '../../../api';
import { toTitleCase } from '../../../util/strings'; import { toTitleCase } from '../../../util/strings';
import useRequest, { useDismissableError } from '../../../util/useRequest'; import useRequest, { useDismissableError } from '../../../util/useRequest';
import ProjectSyncButton from '../shared/ProjectSyncButton';
function ProjectDetail({ project, i18n }) { function ProjectDetail({ project, i18n }) {
const { const {
@@ -148,27 +149,28 @@ function ProjectDetail({ project, i18n }) {
/> />
</DetailList> </DetailList>
<CardActionsRow> <CardActionsRow>
{summary_fields.user_capabilities && {summary_fields.user_capabilities?.edit && (
summary_fields.user_capabilities.edit && ( <Button
<Button aria-label={i18n._(t`edit`)}
aria-label={i18n._(t`edit`)} component={Link}
component={Link} to={`/projects/${id}/edit`}
to={`/projects/${id}/edit`} >
> {i18n._(t`Edit`)}
{i18n._(t`Edit`)} </Button>
</Button> )}
)} {summary_fields.user_capabilities?.start && (
{summary_fields.user_capabilities && <ProjectSyncButton projectId={project.id} />
summary_fields.user_capabilities.delete && ( )}
<DeleteButton {summary_fields.user_capabilities?.delete && (
name={name} <DeleteButton
modalTitle={i18n._(t`Delete Project`)} name={name}
onConfirm={deleteProject} modalTitle={i18n._(t`Delete Project`)}
isDisabled={isLoading} onConfirm={deleteProject}
> isDisabled={isLoading}
{i18n._(t`Delete`)} >
</DeleteButton> {i18n._(t`Delete`)}
)} </DeleteButton>
)}
</CardActionsRow> </CardActionsRow>
{/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */} {/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */}
{error && ( {error && (

View File

@@ -9,7 +9,12 @@ import { ProjectsAPI } from '../../../api';
import ProjectDetail from './ProjectDetail'; import ProjectDetail from './ProjectDetail';
jest.mock('../../../api'); jest.mock('../../../api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useRouteMatch: () => ({
url: '/projects/1/details',
}),
}));
describe('<ProjectDetail />', () => { describe('<ProjectDetail />', () => {
const mockProject = { const mockProject = {
id: 1, id: 1,
@@ -139,13 +144,19 @@ describe('<ProjectDetail />', () => {
); );
}); });
test('should show edit button for users with edit permission', async () => { test('should show edit and sync button for users with edit permission', async () => {
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />); const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
const editButton = await waitForElement( const editButton = await waitForElement(
wrapper, wrapper,
'ProjectDetail Button[aria-label="edit"]' 'ProjectDetail Button[aria-label="edit"]'
); );
const syncButton = await waitForElement(
wrapper,
'ProjectDetail Button[aria-label="Sync Project"]'
);
expect(editButton.text()).toEqual('Edit'); expect(editButton.text()).toEqual('Edit');
expect(syncButton.text()).toEqual('Sync');
expect(editButton.prop('to')).toBe(`/projects/${mockProject.id}/edit`); expect(editButton.prop('to')).toBe(`/projects/${mockProject.id}/edit`);
}); });
@@ -166,6 +177,9 @@ describe('<ProjectDetail />', () => {
expect(wrapper.find('ProjectDetail Button[aria-label="edit"]').length).toBe( expect(wrapper.find('ProjectDetail Button[aria-label="edit"]').length).toBe(
0 0
); );
expect(wrapper.find('ProjectDetail Button[aria-label="sync"]').length).toBe(
0
);
}); });
test('edit button should navigate to project edit', () => { test('edit button should navigate to project edit', () => {
@@ -180,6 +194,17 @@ describe('<ProjectDetail />', () => {
expect(history.location.pathname).toEqual('/projects/1/edit'); expect(history.location.pathname).toEqual('/projects/1/edit');
}); });
test('sync button should call api to syn project', async () => {
ProjectsAPI.readSync.mockResolvedValue({ data: { can_update: true } });
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
await act(() =>
wrapper
.find('ProjectDetail Button[aria-label="Sync Project"]')
.prop('onClick')(1)
);
expect(ProjectsAPI.sync).toHaveBeenCalledTimes(1);
});
test('expected api calls are made for delete', async () => { test('expected api calls are made for delete', async () => {
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />); const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
await waitForElement(wrapper, 'ProjectDetail Button[aria-label="Delete"]'); await waitForElement(wrapper, 'ProjectDetail Button[aria-label="Delete"]');

View File

@@ -25,7 +25,7 @@ describe('<ProjectEdit />', () => {
scm_update_on_launch: true, scm_update_on_launch: true,
scm_update_cache_timeout: 3, scm_update_cache_timeout: 3,
allow_override: false, allow_override: false,
custom_virtualenv: '/venv/custom-env', custom_virtualenv: '/var/lib/awx/venv/custom-env',
summary_fields: { summary_fields: {
credential: { credential: {
id: 100, id: 100,

View File

@@ -14,7 +14,7 @@ import {
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Link } from 'react-router-dom'; 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 styled from 'styled-components';
import { formatDateString, timeOfDay } from '../../../util/dates'; import { formatDateString, timeOfDay } from '../../../util/dates';
import { ProjectsAPI } from '../../../api'; import { ProjectsAPI } from '../../../api';
@@ -153,23 +153,10 @@ function ProjectListItem({
aria-labelledby={labelId} aria-labelledby={labelId}
id={labelId} id={labelId}
> >
{project.summary_fields.user_capabilities.start ? ( {project.summary_fields.user_capabilities.start && (
<Tooltip content={i18n._(t`Sync Project`)} position="top"> <Tooltip content={i18n._(t`Sync Project`)} position="top">
<ProjectSyncButton projectId={project.id}> <ProjectSyncButton projectId={project.id} />
{handleSync => (
<Button
isDisabled={isDisabled}
aria-label={i18n._(t`Sync Project`)}
variant="plain"
onClick={handleSync}
>
<SyncIcon />
</Button>
)}
</ProjectSyncButton>
</Tooltip> </Tooltip>
) : (
''
)} )}
{project.summary_fields.user_capabilities.edit ? ( {project.summary_fields.user_capabilities.edit ? (
<Tooltip content={i18n._(t`Edit Project`)} position="top"> <Tooltip content={i18n._(t`Edit Project`)} position="top">

View File

@@ -284,11 +284,11 @@ function ProjectFormFields({
data={[ data={[
{ {
label: i18n._(t`Use Default Ansible Environment`), label: i18n._(t`Use Default Ansible Environment`),
value: '/venv/ansible/', value: '/var/lib/awx/venv/ansible/',
key: 'default', key: 'default',
}, },
...custom_virtualenvs ...custom_virtualenvs
.filter(datum => datum !== '/venv/ansible/') .filter(datum => datum !== '/var/lib/awx/venv/ansible/')
.map(datum => ({ .map(datum => ({
label: datum, label: datum,
value: datum, value: datum,

View File

@@ -22,7 +22,7 @@ describe('<ProjectForm />', () => {
scm_update_on_launch: true, scm_update_on_launch: true,
scm_update_cache_timeout: 3, scm_update_cache_timeout: 3,
allow_override: false, allow_override: false,
custom_virtualenv: '/venv/custom-env', custom_virtualenv: '/var/lib/awx/venv/custom-env',
summary_fields: { summary_fields: {
credential: { credential: {
id: 100, id: 100,

View File

@@ -1,4 +1,8 @@
import React, { useCallback } from 'react'; 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 { number } from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -8,28 +12,27 @@ import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
import { ProjectsAPI } from '../../../api'; import { ProjectsAPI } from '../../../api';
function ProjectSyncButton({ i18n, children, projectId }) { function ProjectSyncButton({ i18n, projectId }) {
const match = useRouteMatch();
const { request: handleSync, error: syncError } = useRequest( const { request: handleSync, error: syncError } = useRequest(
useCallback(async () => { useCallback(async () => {
const { data } = await ProjectsAPI.readSync(projectId); await ProjectsAPI.sync(projectId);
if (data.can_update) { }, [projectId]),
await ProjectsAPI.sync(projectId);
} else {
throw new Error(
i18n._(
t`You don't have the necessary permissions to sync this project.`
)
);
}
}, [i18n, projectId]),
null null
); );
const { error, dismissError } = useDismissableError(syncError); const { error, dismissError } = useDismissableError(syncError);
const isDetailsView = match.url.endsWith('/details');
return ( return (
<> <>
{children(handleSync)} <Button
aria-label={i18n._(t`Sync Project`)}
variant={isDetailsView ? 'secondary' : 'plain'}
onClick={handleSync}
>
{match.url.endsWith('/details') ? i18n._(t`Sync`) : <SyncIcon />}
</Button>
{error && ( {error && (
<AlertModal <AlertModal
isOpen={error} isOpen={error}

View File

@@ -10,11 +10,6 @@ jest.mock('../../../api');
describe('ProjectSyncButton', () => { describe('ProjectSyncButton', () => {
let wrapper; let wrapper;
ProjectsAPI.readSync.mockResolvedValue({
data: {
can_update: true,
},
});
const children = handleSync => ( const children = handleSync => (
<button type="submit" onClick={() => handleSync()} /> <button type="submit" onClick={() => handleSync()} />
@@ -43,8 +38,7 @@ describe('ProjectSyncButton', () => {
await act(async () => { await act(async () => {
button.prop('onClick')(); button.prop('onClick')();
}); });
expect(ProjectsAPI.readSync).toHaveBeenCalledWith(1);
await sleep(0);
expect(ProjectsAPI.sync).toHaveBeenCalledWith(1); expect(ProjectsAPI.sync).toHaveBeenCalledWith(1);
}); });
test('displays error modal after unsuccessful sync', async () => { test('displays error modal after unsuccessful sync', async () => {

View File

@@ -86,6 +86,7 @@ function ActivityStreamDetail({ i18n }) {
<Button <Button
aria-label={i18n._(t`Edit`)} aria-label={i18n._(t`Edit`)}
component={Link} component={Link}
ouiaId="edit-button"
to="/settings/activity_stream/edit" to="/settings/activity_stream/edit"
> >
{i18n._(t`Edit`)} {i18n._(t`Edit`)}

View File

@@ -78,6 +78,7 @@ function AzureADDetail({ i18n }) {
<Button <Button
aria-label={i18n._(t`Edit`)} aria-label={i18n._(t`Edit`)}
component={Link} component={Link}
ouiaId="edit-button"
to="/settings/azure/edit" to="/settings/azure/edit"
> >
{i18n._(t`Edit`)} {i18n._(t`Edit`)}

View File

@@ -6,6 +6,8 @@ import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError'; import ContentError from '../../../components/ContentError';
import GitHubDetail from './GitHubDetail'; import GitHubDetail from './GitHubDetail';
import GitHubEdit from './GitHubEdit'; import GitHubEdit from './GitHubEdit';
import GitHubOrgEdit from './GitHubOrgEdit';
import GitHubTeamEdit from './GitHubTeamEdit';
function GitHub({ i18n }) { function GitHub({ i18n }) {
const baseURL = '/settings/github'; const baseURL = '/settings/github';
@@ -29,9 +31,15 @@ function GitHub({ i18n }) {
<Route path={`${baseURL}/:category/details`}> <Route path={`${baseURL}/:category/details`}>
<GitHubDetail /> <GitHubDetail />
</Route> </Route>
<Route path={`${baseURL}/:category/edit`}> <Route path={`${baseURL}/default/edit`}>
<GitHubEdit /> <GitHubEdit />
</Route> </Route>
<Route path={`${baseURL}/organization/edit`}>
<GitHubOrgEdit />
</Route>
<Route path={`${baseURL}/team/edit`}>
<GitHubTeamEdit />
</Route>
<Route key="not-found" path={`${baseURL}/*`}> <Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound> <ContentError isNotFound>
<Link to={`${baseURL}/default/details`}> <Link to={`${baseURL}/default/details`}>

View File

@@ -5,33 +5,94 @@ import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
} from '../../../../testUtils/enzymeHelpers'; } from '../../../../testUtils/enzymeHelpers';
import GitHub from './GitHub';
import { SettingsAPI } from '../../../api'; import { SettingsAPI } from '../../../api';
import { SettingsProvider } from '../../../contexts/Settings';
import mockAllOptions from '../shared/data.allSettingOptions.json';
import GitHub from './GitHub';
jest.mock('../../../api/models/Settings'); jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<GitHub />', () => { describe('<GitHub />', () => {
let wrapper; let wrapper;
beforeEach(() => {
SettingsAPI.readCategory.mockResolvedValueOnce({
data: {
SOCIAL_AUTH_GITHUB_CALLBACK_URL:
'https://towerhost/sso/complete/github/',
SOCIAL_AUTH_GITHUB_KEY: 'mock github key',
SOCIAL_AUTH_GITHUB_SECRET: '$encrypted$',
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: null,
SOCIAL_AUTH_GITHUB_TEAM_MAP: null,
},
});
SettingsAPI.readCategory.mockResolvedValueOnce({
data: {
SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL:
'https://towerhost/sso/complete/github-org/',
SOCIAL_AUTH_GITHUB_ORG_KEY: '',
SOCIAL_AUTH_GITHUB_ORG_SECRET: '$encrypted$',
SOCIAL_AUTH_GITHUB_ORG_NAME: '',
SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null,
SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
},
});
SettingsAPI.readCategory.mockResolvedValueOnce({
data: {
SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL:
'https://towerhost/sso/complete/github-team/',
SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)',
SOCIAL_AUTH_GITHUB_TEAM_SECRET: '$encrypted$',
SOCIAL_AUTH_GITHUB_TEAM_ID: 'team_id',
SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {},
SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
},
});
});
afterEach(() => { afterEach(() => {
wrapper.unmount(); wrapper.unmount();
jest.clearAllMocks(); jest.clearAllMocks();
}); });
test('should render github details', async () => { test('should render github default details', async () => {
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['/settings/github/'], initialEntries: ['/settings/github/'],
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<GitHub />, { wrapper = mountWithContexts(
context: { router: { history } }, <SettingsProvider value={mockAllOptions.actions}>
}); <GitHub />
</SettingsProvider>,
{
context: { router: { history } },
}
);
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('GitHubDetail').length).toBe(1); expect(wrapper.find('GitHubDetail').length).toBe(1);
expect(wrapper.find('Detail[label="GitHub OAuth2 Key"]').length).toBe(1);
});
test('should redirect to github organization category details', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/github/organization'],
});
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GitHub />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('GitHubDetail').length).toBe(1);
expect(
wrapper.find('Detail[label="GitHub Organization OAuth2 Key"]').length
).toBe(1);
}); });
test('should render github edit', async () => { test('should render github edit', async () => {
@@ -39,9 +100,14 @@ describe('<GitHub />', () => {
initialEntries: ['/settings/github/default/edit'], initialEntries: ['/settings/github/default/edit'],
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<GitHub />, { wrapper = mountWithContexts(
context: { router: { history } }, <SettingsProvider value={mockAllOptions.actions}>
}); <GitHub />
</SettingsProvider>,
{
context: { router: { history } },
}
);
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('GitHubEdit').length).toBe(1); expect(wrapper.find('GitHubEdit').length).toBe(1);
@@ -52,9 +118,14 @@ describe('<GitHub />', () => {
initialEntries: ['/settings/github/foo/bar'], initialEntries: ['/settings/github/foo/bar'],
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<GitHub />, { wrapper = mountWithContexts(
context: { router: { history } }, <SettingsProvider value={mockAllOptions.actions}>
}); <GitHub />
</SettingsProvider>,
{
context: { router: { history } },
}
);
}); });
expect(wrapper.find('ContentError').length).toBe(1); expect(wrapper.find('ContentError').length).toBe(1);
}); });

View File

@@ -114,6 +114,7 @@ function GitHubDetail({ i18n }) {
<Button <Button
aria-label={i18n._(t`Edit`)} aria-label={i18n._(t`Edit`)}
component={Link} component={Link}
ouiaId="edit-button"
to={`${baseURL}/${category}/edit`} to={`${baseURL}/${category}/edit`}
> >
{i18n._(t`Edit`)} {i18n._(t`Edit`)}

View File

@@ -1,25 +1,141 @@
import React from 'react'; import React, { useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { Formik } from 'formik';
import { t } from '@lingui/macro'; import { Form } from '@patternfly/react-core';
import { Button } from '@patternfly/react-core'; import { CardBody } from '../../../../components/Card';
import { CardBody, CardActionsRow } from '../../../../components/Card'; import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { FormSubmitError } from '../../../../components/FormField';
import { FormColumnLayout } from '../../../../components/FormLayout';
import { useSettings } from '../../../../contexts/Settings';
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
import {
EncryptedField,
InputField,
ObjectField,
} from '../../shared/SharedFields';
import { formatJson } from '../../shared/settingUtils';
import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
function GitHubEdit() {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const { isLoading, error, request: fetchGithub, result: github } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('github');
const mergedData = {};
Object.keys(data).forEach(key => {
if (!options[key]) {
return;
}
mergedData[key] = options[key];
mergedData[key].value = data[key];
});
return mergedData;
}, [options]),
null
);
useEffect(() => {
fetchGithub();
}, [fetchGithub]);
const { error: submitError, request: submitForm } = useRequest(
useCallback(
async values => {
await SettingsAPI.updateAll(values);
history.push('/settings/github/details');
},
[history]
),
null
);
const handleSubmit = async form => {
await submitForm({
...form,
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: formatJson(
form.SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP
),
SOCIAL_AUTH_GITHUB_TEAM_MAP: formatJson(form.SOCIAL_AUTH_GITHUB_TEAM_MAP),
});
};
const handleRevertAll = async () => {
const defaultValues = Object.assign(
...Object.entries(github).map(([key, value]) => ({
[key]: value.default,
}))
);
await submitForm(defaultValues);
closeModal();
};
const handleCancel = () => {
history.push('/settings/github/details');
};
const initialValues = fields =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
: emptyDefault;
} else {
acc[key] = fields[key].value ?? '';
}
return acc;
}, {});
function GitHubEdit({ i18n }) {
return ( return (
<CardBody> <CardBody>
{i18n._(t`Edit form coming soon :)`)} {isLoading && <ContentLoading />}
<CardActionsRow> {!isLoading && error && <ContentError error={error} />}
<Button {!isLoading && github && (
aria-label={i18n._(t`Cancel`)} <Formik initialValues={initialValues(github)} onSubmit={handleSubmit}>
component={Link} {formik => (
to="/settings/github/details" <Form autoComplete="off" onSubmit={formik.handleSubmit}>
> <FormColumnLayout>
{i18n._(t`Cancel`)} <InputField
</Button> name="SOCIAL_AUTH_GITHUB_KEY"
</CardActionsRow> config={github.SOCIAL_AUTH_GITHUB_KEY}
/>
<EncryptedField
name="SOCIAL_AUTH_GITHUB_SECRET"
config={github.SOCIAL_AUTH_GITHUB_SECRET}
/>
<ObjectField
name="SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP"
config={github.SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP}
/>
<ObjectField
name="SOCIAL_AUTH_GITHUB_TEAM_MAP"
config={github.SOCIAL_AUTH_GITHUB_TEAM_MAP}
/>
{submitError && <FormSubmitError error={submitError} />}
</FormColumnLayout>
<RevertFormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
onRevert={toggleModal}
/>
{isModalOpen && (
<RevertAllAlert
onClose={closeModal}
onRevertAll={handleRevertAll}
/>
)}
</Form>
)}
</Formik>
)}
</CardBody> </CardBody>
); );
} }
export default withI18n()(GitHubEdit); export default GitHubEdit;

View File

@@ -1,16 +1,173 @@
import React from 'react'; 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'; import GitHubEdit from './GitHubEdit';
jest.mock('../../../../api/models/Settings');
SettingsAPI.updateAll.mockResolvedValue({});
SettingsAPI.readCategory.mockResolvedValue({
data: {
SOCIAL_AUTH_GITHUB_CALLBACK_URL: 'https://foo/complete/github/',
SOCIAL_AUTH_GITHUB_KEY: 'mock github key',
SOCIAL_AUTH_GITHUB_SECRET: '$encrypted$',
SOCIAL_AUTH_GITHUB_TEAM_MAP: {},
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: {
Default: {
users: true,
},
},
},
});
describe('<GitHubEdit />', () => { describe('<GitHubEdit />', () => {
let wrapper; let wrapper;
beforeEach(() => { let history;
wrapper = mountWithContexts(<GitHubEdit />);
});
afterEach(() => { afterEach(() => {
wrapper.unmount(); wrapper.unmount();
jest.clearAllMocks();
}); });
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/settings/github/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GitHubEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders without crashing', () => { test('initially renders without crashing', () => {
expect(wrapper.find('GitHubEdit').length).toBe(1); expect(wrapper.find('GitHubEdit').length).toBe(1);
}); });
test('should display expected form fields', async () => {
expect(wrapper.find('FormGroup[label="GitHub OAuth2 Key"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="GitHub OAuth2 Secret"]').length).toBe(
1
);
expect(
wrapper.find('FormGroup[label="GitHub OAuth2 Organization Map"]').length
).toBe(1);
expect(
wrapper.find('FormGroup[label="GitHub OAuth2 Team Map"]').length
).toBe(1);
});
test('should successfully send default values to api on form revert all', async () => {
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
await act(async () => {
wrapper
.find('button[aria-label="Revert all to default"]')
.invoke('onClick')();
});
wrapper.update();
expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
await act(async () => {
wrapper
.find('RevertAllAlert button[aria-label="Confirm revert all"]')
.invoke('onClick')();
});
wrapper.update();
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
SOCIAL_AUTH_GITHUB_KEY: '',
SOCIAL_AUTH_GITHUB_SECRET: '',
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: null,
SOCIAL_AUTH_GITHUB_TEAM_MAP: null,
});
});
test('should successfully send request to api on form submission', async () => {
act(() => {
wrapper
.find(
'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_SECRET"] button[aria-label="Revert"]'
)
.invoke('onClick')();
wrapper.find('input#SOCIAL_AUTH_GITHUB_KEY').simulate('change', {
target: { value: 'new key', name: 'SOCIAL_AUTH_GITHUB_KEY' },
});
wrapper
.find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP')
.invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}');
});
wrapper.update();
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
SOCIAL_AUTH_GITHUB_KEY: 'new key',
SOCIAL_AUTH_GITHUB_SECRET: '',
SOCIAL_AUTH_GITHUB_TEAM_MAP: {},
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: {
Default: {
users: false,
},
},
});
});
test('should navigate to github default detail on successful submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual('/settings/github/details');
});
test('should navigate to github default detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual('/settings/github/details');
});
test('should display error message on unsuccessful submission', async () => {
const error = {
response: {
data: { detail: 'An error occurred' },
},
};
SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
expect(wrapper.find('FormSubmitError').length).toBe(0);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
});
test('should display ContentError on throw', async () => {
SettingsAPI.readCategory.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GitHubEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
}); });

View File

@@ -0,0 +1,147 @@
import React, { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { Formik } from 'formik';
import { Form } from '@patternfly/react-core';
import { CardBody } from '../../../../components/Card';
import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { FormSubmitError } from '../../../../components/FormField';
import { FormColumnLayout } from '../../../../components/FormLayout';
import { useSettings } from '../../../../contexts/Settings';
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
import {
EncryptedField,
InputField,
ObjectField,
} from '../../shared/SharedFields';
import { formatJson } from '../../shared/settingUtils';
import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
function GitHubOrgEdit() {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const { isLoading, error, request: fetchGithub, result: github } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('github-org');
const mergedData = {};
Object.keys(data).forEach(key => {
if (!options[key]) {
return;
}
mergedData[key] = options[key];
mergedData[key].value = data[key];
});
return mergedData;
}, [options]),
null
);
useEffect(() => {
fetchGithub();
}, [fetchGithub]);
const { error: submitError, request: submitForm } = useRequest(
useCallback(
async values => {
await SettingsAPI.updateAll(values);
history.push('/settings/github/organization/details');
},
[history]
),
null
);
const handleSubmit = async form => {
await submitForm({
...form,
SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: formatJson(
form.SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP
),
SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: formatJson(
form.SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP
),
});
};
const handleRevertAll = async () => {
const defaultValues = Object.assign(
...Object.entries(github).map(([key, value]) => ({
[key]: value.default,
}))
);
await submitForm(defaultValues);
closeModal();
};
const handleCancel = () => {
history.push('/settings/github/organization/details');
};
const initialValues = fields =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
: emptyDefault;
} else {
acc[key] = fields[key].value ?? '';
}
return acc;
}, {});
return (
<CardBody>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && github && (
<Formik initialValues={initialValues(github)} onSubmit={handleSubmit}>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<InputField
name="SOCIAL_AUTH_GITHUB_ORG_KEY"
config={github.SOCIAL_AUTH_GITHUB_ORG_KEY}
/>
<EncryptedField
name="SOCIAL_AUTH_GITHUB_ORG_SECRET"
config={github.SOCIAL_AUTH_GITHUB_ORG_SECRET}
/>
<InputField
name="SOCIAL_AUTH_GITHUB_ORG_NAME"
config={github.SOCIAL_AUTH_GITHUB_ORG_NAME}
/>
<ObjectField
name="SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP"
config={github.SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP}
/>
<ObjectField
name="SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP"
config={github.SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP}
/>
{submitError && <FormSubmitError error={submitError} />}
</FormColumnLayout>
<RevertFormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
onRevert={toggleModal}
/>
{isModalOpen && (
<RevertAllAlert
onClose={closeModal}
onRevertAll={handleRevertAll}
/>
)}
</Form>
)}
</Formik>
)}
</CardBody>
);
}
export default GitHubOrgEdit;

View File

@@ -0,0 +1,186 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import GitHubOrgEdit from './GitHubOrgEdit';
jest.mock('../../../../api/models/Settings');
SettingsAPI.updateAll.mockResolvedValue({});
SettingsAPI.readCategory.mockResolvedValue({
data: {
SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL:
'https://towerhost/sso/complete/github-org/',
SOCIAL_AUTH_GITHUB_ORG_KEY: '',
SOCIAL_AUTH_GITHUB_ORG_SECRET: '$encrypted$',
SOCIAL_AUTH_GITHUB_ORG_NAME: '',
SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null,
SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
},
});
describe('<GitHubOrgEdit />', () => {
let wrapper;
let history;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/settings/github/organization/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GitHubOrgEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders without crashing', () => {
expect(wrapper.find('GitHubOrgEdit').length).toBe(1);
});
test('should display expected form fields', async () => {
expect(
wrapper.find('FormGroup[label="GitHub Organization OAuth2 Key"]').length
).toBe(1);
expect(
wrapper.find('FormGroup[label="GitHub Organization OAuth2 Secret"]')
.length
).toBe(1);
expect(
wrapper.find('FormGroup[label="GitHub Organization Name"]').length
).toBe(1);
expect(
wrapper.find(
'FormGroup[label="GitHub Organization OAuth2 Organization Map"]'
).length
).toBe(1);
expect(
wrapper.find('FormGroup[label="GitHub Organization OAuth2 Team Map"]')
.length
).toBe(1);
});
test('should successfully send default values to api on form revert all', async () => {
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
await act(async () => {
wrapper
.find('button[aria-label="Revert all to default"]')
.invoke('onClick')();
});
wrapper.update();
expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
await act(async () => {
wrapper
.find('RevertAllAlert button[aria-label="Confirm revert all"]')
.invoke('onClick')();
});
wrapper.update();
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
SOCIAL_AUTH_GITHUB_ORG_KEY: '',
SOCIAL_AUTH_GITHUB_ORG_SECRET: '',
SOCIAL_AUTH_GITHUB_ORG_NAME: '',
SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null,
SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
});
});
test('should successfully send request to api on form submission', async () => {
act(() => {
wrapper
.find(
'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_ORG_SECRET"] button[aria-label="Revert"]'
)
.invoke('onClick')();
wrapper.find('input#SOCIAL_AUTH_GITHUB_ORG_NAME').simulate('change', {
target: { value: 'new org', name: 'SOCIAL_AUTH_GITHUB_ORG_NAME' },
});
wrapper
.find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP')
.invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}');
});
wrapper.update();
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
SOCIAL_AUTH_GITHUB_ORG_KEY: '',
SOCIAL_AUTH_GITHUB_ORG_SECRET: '',
SOCIAL_AUTH_GITHUB_ORG_NAME: 'new org',
SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: {},
SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: {
Default: {
users: false,
},
},
});
});
test('should navigate to github organization detail on successful submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual(
'/settings/github/organization/details'
);
});
test('should navigate to github organization detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual(
'/settings/github/organization/details'
);
});
test('should display error message on unsuccessful submission', async () => {
const error = {
response: {
data: { detail: 'An error occurred' },
},
};
SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
expect(wrapper.find('FormSubmitError').length).toBe(0);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
});
test('should display ContentError on throw', async () => {
SettingsAPI.readCategory.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GitHubOrgEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@@ -0,0 +1 @@
export { default } from './GitHubOrgEdit';

View File

@@ -0,0 +1,147 @@
import React, { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { Formik } from 'formik';
import { Form } from '@patternfly/react-core';
import { CardBody } from '../../../../components/Card';
import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { FormSubmitError } from '../../../../components/FormField';
import { FormColumnLayout } from '../../../../components/FormLayout';
import { useSettings } from '../../../../contexts/Settings';
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
import {
EncryptedField,
InputField,
ObjectField,
} from '../../shared/SharedFields';
import { formatJson } from '../../shared/settingUtils';
import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
function GitHubTeamEdit() {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const { isLoading, error, request: fetchGithub, result: github } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('github-team');
const mergedData = {};
Object.keys(data).forEach(key => {
if (!options[key]) {
return;
}
mergedData[key] = options[key];
mergedData[key].value = data[key];
});
return mergedData;
}, [options]),
null
);
useEffect(() => {
fetchGithub();
}, [fetchGithub]);
const { error: submitError, request: submitForm } = useRequest(
useCallback(
async values => {
await SettingsAPI.updateAll(values);
history.push('/settings/github/team/details');
},
[history]
),
null
);
const handleSubmit = async form => {
await submitForm({
...form,
SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: formatJson(
form.SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP
),
SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: formatJson(
form.SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP
),
});
};
const handleRevertAll = async () => {
const defaultValues = Object.assign(
...Object.entries(github).map(([key, value]) => ({
[key]: value.default,
}))
);
await submitForm(defaultValues);
closeModal();
};
const handleCancel = () => {
history.push('/settings/github/team/details');
};
const initialValues = fields =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
: emptyDefault;
} else {
acc[key] = fields[key].value ?? '';
}
return acc;
}, {});
return (
<CardBody>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && github && (
<Formik initialValues={initialValues(github)} onSubmit={handleSubmit}>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<InputField
name="SOCIAL_AUTH_GITHUB_TEAM_KEY"
config={github.SOCIAL_AUTH_GITHUB_TEAM_KEY}
/>
<EncryptedField
name="SOCIAL_AUTH_GITHUB_TEAM_SECRET"
config={github.SOCIAL_AUTH_GITHUB_TEAM_SECRET}
/>
<InputField
name="SOCIAL_AUTH_GITHUB_TEAM_ID"
config={github.SOCIAL_AUTH_GITHUB_TEAM_ID}
/>
<ObjectField
name="SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP"
config={github.SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP}
/>
<ObjectField
name="SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP"
config={github.SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP}
/>
{submitError && <FormSubmitError error={submitError} />}
</FormColumnLayout>
<RevertFormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
onRevert={toggleModal}
/>
{isModalOpen && (
<RevertAllAlert
onClose={closeModal}
onRevertAll={handleRevertAll}
/>
)}
</Form>
)}
</Formik>
)}
</CardBody>
);
}
export default GitHubTeamEdit;

View File

@@ -0,0 +1,177 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import GitHubTeamEdit from './GitHubTeamEdit';
jest.mock('../../../../api/models/Settings');
SettingsAPI.updateAll.mockResolvedValue({});
SettingsAPI.readCategory.mockResolvedValue({
data: {
SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL:
'https://towerhost/sso/complete/github-team/',
SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)',
SOCIAL_AUTH_GITHUB_TEAM_SECRET: '$encrypted$',
SOCIAL_AUTH_GITHUB_TEAM_ID: 'team_id',
SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {},
SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
},
});
describe('<GitHubTeamEdit />', () => {
let wrapper;
let history;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/settings/github/team/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GitHubTeamEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders without crashing', () => {
expect(wrapper.find('GitHubTeamEdit').length).toBe(1);
});
test('should display expected form fields', async () => {
expect(
wrapper.find('FormGroup[label="GitHub Team OAuth2 Key"]').length
).toBe(1);
expect(
wrapper.find('FormGroup[label="GitHub Team OAuth2 Secret"]').length
).toBe(1);
expect(wrapper.find('FormGroup[label="GitHub Team ID"]').length).toBe(1);
expect(
wrapper.find('FormGroup[label="GitHub Team OAuth2 Organization Map"]')
.length
).toBe(1);
expect(
wrapper.find('FormGroup[label="GitHub Team OAuth2 Team Map"]').length
).toBe(1);
});
test('should successfully send default values to api on form revert all', async () => {
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
await act(async () => {
wrapper
.find('button[aria-label="Revert all to default"]')
.invoke('onClick')();
});
wrapper.update();
expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
await act(async () => {
wrapper
.find('RevertAllAlert button[aria-label="Confirm revert all"]')
.invoke('onClick')();
});
wrapper.update();
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
SOCIAL_AUTH_GITHUB_TEAM_KEY: '',
SOCIAL_AUTH_GITHUB_TEAM_SECRET: '',
SOCIAL_AUTH_GITHUB_TEAM_ID: '',
SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: null,
SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: null,
});
});
test('should successfully send request to api on form submission', async () => {
act(() => {
wrapper
.find(
'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_TEAM_SECRET"] button[aria-label="Revert"]'
)
.invoke('onClick')();
wrapper.find('input#SOCIAL_AUTH_GITHUB_TEAM_ID').simulate('change', {
target: { value: '12345', name: 'SOCIAL_AUTH_GITHUB_TEAM_ID' },
});
wrapper
.find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP')
.invoke('onChange')('{\n"Default":{\n"users":\ntrue\n}\n}');
});
wrapper.update();
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)',
SOCIAL_AUTH_GITHUB_TEAM_SECRET: '',
SOCIAL_AUTH_GITHUB_TEAM_ID: '12345',
SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {
Default: {
users: true,
},
},
});
});
test('should navigate to github team detail on successful submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual('/settings/github/team/details');
});
test('should navigate to github team detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual('/settings/github/team/details');
});
test('should display error message on unsuccessful submission', async () => {
const error = {
response: {
data: { detail: 'An error occurred' },
},
};
SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
expect(wrapper.find('FormSubmitError').length).toBe(0);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
});
test('should display ContentError on throw', async () => {
SettingsAPI.readCategory.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GitHubTeamEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@@ -0,0 +1 @@
export { default } from './GitHubTeamEdit';

View File

@@ -2,13 +2,28 @@ import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import GoogleOAuth2 from './GoogleOAuth2'; import { SettingsProvider } from '../../../contexts/Settings';
import { SettingsAPI } from '../../../api'; import { SettingsAPI } from '../../../api';
import mockAllOptions from '../shared/data.allSettingOptions.json';
import GoogleOAuth2 from './GoogleOAuth2';
jest.mock('../../../api/models/Settings'); jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({ SettingsAPI.readCategory.mockResolvedValue({
data: {}, data: {
SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL:
'https://towerhost/sso/complete/google-oauth2/',
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: 'mock key',
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: '$encrypted$',
SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: [
'example.com',
'example_2.com',
],
SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: {},
SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: {
Default: {},
},
SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: {},
},
}); });
describe('<GoogleOAuth2 />', () => { describe('<GoogleOAuth2 />', () => {
@@ -24,9 +39,14 @@ describe('<GoogleOAuth2 />', () => {
initialEntries: ['/settings/google_oauth2/details'], initialEntries: ['/settings/google_oauth2/details'],
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<GoogleOAuth2 />, { wrapper = mountWithContexts(
context: { router: { history } }, <SettingsProvider value={mockAllOptions.actions}>
}); <GoogleOAuth2 />
</SettingsProvider>,
{
context: { router: { history } },
}
);
}); });
expect(wrapper.find('GoogleOAuth2Detail').length).toBe(1); expect(wrapper.find('GoogleOAuth2Detail').length).toBe(1);
}); });
@@ -36,9 +56,14 @@ describe('<GoogleOAuth2 />', () => {
initialEntries: ['/settings/google_oauth2/edit'], initialEntries: ['/settings/google_oauth2/edit'],
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<GoogleOAuth2 />, { wrapper = mountWithContexts(
context: { router: { history } }, <SettingsProvider value={mockAllOptions.actions}>
}); <GoogleOAuth2 />
</SettingsProvider>,
{
context: { router: { history } },
}
);
}); });
expect(wrapper.find('GoogleOAuth2Edit').length).toBe(1); expect(wrapper.find('GoogleOAuth2Edit').length).toBe(1);
}); });

View File

@@ -78,6 +78,7 @@ function GoogleOAuth2Detail({ i18n }) {
<Button <Button
aria-label={i18n._(t`Edit`)} aria-label={i18n._(t`Edit`)}
component={Link} component={Link}
ouiaId="edit-button"
to="/settings/google_oauth2/edit" to="/settings/google_oauth2/edit"
> >
{i18n._(t`Edit`)} {i18n._(t`Edit`)}

View File

@@ -1,25 +1,171 @@
import React from 'react'; import React, { useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { Formik } from 'formik';
import { t } from '@lingui/macro'; import { Form } from '@patternfly/react-core';
import { Button } from '@patternfly/react-core'; import { CardBody } from '../../../../components/Card';
import { CardBody, CardActionsRow } from '../../../../components/Card'; import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { FormSubmitError } from '../../../../components/FormField';
import { FormColumnLayout } from '../../../../components/FormLayout';
import { useSettings } from '../../../../contexts/Settings';
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
import {
EncryptedField,
InputField,
ObjectField,
} from '../../shared/SharedFields';
import { formatJson } from '../../shared/settingUtils';
import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
function GoogleOAuth2Edit() {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const {
isLoading,
error,
request: fetchGoogleOAuth2,
result: googleOAuth2,
} = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('google-oauth2');
const mergedData = {};
Object.keys(data).forEach(key => {
if (!options[key]) {
return;
}
mergedData[key] = options[key];
mergedData[key].value = data[key];
});
return mergedData;
}, [options]),
null
);
useEffect(() => {
fetchGoogleOAuth2();
}, [fetchGoogleOAuth2]);
const { error: submitError, request: submitForm } = useRequest(
useCallback(
async values => {
await SettingsAPI.updateAll(values);
history.push('/settings/google_oauth2/details');
},
[history]
),
null
);
const handleSubmit = async form => {
await submitForm({
...form,
SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: formatJson(
form.SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS
),
SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: formatJson(
form.SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS
),
SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: formatJson(
form.SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP
),
SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: formatJson(
form.SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP
),
});
};
const handleRevertAll = async () => {
const defaultValues = Object.assign(
...Object.entries(googleOAuth2).map(([key, value]) => ({
[key]: value.default,
}))
);
await submitForm(defaultValues);
closeModal();
};
const handleCancel = () => {
history.push('/settings/google_oauth2/details');
};
const initialValues = fields =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
: emptyDefault;
} else {
acc[key] = fields[key].value ?? '';
}
return acc;
}, {});
function GoogleOAuth2Edit({ i18n }) {
return ( return (
<CardBody> <CardBody>
{i18n._(t`Edit form coming soon :)`)} {isLoading && <ContentLoading />}
<CardActionsRow> {!isLoading && error && <ContentError error={error} />}
<Button {!isLoading && googleOAuth2 && (
aria-label={i18n._(t`Cancel`)} <Formik
component={Link} initialValues={initialValues(googleOAuth2)}
to="/settings/google_oauth2/details" onSubmit={handleSubmit}
> >
{i18n._(t`Cancel`)} {formik => (
</Button> <Form autoComplete="off" onSubmit={formik.handleSubmit}>
</CardActionsRow> <FormColumnLayout>
<InputField
name="SOCIAL_AUTH_GOOGLE_OAUTH2_KEY"
config={googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY}
/>
<EncryptedField
name="SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET"
config={googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET}
/>
<ObjectField
name="SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS"
config={
googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS
}
/>
<ObjectField
name="SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS"
config={
googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS
}
/>
<ObjectField
name="SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP"
config={
googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP
}
/>
<ObjectField
name="SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP"
config={googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP}
/>
{submitError && <FormSubmitError error={submitError} />}
</FormColumnLayout>
<RevertFormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
onRevert={toggleModal}
/>
{isModalOpen && (
<RevertAllAlert
onClose={closeModal}
onRevertAll={handleRevertAll}
/>
)}
</Form>
)}
</Formik>
)}
</CardBody> </CardBody>
); );
} }
export default withI18n()(GoogleOAuth2Edit); export default GoogleOAuth2Edit;

Some files were not shown because too many files have changed in this diff Show More