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
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 # Base development image build
awx-devel-build: awx-devel-build:
docker build -t ansible/awx_devel -f tools/docker-compose/Dockerfile \ 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) . --cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) .
docker tag ansible/awx_devel $(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) #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=_('Authentication'),
category_slug='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['oauth2'] = drf_reverse('api:oauth_authorization_root_view')
data['custom_logo'] = settings.CUSTOM_LOGO data['custom_logo'] = settings.CUSTOM_LOGO
data['custom_login_info'] = settings.CUSTOM_LOGIN_INFO data['custom_login_info'] = settings.CUSTOM_LOGIN_INFO
data['login_redirect_override'] = settings.LOGIN_REDIRECT_OVERRIDE
return Response(data) return Response(data)

View File

@@ -166,6 +166,8 @@ def instance_info(since, include_hostnames=False):
instances = models.Instance.objects.values_list('hostname').values( instances = models.Instance.objects.values_list('hostname').values(
'uuid', 'version', 'capacity', 'cpu', 'memory', 'managed_by_policy', 'hostname', 'last_isolated_check', 'enabled') 'uuid', 'version', 'capacity', 'cpu', 'memory', 'managed_by_policy', 'hostname', 'last_isolated_check', 'enabled')
for instance in instances: 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 = { instance_info = {
'uuid': instance['uuid'], 'uuid': instance['uuid'],
'version': instance['version'], 'version': instance['version'],
@@ -174,7 +176,9 @@ def instance_info(since, include_hostnames=False):
'memory': instance['memory'], 'memory': instance['memory'],
'managed_by_policy': instance['managed_by_policy'], 'managed_by_policy': instance['managed_by_policy'],
'last_isolated_check': _get_isolated_datetime(instance['last_isolated_check']), '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: if include_hostnames is True:
instance_info['hostname'] = instance['hostname'] 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_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_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_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_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') 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_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_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_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({ INSTANCE_INFO.labels(hostname=hostname, instance_uuid=uuid).info({
'enabled': str(instance_data[uuid]['enabled']), 'enabled': str(instance_data[uuid]['enabled']),
'last_isolated_check': getattr(instance_data[uuid], 'last_isolated_check', 'None'), 'last_isolated_check': getattr(instance_data[uuid], 'last_isolated_check', 'None'),

View File

@@ -1,8 +1,17 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models.signals import pre_migrate
from django.utils.translation import ugettext_lazy as _ 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): class MainConfig(AppConfig):
name = 'awx.main' name = 'awx.main'
verbose_name = _('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.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.db.migrations.executor import MigrationExecutor from django.db import IntegrityError
from django.db import IntegrityError, connection
from django.utils.functional import curry from django.utils.functional import curry
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.apps import apps from django.apps import apps
@@ -24,6 +23,7 @@ from django.urls import reverse, resolve
from awx.main.models import ActivityStream from awx.main.models import ActivityStream
from awx.main.utils.named_url_graph import generate_graph, GraphNode 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 from awx.conf import fields, register
@@ -213,8 +213,7 @@ class URLModificationMiddleware(MiddlewareMixin):
class MigrationRanCheckMiddleware(MiddlewareMixin): class MigrationRanCheckMiddleware(MiddlewareMixin):
def process_request(self, request): def process_request(self, request):
executor = MigrationExecutor(connection) if migration_in_progress_check_or_relase():
plan = executor.migration_plan(executor.loader.graph.leaf_nodes()) if getattr(resolve(request.path), 'url_name', '') == 'migrations_notran':
if bool(plan) and \ return
getattr(resolve(request.path), 'url_name', '') != 'migrations_notran':
return redirect(reverse("ui:migrations_notran")) return redirect(reverse("ui:migrations_notran"))

View File

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

View File

@@ -1,4 +1,5 @@
# Python # Python
import logging
import re import re
# Django # Django
@@ -22,6 +23,9 @@ DATA_URI_RE = re.compile(r'.*') # FIXME
__all__ = ['OAuth2AccessToken', 'OAuth2Application'] __all__ = ['OAuth2AccessToken', 'OAuth2Application']
logger = logging.getLogger('awx.main.models.oauth')
class OAuth2Application(AbstractApplication): class OAuth2Application(AbstractApplication):
class Meta: class Meta:
@@ -120,15 +124,27 @@ class OAuth2AccessToken(AbstractAccessToken):
def is_valid(self, scopes=None): def is_valid(self, scopes=None):
valid = super(OAuth2AccessToken, self).is_valid(scopes) valid = super(OAuth2AccessToken, self).is_valid(scopes)
if valid: 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() 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 return valid
def save(self, *args, **kwargs): def validate_external_users(self):
if self.user and settings.ALLOW_OAUTH2_FOR_EXTERNAL_USERS is False: if self.user and settings.ALLOW_OAUTH2_FOR_EXTERNAL_USERS is False:
external_account = get_external_account(self.user) external_account = get_external_account(self.user)
if external_account is not None: if external_account is not None:
raise oauth2.AccessDeniedError(_( raise oauth2.AccessDeniedError(_(
'OAuth2 Tokens cannot be created by users associated with an external authentication provider ({})' 'OAuth2 Tokens cannot be created by users associated with an external authentication provider ({})'
).format(external_account)) ).format(external_account))
def save(self, *args, **kwargs):
self.validate_external_users()
super(OAuth2AccessToken, self).save(*args, **kwargs) 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_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_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}, default_messages = {"started": {"message": DEFAULT_MSG, "body": None},
"success": {"message": DEFAULT_MSG, "body": None}, "success": {"message": DEFAULT_MSG, "body": None},
"error": {"message": DEFAULT_MSG, "body": None}, "error": {"message": DEFAULT_MSG, "body": None},
"workflow_approval": {"running": {"message": 'The approval node "{{ approval_node_name }}" needs review. ' "workflow_approval": {"running": {"message": DEFAULT_APPROVAL_RUNNING_MSG, "body": None},
'This node can be viewed at: {{ workflow_url }}', "approved": {"message": DEFAULT_APPROVAL_APPROVED_MSG, "body": None},
"body": None}, "timed_out": {"message": DEFAULT_APPROVAL_TIMEOUT_MSG, "body": None},
"approved": {"message": 'The approval node "{{ approval_node_name }}" was approved. {{ workflow_url }}', "denied": {"message": DEFAULT_APPROVAL_DENIED_MSG, "body": None}}}
"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}}}

View File

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

View File

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

View File

@@ -5,11 +5,15 @@ import logging
# AWX # AWX
from awx.main.scheduler import TaskManager from awx.main.scheduler import TaskManager
from awx.main.dispatch.publish import task 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') logger = logging.getLogger('awx.main.scheduler')
@task() @task()
def run_task_manager(): 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.") logger.debug("Running Tower task manager.")
TaskManager().schedule() 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)) 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') @task(queue='tower_broadcast_all', exchange_type='fanout')
def handle_setting_changes(setting_keys): def handle_setting_changes(setting_keys):
orig_len = len(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) project_path = instance.project.get_project_path(check_if_exists=False)
if os.path.exists(project_path): if os.path.exists(project_path):
git_repo = git.Repo(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 @staticmethod
def make_local_copy(project_path, destination_folder, scm_type, scm_revision): 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_custom_virtualenvs_total':0.0,
'awx_running_jobs_total':0.0, 'awx_running_jobs_total':0.0,
'awx_instance_capacity':100.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_cpu':0.0,
'awx_instance_memory':0.0, 'awx_instance_memory':0.0,
'awx_instance_info':1.0, 'awx_instance_info':1.0,

View File

@@ -1,6 +1,8 @@
import pytest import pytest
import base64 import base64
import contextlib
import json import json
from unittest import mock
from django.db import connection from django.db import connection
from django.test.utils import override_settings from django.test.utils import override_settings
@@ -14,6 +16,18 @@ from awx.sso.models import UserEnterpriseAuth
from oauth2_provider.models import RefreshToken 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 @pytest.mark.django_db
def test_personal_access_token_creation(oauth_application, post, alice): def test_personal_access_token_creation(oauth_application, post, alice):
url = drf_reverse('api:oauth_authorization_root_view') + 'token/' 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 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 @pytest.mark.django_db
def test_pat_creation_no_default_scope(oauth_application, post, admin): def test_pat_creation_no_default_scope(oauth_application, post, admin):
# tests that the default scope is overriden # 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() 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 @pytest.fixture
@mock.patch.object(Project, "update", lambda self, **kwargs: None) @mock.patch.object(Project, "update", lambda self, **kwargs: None)
def project(instance, organization): def project(instance, organization):

View File

@@ -281,15 +281,18 @@ class TestTaskImpact:
return job return job
return r 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 = 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 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 = 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 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 # factory creates on host per slice
workflow_job = slice_job_factory(3, jt_kwargs={'forks': 50}, spawn=True) workflow_job = slice_job_factory(3, jt_kwargs={'forks': 50}, spawn=True)
# arrange the jobs by their number # 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']) len(jobs[0].inventory.get_script_data(slice_number=i + 1, slice_count=3)['all']['hosts'])
for i in range(3) for i in range(3)
] == [2, 1, 1] ] == [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] assert [job.task_impact for job in jobs] == [3, 2, 2]

