Merge branch 'devel' of github.com:ansible/awx into fix_collection_sanity

This commit is contained in:
Jake Jackson 2019-12-18 09:52:24 -05:00
commit caad204cbb
139 changed files with 5404 additions and 2241 deletions

View File

@ -663,7 +663,6 @@ docker-compose-build: awx-devel-build
# Base development image build
awx-devel-build:
docker build -t ansible/awx_devel -f tools/docker-compose/Dockerfile \
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:devel \
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) .
docker tag ansible/awx_devel $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
#docker push $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)

View File

@ -1 +1 @@
9.0.1
9.1.0

View File

@ -62,3 +62,15 @@ register(
category=_('Authentication'),
category_slug='authentication',
)
register(
'LOGIN_REDIRECT_OVERRIDE',
field_class=fields.CharField,
allow_blank=True,
required=False,
default='',
label=_('Login redirect override URL'),
help_text=_('URL to which unauthorized users will be redirected to log in. '
'If blank, users will be sent to the Tower login page.'),
category=_('Authentication'),
category_slug='authentication',
)

View File

@ -60,6 +60,7 @@ class ApiRootView(APIView):
data['oauth2'] = drf_reverse('api:oauth_authorization_root_view')
data['custom_logo'] = settings.CUSTOM_LOGO
data['custom_login_info'] = settings.CUSTOM_LOGIN_INFO
data['login_redirect_override'] = settings.LOGIN_REDIRECT_OVERRIDE
return Response(data)

View File

@ -166,6 +166,8 @@ def instance_info(since, include_hostnames=False):
instances = models.Instance.objects.values_list('hostname').values(
'uuid', 'version', 'capacity', 'cpu', 'memory', 'managed_by_policy', 'hostname', 'last_isolated_check', 'enabled')
for instance in instances:
consumed_capacity = sum(x.task_impact for x in models.UnifiedJob.objects.filter(execution_node=instance['hostname'],
status__in=('running', 'waiting')))
instance_info = {
'uuid': instance['uuid'],
'version': instance['version'],
@ -174,7 +176,9 @@ def instance_info(since, include_hostnames=False):
'memory': instance['memory'],
'managed_by_policy': instance['managed_by_policy'],
'last_isolated_check': _get_isolated_datetime(instance['last_isolated_check']),
'enabled': instance['enabled']
'enabled': instance['enabled'],
'consumed_capacity': consumed_capacity,
'remaining_capacity': instance['capacity'] - consumed_capacity
}
if include_hostnames is True:
instance_info['hostname'] = instance['hostname']

View File

@ -46,6 +46,8 @@ INSTANCE_MEMORY = Gauge('awx_instance_memory', 'RAM (Kb) on each node in a Tower
INSTANCE_INFO = Info('awx_instance', 'Info about each node in a Tower system', ['hostname', 'instance_uuid',])
INSTANCE_LAUNCH_TYPE = Gauge('awx_instance_launch_type_total', 'Type of Job launched', ['node', 'launch_type',])
INSTANCE_STATUS = Gauge('awx_instance_status_total', 'Status of Job launched', ['node', 'status',])
INSTANCE_CONSUMED_CAPACITY = Gauge('awx_instance_consumed_capacity', 'Consumed capacity of each node in a Tower system', ['hostname', 'instance_uuid',])
INSTANCE_REMAINING_CAPACITY = Gauge('awx_instance_remaining_capacity', 'Remaining capacity of each node in a Tower system', ['hostname', 'instance_uuid',])
LICENSE_INSTANCE_TOTAL = Gauge('awx_license_instance_total', 'Total number of managed hosts provided by your license')
LICENSE_INSTANCE_FREE = Gauge('awx_license_instance_free', 'Number of remaining managed hosts provided by your license')
@ -104,6 +106,8 @@ def metrics():
INSTANCE_CAPACITY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['capacity'])
INSTANCE_CPU.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['cpu'])
INSTANCE_MEMORY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['memory'])
INSTANCE_CONSUMED_CAPACITY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['consumed_capacity'])
INSTANCE_REMAINING_CAPACITY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['remaining_capacity'])
INSTANCE_INFO.labels(hostname=hostname, instance_uuid=uuid).info({
'enabled': str(instance_data[uuid]['enabled']),
'last_isolated_check': getattr(instance_data[uuid], 'last_isolated_check', 'None'),

View File

@ -1,8 +1,17 @@
from django.apps import AppConfig
from django.db.models.signals import pre_migrate
from django.utils.translation import ugettext_lazy as _
def raise_migration_flag(**kwargs):
from awx.main.tasks import set_migration_flag
set_migration_flag.delay()
class MainConfig(AppConfig):
name = 'awx.main'
verbose_name = _('Main')
def ready(self):
pre_migrate.connect(raise_migration_flag, sender=self)

View File

@ -0,0 +1,129 @@
import base64
import json
import os
from django.core.management.base import BaseCommand
from django.conf import settings
from django.db import transaction
from django.db.models.signals import post_save
from awx.conf import settings_registry
from awx.conf.models import Setting
from awx.conf.signals import on_post_save_setting
from awx.main.models import (
UnifiedJob, Credential, NotificationTemplate, Job, JobTemplate, WorkflowJob,
WorkflowJobTemplate, OAuth2Application
)
from awx.main.utils.encryption import (
encrypt_field, decrypt_field, encrypt_value, decrypt_value, get_encryption_key
)
class Command(BaseCommand):
"""
Regenerate a new SECRET_KEY value and re-encrypt every secret in the
Tower database.
"""
@transaction.atomic
def handle(self, **options):
self.old_key = settings.SECRET_KEY
self.new_key = base64.encodebytes(os.urandom(33)).decode().rstrip()
self._notification_templates()
self._credentials()
self._unified_jobs()
self._oauth2_app_secrets()
self._settings()
self._survey_passwords()
return self.new_key
def _notification_templates(self):
for nt in NotificationTemplate.objects.iterator():
CLASS_FOR_NOTIFICATION_TYPE = dict([(x[0], x[2]) for x in NotificationTemplate.NOTIFICATION_TYPES])
notification_class = CLASS_FOR_NOTIFICATION_TYPE[nt.notification_type]
for field in filter(lambda x: notification_class.init_parameters[x]['type'] == "password",
notification_class.init_parameters):
nt.notification_configuration[field] = decrypt_field(nt, 'notification_configuration', subfield=field, secret_key=self.old_key)
nt.notification_configuration[field] = encrypt_field(nt, 'notification_configuration', subfield=field, secret_key=self.new_key)
nt.save()
def _credentials(self):
for credential in Credential.objects.iterator():
for field_name in credential.credential_type.secret_fields:
if field_name in credential.inputs:
credential.inputs[field_name] = decrypt_field(
credential,
field_name,
secret_key=self.old_key
)
credential.inputs[field_name] = encrypt_field(
credential,
field_name,
secret_key=self.new_key
)
credential.save()
def _unified_jobs(self):
for uj in UnifiedJob.objects.iterator():
if uj.start_args:
uj.start_args = decrypt_field(
uj,
'start_args',
secret_key=self.old_key
)
uj.start_args = encrypt_field(uj, 'start_args', secret_key=self.new_key)
uj.save()
def _oauth2_app_secrets(self):
for app in OAuth2Application.objects.iterator():
raw = app.client_secret
app.client_secret = raw
encrypted = encrypt_value(raw, secret_key=self.new_key)
OAuth2Application.objects.filter(pk=app.pk).update(client_secret=encrypted)
def _settings(self):
# don't update memcached, the *actual* value isn't changing
post_save.disconnect(on_post_save_setting, sender=Setting)
for setting in Setting.objects.filter().order_by('pk'):
if settings_registry.is_setting_encrypted(setting.key):
setting.value = decrypt_field(setting, 'value', secret_key=self.old_key)
setting.value = encrypt_field(setting, 'value', secret_key=self.new_key)
setting.save()
def _survey_passwords(self):
for _type in (JobTemplate, WorkflowJobTemplate):
for jt in _type.objects.exclude(survey_spec={}):
changed = False
if jt.survey_spec.get('spec', []):
for field in jt.survey_spec['spec']:
if field.get('type') == 'password' and field.get('default', ''):
raw = decrypt_value(
get_encryption_key('value', None, secret_key=self.old_key),
field['default']
)
field['default'] = encrypt_value(
raw,
pk=None,
secret_key=self.new_key
)
changed = True
if changed:
jt.save(update_fields=["survey_spec"])
for _type in (Job, WorkflowJob):
for job in _type.objects.exclude(survey_passwords={}).iterator():
changed = False
for key in job.survey_passwords:
if key in job.extra_vars:
extra_vars = json.loads(job.extra_vars)
if not extra_vars.get(key):
continue
raw = decrypt_value(
get_encryption_key('value', None, secret_key=self.old_key),
extra_vars[key]
)
extra_vars[key] = encrypt_value(raw, pk=None, secret_key=self.new_key)
job.extra_vars = json.dumps(extra_vars)
changed = True
if changed:
job.save(update_fields=["extra_vars"])

View File

@ -13,8 +13,7 @@ import urllib.parse
from django.conf import settings
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.db.migrations.executor import MigrationExecutor
from django.db import IntegrityError, connection
from django.db import IntegrityError
from django.utils.functional import curry
from django.shortcuts import get_object_or_404, redirect
from django.apps import apps
@ -24,6 +23,7 @@ from django.urls import reverse, resolve
from awx.main.models import ActivityStream
from awx.main.utils.named_url_graph import generate_graph, GraphNode
from awx.main.utils.db import migration_in_progress_check_or_relase
from awx.conf import fields, register
@ -213,8 +213,7 @@ class URLModificationMiddleware(MiddlewareMixin):
class MigrationRanCheckMiddleware(MiddlewareMixin):
def process_request(self, request):
executor = MigrationExecutor(connection)
plan = executor.migration_plan(executor.loader.graph.leaf_nodes())
if bool(plan) and \
getattr(resolve(request.path), 'url_name', '') != 'migrations_notran':
if migration_in_progress_check_or_relase():
if getattr(resolve(request.path), 'url_name', '') == 'migrations_notran':
return
return redirect(reverse("ui:migrations_notran"))

View File

@ -634,7 +634,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
else:
# If for some reason we can't count the hosts then lets assume the impact as forks
if self.inventory is not None:
count_hosts = self.inventory.hosts.count()
count_hosts = self.inventory.total_hosts
if self.job_slice_count > 1:
# Integer division intentional
count_hosts = (count_hosts + self.job_slice_count - self.job_slice_number) // self.job_slice_count

View File

@ -1,4 +1,5 @@
# Python
import logging
import re
# Django
@ -22,6 +23,9 @@ DATA_URI_RE = re.compile(r'.*') # FIXME
__all__ = ['OAuth2AccessToken', 'OAuth2Application']
logger = logging.getLogger('awx.main.models.oauth')
class OAuth2Application(AbstractApplication):
class Meta:
@ -120,15 +124,27 @@ class OAuth2AccessToken(AbstractAccessToken):
def is_valid(self, scopes=None):
valid = super(OAuth2AccessToken, self).is_valid(scopes)
if valid:
try:
self.validate_external_users()
except oauth2.AccessDeniedError:
logger.exception(f'Failed to authenticate {self.user.username}')
return False
self.last_used = now()
connection.on_commit(lambda: self.save(update_fields=['last_used']))
def _update_last_used():
if OAuth2AccessToken.objects.filter(pk=self.pk).exists():
self.save(update_fields=['last_used'])
connection.on_commit(_update_last_used)
return valid
def save(self, *args, **kwargs):
def validate_external_users(self):
if self.user and settings.ALLOW_OAUTH2_FOR_EXTERNAL_USERS is False:
external_account = get_external_account(self.user)
if external_account is not None:
raise oauth2.AccessDeniedError(_(
'OAuth2 Tokens cannot be created by users associated with an external authentication provider ({})'
).format(external_account))
def save(self, *args, **kwargs):
self.validate_external_users()
super(OAuth2AccessToken, self).save(*args, **kwargs)

View File

@ -6,15 +6,24 @@ class CustomNotificationBase(object):
DEFAULT_MSG = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}"
DEFAULT_BODY = "{{ job_friendly_name }} #{{ job.id }} had status {{ job.status }}, view details at {{ url }}\n\n{{ job_metadata }}"
DEFAULT_APPROVAL_RUNNING_MSG = 'The approval node "{{ approval_node_name }}" needs review. This node can be viewed at: {{ workflow_url }}'
DEFAULT_APPROVAL_RUNNING_BODY = ('The approval node "{{ approval_node_name }}" needs review. '
'This approval node can be viewed at: {{ workflow_url }}\n\n{{ job_metadata }}')
DEFAULT_APPROVAL_APPROVED_MSG = 'The approval node "{{ approval_node_name }}" was approved. {{ workflow_url }}'
DEFAULT_APPROVAL_APPROVED_BODY = 'The approval node "{{ approval_node_name }}" was approved. {{ workflow_url }}\n\n{{ job_metadata }}'
DEFAULT_APPROVAL_TIMEOUT_MSG = 'The approval node "{{ approval_node_name }}" has timed out. {{ workflow_url }}'
DEFAULT_APPROVAL_TIMEOUT_BODY = 'The approval node "{{ approval_node_name }}" has timed out. {{ workflow_url }}\n\n{{ job_metadata }}'
DEFAULT_APPROVAL_DENIED_MSG = 'The approval node "{{ approval_node_name }}" was denied. {{ workflow_url }}'
DEFAULT_APPROVAL_DENIED_BODY = 'The approval node "{{ approval_node_name }}" was denied. {{ workflow_url }}\n\n{{ job_metadata }}'
default_messages = {"started": {"message": DEFAULT_MSG, "body": None},
"success": {"message": DEFAULT_MSG, "body": None},
"error": {"message": DEFAULT_MSG, "body": None},
"workflow_approval": {"running": {"message": 'The approval node "{{ approval_node_name }}" needs review. '
'This node can be viewed at: {{ workflow_url }}',
"body": None},
"approved": {"message": 'The approval node "{{ approval_node_name }}" was approved. {{ workflow_url }}',
"body": None},
"timed_out": {"message": 'The approval node "{{ approval_node_name }}" has timed out. {{ workflow_url }}',
"body": None},
"denied": {"message": 'The approval node "{{ approval_node_name }}" was denied. {{ workflow_url }}',
"body": None}}}
"workflow_approval": {"running": {"message": DEFAULT_APPROVAL_RUNNING_MSG, "body": None},
"approved": {"message": DEFAULT_APPROVAL_APPROVED_MSG, "body": None},
"timed_out": {"message": DEFAULT_APPROVAL_TIMEOUT_MSG, "body": None},
"denied": {"message": DEFAULT_APPROVAL_DENIED_MSG, "body": None}}}

View File

@ -8,6 +8,18 @@ from awx.main.notifications.custom_notification_base import CustomNotificationBa
DEFAULT_MSG = CustomNotificationBase.DEFAULT_MSG
DEFAULT_BODY = CustomNotificationBase.DEFAULT_BODY
DEFAULT_APPROVAL_RUNNING_MSG = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_MSG
DEFAULT_APPROVAL_RUNNING_BODY = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_BODY
DEFAULT_APPROVAL_APPROVED_MSG = CustomNotificationBase.DEFAULT_APPROVAL_APPROVED_MSG
DEFAULT_APPROVAL_APPROVED_BODY = CustomNotificationBase.DEFAULT_APPROVAL_APPROVED_BODY
DEFAULT_APPROVAL_TIMEOUT_MSG = CustomNotificationBase.DEFAULT_APPROVAL_TIMEOUT_MSG
DEFAULT_APPROVAL_TIMEOUT_BODY = CustomNotificationBase.DEFAULT_APPROVAL_TIMEOUT_BODY
DEFAULT_APPROVAL_DENIED_MSG = CustomNotificationBase.DEFAULT_APPROVAL_DENIED_MSG
DEFAULT_APPROVAL_DENIED_BODY = CustomNotificationBase.DEFAULT_APPROVAL_DENIED_BODY
class CustomEmailBackend(EmailBackend, CustomNotificationBase):
@ -26,10 +38,10 @@ class CustomEmailBackend(EmailBackend, CustomNotificationBase):
default_messages = {"started": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"success": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"error": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"workflow_approval": {"running": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"approved": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"timed_out": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
"denied": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}}}
"workflow_approval": {"running": {"message": DEFAULT_APPROVAL_RUNNING_MSG, "body": DEFAULT_APPROVAL_RUNNING_BODY},
"approved": {"message": DEFAULT_APPROVAL_APPROVED_MSG, "body": DEFAULT_APPROVAL_APPROVED_BODY},
"timed_out": {"message": DEFAULT_APPROVAL_TIMEOUT_MSG, "body": DEFAULT_APPROVAL_TIMEOUT_BODY},
"denied": {"message": DEFAULT_APPROVAL_DENIED_MSG, "body": DEFAULT_APPROVAL_DENIED_BODY}}}
def format_body(self, body):
# leave body unchanged (expect a string)

View File

