Merge pull request #27 from ansible/devel

Rebase
This commit is contained in:
Sean Sullivan 2021-01-16 22:51:21 -06:00 committed by GitHub
commit e4cb50921e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
163 changed files with 3425 additions and 1332 deletions

View File

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

View File

@ -85,7 +85,7 @@ If you're not using Docker for Mac, or Docker for Windows, you may need, or choo
#### Frontend Development
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**

View File

@ -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"

View File

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

View File

@ -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:

View File

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

View File

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

View File

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

View File

@ -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:

View File

@ -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)])

View File

@ -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'),

View File

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

View File

@ -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)

View File

@ -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)

View File

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

View File

@ -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):

View File

@ -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}}"

View File

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

View File

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

View File

@ -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")

View File

@ -48,56 +48,12 @@ if "pytest" in sys.modules:
}
}
# Absolute filesystem path to the directory to host projects (with playbooks).
# This directory should NOT be web-accessible.
PROJECTS_ROOT = '/var/lib/awx/projects/'
# Location for cross-development of inventory plugins
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

View File

@ -1,192 +0,0 @@
# Copyright (c) 2015 Ansible, Inc. (formerly AnsibleWorks, Inc.)
# All Rights Reserved.
# Local Django settings for AWX project. Rename to "local_settings.py" and
# edit as needed for your development environment.
# All variables defined in awx/settings/development.py will already be loaded
# into the global namespace before this file is loaded, to allow for reading
# and updating the default settings as needed.
###############################################################################
# MISC PROJECT SETTINGS
###############################################################################
# Database settings to use PostgreSQL for development.
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'awx-dev',
'USER': 'awx-dev',
'PASSWORD': 'AWXsome1',
'HOST': 'localhost',
'PORT': '',
}
}
# Use SQLite for unit tests instead of PostgreSQL. If the lines below are
# commented out, Django will create the test_awx-dev database in PostgreSQL to
# run unit tests.
if is_testing(sys.argv):
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'awx.sqlite3'),
'TEST': {
# Test database cannot be :memory: for tests.
'NAME': os.path.join(BASE_DIR, 'awx_test.sqlite3'),
},
}
}
# AMQP configuration.
BROKER_URL = 'amqp://guest:guest@localhost:5672'
# Absolute filesystem path to the directory to host projects (with playbooks).
# This directory should NOT be web-accessible.
PROJECTS_ROOT = os.path.join(BASE_DIR, 'projects')
# Absolute filesystem path to the directory for job status stdout
# This directory should not be web-accessible
JOBOUTPUT_ROOT = os.path.join(BASE_DIR, 'job_status')
# The UUID of the system, for HA.
SYSTEM_UUID = '00000000-0000-0000-0000-000000000000'
# Local time zone for this installation. Choices can be found here:
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
# although not all choices may be available on all operating systems.
# On Unix systems, a value of None will cause Django to use the same
# timezone as the operating system.
# If running in a Windows environment this must be set to the same as your
# system time zone.
TIME_ZONE = None
# Language code for this installation. All choices can be found here:
# http://www.i18nguy.com/unicode/language-identifiers.html
LANGUAGE_CODE = 'en-us'
# SECURITY WARNING: keep the secret key used in production secret!
# Hardcoded values can leak through source control. Consider loading
# the secret key from an environment variable or a file instead.
SECRET_KEY = 'p7z7g1ql4%6+(6nlebb6hdk7sd^&fnjpal308%n%+p^_e6vo1y'
# HTTP headers and meta keys to search to determine remote host name or IP. Add
# additional items to this list, such as "HTTP_X_FORWARDED_FOR", if behind a
# reverse proxy.
REMOTE_HOST_HEADERS = ['REMOTE_ADDR', 'REMOTE_HOST']
# If Tower is behind a reverse proxy/load balancer, use this setting to
# whitelist the proxy IP addresses from which Tower should trust custom
# REMOTE_HOST_HEADERS header values
# REMOTE_HOST_HEADERS = ['HTTP_X_FORWARDED_FOR', ''REMOTE_ADDR', 'REMOTE_HOST']
# PROXY_IP_WHITELIST = ['10.0.1.100', '10.0.1.101']
# If this setting is an empty list (the default), the headers specified by
# REMOTE_HOST_HEADERS will be trusted unconditionally')
PROXY_IP_WHITELIST = []
# Define additional environment variables to be passed to ansible subprocesses
#AWX_TASK_ENV['FOO'] = 'BAR'
# If set, use -vvv for project updates instead of -v for more output.
# PROJECT_UPDATE_VVV=True
###############################################################################
# LOGGING SETTINGS
###############################################################################
# Enable logging to syslog. Setting level to ERROR captures 500 errors,
# WARNING also logs 4xx responses.
LOGGING['handlers']['syslog'] = {
'level': 'WARNING',
'filters': [],
'class': 'logging.handlers.SysLogHandler',
'address': '/dev/log',
'facility': 'local0',
'formatter': 'simple',
}
# Enable the following lines to also log to a file.
#LOGGING['handlers']['file'] = {
# 'class': 'logging.FileHandler',
# 'filename': os.path.join(BASE_DIR, 'awx.log'),
# 'formatter': 'simple',
#}
# Enable the following lines to turn on lots of permissions-related logging.
#LOGGING['loggers']['awx.main.access']['level'] = 'DEBUG'
#LOGGING['loggers']['awx.main.signals']['level'] = 'DEBUG'
#LOGGING['loggers']['awx.main.permissions']['level'] = 'DEBUG'
# Enable the following line to turn on database settings logging.
#LOGGING['loggers']['awx.conf']['level'] = 'DEBUG'
# Enable the following lines to turn on LDAP auth logging.
#LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console']
#LOGGING['loggers']['django_auth_ldap']['level'] = 'DEBUG'
###############################################################################
# SCM TEST SETTINGS
###############################################################################
# Define these variables to enable more complete testing of project support for
# SCM updates. The test repositories listed do not have to contain any valid
# playbooks.
try:
path = os.path.expanduser(os.path.expandvars('~/.ssh/id_rsa'))
TEST_SSH_KEY_DATA = file(path, 'rb').read()
except IOError:
TEST_SSH_KEY_DATA = ''
TEST_GIT_USERNAME = ''
TEST_GIT_PASSWORD = ''
TEST_GIT_KEY_DATA = TEST_SSH_KEY_DATA
TEST_GIT_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com.git'
TEST_GIT_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs.git'
TEST_GIT_PRIVATE_SSH = 'git@github.com:ansible/product-docs.git'
TEST_SVN_USERNAME = ''
TEST_SVN_PASSWORD = ''
TEST_SVN_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com'
TEST_SVN_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs'
# To test repo access via SSH login to localhost.
import getpass
TEST_SSH_LOOPBACK_USERNAME = getpass.getuser()
TEST_SSH_LOOPBACK_PASSWORD = ''
###############################################################################
# INVENTORY IMPORT TEST SETTINGS
###############################################################################
# Define these variables to enable more complete testing of inventory import
# from cloud providers.
# EC2 credentials
TEST_AWS_ACCESS_KEY_ID = ''
TEST_AWS_SECRET_ACCESS_KEY = ''
TEST_AWS_REGIONS = 'all'
# Check IAM STS credentials
TEST_AWS_SECURITY_TOKEN = ''
# Rackspace credentials
TEST_RACKSPACE_USERNAME = ''
TEST_RACKSPACE_API_KEY = ''
TEST_RACKSPACE_REGIONS = 'all'
# VMware credentials
TEST_VMWARE_HOST = ''
TEST_VMWARE_USER = ''
TEST_VMWARE_PASSWORD = ''
# OpenStack credentials
TEST_OPENSTACK_HOST = ''
TEST_OPENSTACK_USER = ''
TEST_OPENSTACK_PASSWORD = ''
TEST_OPENSTACK_PROJECT = ''
# Azure credentials.
TEST_AZURE_USERNAME = ''
TEST_AZURE_KEY_DATA = ''