View File

@@ -4,6 +4,7 @@ import json
from datetime import timedelta from datetime import timedelta
from awx.main.scheduler import TaskManager from awx.main.scheduler import TaskManager
from awx.main.scheduler.dependency_graph import DependencyGraph
from awx.main.utils import encrypt_field from awx.main.utils import encrypt_field
from awx.main.models import WorkflowJobTemplate, JobTemplate 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()] iu = [x for x in ii.inventory_updates.all()]
assert len(pu) == 1 assert len(pu) == 1
assert len(iu) == 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 # Copyright (c) 2017 Ansible by Red Hat
# All Rights Reserved. # All Rights Reserved.
import logging
from itertools import chain 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): def get_all_field_names(model):
# Implements compatibility with _meta.get_all_field_names # Implements compatibility with _meta.get_all_field_names
@@ -14,3 +22,21 @@ def get_all_field_names(model):
# GenericForeignKey from the results. # GenericForeignKey from the results.
if not (field.many_to_one and field.related_model is None) 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 base64
import hashlib import hashlib
import logging import logging
@@ -35,7 +37,7 @@ class Fernet256(Fernet):
self._backend = backend 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, Generate key for encrypted password based on field name,
``settings.SECRET_KEY``, and instance pk (if available). ``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 from django.conf import settings
h = hashlib.sha512() 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: if pk is not None:
h.update(smart_bytes(str(pk))) h.update(smart_bytes(str(pk)))
h.update(smart_bytes(field_name)) h.update(smart_bytes(field_name))
return base64.urlsafe_b64encode(h.digest()) 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']) 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. 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) value = smart_str(value)
if not value or value.startswith('$encrypted$') or (ask and value == 'ASK'): if not value or value.startswith('$encrypted$') or (ask and value == 'ASK'):
return value 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) f = Fernet256(key)
encrypted = f.encrypt(smart_bytes(value)) encrypted = f.encrypt(smart_bytes(value))
b64data = smart_str(base64.b64encode(encrypted)) b64data = smart_str(base64.b64encode(encrypted))
@@ -99,7 +144,7 @@ def decrypt_value(encryption_key, value):
return smart_str(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. 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) value = smart_str(value)
if not value or not value.startswith('$encrypted$'): if not value or not value.startswith('$encrypted$'):
return value 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: try:
return smart_str(decrypt_value(key, value)) return smart_str(decrypt_value(key, value))

View File

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

View File

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

View File