@ -15,7 +15,6 @@ class DependencyGraph(object):
INVENTORY_UPDATES = 'inventory_updates'
JOB_TEMPLATE_JOBS = 'job_template_jobs'
JOB_PROJECT_IDS = 'job_project_ids'
JOB_INVENTORY_IDS = 'job_inventory_ids'
SYSTEM_JOB = 'system_job'
@ -41,8 +40,6 @@ class DependencyGraph(object):
Track runnable job related project and inventory to ensure updates
don't run while a job needing those resources is running.
'''
# project_id -> True / False
self.data[self.JOB_PROJECT_IDS] = {}
# inventory_id -> True / False
self.data[self.JOB_INVENTORY_IDS] = {}
@ -66,7 +63,7 @@ class DependencyGraph(object):
def get_now(self):
return tz_now()
def mark_system_job(self):
self.data[self.SYSTEM_JOB] = False
@ -81,15 +78,13 @@ class DependencyGraph(object):
def mark_job_template_job(self, job):
self.data[self.JOB_INVENTORY_IDS][job.inventory_id] = False
self.data[self.JOB_PROJECT_IDS][job.project_id] = False
self.data[self.JOB_TEMPLATE_JOBS][job.job_template_id] = False
def mark_workflow_job(self, job):
self.data[self.WORKFLOW_JOB_TEMPLATES_JOBS][job.workflow_job_template_id] = False
def can_project_update_run(self, job):
return self.data[self.JOB_PROJECT_IDS].get(job.project_id, True) and \
self.data[self.PROJECT_UPDATES].get(job.project_id, True)
return self.data[self.PROJECT_UPDATES].get(job.project_id, True)
def can_inventory_update_run(self, job):
return self.data[self.JOB_INVENTORY_IDS].get(job.inventory_source.inventory_id, True) and \

View File

@ -5,11 +5,15 @@ import logging
# AWX
from awx.main.scheduler import TaskManager
from awx.main.dispatch.publish import task
from awx.main.utils.db import migration_in_progress_check_or_relase
logger = logging.getLogger('awx.main.scheduler')
@task()
def run_task_manager():
if migration_in_progress_check_or_relase():
logger.debug("Not running task manager because migration is in progress.")
return
logger.debug("Running Tower task manager.")
TaskManager().schedule()

View File

@ -263,6 +263,12 @@ def apply_cluster_membership_policies():
logger.debug('Cluster policy computation finished in {} seconds'.format(time.time() - started_compute))
@task(queue='tower_broadcast_all', exchange_type='fanout')
def set_migration_flag():
logger.debug('Received migration-in-progress signal, will serve redirect.')
cache.set('migration_in_progress', True)
@task(queue='tower_broadcast_all', exchange_type='fanout')
def handle_setting_changes(setting_keys):
orig_len = len(setting_keys)
@ -2183,7 +2189,10 @@ class RunProjectUpdate(BaseTask):
project_path = instance.project.get_project_path(check_if_exists=False)
if os.path.exists(project_path):
git_repo = git.Repo(project_path)
self.original_branch = git_repo.active_branch
if git_repo.head.is_detached:
self.original_branch = git_repo.head.commit
else:
self.original_branch = git_repo.active_branch
@staticmethod
def make_local_copy(project_path, destination_folder, scm_type, scm_revision):

View File

@ -25,6 +25,8 @@ EXPECTED_VALUES = {
'awx_custom_virtualenvs_total':0.0,
'awx_running_jobs_total':0.0,
'awx_instance_capacity':100.0,
'awx_instance_consumed_capacity':0.0,
'awx_instance_remaining_capacity':100.0,
'awx_instance_cpu':0.0,
'awx_instance_memory':0.0,
'awx_instance_info':1.0,

View File

@ -1,6 +1,8 @@
import pytest
import base64
import contextlib
import json
from unittest import mock
from django.db import connection
from django.test.utils import override_settings
@ -14,6 +16,18 @@ from awx.sso.models import UserEnterpriseAuth
from oauth2_provider.models import RefreshToken
@contextlib.contextmanager
def immediate_on_commit():
"""
Context manager executing transaction.on_commit() hooks immediately as
if the connection was in auto-commit mode.
"""
def on_commit(func):
func()
with mock.patch('django.db.connection.on_commit', side_effect=on_commit) as patch:
yield patch
@pytest.mark.django_db
def test_personal_access_token_creation(oauth_application, post, alice):
url = drf_reverse('api:oauth_authorization_root_view') + 'token/'
@ -54,6 +68,41 @@ def test_token_creation_disabled_for_external_accounts(oauth_application, post,
assert AccessToken.objects.count() == 0
@pytest.mark.django_db
def test_existing_token_disabled_for_external_accounts(oauth_application, get, post, admin):
UserEnterpriseAuth(user=admin, provider='radius').save()
url = drf_reverse('api:oauth_authorization_root_view') + 'token/'
with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USERS=True):
resp = post(
url,
data='grant_type=password&username=admin&password=admin&scope=read',
content_type='application/x-www-form-urlencoded',
HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([
oauth_application.client_id, oauth_application.client_secret
])))),
status=201
)
token = json.loads(resp.content)['access_token']
assert AccessToken.objects.count() == 1
with immediate_on_commit():
resp = get(
drf_reverse('api:user_me_list', kwargs={'version': 'v2'}),
HTTP_AUTHORIZATION='Bearer ' + token,
status=200
)
assert json.loads(resp.content)['results'][0]['username'] == 'admin'
with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USER=False):
with immediate_on_commit():
resp = get(
drf_reverse('api:user_me_list', kwargs={'version': 'v2'}),
HTTP_AUTHORIZATION='Bearer ' + token,
status=401
)
assert b'To establish a login session' in resp.content
@pytest.mark.django_db
def test_pat_creation_no_default_scope(oauth_application, post, admin):
# tests that the default scope is overriden

View File

@ -0,0 +1,173 @@
import json
from cryptography.fernet import InvalidToken
from django.test.utils import override_settings
from django.conf import settings
import pytest
from awx.main import models
from awx.conf.models import Setting
from awx.main.management.commands import regenerate_secret_key
from awx.main.utils.encryption import encrypt_field, decrypt_field, encrypt_value
PREFIX = '$encrypted$UTF8$AESCBC$'
@pytest.mark.django_db
class TestKeyRegeneration:
def test_encrypted_ssh_password(self, credential):
# test basic decryption
assert credential.inputs['password'].startswith(PREFIX)
assert credential.get_input('password') == 'secret'
# re-key the credential
new_key = regenerate_secret_key.Command().handle()
new_cred = models.Credential.objects.get(pk=credential.pk)
assert credential.inputs['password'] != new_cred.inputs['password']
# verify that the old SECRET_KEY doesn't work
with pytest.raises(InvalidToken):
new_cred.get_input('password')
# verify that the new SECRET_KEY *does* work
with override_settings(SECRET_KEY=new_key):
assert new_cred.get_input('password') == 'secret'
def test_encrypted_setting_values(self):
# test basic decryption
settings.LOG_AGGREGATOR_PASSWORD = 'sensitive'
s = Setting.objects.filter(key='LOG_AGGREGATOR_PASSWORD').first()
assert s.value.startswith(PREFIX)
assert settings.LOG_AGGREGATOR_PASSWORD == 'sensitive'
# re-key the setting value
new_key = regenerate_secret_key.Command().handle()
new_setting = Setting.objects.filter(key='LOG_AGGREGATOR_PASSWORD').first()
assert s.value != new_setting.value
# wipe out the local cache so the value is pulled from the DB again
settings.cache.delete('LOG_AGGREGATOR_PASSWORD')
# verify that the old SECRET_KEY doesn't work
with pytest.raises(InvalidToken):
settings.LOG_AGGREGATOR_PASSWORD
# verify that the new SECRET_KEY *does* work
with override_settings(SECRET_KEY=new_key):
assert settings.LOG_AGGREGATOR_PASSWORD == 'sensitive'
def test_encrypted_notification_secrets(self, notification_template_with_encrypt):
# test basic decryption
nt = notification_template_with_encrypt
nc = nt.notification_configuration
assert nc['token'].startswith(PREFIX)
Slack = nt.CLASS_FOR_NOTIFICATION_TYPE[nt.notification_type]
class TestBackend(Slack):
def __init__(self, *args, **kw):
assert kw['token'] == 'token'
def send_messages(self, messages):
pass
nt.CLASS_FOR_NOTIFICATION_TYPE['test'] = TestBackend
nt.notification_type = 'test'
nt.send('Subject', 'Body')
# re-key the notification config
new_key = regenerate_secret_key.Command().handle()
new_nt = models.NotificationTemplate.objects.get(pk=nt.pk)
assert nt.notification_configuration['token'] != new_nt.notification_configuration['token']
# verify that the old SECRET_KEY doesn't work
with pytest.raises(InvalidToken):
new_nt.CLASS_FOR_NOTIFICATION_TYPE['test'] = TestBackend
new_nt.notification_type = 'test'
new_nt.send('Subject', 'Body')
# verify that the new SECRET_KEY *does* work
with override_settings(SECRET_KEY=new_key):
new_nt.send('Subject', 'Body')
def test_job_start_args(self, job_factory):
# test basic decryption
job = job_factory()
job.start_args = json.dumps({'foo': 'bar'})
job.start_args = encrypt_field(job, field_name='start_args')
job.save()
assert job.start_args.startswith(PREFIX)
# re-key the start_args
new_key = regenerate_secret_key.Command().handle()
new_job = models.Job.objects.get(pk=job.pk)
assert new_job.start_args != job.start_args
# verify that the old SECRET_KEY doesn't work
with pytest.raises(InvalidToken):
decrypt_field(new_job, field_name='start_args')
# verify that the new SECRET_KEY *does* work
with override_settings(SECRET_KEY=new_key):
assert json.loads(
decrypt_field(new_job, field_name='start_args')
) == {'foo': 'bar'}
@pytest.mark.parametrize('cls', ('JobTemplate', 'WorkflowJobTemplate'))
def test_survey_spec(self, inventory, project, survey_spec_factory, cls):
params = {}
if cls == 'JobTemplate':
params['inventory'] = inventory
params['project'] = project
# test basic decryption
jt = getattr(models, cls).objects.create(
name='Example Template',
survey_spec=survey_spec_factory([{
'variable': 'secret_key',
'default': encrypt_value('donttell', pk=None),
'type': 'password'
}]),
survey_enabled=True,
**params
)
job = jt.create_unified_job()
assert jt.survey_spec['spec'][0]['default'].startswith(PREFIX)
assert job.survey_passwords == {'secret_key': '$encrypted$'}
assert json.loads(job.decrypted_extra_vars())['secret_key'] == 'donttell'
# re-key the extra_vars
new_key = regenerate_secret_key.Command().handle()
new_job = models.UnifiedJob.objects.get(pk=job.pk)
assert new_job.extra_vars != job.extra_vars
# verify that the old SECRET_KEY doesn't work
with pytest.raises(InvalidToken):
new_job.decrypted_extra_vars()
# verify that the new SECRET_KEY *does* work
with override_settings(SECRET_KEY=new_key):
assert json.loads(
new_job.decrypted_extra_vars()
)['secret_key'] == 'donttell'
def test_oauth2_application_client_secret(self, oauth_application):
# test basic decryption
secret = oauth_application.client_secret
assert len(secret) == 128
# re-key the client_secret
new_key = regenerate_secret_key.Command().handle()
# verify that the old SECRET_KEY doesn't work
with pytest.raises(InvalidToken):
models.OAuth2Application.objects.get(
pk=oauth_application.pk
).client_secret
# verify that the new SECRET_KEY *does* work
with override_settings(SECRET_KEY=new_key):
assert models.OAuth2Application.objects.get(
pk=oauth_application.pk
).client_secret == secret

View File

@ -122,6 +122,22 @@ def project_playbooks():
mocked.start()
@pytest.fixture
def run_computed_fields_right_away(request):
def run_me(inventory_id, should_update_hosts=True):
i = Inventory.objects.get(id=inventory_id)
i.update_computed_fields(update_hosts=should_update_hosts)
mocked = mock.patch(
'awx.main.signals.update_inventory_computed_fields.delay',
new=run_me
)
mocked.start()
request.addfinalizer(mocked.stop)
@pytest.fixture
@mock.patch.object(Project, "update", lambda self, **kwargs: None)
def project(instance, organization):

View File

@ -281,15 +281,18 @@ class TestTaskImpact:
return job
return r
def test_limit_task_impact(self, job_host_limit):
def test_limit_task_impact(self, job_host_limit, run_computed_fields_right_away):
job = job_host_limit(5, 2)
job.inventory.refresh_from_db() # FIXME: computed fields operates on reloaded inventory
assert job.inventory.total_hosts == 5
assert job.task_impact == 2 + 1 # forks becomes constraint
def test_host_task_impact(self, job_host_limit):
def test_host_task_impact(self, job_host_limit, run_computed_fields_right_away):
job = job_host_limit(3, 5)
job.inventory.refresh_from_db() # FIXME: computed fields operates on reloaded inventory
assert job.task_impact == 3 + 1 # hosts becomes constraint
def test_shard_task_impact(self, slice_job_factory):
def test_shard_task_impact(self, slice_job_factory, run_computed_fields_right_away):
# factory creates on host per slice
workflow_job = slice_job_factory(3, jt_kwargs={'forks': 50}, spawn=True)
# arrange the jobs by their number
@ -308,4 +311,5 @@ class TestTaskImpact:
len(jobs[0].inventory.get_script_data(slice_number=i + 1, slice_count=3)['all']['hosts'])
for i in range(3)
] == [2, 1, 1]
jobs[0].inventory.refresh_from_db() # FIXME: computed fields operates on reloaded inventory
assert [job.task_impact for job in jobs] == [3, 2, 2]

View File

@ -4,6 +4,7 @@ import json
from datetime import timedelta
from awx.main.scheduler import TaskManager
from awx.main.scheduler.dependency_graph import DependencyGraph
from awx.main.utils import encrypt_field
from awx.main.models import WorkflowJobTemplate, JobTemplate
@ -326,3 +327,29 @@ def test_shared_dependencies_launch(default_instance_group, job_template_factory
iu = [x for x in ii.inventory_updates.all()]
assert len(pu) == 1
assert len(iu) == 1
@pytest.mark.django_db
def test_job_not_blocking_project_update(default_instance_group, job_template_factory):
objects = job_template_factory('jt', organization='org1', project='proj',
inventory='inv', credential='cred',
jobs=["job"])
job = objects.jobs["job"]
job.instance_group = default_instance_group
job.status = "running"
job.save()
with mock.patch("awx.main.scheduler.TaskManager.start_task"):
task_manager = TaskManager()
task_manager._schedule()
proj = objects.project
project_update = proj.create_project_update()
project_update.instance_group = default_instance_group
project_update.status = "pending"
project_update.save()
assert not task_manager.is_job_blocked(project_update)
dependency_graph = DependencyGraph(None)
dependency_graph.add_job(job)
assert not dependency_graph.is_job_blocked(project_update)

View File

@ -1,8 +1,16 @@
# Copyright (c) 2017 Ansible by Red Hat
# All Rights Reserved.
import logging
from itertools import chain
from django.core.cache import cache
from django.db.migrations.executor import MigrationExecutor
from django.db import connection
logger = logging.getLogger('awx.main.utils.db')
def get_all_field_names(model):
# Implements compatibility with _meta.get_all_field_names
@ -14,3 +22,21 @@ def get_all_field_names(model):
# GenericForeignKey from the results.
if not (field.many_to_one and field.related_model is None)
)))
def migration_in_progress_check_or_relase():
'''A memcache flag is raised (set to True) to inform cluster
that a migration is ongoing see main.apps.MainConfig.ready
if the flag is True then the flag is removed on this instance if
models-db consistency is observed
effective value of migration flag is returned
'''
migration_in_progress = cache.get('migration_in_progress', False)
if migration_in_progress:
executor = MigrationExecutor(connection)
plan = executor.migration_plan(executor.loader.graph.leaf_nodes())
if not bool(plan):
logger.info('Detected that migration finished, migration flag taken down.')
cache.delete('migration_in_progress')
migration_in_progress = False
return migration_in_progress

View File

@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
import base64
import hashlib
import logging
@ -35,7 +37,7 @@ class Fernet256(Fernet):
self._backend = backend
def get_encryption_key(field_name, pk=None):
def get_encryption_key(field_name, pk=None, secret_key=None):
'''
Generate key for encrypted password based on field name,
``settings.SECRET_KEY``, and instance pk (if available).
@ -46,19 +48,58 @@ def get_encryption_key(field_name, pk=None):
'''
from django.conf import settings
h = hashlib.sha512()
h.update(smart_bytes(settings.SECRET_KEY))
h.update(smart_bytes(secret_key or settings.SECRET_KEY))
if pk is not None:
h.update(smart_bytes(str(pk)))
h.update(smart_bytes(field_name))
return base64.urlsafe_b64encode(h.digest())
def encrypt_value(value, pk=None):
def encrypt_value(value, pk=None, secret_key=None):
#
# ⚠️ D-D-D-DANGER ZONE ⚠️
#
# !!! BEFORE USING THIS FUNCTION PLEASE READ encrypt_field !!!
#
TransientField = namedtuple('TransientField', ['pk', 'value'])
return encrypt_field(TransientField(pk=pk, value=value), 'value')
return encrypt_field(TransientField(pk=pk, value=value), 'value', secret_key=secret_key)
def encrypt_field(instance, field_name, ask=False, subfield=None):
def encrypt_field(instance, field_name, ask=False, subfield=None, secret_key=None):
#
# ⚠️ D-D-D-DANGER ZONE ⚠️
#
# !!! PLEASE READ BEFORE USING THIS FUNCTION ANYWHERE !!!
#
# You should know that this function is used in various places throughout
# AWX for symmetric encryption - generally it's used to encrypt sensitive
# values that we store in the AWX database (such as SSH private keys for
# credentials).
#
# If you're reading this function's code because you're thinking about
# using it to encrypt *something new*, please remember that AWX has
# official support for *regenerating* the SECRET_KEY (on which the
# symmetric key is based):
#
# $ awx-manage regenerate_secret_key
# $ setup.sh -k
#
# ...so you'll need to *also* add code to support the
# migration/re-encryption of these values (the code in question lives in
# `awx.main.management.commands.regenerate_secret_key`):
#
# For example, if you find that you're adding a new database column that is
# encrypted, in addition to calling `encrypt_field` in the appropriate
# places, you would also need to update the `awx-manage regenerate_secret_key`
# so that values are properly migrated when the SECRET_KEY changes.
#
# This process *generally* involves adding Python code to the
# `regenerate_secret_key` command, i.e.,
#
# 1. Query the database for existing encrypted values on the appropriate object(s)
# 2. Decrypting them using the *old* SECRET_KEY
# 3. Storing newly encrypted values using the *newly generated* SECRET_KEY
#
'''
Return content of the given instance and field name encrypted.
'''
@ -76,7 +117,11 @@ def encrypt_field(instance, field_name, ask=False, subfield=None):
value = smart_str(value)
if not value or value.startswith('$encrypted$') or (ask and value == 'ASK'):
return value
key = get_encryption_key(field_name, getattr(instance, 'pk', None))
key = get_encryption_key(
field_name,
getattr(instance, 'pk', None),
secret_key=secret_key
)
f = Fernet256(key)
encrypted = f.encrypt(smart_bytes(value))
b64data = smart_str(base64.b64encode(encrypted))
@ -99,7 +144,7 @@ def decrypt_value(encryption_key, value):
return smart_str(value)
def decrypt_field(instance, field_name, subfield=None):
def decrypt_field(instance, field_name, subfield=None, secret_key=None):
'''
Return content of the given instance and field name decrypted.
'''
@ -115,7 +160,11 @@ def decrypt_field(instance, field_name, subfield=None):
value = smart_str(value)
if not value or not value.startswith('$encrypted$'):
return value
key = get_encryption_key(field_name, getattr(instance, 'pk', None))
key = get_encryption_key(
field_name,
getattr(instance, 'pk', None),
secret_key=secret_key
)
try:
return smart_str(decrypt_value(key, value))

View File

@ -4,7 +4,7 @@
import json
# Django
from django.http import HttpResponse
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.utils.html import format_html
from django.utils.translation import ugettext_lazy as _
@ -97,3 +97,6 @@ def handle_csp_violation(request):
logger = logging.getLogger('awx')
logger.error(json.loads(request.body))
return HttpResponse(content=None)
def handle_login_redirect(request):
return HttpResponseRedirect("/#/login")

View File

@ -15,7 +15,9 @@
ignore_errors: true
- name: remove build artifacts
file: path="{{item}}" state=absent
file:
path: '{{item}}'
state: absent
register: result
with_items: "{{cleanup_dirs}}"
until: result is succeeded

View File

@ -115,7 +115,8 @@
- update_insights
- name: Repository Version
debug: msg="Repository Version {{ scm_version }}"
debug:
msg: "Repository Version {{ scm_version }}"
tags:
- update_git
- update_hg
@ -130,7 +131,8 @@
- block:
- name: detect requirements.yml
stat: path={{project_path|quote}}/roles/requirements.yml
stat:
path: '{{project_path|quote}}/roles/requirements.yml'
register: doesRequirementsExist
- name: fetch galaxy roles from requirements.yml
@ -149,7 +151,8 @@
- block:
- name: detect collections/requirements.yml
stat: path={{project_path|quote}}/collections/requirements.yml
stat:
path: '{{project_path|quote}}/collections/requirements.yml'
register: doesCollectionRequirementsExist
- name: fetch galaxy collections from collections/requirements.yml

View File

@ -373,6 +373,10 @@ TACACSPLUS_AUTH_PROTOCOL = 'ascii'
# Note: This setting may be overridden by database settings.
AUTH_BASIC_ENABLED = True
# If set, specifies a URL that unauthenticated users will be redirected to
# when trying to access a UI page that requries authentication.
LOGIN_REDIRECT_OVERRIDE = None
# If set, serve only minified JS for UI.
USE_MINIFIED_JS = False

View File

@ -39,7 +39,7 @@ function getStatusDetails (jobStatus) {
value = choices[unmapped];
}
return { label, icon, value };
return { unmapped, label, icon, value };
}
function getStartDetails (started) {

View File

@ -12,9 +12,9 @@
class="List-actionButton List-actionButton--delete"
data-placement="top"
ng-click="vm.cancelJob()"
ng-show="vm.status.value === 'Pending' ||
vm.status.value === 'Waiting' ||
vm.status.value === 'Running'"
ng-show="vm.status.unmapped === 'pending' ||
vm.status.unmapped === 'waiting' ||
vm.status.unmapped === 'running'"
aw-tool-tip="{{:: vm.strings.get('tooltips.CANCEL') }}"
data-original-title=""
title="">
@ -27,11 +27,11 @@
data-placement="top"
ng-click="vm.deleteJob()"
ng-show="vm.canDelete && (
vm.status.value === 'New' ||
vm.status.value === 'Successful' ||
vm.status.value === 'Failed' ||
vm.status.value === 'Error' ||
vm.status.value === 'Canceled')"
vm.status.unmapped === 'new' ||
vm.status.unmapped === 'successful' ||
vm.status.unmapped === 'failed' ||
vm.status.unmapped === 'error' ||
vm.status.unmapped === 'canceled')"
aw-tool-tip="{{:: vm.strings.get('tooltips.DELETE') }}"
data-original-title=""
title="">

View File

@ -3,6 +3,8 @@ global.$AnsibleConfig = null;
// Provided via Webpack DefinePlugin in webpack.config.js
global.$ENV = {};
global.$ConfigResponse = {};
var urlPrefix;
if ($basePath) {
@ -383,7 +385,11 @@ angular
var stime = timestammp[lastUser.id].time,
now = new Date().getTime();
if ((stime - now) <= 0) {
$location.path('/login');
if (global.$AnsibleConfig.login_redirect_override) {
window.location.replace(global.$AnsibleConfig.login_redirect_override);
} else {
$location.path('/login');
}
}
}
// If browser refresh, set the user_is_superuser value

View File

@ -15,7 +15,9 @@ function bootstrap (callback) {
angular.module('I18N').constant('LOCALE', locale);
}
angular.element(document).ready(() => callback());
fetchConfig(() => {
angular.element(document).ready(() => callback());
});
});
}
@ -49,6 +51,25 @@ function fetchLocaleStrings (callback) {
request.fail(() => callback({ code: DEFAULT_LOCALE }));
}
function fetchConfig (callback) {
const request = $.ajax('/api/');
request.done(res => {
global.$ConfigResponse = res;
if (res.login_redirect_override) {
if (!document.cookie.split(';').filter((item) => item.includes('userLoggedIn=true')).length && !window.location.href.includes('/#/login')) {
window.location.replace(res.login_redirect_override);
} else {
callback();
}
} else {
callback();
}
});
request.fail(() => callback());
}
/**
* Grabs the language off of navigator for browser compatibility.
* If the language isn't set, then it falls back to the DEFAULT_LOCALE. The

View File

@ -40,6 +40,10 @@ export default ['i18n', function(i18n) {
ALLOW_OAUTH2_FOR_EXTERNAL_USERS: {
type: 'toggleSwitch',
},
LOGIN_REDIRECT_OVERRIDE: {
type: 'text',
reset: 'LOGIN_REDIRECT_OVERRIDE'
},
ACCESS_TOKEN_EXPIRE_SECONDS: {
type: 'text',
reset: 'ACCESS_TOKEN_EXPIRE_SECONDS'

View File

@ -1,55 +1,40 @@
export default
function LoadConfig($log, $rootScope, $http, Store) {
function LoadConfig($rootScope, Store) {
return function() {
var configSettings = {};
var configInit = function() {
// Auto-resolving what used to be found when attempting to load local_setting.json
if ($rootScope.loginConfig) {
$rootScope.loginConfig.resolve('config loaded');
}
$rootScope.$emit('ConfigReady');
if(global.$ConfigResponse.custom_logo) {
configSettings.custom_logo = true;
$rootScope.custom_logo = global.$ConfigResponse.custom_logo;
} else {
configSettings.custom_logo = false;
}
// Load new hardcoded settings from above
if(global.$ConfigResponse.custom_login_info) {
configSettings.custom_login_info = global.$ConfigResponse.custom_login_info;
$rootScope.custom_login_info = global.$ConfigResponse.custom_login_info;
} else {
configSettings.custom_login_info = false;
}
global.$AnsibleConfig = configSettings;
Store('AnsibleConfig', global.$AnsibleConfig);
$rootScope.$emit('LoadConfig');
};
if (global.$ConfigResponse.login_redirect_override) {
configSettings.login_redirect_override = global.$ConfigResponse.login_redirect_override;
}
// Retrieve the custom logo information - update configSettings from above
$http({
method: 'GET',
url: '/api/',
})
.then(function({data}) {
if(data.custom_logo) {
configSettings.custom_logo = true;
$rootScope.custom_logo = data.custom_logo;
} else {
configSettings.custom_logo = false;
}
// Auto-resolving what used to be found when attempting to load local_setting.json
if ($rootScope.loginConfig) {
$rootScope.loginConfig.resolve('config loaded');
}
global.$AnsibleConfig = configSettings;
Store('AnsibleConfig', global.$AnsibleConfig);
$rootScope.$emit('ConfigReady');
if(data.custom_login_info) {
configSettings.custom_login_info = data.custom_login_info;
$rootScope.custom_login_info = data.custom_login_info;
} else {
configSettings.custom_login_info = false;
}
configInit();
}).catch(({error}) => {
$log.debug(error);
configInit();
});
// Load new hardcoded settings from above
$rootScope.$emit('LoadConfig');
};
}
LoadConfig.$inject =
[ '$log', '$rootScope', '$http',
'Store'
];
[ '$rootScope', 'Store' ];

View File

@ -246,10 +246,10 @@
"integrity": "sha512-nB/xe7JQWF9nLvhHommAICQ3eWrfRETo0EVGFESi952CDzDa+GAJ/2BFBNw44QqQPxj1Xua/uYKrbLsOGWZdbQ=="
},
"angular-scheduler": {
"version": "git+https://git@github.com/ansible/angular-scheduler.git#7628cb2fc9e6280811baa464f0020a636e65d702",
"from": "git+https://git@github.com/ansible/angular-scheduler.git#v0.3.3",
"version": "git+https://git@github.com/ansible/angular-scheduler.git#a519c52312cb4430a59a8d58e01d3eac3fe5018a",
"from": "git+https://git@github.com/ansible/angular-scheduler.git#v0.4.1",
"requires": {
"angular": "~1.6.6",
"angular": "~1.7.2",
"angular-tz-extensions": "github:ansible/angular-tz-extensions#fc60660f43ee9ff84da94ca71ab27ef0c20fd77d",
"jquery": "*",
"jquery-ui": "*",
@ -258,45 +258,25 @@
"rrule": "github:jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c"
},
"dependencies": {
"angular": {
"version": "1.6.10",
"resolved": "https://registry.npmjs.org/angular/-/angular-1.6.10.tgz",
"integrity": "sha512-PCZ5/hVdvPQiYyH0VwsPjrErPHRcITnaXxhksceOXgtJeesKHLA7KDu4X/yvcAi+1zdGgGF+9pDxkJvghXI9Wg=="
},
"angular-tz-extensions": {
"version": "github:ansible/angular-tz-extensions#fc60660f43ee9ff84da94ca71ab27ef0c20fd77d",
"from": "github:ansible/angular-tz-extensions#fc60660f43ee9ff84da94ca71ab27ef0c20fd77d",
"from": "github:ansible/angular-tz-extensions",
"requires": {
"angular": "~1.7.2",
"angular-filters": "^1.1.2",
"jquery": "^3.1.0",
"jstimezonedetect": "1.0.5",
"timezone-js": "github:ansible/timezone-js#6937de14ce0c193961538bb5b3b12b7ef62a358f"
},
"dependencies": {
"angular": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/angular/-/angular-1.7.6.tgz",
"integrity": "sha512-QELpvuMIe1FTGniAkRz93O6A+di0yu88niDwcdzrSqtUHNtZMgtgFS4f7W/6Gugbuwej8Kyswlmymwdp8iPCWg=="
},
"timezone-js": {
"version": "github:ansible/timezone-js#6937de14ce0c193961538bb5b3b12b7ef62a358f",
"from": "github:ansible/timezone-js#0.4.14"
}
}
},
"lodash": {
"version": "3.8.0",
"resolved": "http://registry.npmjs.org/lodash/-/lodash-3.8.0.tgz",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.8.0.tgz",
"integrity": "sha1-N265i9zZOCqTZcM8TLglDeEyW5E="
},
"rrule": {
"version": "github:jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c",
"from": "github:jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c"
},
"timezone-js": {
"version": "github:ansible/timezone-js#6937de14ce0c193961538bb5b3b12b7ef62a358f",
"from": "github:ansible/timezone-js#6937de14ce0c193961538bb5b3b12b7ef62a358f"
}
}
},

View File

@ -107,7 +107,7 @@
"angular-moment": "^1.3.0",
"angular-mousewheel": "^1.0.5",
"angular-sanitize": "^1.7.9",
"angular-scheduler": "git+https://git@github.com/ansible/angular-scheduler#v0.3.3",
"angular-scheduler": "git+https://git@github.com/ansible/angular-scheduler#v0.4.1",
"angular-tz-extensions": "git+https://git@github.com/ansible/angular-tz-extensions#v0.5.2",
"angular-xeditable": "~0.8.0",
"ansi-to-html": "^0.6.3",

File diff suppressed because it is too large Load Diff

View File

@ -52,7 +52,7 @@
"react-hot-loader": "^4.3.3",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.0",
"webpack": "^4.23.1",
"webpack": "^4.41.2",
"webpack-cli": "^3.0.8",
"webpack-dev-server": "^3.1.14"
},

View File

@ -2,6 +2,7 @@ import AdHocCommands from './models/AdHocCommands';
import Config from './models/Config';
import CredentialTypes from './models/CredentialTypes';
import Credentials from './models/Credentials';
import Groups from './models/Groups';
import Hosts from './models/Hosts';
import InstanceGroups from './models/InstanceGroups';
import Inventories from './models/Inventories';
@ -28,6 +29,7 @@ const AdHocCommandsAPI = new AdHocCommands();
const ConfigAPI = new Config();
const CredentialsAPI = new Credentials();
const CredentialTypesAPI = new CredentialTypes();
const GroupsAPI = new Groups();
const HostsAPI = new Hosts();
const InstanceGroupsAPI = new InstanceGroups();
const InventoriesAPI = new Inventories();
@ -55,6 +57,7 @@ export {
ConfigAPI,
CredentialsAPI,
CredentialTypesAPI,
GroupsAPI,
HostsAPI,
InstanceGroupsAPI,
InventoriesAPI,

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class Groups extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/groups/';
}
}
export default Groups;

View File

@ -7,6 +7,10 @@ class Inventories extends InstanceGroupsMixin(Base) {
this.baseUrl = '/api/v2/inventories/';
this.readAccessList = this.readAccessList.bind(this);
this.readHosts = this.readHosts.bind(this);
this.readGroups = this.readGroups.bind(this);
this.readGroupsOptions = this.readGroupsOptions.bind(this);
this.promoteGroup = this.promoteGroup.bind(this);
}
readAccessList(id, params) {
@ -15,9 +19,28 @@ class Inventories extends InstanceGroupsMixin(Base) {
});
}
createHost(id, data) {
return this.http.post(`${this.baseUrl}${id}/hosts/`, data);
}
readHosts(id, params) {
return this.http.get(`${this.baseUrl}${id}/hosts/`, { params });
}
readGroups(id, params) {
return this.http.get(`${this.baseUrl}${id}/groups/`, { params });
}
readGroupsOptions(id) {
return this.http.options(`${this.baseUrl}${id}/groups/`);
}
promoteGroup(inventoryId, groupId) {
return this.http.post(`${this.baseUrl}${inventoryId}/groups/`, {
id: groupId,
disassociate: true,
});
}
}
export default Inventories;

View File

@ -110,6 +110,7 @@
--pf-c-modal-box__footer--PaddingRight: 20px;
--pf-c-modal-box__footer--PaddingBottom: 20px;
--pf-c-modal-box__footer--PaddingLeft: 20px;
--pf-c-modal-box__footer--MarginTop: 24px;
justify-content: flex-end;
}

View File

@ -187,11 +187,13 @@ class AddResourceRole extends React.Component {
<SelectableCard
isSelected={selectedResource === 'users'}
label={i18n._(t`Users`)}
dataCy="add-role-users"
onClick={() => this.handleResourceSelect('users')}
/>
<SelectableCard
isSelected={selectedResource === 'teams'}
label={i18n._(t`Teams`)}
dataCy="add-role-teams"
onClick={() => this.handleResourceSelect('teams')}
/>
</div>

View File

@ -33,7 +33,7 @@ const Label = styled.div`
class SelectableCard extends Component {
render() {
const { label, onClick, isSelected } = this.props;
const { label, onClick, isSelected, dataCy } = this.props;
return (
<SelectableItem
@ -41,6 +41,7 @@ class SelectableCard extends Component {
onKeyPress={onClick}
role="button"
tabIndex="0"
data-cy={dataCy}
isSelected={isSelected}
>
<Indicator isSelected={isSelected} />

View File

@ -25,7 +25,7 @@ class AnsibleSelect extends React.Component {
}
render() {
const { id, data, i18n, isValid, onBlur, value } = this.props;
const { id, data, i18n, isValid, onBlur, value, className } = this.props;
return (
<FormSelect
@ -35,13 +35,14 @@ class AnsibleSelect extends React.Component {
onBlur={onBlur}
aria-label={i18n._(t`Select Input`)}
isValid={isValid}
className={className}
>
{data.map(datum => (
{data.map(option => (
<FormSelectOption
key={datum.key}
value={datum.value}
label={datum.label}
isDisabled={datum.isDisabled}
key={option.key}
value={option.value}
label={option.label}
isDisabled={option.isDisabled}
/>
))}
</FormSelect>
@ -49,19 +50,28 @@ class AnsibleSelect extends React.Component {
}
}
const Option = shape({
key: oneOfType([string, number]).isRequired,
value: oneOfType([string, number]).isRequired,
label: string.isRequired,
isDisabled: bool,
});
AnsibleSelect.defaultProps = {
data: [],
isValid: true,
onBlur: () => {},
className: '',
};
AnsibleSelect.propTypes = {
data: arrayOf(shape()),
data: arrayOf(Option),
id: string.isRequired,
isValid: bool,
onBlur: func,
onChange: func.isRequired,
value: oneOfType([string, number]).isRequired,
className: string,
};
export { AnsibleSelect as _AnsibleSelect };

View File

@ -16,6 +16,7 @@ const CheckboxListItem = ({
label,
isSelected,
onSelect,
onDeselect,
isRadio,
}) => {
const CheckboxRadio = isRadio ? DataListRadio : DataListCheck;
@ -25,7 +26,7 @@ const CheckboxListItem = ({
<CheckboxRadio
id={`selected-${itemId}`}
checked={isSelected}
onChange={onSelect}
onChange={isSelected ? onDeselect : onSelect}
aria-labelledby={`check-action-item-${itemId}`}
name={name}
value={itemId}
@ -60,6 +61,7 @@ CheckboxListItem.propTypes = {
label: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired,
};
export default CheckboxListItem;

View File

@ -12,6 +12,7 @@ describe('CheckboxListItem', () => {
label="Buzz"
isSelected={false}
onSelect={() => {}}
onDeselect={() => {}}
/>
);
expect(wrapper).toHaveLength(1);

View File

@ -4,8 +4,8 @@ import { withI18n } from '@lingui/react';
import { EmptyState, EmptyStateBody } from '@patternfly/react-core';
// TODO: Better loading state - skeleton lines / spinner, etc.
const ContentLoading = ({ i18n }) => (
<EmptyState>
const ContentLoading = ({ className, i18n }) => (
<EmptyState className={className}>
<EmptyStateBody>{i18n._(t`Loading...`)}</EmptyStateBody>
</EmptyState>
);

View File

@ -1,11 +1,20 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import React, { useEffect, useState } from 'react';
import { bool, func, number, string, oneOfType } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { CredentialsAPI } from '@api';
import { Credential } from '@types';
import { mergeParams } from '@util/qs';
import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
import { FormGroup } from '@patternfly/react-core';
import Lookup from '@components/Lookup';
import OptionsList from './shared/OptionsList';
import LookupErrorMessage from './shared/LookupErrorMessage';
const QS_CONFIG = getQSConfig('credentials', {
page: 1,
page_size: 5,
order_by: 'name',
});
function CredentialLookup({
helperTextInvalid,
@ -16,11 +25,28 @@ function CredentialLookup({
required,
credentialTypeId,
value,
history,
}) {
const getCredentials = async params =>
CredentialsAPI.read(
mergeParams(params, { credential_type: credentialTypeId })
);
const [credentials, setCredentials] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
useEffect(() => {
(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
try {
const { data } = await CredentialsAPI.read(
mergeParams(params, { credential_type: credentialTypeId })
);
setCredentials(data.results);
setCount(data.count);
} catch (err) {
if (setError) {
setError(err);
}
}
})();
}, [credentialTypeId, history.location.search]);
return (
<FormGroup
@ -32,15 +58,26 @@ function CredentialLookup({
>
<Lookup
id="credential"
lookupHeader={label}
name="credential"
header={label}
value={value}
onBlur={onBlur}
onLookupSave={onChange}
getItems={getCredentials}
onChange={onChange}
required={required}
sortedColumnKey="name"
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}
options={credentials}
optionCount={count}
header={label}
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
/>
)}
/>
<LookupErrorMessage error={error} />
</FormGroup>
);
}
@ -65,4 +102,4 @@ CredentialLookup.defaultProps = {
};
export { CredentialLookup as _CredentialLookup };
export default withI18n()(CredentialLookup);
export default withI18n()(withRouter(CredentialLookup));

View File

@ -1,4 +1,5 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import CredentialLookup, { _CredentialLookup } from './CredentialLookup';
import { CredentialsAPI } from '@api';
@ -9,19 +10,48 @@ describe('CredentialLookup', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(
<CredentialLookup credentialTypeId={1} label="Foo" onChange={() => {}} />
);
CredentialsAPI.read.mockResolvedValueOnce({
data: {
results: [
{ id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' },
{ id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' },
{ id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' },
{ id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' },
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
],
count: 5,
},
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('initially renders successfully', () => {
test('should render successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(
<CredentialLookup
credentialTypeId={1}
label="Foo"
onChange={() => {}}
/>
);
});
expect(wrapper.find('CredentialLookup')).toHaveLength(1);
});
test('should fetch credentials', () => {
test('should fetch credentials', async () => {
await act(async () => {
wrapper = mountWithContexts(
<CredentialLookup
credentialTypeId={1}
label="Foo"
onChange={() => {}}
/>
);
});
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type: 1,
@ -30,11 +60,31 @@ describe('CredentialLookup', () => {
page_size: 5,
});
});
test('should display label', () => {
test('should display label', async () => {
await act(async () => {
wrapper = mountWithContexts(
<CredentialLookup
credentialTypeId={1}
label="Foo"
onChange={() => {}}
/>
);
});
const title = wrapper.find('FormGroup .pf-c-form__label-text');
expect(title.text()).toEqual('Foo');
});
test('should define default value for function props', () => {
test('should define default value for function props', async () => {
await act(async () => {
wrapper = mountWithContexts(
<CredentialLookup
credentialTypeId={1}
label="Foo"
onChange={() => {}}
/>
);
});
expect(_CredentialLookup.defaultProps.onBlur).toBeInstanceOf(Function);
expect(_CredentialLookup.defaultProps.onBlur).not.toThrow();
});

View File

@ -1,48 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import React, { useState, useEffect } from 'react';
import { arrayOf, string, func, object, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { FormGroup } from '@patternfly/react-core';
import { InstanceGroupsAPI } from '@api';
import Lookup from '@components/Lookup';
import { getQSConfig, parseQueryString } from '@util/qs';
import { FieldTooltip } from '@components/FormField';
import Lookup from './Lookup';
import OptionsList from './shared/OptionsList';
import LookupErrorMessage from './shared/LookupErrorMessage';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;
`;
const QS_CONFIG = getQSConfig('instance_groups', {
page: 1,
page_size: 5,
order_by: 'name',
});
const getInstanceGroups = async params => InstanceGroupsAPI.read(params);
function InstanceGroupsLookup(props) {
const {
value,
onChange,
tooltip,
className,
required,
history,
i18n,
} = props;
const [instanceGroups, setInstanceGroups] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
class InstanceGroupsLookup extends React.Component {
render() {
const { value, tooltip, onChange, className, i18n } = this.props;
useEffect(() => {
(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
try {
const { data } = await InstanceGroupsAPI.read(params);
setInstanceGroups(data.results);
setCount(data.count);
} catch (err) {
setError(err);
}
})();
}, [history.location]);
/*
Wrapping <div> added to workaround PF bug:
https://github.com/patternfly/patternfly-react/issues/2855
*/
return (
<div className={className}>
<FormGroup
label={i18n._(t`Instance Groups`)}
fieldId="org-instance-groups"
>
{tooltip && (
<Tooltip position="right" content={tooltip}>
<QuestionCircleIcon />
</Tooltip>
)}
<Lookup
id="org-instance-groups"
lookupHeader={i18n._(t`Instance Groups`)}
name="instanceGroups"
value={value}
onLookupSave={onChange}
getItems={getInstanceGroups}
qsNamespace="instance-group"
multiple
return (
<FormGroup
className={className}
label={i18n._(t`Instance Groups`)}
fieldId="org-instance-groups"
>
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
id="org-instance-groups"
header={i18n._(t`Instance Groups`)}
value={value}
onChange={onChange}
qsConfig={QS_CONFIG}
multiple
required={required}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}
options={instanceGroups}
optionCount={count}
columns={[
{
name: i18n._(t`Name`),
@ -63,22 +84,33 @@ class InstanceGroupsLookup extends React.Component {
isNumeric: true,
},
]}
sortedColumnKey="name"
multiple={state.multiple}
header={i18n._(t`Instance Groups`)}
name="instanceGroups"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
/>
</FormGroup>
</div>
);
}
)}
/>
<LookupErrorMessage error={error} />
</FormGroup>
);
}
InstanceGroupsLookup.propTypes = {
value: PropTypes.arrayOf(PropTypes.object).isRequired,
tooltip: PropTypes.string,
onChange: PropTypes.func.isRequired,
value: arrayOf(object).isRequired,
tooltip: string,
onChange: func.isRequired,
className: string,
required: bool,
};
InstanceGroupsLookup.defaultProps = {
tooltip: '',
className: '',
required: false,
};
export default withI18n()(InstanceGroupsLookup);
export default withI18n()(withRouter(InstanceGroupsLookup));

View File

@ -1,5 +1,6 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { string, func, bool } 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';
@ -7,61 +8,94 @@ import { InventoriesAPI } from '@api';
import { Inventory } from '@types';
import Lookup from '@components/Lookup';
import { FieldTooltip } from '@components/FormField';
import { getQSConfig, parseQueryString } from '@util/qs';
import OptionsList from './shared/OptionsList';
import LookupErrorMessage from './shared/LookupErrorMessage';
const getInventories = async params => InventoriesAPI.read(params);
const QS_CONFIG = getQSConfig('inventory', {
page: 1,
page_size: 5,
order_by: 'name',
});
class InventoryLookup extends React.Component {
render() {
const {
value,
tooltip,
onChange,
onBlur,
required,
isValid,
helperTextInvalid,
i18n,
} = this.props;
function InventoryLookup({
value,
tooltip,
onChange,
onBlur,
required,
isValid,
helperTextInvalid,
i18n,
history,
}) {
const [inventories, setInventories] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
return (
<FormGroup
label={i18n._(t`Inventory`)}
isRequired={required}
fieldId="inventory-lookup"
isValid={isValid}
helperTextInvalid={helperTextInvalid}
>
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
id="inventory-lookup"
lookupHeader={i18n._(t`Inventory`)}
name="inventory"
value={value}
onLookupSave={onChange}
onBlur={onBlur}
getItems={getInventories}
required={required}
qsNamespace="inventory"
columns={[
{ name: i18n._(t`Name`), key: 'name', isSortable: true },
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: false,
isNumeric: true,
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: false,
isNumeric: true,
},
]}
sortedColumnKey="name"
/>
</FormGroup>
);
}
useEffect(() => {
(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
try {
const { data } = await InventoriesAPI.read(params);
setInventories(data.results);
setCount(data.count);
} catch (err) {
setError(err);
}
})();
}, [history.location]);
return (
<FormGroup
label={i18n._(t`Inventory`)}
isRequired={required}
fieldId="inventory-lookup"
isValid={isValid}
helperTextInvalid={helperTextInvalid}
>
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
id="inventory-lookup"
header={i18n._(t`Inventory`)}
value={value}
onChange={onChange}
onBlur={onBlur}
required={required}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}
options={inventories}
optionCount={count}
columns={[
{ name: i18n._(t`Name`), key: 'name', isSortable: true },
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: false,
isNumeric: true,
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: false,
isNumeric: true,
},
]}
multiple={state.multiple}
header={i18n._(t`Inventory`)}
name="inventory"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
/>
)}
/>
<LookupErrorMessage error={error} />
</FormGroup>
);
}
InventoryLookup.propTypes = {
@ -77,4 +111,4 @@ InventoryLookup.defaultProps = {
required: false,
};
export default withI18n()(InventoryLookup);
export default withI18n()(withRouter(InventoryLookup));

View File

@ -1,4 +1,4 @@
import React, { Fragment } from 'react';
import React, { Fragment, useReducer, useEffect } from 'react';
import {
string,
bool,
@ -15,20 +15,14 @@ import {
ButtonVariant,
InputGroup as PFInputGroup,
Modal,
ToolbarItem,
} from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import AnsibleSelect from '../AnsibleSelect';
import PaginatedDataList from '../PaginatedDataList';
import VerticalSeperator from '../VerticalSeparator';
import DataListToolbar from '../DataListToolbar';
import CheckboxListItem from '../CheckboxListItem';
import SelectedList from '../SelectedList';
import { ChipGroup, Chip, CredentialChip } from '../Chip';
import { getQSConfig, parseQueryString } from '../../util/qs';
import reducer, { initReducer } from './shared/reducer';
import { ChipGroup, Chip } from '../Chip';
import { QSConfig } from '@types';
const SearchButton = styled(Button)`
::after {
@ -36,6 +30,7 @@ const SearchButton = styled(Button)`
var(--pf-global--BorderColor--200);
}
`;
SearchButton.displayName = 'SearchButton';
const InputGroup = styled(PFInputGroup)`
${props =>
@ -54,315 +49,124 @@ const ChipHolder = styled.div`
border-bottom-right-radius: 3px;
`;
class Lookup extends React.Component {
constructor(props) {
super(props);
function Lookup(props) {
const {
id,
header,
onChange,
onBlur,
value,
multiple,
required,
qsConfig,
renderItemChip,
renderOptionsList,
history,
i18n,
} = props;
this.assertCorrectValueType();
let lookupSelectedItems = [];
if (props.value) {
lookupSelectedItems = props.multiple ? [...props.value] : [props.value];
}
this.state = {
isModalOpen: false,
lookupSelectedItems,
results: [],
count: 0,
error: null,
};
this.qsConfig = getQSConfig(props.qsNamespace, {
page: 1,
page_size: 5,
order_by: props.sortedColumnKey,
});
this.handleModalToggle = this.handleModalToggle.bind(this);
this.toggleSelected = this.toggleSelected.bind(this);
this.saveModal = this.saveModal.bind(this);
this.getData = this.getData.bind(this);
this.clearQSParams = this.clearQSParams.bind(this);
}
const [state, dispatch] = useReducer(
reducer,
{ value, multiple, required },
initReducer
);
componentDidMount() {
this.getData();
}
useEffect(() => {
dispatch({ type: 'SET_MULTIPLE', value: multiple });
}, [multiple]);
componentDidUpdate(prevProps) {
const { location, selectedCategory } = this.props;
if (
location !== prevProps.location ||
prevProps.selectedCategory !== selectedCategory
) {
this.getData();
}
}
useEffect(() => {
dispatch({ type: 'SET_VALUE', value });
}, [value]);
assertCorrectValueType() {
const { multiple, value, selectCategoryOptions } = this.props;
if (selectCategoryOptions) {
return;
}
if (!multiple && Array.isArray(value)) {
throw new Error(
'Lookup value must not be an array unless `multiple` is set'
);
}
if (multiple && !Array.isArray(value)) {
throw new Error('Lookup value must be an array if `multiple` is set');
}
}
async getData() {
const {
getItems,
location: { search },
} = this.props;
const queryParams = parseQueryString(this.qsConfig, search);
this.setState({ error: false });
try {
const { data } = await getItems(queryParams);
const { results, count } = data;
this.setState({
results,
count,
});
} catch (err) {
this.setState({ error: true });
}
}
toggleSelected(row) {
const {
name,
onLookupSave,
multiple,
onToggleItem,
selectCategoryOptions,
} = this.props;
const {
lookupSelectedItems: updatedSelectedItems,
isModalOpen,
} = this.state;
const selectedIndex = updatedSelectedItems.findIndex(
selectedRow => selectedRow.id === row.id
);
if (multiple) {
if (selectCategoryOptions) {
onToggleItem(row, isModalOpen);
}
if (selectedIndex > -1) {
updatedSelectedItems.splice(selectedIndex, 1);
this.setState({ lookupSelectedItems: updatedSelectedItems });
} else {
this.setState(prevState => ({
lookupSelectedItems: [...prevState.lookupSelectedItems, row],
}));
}
} else {
this.setState({ lookupSelectedItems: [row] });
}
// Updates the selected items from parent state
// This handles the case where the user removes chips from the lookup input
// while the modal is closed
if (!isModalOpen) {
onLookupSave(updatedSelectedItems, name);
}
}
handleModalToggle() {
const { isModalOpen } = this.state;
const { value, multiple, selectCategory } = this.props;
// Resets the selected items from parent state whenever modal is opened
// This handles the case where the user closes/cancels the modal and
// opens it again
if (!isModalOpen) {
let lookupSelectedItems = [];
if (value) {
lookupSelectedItems = multiple ? [...value] : [value];
}
this.setState({ lookupSelectedItems });
} else {
this.clearQSParams();
if (selectCategory) {
selectCategory(null, 'Machine');
}
}
this.setState(prevState => ({
isModalOpen: !prevState.isModalOpen,
}));
}
saveModal() {
const { onLookupSave, name, multiple } = this.props;
const { lookupSelectedItems } = this.state;
const value = multiple
? lookupSelectedItems
: lookupSelectedItems[0] || null;
this.handleModalToggle();
onLookupSave(value, name);
}
clearQSParams() {
const { history } = this.props;
const clearQSParams = () => {
const parts = history.location.search.replace(/^\?/, '').split('&');
const ns = this.qsConfig.namespace;
const ns = qsConfig.namespace;
const otherParts = parts.filter(param => !param.startsWith(`${ns}.`));
history.push(`${history.location.pathname}?${otherParts.join('&')}`);
}
};
render() {
const {
isModalOpen,
lookupSelectedItems,
error,
results,
count,
} = this.state;
const {
form,
id,
lookupHeader,
value,
columns,
multiple,
name,
onBlur,
selectCategory,
required,
i18n,
selectCategoryOptions,
selectedCategory,
} = this.props;
const header = lookupHeader || i18n._(t`Items`);
const canDelete = !required || (multiple && value.length > 1);
const chips = () => {
return selectCategoryOptions && selectCategoryOptions.length > 0 ? (
<ChipGroup defaultIsOpen numChips={5}>
{(multiple ? value : [value]).map(chip => (
<CredentialChip
key={chip.id}
onClick={() => this.toggleSelected(chip)}
isReadOnly={!canDelete}
credential={chip}
/>
))}
</ChipGroup>
) : (
<ChipGroup defaultIsOpen numChips={5}>
{(multiple ? value : [value]).map(chip => (
<Chip
key={chip.id}
onClick={() => this.toggleSelected(chip)}
isReadOnly={!canDelete}
>
{chip.name}
</Chip>
))}
</ChipGroup>
);
};
return (
<Fragment>
<InputGroup onBlur={onBlur}>
<SearchButton
aria-label="Search"
id={id}
onClick={this.handleModalToggle}
variant={ButtonVariant.tertiary}
>
<SearchIcon />
</SearchButton>
<ChipHolder className="pf-c-form-control">
{value ? chips(value) : null}
</ChipHolder>
</InputGroup>
<Modal
className="awx-c-modal"
title={i18n._(t`Select ${header}`)}
isOpen={isModalOpen}
onClose={this.handleModalToggle}
actions={[
<Button
key="save"
variant="primary"
onClick={this.saveModal}
style={results.length === 0 ? { display: 'none' } : {}}
>
{i18n._(t`Save`)}
</Button>,
<Button
key="cancel"
variant="secondary"
onClick={this.handleModalToggle}
>
{results.length === 0 ? i18n._(t`Close`) : i18n._(t`Cancel`)}
</Button>,
]}
>
{selectCategoryOptions && selectCategoryOptions.length > 0 && (
<ToolbarItem css=" display: flex; align-items: center;">
<span css="flex: 0 0 25%;">Selected Category</span>
<VerticalSeperator />
<AnsibleSelect
css="flex: 1 1 75%;"
id="multiCredentialsLookUp-select"
label="Selected Category"
data={selectCategoryOptions}
value={selectedCategory.label}
onChange={selectCategory}
form={form}
/>
</ToolbarItem>
)}
<PaginatedDataList
items={results}
itemCount={count}
pluralizedItemName={lookupHeader}
qsConfig={this.qsConfig}
toolbarColumns={columns}
renderItem={item => (
<CheckboxListItem
key={item.id}
itemId={item.id}
name={multiple ? item.name : name}
label={item.name}
isSelected={
selectCategoryOptions
? value.some(i => i.id === item.id)
: lookupSelectedItems.some(i => i.id === item.id)
}
onSelect={() => this.toggleSelected(item)}
isRadio={
!multiple ||
(selectCategoryOptions &&
selectCategoryOptions.length &&
selectedCategory.value !== 'Vault')
}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
{lookupSelectedItems.length > 0 && (
<SelectedList
label={i18n._(t`Selected`)}
selected={selectCategoryOptions ? value : lookupSelectedItems}
onRemove={this.toggleSelected}
isReadOnly={!canDelete}
isCredentialList={
selectCategoryOptions && selectCategoryOptions.length > 0
}
/>
)}
{error ? <div>error</div> : ''}
</Modal>
</Fragment>
);
const save = () => {
const { selectedItems } = state;
const val = multiple ? selectedItems : selectedItems[0] || null;
onChange(val);
clearQSParams();
dispatch({ type: 'CLOSE_MODAL' });
};
const removeItem = item => {
if (multiple) {
onChange(value.filter(i => i.id !== item.id));
} else {
onChange(null);
}
};
const closeModal = () => {
clearQSParams();
dispatch({ type: 'CLOSE_MODAL' });
};
const { isModalOpen, selectedItems } = state;
const canDelete = !required || (multiple && value.length > 1);
let items = [];
if (multiple) {
items = value;
} else if (value) {
items.push(value);
}
return (
<Fragment>
<InputGroup onBlur={onBlur}>
<SearchButton
aria-label="Search"
id={id}
onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}
variant={ButtonVariant.tertiary}
>
<SearchIcon />
</SearchButton>
<ChipHolder className="pf-c-form-control">
<ChipGroup numChips={5}>
{items.map(item =>
renderItemChip({
item,
removeItem,
canDelete,
})
)}
</ChipGroup>
</ChipHolder>
</InputGroup>
<Modal
className="awx-c-modal"
title={i18n._(t`Select ${header || i18n._(t`Items`)}`)}
isOpen={isModalOpen}
onClose={closeModal}
actions={[
<Button
key="select"
variant="primary"
onClick={save}
style={
required && selectedItems.length === 0 ? { display: 'none' } : {}
}
>
{i18n._(t`Select`)}
</Button>,
<Button key="cancel" variant="secondary" onClick={closeModal}>
{i18n._(t`Cancel`)}
</Button>,
]}
>
{renderOptionsList({
state,
dispatch,
canDelete,
})}
</Modal>
</Fragment>
);
}
const Item = shape({
@ -371,25 +175,33 @@ const Item = shape({
Lookup.propTypes = {
id: string,
getItems: func.isRequired,
lookupHeader: string,
name: string,
onLookupSave: func.isRequired,
header: string,
onChange: func.isRequired,
value: oneOfType([Item, arrayOf(Item)]),
sortedColumnKey: string.isRequired,
multiple: bool,
required: bool,
qsNamespace: string,
onBlur: func,
qsConfig: QSConfig.isRequired,
renderItemChip: func,
renderOptionsList: func.isRequired,
};
Lookup.defaultProps = {
id: 'lookup-search',
lookupHeader: null,
name: null,
header: null,
value: null,
multiple: false,
required: false,
qsNamespace: 'lookup',
onBlur: () => {},
renderItemChip: ({ item, removeItem, canDelete }) => (
<Chip
key={item.id}
onClick={() => removeItem(item)}
isReadOnly={!canDelete}
>
{item.name}
</Chip>
),
};
export { Lookup as _Lookup };

View File

@ -1,11 +1,9 @@
/* eslint-disable react/jsx-pascal-case */
import React from 'react';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import Lookup, { _Lookup } from './Lookup';
let mockData = [{ name: 'foo', id: 1, isChecked: false }];
const mockColumns = [{ name: 'Name', key: 'name', isSortable: true }];
import { getQSConfig } from '@util/qs';
import Lookup from './Lookup';
/**
* Check that an element is present on the document body
@ -44,348 +42,118 @@ async function checkInputTagValues(wrapper, expected) {
});
}
/**
* Check lookup modal list for expected values
* @param {wrapper} enzyme wrapper instance
* @param {expected} array of [selected, text] pairs describing
* the expected visible state of the modal data list
*/
async function checkModalListValues(wrapper, expected) {
// fail if modal isn't actually visible
checkRootElementPresent('body div[role="dialog"]');
// check list item values
const rows = await waitForElement(
wrapper,
'DataListItemRow',
el => el.length === expected.length
);
expect(rows).toHaveLength(expected.length);
rows.forEach((el, index) => {
const [expectedChecked, expectedText] = expected[index];
expect(expectedText).toEqual(el.text());
expect(expectedChecked).toEqual(el.find('input').props().checked);
});
}
/**
* Check lookup modal selection tags for expected values
* @param {wrapper} enzyme wrapper instance
* @param {expected} array of expected tag values
*/
async function checkModalTagValues(wrapper, expected) {
// fail if modal isn't actually visible
checkRootElementPresent('body div[role="dialog"]');
// check modal chip values
const chips = await waitForElement(
wrapper,
'Modal Chip span',
el => el.length === expected.length
);
expect(chips).toHaveLength(expected.length);
chips.forEach((el, index) => {
expect(el.text()).toEqual(expected[index]);
});
}
describe('<Lookup multiple/>', () => {
let wrapper;
let onChange;
beforeEach(() => {
const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }];
onChange = jest.fn();
document.body.innerHTML = '';
wrapper = mountWithContexts(
<Lookup
multiple
lookupHeader="Foo Bar"
name="foobar"
value={mockSelected}
onLookupSave={onChange}
getItems={() => ({
data: {
count: 2,
results: [
...mockSelected,
{ name: 'bar', id: 2, url: '/api/v2/item/2' },
],
},
})}
columns={mockColumns}
sortedColumnKey="name"
/>
);
});
test('Initially renders succesfully', () => {
expect(wrapper.find('Lookup')).toHaveLength(1);
});
test('Expected items are shown', async done => {
expect(wrapper.find('Lookup')).toHaveLength(1);
await checkInputTagValues(wrapper, ['foo']);
done();
});
test('Open and close modal', async done => {
checkRootElementNotPresent('body div[role="dialog"]');
wrapper.find('button[aria-label="Search"]').simulate('click');
checkRootElementPresent('body div[role="dialog"]');
// This check couldn't pass unless api response was formatted properly
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
wrapper.find('Modal button[aria-label="Close"]').simulate('click');
checkRootElementNotPresent('body div[role="dialog"]');
wrapper.find('button[aria-label="Search"]').simulate('click');
checkRootElementPresent('body div[role="dialog"]');
wrapper
.find('Modal button')
.findWhere(e => e.text() === 'Cancel')
.first()
.simulate('click');
checkRootElementNotPresent('body div[role="dialog"]');
done();
});
test('Add item with checkbox then save', async done => {
wrapper.find('button[aria-label="Search"]').simulate('click');
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
wrapper
.find('DataListItemRow')
.findWhere(el => el.text() === 'bar')
.find('input[type="checkbox"]')
.simulate('change');
await checkModalListValues(wrapper, [[true, 'foo'], [true, 'bar']]);
wrapper
.find('Modal button')
.findWhere(e => e.text() === 'Save')
.first()
.simulate('click');
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][0].map(({ name }) => name)).toEqual([
'foo',
'bar',
]);
done();
});
test('Add item with checkbox then cancel', async done => {
wrapper.find('button[aria-label="Search"]').simulate('click');
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
wrapper
.find('DataListItemRow')
.findWhere(el => el.text() === 'bar')
.find('input[type="checkbox"]')
.simulate('change');
await checkModalListValues(wrapper, [[true, 'foo'], [true, 'bar']]);
wrapper
.find('Modal button')
.findWhere(e => e.text() === 'Cancel')
.first()
.simulate('click');
expect(onChange).toHaveBeenCalledTimes(0);
await checkInputTagValues(wrapper, ['foo']);
done();
});
test('Remove item with checkbox', async done => {
wrapper.find('button[aria-label="Search"]').simulate('click');
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
await checkModalTagValues(wrapper, ['foo']);
wrapper
.find('DataListItemRow')
.findWhere(el => el.text() === 'foo')
.find('input[type="checkbox"]')
.simulate('change');
await checkModalListValues(wrapper, [[false, 'foo'], [false, 'bar']]);
await checkModalTagValues(wrapper, []);
done();
});
test('Remove item with selected icon button', async done => {
wrapper.find('button[aria-label="Search"]').simulate('click');
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
await checkModalTagValues(wrapper, ['foo']);
wrapper
.find('Modal Chip')
.findWhere(el => el.text() === 'foo')
.first()
.find('button')
.simulate('click');
await checkModalListValues(wrapper, [[false, 'foo'], [false, 'bar']]);
await checkModalTagValues(wrapper, []);
done();
});
test('Remove item with input group button', async done => {
await checkInputTagValues(wrapper, ['foo']);
wrapper
.find('Lookup InputGroup Chip')
.findWhere(el => el.text() === 'foo')
.first()
.find('button')
.simulate('click');
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith([], 'foobar');
done();
});
});
const QS_CONFIG = getQSConfig('test', {});
const TestList = () => <div />;
describe('<Lookup />', () => {
let wrapper;
let onChange;
async function mountWrapper() {
const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }];
await act(async () => {
wrapper = mountWithContexts(
<Lookup
id="test"
multiple
header="Foo Bar"
value={mockSelected}
onChange={onChange}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<TestList
id="options-list"
state={state}
dispatch={dispatch}
canDelete={canDelete}
/>
)}
/>
);
});
return wrapper;
}
beforeEach(() => {
const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' };
onChange = jest.fn();
document.body.innerHTML = '';
wrapper = mountWithContexts(
<Lookup
lookupHeader="Foo Bar"
name="foobar"
value={mockSelected}
onLookupSave={onChange}
getItems={() => ({
data: {
count: 2,
results: [
mockSelected,
{ name: 'bar', id: 2, url: '/api/v2/item/2' },
],
},
})}
columns={mockColumns}
sortedColumnKey="name"
/>
);
});
test('Initially renders succesfully', () => {
afterEach(() => {
jest.restoreAllMocks();
});
test('should render succesfully', async () => {
wrapper = await mountWrapper();
expect(wrapper.find('Lookup')).toHaveLength(1);
});
test('Expected items are shown', async done => {
test('should show selected items', async () => {
wrapper = await mountWrapper();
expect(wrapper.find('Lookup')).toHaveLength(1);
await checkInputTagValues(wrapper, ['foo']);
done();
});
test('Open and close modal', async done => {
checkRootElementNotPresent('body div[role="dialog"]');
wrapper.find('button[aria-label="Search"]').simulate('click');
checkRootElementPresent('body div[role="dialog"]');
// This check couldn't pass unless api response was formatted properly
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
wrapper.find('Modal button[aria-label="Close"]').simulate('click');
test('should open and close modal', async () => {
wrapper = await mountWrapper();
checkRootElementNotPresent('body div[role="dialog"]');
wrapper.find('button[aria-label="Search"]').simulate('click');
checkRootElementPresent('body div[role="dialog"]');
const list = wrapper.find('TestList');
expect(list).toHaveLength(1);
expect(list.prop('state')).toEqual({
selectedItems: [{ id: 1, name: 'foo', url: '/api/v2/item/1' }],
value: [{ id: 1, name: 'foo', url: '/api/v2/item/1' }],
multiple: true,
isModalOpen: true,
required: false,
});
expect(list.prop('dispatch')).toBeTruthy();
expect(list.prop('canDelete')).toEqual(true);
wrapper
.find('Modal button')
.findWhere(e => e.text() === 'Cancel')
.first()
.simulate('click');
checkRootElementNotPresent('body div[role="dialog"]');
done();
});
test('Change selected item with radio control then save', async done => {
wrapper.find('button[aria-label="Search"]').simulate('click');
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
await checkModalTagValues(wrapper, ['foo']);
test('should remove item when X button clicked', async () => {
wrapper = await mountWrapper();
await checkInputTagValues(wrapper, ['foo']);
wrapper
.find('DataListItemRow')
.findWhere(el => el.text() === 'bar')
.find('input[type="radio"]')
.simulate('change');
await checkModalListValues(wrapper, [[false, 'foo'], [true, 'bar']]);
await checkModalTagValues(wrapper, ['bar']);
wrapper
.find('Modal button')
.findWhere(e => e.text() === 'Save')
.find('Lookup InputGroup Chip')
.findWhere(el => el.text() === 'foo')
.first()
.simulate('click');
expect(onChange).toHaveBeenCalledTimes(1);
const [[{ name }]] = onChange.mock.calls;
expect(name).toEqual('bar');
done();
});
test('Change selected item with checkbox then cancel', async done => {
wrapper.find('button[aria-label="Search"]').simulate('click');
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
await checkModalTagValues(wrapper, ['foo']);
wrapper
.find('DataListItemRow')
.findWhere(el => el.text() === 'bar')
.find('input[type="radio"]')
.simulate('change');
await checkModalListValues(wrapper, [[false, 'foo'], [true, 'bar']]);
await checkModalTagValues(wrapper, ['bar']);
wrapper
.find('Modal button')
.findWhere(e => e.text() === 'Cancel')
.first()
.simulate('click');
expect(onChange).toHaveBeenCalledTimes(0);
done();
});
test('should re-fetch data when URL params change', async done => {
mockData = [{ name: 'foo', id: 1, isChecked: false }];
const history = createMemoryHistory({
initialEntries: ['/organizations/add'],
});
const getItems = jest.fn();
const LookupWrapper = mountWithContexts(
<_Lookup
multiple
name="foo"
lookupHeader="Foo Bar"
onLookupSave={() => {}}
value={mockData}
columns={mockColumns}
sortedColumnKey="name"
getItems={getItems}
location={{ history }}
i18n={{ _: val => val.toString() }}
/>
);
expect(getItems).toHaveBeenCalledTimes(1);
history.push('organizations/add?page=2');
LookupWrapper.setProps({
location: { history },
});
LookupWrapper.update();
expect(getItems).toHaveBeenCalledTimes(2);
done();
});
test('should clear its query params when closed', async () => {
mockData = [{ name: 'foo', id: 1, isChecked: false }];
const history = createMemoryHistory({
initialEntries: ['/organizations/add?inventory.name=foo&bar=baz'],
});
wrapper = mountWithContexts(
<_Lookup
multiple
name="foo"
lookupHeader="Foo Bar"
onLookupSave={() => {}}
value={mockData}
columns={mockColumns}
sortedColumnKey="name"
getItems={() => {}}
location={{ history }}
history={history}
qsNamespace="inventory"
i18n={{ _: val => val.toString() }}
/>
);
wrapper
.find('InputGroup Button')
.at(0)
.invoke('onClick')();
wrapper.find('Modal').invoke('onClose')();
expect(history.location.search).toEqual('?bar=baz');
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith([]);
});
test('should pass canDelete false if required single select', async () => {
await act(async () => {
const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' };
wrapper = mountWithContexts(
<Lookup
id="test"
header="Foo Bar"
required
value={mockSelected}
onChange={onChange}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<TestList
id="options-list"
state={state}
dispatch={dispatch}
canDelete={canDelete}
/>
)}
/>
);
});
wrapper.find('button[aria-label="Search"]').simulate('click');
const list = wrapper.find('TestList');
expect(list.prop('canDelete')).toEqual(false);
});
});

View File

@ -1,146 +1,167 @@
import React from 'react';
import React, { Fragment, useState, useEffect } from 'react';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { FormGroup, ToolbarItem } from '@patternfly/react-core';
import { CredentialsAPI, CredentialTypesAPI } from '@api';
import Lookup from '@components/Lookup';
import AnsibleSelect from '@components/AnsibleSelect';
import { FieldTooltip } from '@components/FormField';
import { CredentialChip } from '@components/Chip';
import VerticalSeperator from '@components/VerticalSeparator';
import { getQSConfig, parseQueryString } from '@util/qs';
import Lookup from './Lookup';
import OptionsList from './shared/OptionsList';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;
`;
const QS_CONFIG = getQSConfig('credentials', {
page: 1,
page_size: 5,
order_by: 'name',
});
class MultiCredentialsLookup extends React.Component {
constructor(props) {
super(props);
async function loadCredentialTypes() {
const { data } = await CredentialTypesAPI.read();
const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault'];
return data.results.filter(type => acceptableTypes.includes(type.kind));
}
this.state = {
selectedCredentialType: { label: 'Machine', id: 1, kind: 'ssh' },
credentialTypes: [],
};
this.loadCredentialTypes = this.loadCredentialTypes.bind(this);
this.handleCredentialTypeSelect = this.handleCredentialTypeSelect.bind(
this
);
this.loadCredentials = this.loadCredentials.bind(this);
this.toggleCredentialSelection = this.toggleCredentialSelection.bind(this);
}
async function loadCredentials(params, selectedCredentialTypeId) {
params.credential_type = selectedCredentialTypeId || 1;
const { data } = await CredentialsAPI.read(params);
return data;
}
componentDidMount() {
this.loadCredentialTypes();
}
function MultiCredentialsLookup(props) {
const { tooltip, value, onChange, onError, history, i18n } = props;
const [credentialTypes, setCredentialTypes] = useState([]);
const [selectedType, setSelectedType] = useState(null);
const [credentials, setCredentials] = useState([]);
const [credentialsCount, setCredentialsCount] = useState(0);
async loadCredentialTypes() {
const { onError } = this.props;
try {
const { data } = await CredentialTypesAPI.read();
const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault'];
const credentialTypes = [];
data.results.forEach(cred => {
acceptableTypes.forEach(aT => {
if (aT === cred.kind) {
// This object has several repeated values as some of it's children
// require different field values.
cred = {
id: cred.id,
key: cred.id,
kind: cred.kind,
type: cred.namespace,
value: cred.name,
label: cred.name,
isDisabled: false,
};
credentialTypes.push(cred);
}
});
});
this.setState({ credentialTypes });
} catch (err) {
onError(err);
}
}
useEffect(() => {
(async () => {
try {
const types = await loadCredentialTypes();
setCredentialTypes(types);
const match = types.find(type => type.kind === 'ssh') || types[0];
setSelectedType(match);
} catch (err) {
onError(err);
}
})();
}, [onError]);
async loadCredentials(params) {
const { selectedCredentialType } = this.state;
params.credential_type = selectedCredentialType.id || 1;
return CredentialsAPI.read(params);
}
useEffect(() => {
(async () => {
if (!selectedType) {
return;
}
try {
const params = parseQueryString(QS_CONFIG, history.location.search);
const { results, count } = await loadCredentials(
params,
selectedType.id
);
setCredentials(results);
setCredentialsCount(count);
} catch (err) {
onError(err);
}
})();
}, [selectedType, history.location.search, onError]);
toggleCredentialSelection(newCredential) {
const { onChange, credentials: credentialsToUpdate } = this.props;
const renderChip = ({ item, removeItem, canDelete }) => (
<CredentialChip
key={item.id}
onClick={() => removeItem(item)}
isReadOnly={!canDelete}
credential={item}
/>
);
let newCredentialsList;
const isSelectedCredentialInState =
credentialsToUpdate.filter(cred => cred.id === newCredential.id).length >
0;
const isMultiple = selectedType && selectedType.kind === 'vault';
if (isSelectedCredentialInState) {
newCredentialsList = credentialsToUpdate.filter(
cred => cred.id !== newCredential.id
);
} else {
newCredentialsList = credentialsToUpdate.filter(
credential =>
credential.kind === 'vault' || credential.kind !== newCredential.kind
);
newCredentialsList = [...newCredentialsList, newCredential];
}
onChange(newCredentialsList);
}
handleCredentialTypeSelect(value, type) {
const { credentialTypes } = this.state;
const selectedType = credentialTypes.filter(item => item.label === type);
this.setState({ selectedCredentialType: selectedType[0] });
}
render() {
const { selectedCredentialType, credentialTypes } = this.state;
const { tooltip, i18n, credentials } = this.props;
return (
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential">
{tooltip && (
<Tooltip position="right" content={tooltip}>
<QuestionCircleIcon />
</Tooltip>
)}
{credentialTypes && (
<Lookup
selectCategoryOptions={credentialTypes}
selectCategory={this.handleCredentialTypeSelect}
selectedCategory={selectedCredentialType}
onToggleItem={this.toggleCredentialSelection}
onloadCategories={this.loadCredentialTypes}
id="multiCredential"
lookupHeader={i18n._(t`Credentials`)}
name="credentials"
value={credentials}
multiple
onLookupSave={() => {}}
getItems={this.loadCredentials}
qsNamespace="credentials"
columns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
]}
sortedColumnKey="name"
/>
)}
</FormGroup>
);
}
return (
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential">
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
id="multiCredential"
header={i18n._(t`Credentials`)}
value={value}
multiple
onChange={onChange}
qsConfig={QS_CONFIG}
renderItemChip={renderChip}
renderOptionsList={({ state, dispatch, canDelete }) => {
return (
<Fragment>
{credentialTypes && credentialTypes.length > 0 && (
<ToolbarItem css=" display: flex; align-items: center;">
<div css="flex: 0 0 25%;">{i18n._(t`Selected Category`)}</div>
<VerticalSeperator />
<AnsibleSelect
css="flex: 1 1 75%;"
id="multiCredentialsLookUp-select"
label={i18n._(t`Selected Category`)}
data={credentialTypes.map(type => ({
key: type.id,
value: type.id,
label: type.name,
isDisabled: false,
}))}
value={selectedType && selectedType.id}
onChange={(e, id) => {
setSelectedType(
credentialTypes.find(o => o.id === parseInt(id, 10))
);
}}
/>
</ToolbarItem>
)}
<OptionsList
value={state.selectedItems}
options={credentials}
optionCount={credentialsCount}
columns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
]}
multiple={isMultiple}
header={i18n._(t`Credentials`)}
name="credentials"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => {
if (isMultiple) {
return dispatch({ type: 'SELECT_ITEM', item });
}
const selectedItems = state.selectedItems.filter(
i => i.kind !== item.kind
);
selectedItems.push(item);
return dispatch({
type: 'SET_SELECTED_ITEMS',
selectedItems,
});
}}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
renderItemChip={renderChip}
/>
</Fragment>
);
}}
/>
</FormGroup>
);
}
MultiCredentialsLookup.propTypes = {
tooltip: PropTypes.string,
credentials: PropTypes.arrayOf(
value: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
@ -155,8 +176,8 @@ MultiCredentialsLookup.propTypes = {
MultiCredentialsLookup.defaultProps = {
tooltip: '',
credentials: [],
value: [],
};
export { MultiCredentialsLookup as _MultiCredentialsLookup };
export default withI18n()(MultiCredentialsLookup);
export { MultiCredentialsLookup as _MultiCredentialsLookup };
export default withI18n()(withRouter(MultiCredentialsLookup));

View File

@ -1,6 +1,6 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import MultiCredentialsLookup from './MultiCredentialsLookup';
import { CredentialsAPI, CredentialTypesAPI } from '@api';
@ -8,9 +8,6 @@ jest.mock('@api');
describe('<MultiCredentialsLookup />', () => {
let wrapper;
let lookup;
let credLookup;
let onChange;
const credentials = [
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
@ -18,8 +15,9 @@ describe('<MultiCredentialsLookup />', () => {
{ name: 'Gatsby', id: 21, kind: 'vault' },
{ name: 'Gatsby', id: 8, kind: 'Machine' },
];
beforeEach(() => {
CredentialTypesAPI.read.mockResolvedValue({
CredentialTypesAPI.read.mockResolvedValueOnce({
data: {
results: [
{
@ -46,17 +44,6 @@ describe('<MultiCredentialsLookup />', () => {
count: 3,
},
});
onChange = jest.fn();
wrapper = mountWithContexts(
<MultiCredentialsLookup
onError={() => {}}
credentials={credentials}
onChange={onChange}
tooltip="This is credentials look up"
/>
);
lookup = wrapper.find('Lookup');
credLookup = wrapper.find('MultiCredentialsLookup');
});
afterEach(() => {
@ -64,16 +51,40 @@ describe('<MultiCredentialsLookup />', () => {
wrapper.unmount();
});
test('MultiCredentialsLookup renders properly', () => {
test('MultiCredentialsLookup renders properly', async () => {
const onChange = jest.fn();
await act(async () => {
wrapper = mountWithContexts(
<MultiCredentialsLookup
value={credentials}
tooltip="This is credentials look up"
onChange={onChange}
onError={() => {}}
/>
);
});
expect(wrapper.find('MultiCredentialsLookup')).toHaveLength(1);
expect(CredentialTypesAPI.read).toHaveBeenCalled();
});
test('onChange is called when you click to remove a credential from input', async () => {
const chip = wrapper.find('PFChip').find({ isOverflowChip: false });
const button = chip.at(1).find('ChipButton');
const onChange = jest.fn();
await act(async () => {
wrapper = mountWithContexts(
<MultiCredentialsLookup
value={credentials}
tooltip="This is credentials look up"
onChange={onChange}
onError={() => {}}
/>
);
});
const chip = wrapper.find('CredentialChip');
expect(chip).toHaveLength(4);
button.prop('onClick')();
const button = chip.at(1).find('ChipButton');
await act(async () => {
button.invoke('onClick')();
});
expect(onChange).toBeCalledWith([
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
{ id: 21, kind: 'vault', name: 'Gatsby' },
@ -81,33 +92,122 @@ describe('<MultiCredentialsLookup />', () => {
]);
});
test('can change credential types', () => {
lookup.prop('selectCategory')({}, 'Vault');
expect(credLookup.state('selectedCredentialType')).toEqual({
id: 500,
key: 500,
kind: 'vault',
type: 'buzz',
value: 'Vault',
label: 'Vault',
isDisabled: false,
test('should change credential types', async () => {
await act(async () => {
wrapper = mountWithContexts(
<MultiCredentialsLookup
value={credentials}
tooltip="This is credentials look up"
onChange={() => {}}
onError={() => {}}
/>
);
});
expect(CredentialsAPI.read).toHaveBeenCalled();
const searchButton = await waitForElement(wrapper, 'SearchButton');
await act(async () => {
searchButton.invoke('onClick')();
});
const select = await waitForElement(wrapper, 'AnsibleSelect');
CredentialsAPI.read.mockResolvedValueOnce({
data: {
results: [
{ id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' },
],
count: 1,
},
});
expect(CredentialsAPI.read).toHaveBeenCalledTimes(2);
await act(async () => {
select.invoke('onChange')({}, 500);
});
wrapper.update();
expect(CredentialsAPI.read).toHaveBeenCalledTimes(3);
expect(wrapper.find('OptionsList').prop('options')).toEqual([
{ id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' },
]);
});
test('Toggle credentials only adds 1 credential per credential type except vault(see below)', () => {
lookup.prop('onToggleItem')({ name: 'Party', id: 9, kind: 'Machine' });
test('should only add 1 credential per credential type except vault(see below)', async () => {
const onChange = jest.fn();
await act(async () => {
wrapper = mountWithContexts(
<MultiCredentialsLookup
value={credentials}
tooltip="This is credentials look up"
onChange={onChange}
onError={() => {}}
/>
);
});
const searchButton = await waitForElement(wrapper, 'SearchButton');
await act(async () => {
searchButton.invoke('onClick')();
});
wrapper.update();
const optionsList = wrapper.find('OptionsList');
expect(optionsList.prop('multiple')).toEqual(false);
act(() => {
optionsList.invoke('selectItem')({
id: 5,
kind: 'Machine',
name: 'Cred 5',
url: 'www.google.com',
});
});
wrapper.update();
act(() => {
wrapper.find('Button[variant="primary"]').invoke('onClick')();
});
expect(onChange).toBeCalledWith([
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
{ id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
{ id: 21, kind: 'vault', name: 'Gatsby' },
{ id: 9, kind: 'Machine', name: 'Party' },
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
]);
});
test('Toggle credentials only adds 1 credential per credential type', () => {
lookup.prop('onToggleItem')({ name: 'Party', id: 22, kind: 'vault' });
test('should allow multiple vault credentials', async () => {
const onChange = jest.fn();
await act(async () => {
wrapper = mountWithContexts(
<MultiCredentialsLookup
value={credentials}
tooltip="This is credentials look up"
onChange={onChange}
onError={() => {}}
/>
);
});
const searchButton = await waitForElement(wrapper, 'SearchButton');
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);
act(() => {
optionsList.invoke('selectItem')({
id: 5,
kind: 'Machine',
name: 'Cred 5',
url: 'www.google.com',
});
});
wrapper.update();
act(() => {
wrapper.find('Button[variant="primary"]').invoke('onClick')();
});
expect(onChange).toBeCalledWith([
...credentials,
{ name: 'Party', id: 22, kind: 'vault' },
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
{ id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
{ id: 21, kind: 'vault', name: 'Gatsby' },
{ id: 8, kind: 'Machine', name: 'Gatsby' },
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
]);
});
});

View File

@ -1,13 +1,21 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { string, func, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { string, func, bool } from 'prop-types';
import { OrganizationsAPI } from '@api';
import { Organization } from '@types';
import { FormGroup } from '@patternfly/react-core';
import Lookup from '@components/Lookup';
import { getQSConfig, parseQueryString } from '@util/qs';
import Lookup from './Lookup';
import OptionsList from './shared/OptionsList';
import LookupErrorMessage from './shared/LookupErrorMessage';
const getOrganizations = async params => OrganizationsAPI.read(params);
const QS_CONFIG = getQSConfig('organizations', {
page: 1,
page_size: 5,
order_by: 'name',
});
function OrganizationLookup({
helperTextInvalid,
@ -17,7 +25,25 @@ function OrganizationLookup({
onChange,
required,
value,
history,
}) {
const [organizations, setOrganizations] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
useEffect(() => {
(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
try {
const { data } = await OrganizationsAPI.read(params);
setOrganizations(data.results);
setCount(data.count);
} catch (err) {
setError(err);
}
})();
}, [history.location]);
return (
<FormGroup
fieldId="organization"
@ -28,15 +54,29 @@ function OrganizationLookup({
>
<Lookup
id="organization"
lookupHeader={i18n._(t`Organization`)}
name="organization"
header={i18n._(t`Organization`)}
value={value}
onBlur={onBlur}
onLookupSave={onChange}
getItems={getOrganizations}
onChange={onChange}
qsConfig={QS_CONFIG}
required={required}
sortedColumnKey="name"
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}
options={organizations}
optionCount={count}
multiple={state.multiple}
header={i18n._(t`Organization`)}
name="organization"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
/>
)}
/>
<LookupErrorMessage error={error} />
</FormGroup>
);
}
@ -58,5 +98,5 @@ OrganizationLookup.defaultProps = {
value: null,
};
export default withI18n()(OrganizationLookup);
export { OrganizationLookup as _OrganizationLookup };
export default withI18n()(withRouter(OrganizationLookup));

View File

@ -1,4 +1,5 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup';
import { OrganizationsAPI } from '@api';
@ -8,18 +9,22 @@ jest.mock('@api');
describe('OrganizationLookup', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('initially renders successfully', () => {
test('should render successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
});
expect(wrapper).toHaveLength(1);
});
test('should fetch organizations', () => {
test('should fetch organizations', async () => {
await act(async () => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
});
expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1);
expect(OrganizationsAPI.read).toHaveBeenCalledWith({
order_by: 'name',
@ -27,11 +32,19 @@ describe('OrganizationLookup', () => {
page_size: 5,
});
});
test('should display "Organization" label', () => {
test('should display "Organization" label', async () => {
await act(async () => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
});
const title = wrapper.find('FormGroup .pf-c-form__label-text');
expect(title.text()).toEqual('Organization');
});
test('should define default value for function props', () => {
test('should define default value for function props', async () => {
await act(async () => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
});
expect(_OrganizationLookup.defaultProps.onBlur).toBeInstanceOf(Function);
expect(_OrganizationLookup.defaultProps.onBlur).not.toThrow();
});

View File

@ -1,59 +1,90 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { string, func, bool } 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';
import { ProjectsAPI } from '@api';
import { Project } from '@types';
import Lookup from '@components/Lookup';
import { FieldTooltip } from '@components/FormField';
import { getQSConfig, parseQueryString } from '@util/qs';
import Lookup from './Lookup';
import OptionsList from './shared/OptionsList';
import LookupErrorMessage from './shared/LookupErrorMessage';
class ProjectLookup extends React.Component {
render() {
const {
helperTextInvalid,
i18n,
isValid,
onChange,
required,
tooltip,
value,
onBlur,
} = this.props;
const QS_CONFIG = getQSConfig('project', {
page: 1,
page_size: 5,
order_by: 'name',
});
const loadProjects = async params => {
const response = await ProjectsAPI.read(params);
const { results, count } = response.data;
if (count === 1) {
onChange(results[0], 'project');
function ProjectLookup({
helperTextInvalid,
i18n,
isValid,
onChange,
required,
tooltip,
value,
onBlur,
history,
}) {
const [projects, setProjects] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
useEffect(() => {
(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
try {
const { data } = await ProjectsAPI.read(params);
setProjects(data.results);
setCount(data.count);
if (data.count === 1) {
onChange(data.results[0]);
}
} catch (err) {
setError(err);
}
return response;
};
})();
}, [onChange, history.location]);
return (
<FormGroup
fieldId="project"
helperTextInvalid={helperTextInvalid}
isRequired={required}
isValid={isValid}
label={i18n._(t`Project`)}
>
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
id="project"
lookupHeader={i18n._(t`Project`)}
name="project"
value={value}
onBlur={onBlur}
onLookupSave={onChange}
getItems={loadProjects}
required={required}
sortedColumnKey="name"
qsNamespace="project"
/>
</FormGroup>
);
}
return (
<FormGroup
fieldId="project"
helperTextInvalid={helperTextInvalid}
isRequired={required}
isValid={isValid}
label={i18n._(t`Project`)}
>
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
id="project"
header={i18n._(t`Project`)}
name="project"
value={value}
onBlur={onBlur}
onChange={onChange}
required={required}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}
options={projects}
optionCount={count}
multiple={state.multiple}
header={i18n._(t`Project`)}
name="project"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
/>
)}
/>
<LookupErrorMessage error={error} />
</FormGroup>
);
}
ProjectLookup.propTypes = {
@ -75,4 +106,5 @@ ProjectLookup.defaultProps = {
onBlur: () => {},
};
export default withI18n()(ProjectLookup);
export { ProjectLookup as _ProjectLookup };
export default withI18n()(withRouter(ProjectLookup));

View File

@ -1,4 +1,5 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils';
import { ProjectsAPI } from '@api';
@ -15,9 +16,11 @@ describe('<ProjectLookup />', () => {
},
});
const onChange = jest.fn();
mountWithContexts(<ProjectLookup onChange={onChange} />);
await act(async () => {
mountWithContexts(<ProjectLookup onChange={onChange} />);
});
await sleep(0);
expect(onChange).toHaveBeenCalledWith({ id: 1 }, 'project');
expect(onChange).toHaveBeenCalledWith({ id: 1 });
});
test('should not auto-select project when multiple available', async () => {
@ -28,7 +31,9 @@ describe('<ProjectLookup />', () => {
},
});
const onChange = jest.fn();
mountWithContexts(<ProjectLookup onChange={onChange} />);
await act(async () => {
mountWithContexts(<ProjectLookup onChange={onChange} />);
});
await sleep(0);
expect(onChange).not.toHaveBeenCalled();
});

View File

@ -0,0 +1,5 @@
# Lookup
required single select lookups should not include a close X on the tag... you would have to select something else to change it
optional single select lookups should include a close X to remove it on the spot

View File

@ -0,0 +1,17 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
function LookupErrorMessage({ error, i18n }) {
if (!error) {
return null;
}
return (
<div className="pf-c-form__helper-text pf-m-error" aria-live="polite">
{error.message || i18n._(t`An error occured`)}
</div>
);
}
export default withI18n()(LookupErrorMessage);

View File

@ -0,0 +1,95 @@
import React from 'react';
import {
arrayOf,
shape,
bool,
func,
number,
string,
oneOfType,
} from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import SelectedList from '../../SelectedList';
import PaginatedDataList from '../../PaginatedDataList';
import CheckboxListItem from '../../CheckboxListItem';
import DataListToolbar from '../../DataListToolbar';
import { QSConfig } from '@types';
function OptionsList({
value,
options,
optionCount,
columns,
multiple,
header,
name,
qsConfig,
readOnly,
selectItem,
deselectItem,
renderItemChip,
isLoading,
i18n,
}) {
return (
<div>
{value.length > 0 && (
<SelectedList
label={i18n._(t`Selected`)}
selected={value}
showOverflowAfter={5}
onRemove={item => deselectItem(item)}
isReadOnly={readOnly}
renderItemChip={renderItemChip}
/>
)}
<PaginatedDataList
items={options}
itemCount={optionCount}
pluralizedItemName={header}
qsConfig={qsConfig}
toolbarColumns={columns}
hasContentLoading={isLoading}
renderItem={item => (
<CheckboxListItem
key={item.id}
itemId={item.id}
name={multiple ? item.name : name}
label={item.name}
isSelected={value.some(i => i.id === item.id)}
onSelect={() => selectItem(item)}
onDeselect={() => deselectItem(item)}
isRadio={!multiple}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
</div>
);
}
const Item = shape({
id: oneOfType([number, string]).isRequired,
name: string.isRequired,
url: string,
});
OptionsList.propTypes = {
value: arrayOf(Item).isRequired,
options: arrayOf(Item).isRequired,
optionCount: number.isRequired,
columns: arrayOf(shape({})),
multiple: bool,
qsConfig: QSConfig.isRequired,
selectItem: func.isRequired,
deselectItem: func.isRequired,
renderItemChip: func,
};
OptionsList.defaultProps = {
multiple: false,
renderItemChip: null,
columns: [],
};
export default withI18n()(OptionsList);

View File

@ -0,0 +1,53 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { getQSConfig } from '@util/qs';
import OptionsList from './OptionsList';
const qsConfig = getQSConfig('test', {});
describe('<OptionsList />', () => {
it('should display list of options', () => {
const options = [
{ id: 1, name: 'foo', url: '/item/1' },
{ id: 2, name: 'bar', url: '/item/2' },
{ id: 3, name: 'baz', url: '/item/3' },
];
const wrapper = mountWithContexts(
<OptionsList
value={[]}
options={options}
optionCount={3}
columns={[]}
qsConfig={qsConfig}
selectItem={() => {}}
deselectItem={() => {}}
name="Item"
/>
);
expect(wrapper.find('PaginatedDataList').prop('items')).toEqual(options);
expect(wrapper.find('SelectedList')).toHaveLength(0);
});
it('should render selected list', () => {
const options = [
{ id: 1, name: 'foo', url: '/item/1' },
{ id: 2, name: 'bar', url: '/item/2' },
{ id: 3, name: 'baz', url: '/item/3' },
];
const wrapper = mountWithContexts(
<OptionsList
value={[options[1]]}
options={options}
optionCount={3}
columns={[]}
qsConfig={qsConfig}
selectItem={() => {}}
deselectItem={() => {}}
name="Item"
/>
);
const list = wrapper.find('SelectedList');
expect(list).toHaveLength(1);
expect(list.prop('selected')).toEqual([options[1]]);
});
});

View File

@ -0,0 +1,96 @@
export default function reducer(state, action) {
switch (action.type) {
case 'SELECT_ITEM':
return selectItem(state, action.item);
case 'DESELECT_ITEM':
return deselectItem(state, action.item);
case 'TOGGLE_MODAL':
return toggleModal(state);
case 'CLOSE_MODAL':
return closeModal(state);
case 'SET_MULTIPLE':
return { ...state, multiple: action.value };
case 'SET_VALUE':
return { ...state, value: action.value };
case 'SET_SELECTED_ITEMS':
return { ...state, selectedItems: action.selectedItems };
default:
throw new Error(`Unrecognized action type: ${action.type}`);
}
}
function selectItem(state, item) {
const { selectedItems, multiple } = state;
if (!multiple) {
return {
...state,
selectedItems: [item],
};
}
const index = selectedItems.findIndex(i => i.id === item.id);
if (index > -1) {
return state;
}
return {
...state,
selectedItems: [...selectedItems, item],
};
}
function deselectItem(state, item) {
return {
...state,
selectedItems: state.selectedItems.filter(i => i.id !== item.id),
};
}
function toggleModal(state) {
const { isModalOpen, value, multiple } = state;
if (isModalOpen) {
return closeModal(state);
}
let selectedItems = [];
if (multiple) {
selectedItems = [...value];
} else if (value) {
selectedItems.push(value);
}
return {
...state,
isModalOpen: !isModalOpen,
selectedItems,
};
}
function closeModal(state) {
return {
...state,
isModalOpen: false,
};
}
export function initReducer({ value, multiple = false, required = false }) {
assertCorrectValueType(value, multiple);
let selectedItems = [];
if (value) {
selectedItems = multiple ? [...value] : [value];
}
return {
selectedItems,
value,
multiple,
isModalOpen: false,
required,
};
}
function assertCorrectValueType(value, multiple) {
if (!multiple && Array.isArray(value)) {
throw new Error(
'Lookup value must not be an array unless `multiple` is set'
);
}
if (multiple && !Array.isArray(value)) {
throw new Error('Lookup value must be an array if `multiple` is set');
}
}

View File

@ -0,0 +1,280 @@
import reducer, { initReducer } from './reducer';
describe('Lookup reducer', () => {
describe('SELECT_ITEM', () => {
it('should add item to selected items (multiple select)', () => {
const state = {
selectedItems: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'SELECT_ITEM',
item: { id: 2 },
});
expect(result).toEqual({
selectedItems: [{ id: 1 }, { id: 2 }],
multiple: true,
});
});
it('should not duplicate item if already selected (multiple select)', () => {
const state = {
selectedItems: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'SELECT_ITEM',
item: { id: 1 },
});
expect(result).toEqual({
selectedItems: [{ id: 1 }],
multiple: true,
});
});
it('should replace selected item (single select)', () => {
const state = {
selectedItems: [{ id: 1 }],
multiple: false,
};
const result = reducer(state, {
type: 'SELECT_ITEM',
item: { id: 2 },
});
expect(result).toEqual({
selectedItems: [{ id: 2 }],
multiple: false,
});
});
it('should not duplicate item if already selected (single select)', () => {
const state = {
selectedItems: [{ id: 1 }],
multiple: false,
};
const result = reducer(state, {
type: 'SELECT_ITEM',
item: { id: 1 },
});
expect(result).toEqual({
selectedItems: [{ id: 1 }],
multiple: false,
});
});
});
describe('DESELECT_ITEM', () => {
it('should de-select item (multiple)', () => {
const state = {
selectedItems: [{ id: 1 }, { id: 2 }],
multiple: true,
};
const result = reducer(state, {
type: 'DESELECT_ITEM',
item: { id: 1 },
});
expect(result).toEqual({
selectedItems: [{ id: 2 }],
multiple: true,
});
});
it('should not change list if item not selected (multiple)', () => {
const state = {
selectedItems: [{ id: 1 }, { id: 2 }],
multiple: true,
};
const result = reducer(state, {
type: 'DESELECT_ITEM',
item: { id: 3 },
});
expect(result).toEqual({
selectedItems: [{ id: 1 }, { id: 2 }],
multiple: true,
});
});
it('should de-select item (single select)', () => {
const state = {
selectedItems: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'DESELECT_ITEM',
item: { id: 1 },
});
expect(result).toEqual({
selectedItems: [],
multiple: true,
});
});
});
describe('TOGGLE_MODAL', () => {
it('should open the modal (single)', () => {
const state = {
isModalOpen: false,
selectedItems: [],
value: { id: 1 },
multiple: false,
};
const result = reducer(state, {
type: 'TOGGLE_MODAL',
});
expect(result).toEqual({
isModalOpen: true,
selectedItems: [{ id: 1 }],
value: { id: 1 },
multiple: false,
});
});
it('should set null value to empty array', () => {
const state = {
isModalOpen: false,
selectedItems: [{ id: 1 }],
value: null,
multiple: false,
};
const result = reducer(state, {
type: 'TOGGLE_MODAL',
});
expect(result).toEqual({
isModalOpen: true,
selectedItems: [],
value: null,
multiple: false,
});
});
it('should open the modal (multiple)', () => {
const state = {
isModalOpen: false,
selectedItems: [],
value: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'TOGGLE_MODAL',
});
expect(result).toEqual({
isModalOpen: true,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: true,
});
});
it('should close the modal', () => {
const state = {
isModalOpen: true,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'TOGGLE_MODAL',
});
expect(result).toEqual({
isModalOpen: false,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: true,
});
});
});
describe('CLOSE_MODAL', () => {
it('should close the modal', () => {
const state = {
isModalOpen: true,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'CLOSE_MODAL',
});
expect(result).toEqual({
isModalOpen: false,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: true,
});
});
});
describe('SET_MULTIPLE', () => {
it('should set multiple to true', () => {
const state = {
isModalOpen: false,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: false,
};
const result = reducer(state, {
type: 'SET_MULTIPLE',
value: true,
});
expect(result).toEqual({
isModalOpen: false,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: true,
});
});
it('should set multiple to false', () => {
const state = {
isModalOpen: false,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'SET_MULTIPLE',
value: false,
});
expect(result).toEqual({
isModalOpen: false,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: false,
});
});
});
describe('SET_VALUE', () => {
it('should set the value', () => {
const state = {
value: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'SET_VALUE',
value: [{ id: 3 }],
});
expect(result).toEqual({
value: [{ id: 3 }],
multiple: true,
});
});
});
});
describe('initReducer', () => {
it('should init', () => {
const state = initReducer({
value: [],
multiple: true,
required: true,
});
expect(state).toEqual({
selectedItems: [],
value: [],
multiple: true,
isModalOpen: false,
required: true,
});
});
});

View File

@ -111,7 +111,14 @@ class PageHeaderToolbar extends Component {
</DropdownToggle>
}
dropdownItems={[
<DropdownItem key="user" href="#/home">
<DropdownItem
key="user"
href={
loggedInUser
? `#/users/${loggedInUser.id}/details`
: '#/home'
}
>
{i18n._(t`User Details`)}
</DropdownItem>,
<DropdownItem

View File

@ -79,10 +79,10 @@ class ResourceAccessListItem extends React.Component {
<DataListCell key="name">
{accessRecord.username && (
<TextContent>
{accessRecord.url ? (
{accessRecord.id ? (
<Text component={TextVariants.h6}>
<Link
to={{ pathname: accessRecord.url }}
to={{ pathname: `/users/${accessRecord.id}/details` }}
css="font-weight: bold"
>
{accessRecord.username}

View File

@ -58,7 +58,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<ForwardRef
to={
Object {
"pathname": "/bar",
"pathname": "/users/2/details",
}
}
>
@ -114,7 +114,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<ForwardRef
to={
Object {
"pathname": "/bar",
"pathname": "/users/2/details",
}
}
>
@ -193,7 +193,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<ForwardRef
to={
Object {
"pathname": "/bar",
"pathname": "/users/2/details",
}
}
>
@ -260,7 +260,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Styled(Link)
to={
Object {
"pathname": "/bar",
"pathname": "/users/2/details",
}
}
>
@ -308,7 +308,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
forwardedRef={null}
to={
Object {
"pathname": "/bar",
"pathname": "/users/2/details",
}
}
>
@ -316,18 +316,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
className="sc-bdVaJa fqQVUT"
to={
Object {
"pathname": "/bar",
"pathname": "/users/2/details",
}
}
>
<LinkAnchor
className="sc-bdVaJa fqQVUT"
href="/bar"
href="/users/2/details"
navigate={[Function]}
>
<a
className="sc-bdVaJa fqQVUT"
href="/bar"
href="/users/2/details"
onClick={[Function]}
>
jane

View File

@ -3,6 +3,7 @@ import { shape, string, number, arrayOf } from 'prop-types';
import { Tab, Tabs as PFTabs } from '@patternfly/react-core';
import { withRouter } from 'react-router-dom';
import styled from 'styled-components';
import { CaretLeftIcon } from '@patternfly/react-icons';
const Tabs = styled(PFTabs)`
--pf-c-tabs__button--PaddingLeft: 20px;
@ -62,7 +63,15 @@ function RoutedTabs(props) {
eventKey={tab.id}
key={tab.id}
link={tab.link}
title={tab.name}
title={
tab.isNestedTabs ? (
<>
<CaretLeftIcon /> {tab.name}
</>
) : (
tab.name
)
}
/>
))}
</Tabs>

View File

@ -2,7 +2,7 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Split as PFSplit, SplitItem } from '@patternfly/react-core';
import styled from 'styled-components';
import { ChipGroup, Chip, CredentialChip } from '../Chip';
import { ChipGroup, Chip } from '../Chip';
import VerticalSeparator from '../VerticalSeparator';
const Split = styled(PFSplit)`
@ -26,34 +26,31 @@ class SelectedList extends Component {
onRemove,
displayKey,
isReadOnly,
isCredentialList,
renderItemChip,
} = this.props;
const chips = isCredentialList
? selected.map(item => (
<CredentialChip
key={item.id}
isReadOnly={isReadOnly}
onClick={() => onRemove(item)}
credential={item}
>
{item[displayKey]}
</CredentialChip>
))
: selected.map(item => (
<Chip
key={item.id}
isReadOnly={isReadOnly}
onClick={() => onRemove(item)}
>
{item[displayKey]}
</Chip>
));
const renderChip =
renderItemChip ||
(({ item, removeItem }) => (
<Chip key={item.id} onClick={removeItem} isReadOnly={isReadOnly}>
{item[displayKey]}
</Chip>
));
return (
<Split>
<SplitLabelItem>{label}</SplitLabelItem>
<VerticalSeparator />
<SplitItem>
<ChipGroup numChips={5}>{chips}</ChipGroup>
<ChipGroup numChips={5}>
{selected.map(item =>
renderChip({
item,
removeItem: () => onRemove(item),
canDelete: !isReadOnly,
})
)}
</ChipGroup>
</SplitItem>
</Split>
);
@ -66,6 +63,7 @@ SelectedList.propTypes = {
onRemove: PropTypes.func,
selected: PropTypes.arrayOf(PropTypes.object).isRequired,
isReadOnly: PropTypes.bool,
renderItemChip: PropTypes.func,
};
SelectedList.defaultProps = {
@ -73,6 +71,7 @@ SelectedList.defaultProps = {
label: 'Selected',
onRemove: () => null,
isReadOnly: false,
renderItemChip: null,
};
export default SelectedList;

View File

@ -15,7 +15,7 @@ import CredentialTypes from '@screens/CredentialType';
import Dashboard from '@screens/Dashboard';
import Hosts from '@screens/Host';
import InstanceGroups from '@screens/InstanceGroup';
import Inventories from '@screens/Inventory';
import Inventory from '@screens/Inventory';
import InventoryScripts from '@screens/InventoryScript';
import { Jobs } from '@screens/Job';
import Login from '@screens/Login';
@ -139,7 +139,7 @@ export function main(render) {
{
title: i18n._(t`Inventories`),
path: '/inventories',
component: Inventories,
component: Inventory,
},
{
title: i18n._(t`Hosts`),

View File

@ -1,20 +1,10 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
Card,
CardHeader,
CardBody,
Tooltip,
} from '@patternfly/react-core';
import { PageSection, Card, CardBody } from '@patternfly/react-core';
import { HostsAPI } from '@api';
import { Config } from '@contexts/Config';
import CardCloseButton from '@components/CardCloseButton';
import HostForm from '../shared/HostForm';
import HostForm from '../shared';
class HostAdd extends React.Component {
constructor(props) {
@ -41,16 +31,10 @@ class HostAdd extends React.Component {
render() {
const { error } = this.state;
const { i18n } = this.props;
return (
<PageSection>
<Card>
<CardHeader className="at-u-textRight">
<Tooltip content={i18n._(t`Close`)} position="top">
<CardCloseButton onClick={this.handleCancel} />
</Tooltip>
</CardHeader>
<CardBody>
<Config>
{({ me }) => (

View File

@ -1,4 +1,5 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import HostAdd from './HostAdd';
@ -7,8 +8,11 @@ import { HostsAPI } from '@api';
jest.mock('@api');
describe('<HostAdd />', () => {
test('handleSubmit should post to api', () => {
const wrapper = mountWithContexts(<HostAdd />);
test('handleSubmit should post to api', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<HostAdd />);
});
const updatedHostData = {
name: 'new name',
description: 'new description',
@ -19,21 +23,15 @@ describe('<HostAdd />', () => {
expect(HostsAPI.create).toHaveBeenCalledWith(updatedHostData);
});
test('should navigate to hosts list when cancel is clicked', () => {
test('should navigate to hosts list when cancel is clicked', async () => {
const history = createMemoryHistory({});
const wrapper = mountWithContexts(<HostAdd />, {
context: { router: { history } },
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<HostAdd />, {
context: { router: { history } },
});
});
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(history.location.pathname).toEqual('/hosts');
});
test('should navigate to hosts list when close (x) is clicked', () => {
const history = createMemoryHistory({});
const wrapper = mountWithContexts(<HostAdd />, {
context: { router: { history } },
});
wrapper.find('button[aria-label="Close"]').prop('onClick')();
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
expect(history.location.pathname).toEqual('/hosts');
});
@ -51,11 +49,14 @@ describe('<HostAdd />', () => {
...hostData,
},
});
const wrapper = mountWithContexts(<HostAdd />, {
context: { router: { history } },
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<HostAdd />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'button[aria-label="Save"]');
await wrapper.find('HostForm').prop('handleSubmit')(hostData);
await wrapper.find('HostForm').invoke('handleSubmit')(hostData);
expect(history.location.pathname).toEqual('/hosts/5');
});
});

View File

@ -6,7 +6,7 @@ import { CardBody } from '@patternfly/react-core';
import { HostsAPI } from '@api';
import { Config } from '@contexts/Config';
import HostForm from '../shared/HostForm';
import HostForm from '../shared';
class HostEdit extends Component {
constructor(props) {

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import React, { useState } from 'react';
import { func, shape } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { Formik, Field } from 'formik';
@ -15,120 +15,86 @@ import { VariablesField } from '@components/CodeMirrorInput';
import { required } from '@util/validators';
import { InventoryLookup } from '@components/Lookup';
class HostForm extends Component {
constructor(props) {
super(props);
function HostForm({ handleSubmit, handleCancel, host, i18n }) {
const [inventory, setInventory] = useState(
host ? host.summary_fields.inventory : ''
);
this.handleSubmit = this.handleSubmit.bind(this);
this.state = {
formIsValid: true,
inventory: props.host.summary_fields.inventory,
};
}
handleSubmit(values) {
const { handleSubmit } = this.props;
handleSubmit(values);
}
render() {
const { host, handleCancel, i18n } = this.props;
const { formIsValid, inventory, error } = this.state;
const initialValues = !host.id
? {
name: host.name,
description: host.description,
inventory: host.inventory || '',
variables: host.variables,
}
: {
name: host.name,
description: host.description,
variables: host.variables,
};
return (
<Formik
initialValues={initialValues}
onSubmit={this.handleSubmit}
render={formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow>
<FormField
id="host-name"
name="name"
type="text"
label={i18n._(t`Name`)}
validate={required(null, i18n)}
isRequired
/>
<FormField
id="host-description"
name="description"
type="text"
label={i18n._(t`Description`)}
/>
{!host.id && (
<Field
name="inventory"
validate={required(
i18n._(t`Select a value for this field`),
i18n
)}
render={({ form }) => (
<InventoryLookup
value={inventory}
onBlur={() => form.setFieldTouched('inventory')}
tooltip={i18n._(
t`Select the inventory that this host will belong to.`
)}
isValid={
!form.touched.inventory || !form.errors.inventory
}
helperTextInvalid={form.errors.inventory}
onChange={value => {
form.setFieldValue('inventory', value.id);
this.setState({ inventory: value });
}}
required
touched={form.touched.inventory}
error={form.errors.inventory}
/>
)}
/>
)}
</FormRow>
<FormRow>
<VariablesField
id="host-variables"
name="variables"
label={i18n._(t`Variables`)}
/>
</FormRow>
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
submitDisabled={!formIsValid}
return (
<Formik
initialValues={{
name: host.name,
description: host.description,
inventory: host.inventory || '',
variables: host.variables,
}}
onSubmit={handleSubmit}
render={formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow>
<FormField
id="host-name"
name="name"
type="text"
label={i18n._(t`Name`)}
validate={required(null, i18n)}
isRequired
/>
{error ? <div>error</div> : null}
</Form>
)}
/>
);
}
<FormField
id="host-description"
name="description"
type="text"
label={i18n._(t`Description`)}
/>
{!host.id && (
<Field
name="inventory"
validate={required(
i18n._(t`Select a value for this field`),
i18n
)}
render={({ form }) => (
<InventoryLookup
value={inventory}
onBlur={() => form.setFieldTouched('inventory')}
tooltip={i18n._(
t`Select the inventory that this host will belong to.`
)}
isValid={!form.touched.inventory || !form.errors.inventory}
helperTextInvalid={form.errors.inventory}
onChange={value => {
form.setFieldValue('inventory', value.id);
setInventory(value);
}}
required
touched={form.touched.inventory}
error={form.errors.inventory}
/>
)}
/>
)}
</FormRow>
<FormRow>
<VariablesField
id="host-variables"
name="variables"
label={i18n._(t`Variables`)}
/>
</FormRow>
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
/>
</Form>
)}
/>
);
}
FormField.propTypes = {
label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
};
HostForm.propTypes = {
host: PropTypes.shape(),
handleSubmit: PropTypes.func.isRequired,
handleCancel: PropTypes.func.isRequired,
handleSubmit: func.isRequired,
handleCancel: func.isRequired,
host: shape({}),
};
HostForm.defaultProps = {

View File

@ -65,11 +65,7 @@ describe('<HostForm />', () => {
expect(handleSubmit).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Save"]').simulate('click');
await sleep(1);
expect(handleSubmit).toHaveBeenCalledWith({
name: 'Foo',
description: 'Bar',
variables: '---',
});
expect(handleSubmit).toHaveBeenCalled();
});
test('calls "handleCancel" when Cancel button is clicked', () => {

View File

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

View File

@ -27,7 +27,7 @@ class Inventories extends Component {
};
}
setBreadCrumbConfig = inventory => {
setBreadCrumbConfig = (inventory, group) => {
const { i18n } = this.props;
if (!inventory) {
return;
@ -57,6 +57,15 @@ class Inventories extends Component {
),
[`/inventories/inventory/${inventory.id}/sources`]: i18n._(t`Sources`),
[`/inventories/inventory/${inventory.id}/groups`]: i18n._(t`Groups`),
[`/inventories/inventory/${inventory.id}/groups/add`]: i18n._(
t`Create New Group`
),
[`/inventories/inventory/${inventory.id}/groups/${group &&
group.id}`]: `${group && group.name}`,
[`/inventories/inventory/${inventory.id}/groups/${group &&
group.id}/details`]: i18n._(t`Group Details`),
[`/inventories/inventory/${inventory.id}/groups/${group &&
group.id}/edit`]: i18n._(t`Edit Details`),
};
this.setState({ breadcrumbConfig });
};

View File

@ -57,7 +57,11 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
</CardHeader>
);
if (location.pathname.endsWith('edit') || location.pathname.endsWith('add')) {
if (
location.pathname.endsWith('edit') ||
location.pathname.endsWith('add') ||
location.pathname.includes('groups/')
) {
cardHeader = null;
}
@ -123,7 +127,15 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
<Route
key="groups"
path="/inventories/inventory/:id/groups"
render={() => <InventoryGroups inventory={inventory} />}
render={() => (
<InventoryGroups
location={location}
match={match}
history={history}
setBreadcrumb={setBreadcrumb}
inventory={inventory}
/>
)}
/>,
<Route
key="hosts"

View File

@ -15,7 +15,7 @@ import { getAddedAndRemoved } from '../../../util/lists';
function InventoryEdit({ history, i18n, inventory }) {
const [error, setError] = useState(null);
const [associatedInstanceGroups, setInstanceGroups] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [contentLoading, setContentLoading] = useState(true);
const [credentialTypeId, setCredentialTypeId] = useState(null);
useEffect(() => {
@ -39,11 +39,11 @@ function InventoryEdit({ history, i18n, inventory }) {
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
setContentLoading(false);
}
};
loadData();
}, [inventory.id, isLoading, inventory, credentialTypeId]);
}, [inventory.id, contentLoading, inventory, credentialTypeId]);
const handleCancel = () => {
history.push('/inventories');
@ -85,7 +85,7 @@ function InventoryEdit({ history, i18n, inventory }) {
history.push(`${url}`);
}
};
if (isLoading) {
if (contentLoading) {
return <ContentLoading />;
}
if (error) {

View File

@ -0,0 +1,159 @@
import React, { useEffect, useState } from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { CardHeader } from '@patternfly/react-core';
import { Switch, Route, withRouter, Link, Redirect } from 'react-router-dom';
import { GroupsAPI } from '@api';
import CardCloseButton from '@components/CardCloseButton';
import RoutedTabs from '@components/RoutedTabs';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import InventoryGroupEdit from '../InventoryGroupEdit/InventoryGroupEdit';
import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail';
function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) {
const [inventoryGroup, setInventoryGroup] = useState(null);
const [contentLoading, setContentLoading] = useState(true);
const [contentError, setContentError] = useState(null);
useEffect(() => {
const loadData = async () => {
try {
const { data } = await GroupsAPI.readDetail(match.params.groupId);
setInventoryGroup(data);
setBreadcrumb(inventory, data);
} catch (err) {
setContentError(err);
} finally {
setContentLoading(false);
}
};
loadData();
}, [
history.location.pathname,
match.params.groupId,
inventory,
setBreadcrumb,
]);
const tabsArray = [
{
name: i18n._(t`Return to Groups`),
link: `/inventories/inventory/${inventory.id}/groups`,
id: 99,
isNestedTabs: true,
},
{
name: i18n._(t`Details`),
link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup &&
inventoryGroup.id}/details`,
id: 0,
},
{
name: i18n._(t`Related Groups`),
link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup &&
inventoryGroup.id}/nested_groups`,
id: 1,
},
{
name: i18n._(t`Hosts`),
link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup &&
inventoryGroup.id}/nested_hosts`,
id: 2,
},
];
// In cases where a user manipulates the url such that they try to navigate to a Inventory Group
// that is not associated with the Inventory Id in the Url this Content Error is thrown.
// Inventory Groups have a 1: 1 relationship to Inventories thus their Ids must corrolate.
if (contentLoading) {
return <ContentLoading />;
}
if (
inventoryGroup.summary_fields.inventory.id !== parseInt(match.params.id, 10)
) {
return (
<ContentError>
{inventoryGroup && (
<Link to={`/inventories/inventory/${inventory.id}/groups`}>
{i18n._(t`View Inventory Groups`)}
</Link>
)}
</ContentError>
);
}
if (contentError) {
return <ContentError error={contentError} />;
}
let cardHeader = null;
if (
history.location.pathname.includes('groups/') &&
!history.location.pathname.endsWith('edit')
) {
cardHeader = (
<CardHeader style={{ padding: 0 }}>
<RoutedTabs history={history} tabsArray={tabsArray} />
<CardCloseButton
linkTo={`/inventories/inventory/${inventory.id}/groups`}
/>
</CardHeader>
);
}
return (
<>
{cardHeader}
<Switch>
<Redirect
from="/inventories/inventory/:id/groups/:groupId"
to="/inventories/inventory/:id/groups/:groupId/details"
exact
/>
{inventoryGroup && [
<Route
key="edit"
path="/inventories/inventory/:id/groups/:groupId/edit"
render={() => {
return (
<InventoryGroupEdit
inventory={inventory}
inventoryGroup={inventoryGroup}
/>
);
}}
/>,
<Route
key="details"
path="/inventories/inventory/:id/groups/:groupId/details"
render={() => {
return <InventoryGroupDetail inventoryGroup={inventoryGroup} />;
}}
/>,
]}
<Route
key="not-found"
path="*"
render={() => {
return (
<ContentError>
{inventory && (
<Link to={`/inventories/inventory/${inventory.id}/details`}>
{i18n._(t`View Inventory Details`)}
</Link>
)}
</ContentError>
);
}}
/>
</Switch>
</>
);
}
export { InventoryGroups as _InventoryGroups };
export default withI18n()(withRouter(InventoryGroups));

View File

@ -0,0 +1,71 @@
import React from 'react';
import { GroupsAPI } from '@api';
import { Route } from 'react-router-dom';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import InventoryGroup from './InventoryGroup';
jest.mock('@api');
GroupsAPI.readDetail.mockResolvedValue({
data: {
id: 1,
name: 'Foo',
description: 'Bar',
variables: 'bizz: buzz',
summary_fields: {
inventory: { id: 1 },
created_by: { id: 1, name: 'Athena' },
modified_by: { id: 1, name: 'Apollo' },
},
},
});
describe('<InventoryGroup />', () => {
let wrapper;
let history;
const inventory = { id: 1, name: 'Foo' };
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/groups/1/details'],
});
await act(async () => {
wrapper = mountWithContexts(
<Route
path="/inventories/inventory/:id/groups"
component={() => (
<InventoryGroup setBreadcrumb={() => {}} inventory={inventory} />
)}
/>,
{
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
},
},
},
},
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
wrapper.unmount();
});
test('renders successfully', async () => {
expect(wrapper.length).toBe(1);
});
test('expect all tabs to exist, including Return to Groups', async () => {
expect(wrapper.find('button[aria-label="Return to Groups"]').length).toBe(
1
);
expect(wrapper.find('button[aria-label="Details"]').length).toBe(1);
expect(wrapper.find('button[aria-label="Related Groups"]').length).toBe(1);
expect(wrapper.find('button[aria-label="Hosts"]').length).toBe(1);
});
});

View File

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

View File

@ -0,0 +1,39 @@
import React, { useState, useEffect } from 'react';
import { withI18n } from '@lingui/react';
import { withRouter } from 'react-router-dom';
import { GroupsAPI } from '@api';
import { Card } from '@patternfly/react-core';
import InventoryGroupForm from '../shared/InventoryGroupForm';
function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) {
const [error, setError] = useState(null);
useEffect(() => setBreadcrumb(inventory), [inventory, setBreadcrumb]);
const handleSubmit = async values => {
values.inventory = inventory.id;
try {
const { data } = await GroupsAPI.create(values);
history.push(`/inventories/inventory/${inventory.id}/groups/${data.id}`);
} catch (err) {
setError(err);
}
};
const handleCancel = () => {
history.push(`/inventories/inventory/${inventory.id}/groups`);
};
return (
<Card>
<InventoryGroupForm
error={error}
handleCancel={handleCancel}
handleSubmit={handleSubmit}
/>
</Card>
);
}
export default withI18n()(withRouter(InventoryGroupsAdd));
export { InventoryGroupsAdd as _InventoryGroupsAdd };

View File

@ -0,0 +1,59 @@
import React from 'react';
import { Route } from 'react-router-dom';
import { GroupsAPI } from '@api';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import InventoryGroupAdd from './InventoryGroupAdd';
jest.mock('@api');
describe('<InventoryGroupAdd />', () => {
let wrapper;
let history;
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/groups/add'],
});
await act(async () => {
wrapper = mountWithContexts(
<Route
path="/inventories/inventory/:id/groups/add"
component={() => (
<InventoryGroupAdd setBreadcrumb={() => {}} inventory={{ id: 1 }} />
)}
/>,
{
context: {
router: { history, route: { location: history.location } },
},
}
);
});
});
afterEach(() => {
wrapper.unmount();
});
test('InventoryGroupAdd renders successfully', () => {
expect(wrapper.length).toBe(1);
});
test('cancel should navigate user to Inventory Groups List', async () => {
wrapper.find('button[aria-label="Cancel"]').simulate('click');
expect(history.location.pathname).toEqual(
'/inventories/inventory/1/groups'
);
});
test('handleSubmit should call api', async () => {
await act(async () => {
wrapper.find('InventoryGroupForm').prop('handleSubmit')({
name: 'Bar',
description: 'Ansible',
variables: 'ying: yang',
});
});
expect(GroupsAPI.create).toBeCalled();
});
});

View File

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

View File

@ -0,0 +1,165 @@
import React, { useState } from 'react';
import { t } from '@lingui/macro';
import { CardBody, Button } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { withRouter, Link } from 'react-router-dom';
import styled from 'styled-components';
import { VariablesInput as CodeMirrorInput } from '@components/CodeMirrorInput';
import ErrorDetail from '@components/ErrorDetail';
import AlertModal from '@components/AlertModal';
import { formatDateString } from '@util/dates';
import { GroupsAPI } from '@api';
import { DetailList, Detail } from '@components/DetailList';
const VariablesInput = styled(CodeMirrorInput)`
.pf-c-form__label {
font-weight: 600;
font-size: 16px;
}
margin: 20px 0;
`;
const ActionButtonWrapper = styled.div`
display: flex;
justify-content: flex-end;
margin-top: 20px;
& > :not(:first-child) {
margin-left: 20px;
}
`;
function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) {
const {
summary_fields: { created_by, modified_by },
created,
modified,
name,
description,
variables,
} = inventoryGroup;
const [error, setError] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const handleDelete = async () => {
setIsDeleteModalOpen(false);
try {
await GroupsAPI.destroy(inventoryGroup.id);
history.push(`/inventories/inventory/${match.params.id}/groups`);
} catch (err) {
setError(err);
}
};
let createdBy = '';
if (created) {
if (created_by && created_by.username) {
createdBy = (
<span>
{i18n._(t`${formatDateString(inventoryGroup.created)} by`)}{' '}
<Link to={`/users/${created_by.id}`}>{created_by.username}</Link>
</span>
);
} else {
createdBy = formatDateString(inventoryGroup.created);
}
}
let modifiedBy = '';
if (modified) {
if (modified_by && modified_by.username) {
modifiedBy = (
<span>
{i18n._(t`${formatDateString(inventoryGroup.modified)} by`)}{' '}
<Link to={`/users/${modified_by.id}`}>{modified_by.username}</Link>
</span>
);
} else {
modifiedBy = formatDateString(inventoryGroup.modified);
}
}
return (
<CardBody css="padding-top: 20px">
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
</DetailList>
<VariablesInput
id="inventoryGroup-variables"
readOnly
value={variables}
rows={4}
label={i18n._(t`Variables`)}
/>
<DetailList>
{createdBy && <Detail label={i18n._(t`Created`)} value={createdBy} />}
{modifiedBy && (
<Detail label={i18n._(t`Modified`)} value={modifiedBy} />
)}
</DetailList>
<ActionButtonWrapper>
<Button
variant="primary"
aria-label={i18n._(t`Edit`)}
onClick={() =>
history.push(
`/inventories/inventory/${match.params.id}/groups/${inventoryGroup.id}/edit`
)
}
>
{i18n._(t`Edit`)}
</Button>
<Button
variant="danger"
aria-label={i18n._(t`Delete`)}
onClick={() => setIsDeleteModalOpen(true)}
>
{i18n._(t`Delete`)}
</Button>
</ActionButtonWrapper>
{isDeleteModalOpen && (
<AlertModal
variant="danger"
title={i18n._(t`Delete Inventory Group`)}
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
actions={[
<Button
key="delete"
variant="danger"
aria-label={i18n._(t`confirm delete`)}
onClick={handleDelete}
>
{i18n._(t`Delete`)}
</Button>,
<Button
key="cancel"
variant="secondary"
aria-label={i18n._(t`cancel delete`)}
onClick={() => setIsDeleteModalOpen(false)}
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
{i18n._(t`Are you sure you want to delete:`)}
<br />
<strong>{inventoryGroup.name}</strong>
<br />
</AlertModal>
)}
{error && (
<AlertModal
variant="danger"
title={i18n._(t`Error!`)}
isOpen={error}
onClose={() => setError(false)}
>
{i18n._(t`Failed to delete group ${inventoryGroup.name}.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
</CardBody>
);
}
export default withI18n()(withRouter(InventoryGroupDetail));

View File

@ -0,0 +1,86 @@
import React from 'react';
import { GroupsAPI } from '@api';
import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import InventoryGroupDetail from './InventoryGroupDetail';
jest.mock('@api');
const inventoryGroup = {
name: 'Foo',
description: 'Bar',
variables: 'bizz: buzz',
id: 1,
created: '2019-12-02T15:58:16.276813Z',
modified: '2019-12-03T20:33:46.207654Z',
summary_fields: {
created_by: {
username: 'James',
id: 13,
},
modified_by: {
username: 'Bond',
id: 14,
},
},
};
describe('<InventoryGroupDetail />', () => {
let wrapper;
let history;
beforeEach(async () => {
await act(async () => {
history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/groups/1/details'],
});
wrapper = mountWithContexts(
<Route
path="/inventories/inventory/:id/groups/:groupId"
component={() => (
<InventoryGroupDetail inventoryGroup={inventoryGroup} />
)}
/>,
{
context: {
router: { history, route: { location: history.location } },
},
}
);
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
});
afterEach(() => {
wrapper.unmount();
});
test('InventoryGroupDetail renders successfully', () => {
expect(wrapper.length).toBe(1);
});
test('should open delete modal and then call api to delete the group', async () => {
await act(async () => {
wrapper.find('button[aria-label="Delete"]').simulate('click');
});
await waitForElement(wrapper, 'Modal', el => el.length === 1);
expect(wrapper.find('Modal').length).toBe(1);
await act(async () => {
wrapper.find('button[aria-label="confirm delete"]').simulate('click');
});
expect(GroupsAPI.destroy).toBeCalledWith(1);
});
test('should navigate user to edit form on edit button click', async () => {
wrapper.find('button[aria-label="Edit"]').simulate('click');
expect(history.location.pathname).toEqual(
'/inventories/inventory/1/groups/1/edit'
);
});
test('details shoudld render with the proper values', () => {
expect(wrapper.find('Detail[label="Name"]').prop('value')).toBe('Foo');
expect(wrapper.find('Detail[label="Description"]').prop('value')).toBe(
'Bar'
);
expect(wrapper.find('Detail[label="Created"]').length).toBe(1);
expect(wrapper.find('Detail[label="Modified"]').length).toBe(1);
expect(wrapper.find('VariablesInput').prop('value')).toBe('bizz: buzz');
});
});

View File

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

View File

@ -0,0 +1,38 @@
import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { withRouter } from 'react-router-dom';
import { GroupsAPI } from '@api';
import InventoryGroupForm from '../shared/InventoryGroupForm';
function InventoryGroupEdit({ history, inventoryGroup, inventory, match }) {
const [error, setError] = useState(null);
const handleSubmit = async values => {
try {
await GroupsAPI.update(match.params.groupId, values);
history.push(
`/inventories/inventory/${inventory.id}/groups/${inventoryGroup.id}`
);
} catch (err) {
setError(err);
}
};
const handleCancel = () => {
history.push(
`/inventories/inventory/${inventory.id}/groups/${inventoryGroup.id}`
);
};
return (
<InventoryGroupForm
error={error}
group={inventoryGroup}
handleCancel={handleCancel}
handleSubmit={handleSubmit}
/>
);
}
export default withI18n()(withRouter(InventoryGroupEdit));
export { InventoryGroupEdit as _InventoryGroupEdit };

View File

@ -0,0 +1,73 @@
import React from 'react';
import { Route } from 'react-router-dom';
import { GroupsAPI } from '@api';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import InventoryGroupEdit from './InventoryGroupEdit';
jest.mock('@api');
GroupsAPI.readDetail.mockResolvedValue({
data: {
name: 'Foo',
description: 'Bar',
variables: 'bizz: buzz',
},
});
describe('<InventoryGroupEdit />', () => {
let wrapper;
let history;
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/groups/2/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<Route
path="/inventories/inventory/:id/groups/:groupId/edit"
component={() => (
<InventoryGroupEdit
setBreadcrumb={() => {}}
inventory={{ id: 1 }}
inventoryGroup={{ id: 2 }}
/>
)}
/>,
{
context: {
router: {
history,
route: {
match: {
params: { groupId: 13 },
},
location: history.location,
},
},
},
}
);
});
});
afterEach(() => {
wrapper.unmount();
});
test('InventoryGroupEdit renders successfully', () => {
expect(wrapper.length).toBe(1);
});
test('cancel should navigate user to Inventory Groups List', async () => {
wrapper.find('button[aria-label="Cancel"]').simulate('click');
expect(history.location.pathname).toEqual(
'/inventories/inventory/1/groups/2'
);
});
test('handleSubmit should call api', async () => {
wrapper.find('InventoryGroupForm').prop('handleSubmit')({
name: 'Bar',
description: 'Ansible',
variables: 'ying: yang',
});
expect(GroupsAPI.update).toBeCalled();
});
});

View File

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

View File

@ -0,0 +1,77 @@
import React from 'react';
import { bool, func, number, oneOfType, string } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Group } from '@types';
import {
DataListItem,
DataListItemRow,
DataListItemCells,
Tooltip,
} from '@patternfly/react-core';
import { Link } from 'react-router-dom';
import { PencilAltIcon } from '@patternfly/react-icons';
import ActionButtonCell from '@components/ActionButtonCell';
import DataListCell from '@components/DataListCell';
import DataListCheck from '@components/DataListCheck';
import ListActionButton from '@components/ListActionButton';
import VerticalSeparator from '@components/VerticalSeparator';
function InventoryGroupItem({
i18n,
group,
inventoryId,
isSelected,
onSelect,
}) {
const labelId = `check-action-${group.id}`;
const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/details`;
const editUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/edit`;
return (
<DataListItem key={group.id} aria-labelledby={labelId}>
<DataListItemRow>
<DataListCheck
aria-labelledby={labelId}
id={`select-group-${group.id}`}
checked={isSelected}
onChange={onSelect}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="divider">
<VerticalSeparator />
<Link to={`${detailUrl}`} id={labelId}>
<b>{group.name}</b>
</Link>
</DataListCell>,
<ActionButtonCell lastcolumn="true" key="action">
{group.summary_fields.user_capabilities.edit && (
<Tooltip content={i18n._(t`Edit Group`)} position="top">
<ListActionButton
variant="plain"
component={Link}
to={editUrl}
>
<PencilAltIcon />
</ListActionButton>
</Tooltip>
)}
</ActionButtonCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
}
InventoryGroupItem.propTypes = {
group: Group.isRequired,
inventoryId: oneOfType([number, string]).isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
};
export default withI18n()(InventoryGroupItem);

View File

@ -0,0 +1,52 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import InventoryGroupItem from './InventoryGroupItem';
describe('<InventoryGroupItem />', () => {
let wrapper;
const mockGroup = {
id: 2,
type: 'group',
name: 'foo',
inventory: 1,
summary_fields: {
user_capabilities: {
edit: true,
},
},
};
beforeEach(() => {
wrapper = mountWithContexts(
<InventoryGroupItem
group={mockGroup}
inventoryId={1}
isSelected={false}
onSelect={() => {}}
/>
);
});
test('initially renders successfully', () => {
expect(wrapper.find('InventoryGroupItem').length).toBe(1);
});
test('edit button should be shown to users with edit capabilities', () => {
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
});
test('edit button should be hidden from users without edit capabilities', () => {
const copyMockGroup = Object.assign({}, mockGroup);
copyMockGroup.summary_fields.user_capabilities.edit = false;
wrapper = mountWithContexts(
<InventoryGroupItem
group={copyMockGroup}
inventoryId={1}
isSelected={false}
onSelect={() => {}}
/>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
});

View File

@ -1,10 +1,45 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
import React from 'react';
import { withI18n } from '@lingui/react';
class InventoryGroups extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
import { Switch, Route, withRouter } from 'react-router-dom';
import InventoryGroupAdd from '../InventoryGroupAdd/InventoryGroupAdd';
import InventoryGroup from '../InventoryGroup/InventoryGroup';
import InventoryGroupsList from './InventoryGroupsList';
function InventoryGroups({ setBreadcrumb, inventory, location, match }) {
return (
<Switch>
<Route
key="add"
path="/inventories/inventory/:id/groups/add"
render={() => {
return (
<InventoryGroupAdd
setBreadcrumb={setBreadcrumb}
inventory={inventory}
/>
);
}}
/>
<Route
key="details"
path="/inventories/inventory/:id/groups/:groupId/"
render={() => (
<InventoryGroup inventory={inventory} setBreadcrumb={setBreadcrumb} />
)}
/>
<Route
key="list"
path="/inventories/inventory/:id/groups"
render={() => {
return <InventoryGroupsList location={location} match={match} />;
}}
/>
</Switch>
);
}
export default InventoryGroups;
export { InventoryGroups as _InventoryGroups };
export default withI18n()(withRouter(InventoryGroups));

View File

@ -0,0 +1,49 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import InventoryGroups from './InventoryGroups';
jest.mock('@api');
describe('<InventoryGroups />', () => {
test('initially renders successfully', async () => {
let wrapper;
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/groups'],
});
const inventory = { id: 1, name: 'Foo' };
await act(async () => {
wrapper = mountWithContexts(
<InventoryGroups setBreadcrumb={() => {}} inventory={inventory} />,
{
context: {
router: { history, route: { location: history.location } },
},
}
);
});
expect(wrapper.length).toBe(1);
expect(wrapper.find('InventoryGroupsList').length).toBe(1);
});
test('test that InventoryGroupsAdd renders', async () => {
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/groups/add'],
});
const inventory = { id: 1, name: 'Foo' };
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<InventoryGroups setBreadcrumb={() => {}} inventory={inventory} />,
{
context: {
router: { history, route: { location: history.location } },
},
}
);
});
expect(wrapper.find('InventoryGroupsAdd').length).toBe(1);
});
});

View File

@ -0,0 +1,248 @@
import React, { useState, useEffect } from 'react';
import { TrashAltIcon } from '@patternfly/react-icons';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { getQSConfig, parseQueryString } from '@util/qs';
import { InventoriesAPI, GroupsAPI } from '@api';
import { Button, Tooltip } from '@patternfly/react-core';
import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import DataListToolbar from '@components/DataListToolbar';
import PaginatedDataList, {
ToolbarAddButton,
} from '@components/PaginatedDataList';
import styled from 'styled-components';
import InventoryGroupItem from './InventoryGroupItem';
import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal';
const QS_CONFIG = getQSConfig('group', {
page: 1,
page_size: 20,
order_by: 'name',
});
const DeleteButton = styled(Button)`
padding: 5px 8px;
&:hover {
background-color: #d9534f;
color: white;
}
&[disabled] {
color: var(--pf-c-button--m-plain--Color);
pointer-events: initial;
cursor: not-allowed;
}
`;
function cannotDelete(item) {
return !item.summary_fields.user_capabilities.delete;
}
const useModal = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
function toggleModal() {
setIsModalOpen(!isModalOpen);
}
return {
isModalOpen,
toggleModal,
};
};
function InventoryGroupsList({ i18n, location, match }) {
const [actions, setActions] = useState(null);
const [contentError, setContentError] = useState(null);
const [deletionError, setDeletionError] = useState(null);
const [groupCount, setGroupCount] = useState(0);
const [groups, setGroups] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [selected, setSelected] = useState([]);
const { isModalOpen, toggleModal } = useModal();
const inventoryId = match.params.id;
const fetchGroups = (id, queryString) => {
const params = parseQueryString(QS_CONFIG, queryString);
return InventoriesAPI.readGroups(id, params);
};
useEffect(() => {
async function fetchData() {
try {
const [
{
data: { count, results },
},
{
data: { actions: optionActions },
},
] = await Promise.all([
fetchGroups(inventoryId, location.search),
InventoriesAPI.readGroupsOptions(inventoryId),
]);
setGroups(results);
setGroupCount(count);
setActions(optionActions);
} catch (error) {
setContentError(error);
} finally {
setIsLoading(false);
}
}
fetchData();
}, [inventoryId, location]);
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...groups] : []);
};
const handleSelect = row => {
if (selected.some(s => s.id === row.id)) {
setSelected(selected.filter(s => s.id !== row.id));
} else {
setSelected(selected.concat(row));
}
};
const renderTooltip = () => {
const itemsUnableToDelete = selected
.filter(cannotDelete)
.map(item => item.name)
.join(', ');
if (selected.some(cannotDelete)) {
return (
<div>
{i18n._(
t`You do not have permission to delete the following Groups: ${itemsUnableToDelete}`
)}
</div>
);
}
if (selected.length) {
return i18n._(t`Delete`);
}
return i18n._(t`Select a row to delete`);
};
const handleDelete = async option => {
setIsLoading(true);
try {
/* eslint-disable no-await-in-loop, no-restricted-syntax */
/* Delete groups sequentially to avoid api integrity errors */
for (const group of selected) {
if (option === 'delete') {
await GroupsAPI.destroy(+group.id);
} else if (option === 'promote') {
await InventoriesAPI.promoteGroup(inventoryId, +group.id);
}
}
/* eslint-enable no-await-in-loop, no-restricted-syntax */
} catch (error) {
setDeletionError(error);
}
toggleModal();
try {
const {
data: { count, results },
} = await fetchGroups(inventoryId, location.search);
setGroups(results);
setGroupCount(count);
} catch (error) {
setContentError(error);
}
setIsLoading(false);
};
const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const isAllSelected =
selected.length > 0 && selected.length === groups.length;
return (
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
items={groups}
itemCount={groupCount}
qsConfig={QS_CONFIG}
renderItem={item => (
<InventoryGroupItem
key={item.id}
group={item}
inventoryId={inventoryId}
isSelected={selected.some(row => row.id === item.id)}
onSelect={() => handleSelect(item)}
/>
)}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG}
additionalControls={[
<Tooltip content={renderTooltip()} position="top" key="delete">
<div>
<DeleteButton
variant="plain"
aria-label={i18n._(t`Delete`)}
onClick={toggleModal}
isDisabled={
selected.length === 0 || selected.some(cannotDelete)
}
>
<TrashAltIcon />
</DeleteButton>
</div>
</Tooltip>,
canAdd && (
<ToolbarAddButton
key="add"
linkTo={`/inventories/inventory/${inventoryId}/groups/add`}
/>
),
]}
/>
)}
emptyStateControls={
canAdd && (
<ToolbarAddButton
key="add"
linkTo={`/inventories/inventory/${inventoryId}/groups/add`}
/>
)
}
/>
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)}
>
{i18n._(t`Failed to delete one or more groups.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)}
<InventoryGroupsDeleteModal
groups={selected}
isModalOpen={isModalOpen}
onClose={toggleModal}
onDelete={handleDelete}
/>
</>
);
}
export default withI18n()(withRouter(InventoryGroupsList));

View File

@ -0,0 +1,217 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { InventoriesAPI, GroupsAPI } from '@api';
import InventoryGroupsList from './InventoryGroupsList';
jest.mock('@api');
const mockGroups = [
{
id: 1,
type: 'group',
name: 'foo',
inventory: 1,
url: '/api/v2/groups/1',
summary_fields: {
user_capabilities: {
delete: true,
edit: true,
},
},
},
{
id: 2,
type: 'group',
name: 'bar',
inventory: 1,
url: '/api/v2/groups/2',
summary_fields: {
user_capabilities: {
delete: true,
edit: true,
},
},
},
{
id: 3,
type: 'group',
name: 'baz',
inventory: 1,
url: '/api/v2/groups/3',
summary_fields: {
user_capabilities: {
delete: false,
edit: false,
},
},
},
];
describe('<InventoryGroupsList />', () => {
let wrapper;
beforeEach(async () => {
InventoriesAPI.readGroups.mockResolvedValue({
data: {
count: mockGroups.length,
results: mockGroups,
},
});
InventoriesAPI.readGroupsOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
},
});
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/3/groups'],
});
await act(async () => {
wrapper = mountWithContexts(
<Route
path="/inventories/inventory/:id/groups"
component={() => <InventoryGroupsList />}
/>,
{
context: {
router: { history, route: { location: history.location } },
},
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders successfully', () => {
expect(wrapper.find('InventoryGroupsList').length).toBe(1);
});
test('should fetch groups from api and render them in the list', async () => {
expect(InventoriesAPI.readGroups).toHaveBeenCalled();
expect(wrapper.find('InventoryGroupItem').length).toBe(3);
});
test('should check and uncheck the row item', async () => {
expect(
wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
).toBe(false);
await act(async () => {
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(
true
);
});
wrapper.update();
expect(
wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
).toBe(true);
await act(async () => {
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(
false
);
});
wrapper.update();
expect(
wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
).toBe(false);
});
test('should check all row items when select all is checked', async () => {
wrapper.find('PFDataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
});
wrapper.update();
wrapper.find('PFDataListCheck').forEach(el => {
expect(el.props().checked).toBe(true);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(false);
});
wrapper.update();
wrapper.find('PFDataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
});
test('should show content error when api throws error on initial render', async () => {
InventoriesAPI.readGroupsOptions.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupsList />);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
test('should show content error if groups are not successfully fetched from api', async () => {
InventoriesAPI.readGroups.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')();
});
wrapper.update();
await act(async () => {
wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')();
});
await waitForElement(
wrapper,
'InventoryGroupsDeleteModal',
el => el.props().isModalOpen === true
);
await act(async () => {
wrapper
.find('ModalBoxFooter Button[aria-label="Delete"]')
.invoke('onClick')();
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
test('should show error modal when group is not successfully deleted from api', async () => {
GroupsAPI.destroy.mockRejectedValue(
new Error({
response: {
config: {
method: 'delete',
url: '/api/v2/groups/1',
},
data: 'An error occurred',
},
})
);
await act(async () => {
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')();
});
wrapper.update();
await act(async () => {
wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')();
});
await waitForElement(
wrapper,
'InventoryGroupsDeleteModal',
el => el.props().isModalOpen === true
);
await act(async () => {
wrapper.find('Radio[id="radio-delete"]').invoke('onChange')();
});
wrapper.update();
await act(async () => {
wrapper
.find('ModalBoxFooter Button[aria-label="Delete"]')
.invoke('onClick')();
});
await waitForElement(wrapper, { title: 'Error!', variant: 'danger' });
await act(async () => {
wrapper.find('ModalBoxCloseButton').invoke('onClose')();
});
});
});

View File

@ -1,8 +1,36 @@
import React from 'react';
import React, { useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { CardBody } from '@patternfly/react-core';
import InventoryHostForm from '../shared/InventoryHostForm';
import { InventoriesAPI } from '@api';
function InventoryHostAdd() {
return <CardBody>Coming soon :)</CardBody>;
const [formError, setFormError] = useState(null);
const history = useHistory();
const { id } = useParams();
const handleSubmit = async values => {
try {
const { data: response } = await InventoriesAPI.createHost(id, values);
history.push(`/inventories/inventory/${id}/hosts/${response.id}/details`);
} catch (error) {
setFormError(error);
}
};
const handleCancel = () => {
history.push(`/inventories/inventory/${id}/hosts`);
};
return (
<CardBody>
<InventoryHostForm
handleSubmit={handleSubmit}
handleCancel={handleCancel}
/>
{formError ? <div className="formSubmitError">error</div> : ''}
</CardBody>
);
}
export default InventoryHostAdd;

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