View File

@ -30,10 +30,6 @@ SECRET_KEY = None
# See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
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 = {}

View File

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

View File

@ -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",

View File

@ -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",

View File

@ -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}/`);
}

View File

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

View File

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

View File

@ -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 };

View File

@ -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');
});
});

View File

@ -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/',
},
];

View File

@ -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}

View File

@ -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;

View File

@ -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>
);
}

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import React, { useCallback, useEffect } from 'react';
import { 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);

View File

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

View File

@ -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));

View File

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

View File

@ -71,6 +71,16 @@ function MultiCredentialsLookup(props) {
loadCredentials(params, selectedType.id),
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}

View File

@ -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 () => {

View File

@ -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 };

View File

@ -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);
});
});

View File

@ -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({

View File

@ -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>
</>
);
}

View File

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

View File

@ -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,

View File

@ -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;

View File

@ -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 &&

View File

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

View File

@ -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 () => {

View File

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

View File

@ -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`),

View File

@ -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",

View File

@ -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,

View File

@ -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`) }} />

View File

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

View File

@ -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>
);
});
});

View File

@ -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",

View File

@ -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 () => {

View File

@ -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,
});

View File

@ -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)');

View File

@ -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',
};

View File

@ -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",

View File

@ -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,

View File

@ -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(() => {

View File

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

View File

@ -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"
}

View File

@ -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}

View File

@ -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 () => {

View File

@ -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);

View File

@ -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 () => {

View File

@ -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,

View File

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

View File

@ -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 && (

View File

@ -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"]');

View File

@ -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,

View File

@ -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">

View File

@ -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,

View File

@ -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,

View File

@ -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}

View File

@ -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 () => {

View File

@ -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`)}

View File

@ -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`)}

View File

@ -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`}>

View File

@ -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);
});

View File

@ -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`)}

View File

@ -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;

View File

@ -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);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,13 +2,28 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { 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);
});

View File

@ -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`)}

View File

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