mirror of
https://github.com/ansible/awx.git
synced 2026-01-14 11:20:39 -03:30
commit
e4cb50921e
@ -1,2 +1 @@
|
||||
.git
|
||||
awx/ui/node_modules
|
||||
|
||||
@ -85,7 +85,7 @@ If you're not using Docker for Mac, or Docker for Windows, you may need, or choo
|
||||
|
||||
#### Frontend Development
|
||||
|
||||
See [the ui development documentation](awx/ui/README.md).
|
||||
See [the ui development documentation](awx/ui_next/CONTRIBUTING.md).
|
||||
|
||||
|
||||
### Build the environment
|
||||
@ -158,7 +158,7 @@ $ docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
44251b476f98 gcr.io/ansible-tower-engineering/awx_devel:devel "/entrypoint.sh /bin…" 27 seconds ago Up 23 seconds 0.0.0.0:6899->6899/tcp, 0.0.0.0:7899-7999->7899-7999/tcp, 0.0.0.0:8013->8013/tcp, 0.0.0.0:8043->8043/tcp, 0.0.0.0:8080->8080/tcp, 22/tcp, 0.0.0.0:8888->8888/tcp tools_awx_run_9e820694d57e
|
||||
40de380e3c2e redis:latest "docker-entrypoint.s…" 28 seconds ago Up 26 seconds
|
||||
b66a506d3007 postgres:10 "docker-entrypoint.s…" 28 seconds ago Up 26 seconds 0.0.0.0:5432->5432/tcp tools_postgres_1
|
||||
b66a506d3007 postgres:12 "docker-entrypoint.s…" 28 seconds ago Up 26 seconds 0.0.0.0:5432->5432/tcp tools_postgres_1
|
||||
```
|
||||
**NOTE**
|
||||
|
||||
|
||||
12
Makefile
12
Makefile
@ -19,7 +19,8 @@ PYCURL_SSL_LIBRARY ?= openssl
|
||||
COMPOSE_TAG ?= $(GIT_BRANCH)
|
||||
COMPOSE_HOST ?= $(shell hostname)
|
||||
|
||||
VENV_BASE ?= /venv
|
||||
VENV_BASE ?= /var/lib/awx/venv/
|
||||
COLLECTION_BASE ?= /var/lib/awx/vendor/awx_ansible_collections
|
||||
SCL_PREFIX ?=
|
||||
CELERY_SCHEDULE_FILE ?= /var/lib/awx/beat.db
|
||||
|
||||
@ -270,7 +271,7 @@ uwsgi: collectstatic
|
||||
@if [ "$(VENV_BASE)" ]; then \
|
||||
. $(VENV_BASE)/awx/bin/activate; \
|
||||
fi; \
|
||||
uwsgi -b 32768 --socket 127.0.0.1:8050 --module=awx.wsgi:application --home=/venv/awx --chdir=/awx_devel/ --vacuum --processes=5 --harakiri=120 --master --no-orphans --py-autoreload 1 --max-requests=1000 --stats /tmp/stats.socket --lazy-apps --logformat "%(addr) %(method) %(uri) - %(proto) %(status)" --hook-accepting1="exec:supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver"
|
||||
uwsgi -b 32768 --socket 127.0.0.1:8050 --module=awx.wsgi:application --home=/var/lib/awx/venv/awx --chdir=/awx_devel/ --vacuum --processes=5 --harakiri=120 --master --no-orphans --py-autoreload 1 --max-requests=1000 --stats /tmp/stats.socket --lazy-apps --logformat "%(addr) %(method) %(uri) - %(proto) %(status)" --hook-accepting1="exec:supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver"
|
||||
|
||||
daphne:
|
||||
@if [ "$(VENV_BASE)" ]; then \
|
||||
@ -340,7 +341,7 @@ check: flake8 pep8 # pyflakes pylint
|
||||
|
||||
awx-link:
|
||||
[ -d "/awx_devel/awx.egg-info" ] || python3 /awx_devel/setup.py egg_info_dev
|
||||
cp -f /tmp/awx.egg-link /venv/awx/lib/python$(PYTHON_VERSION)/site-packages/awx.egg-link
|
||||
cp -f /tmp/awx.egg-link /var/lib/awx/venv/awx/lib/python$(PYTHON_VERSION)/site-packages/awx.egg-link
|
||||
|
||||
TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests
|
||||
|
||||
@ -618,7 +619,10 @@ clean-elk:
|
||||
docker rm tools_kibana_1
|
||||
|
||||
psql-container:
|
||||
docker run -it --net tools_default --rm postgres:10 sh -c 'exec psql -h "postgres" -p "5432" -U postgres'
|
||||
docker run -it --net tools_default --rm postgres:12 sh -c 'exec psql -h "postgres" -p "5432" -U postgres'
|
||||
|
||||
VERSION:
|
||||
@echo "awx: $(VERSION)"
|
||||
|
||||
Dockerfile: installer/roles/image_build/templates/Dockerfile.j2
|
||||
ansible localhost -m template -a "src=installer/roles/image_build/templates/Dockerfile.j2 dest=Dockerfile"
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
[](https://ansible.softwarefactory-project.io/zuul/status)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/ansible/awx-logos/master/awx/ui/client/assets/logo-login.svg?sanitize=true" width=200 alt="AWX" />
|
||||
|
||||
AWX provides a web-based user interface, REST API, and task engine built on top of [Ansible](https://github.com/ansible/ansible). It is the upstream project for [Tower](https://www.ansible.com/tower), a commercial derivative of AWX.
|
||||
|
||||
To install AWX, please view the [Install guide](./INSTALL.md).
|
||||
|
||||
@ -4,6 +4,7 @@ import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
|
||||
# Django
|
||||
from django.conf import LazySettings
|
||||
@ -247,6 +248,7 @@ class SettingsWrapper(UserSettingsHolder):
|
||||
# These values have to be stored via self.__dict__ in this way to get
|
||||
# around the magic __setattr__ method on this class (which is used to
|
||||
# store API-assigned settings in the database).
|
||||
self.__dict__['__forks__'] = {}
|
||||
self.__dict__['default_settings'] = default_settings
|
||||
self.__dict__['_awx_conf_settings'] = self
|
||||
self.__dict__['_awx_conf_preload_expires'] = None
|
||||
@ -255,6 +257,26 @@ class SettingsWrapper(UserSettingsHolder):
|
||||
self.__dict__['cache'] = EncryptedCacheProxy(cache, registry)
|
||||
self.__dict__['registry'] = registry
|
||||
|
||||
# record the current pid so we compare it post-fork for
|
||||
# processes like the dispatcher and callback receiver
|
||||
self.__dict__['pid'] = os.getpid()
|
||||
|
||||
def __clean_on_fork__(self):
|
||||
pid = os.getpid()
|
||||
# if the current pid does *not* match the value on self, it means
|
||||
# that value was copied on fork, and we're now in a *forked* process;
|
||||
# the *first* time we enter this code path (on setting access),
|
||||
# forcibly close DB/cache sockets and set a marker so we don't run
|
||||
# this code again _in this process_
|
||||
#
|
||||
if pid != self.__dict__['pid'] and pid not in self.__dict__['__forks__']:
|
||||
self.__dict__['__forks__'][pid] = True
|
||||
# It's important to close these post-fork, because we
|
||||
# don't want the forked processes to inherit the open sockets
|
||||
# for the DB and cache connections (that way lies race conditions)
|
||||
connection.close()
|
||||
django_cache.close()
|
||||
|
||||
@cached_property
|
||||
def all_supported_settings(self):
|
||||
return self.registry.get_registered_settings()
|
||||
@ -330,6 +352,7 @@ class SettingsWrapper(UserSettingsHolder):
|
||||
self.cache.set_many(settings_to_cache, timeout=SETTING_CACHE_TIMEOUT)
|
||||
|
||||
def _get_local(self, name, validate=True):
|
||||
self.__clean_on_fork__()
|
||||
self._preload_cache()
|
||||
cache_key = Setting.get_cache_key(name)
|
||||
try:
|
||||
|
||||
@ -3354,6 +3354,15 @@ msgid ""
|
||||
"common scenarios."
|
||||
msgstr ""
|
||||
|
||||
#: awx/main/models/credential/__init__.py:824
|
||||
msgid "Region Name"
|
||||
msgstr ""
|
||||
|
||||
#: awx/main/models/credential/__init__.py:826
|
||||
msgid ""
|
||||
"For some cloud providers, like OVH, region must be specified."
|
||||
msgstr ""
|
||||
|
||||
#: awx/main/models/credential/__init__.py:824
|
||||
#: awx/main/models/credential/__init__.py:1131
|
||||
#: awx/main/models/credential/__init__.py:1166
|
||||
|
||||
@ -3354,6 +3354,15 @@ msgid ""
|
||||
"common scenarios."
|
||||
msgstr ""
|
||||
|
||||
#: awx/main/models/credential/__init__.py:824
|
||||
msgid "Region Name"
|
||||
msgstr ""
|
||||
|
||||
#: awx/main/models/credential/__init__.py:826
|
||||
msgid ""
|
||||
"For some cloud providers, like OVH, region must be specified."
|
||||
msgstr ""
|
||||
|
||||
#: awx/main/models/credential/__init__.py:824
|
||||
#: awx/main/models/credential/__init__.py:1131
|
||||
#: awx/main/models/credential/__init__.py:1166
|
||||
|
||||
@ -3294,6 +3294,16 @@ msgid ""
|
||||
"common scenarios."
|
||||
msgstr "Les domaines OpenStack définissent les limites administratives. Ils sont nécessaires uniquement pour les URL d’authentification Keystone v3. Voir la documentation Ansible Tower pour les scénarios courants."
|
||||
|
||||
#: awx/main/models/credential/__init__.py:824
|
||||
msgid "Region Name"
|
||||
msgstr "Nom de la region"
|
||||
|
||||
#: awx/main/models/credential/__init__.py:826
|
||||
msgid ""
|
||||
"For some cloud providers, like OVH, region must be specified."
|
||||
msgstr ""
|
||||
"Chez certains fournisseurs, comme OVH, vous devez spécifier le nom de la région"
|
||||
|
||||
#: awx/main/models/credential/__init__.py:812
|
||||
#: awx/main/models/credential/__init__.py:1110
|
||||
#: awx/main/models/credential/__init__.py:1144
|
||||
|
||||
@ -7,6 +7,7 @@ import tempfile
|
||||
import time
|
||||
import logging
|
||||
import yaml
|
||||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
import ansible_runner
|
||||
@ -123,6 +124,7 @@ class IsolatedManager(object):
|
||||
dir=private_data_dir
|
||||
)
|
||||
params = self.runner_params.copy()
|
||||
params.get('envvars', dict())['ANSIBLE_CALLBACK_WHITELIST'] = 'profile_tasks'
|
||||
params['playbook'] = playbook
|
||||
params['private_data_dir'] = iso_dir
|
||||
if idle_timeout:
|
||||
@ -168,7 +170,8 @@ class IsolatedManager(object):
|
||||
extravars = {
|
||||
'src': self.private_data_dir,
|
||||
'dest': settings.AWX_PROOT_BASE_PATH,
|
||||
'ident': self.ident
|
||||
'ident': self.ident,
|
||||
'job_id': self.instance.id,
|
||||
}
|
||||
if playbook:
|
||||
extravars['playbook'] = playbook
|
||||
@ -204,7 +207,10 @@ class IsolatedManager(object):
|
||||
:param interval: an interval (in seconds) to wait between status polls
|
||||
"""
|
||||
interval = interval if interval is not None else settings.AWX_ISOLATED_CHECK_INTERVAL
|
||||
extravars = {'src': self.private_data_dir}
|
||||
extravars = {
|
||||
'src': self.private_data_dir,
|
||||
'job_id': self.instance.id
|
||||
}
|
||||
status = 'failed'
|
||||
rc = None
|
||||
last_check = time.time()
|
||||
@ -220,9 +226,13 @@ class IsolatedManager(object):
|
||||
logger.warning('Isolated job {} was manually canceled.'.format(self.instance.id))
|
||||
|
||||
logger.debug('Checking on isolated job {} with `check_isolated.yml`.'.format(self.instance.id))
|
||||
time_start = datetime.datetime.now()
|
||||
runner_obj = self.run_management_playbook('check_isolated.yml',
|
||||
self.private_data_dir,
|
||||
extravars=extravars)
|
||||
time_end = datetime.datetime.now()
|
||||
time_diff = time_end - time_start
|
||||
logger.debug('Finished checking on isolated job {} with `check_isolated.yml` took {} seconds.'.format(self.instance.id, time_diff.total_seconds()))
|
||||
status, rc = runner_obj.status, runner_obj.rc
|
||||
|
||||
if self.check_callback is not None and not self.captured_command_artifact:
|
||||
|
||||
@ -133,7 +133,7 @@ class AnsibleInventoryLoader(object):
|
||||
# NOTE: why do we add "python" to the start of these args?
|
||||
# the script that runs ansible-inventory specifies a python interpreter
|
||||
# that makes no sense in light of the fact that we put all the dependencies
|
||||
# inside of /venv/ansible, so we override the specified interpreter
|
||||
# inside of /var/lib/awx/venv/ansible, so we override the specified interpreter
|
||||
# https://github.com/ansible/ansible/issues/50714
|
||||
bargs = ['python', ansible_inventory_path, '-i', self.source]
|
||||
bargs.extend(['--playbook-dir', functioning_dir(self.source)])
|
||||
|
||||
@ -819,6 +819,11 @@ ManagedCredentialType(
|
||||
'It is only needed for Keystone v3 authentication '
|
||||
'URLs. Refer to Ansible Tower documentation for '
|
||||
'common scenarios.')
|
||||
}, {
|
||||
'id': 'region',
|
||||
'label': ugettext_noop('Region Name'),
|
||||
'type': 'string',
|
||||
'help_text': ugettext_noop('For some cloud providers, like OVH, region must be specified'),
|
||||
}, {
|
||||
'id': 'verify_ssl',
|
||||
'label': ugettext_noop('Verify SSL'),
|
||||
|
||||
@ -82,6 +82,7 @@ def _openstack_data(cred):
|
||||
if cred.has_input('domain'):
|
||||
openstack_auth['domain_name'] = cred.get_input('domain', default='')
|
||||
verify_state = cred.get_input('verify_ssl', default=True)
|
||||
|
||||
openstack_data = {
|
||||
'clouds': {
|
||||
'devstack': {
|
||||
@ -90,6 +91,10 @@ def _openstack_data(cred):
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if cred.has_input('project_region_name'):
|
||||
openstack_data['clouds']['devstack']['region_name'] = cred.get_input('project_region_name', default='')
|
||||
|
||||
return openstack_data
|
||||
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ from django.core.mail.message import EmailMessage
|
||||
from django.db import connection
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.encoding import smart_str, force_text
|
||||
from jinja2 import sandbox
|
||||
from jinja2 import sandbox, ChainableUndefined
|
||||
from jinja2.exceptions import TemplateSyntaxError, UndefinedError, SecurityError
|
||||
|
||||
# AWX
|
||||
@ -429,7 +429,7 @@ class JobNotificationMixin(object):
|
||||
raise RuntimeError("Define me")
|
||||
|
||||
def build_notification_message(self, nt, status):
|
||||
env = sandbox.ImmutableSandboxedEnvironment()
|
||||
env = sandbox.ImmutableSandboxedEnvironment(undefined=ChainableUndefined)
|
||||
|
||||
from awx.api.serializers import UnifiedJobSerializer
|
||||
job_serialization = UnifiedJobSerializer(self).to_representation(self)
|
||||
|
||||
@ -378,6 +378,7 @@ def gather_analytics():
|
||||
|
||||
from awx.conf.models import Setting
|
||||
from rest_framework.fields import DateTimeField
|
||||
from awx.main.signals import disable_activity_stream
|
||||
if not settings.INSIGHTS_TRACKING_STATE:
|
||||
return
|
||||
if not (settings.AUTOMATION_ANALYTICS_URL and settings.REDHAT_USERNAME and settings.REDHAT_PASSWORD):
|
||||
@ -414,7 +415,8 @@ def gather_analytics():
|
||||
if not _gather_and_ship(incremental_collectors, since=start, until=until):
|
||||
break
|
||||
start = until
|
||||
settings.AUTOMATION_ANALYTICS_LAST_GATHER = until
|
||||
with disable_activity_stream():
|
||||
settings.AUTOMATION_ANALYTICS_LAST_GATHER = until
|
||||
if subset:
|
||||
_gather_and_ship(subset, since=since, until=gather_time)
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ def test_awx_virtualenv_from_settings(inventory, project, machine_credential):
|
||||
)
|
||||
jt.credentials.add(machine_credential)
|
||||
job = jt.create_unified_job()
|
||||
assert job.ansible_virtualenv_path == '/venv/ansible'
|
||||
assert job.ansible_virtualenv_path == '/var/lib/awx/venv/ansible'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -43,28 +43,28 @@ def test_awx_custom_virtualenv(inventory, project, machine_credential, organizat
|
||||
jt.credentials.add(machine_credential)
|
||||
job = jt.create_unified_job()
|
||||
|
||||
job.organization.custom_virtualenv = '/venv/fancy-org'
|
||||
job.organization.custom_virtualenv = '/var/lib/awx/venv/fancy-org'
|
||||
job.organization.save()
|
||||
assert job.ansible_virtualenv_path == '/venv/fancy-org'
|
||||
assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-org'
|
||||
|
||||
job.project.custom_virtualenv = '/venv/fancy-proj'
|
||||
job.project.custom_virtualenv = '/var/lib/awx/venv/fancy-proj'
|
||||
job.project.save()
|
||||
assert job.ansible_virtualenv_path == '/venv/fancy-proj'
|
||||
assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-proj'
|
||||
|
||||
job.job_template.custom_virtualenv = '/venv/fancy-jt'
|
||||
job.job_template.custom_virtualenv = '/var/lib/awx/venv/fancy-jt'
|
||||
job.job_template.save()
|
||||
assert job.ansible_virtualenv_path == '/venv/fancy-jt'
|
||||
assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-jt'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_awx_custom_virtualenv_without_jt(project):
|
||||
project.custom_virtualenv = '/venv/fancy-proj'
|
||||
project.custom_virtualenv = '/var/lib/awx/venv/fancy-proj'
|
||||
project.save()
|
||||
job = Job(project=project)
|
||||
job.save()
|
||||
|
||||
job = Job.objects.get(pk=job.id)
|
||||
assert job.ansible_virtualenv_path == '/venv/fancy-proj'
|
||||
assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-proj'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@ -180,7 +180,7 @@ def test_openstack_client_config_generation(mocker, source, expected, private_da
|
||||
'source_vars_dict': {},
|
||||
'get_cloud_credential': mocker.Mock(return_value=credential),
|
||||
'get_extra_credentials': lambda x: [],
|
||||
'ansible_virtualenv_path': '/venv/foo'
|
||||
'ansible_virtualenv_path': '/var/lib/awx/venv/foo'
|
||||
})
|
||||
cloud_config = update.build_private_data(inventory_update, private_data_dir)
|
||||
cloud_credential = yaml.safe_load(
|
||||
@ -224,6 +224,52 @@ def test_openstack_client_config_generation_with_project_domain_name(mocker, sou
|
||||
'source_vars_dict': {},
|
||||
'get_cloud_credential': mocker.Mock(return_value=credential),
|
||||
'get_extra_credentials': lambda x: [],
|
||||
'ansible_virtualenv_path': '/var/lib/awx/venv/foo'
|
||||
})
|
||||
cloud_config = update.build_private_data(inventory_update, private_data_dir)
|
||||
cloud_credential = yaml.safe_load(
|
||||
cloud_config.get('credentials')[credential]
|
||||
)
|
||||
assert cloud_credential['clouds'] == {
|
||||
'devstack': {
|
||||
'auth': {
|
||||
'auth_url': 'https://keystone.openstack.example.org',
|
||||
'password': 'secrete',
|
||||
'project_name': 'demo-project',
|
||||
'username': 'demo',
|
||||
'domain_name': 'my-demo-domain',
|
||||
'project_domain_name': 'project-domain',
|
||||
},
|
||||
'verify': expected,
|
||||
'private': True,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source,expected", [
|
||||
(None, True), (False, False), (True, True)
|
||||
])
|
||||
def test_openstack_client_config_generation_with_project_region_name(mocker, source, expected, private_data_dir):
|
||||
update = tasks.RunInventoryUpdate()
|
||||
credential_type = CredentialType.defaults['openstack']()
|
||||
inputs = {
|
||||
'host': 'https://keystone.openstack.example.org',
|
||||
'username': 'demo',
|
||||
'password': 'secrete',
|
||||
'project': 'demo-project',
|
||||
'domain': 'my-demo-domain',
|
||||
'project_domain_name': 'project-domain',
|
||||
'project_region_name': 'region-name',
|
||||
}
|
||||
if source is not None:
|
||||
inputs['verify_ssl'] = source
|
||||
credential = Credential(pk=1, credential_type=credential_type, inputs=inputs)
|
||||
|
||||
inventory_update = mocker.Mock(**{
|
||||
'source': 'openstack',
|
||||
'source_vars_dict': {},
|
||||
'get_cloud_credential': mocker.Mock(return_value=credential),
|
||||
'get_extra_credentials': lambda x: [],
|
||||
'ansible_virtualenv_path': '/venv/foo'
|
||||
})
|
||||
cloud_config = update.build_private_data(inventory_update, private_data_dir)
|
||||
@ -242,6 +288,7 @@ def test_openstack_client_config_generation_with_project_domain_name(mocker, sou
|
||||
},
|
||||
'verify': expected,
|
||||
'private': True,
|
||||
'region_name': 'region-name',
|
||||
}
|
||||
}
|
||||
|
||||
@ -267,7 +314,7 @@ def test_openstack_client_config_generation_with_private_source_vars(mocker, sou
|
||||
'source_vars_dict': {'private': source},
|
||||
'get_cloud_credential': mocker.Mock(return_value=credential),
|
||||
'get_extra_credentials': lambda x: [],
|
||||
'ansible_virtualenv_path': '/venv/foo'
|
||||
'ansible_virtualenv_path': '/var/lib/awx/venv/foo'
|
||||
})
|
||||
cloud_config = update.build_private_data(inventory_update, private_data_dir)
|
||||
cloud_credential = yaml.load(
|
||||
@ -625,13 +672,13 @@ class TestGenericRun():
|
||||
|
||||
def test_invalid_custom_virtualenv(self, patch_Job, private_data_dir):
|
||||
job = Job(project=Project(), inventory=Inventory())
|
||||
job.project.custom_virtualenv = '/venv/missing'
|
||||
job.project.custom_virtualenv = '/var/lib/awx/venv/missing'
|
||||
task = tasks.RunJob()
|
||||
|
||||
with pytest.raises(tasks.InvalidVirtualenvError) as e:
|
||||
task.build_env(job, private_data_dir)
|
||||
|
||||
assert 'Invalid virtual environment selected: /venv/missing' == str(e.value)
|
||||
assert 'Invalid virtual environment selected: /var/lib/awx/venv/missing' == str(e.value)
|
||||
|
||||
|
||||
class TestAdhocRun(TestJobExecution):
|
||||
|
||||
@ -9,6 +9,9 @@
|
||||
- ansible.posix
|
||||
|
||||
tasks:
|
||||
- name: "Output job the playbook is running for"
|
||||
debug:
|
||||
msg: "Checking on job {{ job_id }}"
|
||||
|
||||
- name: Determine if daemon process is alive.
|
||||
shell: "ansible-runner is-alive {{src}}"
|
||||
|
||||
@ -13,6 +13,10 @@
|
||||
- ansible.posix
|
||||
|
||||
tasks:
|
||||
- name: "Output job the playbook is running for"
|
||||
debug:
|
||||
msg: "Checking on job {{ job_id }}"
|
||||
|
||||
- name: synchronize job environment with isolated host
|
||||
synchronize:
|
||||
copy_links: true
|
||||
|
||||
@ -116,7 +116,7 @@ LOGIN_URL = '/api/login/'
|
||||
|
||||
# Absolute filesystem path to the directory to host projects (with playbooks).
|
||||
# This directory should not be web-accessible.
|
||||
PROJECTS_ROOT = os.path.join(BASE_DIR, 'projects')
|
||||
PROJECTS_ROOT = '/var/lib/awx/projects/'
|
||||
|
||||
# Absolute filesystem path to the directory to host collections for
|
||||
# running inventory imports, isolated playbooks
|
||||
@ -125,10 +125,10 @@ AWX_ANSIBLE_COLLECTIONS_PATHS = os.path.join(BASE_DIR, 'vendor', 'awx_ansible_co
|
||||
# Absolute filesystem path to the directory for job status stdout (default for
|
||||
# development and tests, default for production defined in production.py). This
|
||||
# directory should not be web-accessible
|
||||
JOBOUTPUT_ROOT = os.path.join(BASE_DIR, 'job_output')
|
||||
JOBOUTPUT_ROOT = '/var/lib/awx/job_status/'
|
||||
|
||||
# Absolute filesystem path to the directory to store logs
|
||||
LOG_ROOT = os.path.join(BASE_DIR)
|
||||
LOG_ROOT = '/var/log/tower/'
|
||||
|
||||
# The heartbeat file for the tower scheduler
|
||||
SCHEDULE_METADATA_LOCATION = os.path.join(BASE_DIR, '.tower_cycle')
|
||||
@ -932,6 +932,14 @@ LOGGING = {
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'isolated_manager': {
|
||||
'level': 'WARNING',
|
||||
'class':'logging.handlers.RotatingFileHandler',
|
||||
'filename': os.path.join(LOG_ROOT, 'isolated_manager.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
@ -981,6 +989,11 @@ LOGGING = {
|
||||
'awx.main.wsbroadcast': {
|
||||
'handlers': ['wsbroadcast'],
|
||||
},
|
||||
'awx.isolated.manager': {
|
||||
'level': 'WARNING',
|
||||
'handlers': ['console', 'file', 'isolated_manager'],
|
||||
'propagate': True
|
||||
},
|
||||
'awx.isolated.manager.playbooks': {
|
||||
'handlers': ['management_playbooks'],
|
||||
'propagate': False
|
||||
|
||||
@ -148,9 +148,9 @@ include(optional('/etc/tower/settings.py'), scope=locals())
|
||||
include(optional('/etc/tower/conf.d/*.py'), scope=locals())
|
||||
|
||||
# Installed differently in Dockerfile compared to production versions
|
||||
AWX_ANSIBLE_COLLECTIONS_PATHS = '/vendor/awx_ansible_collections'
|
||||
AWX_ANSIBLE_COLLECTIONS_PATHS = '/var/lib/awx/vendor/awx_ansible_collections'
|
||||
|
||||
BASE_VENV_PATH = "/venv/"
|
||||
BASE_VENV_PATH = "/var/lib/awx/venv/"
|
||||
ANSIBLE_VENV_PATH = os.path.join(BASE_VENV_PATH, "ansible")
|
||||
AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx")
|
||||
|
||||
|
||||
@ -48,56 +48,12 @@ if "pytest" in sys.modules:
|
||||
}
|
||||
}
|
||||
|
||||
# Absolute filesystem path to the directory to host projects (with playbooks).
|
||||
# This directory should NOT be web-accessible.
|
||||
PROJECTS_ROOT = '/var/lib/awx/projects/'
|
||||
|
||||
# Location for cross-development of inventory plugins
|
||||
AWX_ANSIBLE_COLLECTIONS_PATHS = '/vendor/awx_ansible_collections'
|
||||
|
||||
# Absolute filesystem path to the directory for job status stdout
|
||||
# This directory should not be web-accessible
|
||||
JOBOUTPUT_ROOT = os.path.join(BASE_DIR, 'job_status')
|
||||
AWX_ANSIBLE_COLLECTIONS_PATHS = '/var/lib/awx/vendor/awx_ansible_collections'
|
||||
|
||||
# The UUID of the system, for HA.
|
||||
SYSTEM_UUID = '00000000-0000-0000-0000-000000000000'
|
||||
|
||||
# Local time zone for this installation. Choices can be found here:
|
||||
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
|
||||
# although not all choices may be available on all operating systems.
|
||||
# On Unix systems, a value of None will cause Django to use the same
|
||||
# timezone as the operating system.
|
||||
# If running in a Windows environment this must be set to the same as your
|
||||
# system time zone.
|
||||
USE_TZ = True
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
# Language code for this installation. All choices can be found here:
|
||||
# http://www.i18nguy.com/unicode/language-identifiers.html
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
# Hardcoded values can leak through source control. Consider loading
|
||||
# the secret key from an environment variable or a file instead.
|
||||
SECRET_KEY = 'p7z7g1ql4%6+(6nlebb6hdk7sd^&fnjpal308%n%+p^_e6vo1y'
|
||||
|
||||
# HTTP headers and meta keys to search to determine remote host name or IP. Add
|
||||
# additional items to this list, such as "HTTP_X_FORWARDED_FOR", if behind a
|
||||
# reverse proxy.
|
||||
REMOTE_HOST_HEADERS = ['REMOTE_ADDR', 'REMOTE_HOST']
|
||||
|
||||
# If Tower is behind a reverse proxy/load balancer, use this setting to
|
||||
# whitelist the proxy IP addresses from which Tower should trust custom
|
||||
# REMOTE_HOST_HEADERS header values
|
||||
# REMOTE_HOST_HEADERS = ['HTTP_X_FORWARDED_FOR', ''REMOTE_ADDR', 'REMOTE_HOST']
|
||||
# PROXY_IP_WHITELIST = ['10.0.1.100', '10.0.1.101']
|
||||
# If this setting is an empty list (the default), the headers specified by
|
||||
# REMOTE_HOST_HEADERS will be trusted unconditionally')
|
||||
PROXY_IP_WHITELIST = []
|
||||
|
||||
# Define additional environment variables to be passed to ansible subprocesses
|
||||
#AWX_TASK_ENV['FOO'] = 'BAR'
|
||||
|
||||
# If set, use -vvv for project updates instead of -v for more output.
|
||||
# PROJECT_UPDATE_VVV=True
|
||||
|
||||
@ -108,40 +64,6 @@ PROXY_IP_WHITELIST = []
|
||||
# Enable logging to syslog. Setting level to ERROR captures 500 errors,
|
||||
# WARNING also logs 4xx responses.
|
||||
|
||||
LOGGING['handlers']['syslog'] = {
|
||||
'level': 'WARNING',
|
||||
'filters': ['require_debug_false'],
|
||||
'class': 'logging.NullHandler',
|
||||
'formatter': 'simple',
|
||||
}
|
||||
|
||||
LOGGING['loggers']['django.request']['handlers'] = ['console']
|
||||
LOGGING['loggers']['rest_framework.request']['handlers'] = ['console']
|
||||
LOGGING['loggers']['awx']['handlers'] = ['console', 'external_logger']
|
||||
LOGGING['loggers']['awx.main.commands.run_callback_receiver']['handlers'] = [] # propogates to awx
|
||||
LOGGING['loggers']['awx.main.tasks']['handlers'] = ['console', 'external_logger']
|
||||
LOGGING['loggers']['awx.main.scheduler']['handlers'] = ['console', 'external_logger']
|
||||
LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console']
|
||||
LOGGING['loggers']['social']['handlers'] = ['console']
|
||||
LOGGING['loggers']['system_tracking_migrations']['handlers'] = ['console']
|
||||
LOGGING['loggers']['rbac_migrations']['handlers'] = ['console']
|
||||
LOGGING['loggers']['awx.isolated.manager.playbooks']['handlers'] = ['console']
|
||||
LOGGING['handlers']['callback_receiver'] = {'class': 'logging.NullHandler'}
|
||||
LOGGING['handlers']['fact_receiver'] = {'class': 'logging.NullHandler'}
|
||||
LOGGING['handlers']['task_system'] = {'class': 'logging.NullHandler'}
|
||||
LOGGING['handlers']['tower_warnings'] = {'class': 'logging.NullHandler'}
|
||||
LOGGING['handlers']['rbac_migrations'] = {'class': 'logging.NullHandler'}
|
||||
LOGGING['handlers']['system_tracking_migrations'] = {'class': 'logging.NullHandler'}
|
||||
LOGGING['handlers']['management_playbooks'] = {'class': 'logging.NullHandler'}
|
||||
|
||||
|
||||
# Enable the following lines to also log to a file.
|
||||
#LOGGING['handlers']['file'] = {
|
||||
# 'class': 'logging.FileHandler',
|
||||
# 'filename': os.path.join(BASE_DIR, 'awx.log'),
|
||||
# 'formatter': 'simple',
|
||||
#}
|
||||
|
||||
# Enable the following lines to turn on lots of permissions-related logging.
|
||||
#LOGGING['loggers']['awx.main.access']['level'] = 'DEBUG'
|
||||
#LOGGING['loggers']['awx.main.signals']['level'] = 'DEBUG'
|
||||
@ -154,74 +76,6 @@ LOGGING['handlers']['management_playbooks'] = {'class': 'logging.NullHandler'}
|
||||
#LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console']
|
||||
#LOGGING['loggers']['django_auth_ldap']['level'] = 'DEBUG'
|
||||
|
||||
###############################################################################
|
||||
# SCM TEST SETTINGS
|
||||
###############################################################################
|
||||
|
||||
# Define these variables to enable more complete testing of project support for
|
||||
# SCM updates. The test repositories listed do not have to contain any valid
|
||||
# playbooks.
|
||||
|
||||
try:
|
||||
path = os.path.expanduser(os.path.expandvars('~/.ssh/id_rsa'))
|
||||
TEST_SSH_KEY_DATA = open(path, 'rb').read()
|
||||
except IOError:
|
||||
TEST_SSH_KEY_DATA = ''
|
||||
|
||||
TEST_GIT_USERNAME = ''
|
||||
TEST_GIT_PASSWORD = ''
|
||||
TEST_GIT_KEY_DATA = TEST_SSH_KEY_DATA
|
||||
TEST_GIT_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com.git'
|
||||
TEST_GIT_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs.git'
|
||||
TEST_GIT_PRIVATE_SSH = 'git@github.com:ansible/product-docs.git'
|
||||
|
||||
TEST_SVN_USERNAME = ''
|
||||
TEST_SVN_PASSWORD = ''
|
||||
TEST_SVN_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com'
|
||||
TEST_SVN_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs'
|
||||
|
||||
# To test repo access via SSH login to localhost.
|
||||
import getpass
|
||||
try:
|
||||
TEST_SSH_LOOPBACK_USERNAME = getpass.getuser()
|
||||
except KeyError:
|
||||
TEST_SSH_LOOPBACK_USERNAME = 'root'
|
||||
TEST_SSH_LOOPBACK_PASSWORD = ''
|
||||
|
||||
###############################################################################
|
||||
# INVENTORY IMPORT TEST SETTINGS
|
||||
###############################################################################
|
||||
|
||||
# Define these variables to enable more complete testing of inventory import
|
||||
# from cloud providers.
|
||||
|
||||
# EC2 credentials
|
||||
TEST_AWS_ACCESS_KEY_ID = ''
|
||||
TEST_AWS_SECRET_ACCESS_KEY = ''
|
||||
TEST_AWS_REGIONS = 'all'
|
||||
# Check IAM STS credentials
|
||||
TEST_AWS_SECURITY_TOKEN = ''
|
||||
|
||||
# Rackspace credentials
|
||||
TEST_RACKSPACE_USERNAME = ''
|
||||
TEST_RACKSPACE_API_KEY = ''
|
||||
TEST_RACKSPACE_REGIONS = 'all'
|
||||
|
||||
# VMware credentials
|
||||
TEST_VMWARE_HOST = ''
|
||||
TEST_VMWARE_USER = ''
|
||||
TEST_VMWARE_PASSWORD = ''
|
||||
|
||||
# OpenStack credentials
|
||||
TEST_OPENSTACK_HOST = ''
|
||||
TEST_OPENSTACK_USER = ''
|
||||
TEST_OPENSTACK_PASSWORD = ''
|
||||
TEST_OPENSTACK_PROJECT = ''
|
||||
|
||||
# Azure credentials.
|
||||
TEST_AZURE_USERNAME = ''
|
||||
TEST_AZURE_KEY_DATA = ''
|
||||
|
||||
BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖'
|
||||
BROADCAST_WEBSOCKET_PORT = 8013
|
||||
BROADCAST_WEBSOCKET_VERIFY_CERT = False
|
||||
|
||||
@ -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 = ''
|
||||
@ -30,10 +30,6 @@ SECRET_KEY = None
|
||||
# See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
# Absolute filesystem path to the directory for job status stdout
|
||||
# This directory should not be web-accessible
|
||||
JOBOUTPUT_ROOT = '/var/lib/awx/job_status/'
|
||||
|
||||
# The heartbeat file for the tower scheduler
|
||||
SCHEDULE_METADATA_LOCATION = '/var/lib/awx/.tower_cycle'
|
||||
|
||||
@ -46,15 +42,6 @@ AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx")
|
||||
|
||||
AWX_ISOLATED_USERNAME = 'awx'
|
||||
|
||||
LOGGING['handlers']['tower_warnings']['filename'] = '/var/log/tower/tower.log' # noqa
|
||||
LOGGING['handlers']['callback_receiver']['filename'] = '/var/log/tower/callback_receiver.log' # noqa
|
||||
LOGGING['handlers']['dispatcher']['filename'] = '/var/log/tower/dispatcher.log' # noqa
|
||||
LOGGING['handlers']['wsbroadcast']['filename'] = '/var/log/tower/wsbroadcast.log' # noqa
|
||||
LOGGING['handlers']['task_system']['filename'] = '/var/log/tower/task_system.log' # noqa
|
||||
LOGGING['handlers']['management_playbooks']['filename'] = '/var/log/tower/management_playbooks.log' # noqa
|
||||
LOGGING['handlers']['system_tracking_migrations']['filename'] = '/var/log/tower/tower_system_tracking_migrations.log' # noqa
|
||||
LOGGING['handlers']['rbac_migrations']['filename'] = '/var/log/tower/tower_rbac_migrations.log' # noqa
|
||||
|
||||
# Store a snapshot of default settings at this point before loading any
|
||||
# customizable config files.
|
||||
DEFAULTS_SNAPSHOT = {}
|
||||
|
||||
@ -57,7 +57,7 @@ The UI is built using [ReactJS](https://reactjs.org/docs/getting-started.html) a
|
||||
|
||||
The AWX UI requires the following:
|
||||
|
||||
- Node 10.x LTS
|
||||
- Node 14.x LTS
|
||||
- NPM 6.x LTS
|
||||
|
||||
Run the following to install all the dependencies:
|
||||
|
||||
68
awx/ui_next/package-lock.json
generated
68
awx/ui_next/package-lock.json
generated
@ -3387,12 +3387,18 @@
|
||||
"dev": true
|
||||
},
|
||||
"axios": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz",
|
||||
"integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==",
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
|
||||
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
|
||||
"requires": {
|
||||
"follow-redirects": "1.5.10",
|
||||
"is-buffer": "^2.0.2"
|
||||
"follow-redirects": "^1.10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"follow-redirects": {
|
||||
"version": "1.13.1",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.1.tgz",
|
||||
"integrity": "sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"axobject-query": {
|
||||
@ -4195,6 +4201,16 @@
|
||||
"integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==",
|
||||
"dev": true
|
||||
},
|
||||
"bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"bluebird": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||
@ -5961,6 +5977,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
|
||||
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@ -7911,6 +7928,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"filesize": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-6.0.1.tgz",
|
||||
@ -8110,6 +8134,7 @@
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
|
||||
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"debug": "=3.1.0"
|
||||
}
|
||||
@ -9500,11 +9525,6 @@
|
||||
"call-bind": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"is-buffer": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
|
||||
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ=="
|
||||
},
|
||||
"is-callable": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz",
|
||||
@ -10315,7 +10335,11 @@
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
||||
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"bindings": "^1.5.0",
|
||||
"nan": "^2.12.1"
|
||||
}
|
||||
},
|
||||
"is-buffer": {
|
||||
"version": "1.1.6",
|
||||
@ -11731,7 +11755,8 @@
|
||||
"ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
|
||||
"dev": true
|
||||
},
|
||||
"multicast-dns": {
|
||||
"version": "6.2.3",
|
||||
@ -11755,6 +11780,13 @@
|
||||
"integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=",
|
||||
"dev": true
|
||||
},
|
||||
"nan": {
|
||||
"version": "2.14.2",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz",
|
||||
"integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"nanomatch": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
|
||||
@ -17683,7 +17715,11 @@
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
||||
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"bindings": "^1.5.0",
|
||||
"nan": "^2.12.1"
|
||||
}
|
||||
},
|
||||
"glob-parent": {
|
||||
"version": "3.1.0",
|
||||
@ -18364,7 +18400,11 @@
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
||||
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"bindings": "^1.5.0",
|
||||
"nan": "^2.12.1"
|
||||
}
|
||||
},
|
||||
"glob-parent": {
|
||||
"version": "3.1.0",
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
"@patternfly/react-icons": "4.7.22",
|
||||
"@patternfly/react-table": "^4.19.15",
|
||||
"ansi-to-html": "^0.6.11",
|
||||
"axios": "^0.18.1",
|
||||
"axios": "^0.21.1",
|
||||
"codemirror": "^5.47.0",
|
||||
"d3": "^5.12.0",
|
||||
"dagre": "^0.8.4",
|
||||
|
||||
@ -36,6 +36,10 @@ class Jobs extends RelaunchMixin(Base) {
|
||||
return this.http.post(`/api/v2${getBaseURL(type)}${id}/cancel/`);
|
||||
}
|
||||
|
||||
readCredentials(id, type) {
|
||||
return this.http.get(`/api/v2${getBaseURL(type)}${id}/credentials/`);
|
||||
}
|
||||
|
||||
readDetail(id, type) {
|
||||
return this.http.get(`/api/v2${getBaseURL(type)}${id}/`);
|
||||
}
|
||||
|
||||
@ -57,7 +57,7 @@ function AdHocCommands({ adHocItems, i18n, hasListItems }) {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
const {
|
||||
isloading: isLaunchLoading,
|
||||
isLoading: isLaunchLoading,
|
||||
error: launchError,
|
||||
request: launchAdHocCommands,
|
||||
} = useRequest(
|
||||
|
||||
@ -58,7 +58,7 @@ function AdHocCredentialStep({ i18n, credentialTypeId, onEnableLaunch }) {
|
||||
return <ContentError error={error} />;
|
||||
}
|
||||
if (isLoading) {
|
||||
return <ContentLoading error={error} />;
|
||||
return <ContentLoading />;
|
||||
}
|
||||
return (
|
||||
<Form>
|
||||
|
||||
@ -144,7 +144,7 @@ class AddResourceRole extends React.Component {
|
||||
currentStepId,
|
||||
maxEnabledStep,
|
||||
} = this.state;
|
||||
const { onClose, roles, i18n } = this.props;
|
||||
const { onClose, roles, i18n, resource } = this.props;
|
||||
|
||||
// Object roles can be user only, so we remove them when
|
||||
// showing role choices for team access
|
||||
@ -235,18 +235,24 @@ class AddResourceRole extends React.Component {
|
||||
t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.`
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SelectableCard
|
||||
isSelected={selectedResource === 'users'}
|
||||
label={i18n._(t`Users`)}
|
||||
dataCy="add-role-users"
|
||||
ariaLabel={i18n._(t`Users`)}
|
||||
onClick={() => this.handleResourceSelect('users')}
|
||||
/>
|
||||
<SelectableCard
|
||||
isSelected={selectedResource === 'teams'}
|
||||
label={i18n._(t`Teams`)}
|
||||
dataCy="add-role-teams"
|
||||
onClick={() => this.handleResourceSelect('teams')}
|
||||
/>
|
||||
{resource?.type === 'credential' &&
|
||||
!resource?.organization ? null : (
|
||||
<SelectableCard
|
||||
isSelected={selectedResource === 'teams'}
|
||||
label={i18n._(t`Teams`)}
|
||||
dataCy="add-role-teams"
|
||||
ariaLabel={i18n._(t`Teams`)}
|
||||
onClick={() => this.handleResourceSelect('teams')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
enableNext: selectedResource !== null,
|
||||
@ -329,10 +335,12 @@ AddResourceRole.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
roles: PropTypes.shape(),
|
||||
resource: PropTypes.shape(),
|
||||
};
|
||||
|
||||
AddResourceRole.defaultProps = {
|
||||
roles: {},
|
||||
resource: {},
|
||||
};
|
||||
|
||||
export { AddResourceRole as _AddResourceRole };
|
||||
|
||||
@ -221,4 +221,22 @@ describe('<_AddResourceRole />', () => {
|
||||
expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2);
|
||||
expect(handleSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not display team as a choice in case credential does not have organization', () => {
|
||||
const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect');
|
||||
const wrapper = mountWithContexts(
|
||||
<AddResourceRole
|
||||
onClose={() => {}}
|
||||
onSave={() => {}}
|
||||
roles={roles}
|
||||
resource={{ type: 'credential', organization: null }}
|
||||
/>,
|
||||
{ context: { network: { handleHttpError: () => {} } } }
|
||||
).find('AddResourceRole');
|
||||
const selectableCardWrapper = wrapper.find('SelectableCard');
|
||||
expect(selectableCardWrapper.length).toBe(1);
|
||||
selectableCardWrapper.first().simulate('click');
|
||||
expect(spy).toHaveBeenCalledWith('users');
|
||||
expect(wrapper.state('selectedResource')).toBe('users');
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,12 +6,12 @@ const mockData = [
|
||||
{
|
||||
key: 'baz',
|
||||
label: 'Baz',
|
||||
value: '/venv/baz/',
|
||||
value: '/var/lib/awx/venv/baz/',
|
||||
},
|
||||
{
|
||||
key: 'default',
|
||||
label: 'Default',
|
||||
value: '/venv/ansible/',
|
||||
value: '/var/lib/awx/venv/ansible/',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import 'codemirror/mode/javascript/javascript';
|
||||
import 'codemirror/mode/yaml/yaml';
|
||||
import 'codemirror/mode/jinja2/jinja2';
|
||||
import 'codemirror/lib/codemirror.css';
|
||||
import 'codemirror/addon/display/placeholder';
|
||||
|
||||
const LINE_HEIGHT = 24;
|
||||
const PADDING = 12;
|
||||
@ -55,6 +56,17 @@ const CodeMirror = styled(ReactCodeMirror)`
|
||||
background-color: var(--pf-c-form-control--disabled--BackgroundColor);
|
||||
}
|
||||
`}
|
||||
${props =>
|
||||
props.options &&
|
||||
props.options.placeholder &&
|
||||
`
|
||||
.CodeMirror-empty {
|
||||
pre.CodeMirror-placeholder {
|
||||
color: var(--pf-c-form-control--placeholder--Color);
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
function CodeMirrorInput({
|
||||
@ -66,6 +78,7 @@ function CodeMirrorInput({
|
||||
rows,
|
||||
fullHeight,
|
||||
className,
|
||||
placeholder,
|
||||
}) {
|
||||
// Workaround for CodeMirror bug: If CodeMirror renders in a modal on the
|
||||
// modal's initial render, it appears as an empty box due to mis-calculated
|
||||
@ -92,6 +105,7 @@ function CodeMirrorInput({
|
||||
smartIndent: false,
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
placeholder,
|
||||
readOnly,
|
||||
}}
|
||||
fullHeight={fullHeight}
|
||||
|
||||
@ -1,22 +1,25 @@
|
||||
import React from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { withI18n } from '@lingui/react';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
EmptyState as PFEmptyState,
|
||||
EmptyStateBody,
|
||||
EmptyStateIcon,
|
||||
Spinner,
|
||||
} from '@patternfly/react-core';
|
||||
|
||||
const EmptyState = styled(PFEmptyState)`
|
||||
--pf-c-empty-state--m-lg--MaxWidth: none;
|
||||
min-height: 250px;
|
||||
`;
|
||||
|
||||
// TODO: Better loading state - skeleton lines / spinner, etc.
|
||||
const ContentLoading = ({ className, i18n }) => (
|
||||
<EmptyState variant="full" className={className}>
|
||||
<EmptyStateBody>{i18n._(t`Loading...`)}</EmptyStateBody>
|
||||
</EmptyState>
|
||||
);
|
||||
const ContentLoading = ({ className }) => {
|
||||
return (
|
||||
<EmptyState variant="full" className={className}>
|
||||
<EmptyStateIcon variant="container" component={Spinner} />
|
||||
</EmptyState>
|
||||
);
|
||||
};
|
||||
|
||||
export { ContentLoading as _ContentLoading };
|
||||
export default withI18n()(ContentLoading);
|
||||
export default ContentLoading;
|
||||
|
||||
@ -16,10 +16,17 @@ function CredentialChip({ credential, i18n, i18nHash, ...props }) {
|
||||
type = toTitleCase(credential.kind);
|
||||
}
|
||||
|
||||
const buildCredentialName = () => {
|
||||
if (credential.kind === 'vault' && credential.inputs?.vault_id) {
|
||||
return `${credential.name} | ${credential.inputs.vault_id}`;
|
||||
}
|
||||
return `${credential.name}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Chip {...props}>
|
||||
<strong>{type}: </strong>
|
||||
{credential.name}
|
||||
{buildCredentialName()}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
23
awx/ui_next/src/components/LoadingSpinner/LoadingSpinner.jsx
Normal file
23
awx/ui_next/src/components/LoadingSpinner/LoadingSpinner.jsx
Normal 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;
|
||||
1
awx/ui_next/src/components/LoadingSpinner/index.js
Normal file
1
awx/ui_next/src/components/LoadingSpinner/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './LoadingSpinner';
|
||||
@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import {
|
||||
arrayOf,
|
||||
bool,
|
||||
@ -8,7 +9,6 @@ import {
|
||||
string,
|
||||
oneOfType,
|
||||
} from 'prop-types';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { FormGroup } from '@patternfly/react-core';
|
||||
@ -39,13 +39,13 @@ function CredentialLookup({
|
||||
credentialTypeKind,
|
||||
credentialTypeNamespace,
|
||||
value,
|
||||
history,
|
||||
i18n,
|
||||
tooltip,
|
||||
isDisabled,
|
||||
autoPopulate,
|
||||
multiple,
|
||||
}) {
|
||||
const history = useHistory();
|
||||
const autoPopulateLookup = useAutoPopulateLookup(onChange);
|
||||
const {
|
||||
result: { count, credentials, relatedSearchableKeys, searchableKeys },
|
||||
@ -72,22 +72,28 @@ function CredentialLookup({
|
||||
...typeNamespaceParams,
|
||||
})
|
||||
),
|
||||
CredentialsAPI.readOptions,
|
||||
CredentialsAPI.readOptions(),
|
||||
]);
|
||||
|
||||
if (autoPopulate) {
|
||||
autoPopulateLookup(data.results);
|
||||
}
|
||||
|
||||
const searchKeys = Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable);
|
||||
const item = searchKeys.indexOf('type');
|
||||
if (item) {
|
||||
searchKeys[item] = 'credential_type__kind';
|
||||
}
|
||||
|
||||
return {
|
||||
count: data.count,
|
||||
credentials: data.results,
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data?.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data?.actions?.GET[key]?.filterable),
|
||||
searchableKeys: searchKeys,
|
||||
};
|
||||
}, [
|
||||
autoPopulate,
|
||||
@ -222,4 +228,4 @@ CredentialLookup.defaultProps = {
|
||||
};
|
||||
|
||||
export { CredentialLookup as _CredentialLookup };
|
||||
export default withI18n()(withRouter(CredentialLookup));
|
||||
export default withI18n()(CredentialLookup);
|
||||
|
||||
@ -13,7 +13,7 @@ import useRequest from '../../util/useRequest';
|
||||
import Lookup from './Lookup';
|
||||
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||
|
||||
const QS_CONFIG = getQSConfig('instance_groups', {
|
||||
const QS_CONFIG = getQSConfig('instance-groups', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
|
||||
@ -16,6 +16,7 @@ const QS_CONFIG = getQSConfig('inventory', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
role_level: 'use_role',
|
||||
});
|
||||
|
||||
function InventoryLookup({
|
||||
@ -29,6 +30,7 @@ function InventoryLookup({
|
||||
fieldId,
|
||||
promptId,
|
||||
promptName,
|
||||
isOverrideDisabled,
|
||||
}) {
|
||||
const {
|
||||
result: {
|
||||
@ -57,8 +59,10 @@ function InventoryLookup({
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
canEdit: Boolean(actionsResponse.data.actions.POST),
|
||||
canEdit:
|
||||
Boolean(actionsResponse.data.actions.POST) || isOverrideDisabled,
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [history.location]),
|
||||
{
|
||||
inventories: [],
|
||||
@ -195,11 +199,13 @@ InventoryLookup.propTypes = {
|
||||
value: Inventory,
|
||||
onChange: func.isRequired,
|
||||
required: bool,
|
||||
isOverrideDisabled: bool,
|
||||
};
|
||||
|
||||
InventoryLookup.defaultProps = {
|
||||
value: null,
|
||||
required: false,
|
||||
isOverrideDisabled: false,
|
||||
};
|
||||
|
||||
export default withI18n()(withRouter(InventoryLookup));
|
||||
|
||||
87
awx/ui_next/src/components/Lookup/InventoryLookup.test.jsx
Normal file
87
awx/ui_next/src/components/Lookup/InventoryLookup.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -71,6 +71,16 @@ function MultiCredentialsLookup(props) {
|
||||
loadCredentials(params, selectedType.id),
|
||||
CredentialsAPI.readOptions(),
|
||||
]);
|
||||
|
||||
results.map(result => {
|
||||
if (result.kind === 'vault' && result.inputs?.vault_id) {
|
||||
result.label = `${result.name} | ${result.inputs.vault_id}`;
|
||||
return result;
|
||||
}
|
||||
result.label = `${result.name}`;
|
||||
return result;
|
||||
});
|
||||
|
||||
return {
|
||||
credentials: results,
|
||||
credentialsCount: count,
|
||||
@ -108,7 +118,6 @@ function MultiCredentialsLookup(props) {
|
||||
credential={item}
|
||||
/>
|
||||
);
|
||||
|
||||
const isVault = selectedType?.kind === 'vault';
|
||||
|
||||
return (
|
||||
@ -187,6 +196,7 @@ function MultiCredentialsLookup(props) {
|
||||
relatedSearchableKeys={relatedSearchableKeys}
|
||||
multiple={isVault}
|
||||
header={i18n._(t`Credentials`)}
|
||||
displayKey={isVault ? 'label' : 'name'}
|
||||
name="credentials"
|
||||
qsConfig={QS_CONFIG}
|
||||
readOnly={!canDelete}
|
||||
|
||||
@ -87,6 +87,23 @@ describe('<MultiCredentialsLookup />', () => {
|
||||
name: 'Cred 5',
|
||||
url: 'www.google.com',
|
||||
},
|
||||
|
||||
{
|
||||
id: 6,
|
||||
credential_type: 5,
|
||||
kind: 'vault',
|
||||
name: 'Cred 6',
|
||||
url: 'www.google.com',
|
||||
inputs: { vault_id: 'vault ID' },
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
credential_type: 5,
|
||||
kind: 'vault',
|
||||
name: 'Cred 7',
|
||||
url: 'www.google.com',
|
||||
inputs: {},
|
||||
},
|
||||
],
|
||||
count: 3,
|
||||
},
|
||||
@ -196,7 +213,13 @@ describe('<MultiCredentialsLookup />', () => {
|
||||
wrapper.update();
|
||||
expect(CredentialsAPI.read).toHaveBeenCalledTimes(2);
|
||||
expect(wrapper.find('OptionsList').prop('options')).toEqual([
|
||||
{ id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' },
|
||||
{
|
||||
id: 1,
|
||||
kind: 'cloud',
|
||||
name: 'New Cred',
|
||||
url: 'www.google.com',
|
||||
label: 'New Cred',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@ -268,6 +291,36 @@ describe('<MultiCredentialsLookup />', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('should properly render vault credential labels', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<MultiCredentialsLookup
|
||||
value={credentials}
|
||||
tooltip="This is credentials look up"
|
||||
onChange={() => {}}
|
||||
onError={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const searchButton = await waitForElement(
|
||||
wrapper,
|
||||
'Button[aria-label="Search"]'
|
||||
);
|
||||
await act(async () => {
|
||||
searchButton.invoke('onClick')();
|
||||
});
|
||||
wrapper.update();
|
||||
const typeSelect = wrapper.find('AnsibleSelect');
|
||||
act(() => {
|
||||
typeSelect.invoke('onChange')({}, 500);
|
||||
});
|
||||
wrapper.update();
|
||||
const optionsList = wrapper.find('OptionsList');
|
||||
expect(optionsList.prop('multiple')).toEqual(true);
|
||||
expect(wrapper.find('CheckboxListItem[label="Cred 6 | vault ID"]'));
|
||||
expect(wrapper.find('CheckboxListItem[label="Cred 7"]'));
|
||||
});
|
||||
|
||||
test('should allow multiple vault credentials with no vault id', async () => {
|
||||
const onChange = jest.fn();
|
||||
await act(async () => {
|
||||
|
||||
@ -18,6 +18,7 @@ const QS_CONFIG = getQSConfig('project', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
role_level: 'use_role',
|
||||
});
|
||||
|
||||
function ProjectLookup({
|
||||
@ -31,6 +32,7 @@ function ProjectLookup({
|
||||
value,
|
||||
onBlur,
|
||||
history,
|
||||
isOverrideDisabled,
|
||||
}) {
|
||||
const autoPopulateLookup = useAutoPopulateLookup(onChange);
|
||||
const {
|
||||
@ -57,8 +59,10 @@ function ProjectLookup({
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
canEdit: Boolean(actionsResponse.data.actions.POST),
|
||||
canEdit:
|
||||
Boolean(actionsResponse.data.actions.POST) || isOverrideDisabled,
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoPopulate, autoPopulateLookup, history.location.search]),
|
||||
{
|
||||
count: 0,
|
||||
@ -160,6 +164,7 @@ ProjectLookup.propTypes = {
|
||||
required: bool,
|
||||
tooltip: string,
|
||||
value: Project,
|
||||
isOverrideDisabled: bool,
|
||||
};
|
||||
|
||||
ProjectLookup.defaultProps = {
|
||||
@ -170,6 +175,7 @@ ProjectLookup.defaultProps = {
|
||||
required: false,
|
||||
tooltip: '',
|
||||
value: null,
|
||||
isOverrideDisabled: false,
|
||||
};
|
||||
|
||||
export { ProjectLookup as _ProjectLookup };
|
||||
|
||||
@ -7,6 +7,10 @@ import ProjectLookup from './ProjectLookup';
|
||||
jest.mock('../../api');
|
||||
|
||||
describe('<ProjectLookup />', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should auto-select project when only one available and autoPopulate prop is true', async () => {
|
||||
ProjectsAPI.read.mockReturnValue({
|
||||
data: {
|
||||
@ -48,4 +52,46 @@ describe('<ProjectLookup />', () => {
|
||||
});
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('project lookup should be enabled', async () => {
|
||||
let wrapper;
|
||||
|
||||
ProjectsAPI.readOptions.mockReturnValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
},
|
||||
related_search_fields: [],
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ProjectLookup isOverrideDisabled onChange={() => {}} />
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(ProjectsAPI.read).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.find('ProjectLookup')).toHaveLength(1);
|
||||
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
|
||||
});
|
||||
|
||||
test('project lookup should be disabled', async () => {
|
||||
let wrapper;
|
||||
|
||||
ProjectsAPI.readOptions.mockReturnValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
},
|
||||
related_search_fields: [],
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ProjectLookup onChange={() => {}} />);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(ProjectsAPI.read).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.find('ProjectLookup')).toHaveLength(1);
|
||||
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import { DataList } from '@patternfly/react-core';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withRouter, useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
import ListHeader from '../ListHeader';
|
||||
import ContentEmpty from '../ContentEmpty';
|
||||
@ -21,167 +22,155 @@ import {
|
||||
import { QSConfig, SearchColumns, SortColumns } from '../../types';
|
||||
|
||||
import PaginatedDataListItem from './PaginatedDataListItem';
|
||||
import LoadingSpinner from '../LoadingSpinner';
|
||||
|
||||
class PaginatedDataList extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleSetPage = this.handleSetPage.bind(this);
|
||||
this.handleSetPageSize = this.handleSetPageSize.bind(this);
|
||||
this.handleListItemSelect = this.handleListItemSelect.bind(this);
|
||||
}
|
||||
|
||||
handleListItemSelect = (id = 0) => {
|
||||
const { items, onRowClick } = this.props;
|
||||
function PaginatedDataList({
|
||||
items,
|
||||
onRowClick,
|
||||
contentError,
|
||||
hasContentLoading,
|
||||
emptyStateControls,
|
||||
itemCount,
|
||||
qsConfig,
|
||||
renderItem,
|
||||
toolbarSearchColumns,
|
||||
toolbarSearchableKeys,
|
||||
toolbarRelatedSearchableKeys,
|
||||
toolbarSortColumns,
|
||||
pluralizedItemName,
|
||||
showPageSizeOptions,
|
||||
location,
|
||||
i18n,
|
||||
renderToolbar,
|
||||
}) {
|
||||
const { search, pathname } = useLocation();
|
||||
const history = useHistory();
|
||||
const handleListItemSelect = (id = 0) => {
|
||||
const match = items.find(item => item.id === Number(id));
|
||||
onRowClick(match);
|
||||
};
|
||||
|
||||
handleSetPage(event, pageNumber) {
|
||||
const { history, qsConfig } = this.props;
|
||||
const { search } = history.location;
|
||||
const handleSetPage = (event, pageNumber) => {
|
||||
const oldParams = parseQueryString(qsConfig, search);
|
||||
this.pushHistoryState(replaceParams(oldParams, { page: pageNumber }));
|
||||
}
|
||||
pushHistoryState(replaceParams(oldParams, { page: pageNumber }));
|
||||
};
|
||||
|
||||
handleSetPageSize(event, pageSize, page) {
|
||||
const { history, qsConfig } = this.props;
|
||||
const { search } = history.location;
|
||||
const handleSetPageSize = (event, pageSize, page) => {
|
||||
const oldParams = parseQueryString(qsConfig, search);
|
||||
this.pushHistoryState(
|
||||
replaceParams(oldParams, { page_size: pageSize, page })
|
||||
);
|
||||
}
|
||||
pushHistoryState(replaceParams(oldParams, { page_size: pageSize, page }));
|
||||
};
|
||||
|
||||
pushHistoryState(params) {
|
||||
const { history, qsConfig } = this.props;
|
||||
const { pathname } = history.location;
|
||||
const pushHistoryState = params => {
|
||||
const encodedParams = encodeNonDefaultQueryString(qsConfig, params);
|
||||
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
contentError,
|
||||
hasContentLoading,
|
||||
emptyStateControls,
|
||||
items,
|
||||
itemCount,
|
||||
qsConfig,
|
||||
renderItem,
|
||||
toolbarSearchColumns,
|
||||
toolbarSearchableKeys,
|
||||
toolbarRelatedSearchableKeys,
|
||||
toolbarSortColumns,
|
||||
pluralizedItemName,
|
||||
showPageSizeOptions,
|
||||
location,
|
||||
i18n,
|
||||
renderToolbar,
|
||||
} = this.props;
|
||||
const searchColumns = toolbarSearchColumns.length
|
||||
? toolbarSearchColumns
|
||||
: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
];
|
||||
const sortColumns = toolbarSortColumns.length
|
||||
? toolbarSortColumns
|
||||
: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
];
|
||||
const queryParams = parseQueryString(qsConfig, location.search);
|
||||
const searchColumns = toolbarSearchColumns.length
|
||||
? toolbarSearchColumns
|
||||
: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
];
|
||||
const sortColumns = toolbarSortColumns.length
|
||||
? toolbarSortColumns
|
||||
: [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
];
|
||||
const queryParams = parseQueryString(qsConfig, location.search);
|
||||
|
||||
const dataListLabel = i18n._(t`${pluralizedItemName} List`);
|
||||
const emptyContentMessage = i18n._(
|
||||
t`Please add ${pluralizedItemName} to populate this list `
|
||||
const dataListLabel = i18n._(t`${pluralizedItemName} List`);
|
||||
const emptyContentMessage = i18n._(
|
||||
t`Please add ${pluralizedItemName} to populate this list `
|
||||
);
|
||||
const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `);
|
||||
|
||||
let Content;
|
||||
if (hasContentLoading && items.length <= 0) {
|
||||
Content = <ContentLoading />;
|
||||
} else if (contentError) {
|
||||
Content = <ContentError error={contentError} />;
|
||||
} else if (items.length <= 0) {
|
||||
Content = (
|
||||
<ContentEmpty title={emptyContentTitle} message={emptyContentMessage} />
|
||||
);
|
||||
const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `);
|
||||
|
||||
let Content;
|
||||
if (hasContentLoading && items.length <= 0) {
|
||||
Content = <ContentLoading />;
|
||||
} else if (contentError) {
|
||||
Content = <ContentError error={contentError} />;
|
||||
} else if (items.length <= 0) {
|
||||
Content = (
|
||||
<ContentEmpty title={emptyContentTitle} message={emptyContentMessage} />
|
||||
);
|
||||
} else {
|
||||
Content = (
|
||||
} else {
|
||||
Content = (
|
||||
<>
|
||||
{hasContentLoading && <LoadingSpinner />}
|
||||
<DataList
|
||||
aria-label={dataListLabel}
|
||||
onSelectDataListItem={id => this.handleListItemSelect(id)}
|
||||
onSelectDataListItem={id => handleListItemSelect(id)}
|
||||
>
|
||||
{items.map(renderItem)}
|
||||
</DataList>
|
||||
);
|
||||
}
|
||||
|
||||
const ToolbarPagination = (
|
||||
<Pagination
|
||||
isCompact
|
||||
dropDirection="down"
|
||||
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({
|
||||
|
||||
@ -11,6 +11,7 @@ import ContentError from '../ContentError';
|
||||
import ContentLoading from '../ContentLoading';
|
||||
import Pagination from '../Pagination';
|
||||
import DataListToolbar from '../DataListToolbar';
|
||||
import LoadingSpinner from '../LoadingSpinner';
|
||||
|
||||
import {
|
||||
encodeNonDefaultQueryString,
|
||||
@ -82,10 +83,13 @@ function PaginatedTable({
|
||||
);
|
||||
} else {
|
||||
Content = (
|
||||
<TableComposable aria-label={dataListLabel}>
|
||||
{headerRow}
|
||||
<Tbody>{items.map(renderRow)}</Tbody>
|
||||
</TableComposable>
|
||||
<>
|
||||
{hasContentLoading && <LoadingSpinner />}
|
||||
<TableComposable aria-label={dataListLabel}>
|
||||
{headerRow}
|
||||
<Tbody>{items.map(renderRow)}</Tbody>
|
||||
</TableComposable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -155,6 +155,7 @@ function ResourceAccessList({ i18n, apiModel, resource }) {
|
||||
fetchAccessRecords();
|
||||
}}
|
||||
roles={resource.summary_fields.object_roles}
|
||||
resource={resource}
|
||||
/>
|
||||
)}
|
||||
{showDeleteModal && (
|
||||
|
||||
@ -62,7 +62,7 @@ function ScheduleList({
|
||||
scheduleActions.data.actions?.GET || {}
|
||||
).filter(key => scheduleActions.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [location, loadSchedules, loadScheduleOptions]),
|
||||
}, [location.search, loadSchedules, loadScheduleOptions]),
|
||||
{
|
||||
schedules: [],
|
||||
itemCount: 0,
|
||||
|
||||
@ -31,7 +31,14 @@ const Description = styled.p`
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
function SelectableCard({ label, description, onClick, isSelected, dataCy }) {
|
||||
function SelectableCard({
|
||||
label,
|
||||
description,
|
||||
onClick,
|
||||
isSelected,
|
||||
dataCy,
|
||||
ariaLabel,
|
||||
}) {
|
||||
return (
|
||||
<SelectableItem
|
||||
onClick={onClick}
|
||||
@ -40,6 +47,7 @@ function SelectableCard({ label, description, onClick, isSelected, dataCy }) {
|
||||
tabIndex="0"
|
||||
data-cy={dataCy}
|
||||
isSelected={isSelected}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<Indicator isSelected={isSelected} />
|
||||
<Contents>
|
||||
@ -55,12 +63,14 @@ SelectableCard.propTypes = {
|
||||
description: PropTypes.string,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
isSelected: PropTypes.bool,
|
||||
ariaLabel: PropTypes.string,
|
||||
};
|
||||
|
||||
SelectableCard.defaultProps = {
|
||||
label: '',
|
||||
description: '',
|
||||
isSelected: false,
|
||||
ariaLabel: '',
|
||||
};
|
||||
|
||||
export default SelectableCard;
|
||||
|
||||
@ -7,7 +7,11 @@ import { Button } from '@patternfly/react-core';
|
||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import { CardBody, CardActionsRow } from '../../../components/Card';
|
||||
import { Detail, DetailList } from '../../../components/DetailList';
|
||||
import {
|
||||
Detail,
|
||||
DetailList,
|
||||
UserDateDetail,
|
||||
} from '../../../components/DetailList';
|
||||
import { ApplicationsAPI } from '../../../api';
|
||||
import DeleteButton from '../../../components/DeleteButton';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
@ -98,6 +102,11 @@ function ApplicationDetails({
|
||||
value={getClientType(application.client_type)}
|
||||
dataCy="app-detail-client-type"
|
||||
/>
|
||||
<UserDateDetail label={i18n._(t`Created`)} date={application.created} />
|
||||
<UserDateDetail
|
||||
label={i18n._(t`Last Modified`)}
|
||||
date={application.modified}
|
||||
/>
|
||||
</DetailList>
|
||||
<CardActionsRow>
|
||||
{application.summary_fields.user_capabilities &&
|
||||
|
||||
@ -56,15 +56,12 @@ function Credential({ i18n, setBreadcrumb }) {
|
||||
id: 99,
|
||||
},
|
||||
{ name: i18n._(t`Details`), link: `/credentials/${id}/details`, id: 0 },
|
||||
];
|
||||
|
||||
if (credential && credential.organization) {
|
||||
tabsArray.push({
|
||||
{
|
||||
name: i18n._(t`Access`),
|
||||
link: `/credentials/${id}/access`,
|
||||
id: 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
let showCardHeader = true;
|
||||
|
||||
@ -108,14 +105,12 @@ function Credential({ i18n, setBreadcrumb }) {
|
||||
<Route key="edit" path="/credentials/:id/edit">
|
||||
<CredentialEdit credential={credential} />
|
||||
</Route>,
|
||||
credential.organization && (
|
||||
<Route key="access" path="/credentials/:id/access">
|
||||
<ResourceAccessList
|
||||
resource={credential}
|
||||
apiModel={CredentialsAPI}
|
||||
/>
|
||||
</Route>
|
||||
),
|
||||
<Route key="access" path="/credentials/:id/access">
|
||||
<ResourceAccessList
|
||||
resource={credential}
|
||||
apiModel={CredentialsAPI}
|
||||
/>
|
||||
</Route>,
|
||||
<Route key="not-found" path="*">
|
||||
{!hasContentLoading && (
|
||||
<ContentError isNotFound>
|
||||
|
||||
@ -31,7 +31,7 @@ describe('<Credential />', () => {
|
||||
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 2);
|
||||
await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 3);
|
||||
});
|
||||
|
||||
test('initially renders org-based credential succesfully', async () => {
|
||||
|
||||
@ -78,7 +78,7 @@ function CredentialDetail({ i18n, credential }) {
|
||||
{}
|
||||
),
|
||||
};
|
||||
}, [credentialId, credential_type]),
|
||||
}, [credentialId, credential_type.id]),
|
||||
{
|
||||
fields: [],
|
||||
managedByTower: true,
|
||||
|
||||
@ -26,7 +26,13 @@ function CredentialList({ i18n }) {
|
||||
const location = useLocation();
|
||||
|
||||
const {
|
||||
result: { credentials, credentialCount, actions },
|
||||
result: {
|
||||
credentials,
|
||||
credentialCount,
|
||||
actions,
|
||||
relatedSearchableKeys,
|
||||
searchableKeys,
|
||||
},
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchCredentials,
|
||||
@ -37,16 +43,29 @@ function CredentialList({ i18n }) {
|
||||
CredentialsAPI.read(params),
|
||||
CredentialsAPI.readOptions(),
|
||||
]);
|
||||
const searchKeys = Object.keys(
|
||||
credActions.data.actions?.GET || {}
|
||||
).filter(key => credActions.data.actions?.GET[key].filterable);
|
||||
const item = searchKeys.indexOf('type');
|
||||
if (item) {
|
||||
searchKeys[item] = 'credential_type__kind';
|
||||
}
|
||||
return {
|
||||
credentials: creds.data.results,
|
||||
credentialCount: creds.data.count,
|
||||
actions: credActions.data.actions,
|
||||
relatedSearchableKeys: (
|
||||
credActions?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: searchKeys,
|
||||
};
|
||||
}, [location]),
|
||||
{
|
||||
credentials: [],
|
||||
credentialCount: 0,
|
||||
actions: {},
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -102,6 +121,8 @@ function CredentialList({ i18n }) {
|
||||
itemCount={credentialCount}
|
||||
qsConfig={QS_CONFIG}
|
||||
onRowClick={handleSelect}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
|
||||
@ -275,6 +275,11 @@
|
||||
"type": "string",
|
||||
"help_text": "OpenStack domains define administrative boundaries. It is only needed for Keystone v3 authentication URLs. Refer to Ansible Tower documentation for common scenarios."
|
||||
},
|
||||
{
|
||||
"id": "project_region_name",
|
||||
"label": "Region Name",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "verify_ssl",
|
||||
"label": "Verify SSL",
|
||||
|
||||
@ -18,7 +18,7 @@ import DatalistToolbar from '../../../components/DataListToolbar';
|
||||
|
||||
import CredentialTypeListItem from './CredentialTypeListItem';
|
||||
|
||||
const QS_CONFIG = getQSConfig('credential_type', {
|
||||
const QS_CONFIG = getQSConfig('credential-type', {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
managed_by_tower: false,
|
||||
|
||||
@ -20,7 +20,7 @@ import useRequest from '../../util/useRequest';
|
||||
import { DashboardAPI } from '../../api';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
||||
import JobList from '../../components/JobList';
|
||||
|
||||
import ContentLoading from '../../components/ContentLoading';
|
||||
import LineChart from './shared/LineChart';
|
||||
import Count from './shared/Count';
|
||||
import DashboardTemplateList from './shared/DashboardTemplateList';
|
||||
@ -62,6 +62,7 @@ function Dashboard({ i18n }) {
|
||||
const [activeTabId, setActiveTabId] = useState(0);
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
result: { jobGraphData, countData },
|
||||
request: fetchDashboardGraph,
|
||||
} = useRequest(
|
||||
@ -105,7 +106,15 @@ function Dashboard({ i18n }) {
|
||||
useEffect(() => {
|
||||
fetchDashboardGraph();
|
||||
}, [fetchDashboardGraph, periodSelection, jobTypeSelection]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<ContentLoading />
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
<Breadcrumbs breadcrumbConfig={{ '/home': i18n._(t`Dashboard`) }} />
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
@ -20,29 +20,22 @@ import HostDetail from './HostDetail';
|
||||
import HostEdit from './HostEdit';
|
||||
import HostGroups from './HostGroups';
|
||||
import { HostsAPI } from '../../api';
|
||||
import useRequest from '../../util/useRequest';
|
||||
|
||||
function Host({ i18n, setBreadcrumb }) {
|
||||
const [host, setHost] = useState(null);
|
||||
const [contentError, setContentError] = useState(null);
|
||||
const [hasContentLoading, setHasContentLoading] = useState(true);
|
||||
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch('/hosts/:id');
|
||||
const { error, isLoading, result: host, request: fetchHost } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await HostsAPI.readDetail(match.params.id);
|
||||
setBreadcrumb(data);
|
||||
return data;
|
||||
}, [match.params.id, setBreadcrumb])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setContentError(null);
|
||||
try {
|
||||
const { data } = await HostsAPI.readDetail(match.params.id);
|
||||
setHost(data);
|
||||
setBreadcrumb(data);
|
||||
} catch (error) {
|
||||
setContentError(error);
|
||||
} finally {
|
||||
setHasContentLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [match.params.id, location, setBreadcrumb]);
|
||||
fetchHost();
|
||||
}, [fetchHost, location]);
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
@ -77,7 +70,7 @@ function Host({ i18n, setBreadcrumb }) {
|
||||
},
|
||||
];
|
||||
|
||||
if (hasContentLoading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
@ -87,12 +80,12 @@ function Host({ i18n, setBreadcrumb }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (contentError) {
|
||||
if (error) {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<ContentError error={contentError}>
|
||||
{contentError?.response?.status === 404 && (
|
||||
<ContentError error={error}>
|
||||
{error?.response?.status === 404 && (
|
||||
<span>
|
||||
{i18n._(t`Host not found.`)}{' '}
|
||||
<Link to="/hosts">{i18n._(t`View all Hosts.`)}</Link>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { HostsAPI } from '../../api';
|
||||
@ -28,7 +29,11 @@ describe('<Host />', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<Host setBreadcrumb={() => {}} />);
|
||||
wrapper = mountWithContexts(
|
||||
<Route path="/hosts/:id/details">
|
||||
<Host setBreadcrumb={() => {}} />
|
||||
</Route>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -83,7 +83,7 @@
|
||||
"PWD": "/tmp/awx_13_r1ffeqze/project",
|
||||
"HOME": "/var/lib/awx",
|
||||
"LANG": "\"en-us\"",
|
||||
"PATH": "/venv/ansible/bin:/venv/awx/bin:/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"PATH": "/var/lib/awx/venv/ansible/bin:/var/lib/awx/venv/awx/bin:/var/lib/awx/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"SHLVL": "4",
|
||||
"JOB_ID": "13",
|
||||
"LC_ALL": "en_US.UTF-8",
|
||||
@ -96,9 +96,9 @@
|
||||
"SDB_PORT": "7899",
|
||||
"MAKEFLAGS": "w",
|
||||
"MAKELEVEL": "2",
|
||||
"PYTHONPATH": "/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
||||
"PYTHONPATH": "/var/lib/awx/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
||||
"CURRENT_UID": "501",
|
||||
"VIRTUAL_ENV": "/venv/ansible",
|
||||
"VIRTUAL_ENV": "/var/lib/awx/venv/ansible",
|
||||
"INVENTORY_ID": "1",
|
||||
"MAX_EVENT_RES": "700000",
|
||||
"PROOT_TMP_DIR": "/tmp",
|
||||
@ -106,7 +106,7 @@
|
||||
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
|
||||
"AWX_GROUP_QUEUES": "tower",
|
||||
"PROJECT_REVISION": "9e2cd25bfb26ba82f40cf31276e1942bf38b3a30",
|
||||
"ANSIBLE_VENV_PATH": "/venv/ansible",
|
||||
"ANSIBLE_VENV_PATH": "/var/lib/awx/venv/ansible",
|
||||
"ANSIBLE_ROLES_PATH": "/tmp/awx_13_r1ffeqze/requirements_roles:~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles",
|
||||
"RUNNER_OMIT_EVENTS": "False",
|
||||
"SUPERVISOR_ENABLED": "1",
|
||||
@ -119,7 +119,7 @@
|
||||
"DJANGO_SETTINGS_MODULE": "awx.settings.development",
|
||||
"ANSIBLE_STDOUT_CALLBACK": "awx_display",
|
||||
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher",
|
||||
"ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
||||
"ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
||||
"ANSIBLE_COLLECTIONS_PATHS": "/tmp/awx_13_r1ffeqze/requirements_collections:~/.ansible/collections:/usr/share/ansible/collections",
|
||||
"ANSIBLE_HOST_KEY_CHECKING": "False",
|
||||
"RUNNER_ONLY_FAILED_EVENTS": "False",
|
||||
|
||||
@ -123,7 +123,7 @@ describe('<ContainerGroupEdit/>', () => {
|
||||
});
|
||||
|
||||
test('called InstanceGroupsAPI.readOptions', async () => {
|
||||
expect(InstanceGroupsAPI.readOptions).toHaveBeenCalledTimes(1);
|
||||
expect(InstanceGroupsAPI.readOptions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('handleCancel returns the user to container group detail', async () => {
|
||||
|
||||
@ -18,7 +18,7 @@ import AddDropDownButton from '../../../components/AddDropDownButton';
|
||||
|
||||
import InstanceGroupListItem from './InstanceGroupListItem';
|
||||
|
||||
const QS_CONFIG = getQSConfig('instance_group', {
|
||||
const QS_CONFIG = getQSConfig('instance-group', {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
@ -58,7 +58,7 @@ describe('InventorySourceDetail', () => {
|
||||
assertDetail(wrapper, 'Description', 'mock description');
|
||||
assertDetail(wrapper, 'Source', 'Sourced from a Project');
|
||||
assertDetail(wrapper, 'Organization', 'Mock Org');
|
||||
assertDetail(wrapper, 'Ansible environment', '/venv/custom');
|
||||
assertDetail(wrapper, 'Ansible environment', '/var/lib/awx/venv/custom');
|
||||
assertDetail(wrapper, 'Project', 'Mock Project');
|
||||
assertDetail(wrapper, 'Inventory file', 'foo');
|
||||
assertDetail(wrapper, 'Verbosity', '2 (Debug)');
|
||||
|
||||
@ -55,7 +55,7 @@ const InventorySourceFormFields = ({ source, sourceOptions, i18n }) => {
|
||||
const [venvField] = useField('custom_virtualenv');
|
||||
const defaultVenv = {
|
||||
label: i18n._(t`Use Default Ansible Environment`),
|
||||
value: '/venv/ansible/',
|
||||
value: '/var/lib/awx/venv/ansible/',
|
||||
key: 'default',
|
||||
};
|
||||
|
||||
|
||||
@ -83,7 +83,7 @@
|
||||
"PWD": "/tmp/awx_13_r1ffeqze/project",
|
||||
"HOME": "/var/lib/awx",
|
||||
"LANG": "\"en-us\"",
|
||||
"PATH": "/venv/ansible/bin:/venv/awx/bin:/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"PATH": "/var/lib/awx/venv/ansible/bin:/var/lib/awx/venv/awx/bin:/var/lib/awx/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"SHLVL": "4",
|
||||
"JOB_ID": "13",
|
||||
"LC_ALL": "en_US.UTF-8",
|
||||
@ -96,9 +96,9 @@
|
||||
"SDB_PORT": "7899",
|
||||
"MAKEFLAGS": "w",
|
||||
"MAKELEVEL": "2",
|
||||
"PYTHONPATH": "/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
||||
"PYTHONPATH": "/var/lib/awx/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
||||
"CURRENT_UID": "501",
|
||||
"VIRTUAL_ENV": "/venv/ansible",
|
||||
"VIRTUAL_ENV": "/var/lib/awx/venv/ansible",
|
||||
"INVENTORY_ID": "1",
|
||||
"MAX_EVENT_RES": "700000",
|
||||
"PROOT_TMP_DIR": "/tmp",
|
||||
@ -106,7 +106,7 @@
|
||||
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
|
||||
"AWX_GROUP_QUEUES": "tower",
|
||||
"PROJECT_REVISION": "9e2cd25bfb26ba82f40cf31276e1942bf38b3a30",
|
||||
"ANSIBLE_VENV_PATH": "/venv/ansible",
|
||||
"ANSIBLE_VENV_PATH": "/var/lib/awx/venv/ansible",
|
||||
"ANSIBLE_ROLES_PATH": "/tmp/awx_13_r1ffeqze/requirements_roles:~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles",
|
||||
"RUNNER_OMIT_EVENTS": "False",
|
||||
"SUPERVISOR_ENABLED": "1",
|
||||
@ -119,7 +119,7 @@
|
||||
"DJANGO_SETTINGS_MODULE": "awx.settings.development",
|
||||
"ANSIBLE_STDOUT_CALLBACK": "awx_display",
|
||||
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher",
|
||||
"ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
||||
"ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
||||
"ANSIBLE_COLLECTIONS_PATHS": "/tmp/awx_13_r1ffeqze/requirements_collections:~/.ansible/collections:/usr/share/ansible/collections",
|
||||
"ANSIBLE_HOST_KEY_CHECKING": "False",
|
||||
"RUNNER_ONLY_FAILED_EVENTS": "False",
|
||||
|
||||
@ -98,7 +98,7 @@
|
||||
"credential": 8,
|
||||
"overwrite":true,
|
||||
"overwrite_vars":true,
|
||||
"custom_virtualenv":"/venv/custom",
|
||||
"custom_virtualenv":"/var/lib/awx/venv/custom",
|
||||
"timeout":0,
|
||||
"verbosity":2,
|
||||
"last_job_run":null,
|
||||
|
||||
@ -29,10 +29,18 @@ function Job({ i18n, setBreadcrumb }) {
|
||||
const { isLoading, error, request: fetchJob, result } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await JobsAPI.readDetail(id, type);
|
||||
if (
|
||||
data?.summary_fields?.credentials?.find(cred => cred.kind === 'vault')
|
||||
) {
|
||||
const {
|
||||
data: { results },
|
||||
} = await JobsAPI.readCredentials(data.id, type);
|
||||
|
||||
data.summary_fields.credentials = results;
|
||||
}
|
||||
setBreadcrumb(data);
|
||||
return data;
|
||||
}, [id, type, setBreadcrumb]),
|
||||
null
|
||||
}, [id, type, setBreadcrumb])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -7,7 +7,11 @@ import { Button, Chip, Label } from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import { DetailList, Detail } from '../../../components/DetailList';
|
||||
import {
|
||||
DetailList,
|
||||
Detail,
|
||||
UserDateDetail,
|
||||
} from '../../../components/DetailList';
|
||||
import { CardBody, CardActionsRow } from '../../../components/Card';
|
||||
import ChipGroup from '../../../components/ChipGroup';
|
||||
import CredentialChip from '../../../components/CredentialChip';
|
||||
@ -80,6 +84,7 @@ const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => {
|
||||
|
||||
function JobDetail({ job, i18n }) {
|
||||
const {
|
||||
created_by,
|
||||
credential,
|
||||
credentials,
|
||||
instance_group: instanceGroup,
|
||||
@ -289,6 +294,12 @@ function JobDetail({ job, i18n }) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<UserDateDetail
|
||||
label={i18n._(t`Created`)}
|
||||
date={job.created}
|
||||
user={created_by}
|
||||
/>
|
||||
<UserDateDetail label={i18n._(t`Last Modified`)} date={job.modified} />
|
||||
</DetailList>
|
||||
{job.extra_vars && (
|
||||
<VariablesInput
|
||||
|
||||
@ -114,7 +114,7 @@
|
||||
"started": "2019-08-08T19:24:18.329589Z",
|
||||
"finished": "2019-08-08T19:24:50.119995Z",
|
||||
"elapsed": 31.79,
|
||||
"job_args": "[\"bwrap\", \"--unshare-pid\", \"--dev-bind\", \"/\", \"/\", \"--proc\", \"/proc\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpvsg8ly2y\", \"/etc/ssh\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq_grmdym\", \"/projects\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpfq8ea2z6\", \"/tmp\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq6v4y_tt\", \"/var/lib/awx\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpupj_jhhb\", \"/var/log\", \"--ro-bind\", \"/venv/ansible\", \"/venv/ansible\", \"--ro-bind\", \"/venv/awx\", \"/venv/awx\", \"--bind\", \"/projects/_6__demo_project\", \"/projects/_6__demo_project\", \"--bind\", \"/tmp/awx_2_a4b1afiw\", \"/tmp/awx_2_a4b1afiw\", \"--chdir\", \"/projects/_6__demo_project\", \"ansible-playbook\", \"-u\", \"admin\", \"-i\", \"/tmp/awx_2_a4b1afiw/tmppb57i4_e\", \"-e\", \"@/tmp/awx_2_a4b1afiw/env/extravars\", \"chatty_tasks.yml\"]",
|
||||
"job_args": "[\"bwrap\", \"--unshare-pid\", \"--dev-bind\", \"/\", \"/\", \"--proc\", \"/proc\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpvsg8ly2y\", \"/etc/ssh\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq_grmdym\", \"/projects\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpfq8ea2z6\", \"/tmp\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq6v4y_tt\", \"/var/lib/awx\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpupj_jhhb\", \"/var/log\", \"--ro-bind\", \"/var/lib/awx/venv/ansible\", \"/var/lib/awx/venv/ansible\", \"--ro-bind\", \"/var/lib/awx/venv/awx\", \"/var/lib/awx/venv/awx\", \"--bind\", \"/projects/_6__demo_project\", \"/projects/_6__demo_project\", \"--bind\", \"/tmp/awx_2_a4b1afiw\", \"/tmp/awx_2_a4b1afiw\", \"--chdir\", \"/projects/_6__demo_project\", \"ansible-playbook\", \"-u\", \"admin\", \"-i\", \"/tmp/awx_2_a4b1afiw/tmppb57i4_e\", \"-e\", \"@/tmp/awx_2_a4b1afiw/env/extravars\", \"chatty_tasks.yml\"]",
|
||||
"job_cwd": "/projects/_6__demo_project",
|
||||
"job_env": {
|
||||
"HOSTNAME": "awx",
|
||||
@ -123,9 +123,9 @@
|
||||
"LC_ALL": "en_US.UTF-8",
|
||||
"SDB_HOST": "0.0.0.0",
|
||||
"MAKELEVEL": "2",
|
||||
"VIRTUAL_ENV": "/venv/ansible",
|
||||
"VIRTUAL_ENV": "/var/lib/awx/venv/ansible",
|
||||
"MFLAGS": "-w",
|
||||
"PATH": "/venv/ansible/bin:/venv/awx/bin:/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"PATH": "/var/lib/awx/venv/ansible/bin:/var/lib/awx/venv/awx/bin:/var/lib/awx/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"SUPERVISOR_GROUP_NAME": "tower-processes",
|
||||
"PWD": "/awx_devel",
|
||||
"LANG": "\"en-us\"",
|
||||
@ -138,7 +138,7 @@
|
||||
"SUPERVISOR_SERVER_URL": "unix:///tmp/supervisor.sock",
|
||||
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher",
|
||||
"CURRENT_UID": "501",
|
||||
"_": "/venv/awx/bin/python3",
|
||||
"_": "/var/lib/awx/venv/awx/bin/python3",
|
||||
"DJANGO_SETTINGS_MODULE": "awx.settings.development",
|
||||
"DJANGO_LIVE_TEST_SERVER_ADDRESS": "localhost:9013-9199",
|
||||
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
|
||||
@ -147,11 +147,11 @@
|
||||
"ANSIBLE_HOST_KEY_CHECKING": "False",
|
||||
"ANSIBLE_INVENTORY_UNPARSED_FAILED": "True",
|
||||
"ANSIBLE_PARAMIKO_RECORD_HOST_KEYS": "False",
|
||||
"ANSIBLE_VENV_PATH": "/venv/ansible",
|
||||
"ANSIBLE_VENV_PATH": "/var/lib/awx/venv/ansible",
|
||||
"PROOT_TMP_DIR": "/tmp",
|
||||
"AWX_PRIVATE_DATA_DIR": "/tmp/awx_2_a4b1afiw",
|
||||
"ANSIBLE_COLLECTIONS_PATHS": "/tmp/collections",
|
||||
"PYTHONPATH": "/venv/ansible/lib/python2.7/site-packages:/awx_devel/awx/lib:",
|
||||
"PYTHONPATH": "/var/lib/awx/venv/ansible/lib/python2.7/site-packages:/awx_devel/awx/lib:",
|
||||
"JOB_ID": "2",
|
||||
"INVENTORY_ID": "1",
|
||||
"PROJECT_REVISION": "23f070aad8e2da131d97ea98b42b553ccf0b0b82",
|
||||
@ -184,5 +184,5 @@
|
||||
"play_count": 1,
|
||||
"task_count": 1
|
||||
},
|
||||
"custom_virtualenv": "/venv/ansible"
|
||||
"custom_virtualenv": "/var/lib/awx/venv/ansible"
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
ArrayDetail,
|
||||
DetailList,
|
||||
DeletedDetail,
|
||||
UserDateDetail,
|
||||
} from '../../../components/DetailList';
|
||||
import CodeDetail from '../../../components/DetailList/CodeDetail';
|
||||
import DeleteButton from '../../../components/DeleteButton';
|
||||
@ -23,6 +24,8 @@ function NotificationTemplateDetail({ i18n, template, defaultMessages }) {
|
||||
const history = useHistory();
|
||||
|
||||
const {
|
||||
created,
|
||||
modified,
|
||||
notification_configuration: configuration,
|
||||
summary_fields,
|
||||
messages,
|
||||
@ -324,6 +327,16 @@ function NotificationTemplateDetail({ i18n, template, defaultMessages }) {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<UserDateDetail
|
||||
label={i18n._(t`Created`)}
|
||||
date={created}
|
||||
user={summary_fields?.created_by}
|
||||
/>
|
||||
<UserDateDetail
|
||||
label={i18n._(t`Last Modified`)}
|
||||
date={modified}
|
||||
user={summary_fields?.modified_by}
|
||||
/>
|
||||
{hasCustomMessages(messages, typeMessageDefaults) && (
|
||||
<CustomMessageDetails
|
||||
messages={messages}
|
||||
|
||||
@ -153,7 +153,7 @@ describe('<OrganizationAdd />', () => {
|
||||
.find('FormSelectOption')
|
||||
.first()
|
||||
.prop('value')
|
||||
).toEqual('/venv/ansible/');
|
||||
).toEqual('/var/lib/awx/venv/ansible/');
|
||||
});
|
||||
|
||||
test('AnsibleSelect component does not render if there are 0 virtual environments', async () => {
|
||||
|
||||
@ -31,7 +31,7 @@ function OrganizationFormFields({ i18n, instanceGroups, setInstanceGroups }) {
|
||||
|
||||
const defaultVenv = {
|
||||
label: i18n._(t`Use Default Ansible Environment`),
|
||||
value: '/venv/ansible/',
|
||||
value: '/var/lib/awx/venv/ansible/',
|
||||
key: 'default',
|
||||
};
|
||||
const { custom_virtualenvs } = useContext(ConfigContext);
|
||||
|
||||
@ -200,7 +200,7 @@ describe('<OrganizationForm />', () => {
|
||||
.find('FormSelectOption')
|
||||
.first()
|
||||
.prop('value')
|
||||
).toEqual('/venv/ansible/');
|
||||
).toEqual('/var/lib/awx/venv/ansible/');
|
||||
});
|
||||
|
||||
test('onSubmit associates and disassociates instance groups', async () => {
|
||||
|
||||
@ -44,6 +44,19 @@ function Project({ i18n, setBreadcrumb }) {
|
||||
role_level: 'notification_admin_role',
|
||||
}),
|
||||
]);
|
||||
|
||||
if (data.summary_fields.credentials) {
|
||||
const params = {
|
||||
page: 1,
|
||||
page_size: 200,
|
||||
order_by: 'name',
|
||||
};
|
||||
const {
|
||||
data: { results },
|
||||
} = await ProjectsAPI.readCredentials(data.id, params);
|
||||
|
||||
data.summary_fields.credentials = results;
|
||||
}
|
||||
return {
|
||||
project: data,
|
||||
isNotifAdmin: notifAdminRes.data.results.length > 0,
|
||||
|
||||
@ -24,7 +24,7 @@ describe('<ProjectAdd />', () => {
|
||||
scm_update_on_launch: true,
|
||||
scm_update_cache_timeout: 3,
|
||||
allow_override: false,
|
||||
custom_virtualenv: '/venv/custom-env',
|
||||
custom_virtualenv: '/var/lib/awx/venv/custom-env',
|
||||
};
|
||||
|
||||
const projectOptionsResolve = {
|
||||
|
||||
@ -19,6 +19,7 @@ import CredentialChip from '../../../components/CredentialChip';
|
||||
import { ProjectsAPI } from '../../../api';
|
||||
import { toTitleCase } from '../../../util/strings';
|
||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||
import ProjectSyncButton from '../shared/ProjectSyncButton';
|
||||
|
||||
function ProjectDetail({ project, i18n }) {
|
||||
const {
|
||||
@ -148,27 +149,28 @@ function ProjectDetail({ project, i18n }) {
|
||||
/>
|
||||
</DetailList>
|
||||
<CardActionsRow>
|
||||
{summary_fields.user_capabilities &&
|
||||
summary_fields.user_capabilities.edit && (
|
||||
<Button
|
||||
aria-label={i18n._(t`edit`)}
|
||||
component={Link}
|
||||
to={`/projects/${id}/edit`}
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
)}
|
||||
{summary_fields.user_capabilities &&
|
||||
summary_fields.user_capabilities.delete && (
|
||||
<DeleteButton
|
||||
name={name}
|
||||
modalTitle={i18n._(t`Delete Project`)}
|
||||
onConfirm={deleteProject}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</DeleteButton>
|
||||
)}
|
||||
{summary_fields.user_capabilities?.edit && (
|
||||
<Button
|
||||
aria-label={i18n._(t`edit`)}
|
||||
component={Link}
|
||||
to={`/projects/${id}/edit`}
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
)}
|
||||
{summary_fields.user_capabilities?.start && (
|
||||
<ProjectSyncButton projectId={project.id} />
|
||||
)}
|
||||
{summary_fields.user_capabilities?.delete && (
|
||||
<DeleteButton
|
||||
name={name}
|
||||
modalTitle={i18n._(t`Delete Project`)}
|
||||
onConfirm={deleteProject}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</DeleteButton>
|
||||
)}
|
||||
</CardActionsRow>
|
||||
{/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */}
|
||||
{error && (
|
||||
|
||||
@ -9,7 +9,12 @@ import { ProjectsAPI } from '../../../api';
|
||||
import ProjectDetail from './ProjectDetail';
|
||||
|
||||
jest.mock('../../../api');
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useRouteMatch: () => ({
|
||||
url: '/projects/1/details',
|
||||
}),
|
||||
}));
|
||||
describe('<ProjectDetail />', () => {
|
||||
const mockProject = {
|
||||
id: 1,
|
||||
@ -139,13 +144,19 @@ describe('<ProjectDetail />', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should show edit button for users with edit permission', async () => {
|
||||
test('should show edit and sync button for users with edit permission', async () => {
|
||||
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
|
||||
const editButton = await waitForElement(
|
||||
wrapper,
|
||||
'ProjectDetail Button[aria-label="edit"]'
|
||||
);
|
||||
|
||||
const syncButton = await waitForElement(
|
||||
wrapper,
|
||||
'ProjectDetail Button[aria-label="Sync Project"]'
|
||||
);
|
||||
expect(editButton.text()).toEqual('Edit');
|
||||
expect(syncButton.text()).toEqual('Sync');
|
||||
expect(editButton.prop('to')).toBe(`/projects/${mockProject.id}/edit`);
|
||||
});
|
||||
|
||||
@ -166,6 +177,9 @@ describe('<ProjectDetail />', () => {
|
||||
expect(wrapper.find('ProjectDetail Button[aria-label="edit"]').length).toBe(
|
||||
0
|
||||
);
|
||||
expect(wrapper.find('ProjectDetail Button[aria-label="sync"]').length).toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test('edit button should navigate to project edit', () => {
|
||||
@ -180,6 +194,17 @@ describe('<ProjectDetail />', () => {
|
||||
expect(history.location.pathname).toEqual('/projects/1/edit');
|
||||
});
|
||||
|
||||
test('sync button should call api to syn project', async () => {
|
||||
ProjectsAPI.readSync.mockResolvedValue({ data: { can_update: true } });
|
||||
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
|
||||
await act(() =>
|
||||
wrapper
|
||||
.find('ProjectDetail Button[aria-label="Sync Project"]')
|
||||
.prop('onClick')(1)
|
||||
);
|
||||
expect(ProjectsAPI.sync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('expected api calls are made for delete', async () => {
|
||||
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
|
||||
await waitForElement(wrapper, 'ProjectDetail Button[aria-label="Delete"]');
|
||||
|
||||
@ -25,7 +25,7 @@ describe('<ProjectEdit />', () => {
|
||||
scm_update_on_launch: true,
|
||||
scm_update_cache_timeout: 3,
|
||||
allow_override: false,
|
||||
custom_virtualenv: '/venv/custom-env',
|
||||
custom_virtualenv: '/var/lib/awx/venv/custom-env',
|
||||
summary_fields: {
|
||||
credential: {
|
||||
id: 100,
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PencilAltIcon, SyncIcon } from '@patternfly/react-icons';
|
||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
import { formatDateString, timeOfDay } from '../../../util/dates';
|
||||
import { ProjectsAPI } from '../../../api';
|
||||
@ -153,23 +153,10 @@ function ProjectListItem({
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
{project.summary_fields.user_capabilities.start ? (
|
||||
{project.summary_fields.user_capabilities.start && (
|
||||
<Tooltip content={i18n._(t`Sync Project`)} position="top">
|
||||
<ProjectSyncButton projectId={project.id}>
|
||||
{handleSync => (
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
aria-label={i18n._(t`Sync Project`)}
|
||||
variant="plain"
|
||||
onClick={handleSync}
|
||||
>
|
||||
<SyncIcon />
|
||||
</Button>
|
||||
)}
|
||||
</ProjectSyncButton>
|
||||
<ProjectSyncButton projectId={project.id} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
{project.summary_fields.user_capabilities.edit ? (
|
||||
<Tooltip content={i18n._(t`Edit Project`)} position="top">
|
||||
|
||||
@ -284,11 +284,11 @@ function ProjectFormFields({
|
||||
data={[
|
||||
{
|
||||
label: i18n._(t`Use Default Ansible Environment`),
|
||||
value: '/venv/ansible/',
|
||||
value: '/var/lib/awx/venv/ansible/',
|
||||
key: 'default',
|
||||
},
|
||||
...custom_virtualenvs
|
||||
.filter(datum => datum !== '/venv/ansible/')
|
||||
.filter(datum => datum !== '/var/lib/awx/venv/ansible/')
|
||||
.map(datum => ({
|
||||
label: datum,
|
||||
value: datum,
|
||||
|
||||
@ -22,7 +22,7 @@ describe('<ProjectForm />', () => {
|
||||
scm_update_on_launch: true,
|
||||
scm_update_cache_timeout: 3,
|
||||
allow_override: false,
|
||||
custom_virtualenv: '/venv/custom-env',
|
||||
custom_virtualenv: '/var/lib/awx/venv/custom-env',
|
||||
summary_fields: {
|
||||
credential: {
|
||||
id: 100,
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useRouteMatch } from 'react-router-dom';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { SyncIcon } from '@patternfly/react-icons';
|
||||
|
||||
import { number } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
@ -8,28 +12,27 @@ import AlertModal from '../../../components/AlertModal';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import { ProjectsAPI } from '../../../api';
|
||||
|
||||
function ProjectSyncButton({ i18n, children, projectId }) {
|
||||
function ProjectSyncButton({ i18n, projectId }) {
|
||||
const match = useRouteMatch();
|
||||
|
||||
const { request: handleSync, error: syncError } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await ProjectsAPI.readSync(projectId);
|
||||
if (data.can_update) {
|
||||
await ProjectsAPI.sync(projectId);
|
||||
} else {
|
||||
throw new Error(
|
||||
i18n._(
|
||||
t`You don't have the necessary permissions to sync this project.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [i18n, projectId]),
|
||||
await ProjectsAPI.sync(projectId);
|
||||
}, [projectId]),
|
||||
null
|
||||
);
|
||||
|
||||
const { error, dismissError } = useDismissableError(syncError);
|
||||
|
||||
const isDetailsView = match.url.endsWith('/details');
|
||||
return (
|
||||
<>
|
||||
{children(handleSync)}
|
||||
<Button
|
||||
aria-label={i18n._(t`Sync Project`)}
|
||||
variant={isDetailsView ? 'secondary' : 'plain'}
|
||||
onClick={handleSync}
|
||||
>
|
||||
{match.url.endsWith('/details') ? i18n._(t`Sync`) : <SyncIcon />}
|
||||
</Button>
|
||||
{error && (
|
||||
<AlertModal
|
||||
isOpen={error}
|
||||
|
||||
@ -10,11 +10,6 @@ jest.mock('../../../api');
|
||||
|
||||
describe('ProjectSyncButton', () => {
|
||||
let wrapper;
|
||||
ProjectsAPI.readSync.mockResolvedValue({
|
||||
data: {
|
||||
can_update: true,
|
||||
},
|
||||
});
|
||||
|
||||
const children = handleSync => (
|
||||
<button type="submit" onClick={() => handleSync()} />
|
||||
@ -43,8 +38,7 @@ describe('ProjectSyncButton', () => {
|
||||
await act(async () => {
|
||||
button.prop('onClick')();
|
||||
});
|
||||
expect(ProjectsAPI.readSync).toHaveBeenCalledWith(1);
|
||||
await sleep(0);
|
||||
|
||||
expect(ProjectsAPI.sync).toHaveBeenCalledWith(1);
|
||||
});
|
||||
test('displays error modal after unsuccessful sync', async () => {
|
||||
|
||||
@ -86,6 +86,7 @@ function ActivityStreamDetail({ i18n }) {
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
ouiaId="edit-button"
|
||||
to="/settings/activity_stream/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
|
||||
@ -78,6 +78,7 @@ function AzureADDetail({ i18n }) {
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
ouiaId="edit-button"
|
||||
to="/settings/azure/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
|
||||
@ -6,6 +6,8 @@ import { PageSection, Card } from '@patternfly/react-core';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import GitHubDetail from './GitHubDetail';
|
||||
import GitHubEdit from './GitHubEdit';
|
||||
import GitHubOrgEdit from './GitHubOrgEdit';
|
||||
import GitHubTeamEdit from './GitHubTeamEdit';
|
||||
|
||||
function GitHub({ i18n }) {
|
||||
const baseURL = '/settings/github';
|
||||
@ -29,9 +31,15 @@ function GitHub({ i18n }) {
|
||||
<Route path={`${baseURL}/:category/details`}>
|
||||
<GitHubDetail />
|
||||
</Route>
|
||||
<Route path={`${baseURL}/:category/edit`}>
|
||||
<Route path={`${baseURL}/default/edit`}>
|
||||
<GitHubEdit />
|
||||
</Route>
|
||||
<Route path={`${baseURL}/organization/edit`}>
|
||||
<GitHubOrgEdit />
|
||||
</Route>
|
||||
<Route path={`${baseURL}/team/edit`}>
|
||||
<GitHubTeamEdit />
|
||||
</Route>
|
||||
<Route key="not-found" path={`${baseURL}/*`}>
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${baseURL}/default/details`}>
|
||||
|
||||
@ -5,33 +5,94 @@ import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import GitHub from './GitHub';
|
||||
import { SettingsAPI } from '../../../api';
|
||||
import { SettingsProvider } from '../../../contexts/Settings';
|
||||
import mockAllOptions from '../shared/data.allSettingOptions.json';
|
||||
import GitHub from './GitHub';
|
||||
|
||||
jest.mock('../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {},
|
||||
});
|
||||
|
||||
describe('<GitHub />', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
SettingsAPI.readCategory.mockResolvedValueOnce({
|
||||
data: {
|
||||
SOCIAL_AUTH_GITHUB_CALLBACK_URL:
|
||||
'https://towerhost/sso/complete/github/',
|
||||
SOCIAL_AUTH_GITHUB_KEY: 'mock github key',
|
||||
SOCIAL_AUTH_GITHUB_SECRET: '$encrypted$',
|
||||
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: null,
|
||||
SOCIAL_AUTH_GITHUB_TEAM_MAP: null,
|
||||
},
|
||||
});
|
||||
SettingsAPI.readCategory.mockResolvedValueOnce({
|
||||
data: {
|
||||
SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL:
|
||||
'https://towerhost/sso/complete/github-org/',
|
||||
SOCIAL_AUTH_GITHUB_ORG_KEY: '',
|
||||
SOCIAL_AUTH_GITHUB_ORG_SECRET: '$encrypted$',
|
||||
SOCIAL_AUTH_GITHUB_ORG_NAME: '',
|
||||
SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null,
|
||||
SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
|
||||
},
|
||||
});
|
||||
SettingsAPI.readCategory.mockResolvedValueOnce({
|
||||
data: {
|
||||
SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL:
|
||||
'https://towerhost/sso/complete/github-team/',
|
||||
SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)',
|
||||
SOCIAL_AUTH_GITHUB_TEAM_SECRET: '$encrypted$',
|
||||
SOCIAL_AUTH_GITHUB_TEAM_ID: 'team_id',
|
||||
SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {},
|
||||
SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render github details', async () => {
|
||||
test('should render github default details', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/github/'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<GitHub />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GitHub />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('GitHubDetail').length).toBe(1);
|
||||
expect(wrapper.find('Detail[label="GitHub OAuth2 Key"]').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should redirect to github organization category details', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/github/organization'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GitHub />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('GitHubDetail').length).toBe(1);
|
||||
expect(
|
||||
wrapper.find('Detail[label="GitHub Organization OAuth2 Key"]').length
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test('should render github edit', async () => {
|
||||
@ -39,9 +100,14 @@ describe('<GitHub />', () => {
|
||||
initialEntries: ['/settings/github/default/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<GitHub />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GitHub />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('GitHubEdit').length).toBe(1);
|
||||
@ -52,9 +118,14 @@ describe('<GitHub />', () => {
|
||||
initialEntries: ['/settings/github/foo/bar'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<GitHub />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GitHub />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
|
||||
@ -114,6 +114,7 @@ function GitHubDetail({ i18n }) {
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
ouiaId="edit-button"
|
||||
to={`${baseURL}/${category}/edit`}
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
|
||||
@ -1,25 +1,141 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Formik } from 'formik';
|
||||
import { Form } from '@patternfly/react-core';
|
||||
import { CardBody } from '../../../../components/Card';
|
||||
import ContentError from '../../../../components/ContentError';
|
||||
import ContentLoading from '../../../../components/ContentLoading';
|
||||
import { FormSubmitError } from '../../../../components/FormField';
|
||||
import { FormColumnLayout } from '../../../../components/FormLayout';
|
||||
import { useSettings } from '../../../../contexts/Settings';
|
||||
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
|
||||
import {
|
||||
EncryptedField,
|
||||
InputField,
|
||||
ObjectField,
|
||||
} from '../../shared/SharedFields';
|
||||
import { formatJson } from '../../shared/settingUtils';
|
||||
import useModal from '../../../../util/useModal';
|
||||
import useRequest from '../../../../util/useRequest';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
|
||||
function GitHubEdit() {
|
||||
const history = useHistory();
|
||||
const { isModalOpen, toggleModal, closeModal } = useModal();
|
||||
const { PUT: options } = useSettings();
|
||||
|
||||
const { isLoading, error, request: fetchGithub, result: github } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await SettingsAPI.readCategory('github');
|
||||
const mergedData = {};
|
||||
Object.keys(data).forEach(key => {
|
||||
if (!options[key]) {
|
||||
return;
|
||||
}
|
||||
mergedData[key] = options[key];
|
||||
mergedData[key].value = data[key];
|
||||
});
|
||||
return mergedData;
|
||||
}, [options]),
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGithub();
|
||||
}, [fetchGithub]);
|
||||
|
||||
const { error: submitError, request: submitForm } = useRequest(
|
||||
useCallback(
|
||||
async values => {
|
||||
await SettingsAPI.updateAll(values);
|
||||
history.push('/settings/github/details');
|
||||
},
|
||||
[history]
|
||||
),
|
||||
null
|
||||
);
|
||||
|
||||
const handleSubmit = async form => {
|
||||
await submitForm({
|
||||
...form,
|
||||
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: formatJson(
|
||||
form.SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP
|
||||
),
|
||||
SOCIAL_AUTH_GITHUB_TEAM_MAP: formatJson(form.SOCIAL_AUTH_GITHUB_TEAM_MAP),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRevertAll = async () => {
|
||||
const defaultValues = Object.assign(
|
||||
...Object.entries(github).map(([key, value]) => ({
|
||||
[key]: value.default,
|
||||
}))
|
||||
);
|
||||
await submitForm(defaultValues);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push('/settings/github/details');
|
||||
};
|
||||
|
||||
const initialValues = fields =>
|
||||
Object.keys(fields).reduce((acc, key) => {
|
||||
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
|
||||
const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
|
||||
acc[key] = fields[key].value
|
||||
? JSON.stringify(fields[key].value, null, 2)
|
||||
: emptyDefault;
|
||||
} else {
|
||||
acc[key] = fields[key].value ?? '';
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
function GitHubEdit({ i18n }) {
|
||||
return (
|
||||
<CardBody>
|
||||
{i18n._(t`Edit form coming soon :)`)}
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
component={Link}
|
||||
to="/settings/github/details"
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
{isLoading && <ContentLoading />}
|
||||
{!isLoading && error && <ContentError error={error} />}
|
||||
{!isLoading && github && (
|
||||
<Formik initialValues={initialValues(github)} onSubmit={handleSubmit}>
|
||||
{formik => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormColumnLayout>
|
||||
<InputField
|
||||
name="SOCIAL_AUTH_GITHUB_KEY"
|
||||
config={github.SOCIAL_AUTH_GITHUB_KEY}
|
||||
/>
|
||||
<EncryptedField
|
||||
name="SOCIAL_AUTH_GITHUB_SECRET"
|
||||
config={github.SOCIAL_AUTH_GITHUB_SECRET}
|
||||
/>
|
||||
<ObjectField
|
||||
name="SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP"
|
||||
config={github.SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP}
|
||||
/>
|
||||
<ObjectField
|
||||
name="SOCIAL_AUTH_GITHUB_TEAM_MAP"
|
||||
config={github.SOCIAL_AUTH_GITHUB_TEAM_MAP}
|
||||
/>
|
||||
{submitError && <FormSubmitError error={submitError} />}
|
||||
</FormColumnLayout>
|
||||
<RevertFormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
onRevert={toggleModal}
|
||||
/>
|
||||
{isModalOpen && (
|
||||
<RevertAllAlert
|
||||
onClose={closeModal}
|
||||
onRevertAll={handleRevertAll}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
)}
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(GitHubEdit);
|
||||
export default GitHubEdit;
|
||||
|
||||
@ -1,16 +1,173 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../../testUtils/enzymeHelpers';
|
||||
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import GitHubEdit from './GitHubEdit';
|
||||
|
||||
jest.mock('../../../../api/models/Settings');
|
||||
SettingsAPI.updateAll.mockResolvedValue({});
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {
|
||||
SOCIAL_AUTH_GITHUB_CALLBACK_URL: 'https://foo/complete/github/',
|
||||
SOCIAL_AUTH_GITHUB_KEY: 'mock github key',
|
||||
SOCIAL_AUTH_GITHUB_SECRET: '$encrypted$',
|
||||
SOCIAL_AUTH_GITHUB_TEAM_MAP: {},
|
||||
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: {
|
||||
Default: {
|
||||
users: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('<GitHubEdit />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<GitHubEdit />);
|
||||
});
|
||||
let history;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
history = createMemoryHistory({
|
||||
initialEntries: ['/settings/github/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GitHubEdit />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('GitHubEdit').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should display expected form fields', async () => {
|
||||
expect(wrapper.find('FormGroup[label="GitHub OAuth2 Key"]').length).toBe(1);
|
||||
expect(wrapper.find('FormGroup[label="GitHub OAuth2 Secret"]').length).toBe(
|
||||
1
|
||||
);
|
||||
expect(
|
||||
wrapper.find('FormGroup[label="GitHub OAuth2 Organization Map"]').length
|
||||
).toBe(1);
|
||||
expect(
|
||||
wrapper.find('FormGroup[label="GitHub OAuth2 Team Map"]').length
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test('should successfully send default values to api on form revert all', async () => {
|
||||
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
|
||||
expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('button[aria-label="Revert all to default"]')
|
||||
.invoke('onClick')();
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('RevertAllAlert button[aria-label="Confirm revert all"]')
|
||||
.invoke('onClick')();
|
||||
});
|
||||
wrapper.update();
|
||||
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
|
||||
SOCIAL_AUTH_GITHUB_KEY: '',
|
||||
SOCIAL_AUTH_GITHUB_SECRET: '',
|
||||
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: null,
|
||||
SOCIAL_AUTH_GITHUB_TEAM_MAP: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('should successfully send request to api on form submission', async () => {
|
||||
act(() => {
|
||||
wrapper
|
||||
.find(
|
||||
'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_SECRET"] button[aria-label="Revert"]'
|
||||
)
|
||||
.invoke('onClick')();
|
||||
wrapper.find('input#SOCIAL_AUTH_GITHUB_KEY').simulate('change', {
|
||||
target: { value: 'new key', name: 'SOCIAL_AUTH_GITHUB_KEY' },
|
||||
});
|
||||
wrapper
|
||||
.find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP')
|
||||
.invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}');
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper.find('Form').invoke('onSubmit')();
|
||||
});
|
||||
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
|
||||
SOCIAL_AUTH_GITHUB_KEY: 'new key',
|
||||
SOCIAL_AUTH_GITHUB_SECRET: '',
|
||||
SOCIAL_AUTH_GITHUB_TEAM_MAP: {},
|
||||
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: {
|
||||
Default: {
|
||||
users: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should navigate to github default detail on successful submission', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('Form').invoke('onSubmit')();
|
||||
});
|
||||
expect(history.location.pathname).toEqual('/settings/github/details');
|
||||
});
|
||||
|
||||
test('should navigate to github default detail when cancel is clicked', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||
});
|
||||
expect(history.location.pathname).toEqual('/settings/github/details');
|
||||
});
|
||||
|
||||
test('should display error message on unsuccessful submission', async () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: { detail: 'An error occurred' },
|
||||
},
|
||||
};
|
||||
SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
|
||||
expect(wrapper.find('FormSubmitError').length).toBe(0);
|
||||
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
|
||||
await act(async () => {
|
||||
wrapper.find('Form').invoke('onSubmit')();
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('FormSubmitError').length).toBe(1);
|
||||
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should display ContentError on throw', async () => {
|
||||
SettingsAPI.readCategory.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GitHubEdit />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './GitHubOrgEdit';
|
||||
@ -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;
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './GitHubTeamEdit';
|
||||
@ -2,13 +2,28 @@ import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import GoogleOAuth2 from './GoogleOAuth2';
|
||||
|
||||
import { SettingsProvider } from '../../../contexts/Settings';
|
||||
import { SettingsAPI } from '../../../api';
|
||||
import mockAllOptions from '../shared/data.allSettingOptions.json';
|
||||
import GoogleOAuth2 from './GoogleOAuth2';
|
||||
|
||||
jest.mock('../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {},
|
||||
data: {
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL:
|
||||
'https://towerhost/sso/complete/google-oauth2/',
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: 'mock key',
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: '$encrypted$',
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: [
|
||||
'example.com',
|
||||
'example_2.com',
|
||||
],
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: {},
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: {
|
||||
Default: {},
|
||||
},
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: {},
|
||||
},
|
||||
});
|
||||
|
||||
describe('<GoogleOAuth2 />', () => {
|
||||
@ -24,9 +39,14 @@ describe('<GoogleOAuth2 />', () => {
|
||||
initialEntries: ['/settings/google_oauth2/details'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<GoogleOAuth2 />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GoogleOAuth2 />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('GoogleOAuth2Detail').length).toBe(1);
|
||||
});
|
||||
@ -36,9 +56,14 @@ describe('<GoogleOAuth2 />', () => {
|
||||
initialEntries: ['/settings/google_oauth2/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<GoogleOAuth2 />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GoogleOAuth2 />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('GoogleOAuth2Edit').length).toBe(1);
|
||||
});
|
||||
|
||||
@ -78,6 +78,7 @@ function GoogleOAuth2Detail({ i18n }) {
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
ouiaId="edit-button"
|
||||
to="/settings/google_oauth2/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
|
||||
@ -1,25 +1,171 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Formik } from 'formik';
|
||||
import { Form } from '@patternfly/react-core';
|
||||
import { CardBody } from '../../../../components/Card';
|
||||
import ContentError from '../../../../components/ContentError';
|
||||
import ContentLoading from '../../../../components/ContentLoading';
|
||||
import { FormSubmitError } from '../../../../components/FormField';
|
||||
import { FormColumnLayout } from '../../../../components/FormLayout';
|
||||
import { useSettings } from '../../../../contexts/Settings';
|
||||
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
|
||||
import {
|
||||
EncryptedField,
|
||||
InputField,
|
||||
ObjectField,
|
||||
} from '../../shared/SharedFields';
|
||||
import { formatJson } from '../../shared/settingUtils';
|
||||
import useModal from '../../../../util/useModal';
|
||||
import useRequest from '../../../../util/useRequest';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
|
||||
function GoogleOAuth2Edit() {
|
||||
const history = useHistory();
|
||||
const { isModalOpen, toggleModal, closeModal } = useModal();
|
||||
const { PUT: options } = useSettings();
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
request: fetchGoogleOAuth2,
|
||||
result: googleOAuth2,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await SettingsAPI.readCategory('google-oauth2');
|
||||
const mergedData = {};
|
||||
Object.keys(data).forEach(key => {
|
||||
if (!options[key]) {
|
||||
return;
|
||||
}
|
||||
mergedData[key] = options[key];
|
||||
mergedData[key].value = data[key];
|
||||
});
|
||||
return mergedData;
|
||||
}, [options]),
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGoogleOAuth2();
|
||||
}, [fetchGoogleOAuth2]);
|
||||
|
||||
const { error: submitError, request: submitForm } = useRequest(
|
||||
useCallback(
|
||||
async values => {
|
||||
await SettingsAPI.updateAll(values);
|
||||
history.push('/settings/google_oauth2/details');
|
||||
},
|
||||
[history]
|
||||
),
|
||||
null
|
||||
);
|
||||
|
||||
const handleSubmit = async form => {
|
||||
await submitForm({
|
||||
...form,
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: formatJson(
|
||||
form.SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS
|
||||
),
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: formatJson(
|
||||
form.SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS
|
||||
),
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: formatJson(
|
||||
form.SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP
|
||||
),
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: formatJson(
|
||||
form.SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRevertAll = async () => {
|
||||
const defaultValues = Object.assign(
|
||||
...Object.entries(googleOAuth2).map(([key, value]) => ({
|
||||
[key]: value.default,
|
||||
}))
|
||||
);
|
||||
await submitForm(defaultValues);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push('/settings/google_oauth2/details');
|
||||
};
|
||||
|
||||
const initialValues = fields =>
|
||||
Object.keys(fields).reduce((acc, key) => {
|
||||
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
|
||||
const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
|
||||
acc[key] = fields[key].value
|
||||
? JSON.stringify(fields[key].value, null, 2)
|
||||
: emptyDefault;
|
||||
} else {
|
||||
acc[key] = fields[key].value ?? '';
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
function GoogleOAuth2Edit({ i18n }) {
|
||||
return (
|
||||
<CardBody>
|
||||
{i18n._(t`Edit form coming soon :)`)}
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
component={Link}
|
||||
to="/settings/google_oauth2/details"
|
||||
{isLoading && <ContentLoading />}
|
||||
{!isLoading && error && <ContentError error={error} />}
|
||||
{!isLoading && googleOAuth2 && (
|
||||
<Formik
|
||||
initialValues={initialValues(googleOAuth2)}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
{formik => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormColumnLayout>
|
||||
<InputField
|
||||
name="SOCIAL_AUTH_GOOGLE_OAUTH2_KEY"
|
||||
config={googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY}
|
||||
/>
|
||||
<EncryptedField
|
||||
name="SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET"
|
||||
config={googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET}
|
||||
/>
|
||||
<ObjectField
|
||||
name="SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS"
|
||||
config={
|
||||
googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS
|
||||
}
|
||||
/>
|
||||
<ObjectField
|
||||
name="SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS"
|
||||
config={
|
||||
googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS
|
||||
}
|
||||
/>
|
||||
<ObjectField
|
||||
name="SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP"
|
||||
config={
|
||||
googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP
|
||||
}
|
||||
/>
|
||||
<ObjectField
|
||||
name="SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP"
|
||||
config={googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP}
|
||||
/>
|
||||
{submitError && <FormSubmitError error={submitError} />}
|
||||
</FormColumnLayout>
|
||||
<RevertFormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
onRevert={toggleModal}
|
||||
/>
|
||||
{isModalOpen && (
|
||||
<RevertAllAlert
|
||||
onClose={closeModal}
|
||||
onRevertAll={handleRevertAll}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
)}
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(GoogleOAuth2Edit);
|
||||
export default GoogleOAuth2Edit;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user