@@ -115,7 +115,8 @@
- update_insights - update_insights
- name: Repository Version - name: Repository Version
debug: msg="Repository Version {{ scm_version }}" debug:
msg: "Repository Version {{ scm_version }}"
tags: tags:
- update_git - update_git
- update_hg - update_hg
@@ -130,7 +131,8 @@
- block: - block:
- name: detect requirements.yml - name: detect requirements.yml
stat: path={{project_path|quote}}/roles/requirements.yml stat:
path: '{{project_path|quote}}/roles/requirements.yml'
register: doesRequirementsExist register: doesRequirementsExist
- name: fetch galaxy roles from requirements.yml - name: fetch galaxy roles from requirements.yml
@@ -149,7 +151,8 @@
- block: - block:
- name: detect collections/requirements.yml - name: detect collections/requirements.yml
stat: path={{project_path|quote}}/collections/requirements.yml stat:
path: '{{project_path|quote}}/collections/requirements.yml'
register: doesCollectionRequirementsExist register: doesCollectionRequirementsExist
- name: fetch galaxy collections from collections/requirements.yml - 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. # Note: This setting may be overridden by database settings.
AUTH_BASIC_ENABLED = True 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. # If set, serve only minified JS for UI.
USE_MINIFIED_JS = False USE_MINIFIED_JS = False

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ global.$AnsibleConfig = null;
// Provided via Webpack DefinePlugin in webpack.config.js // Provided via Webpack DefinePlugin in webpack.config.js
global.$ENV = {}; global.$ENV = {};
global.$ConfigResponse = {};
var urlPrefix; var urlPrefix;
if ($basePath) { if ($basePath) {
@@ -383,7 +385,11 @@ angular
var stime = timestammp[lastUser.id].time, var stime = timestammp[lastUser.id].time,
now = new Date().getTime(); now = new Date().getTime();
if ((stime - now) <= 0) { 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 // 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.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 })); 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. * Grabs the language off of navigator for browser compatibility.
* If the language isn't set, then it falls back to the DEFAULT_LOCALE. The * 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: { ALLOW_OAUTH2_FOR_EXTERNAL_USERS: {
type: 'toggleSwitch', type: 'toggleSwitch',
}, },
LOGIN_REDIRECT_OVERRIDE: {
type: 'text',
reset: 'LOGIN_REDIRECT_OVERRIDE'
},
ACCESS_TOKEN_EXPIRE_SECONDS: { ACCESS_TOKEN_EXPIRE_SECONDS: {
type: 'text', type: 'text',
reset: 'ACCESS_TOKEN_EXPIRE_SECONDS' reset: 'ACCESS_TOKEN_EXPIRE_SECONDS'

View File

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

View File

@@ -246,10 +246,10 @@
"integrity": "sha512-nB/xe7JQWF9nLvhHommAICQ3eWrfRETo0EVGFESi952CDzDa+GAJ/2BFBNw44QqQPxj1Xua/uYKrbLsOGWZdbQ==" "integrity": "sha512-nB/xe7JQWF9nLvhHommAICQ3eWrfRETo0EVGFESi952CDzDa+GAJ/2BFBNw44QqQPxj1Xua/uYKrbLsOGWZdbQ=="
}, },
"angular-scheduler": { "angular-scheduler": {
"version": "git+https://git@github.com/ansible/angular-scheduler.git#7628cb2fc9e6280811baa464f0020a636e65d702", "version": "git+https://git@github.com/ansible/angular-scheduler.git#a519c52312cb4430a59a8d58e01d3eac3fe5018a",
"from": "git+https://git@github.com/ansible/angular-scheduler.git#v0.3.3", "from": "git+https://git@github.com/ansible/angular-scheduler.git#v0.4.1",
"requires": { "requires": {
"angular": "~1.6.6", "angular": "~1.7.2",
"angular-tz-extensions": "github:ansible/angular-tz-extensions#fc60660f43ee9ff84da94ca71ab27ef0c20fd77d", "angular-tz-extensions": "github:ansible/angular-tz-extensions#fc60660f43ee9ff84da94ca71ab27ef0c20fd77d",
"jquery": "*", "jquery": "*",
"jquery-ui": "*", "jquery-ui": "*",
@@ -258,45 +258,25 @@
"rrule": "github:jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c" "rrule": "github:jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c"
}, },
"dependencies": { "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": { "angular-tz-extensions": {
"version": "github:ansible/angular-tz-extensions#fc60660f43ee9ff84da94ca71ab27ef0c20fd77d", "version": "github:ansible/angular-tz-extensions#fc60660f43ee9ff84da94ca71ab27ef0c20fd77d",
"from": "github:ansible/angular-tz-extensions#fc60660f43ee9ff84da94ca71ab27ef0c20fd77d", "from": "github:ansible/angular-tz-extensions",
"requires": { "requires": {
"angular": "~1.7.2", "angular": "~1.7.2",
"angular-filters": "^1.1.2", "angular-filters": "^1.1.2",
"jquery": "^3.1.0", "jquery": "^3.1.0",
"jstimezonedetect": "1.0.5", "jstimezonedetect": "1.0.5",
"timezone-js": "github:ansible/timezone-js#6937de14ce0c193961538bb5b3b12b7ef62a358f" "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": { "lodash": {
"version": "3.8.0", "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=" "integrity": "sha1-N265i9zZOCqTZcM8TLglDeEyW5E="
}, },
"rrule": { "rrule": {
"version": "github:jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c", "version": "github:jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c",
"from": "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-moment": "^1.3.0",
"angular-mousewheel": "^1.0.5", "angular-mousewheel": "^1.0.5",
"angular-sanitize": "^1.7.9", "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-tz-extensions": "git+https://git@github.com/ansible/angular-tz-extensions#v0.5.2",
"angular-xeditable": "~0.8.0", "angular-xeditable": "~0.8.0",
"ansi-to-html": "^0.6.3", "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", "react-hot-loader": "^4.3.3",
"sass-loader": "^7.1.0", "sass-loader": "^7.1.0",
"style-loader": "^0.23.0", "style-loader": "^0.23.0",
"webpack": "^4.23.1", "webpack": "^4.41.2",
"webpack-cli": "^3.0.8", "webpack-cli": "^3.0.8",
"webpack-dev-server": "^3.1.14" "webpack-dev-server": "^3.1.14"
}, },

View File

@@ -2,6 +2,7 @@ import AdHocCommands from './models/AdHocCommands';
import Config from './models/Config'; import Config from './models/Config';
import CredentialTypes from './models/CredentialTypes'; import CredentialTypes from './models/CredentialTypes';
import Credentials from './models/Credentials'; import Credentials from './models/Credentials';
import Groups from './models/Groups';
import Hosts from './models/Hosts'; import Hosts from './models/Hosts';
import InstanceGroups from './models/InstanceGroups'; import InstanceGroups from './models/InstanceGroups';
import Inventories from './models/Inventories'; import Inventories from './models/Inventories';
@@ -28,6 +29,7 @@ const AdHocCommandsAPI = new AdHocCommands();
const ConfigAPI = new Config(); const ConfigAPI = new Config();
const CredentialsAPI = new Credentials(); const CredentialsAPI = new Credentials();
const CredentialTypesAPI = new CredentialTypes(); const CredentialTypesAPI = new CredentialTypes();
const GroupsAPI = new Groups();
const HostsAPI = new Hosts(); const HostsAPI = new Hosts();
const InstanceGroupsAPI = new InstanceGroups(); const InstanceGroupsAPI = new InstanceGroups();
const InventoriesAPI = new Inventories(); const InventoriesAPI = new Inventories();
@@ -55,6 +57,7 @@ export {
ConfigAPI, ConfigAPI,
CredentialsAPI, CredentialsAPI,
CredentialTypesAPI, CredentialTypesAPI,
GroupsAPI,
HostsAPI, HostsAPI,
InstanceGroupsAPI, InstanceGroupsAPI,
InventoriesAPI, 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.baseUrl = '/api/v2/inventories/';
this.readAccessList = this.readAccessList.bind(this); 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) { 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) { readHosts(id, params) {
return this.http.get(`${this.baseUrl}${id}/hosts/`, { 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; export default Inventories;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,20 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { bool, func, number, string, oneOfType } from 'prop-types'; 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 { CredentialsAPI } from '@api';
import { Credential } from '@types'; import { Credential } from '@types';
import { mergeParams } from '@util/qs'; import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
import { FormGroup } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
import Lookup from '@components/Lookup'; 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({ function CredentialLookup({
helperTextInvalid, helperTextInvalid,
@@ -16,11 +25,28 @@ function CredentialLookup({
required, required,
credentialTypeId, credentialTypeId,
value, value,
history,
}) { }) {
const getCredentials = async params => const [credentials, setCredentials] = useState([]);
CredentialsAPI.read( const [count, setCount] = useState(0);
mergeParams(params, { credential_type: credentialTypeId }) 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 ( return (
<FormGroup <FormGroup
@@ -32,15 +58,26 @@ function CredentialLookup({
> >
<Lookup <Lookup
id="credential" id="credential"
lookupHeader={label} header={label}
name="credential"
value={value} value={value}
onBlur={onBlur} onBlur={onBlur}
onLookupSave={onChange} onChange={onChange}
getItems={getCredentials}
required={required} 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> </FormGroup>
); );
} }
@@ -65,4 +102,4 @@ CredentialLookup.defaultProps = {
}; };
export { CredentialLookup as _CredentialLookup }; export { CredentialLookup as _CredentialLookup };
export default withI18n()(CredentialLookup); export default withI18n()(withRouter(CredentialLookup));

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts } from '@testUtils/enzymeHelpers';
import CredentialLookup, { _CredentialLookup } from './CredentialLookup'; import CredentialLookup, { _CredentialLookup } from './CredentialLookup';
import { CredentialsAPI } from '@api'; import { CredentialsAPI } from '@api';
@@ -9,19 +10,48 @@ describe('CredentialLookup', () => {
let wrapper; let wrapper;
beforeEach(() => { beforeEach(() => {
wrapper = mountWithContexts( CredentialsAPI.read.mockResolvedValueOnce({
<CredentialLookup credentialTypeId={1} label="Foo" onChange={() => {}} /> 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(() => { afterEach(() => {
jest.clearAllMocks(); 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); 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).toHaveBeenCalledTimes(1);
expect(CredentialsAPI.read).toHaveBeenCalledWith({ expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type: 1, credential_type: 1,
@@ -30,11 +60,31 @@ describe('CredentialLookup', () => {
page_size: 5, 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'); const title = wrapper.find('FormGroup .pf-c-form__label-text');
expect(title.text()).toEqual('Foo'); 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).toBeInstanceOf(Function);
expect(_CredentialLookup.defaultProps.onBlur).not.toThrow(); expect(_CredentialLookup.defaultProps.onBlur).not.toThrow();
}); });

View File

@@ -1,48 +1,69 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types'; import { arrayOf, string, func, object, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { InstanceGroupsAPI } from '@api'; 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)` const QS_CONFIG = getQSConfig('instance_groups', {
margin-left: 10px; 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 { useEffect(() => {
render() { (async () => {
const { value, tooltip, onChange, className, i18n } = this.props; 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]);
/* return (
Wrapping <div> added to workaround PF bug: <FormGroup
https://github.com/patternfly/patternfly-react/issues/2855 className={className}
*/ label={i18n._(t`Instance Groups`)}
return ( fieldId="org-instance-groups"
<div className={className}> >
<FormGroup {tooltip && <FieldTooltip content={tooltip} />}
label={i18n._(t`Instance Groups`)} <Lookup
fieldId="org-instance-groups" id="org-instance-groups"
> header={i18n._(t`Instance Groups`)}
{tooltip && ( value={value}
<Tooltip position="right" content={tooltip}> onChange={onChange}
<QuestionCircleIcon /> qsConfig={QS_CONFIG}
</Tooltip> multiple
)} required={required}
<Lookup renderOptionsList={({ state, dispatch, canDelete }) => (
id="org-instance-groups" <OptionsList
lookupHeader={i18n._(t`Instance Groups`)} value={state.selectedItems}
name="instanceGroups" options={instanceGroups}
value={value} optionCount={count}
onLookupSave={onChange}
getItems={getInstanceGroups}
qsNamespace="instance-group"
multiple
columns={[ columns={[
{ {
name: i18n._(t`Name`), name: i18n._(t`Name`),
@@ -63,22 +84,33 @@ class InstanceGroupsLookup extends React.Component {
isNumeric: true, 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 = { InstanceGroupsLookup.propTypes = {
value: PropTypes.arrayOf(PropTypes.object).isRequired, value: arrayOf(object).isRequired,
tooltip: PropTypes.string, tooltip: string,
onChange: PropTypes.func.isRequired, onChange: func.isRequired,
className: string,
required: bool,
}; };
InstanceGroupsLookup.defaultProps = { InstanceGroupsLookup.defaultProps = {
tooltip: '', 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 { string, func, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
@@ -7,61 +8,94 @@ import { InventoriesAPI } from '@api';
import { Inventory } from '@types'; import { Inventory } from '@types';
import Lookup from '@components/Lookup'; import Lookup from '@components/Lookup';
import { FieldTooltip } from '@components/FormField'; 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 { function InventoryLookup({
render() { value,
const { tooltip,
value, onChange,
tooltip, onBlur,
onChange, required,
onBlur, isValid,
required, helperTextInvalid,
isValid, i18n,
helperTextInvalid, history,
i18n, }) {
} = this.props; const [inventories, setInventories] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
return ( useEffect(() => {
<FormGroup (async () => {
label={i18n._(t`Inventory`)} const params = parseQueryString(QS_CONFIG, history.location.search);
isRequired={required} try {
fieldId="inventory-lookup" const { data } = await InventoriesAPI.read(params);
isValid={isValid} setInventories(data.results);
helperTextInvalid={helperTextInvalid} setCount(data.count);
> } catch (err) {
{tooltip && <FieldTooltip content={tooltip} />} setError(err);
<Lookup }
id="inventory-lookup" })();
lookupHeader={i18n._(t`Inventory`)} }, [history.location]);
name="inventory"
value={value} return (
onLookupSave={onChange} <FormGroup
onBlur={onBlur} label={i18n._(t`Inventory`)}
getItems={getInventories} isRequired={required}
required={required} fieldId="inventory-lookup"
qsNamespace="inventory" isValid={isValid}
columns={[ helperTextInvalid={helperTextInvalid}
{ name: i18n._(t`Name`), key: 'name', isSortable: true }, >
{ {tooltip && <FieldTooltip content={tooltip} />}
name: i18n._(t`Modified`), <Lookup
key: 'modified', id="inventory-lookup"
isSortable: false, header={i18n._(t`Inventory`)}
isNumeric: true, value={value}
}, onChange={onChange}
{ onBlur={onBlur}
name: i18n._(t`Created`), required={required}
key: 'created', qsConfig={QS_CONFIG}
isSortable: false, renderOptionsList={({ state, dispatch, canDelete }) => (
isNumeric: true, <OptionsList
}, value={state.selectedItems}
]} options={inventories}
sortedColumnKey="name" optionCount={count}
/> columns={[
</FormGroup> { 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 = { InventoryLookup.propTypes = {
@@ -77,4 +111,4 @@ InventoryLookup.defaultProps = {
required: false, 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 { import {
string, string,
bool, bool,
@@ -15,20 +15,14 @@ import {
ButtonVariant, ButtonVariant,
InputGroup as PFInputGroup, InputGroup as PFInputGroup,
Modal, Modal,
ToolbarItem,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import AnsibleSelect from '../AnsibleSelect'; import reducer, { initReducer } from './shared/reducer';
import PaginatedDataList from '../PaginatedDataList'; import { ChipGroup, Chip } from '../Chip';
import VerticalSeperator from '../VerticalSeparator'; import { QSConfig } from '@types';
import DataListToolbar from '../DataListToolbar';
import CheckboxListItem from '../CheckboxListItem';
import SelectedList from '../SelectedList';
import { ChipGroup, Chip, CredentialChip } from '../Chip';
import { getQSConfig, parseQueryString } from '../../util/qs';
const SearchButton = styled(Button)` const SearchButton = styled(Button)`
::after { ::after {
@@ -36,6 +30,7 @@ const SearchButton = styled(Button)`
var(--pf-global--BorderColor--200); var(--pf-global--BorderColor--200);
} }
`; `;
SearchButton.displayName = 'SearchButton';
const InputGroup = styled(PFInputGroup)` const InputGroup = styled(PFInputGroup)`
${props => ${props =>
@@ -54,315 +49,124 @@ const ChipHolder = styled.div`
border-bottom-right-radius: 3px; border-bottom-right-radius: 3px;
`; `;
class Lookup extends React.Component { function Lookup(props) {
constructor(props) { const {
super(props); id,
header,
onChange,
onBlur,
value,
multiple,
required,
qsConfig,
renderItemChip,
renderOptionsList,
history,
i18n,
} = props;
this.assertCorrectValueType(); const [state, dispatch] = useReducer(
let lookupSelectedItems = []; reducer,
if (props.value) { { value, multiple, required },
lookupSelectedItems = props.multiple ? [...props.value] : [props.value]; initReducer
} );
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);
}
componentDidMount() { useEffect(() => {
this.getData(); dispatch({ type: 'SET_MULTIPLE', value: multiple });
} }, [multiple]);
componentDidUpdate(prevProps) { useEffect(() => {
const { location, selectedCategory } = this.props; dispatch({ type: 'SET_VALUE', value });
if ( }, [value]);
location !== prevProps.location ||
prevProps.selectedCategory !== selectedCategory
) {
this.getData();
}
}
assertCorrectValueType() { const clearQSParams = () => {
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 parts = history.location.search.replace(/^\?/, '').split('&'); const parts = history.location.search.replace(/^\?/, '').split('&');
const ns = this.qsConfig.namespace; const ns = qsConfig.namespace;
const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); const otherParts = parts.filter(param => !param.startsWith(`${ns}.`));
history.push(`${history.location.pathname}?${otherParts.join('&')}`); history.push(`${history.location.pathname}?${otherParts.join('&')}`);
} };
render() { const save = () => {
const { const { selectedItems } = state;
isModalOpen, const val = multiple ? selectedItems : selectedItems[0] || null;
lookupSelectedItems, onChange(val);
error, clearQSParams();
results, dispatch({ type: 'CLOSE_MODAL' });
count, };
} = this.state;
const { const removeItem = item => {
form, if (multiple) {
id, onChange(value.filter(i => i.id !== item.id));
lookupHeader, } else {
value, onChange(null);
columns, }
multiple, };
name,
onBlur, const closeModal = () => {
selectCategory, clearQSParams();
required, dispatch({ type: 'CLOSE_MODAL' });
i18n, };
selectCategoryOptions,
selectedCategory, const { isModalOpen, selectedItems } = state;
} = this.props; const canDelete = !required || (multiple && value.length > 1);
const header = lookupHeader || i18n._(t`Items`); let items = [];
const canDelete = !required || (multiple && value.length > 1); if (multiple) {
const chips = () => { items = value;
return selectCategoryOptions && selectCategoryOptions.length > 0 ? ( } else if (value) {
<ChipGroup defaultIsOpen numChips={5}> items.push(value);
{(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>
);
} }
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({ const Item = shape({
@@ -371,25 +175,33 @@ const Item = shape({
Lookup.propTypes = { Lookup.propTypes = {
id: string, id: string,
getItems: func.isRequired, header: string,
lookupHeader: string, onChange: func.isRequired,
name: string,
onLookupSave: func.isRequired,
value: oneOfType([Item, arrayOf(Item)]), value: oneOfType([Item, arrayOf(Item)]),
sortedColumnKey: string.isRequired,
multiple: bool, multiple: bool,
required: bool, required: bool,
qsNamespace: string, onBlur: func,
qsConfig: QSConfig.isRequired,
renderItemChip: func,
renderOptionsList: func.isRequired,
}; };
Lookup.defaultProps = { Lookup.defaultProps = {
id: 'lookup-search', id: 'lookup-search',
lookupHeader: null, header: null,
name: null,
value: null, value: null,
multiple: false, multiple: false,
required: 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 }; export { Lookup as _Lookup };

View File

@@ -1,11 +1,9 @@
/* eslint-disable react/jsx-pascal-case */ /* eslint-disable react/jsx-pascal-case */
import React from 'react'; import React from 'react';
import { createMemoryHistory } from 'history'; import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import Lookup, { _Lookup } from './Lookup'; import { getQSConfig } from '@util/qs';
import Lookup from './Lookup';
let mockData = [{ name: 'foo', id: 1, isChecked: false }];
const mockColumns = [{ name: 'Name', key: 'name', isSortable: true }];
/** /**
* Check that an element is present on the document body * Check that an element is present on the document body
@@ -44,348 +42,118 @@ async function checkInputTagValues(wrapper, expected) {
}); });
} }
/** const QS_CONFIG = getQSConfig('test', {});
* Check lookup modal list for expected values const TestList = () => <div />;
* @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();
});
});
describe('<Lookup />', () => { describe('<Lookup />', () => {
let wrapper; let wrapper;
let onChange; 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(() => { beforeEach(() => {
const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' };
onChange = jest.fn(); onChange = jest.fn();
document.body.innerHTML = ''; 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); 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); expect(wrapper.find('Lookup')).toHaveLength(1);
await checkInputTagValues(wrapper, ['foo']); await checkInputTagValues(wrapper, ['foo']);
done();
}); });
test('Open and close modal', async done => { test('should open and close modal', async () => {
checkRootElementNotPresent('body div[role="dialog"]'); wrapper = await mountWrapper();
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"]'); checkRootElementNotPresent('body div[role="dialog"]');
wrapper.find('button[aria-label="Search"]').simulate('click'); wrapper.find('button[aria-label="Search"]').simulate('click');
checkRootElementPresent('body div[role="dialog"]'); 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 wrapper
.find('Modal button') .find('Modal button')
.findWhere(e => e.text() === 'Cancel') .findWhere(e => e.text() === 'Cancel')
.first() .first()
.simulate('click'); .simulate('click');
checkRootElementNotPresent('body div[role="dialog"]'); checkRootElementNotPresent('body div[role="dialog"]');
done();
}); });
test('Change selected item with radio control then save', async done => { test('should remove item when X button clicked', async () => {
wrapper.find('button[aria-label="Search"]').simulate('click'); wrapper = await mountWrapper();
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); await checkInputTagValues(wrapper, ['foo']);
await checkModalTagValues(wrapper, ['foo']);
wrapper wrapper
.find('DataListItemRow') .find('Lookup InputGroup Chip')
.findWhere(el => el.text() === 'bar') .findWhere(el => el.text() === 'foo')
.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')
.first() .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')(); .invoke('onClick')();
wrapper.find('Modal').invoke('onClose')(); expect(onChange).toHaveBeenCalledTimes(1);
expect(history.location.search).toEqual('?bar=baz'); 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 PropTypes from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core'; import { FormGroup, ToolbarItem } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { CredentialsAPI, CredentialTypesAPI } from '@api'; 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)` const QS_CONFIG = getQSConfig('credentials', {
margin-left: 10px; page: 1,
`; page_size: 5,
order_by: 'name',
});
class MultiCredentialsLookup extends React.Component { async function loadCredentialTypes() {
constructor(props) { const { data } = await CredentialTypesAPI.read();
super(props); const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault'];
return data.results.filter(type => acceptableTypes.includes(type.kind));
}
this.state = { async function loadCredentials(params, selectedCredentialTypeId) {
selectedCredentialType: { label: 'Machine', id: 1, kind: 'ssh' }, params.credential_type = selectedCredentialTypeId || 1;
credentialTypes: [], const { data } = await CredentialsAPI.read(params);
}; return data;
this.loadCredentialTypes = this.loadCredentialTypes.bind(this); }
this.handleCredentialTypeSelect = this.handleCredentialTypeSelect.bind(
this
);
this.loadCredentials = this.loadCredentials.bind(this);
this.toggleCredentialSelection = this.toggleCredentialSelection.bind(this);
}
componentDidMount() { function MultiCredentialsLookup(props) {
this.loadCredentialTypes(); 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() { useEffect(() => {
const { onError } = this.props; (async () => {
try { try {
const { data } = await CredentialTypesAPI.read(); const types = await loadCredentialTypes();
const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault']; setCredentialTypes(types);
const credentialTypes = []; const match = types.find(type => type.kind === 'ssh') || types[0];
data.results.forEach(cred => { setSelectedType(match);
acceptableTypes.forEach(aT => { } catch (err) {
if (aT === cred.kind) { onError(err);
// This object has several repeated values as some of it's children }
// require different field values. })();
cred = { }, [onError]);
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);
}
}
async loadCredentials(params) { useEffect(() => {
const { selectedCredentialType } = this.state; (async () => {
params.credential_type = selectedCredentialType.id || 1; if (!selectedType) {
return CredentialsAPI.read(params); 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 renderChip = ({ item, removeItem, canDelete }) => (
const { onChange, credentials: credentialsToUpdate } = this.props; <CredentialChip
key={item.id}
onClick={() => removeItem(item)}
isReadOnly={!canDelete}
credential={item}
/>
);
let newCredentialsList; const isMultiple = selectedType && selectedType.kind === 'vault';
const isSelectedCredentialInState =
credentialsToUpdate.filter(cred => cred.id === newCredential.id).length >
0;
if (isSelectedCredentialInState) { return (
newCredentialsList = credentialsToUpdate.filter( <FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential">
cred => cred.id !== newCredential.id {tooltip && <FieldTooltip content={tooltip} />}
); <Lookup
} else { id="multiCredential"
newCredentialsList = credentialsToUpdate.filter( header={i18n._(t`Credentials`)}
credential => value={value}
credential.kind === 'vault' || credential.kind !== newCredential.kind multiple
); onChange={onChange}
newCredentialsList = [...newCredentialsList, newCredential]; qsConfig={QS_CONFIG}
} renderItemChip={renderChip}
onChange(newCredentialsList); renderOptionsList={({ state, dispatch, canDelete }) => {
} return (
<Fragment>
handleCredentialTypeSelect(value, type) { {credentialTypes && credentialTypes.length > 0 && (
const { credentialTypes } = this.state; <ToolbarItem css=" display: flex; align-items: center;">
const selectedType = credentialTypes.filter(item => item.label === type); <div css="flex: 0 0 25%;">{i18n._(t`Selected Category`)}</div>
this.setState({ selectedCredentialType: selectedType[0] }); <VerticalSeperator />
} <AnsibleSelect
css="flex: 1 1 75%;"
render() { id="multiCredentialsLookUp-select"
const { selectedCredentialType, credentialTypes } = this.state; label={i18n._(t`Selected Category`)}
const { tooltip, i18n, credentials } = this.props; data={credentialTypes.map(type => ({
return ( key: type.id,
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential"> value: type.id,
{tooltip && ( label: type.name,
<Tooltip position="right" content={tooltip}> isDisabled: false,
<QuestionCircleIcon /> }))}
</Tooltip> value={selectedType && selectedType.id}
)} onChange={(e, id) => {
{credentialTypes && ( setSelectedType(
<Lookup credentialTypes.find(o => o.id === parseInt(id, 10))
selectCategoryOptions={credentialTypes} );
selectCategory={this.handleCredentialTypeSelect} }}
selectedCategory={selectedCredentialType} />
onToggleItem={this.toggleCredentialSelection} </ToolbarItem>
onloadCategories={this.loadCredentialTypes} )}
id="multiCredential" <OptionsList
lookupHeader={i18n._(t`Credentials`)} value={state.selectedItems}
name="credentials" options={credentials}
value={credentials} optionCount={credentialsCount}
multiple columns={[
onLookupSave={() => {}} {
getItems={this.loadCredentials} name: i18n._(t`Name`),
qsNamespace="credentials" key: 'name',
columns={[ isSortable: true,
{ isSearchable: true,
name: i18n._(t`Name`), },
key: 'name', ]}
isSortable: true, multiple={isMultiple}
isSearchable: true, header={i18n._(t`Credentials`)}
}, name="credentials"
]} qsConfig={QS_CONFIG}
sortedColumnKey="name" readOnly={!canDelete}
/> selectItem={item => {
)} if (isMultiple) {
</FormGroup> 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 = { MultiCredentialsLookup.propTypes = {
tooltip: PropTypes.string, tooltip: PropTypes.string,
credentials: PropTypes.arrayOf( value: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
id: PropTypes.number, id: PropTypes.number,
name: PropTypes.string, name: PropTypes.string,
@@ -155,8 +176,8 @@ MultiCredentialsLookup.propTypes = {
MultiCredentialsLookup.defaultProps = { MultiCredentialsLookup.defaultProps = {
tooltip: '', 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 React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import MultiCredentialsLookup from './MultiCredentialsLookup'; import MultiCredentialsLookup from './MultiCredentialsLookup';
import { CredentialsAPI, CredentialTypesAPI } from '@api'; import { CredentialsAPI, CredentialTypesAPI } from '@api';
@@ -8,9 +8,6 @@ jest.mock('@api');
describe('<MultiCredentialsLookup />', () => { describe('<MultiCredentialsLookup />', () => {
let wrapper; let wrapper;
let lookup;
let credLookup;
let onChange;
const credentials = [ const credentials = [
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, { 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: 21, kind: 'vault' },
{ name: 'Gatsby', id: 8, kind: 'Machine' }, { name: 'Gatsby', id: 8, kind: 'Machine' },
]; ];
beforeEach(() => { beforeEach(() => {
CredentialTypesAPI.read.mockResolvedValue({ CredentialTypesAPI.read.mockResolvedValueOnce({
data: { data: {
results: [ results: [
{ {
@@ -46,17 +44,6 @@ describe('<MultiCredentialsLookup />', () => {
count: 3, 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(() => { afterEach(() => {
@@ -64,16 +51,40 @@ describe('<MultiCredentialsLookup />', () => {
wrapper.unmount(); 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(wrapper.find('MultiCredentialsLookup')).toHaveLength(1);
expect(CredentialTypesAPI.read).toHaveBeenCalled(); expect(CredentialTypesAPI.read).toHaveBeenCalled();
}); });
test('onChange is called when you click to remove a credential from input', async () => { test('onChange is called when you click to remove a credential from input', async () => {
const chip = wrapper.find('PFChip').find({ isOverflowChip: false }); const onChange = jest.fn();
const button = chip.at(1).find('ChipButton'); 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); expect(chip).toHaveLength(4);
button.prop('onClick')(); const button = chip.at(1).find('ChipButton');
await act(async () => {
button.invoke('onClick')();
});
expect(onChange).toBeCalledWith([ expect(onChange).toBeCalledWith([
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
{ id: 21, kind: 'vault', name: 'Gatsby' }, { id: 21, kind: 'vault', name: 'Gatsby' },
@@ -81,33 +92,122 @@ describe('<MultiCredentialsLookup />', () => {
]); ]);
}); });
test('can change credential types', () => { test('should change credential types', async () => {
lookup.prop('selectCategory')({}, 'Vault'); await act(async () => {
expect(credLookup.state('selectedCredentialType')).toEqual({ wrapper = mountWithContexts(
id: 500, <MultiCredentialsLookup
key: 500, value={credentials}
kind: 'vault', tooltip="This is credentials look up"
type: 'buzz', onChange={() => {}}
value: 'Vault', onError={() => {}}
label: 'Vault', />
isDisabled: false, );
}); });
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([ expect(onChange).toBeCalledWith([
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
{ id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' }, { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
{ id: 21, kind: 'vault', name: 'Gatsby' }, { 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([ expect(onChange).toBeCalledWith([
...credentials, { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
{ name: 'Party', id: 22, kind: 'vault' }, { 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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { string, func, bool } from 'prop-types';
import { OrganizationsAPI } from '@api'; import { OrganizationsAPI } from '@api';
import { Organization } from '@types'; import { Organization } from '@types';
import { FormGroup } from '@patternfly/react-core'; 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({ function OrganizationLookup({
helperTextInvalid, helperTextInvalid,
@@ -17,7 +25,25 @@ function OrganizationLookup({
onChange, onChange,
required, required,
value, 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 ( return (
<FormGroup <FormGroup
fieldId="organization" fieldId="organization"
@@ -28,15 +54,29 @@ function OrganizationLookup({
> >
<Lookup <Lookup
id="organization" id="organization"
lookupHeader={i18n._(t`Organization`)} header={i18n._(t`Organization`)}
name="organization"
value={value} value={value}
onBlur={onBlur} onBlur={onBlur}
onLookupSave={onChange} onChange={onChange}
getItems={getOrganizations} qsConfig={QS_CONFIG}
required={required} required={required}
sortedColumnKey="name" 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> </FormGroup>
); );
} }
@@ -58,5 +98,5 @@ OrganizationLookup.defaultProps = {
value: null, value: null,
}; };
export default withI18n()(OrganizationLookup);
export { OrganizationLookup as _OrganizationLookup }; export { OrganizationLookup as _OrganizationLookup };
export default withI18n()(withRouter(OrganizationLookup));

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts } from '@testUtils/enzymeHelpers';
import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup'; import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup';
import { OrganizationsAPI } from '@api'; import { OrganizationsAPI } from '@api';
@@ -8,18 +9,22 @@ jest.mock('@api');
describe('OrganizationLookup', () => { describe('OrganizationLookup', () => {
let wrapper; let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
});
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
wrapper.unmount();
}); });
test('initially renders successfully', () => { test('should render successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
});
expect(wrapper).toHaveLength(1); 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).toHaveBeenCalledTimes(1);
expect(OrganizationsAPI.read).toHaveBeenCalledWith({ expect(OrganizationsAPI.read).toHaveBeenCalledWith({
order_by: 'name', order_by: 'name',
@@ -27,11 +32,19 @@ describe('OrganizationLookup', () => {
page_size: 5, 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'); const title = wrapper.find('FormGroup .pf-c-form__label-text');
expect(title.text()).toEqual('Organization'); 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).toBeInstanceOf(Function);
expect(_OrganizationLookup.defaultProps.onBlur).not.toThrow(); 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 { string, func, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
import { ProjectsAPI } from '@api'; import { ProjectsAPI } from '@api';
import { Project } from '@types'; import { Project } from '@types';
import Lookup from '@components/Lookup';
import { FieldTooltip } from '@components/FormField'; 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 { const QS_CONFIG = getQSConfig('project', {
render() { page: 1,
const { page_size: 5,
helperTextInvalid, order_by: 'name',
i18n, });
isValid,
onChange,
required,
tooltip,
value,
onBlur,
} = this.props;
const loadProjects = async params => { function ProjectLookup({
const response = await ProjectsAPI.read(params); helperTextInvalid,
const { results, count } = response.data; i18n,
if (count === 1) { isValid,
onChange(results[0], 'project'); 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 ( return (
<FormGroup <FormGroup
fieldId="project" fieldId="project"
helperTextInvalid={helperTextInvalid} helperTextInvalid={helperTextInvalid}
isRequired={required} isRequired={required}
isValid={isValid} isValid={isValid}
label={i18n._(t`Project`)} label={i18n._(t`Project`)}
> >
{tooltip && <FieldTooltip content={tooltip} />} {tooltip && <FieldTooltip content={tooltip} />}
<Lookup <Lookup
id="project" id="project"
lookupHeader={i18n._(t`Project`)} header={i18n._(t`Project`)}
name="project" name="project"
value={value} value={value}
onBlur={onBlur} onBlur={onBlur}
onLookupSave={onChange} onChange={onChange}
getItems={loadProjects} required={required}
required={required} qsConfig={QS_CONFIG}
sortedColumnKey="name" renderOptionsList={({ state, dispatch, canDelete }) => (
qsNamespace="project" <OptionsList
/> value={state.selectedItems}
</FormGroup> 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 = { ProjectLookup.propTypes = {
@@ -75,4 +106,5 @@ ProjectLookup.defaultProps = {
onBlur: () => {}, 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 React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils'; import { sleep } from '@testUtils/testUtils';
import { ProjectsAPI } from '@api'; import { ProjectsAPI } from '@api';
@@ -15,9 +16,11 @@ describe('<ProjectLookup />', () => {
}, },
}); });
const onChange = jest.fn(); const onChange = jest.fn();
mountWithContexts(<ProjectLookup onChange={onChange} />); await act(async () => {
mountWithContexts(<ProjectLookup onChange={onChange} />);
});
await sleep(0); await sleep(0);
expect(onChange).toHaveBeenCalledWith({ id: 1 }, 'project'); expect(onChange).toHaveBeenCalledWith({ id: 1 });
}); });
test('should not auto-select project when multiple available', async () => { test('should not auto-select project when multiple available', async () => {
@@ -28,7 +31,9 @@ describe('<ProjectLookup />', () => {
}, },
}); });
const onChange = jest.fn(); const onChange = jest.fn();
mountWithContexts(<ProjectLookup onChange={onChange} />); await act(async () => {
mountWithContexts(<ProjectLookup onChange={onChange} />);
});
await sleep(0); await sleep(0);
expect(onChange).not.toHaveBeenCalled(); 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> </DropdownToggle>
} }
dropdownItems={[ dropdownItems={[
<DropdownItem key="user" href="#/home"> <DropdownItem
key="user"
href={
loggedInUser
? `#/users/${loggedInUser.id}/details`
: '#/home'
}
>
{i18n._(t`User Details`)} {i18n._(t`User Details`)}
</DropdownItem>, </DropdownItem>,
<DropdownItem <DropdownItem

View File

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

View File

@@ -58,7 +58,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<ForwardRef <ForwardRef
to={ to={
Object { Object {
"pathname": "/bar", "pathname": "/users/2/details",
} }
} }
> >
@@ -114,7 +114,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<ForwardRef <ForwardRef
to={ to={
Object { Object {
"pathname": "/bar", "pathname": "/users/2/details",
} }
} }
> >
@@ -193,7 +193,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<ForwardRef <ForwardRef
to={ to={
Object { Object {
"pathname": "/bar", "pathname": "/users/2/details",
} }
} }
> >
@@ -260,7 +260,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Styled(Link) <Styled(Link)
to={ to={
Object { Object {
"pathname": "/bar", "pathname": "/users/2/details",
} }
} }
> >
@@ -308,7 +308,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
forwardedRef={null} forwardedRef={null}
to={ to={
Object { Object {
"pathname": "/bar", "pathname": "/users/2/details",
} }
} }
> >
@@ -316,18 +316,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
className="sc-bdVaJa fqQVUT" className="sc-bdVaJa fqQVUT"
to={ to={
Object { Object {
"pathname": "/bar", "pathname": "/users/2/details",
} }
} }
> >
<LinkAnchor <LinkAnchor
className="sc-bdVaJa fqQVUT" className="sc-bdVaJa fqQVUT"
href="/bar" href="/users/2/details"
navigate={[Function]} navigate={[Function]}
> >
<a <a
className="sc-bdVaJa fqQVUT" className="sc-bdVaJa fqQVUT"
href="/bar" href="/users/2/details"
onClick={[Function]} onClick={[Function]}
> >
jane 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 { Tab, Tabs as PFTabs } from '@patternfly/react-core';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { CaretLeftIcon } from '@patternfly/react-icons';
const Tabs = styled(PFTabs)` const Tabs = styled(PFTabs)`
--pf-c-tabs__button--PaddingLeft: 20px; --pf-c-tabs__button--PaddingLeft: 20px;
@@ -62,7 +63,15 @@ function RoutedTabs(props) {
eventKey={tab.id} eventKey={tab.id}
key={tab.id} key={tab.id}
link={tab.link} link={tab.link}
title={tab.name} title={
tab.isNestedTabs ? (
<>
<CaretLeftIcon /> {tab.name}
</>
) : (
tab.name
)
}
/> />
))} ))}
</Tabs> </Tabs>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import React, { Component } from 'react'; import React, { useState } from 'react';
import PropTypes from 'prop-types'; import { func, shape } from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { Formik, Field } from 'formik'; import { Formik, Field } from 'formik';
@@ -15,120 +15,86 @@ import { VariablesField } from '@components/CodeMirrorInput';
import { required } from '@util/validators'; import { required } from '@util/validators';
import { InventoryLookup } from '@components/Lookup'; import { InventoryLookup } from '@components/Lookup';
class HostForm extends Component { function HostForm({ handleSubmit, handleCancel, host, i18n }) {
constructor(props) { const [inventory, setInventory] = useState(
super(props); host ? host.summary_fields.inventory : ''
);
this.handleSubmit = this.handleSubmit.bind(this); return (
<Formik
this.state = { initialValues={{
formIsValid: true, name: host.name,
inventory: props.host.summary_fields.inventory, description: host.description,
}; inventory: host.inventory || '',
} variables: host.variables,
}}
handleSubmit(values) { onSubmit={handleSubmit}
const { handleSubmit } = this.props; render={formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
handleSubmit(values); <FormRow>
} <FormField
id="host-name"
render() { name="name"
const { host, handleCancel, i18n } = this.props; type="text"
const { formIsValid, inventory, error } = this.state; label={i18n._(t`Name`)}
validate={required(null, i18n)}
const initialValues = !host.id isRequired
? {
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}
/> />
{error ? <div>error</div> : null} <FormField
</Form> 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 = { HostForm.propTypes = {
host: PropTypes.shape(), handleSubmit: func.isRequired,
handleSubmit: PropTypes.func.isRequired, handleCancel: func.isRequired,
handleCancel: PropTypes.func.isRequired, host: shape({}),
}; };
HostForm.defaultProps = { HostForm.defaultProps = {

View File

@@ -65,11 +65,7 @@ describe('<HostForm />', () => {
expect(handleSubmit).not.toHaveBeenCalled(); expect(handleSubmit).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Save"]').simulate('click'); wrapper.find('button[aria-label="Save"]').simulate('click');
await sleep(1); await sleep(1);
expect(handleSubmit).toHaveBeenCalledWith({ expect(handleSubmit).toHaveBeenCalled();
name: 'Foo',
description: 'Bar',
variables: '---',
});
}); });
test('calls "handleCancel" when Cancel button is clicked', () => { 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; const { i18n } = this.props;
if (!inventory) { if (!inventory) {
return; return;
@@ -57,6 +57,15 @@ class Inventories extends Component {
), ),
[`/inventories/inventory/${inventory.id}/sources`]: i18n._(t`Sources`), [`/inventories/inventory/${inventory.id}/sources`]: i18n._(t`Sources`),
[`/inventories/inventory/${inventory.id}/groups`]: i18n._(t`Groups`), [`/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 }); this.setState({ breadcrumbConfig });
}; };

View File

@@ -57,7 +57,11 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
</CardHeader> </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; cardHeader = null;
} }
@@ -123,7 +127,15 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
<Route <Route
key="groups" key="groups"
path="/inventories/inventory/:id/groups" path="/inventories/inventory/:id/groups"
render={() => <InventoryGroups inventory={inventory} />} render={() => (
<InventoryGroups
location={location}
match={match}
history={history}
setBreadcrumb={setBreadcrumb}
inventory={inventory}
/>
)}
/>, />,
<Route <Route
key="hosts" key="hosts"

View File

@@ -15,7 +15,7 @@ import { getAddedAndRemoved } from '../../../util/lists';
function InventoryEdit({ history, i18n, inventory }) { function InventoryEdit({ history, i18n, inventory }) {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [associatedInstanceGroups, setInstanceGroups] = useState(null); const [associatedInstanceGroups, setInstanceGroups] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [contentLoading, setContentLoading] = useState(true);
const [credentialTypeId, setCredentialTypeId] = useState(null); const [credentialTypeId, setCredentialTypeId] = useState(null);
useEffect(() => { useEffect(() => {
@@ -39,11 +39,11 @@ function InventoryEdit({ history, i18n, inventory }) {
} catch (err) { } catch (err) {
setError(err); setError(err);
} finally { } finally {
setIsLoading(false); setContentLoading(false);
} }
}; };
loadData(); loadData();
}, [inventory.id, isLoading, inventory, credentialTypeId]); }, [inventory.id, contentLoading, inventory, credentialTypeId]);
const handleCancel = () => { const handleCancel = () => {
history.push('/inventories'); history.push('/inventories');
@@ -85,7 +85,7 @@ function InventoryEdit({ history, i18n, inventory }) {
history.push(`${url}`); history.push(`${url}`);
} }
}; };
if (isLoading) { if (contentLoading) {
return <ContentLoading />; return <ContentLoading />;
} }
if (error) { 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 React from 'react';
import { CardBody } from '@patternfly/react-core'; import { withI18n } from '@lingui/react';
class InventoryGroups extends Component { import { Switch, Route, withRouter } from 'react-router-dom';
render() {
return <CardBody>Coming soon :)</CardBody>; 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 { CardBody } from '@patternfly/react-core';
import InventoryHostForm from '../shared/InventoryHostForm';
import { InventoriesAPI } from '@api';
function InventoryHostAdd() { 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; export default InventoryHostAdd;

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