mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 10:00:01 -03:30
Merge branch 'devel' of github.com:ansible/awx into fix_collection_sanity
This commit is contained in:
commit
caad204cbb
1
Makefile
1
Makefile
@ -663,7 +663,6 @@ docker-compose-build: awx-devel-build
|
||||
# Base development image build
|
||||
awx-devel-build:
|
||||
docker build -t ansible/awx_devel -f tools/docker-compose/Dockerfile \
|
||||
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:devel \
|
||||
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) .
|
||||
docker tag ansible/awx_devel $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
|
||||
#docker push $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
|
||||
|
||||
@ -62,3 +62,15 @@ register(
|
||||
category=_('Authentication'),
|
||||
category_slug='authentication',
|
||||
)
|
||||
register(
|
||||
'LOGIN_REDIRECT_OVERRIDE',
|
||||
field_class=fields.CharField,
|
||||
allow_blank=True,
|
||||
required=False,
|
||||
default='',
|
||||
label=_('Login redirect override URL'),
|
||||
help_text=_('URL to which unauthorized users will be redirected to log in. '
|
||||
'If blank, users will be sent to the Tower login page.'),
|
||||
category=_('Authentication'),
|
||||
category_slug='authentication',
|
||||
)
|
||||
|
||||
@ -60,6 +60,7 @@ class ApiRootView(APIView):
|
||||
data['oauth2'] = drf_reverse('api:oauth_authorization_root_view')
|
||||
data['custom_logo'] = settings.CUSTOM_LOGO
|
||||
data['custom_login_info'] = settings.CUSTOM_LOGIN_INFO
|
||||
data['login_redirect_override'] = settings.LOGIN_REDIRECT_OVERRIDE
|
||||
return Response(data)
|
||||
|
||||
|
||||
|
||||
@ -166,6 +166,8 @@ def instance_info(since, include_hostnames=False):
|
||||
instances = models.Instance.objects.values_list('hostname').values(
|
||||
'uuid', 'version', 'capacity', 'cpu', 'memory', 'managed_by_policy', 'hostname', 'last_isolated_check', 'enabled')
|
||||
for instance in instances:
|
||||
consumed_capacity = sum(x.task_impact for x in models.UnifiedJob.objects.filter(execution_node=instance['hostname'],
|
||||
status__in=('running', 'waiting')))
|
||||
instance_info = {
|
||||
'uuid': instance['uuid'],
|
||||
'version': instance['version'],
|
||||
@ -174,7 +176,9 @@ def instance_info(since, include_hostnames=False):
|
||||
'memory': instance['memory'],
|
||||
'managed_by_policy': instance['managed_by_policy'],
|
||||
'last_isolated_check': _get_isolated_datetime(instance['last_isolated_check']),
|
||||
'enabled': instance['enabled']
|
||||
'enabled': instance['enabled'],
|
||||
'consumed_capacity': consumed_capacity,
|
||||
'remaining_capacity': instance['capacity'] - consumed_capacity
|
||||
}
|
||||
if include_hostnames is True:
|
||||
instance_info['hostname'] = instance['hostname']
|
||||
|
||||
@ -46,6 +46,8 @@ INSTANCE_MEMORY = Gauge('awx_instance_memory', 'RAM (Kb) on each node in a Tower
|
||||
INSTANCE_INFO = Info('awx_instance', 'Info about each node in a Tower system', ['hostname', 'instance_uuid',])
|
||||
INSTANCE_LAUNCH_TYPE = Gauge('awx_instance_launch_type_total', 'Type of Job launched', ['node', 'launch_type',])
|
||||
INSTANCE_STATUS = Gauge('awx_instance_status_total', 'Status of Job launched', ['node', 'status',])
|
||||
INSTANCE_CONSUMED_CAPACITY = Gauge('awx_instance_consumed_capacity', 'Consumed capacity of each node in a Tower system', ['hostname', 'instance_uuid',])
|
||||
INSTANCE_REMAINING_CAPACITY = Gauge('awx_instance_remaining_capacity', 'Remaining capacity of each node in a Tower system', ['hostname', 'instance_uuid',])
|
||||
|
||||
LICENSE_INSTANCE_TOTAL = Gauge('awx_license_instance_total', 'Total number of managed hosts provided by your license')
|
||||
LICENSE_INSTANCE_FREE = Gauge('awx_license_instance_free', 'Number of remaining managed hosts provided by your license')
|
||||
@ -104,6 +106,8 @@ def metrics():
|
||||
INSTANCE_CAPACITY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['capacity'])
|
||||
INSTANCE_CPU.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['cpu'])
|
||||
INSTANCE_MEMORY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['memory'])
|
||||
INSTANCE_CONSUMED_CAPACITY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['consumed_capacity'])
|
||||
INSTANCE_REMAINING_CAPACITY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['remaining_capacity'])
|
||||
INSTANCE_INFO.labels(hostname=hostname, instance_uuid=uuid).info({
|
||||
'enabled': str(instance_data[uuid]['enabled']),
|
||||
'last_isolated_check': getattr(instance_data[uuid], 'last_isolated_check', 'None'),
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
from django.apps import AppConfig
|
||||
from django.db.models.signals import pre_migrate
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
def raise_migration_flag(**kwargs):
|
||||
from awx.main.tasks import set_migration_flag
|
||||
set_migration_flag.delay()
|
||||
|
||||
|
||||
class MainConfig(AppConfig):
|
||||
|
||||
name = 'awx.main'
|
||||
verbose_name = _('Main')
|
||||
|
||||
def ready(self):
|
||||
pre_migrate.connect(raise_migration_flag, sender=self)
|
||||
|
||||
129
awx/main/management/commands/regenerate_secret_key.py
Normal file
129
awx/main/management/commands/regenerate_secret_key.py
Normal 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"])
|
||||
@ -13,8 +13,7 @@ import urllib.parse
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models.signals import post_save
|
||||
from django.db.migrations.executor import MigrationExecutor
|
||||
from django.db import IntegrityError, connection
|
||||
from django.db import IntegrityError
|
||||
from django.utils.functional import curry
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.apps import apps
|
||||
@ -24,6 +23,7 @@ from django.urls import reverse, resolve
|
||||
|
||||
from awx.main.models import ActivityStream
|
||||
from awx.main.utils.named_url_graph import generate_graph, GraphNode
|
||||
from awx.main.utils.db import migration_in_progress_check_or_relase
|
||||
from awx.conf import fields, register
|
||||
|
||||
|
||||
@ -213,8 +213,7 @@ class URLModificationMiddleware(MiddlewareMixin):
|
||||
class MigrationRanCheckMiddleware(MiddlewareMixin):
|
||||
|
||||
def process_request(self, request):
|
||||
executor = MigrationExecutor(connection)
|
||||
plan = executor.migration_plan(executor.loader.graph.leaf_nodes())
|
||||
if bool(plan) and \
|
||||
getattr(resolve(request.path), 'url_name', '') != 'migrations_notran':
|
||||
if migration_in_progress_check_or_relase():
|
||||
if getattr(resolve(request.path), 'url_name', '') == 'migrations_notran':
|
||||
return
|
||||
return redirect(reverse("ui:migrations_notran"))
|
||||
|
||||
@ -634,7 +634,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
||||
else:
|
||||
# If for some reason we can't count the hosts then lets assume the impact as forks
|
||||
if self.inventory is not None:
|
||||
count_hosts = self.inventory.hosts.count()
|
||||
count_hosts = self.inventory.total_hosts
|
||||
if self.job_slice_count > 1:
|
||||
# Integer division intentional
|
||||
count_hosts = (count_hosts + self.job_slice_count - self.job_slice_number) // self.job_slice_count
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
# Python
|
||||
import logging
|
||||
import re
|
||||
|
||||
# Django
|
||||
@ -22,6 +23,9 @@ DATA_URI_RE = re.compile(r'.*') # FIXME
|
||||
__all__ = ['OAuth2AccessToken', 'OAuth2Application']
|
||||
|
||||
|
||||
logger = logging.getLogger('awx.main.models.oauth')
|
||||
|
||||
|
||||
class OAuth2Application(AbstractApplication):
|
||||
|
||||
class Meta:
|
||||
@ -120,15 +124,27 @@ class OAuth2AccessToken(AbstractAccessToken):
|
||||
def is_valid(self, scopes=None):
|
||||
valid = super(OAuth2AccessToken, self).is_valid(scopes)
|
||||
if valid:
|
||||
try:
|
||||
self.validate_external_users()
|
||||
except oauth2.AccessDeniedError:
|
||||
logger.exception(f'Failed to authenticate {self.user.username}')
|
||||
return False
|
||||
self.last_used = now()
|
||||
connection.on_commit(lambda: self.save(update_fields=['last_used']))
|
||||
|
||||
def _update_last_used():
|
||||
if OAuth2AccessToken.objects.filter(pk=self.pk).exists():
|
||||
self.save(update_fields=['last_used'])
|
||||
connection.on_commit(_update_last_used)
|
||||
return valid
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
def validate_external_users(self):
|
||||
if self.user and settings.ALLOW_OAUTH2_FOR_EXTERNAL_USERS is False:
|
||||
external_account = get_external_account(self.user)
|
||||
if external_account is not None:
|
||||
raise oauth2.AccessDeniedError(_(
|
||||
'OAuth2 Tokens cannot be created by users associated with an external authentication provider ({})'
|
||||
).format(external_account))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.validate_external_users()
|
||||
super(OAuth2AccessToken, self).save(*args, **kwargs)
|
||||
|
||||
@ -6,15 +6,24 @@ class CustomNotificationBase(object):
|
||||
DEFAULT_MSG = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}"
|
||||
DEFAULT_BODY = "{{ job_friendly_name }} #{{ job.id }} had status {{ job.status }}, view details at {{ url }}\n\n{{ job_metadata }}"
|
||||
|
||||
DEFAULT_APPROVAL_RUNNING_MSG = 'The approval node "{{ approval_node_name }}" needs review. This node can be viewed at: {{ workflow_url }}'
|
||||
DEFAULT_APPROVAL_RUNNING_BODY = ('The approval node "{{ approval_node_name }}" needs review. '
|
||||
'This approval node can be viewed at: {{ workflow_url }}\n\n{{ job_metadata }}')
|
||||
|
||||
DEFAULT_APPROVAL_APPROVED_MSG = 'The approval node "{{ approval_node_name }}" was approved. {{ workflow_url }}'
|
||||
DEFAULT_APPROVAL_APPROVED_BODY = 'The approval node "{{ approval_node_name }}" was approved. {{ workflow_url }}\n\n{{ job_metadata }}'
|
||||
|
||||
DEFAULT_APPROVAL_TIMEOUT_MSG = 'The approval node "{{ approval_node_name }}" has timed out. {{ workflow_url }}'
|
||||
DEFAULT_APPROVAL_TIMEOUT_BODY = 'The approval node "{{ approval_node_name }}" has timed out. {{ workflow_url }}\n\n{{ job_metadata }}'
|
||||
|
||||
DEFAULT_APPROVAL_DENIED_MSG = 'The approval node "{{ approval_node_name }}" was denied. {{ workflow_url }}'
|
||||
DEFAULT_APPROVAL_DENIED_BODY = 'The approval node "{{ approval_node_name }}" was denied. {{ workflow_url }}\n\n{{ job_metadata }}'
|
||||
|
||||
|
||||
default_messages = {"started": {"message": DEFAULT_MSG, "body": None},
|
||||
"success": {"message": DEFAULT_MSG, "body": None},
|
||||
"error": {"message": DEFAULT_MSG, "body": None},
|
||||
"workflow_approval": {"running": {"message": 'The approval node "{{ approval_node_name }}" needs review. '
|
||||
'This node can be viewed at: {{ workflow_url }}',
|
||||
"body": None},
|
||||
"approved": {"message": 'The approval node "{{ approval_node_name }}" was approved. {{ workflow_url }}',
|
||||
"body": None},
|
||||
"timed_out": {"message": 'The approval node "{{ approval_node_name }}" has timed out. {{ workflow_url }}',
|
||||
"body": None},
|
||||
"denied": {"message": 'The approval node "{{ approval_node_name }}" was denied. {{ workflow_url }}',
|
||||
"body": None}}}
|
||||
"workflow_approval": {"running": {"message": DEFAULT_APPROVAL_RUNNING_MSG, "body": None},
|
||||
"approved": {"message": DEFAULT_APPROVAL_APPROVED_MSG, "body": None},
|
||||
"timed_out": {"message": DEFAULT_APPROVAL_TIMEOUT_MSG, "body": None},
|
||||
"denied": {"message": DEFAULT_APPROVAL_DENIED_MSG, "body": None}}}
|
||||
|
||||
@ -8,6 +8,18 @@ from awx.main.notifications.custom_notification_base import CustomNotificationBa
|
||||
DEFAULT_MSG = CustomNotificationBase.DEFAULT_MSG
|
||||
DEFAULT_BODY = CustomNotificationBase.DEFAULT_BODY
|
||||
|
||||
DEFAULT_APPROVAL_RUNNING_MSG = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_MSG
|
||||
DEFAULT_APPROVAL_RUNNING_BODY = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_BODY
|
||||
|
||||
DEFAULT_APPROVAL_APPROVED_MSG = CustomNotificationBase.DEFAULT_APPROVAL_APPROVED_MSG
|
||||
DEFAULT_APPROVAL_APPROVED_BODY = CustomNotificationBase.DEFAULT_APPROVAL_APPROVED_BODY
|
||||
|
||||
DEFAULT_APPROVAL_TIMEOUT_MSG = CustomNotificationBase.DEFAULT_APPROVAL_TIMEOUT_MSG
|
||||
DEFAULT_APPROVAL_TIMEOUT_BODY = CustomNotificationBase.DEFAULT_APPROVAL_TIMEOUT_BODY
|
||||
|
||||
DEFAULT_APPROVAL_DENIED_MSG = CustomNotificationBase.DEFAULT_APPROVAL_DENIED_MSG
|
||||
DEFAULT_APPROVAL_DENIED_BODY = CustomNotificationBase.DEFAULT_APPROVAL_DENIED_BODY
|
||||
|
||||
|
||||
class CustomEmailBackend(EmailBackend, CustomNotificationBase):
|
||||
|
||||
@ -26,10 +38,10 @@ class CustomEmailBackend(EmailBackend, CustomNotificationBase):
|
||||
default_messages = {"started": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
|
||||
"success": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
|
||||
"error": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
|
||||
"workflow_approval": {"running": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
|
||||
"approved": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
|
||||
"timed_out": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
|
||||
"denied": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}}}
|
||||
"workflow_approval": {"running": {"message": DEFAULT_APPROVAL_RUNNING_MSG, "body": DEFAULT_APPROVAL_RUNNING_BODY},
|
||||
"approved": {"message": DEFAULT_APPROVAL_APPROVED_MSG, "body": DEFAULT_APPROVAL_APPROVED_BODY},
|
||||
"timed_out": {"message": DEFAULT_APPROVAL_TIMEOUT_MSG, "body": DEFAULT_APPROVAL_TIMEOUT_BODY},
|
||||
"denied": {"message": DEFAULT_APPROVAL_DENIED_MSG, "body": DEFAULT_APPROVAL_DENIED_BODY}}}
|
||||
|
||||
def format_body(self, body):
|
||||
# leave body unchanged (expect a string)
|
||||
|
||||
@ -15,7 +15,6 @@ class DependencyGraph(object):
|
||||
INVENTORY_UPDATES = 'inventory_updates'
|
||||
|
||||
JOB_TEMPLATE_JOBS = 'job_template_jobs'
|
||||
JOB_PROJECT_IDS = 'job_project_ids'
|
||||
JOB_INVENTORY_IDS = 'job_inventory_ids'
|
||||
|
||||
SYSTEM_JOB = 'system_job'
|
||||
@ -41,8 +40,6 @@ class DependencyGraph(object):
|
||||
Track runnable job related project and inventory to ensure updates
|
||||
don't run while a job needing those resources is running.
|
||||
'''
|
||||
# project_id -> True / False
|
||||
self.data[self.JOB_PROJECT_IDS] = {}
|
||||
# inventory_id -> True / False
|
||||
self.data[self.JOB_INVENTORY_IDS] = {}
|
||||
|
||||
@ -66,7 +63,7 @@ class DependencyGraph(object):
|
||||
|
||||
def get_now(self):
|
||||
return tz_now()
|
||||
|
||||
|
||||
def mark_system_job(self):
|
||||
self.data[self.SYSTEM_JOB] = False
|
||||
|
||||
@ -81,15 +78,13 @@ class DependencyGraph(object):
|
||||
|
||||
def mark_job_template_job(self, job):
|
||||
self.data[self.JOB_INVENTORY_IDS][job.inventory_id] = False
|
||||
self.data[self.JOB_PROJECT_IDS][job.project_id] = False
|
||||
self.data[self.JOB_TEMPLATE_JOBS][job.job_template_id] = False
|
||||
|
||||
def mark_workflow_job(self, job):
|
||||
self.data[self.WORKFLOW_JOB_TEMPLATES_JOBS][job.workflow_job_template_id] = False
|
||||
|
||||
def can_project_update_run(self, job):
|
||||
return self.data[self.JOB_PROJECT_IDS].get(job.project_id, True) and \
|
||||
self.data[self.PROJECT_UPDATES].get(job.project_id, True)
|
||||
return self.data[self.PROJECT_UPDATES].get(job.project_id, True)
|
||||
|
||||
def can_inventory_update_run(self, job):
|
||||
return self.data[self.JOB_INVENTORY_IDS].get(job.inventory_source.inventory_id, True) and \
|
||||
|
||||
@ -5,11 +5,15 @@ import logging
|
||||
# AWX
|
||||
from awx.main.scheduler import TaskManager
|
||||
from awx.main.dispatch.publish import task
|
||||
from awx.main.utils.db import migration_in_progress_check_or_relase
|
||||
|
||||
logger = logging.getLogger('awx.main.scheduler')
|
||||
|
||||
|
||||
@task()
|
||||
def run_task_manager():
|
||||
if migration_in_progress_check_or_relase():
|
||||
logger.debug("Not running task manager because migration is in progress.")
|
||||
return
|
||||
logger.debug("Running Tower task manager.")
|
||||
TaskManager().schedule()
|
||||
|
||||
@ -263,6 +263,12 @@ def apply_cluster_membership_policies():
|
||||
logger.debug('Cluster policy computation finished in {} seconds'.format(time.time() - started_compute))
|
||||
|
||||
|
||||
@task(queue='tower_broadcast_all', exchange_type='fanout')
|
||||
def set_migration_flag():
|
||||
logger.debug('Received migration-in-progress signal, will serve redirect.')
|
||||
cache.set('migration_in_progress', True)
|
||||
|
||||
|
||||
@task(queue='tower_broadcast_all', exchange_type='fanout')
|
||||
def handle_setting_changes(setting_keys):
|
||||
orig_len = len(setting_keys)
|
||||
@ -2183,7 +2189,10 @@ class RunProjectUpdate(BaseTask):
|
||||
project_path = instance.project.get_project_path(check_if_exists=False)
|
||||
if os.path.exists(project_path):
|
||||
git_repo = git.Repo(project_path)
|
||||
self.original_branch = git_repo.active_branch
|
||||
if git_repo.head.is_detached:
|
||||
self.original_branch = git_repo.head.commit
|
||||
else:
|
||||
self.original_branch = git_repo.active_branch
|
||||
|
||||
@staticmethod
|
||||
def make_local_copy(project_path, destination_folder, scm_type, scm_revision):
|
||||
|
||||
@ -25,6 +25,8 @@ EXPECTED_VALUES = {
|
||||
'awx_custom_virtualenvs_total':0.0,
|
||||
'awx_running_jobs_total':0.0,
|
||||
'awx_instance_capacity':100.0,
|
||||
'awx_instance_consumed_capacity':0.0,
|
||||
'awx_instance_remaining_capacity':100.0,
|
||||
'awx_instance_cpu':0.0,
|
||||
'awx_instance_memory':0.0,
|
||||
'awx_instance_info':1.0,
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import pytest
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.db import connection
|
||||
from django.test.utils import override_settings
|
||||
@ -14,6 +16,18 @@ from awx.sso.models import UserEnterpriseAuth
|
||||
from oauth2_provider.models import RefreshToken
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def immediate_on_commit():
|
||||
"""
|
||||
Context manager executing transaction.on_commit() hooks immediately as
|
||||
if the connection was in auto-commit mode.
|
||||
"""
|
||||
def on_commit(func):
|
||||
func()
|
||||
with mock.patch('django.db.connection.on_commit', side_effect=on_commit) as patch:
|
||||
yield patch
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_personal_access_token_creation(oauth_application, post, alice):
|
||||
url = drf_reverse('api:oauth_authorization_root_view') + 'token/'
|
||||
@ -54,6 +68,41 @@ def test_token_creation_disabled_for_external_accounts(oauth_application, post,
|
||||
assert AccessToken.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_existing_token_disabled_for_external_accounts(oauth_application, get, post, admin):
|
||||
UserEnterpriseAuth(user=admin, provider='radius').save()
|
||||
url = drf_reverse('api:oauth_authorization_root_view') + 'token/'
|
||||
with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USERS=True):
|
||||
resp = post(
|
||||
url,
|
||||
data='grant_type=password&username=admin&password=admin&scope=read',
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([
|
||||
oauth_application.client_id, oauth_application.client_secret
|
||||
])))),
|
||||
status=201
|
||||
)
|
||||
token = json.loads(resp.content)['access_token']
|
||||
assert AccessToken.objects.count() == 1
|
||||
|
||||
with immediate_on_commit():
|
||||
resp = get(
|
||||
drf_reverse('api:user_me_list', kwargs={'version': 'v2'}),
|
||||
HTTP_AUTHORIZATION='Bearer ' + token,
|
||||
status=200
|
||||
)
|
||||
assert json.loads(resp.content)['results'][0]['username'] == 'admin'
|
||||
|
||||
with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USER=False):
|
||||
with immediate_on_commit():
|
||||
resp = get(
|
||||
drf_reverse('api:user_me_list', kwargs={'version': 'v2'}),
|
||||
HTTP_AUTHORIZATION='Bearer ' + token,
|
||||
status=401
|
||||
)
|
||||
assert b'To establish a login session' in resp.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_pat_creation_no_default_scope(oauth_application, post, admin):
|
||||
# tests that the default scope is overriden
|
||||
|
||||
@ -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
|
||||
@ -122,6 +122,22 @@ def project_playbooks():
|
||||
mocked.start()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_computed_fields_right_away(request):
|
||||
|
||||
def run_me(inventory_id, should_update_hosts=True):
|
||||
i = Inventory.objects.get(id=inventory_id)
|
||||
i.update_computed_fields(update_hosts=should_update_hosts)
|
||||
|
||||
mocked = mock.patch(
|
||||
'awx.main.signals.update_inventory_computed_fields.delay',
|
||||
new=run_me
|
||||
)
|
||||
mocked.start()
|
||||
|
||||
request.addfinalizer(mocked.stop)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@mock.patch.object(Project, "update", lambda self, **kwargs: None)
|
||||
def project(instance, organization):
|
||||
|
||||
@ -281,15 +281,18 @@ class TestTaskImpact:
|
||||
return job
|
||||
return r
|
||||
|
||||
def test_limit_task_impact(self, job_host_limit):
|
||||
def test_limit_task_impact(self, job_host_limit, run_computed_fields_right_away):
|
||||
job = job_host_limit(5, 2)
|
||||
job.inventory.refresh_from_db() # FIXME: computed fields operates on reloaded inventory
|
||||
assert job.inventory.total_hosts == 5
|
||||
assert job.task_impact == 2 + 1 # forks becomes constraint
|
||||
|
||||
def test_host_task_impact(self, job_host_limit):
|
||||
def test_host_task_impact(self, job_host_limit, run_computed_fields_right_away):
|
||||
job = job_host_limit(3, 5)
|
||||
job.inventory.refresh_from_db() # FIXME: computed fields operates on reloaded inventory
|
||||
assert job.task_impact == 3 + 1 # hosts becomes constraint
|
||||
|
||||
def test_shard_task_impact(self, slice_job_factory):
|
||||
def test_shard_task_impact(self, slice_job_factory, run_computed_fields_right_away):
|
||||
# factory creates on host per slice
|
||||
workflow_job = slice_job_factory(3, jt_kwargs={'forks': 50}, spawn=True)
|
||||
# arrange the jobs by their number
|
||||
@ -308,4 +311,5 @@ class TestTaskImpact:
|
||||
len(jobs[0].inventory.get_script_data(slice_number=i + 1, slice_count=3)['all']['hosts'])
|
||||
for i in range(3)
|
||||
] == [2, 1, 1]
|
||||
jobs[0].inventory.refresh_from_db() # FIXME: computed fields operates on reloaded inventory
|
||||
assert [job.task_impact for job in jobs] == [3, 2, 2]
|
||||
|
||||
@ -4,6 +4,7 @@ import json
|
||||
from datetime import timedelta
|
||||
|
||||
from awx.main.scheduler import TaskManager
|
||||
from awx.main.scheduler.dependency_graph import DependencyGraph
|
||||
from awx.main.utils import encrypt_field
|
||||
from awx.main.models import WorkflowJobTemplate, JobTemplate
|
||||
|
||||
@ -326,3 +327,29 @@ def test_shared_dependencies_launch(default_instance_group, job_template_factory
|
||||
iu = [x for x in ii.inventory_updates.all()]
|
||||
assert len(pu) == 1
|
||||
assert len(iu) == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_job_not_blocking_project_update(default_instance_group, job_template_factory):
|
||||
objects = job_template_factory('jt', organization='org1', project='proj',
|
||||
inventory='inv', credential='cred',
|
||||
jobs=["job"])
|
||||
job = objects.jobs["job"]
|
||||
job.instance_group = default_instance_group
|
||||
job.status = "running"
|
||||
job.save()
|
||||
|
||||
with mock.patch("awx.main.scheduler.TaskManager.start_task"):
|
||||
task_manager = TaskManager()
|
||||
task_manager._schedule()
|
||||
|
||||
proj = objects.project
|
||||
project_update = proj.create_project_update()
|
||||
project_update.instance_group = default_instance_group
|
||||
project_update.status = "pending"
|
||||
project_update.save()
|
||||
assert not task_manager.is_job_blocked(project_update)
|
||||
|
||||
dependency_graph = DependencyGraph(None)
|
||||
dependency_graph.add_job(job)
|
||||
assert not dependency_graph.is_job_blocked(project_update)
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
# Copyright (c) 2017 Ansible by Red Hat
|
||||
# All Rights Reserved.
|
||||
|
||||
import logging
|
||||
from itertools import chain
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.migrations.executor import MigrationExecutor
|
||||
from django.db import connection
|
||||
|
||||
|
||||
logger = logging.getLogger('awx.main.utils.db')
|
||||
|
||||
|
||||
def get_all_field_names(model):
|
||||
# Implements compatibility with _meta.get_all_field_names
|
||||
@ -14,3 +22,21 @@ def get_all_field_names(model):
|
||||
# GenericForeignKey from the results.
|
||||
if not (field.many_to_one and field.related_model is None)
|
||||
)))
|
||||
|
||||
|
||||
def migration_in_progress_check_or_relase():
|
||||
'''A memcache flag is raised (set to True) to inform cluster
|
||||
that a migration is ongoing see main.apps.MainConfig.ready
|
||||
if the flag is True then the flag is removed on this instance if
|
||||
models-db consistency is observed
|
||||
effective value of migration flag is returned
|
||||
'''
|
||||
migration_in_progress = cache.get('migration_in_progress', False)
|
||||
if migration_in_progress:
|
||||
executor = MigrationExecutor(connection)
|
||||
plan = executor.migration_plan(executor.loader.graph.leaf_nodes())
|
||||
if not bool(plan):
|
||||
logger.info('Detected that migration finished, migration flag taken down.')
|
||||
cache.delete('migration_in_progress')
|
||||
migration_in_progress = False
|
||||
return migration_in_progress
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
@ -35,7 +37,7 @@ class Fernet256(Fernet):
|
||||
self._backend = backend
|
||||
|
||||
|
||||
def get_encryption_key(field_name, pk=None):
|
||||
def get_encryption_key(field_name, pk=None, secret_key=None):
|
||||
'''
|
||||
Generate key for encrypted password based on field name,
|
||||
``settings.SECRET_KEY``, and instance pk (if available).
|
||||
@ -46,19 +48,58 @@ def get_encryption_key(field_name, pk=None):
|
||||
'''
|
||||
from django.conf import settings
|
||||
h = hashlib.sha512()
|
||||
h.update(smart_bytes(settings.SECRET_KEY))
|
||||
h.update(smart_bytes(secret_key or settings.SECRET_KEY))
|
||||
if pk is not None:
|
||||
h.update(smart_bytes(str(pk)))
|
||||
h.update(smart_bytes(field_name))
|
||||
return base64.urlsafe_b64encode(h.digest())
|
||||
|
||||
|
||||
def encrypt_value(value, pk=None):
|
||||
def encrypt_value(value, pk=None, secret_key=None):
|
||||
#
|
||||
# ⚠️ D-D-D-DANGER ZONE ⚠️
|
||||
#
|
||||
# !!! BEFORE USING THIS FUNCTION PLEASE READ encrypt_field !!!
|
||||
#
|
||||
TransientField = namedtuple('TransientField', ['pk', 'value'])
|
||||
return encrypt_field(TransientField(pk=pk, value=value), 'value')
|
||||
return encrypt_field(TransientField(pk=pk, value=value), 'value', secret_key=secret_key)
|
||||
|
||||
|
||||
def encrypt_field(instance, field_name, ask=False, subfield=None):
|
||||
def encrypt_field(instance, field_name, ask=False, subfield=None, secret_key=None):
|
||||
#
|
||||
# ⚠️ D-D-D-DANGER ZONE ⚠️
|
||||
#
|
||||
# !!! PLEASE READ BEFORE USING THIS FUNCTION ANYWHERE !!!
|
||||
#
|
||||
# You should know that this function is used in various places throughout
|
||||
# AWX for symmetric encryption - generally it's used to encrypt sensitive
|
||||
# values that we store in the AWX database (such as SSH private keys for
|
||||
# credentials).
|
||||
#
|
||||
# If you're reading this function's code because you're thinking about
|
||||
# using it to encrypt *something new*, please remember that AWX has
|
||||
# official support for *regenerating* the SECRET_KEY (on which the
|
||||
# symmetric key is based):
|
||||
#
|
||||
# $ awx-manage regenerate_secret_key
|
||||
# $ setup.sh -k
|
||||
#
|
||||
# ...so you'll need to *also* add code to support the
|
||||
# migration/re-encryption of these values (the code in question lives in
|
||||
# `awx.main.management.commands.regenerate_secret_key`):
|
||||
#
|
||||
# For example, if you find that you're adding a new database column that is
|
||||
# encrypted, in addition to calling `encrypt_field` in the appropriate
|
||||
# places, you would also need to update the `awx-manage regenerate_secret_key`
|
||||
# so that values are properly migrated when the SECRET_KEY changes.
|
||||
#
|
||||
# This process *generally* involves adding Python code to the
|
||||
# `regenerate_secret_key` command, i.e.,
|
||||
#
|
||||
# 1. Query the database for existing encrypted values on the appropriate object(s)
|
||||
# 2. Decrypting them using the *old* SECRET_KEY
|
||||
# 3. Storing newly encrypted values using the *newly generated* SECRET_KEY
|
||||
#
|
||||
'''
|
||||
Return content of the given instance and field name encrypted.
|
||||
'''
|
||||
@ -76,7 +117,11 @@ def encrypt_field(instance, field_name, ask=False, subfield=None):
|
||||
value = smart_str(value)
|
||||
if not value or value.startswith('$encrypted$') or (ask and value == 'ASK'):
|
||||
return value
|
||||
key = get_encryption_key(field_name, getattr(instance, 'pk', None))
|
||||
key = get_encryption_key(
|
||||
field_name,
|
||||
getattr(instance, 'pk', None),
|
||||
secret_key=secret_key
|
||||
)
|
||||
f = Fernet256(key)
|
||||
encrypted = f.encrypt(smart_bytes(value))
|
||||
b64data = smart_str(base64.b64encode(encrypted))
|
||||
@ -99,7 +144,7 @@ def decrypt_value(encryption_key, value):
|
||||
return smart_str(value)
|
||||
|
||||
|
||||
def decrypt_field(instance, field_name, subfield=None):
|
||||
def decrypt_field(instance, field_name, subfield=None, secret_key=None):
|
||||
'''
|
||||
Return content of the given instance and field name decrypted.
|
||||
'''
|
||||
@ -115,7 +160,11 @@ def decrypt_field(instance, field_name, subfield=None):
|
||||
value = smart_str(value)
|
||||
if not value or not value.startswith('$encrypted$'):
|
||||
return value
|
||||
key = get_encryption_key(field_name, getattr(instance, 'pk', None))
|
||||
key = get_encryption_key(
|
||||
field_name,
|
||||
getattr(instance, 'pk', None),
|
||||
secret_key=secret_key
|
||||
)
|
||||
|
||||
try:
|
||||
return smart_str(decrypt_value(key, value))
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
import json
|
||||
|
||||
# Django
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
@ -97,3 +97,6 @@ def handle_csp_violation(request):
|
||||
logger = logging.getLogger('awx')
|
||||
logger.error(json.loads(request.body))
|
||||
return HttpResponse(content=None)
|
||||
|
||||
def handle_login_redirect(request):
|
||||
return HttpResponseRedirect("/#/login")
|
||||
|
||||
@ -15,7 +15,9 @@
|
||||
ignore_errors: true
|
||||
|
||||
- name: remove build artifacts
|
||||
file: path="{{item}}" state=absent
|
||||
file:
|
||||
path: '{{item}}'
|
||||
state: absent
|
||||
register: result
|
||||
with_items: "{{cleanup_dirs}}"
|
||||
until: result is succeeded
|
||||
|
||||
@ -115,7 +115,8 @@
|
||||
- update_insights
|
||||
|
||||
- name: Repository Version
|
||||
debug: msg="Repository Version {{ scm_version }}"
|
||||
debug:
|
||||
msg: "Repository Version {{ scm_version }}"
|
||||
tags:
|
||||
- update_git
|
||||
- update_hg
|
||||
@ -130,7 +131,8 @@
|
||||
|
||||
- block:
|
||||
- name: detect requirements.yml
|
||||
stat: path={{project_path|quote}}/roles/requirements.yml
|
||||
stat:
|
||||
path: '{{project_path|quote}}/roles/requirements.yml'
|
||||
register: doesRequirementsExist
|
||||
|
||||
- name: fetch galaxy roles from requirements.yml
|
||||
@ -149,7 +151,8 @@
|
||||
|
||||
- block:
|
||||
- name: detect collections/requirements.yml
|
||||
stat: path={{project_path|quote}}/collections/requirements.yml
|
||||
stat:
|
||||
path: '{{project_path|quote}}/collections/requirements.yml'
|
||||
register: doesCollectionRequirementsExist
|
||||
|
||||
- name: fetch galaxy collections from collections/requirements.yml
|
||||
|
||||
@ -373,6 +373,10 @@ TACACSPLUS_AUTH_PROTOCOL = 'ascii'
|
||||
# Note: This setting may be overridden by database settings.
|
||||
AUTH_BASIC_ENABLED = True
|
||||
|
||||
# If set, specifies a URL that unauthenticated users will be redirected to
|
||||
# when trying to access a UI page that requries authentication.
|
||||
LOGIN_REDIRECT_OVERRIDE = None
|
||||
|
||||
# If set, serve only minified JS for UI.
|
||||
USE_MINIFIED_JS = False
|
||||
|
||||
|
||||
@ -39,7 +39,7 @@ function getStatusDetails (jobStatus) {
|
||||
value = choices[unmapped];
|
||||
}
|
||||
|
||||
return { label, icon, value };
|
||||
return { unmapped, label, icon, value };
|
||||
}
|
||||
|
||||
function getStartDetails (started) {
|
||||
|
||||
@ -12,9 +12,9 @@
|
||||
class="List-actionButton List-actionButton--delete"
|
||||
data-placement="top"
|
||||
ng-click="vm.cancelJob()"
|
||||
ng-show="vm.status.value === 'Pending' ||
|
||||
vm.status.value === 'Waiting' ||
|
||||
vm.status.value === 'Running'"
|
||||
ng-show="vm.status.unmapped === 'pending' ||
|
||||
vm.status.unmapped === 'waiting' ||
|
||||
vm.status.unmapped === 'running'"
|
||||
aw-tool-tip="{{:: vm.strings.get('tooltips.CANCEL') }}"
|
||||
data-original-title=""
|
||||
title="">
|
||||
@ -27,11 +27,11 @@
|
||||
data-placement="top"
|
||||
ng-click="vm.deleteJob()"
|
||||
ng-show="vm.canDelete && (
|
||||
vm.status.value === 'New' ||
|
||||
vm.status.value === 'Successful' ||
|
||||
vm.status.value === 'Failed' ||
|
||||
vm.status.value === 'Error' ||
|
||||
vm.status.value === 'Canceled')"
|
||||
vm.status.unmapped === 'new' ||
|
||||
vm.status.unmapped === 'successful' ||
|
||||
vm.status.unmapped === 'failed' ||
|
||||
vm.status.unmapped === 'error' ||
|
||||
vm.status.unmapped === 'canceled')"
|
||||
aw-tool-tip="{{:: vm.strings.get('tooltips.DELETE') }}"
|
||||
data-original-title=""
|
||||
title="">
|
||||
|
||||
@ -3,6 +3,8 @@ global.$AnsibleConfig = null;
|
||||
// Provided via Webpack DefinePlugin in webpack.config.js
|
||||
global.$ENV = {};
|
||||
|
||||
global.$ConfigResponse = {};
|
||||
|
||||
var urlPrefix;
|
||||
|
||||
if ($basePath) {
|
||||
@ -383,7 +385,11 @@ angular
|
||||
var stime = timestammp[lastUser.id].time,
|
||||
now = new Date().getTime();
|
||||
if ((stime - now) <= 0) {
|
||||
$location.path('/login');
|
||||
if (global.$AnsibleConfig.login_redirect_override) {
|
||||
window.location.replace(global.$AnsibleConfig.login_redirect_override);
|
||||
} else {
|
||||
$location.path('/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
// If browser refresh, set the user_is_superuser value
|
||||
|
||||
@ -15,7 +15,9 @@ function bootstrap (callback) {
|
||||
angular.module('I18N').constant('LOCALE', locale);
|
||||
}
|
||||
|
||||
angular.element(document).ready(() => callback());
|
||||
fetchConfig(() => {
|
||||
angular.element(document).ready(() => callback());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -49,6 +51,25 @@ function fetchLocaleStrings (callback) {
|
||||
request.fail(() => callback({ code: DEFAULT_LOCALE }));
|
||||
}
|
||||
|
||||
function fetchConfig (callback) {
|
||||
const request = $.ajax('/api/');
|
||||
|
||||
request.done(res => {
|
||||
global.$ConfigResponse = res;
|
||||
if (res.login_redirect_override) {
|
||||
if (!document.cookie.split(';').filter((item) => item.includes('userLoggedIn=true')).length && !window.location.href.includes('/#/login')) {
|
||||
window.location.replace(res.login_redirect_override);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
request.fail(() => callback());
|
||||
}
|
||||
|
||||
/**
|
||||
* Grabs the language off of navigator for browser compatibility.
|
||||
* If the language isn't set, then it falls back to the DEFAULT_LOCALE. The
|
||||
|
||||
@ -40,6 +40,10 @@ export default ['i18n', function(i18n) {
|
||||
ALLOW_OAUTH2_FOR_EXTERNAL_USERS: {
|
||||
type: 'toggleSwitch',
|
||||
},
|
||||
LOGIN_REDIRECT_OVERRIDE: {
|
||||
type: 'text',
|
||||
reset: 'LOGIN_REDIRECT_OVERRIDE'
|
||||
},
|
||||
ACCESS_TOKEN_EXPIRE_SECONDS: {
|
||||
type: 'text',
|
||||
reset: 'ACCESS_TOKEN_EXPIRE_SECONDS'
|
||||
|
||||
@ -1,55 +1,40 @@
|
||||
export default
|
||||
function LoadConfig($log, $rootScope, $http, Store) {
|
||||
function LoadConfig($rootScope, Store) {
|
||||
return function() {
|
||||
|
||||
|
||||
var configSettings = {};
|
||||
|
||||
var configInit = function() {
|
||||
// Auto-resolving what used to be found when attempting to load local_setting.json
|
||||
if ($rootScope.loginConfig) {
|
||||
$rootScope.loginConfig.resolve('config loaded');
|
||||
}
|
||||
$rootScope.$emit('ConfigReady');
|
||||
if(global.$ConfigResponse.custom_logo) {
|
||||
configSettings.custom_logo = true;
|
||||
$rootScope.custom_logo = global.$ConfigResponse.custom_logo;
|
||||
} else {
|
||||
configSettings.custom_logo = false;
|
||||
}
|
||||
|
||||
// Load new hardcoded settings from above
|
||||
if(global.$ConfigResponse.custom_login_info) {
|
||||
configSettings.custom_login_info = global.$ConfigResponse.custom_login_info;
|
||||
$rootScope.custom_login_info = global.$ConfigResponse.custom_login_info;
|
||||
} else {
|
||||
configSettings.custom_login_info = false;
|
||||
}
|
||||
|
||||
global.$AnsibleConfig = configSettings;
|
||||
Store('AnsibleConfig', global.$AnsibleConfig);
|
||||
$rootScope.$emit('LoadConfig');
|
||||
};
|
||||
if (global.$ConfigResponse.login_redirect_override) {
|
||||
configSettings.login_redirect_override = global.$ConfigResponse.login_redirect_override;
|
||||
}
|
||||
|
||||
// Retrieve the custom logo information - update configSettings from above
|
||||
$http({
|
||||
method: 'GET',
|
||||
url: '/api/',
|
||||
})
|
||||
.then(function({data}) {
|
||||
if(data.custom_logo) {
|
||||
configSettings.custom_logo = true;
|
||||
$rootScope.custom_logo = data.custom_logo;
|
||||
} else {
|
||||
configSettings.custom_logo = false;
|
||||
}
|
||||
// Auto-resolving what used to be found when attempting to load local_setting.json
|
||||
if ($rootScope.loginConfig) {
|
||||
$rootScope.loginConfig.resolve('config loaded');
|
||||
}
|
||||
global.$AnsibleConfig = configSettings;
|
||||
Store('AnsibleConfig', global.$AnsibleConfig);
|
||||
$rootScope.$emit('ConfigReady');
|
||||
|
||||
if(data.custom_login_info) {
|
||||
configSettings.custom_login_info = data.custom_login_info;
|
||||
$rootScope.custom_login_info = data.custom_login_info;
|
||||
} else {
|
||||
configSettings.custom_login_info = false;
|
||||
}
|
||||
|
||||
configInit();
|
||||
|
||||
}).catch(({error}) => {
|
||||
$log.debug(error);
|
||||
configInit();
|
||||
});
|
||||
// Load new hardcoded settings from above
|
||||
$rootScope.$emit('LoadConfig');
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
LoadConfig.$inject =
|
||||
[ '$log', '$rootScope', '$http',
|
||||
'Store'
|
||||
];
|
||||
[ '$rootScope', 'Store' ];
|
||||
|
||||
30
awx/ui/package-lock.json
generated
30
awx/ui/package-lock.json
generated
@ -246,10 +246,10 @@
|
||||
"integrity": "sha512-nB/xe7JQWF9nLvhHommAICQ3eWrfRETo0EVGFESi952CDzDa+GAJ/2BFBNw44QqQPxj1Xua/uYKrbLsOGWZdbQ=="
|
||||
},
|
||||
"angular-scheduler": {
|
||||
"version": "git+https://git@github.com/ansible/angular-scheduler.git#7628cb2fc9e6280811baa464f0020a636e65d702",
|
||||
"from": "git+https://git@github.com/ansible/angular-scheduler.git#v0.3.3",
|
||||
"version": "git+https://git@github.com/ansible/angular-scheduler.git#a519c52312cb4430a59a8d58e01d3eac3fe5018a",
|
||||
"from": "git+https://git@github.com/ansible/angular-scheduler.git#v0.4.1",
|
||||
"requires": {
|
||||
"angular": "~1.6.6",
|
||||
"angular": "~1.7.2",
|
||||
"angular-tz-extensions": "github:ansible/angular-tz-extensions#fc60660f43ee9ff84da94ca71ab27ef0c20fd77d",
|
||||
"jquery": "*",
|
||||
"jquery-ui": "*",
|
||||
@ -258,45 +258,25 @@
|
||||
"rrule": "github:jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c"
|
||||
},
|
||||
"dependencies": {
|
||||
"angular": {
|
||||
"version": "1.6.10",
|
||||
"resolved": "https://registry.npmjs.org/angular/-/angular-1.6.10.tgz",
|
||||
"integrity": "sha512-PCZ5/hVdvPQiYyH0VwsPjrErPHRcITnaXxhksceOXgtJeesKHLA7KDu4X/yvcAi+1zdGgGF+9pDxkJvghXI9Wg=="
|
||||
},
|
||||
"angular-tz-extensions": {
|
||||
"version": "github:ansible/angular-tz-extensions#fc60660f43ee9ff84da94ca71ab27ef0c20fd77d",
|
||||
"from": "github:ansible/angular-tz-extensions#fc60660f43ee9ff84da94ca71ab27ef0c20fd77d",
|
||||
"from": "github:ansible/angular-tz-extensions",
|
||||
"requires": {
|
||||
"angular": "~1.7.2",
|
||||
"angular-filters": "^1.1.2",
|
||||
"jquery": "^3.1.0",
|
||||
"jstimezonedetect": "1.0.5",
|
||||
"timezone-js": "github:ansible/timezone-js#6937de14ce0c193961538bb5b3b12b7ef62a358f"
|
||||
},
|
||||
"dependencies": {
|
||||
"angular": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/angular/-/angular-1.7.6.tgz",
|
||||
"integrity": "sha512-QELpvuMIe1FTGniAkRz93O6A+di0yu88niDwcdzrSqtUHNtZMgtgFS4f7W/6Gugbuwej8Kyswlmymwdp8iPCWg=="
|
||||
},
|
||||
"timezone-js": {
|
||||
"version": "github:ansible/timezone-js#6937de14ce0c193961538bb5b3b12b7ef62a358f",
|
||||
"from": "github:ansible/timezone-js#0.4.14"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "http://registry.npmjs.org/lodash/-/lodash-3.8.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.8.0.tgz",
|
||||
"integrity": "sha1-N265i9zZOCqTZcM8TLglDeEyW5E="
|
||||
},
|
||||
"rrule": {
|
||||
"version": "github:jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c",
|
||||
"from": "github:jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c"
|
||||
},
|
||||
"timezone-js": {
|
||||
"version": "github:ansible/timezone-js#6937de14ce0c193961538bb5b3b12b7ef62a358f",
|
||||
"from": "github:ansible/timezone-js#6937de14ce0c193961538bb5b3b12b7ef62a358f"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -107,7 +107,7 @@
|
||||
"angular-moment": "^1.3.0",
|
||||
"angular-mousewheel": "^1.0.5",
|
||||
"angular-sanitize": "^1.7.9",
|
||||
"angular-scheduler": "git+https://git@github.com/ansible/angular-scheduler#v0.3.3",
|
||||
"angular-scheduler": "git+https://git@github.com/ansible/angular-scheduler#v0.4.1",
|
||||
"angular-tz-extensions": "git+https://git@github.com/ansible/angular-tz-extensions#v0.5.2",
|
||||
"angular-xeditable": "~0.8.0",
|
||||
"ansi-to-html": "^0.6.3",
|
||||
|
||||
1192
awx/ui_next/package-lock.json
generated
1192
awx/ui_next/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -52,7 +52,7 @@
|
||||
"react-hot-loader": "^4.3.3",
|
||||
"sass-loader": "^7.1.0",
|
||||
"style-loader": "^0.23.0",
|
||||
"webpack": "^4.23.1",
|
||||
"webpack": "^4.41.2",
|
||||
"webpack-cli": "^3.0.8",
|
||||
"webpack-dev-server": "^3.1.14"
|
||||
},
|
||||
|
||||
@ -2,6 +2,7 @@ import AdHocCommands from './models/AdHocCommands';
|
||||
import Config from './models/Config';
|
||||
import CredentialTypes from './models/CredentialTypes';
|
||||
import Credentials from './models/Credentials';
|
||||
import Groups from './models/Groups';
|
||||
import Hosts from './models/Hosts';
|
||||
import InstanceGroups from './models/InstanceGroups';
|
||||
import Inventories from './models/Inventories';
|
||||
@ -28,6 +29,7 @@ const AdHocCommandsAPI = new AdHocCommands();
|
||||
const ConfigAPI = new Config();
|
||||
const CredentialsAPI = new Credentials();
|
||||
const CredentialTypesAPI = new CredentialTypes();
|
||||
const GroupsAPI = new Groups();
|
||||
const HostsAPI = new Hosts();
|
||||
const InstanceGroupsAPI = new InstanceGroups();
|
||||
const InventoriesAPI = new Inventories();
|
||||
@ -55,6 +57,7 @@ export {
|
||||
ConfigAPI,
|
||||
CredentialsAPI,
|
||||
CredentialTypesAPI,
|
||||
GroupsAPI,
|
||||
HostsAPI,
|
||||
InstanceGroupsAPI,
|
||||
InventoriesAPI,
|
||||
|
||||
10
awx/ui_next/src/api/models/Groups.js
Normal file
10
awx/ui_next/src/api/models/Groups.js
Normal 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;
|
||||
@ -7,6 +7,10 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
||||
this.baseUrl = '/api/v2/inventories/';
|
||||
|
||||
this.readAccessList = this.readAccessList.bind(this);
|
||||
this.readHosts = this.readHosts.bind(this);
|
||||
this.readGroups = this.readGroups.bind(this);
|
||||
this.readGroupsOptions = this.readGroupsOptions.bind(this);
|
||||
this.promoteGroup = this.promoteGroup.bind(this);
|
||||
}
|
||||
|
||||
readAccessList(id, params) {
|
||||
@ -15,9 +19,28 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
||||
});
|
||||
}
|
||||
|
||||
createHost(id, data) {
|
||||
return this.http.post(`${this.baseUrl}${id}/hosts/`, data);
|
||||
}
|
||||
|
||||
readHosts(id, params) {
|
||||
return this.http.get(`${this.baseUrl}${id}/hosts/`, { params });
|
||||
}
|
||||
|
||||
readGroups(id, params) {
|
||||
return this.http.get(`${this.baseUrl}${id}/groups/`, { params });
|
||||
}
|
||||
|
||||
readGroupsOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/groups/`);
|
||||
}
|
||||
|
||||
promoteGroup(inventoryId, groupId) {
|
||||
return this.http.post(`${this.baseUrl}${inventoryId}/groups/`, {
|
||||
id: groupId,
|
||||
disassociate: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Inventories;
|
||||
|
||||
@ -110,6 +110,7 @@
|
||||
--pf-c-modal-box__footer--PaddingRight: 20px;
|
||||
--pf-c-modal-box__footer--PaddingBottom: 20px;
|
||||
--pf-c-modal-box__footer--PaddingLeft: 20px;
|
||||
--pf-c-modal-box__footer--MarginTop: 24px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
||||
@ -187,11 +187,13 @@ class AddResourceRole extends React.Component {
|
||||
<SelectableCard
|
||||
isSelected={selectedResource === 'users'}
|
||||
label={i18n._(t`Users`)}
|
||||
dataCy="add-role-users"
|
||||
onClick={() => this.handleResourceSelect('users')}
|
||||
/>
|
||||
<SelectableCard
|
||||
isSelected={selectedResource === 'teams'}
|
||||
label={i18n._(t`Teams`)}
|
||||
dataCy="add-role-teams"
|
||||
onClick={() => this.handleResourceSelect('teams')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -33,7 +33,7 @@ const Label = styled.div`
|
||||
|
||||
class SelectableCard extends Component {
|
||||
render() {
|
||||
const { label, onClick, isSelected } = this.props;
|
||||
const { label, onClick, isSelected, dataCy } = this.props;
|
||||
|
||||
return (
|
||||
<SelectableItem
|
||||
@ -41,6 +41,7 @@ class SelectableCard extends Component {
|
||||
onKeyPress={onClick}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
data-cy={dataCy}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
<Indicator isSelected={isSelected} />
|
||||
|
||||
@ -25,7 +25,7 @@ class AnsibleSelect extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { id, data, i18n, isValid, onBlur, value } = this.props;
|
||||
const { id, data, i18n, isValid, onBlur, value, className } = this.props;
|
||||
|
||||
return (
|
||||
<FormSelect
|
||||
@ -35,13 +35,14 @@ class AnsibleSelect extends React.Component {
|
||||
onBlur={onBlur}
|
||||
aria-label={i18n._(t`Select Input`)}
|
||||
isValid={isValid}
|
||||
className={className}
|
||||
>
|
||||
{data.map(datum => (
|
||||
{data.map(option => (
|
||||
<FormSelectOption
|
||||
key={datum.key}
|
||||
value={datum.value}
|
||||
label={datum.label}
|
||||
isDisabled={datum.isDisabled}
|
||||
key={option.key}
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
isDisabled={option.isDisabled}
|
||||
/>
|
||||
))}
|
||||
</FormSelect>
|
||||
@ -49,19 +50,28 @@ class AnsibleSelect extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
const Option = shape({
|
||||
key: oneOfType([string, number]).isRequired,
|
||||
value: oneOfType([string, number]).isRequired,
|
||||
label: string.isRequired,
|
||||
isDisabled: bool,
|
||||
});
|
||||
|
||||
AnsibleSelect.defaultProps = {
|
||||
data: [],
|
||||
isValid: true,
|
||||
onBlur: () => {},
|
||||
className: '',
|
||||
};
|
||||
|
||||
AnsibleSelect.propTypes = {
|
||||
data: arrayOf(shape()),
|
||||
data: arrayOf(Option),
|
||||
id: string.isRequired,
|
||||
isValid: bool,
|
||||
onBlur: func,
|
||||
onChange: func.isRequired,
|
||||
value: oneOfType([string, number]).isRequired,
|
||||
className: string,
|
||||
};
|
||||
|
||||
export { AnsibleSelect as _AnsibleSelect };
|
||||
|
||||
@ -16,6 +16,7 @@ const CheckboxListItem = ({
|
||||
label,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDeselect,
|
||||
isRadio,
|
||||
}) => {
|
||||
const CheckboxRadio = isRadio ? DataListRadio : DataListCheck;
|
||||
@ -25,7 +26,7 @@ const CheckboxListItem = ({
|
||||
<CheckboxRadio
|
||||
id={`selected-${itemId}`}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
onChange={isSelected ? onDeselect : onSelect}
|
||||
aria-labelledby={`check-action-item-${itemId}`}
|
||||
name={name}
|
||||
value={itemId}
|
||||
@ -60,6 +61,7 @@ CheckboxListItem.propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
isSelected: PropTypes.bool.isRequired,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
onDeselect: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default CheckboxListItem;
|
||||
|
||||
@ -12,6 +12,7 @@ describe('CheckboxListItem', () => {
|
||||
label="Buzz"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
onDeselect={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
|
||||
@ -4,8 +4,8 @@ import { withI18n } from '@lingui/react';
|
||||
import { EmptyState, EmptyStateBody } from '@patternfly/react-core';
|
||||
|
||||
// TODO: Better loading state - skeleton lines / spinner, etc.
|
||||
const ContentLoading = ({ i18n }) => (
|
||||
<EmptyState>
|
||||
const ContentLoading = ({ className, i18n }) => (
|
||||
<EmptyState className={className}>
|
||||
<EmptyStateBody>{i18n._(t`Loading...`)}</EmptyStateBody>
|
||||
</EmptyState>
|
||||
);
|
||||
|
||||
@ -1,11 +1,20 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { bool, func, number, string, oneOfType } from 'prop-types';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { CredentialsAPI } from '@api';
|
||||
import { Credential } from '@types';
|
||||
import { mergeParams } from '@util/qs';
|
||||
import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
|
||||
import { FormGroup } from '@patternfly/react-core';
|
||||
import Lookup from '@components/Lookup';
|
||||
import OptionsList from './shared/OptionsList';
|
||||
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||
|
||||
const QS_CONFIG = getQSConfig('credentials', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
});
|
||||
|
||||
function CredentialLookup({
|
||||
helperTextInvalid,
|
||||
@ -16,11 +25,28 @@ function CredentialLookup({
|
||||
required,
|
||||
credentialTypeId,
|
||||
value,
|
||||
history,
|
||||
}) {
|
||||
const getCredentials = async params =>
|
||||
CredentialsAPI.read(
|
||||
mergeParams(params, { credential_type: credentialTypeId })
|
||||
);
|
||||
const [credentials, setCredentials] = useState([]);
|
||||
const [count, setCount] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
try {
|
||||
const { data } = await CredentialsAPI.read(
|
||||
mergeParams(params, { credential_type: credentialTypeId })
|
||||
);
|
||||
setCredentials(data.results);
|
||||
setCount(data.count);
|
||||
} catch (err) {
|
||||
if (setError) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [credentialTypeId, history.location.search]);
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
@ -32,15 +58,26 @@ function CredentialLookup({
|
||||
>
|
||||
<Lookup
|
||||
id="credential"
|
||||
lookupHeader={label}
|
||||
name="credential"
|
||||
header={label}
|
||||
value={value}
|
||||
onBlur={onBlur}
|
||||
onLookupSave={onChange}
|
||||
getItems={getCredentials}
|
||||
onChange={onChange}
|
||||
required={required}
|
||||
sortedColumnKey="name"
|
||||
qsConfig={QS_CONFIG}
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||
<OptionsList
|
||||
value={state.selectedItems}
|
||||
options={credentials}
|
||||
optionCount={count}
|
||||
header={label}
|
||||
qsConfig={QS_CONFIG}
|
||||
readOnly={!canDelete}
|
||||
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<LookupErrorMessage error={error} />
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
@ -65,4 +102,4 @@ CredentialLookup.defaultProps = {
|
||||
};
|
||||
|
||||
export { CredentialLookup as _CredentialLookup };
|
||||
export default withI18n()(CredentialLookup);
|
||||
export default withI18n()(withRouter(CredentialLookup));
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import CredentialLookup, { _CredentialLookup } from './CredentialLookup';
|
||||
import { CredentialsAPI } from '@api';
|
||||
@ -9,19 +10,48 @@ describe('CredentialLookup', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialLookup credentialTypeId={1} label="Foo" onChange={() => {}} />
|
||||
);
|
||||
CredentialsAPI.read.mockResolvedValueOnce({
|
||||
data: {
|
||||
results: [
|
||||
{ id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' },
|
||||
{ id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' },
|
||||
{ id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' },
|
||||
{ id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' },
|
||||
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
|
||||
],
|
||||
count: 5,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('initially renders successfully', () => {
|
||||
test('should render successfully', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialLookup
|
||||
credentialTypeId={1}
|
||||
label="Foo"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('CredentialLookup')).toHaveLength(1);
|
||||
});
|
||||
test('should fetch credentials', () => {
|
||||
|
||||
test('should fetch credentials', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialLookup
|
||||
credentialTypeId={1}
|
||||
label="Foo"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
|
||||
expect(CredentialsAPI.read).toHaveBeenCalledWith({
|
||||
credential_type: 1,
|
||||
@ -30,11 +60,31 @@ describe('CredentialLookup', () => {
|
||||
page_size: 5,
|
||||
});
|
||||
});
|
||||
test('should display label', () => {
|
||||
|
||||
test('should display label', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialLookup
|
||||
credentialTypeId={1}
|
||||
label="Foo"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const title = wrapper.find('FormGroup .pf-c-form__label-text');
|
||||
expect(title.text()).toEqual('Foo');
|
||||
});
|
||||
test('should define default value for function props', () => {
|
||||
|
||||
test('should define default value for function props', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialLookup
|
||||
credentialTypeId={1}
|
||||
label="Foo"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(_CredentialLookup.defaultProps.onBlur).toBeInstanceOf(Function);
|
||||
expect(_CredentialLookup.defaultProps.onBlur).not.toThrow();
|
||||
});
|
||||
|
||||
@ -1,48 +1,69 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { arrayOf, string, func, object, bool } from 'prop-types';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { FormGroup, Tooltip } from '@patternfly/react-core';
|
||||
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { FormGroup } from '@patternfly/react-core';
|
||||
import { InstanceGroupsAPI } from '@api';
|
||||
import Lookup from '@components/Lookup';
|
||||
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||
import { FieldTooltip } from '@components/FormField';
|
||||
import Lookup from './Lookup';
|
||||
import OptionsList from './shared/OptionsList';
|
||||
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||
|
||||
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
||||
margin-left: 10px;
|
||||
`;
|
||||
const QS_CONFIG = getQSConfig('instance_groups', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
});
|
||||
|
||||
const getInstanceGroups = async params => InstanceGroupsAPI.read(params);
|
||||
function InstanceGroupsLookup(props) {
|
||||
const {
|
||||
value,
|
||||
onChange,
|
||||
tooltip,
|
||||
className,
|
||||
required,
|
||||
history,
|
||||
i18n,
|
||||
} = props;
|
||||
const [instanceGroups, setInstanceGroups] = useState([]);
|
||||
const [count, setCount] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
class InstanceGroupsLookup extends React.Component {
|
||||
render() {
|
||||
const { value, tooltip, onChange, className, i18n } = this.props;
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
try {
|
||||
const { data } = await InstanceGroupsAPI.read(params);
|
||||
setInstanceGroups(data.results);
|
||||
setCount(data.count);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
})();
|
||||
}, [history.location]);
|
||||
|
||||
/*
|
||||
Wrapping <div> added to workaround PF bug:
|
||||
https://github.com/patternfly/patternfly-react/issues/2855
|
||||
*/
|
||||
return (
|
||||
<div className={className}>
|
||||
<FormGroup
|
||||
label={i18n._(t`Instance Groups`)}
|
||||
fieldId="org-instance-groups"
|
||||
>
|
||||
{tooltip && (
|
||||
<Tooltip position="right" content={tooltip}>
|
||||
<QuestionCircleIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Lookup
|
||||
id="org-instance-groups"
|
||||
lookupHeader={i18n._(t`Instance Groups`)}
|
||||
name="instanceGroups"
|
||||
value={value}
|
||||
onLookupSave={onChange}
|
||||
getItems={getInstanceGroups}
|
||||
qsNamespace="instance-group"
|
||||
multiple
|
||||
return (
|
||||
<FormGroup
|
||||
className={className}
|
||||
label={i18n._(t`Instance Groups`)}
|
||||
fieldId="org-instance-groups"
|
||||
>
|
||||
{tooltip && <FieldTooltip content={tooltip} />}
|
||||
<Lookup
|
||||
id="org-instance-groups"
|
||||
header={i18n._(t`Instance Groups`)}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
qsConfig={QS_CONFIG}
|
||||
multiple
|
||||
required={required}
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||
<OptionsList
|
||||
value={state.selectedItems}
|
||||
options={instanceGroups}
|
||||
optionCount={count}
|
||||
columns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
@ -63,22 +84,33 @@ class InstanceGroupsLookup extends React.Component {
|
||||
isNumeric: true,
|
||||
},
|
||||
]}
|
||||
sortedColumnKey="name"
|
||||
multiple={state.multiple}
|
||||
header={i18n._(t`Instance Groups`)}
|
||||
name="instanceGroups"
|
||||
qsConfig={QS_CONFIG}
|
||||
readOnly={!canDelete}
|
||||
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<LookupErrorMessage error={error} />
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
InstanceGroupsLookup.propTypes = {
|
||||
value: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
tooltip: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
value: arrayOf(object).isRequired,
|
||||
tooltip: string,
|
||||
onChange: func.isRequired,
|
||||
className: string,
|
||||
required: bool,
|
||||
};
|
||||
|
||||
InstanceGroupsLookup.defaultProps = {
|
||||
tooltip: '',
|
||||
className: '',
|
||||
required: false,
|
||||
};
|
||||
|
||||
export default withI18n()(InstanceGroupsLookup);
|
||||
export default withI18n()(withRouter(InstanceGroupsLookup));
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { string, func, bool } from 'prop-types';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { FormGroup } from '@patternfly/react-core';
|
||||
@ -7,61 +8,94 @@ import { InventoriesAPI } from '@api';
|
||||
import { Inventory } from '@types';
|
||||
import Lookup from '@components/Lookup';
|
||||
import { FieldTooltip } from '@components/FormField';
|
||||
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||
import OptionsList from './shared/OptionsList';
|
||||
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||
|
||||
const getInventories = async params => InventoriesAPI.read(params);
|
||||
const QS_CONFIG = getQSConfig('inventory', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
});
|
||||
|
||||
class InventoryLookup extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
value,
|
||||
tooltip,
|
||||
onChange,
|
||||
onBlur,
|
||||
required,
|
||||
isValid,
|
||||
helperTextInvalid,
|
||||
i18n,
|
||||
} = this.props;
|
||||
function InventoryLookup({
|
||||
value,
|
||||
tooltip,
|
||||
onChange,
|
||||
onBlur,
|
||||
required,
|
||||
isValid,
|
||||
helperTextInvalid,
|
||||
i18n,
|
||||
history,
|
||||
}) {
|
||||
const [inventories, setInventories] = useState([]);
|
||||
const [count, setCount] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
label={i18n._(t`Inventory`)}
|
||||
isRequired={required}
|
||||
fieldId="inventory-lookup"
|
||||
isValid={isValid}
|
||||
helperTextInvalid={helperTextInvalid}
|
||||
>
|
||||
{tooltip && <FieldTooltip content={tooltip} />}
|
||||
<Lookup
|
||||
id="inventory-lookup"
|
||||
lookupHeader={i18n._(t`Inventory`)}
|
||||
name="inventory"
|
||||
value={value}
|
||||
onLookupSave={onChange}
|
||||
onBlur={onBlur}
|
||||
getItems={getInventories}
|
||||
required={required}
|
||||
qsNamespace="inventory"
|
||||
columns={[
|
||||
{ name: i18n._(t`Name`), key: 'name', isSortable: true },
|
||||
{
|
||||
name: i18n._(t`Modified`),
|
||||
key: 'modified',
|
||||
isSortable: false,
|
||||
isNumeric: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created`),
|
||||
key: 'created',
|
||||
isSortable: false,
|
||||
isNumeric: true,
|
||||
},
|
||||
]}
|
||||
sortedColumnKey="name"
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
try {
|
||||
const { data } = await InventoriesAPI.read(params);
|
||||
setInventories(data.results);
|
||||
setCount(data.count);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
})();
|
||||
}, [history.location]);
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
label={i18n._(t`Inventory`)}
|
||||
isRequired={required}
|
||||
fieldId="inventory-lookup"
|
||||
isValid={isValid}
|
||||
helperTextInvalid={helperTextInvalid}
|
||||
>
|
||||
{tooltip && <FieldTooltip content={tooltip} />}
|
||||
<Lookup
|
||||
id="inventory-lookup"
|
||||
header={i18n._(t`Inventory`)}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
required={required}
|
||||
qsConfig={QS_CONFIG}
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||
<OptionsList
|
||||
value={state.selectedItems}
|
||||
options={inventories}
|
||||
optionCount={count}
|
||||
columns={[
|
||||
{ name: i18n._(t`Name`), key: 'name', isSortable: true },
|
||||
{
|
||||
name: i18n._(t`Modified`),
|
||||
key: 'modified',
|
||||
isSortable: false,
|
||||
isNumeric: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created`),
|
||||
key: 'created',
|
||||
isSortable: false,
|
||||
isNumeric: true,
|
||||
},
|
||||
]}
|
||||
multiple={state.multiple}
|
||||
header={i18n._(t`Inventory`)}
|
||||
name="inventory"
|
||||
qsConfig={QS_CONFIG}
|
||||
readOnly={!canDelete}
|
||||
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<LookupErrorMessage error={error} />
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
InventoryLookup.propTypes = {
|
||||
@ -77,4 +111,4 @@ InventoryLookup.defaultProps = {
|
||||
required: false,
|
||||
};
|
||||
|
||||
export default withI18n()(InventoryLookup);
|
||||
export default withI18n()(withRouter(InventoryLookup));
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React, { Fragment, useReducer, useEffect } from 'react';
|
||||
import {
|
||||
string,
|
||||
bool,
|
||||
@ -15,20 +15,14 @@ import {
|
||||
ButtonVariant,
|
||||
InputGroup as PFInputGroup,
|
||||
Modal,
|
||||
ToolbarItem,
|
||||
} from '@patternfly/react-core';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import AnsibleSelect from '../AnsibleSelect';
|
||||
import PaginatedDataList from '../PaginatedDataList';
|
||||
import VerticalSeperator from '../VerticalSeparator';
|
||||
import DataListToolbar from '../DataListToolbar';
|
||||
import CheckboxListItem from '../CheckboxListItem';
|
||||
import SelectedList from '../SelectedList';
|
||||
import { ChipGroup, Chip, CredentialChip } from '../Chip';
|
||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||
import reducer, { initReducer } from './shared/reducer';
|
||||
import { ChipGroup, Chip } from '../Chip';
|
||||
import { QSConfig } from '@types';
|
||||
|
||||
const SearchButton = styled(Button)`
|
||||
::after {
|
||||
@ -36,6 +30,7 @@ const SearchButton = styled(Button)`
|
||||
var(--pf-global--BorderColor--200);
|
||||
}
|
||||
`;
|
||||
SearchButton.displayName = 'SearchButton';
|
||||
|
||||
const InputGroup = styled(PFInputGroup)`
|
||||
${props =>
|
||||
@ -54,315 +49,124 @@ const ChipHolder = styled.div`
|
||||
border-bottom-right-radius: 3px;
|
||||
`;
|
||||
|
||||
class Lookup extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
function Lookup(props) {
|
||||
const {
|
||||
id,
|
||||
header,
|
||||
onChange,
|
||||
onBlur,
|
||||
value,
|
||||
multiple,
|
||||
required,
|
||||
qsConfig,
|
||||
renderItemChip,
|
||||
renderOptionsList,
|
||||
history,
|
||||
i18n,
|
||||
} = props;
|
||||
|
||||
this.assertCorrectValueType();
|
||||
let lookupSelectedItems = [];
|
||||
if (props.value) {
|
||||
lookupSelectedItems = props.multiple ? [...props.value] : [props.value];
|
||||
}
|
||||
this.state = {
|
||||
isModalOpen: false,
|
||||
lookupSelectedItems,
|
||||
results: [],
|
||||
count: 0,
|
||||
error: null,
|
||||
};
|
||||
this.qsConfig = getQSConfig(props.qsNamespace, {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: props.sortedColumnKey,
|
||||
});
|
||||
this.handleModalToggle = this.handleModalToggle.bind(this);
|
||||
this.toggleSelected = this.toggleSelected.bind(this);
|
||||
this.saveModal = this.saveModal.bind(this);
|
||||
this.getData = this.getData.bind(this);
|
||||
this.clearQSParams = this.clearQSParams.bind(this);
|
||||
}
|
||||
const [state, dispatch] = useReducer(
|
||||
reducer,
|
||||
{ value, multiple, required },
|
||||
initReducer
|
||||
);
|
||||
|
||||
componentDidMount() {
|
||||
this.getData();
|
||||
}
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'SET_MULTIPLE', value: multiple });
|
||||
}, [multiple]);
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { location, selectedCategory } = this.props;
|
||||
if (
|
||||
location !== prevProps.location ||
|
||||
prevProps.selectedCategory !== selectedCategory
|
||||
) {
|
||||
this.getData();
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'SET_VALUE', value });
|
||||
}, [value]);
|
||||
|
||||
assertCorrectValueType() {
|
||||
const { multiple, value, selectCategoryOptions } = this.props;
|
||||
if (selectCategoryOptions) {
|
||||
return;
|
||||
}
|
||||
if (!multiple && Array.isArray(value)) {
|
||||
throw new Error(
|
||||
'Lookup value must not be an array unless `multiple` is set'
|
||||
);
|
||||
}
|
||||
if (multiple && !Array.isArray(value)) {
|
||||
throw new Error('Lookup value must be an array if `multiple` is set');
|
||||
}
|
||||
}
|
||||
|
||||
async getData() {
|
||||
const {
|
||||
getItems,
|
||||
location: { search },
|
||||
} = this.props;
|
||||
const queryParams = parseQueryString(this.qsConfig, search);
|
||||
|
||||
this.setState({ error: false });
|
||||
try {
|
||||
const { data } = await getItems(queryParams);
|
||||
const { results, count } = data;
|
||||
|
||||
this.setState({
|
||||
results,
|
||||
count,
|
||||
});
|
||||
} catch (err) {
|
||||
this.setState({ error: true });
|
||||
}
|
||||
}
|
||||
|
||||
toggleSelected(row) {
|
||||
const {
|
||||
name,
|
||||
onLookupSave,
|
||||
multiple,
|
||||
onToggleItem,
|
||||
selectCategoryOptions,
|
||||
} = this.props;
|
||||
const {
|
||||
lookupSelectedItems: updatedSelectedItems,
|
||||
isModalOpen,
|
||||
} = this.state;
|
||||
|
||||
const selectedIndex = updatedSelectedItems.findIndex(
|
||||
selectedRow => selectedRow.id === row.id
|
||||
);
|
||||
if (multiple) {
|
||||
if (selectCategoryOptions) {
|
||||
onToggleItem(row, isModalOpen);
|
||||
}
|
||||
if (selectedIndex > -1) {
|
||||
updatedSelectedItems.splice(selectedIndex, 1);
|
||||
this.setState({ lookupSelectedItems: updatedSelectedItems });
|
||||
} else {
|
||||
this.setState(prevState => ({
|
||||
lookupSelectedItems: [...prevState.lookupSelectedItems, row],
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
this.setState({ lookupSelectedItems: [row] });
|
||||
}
|
||||
|
||||
// Updates the selected items from parent state
|
||||
// This handles the case where the user removes chips from the lookup input
|
||||
// while the modal is closed
|
||||
if (!isModalOpen) {
|
||||
onLookupSave(updatedSelectedItems, name);
|
||||
}
|
||||
}
|
||||
|
||||
handleModalToggle() {
|
||||
const { isModalOpen } = this.state;
|
||||
const { value, multiple, selectCategory } = this.props;
|
||||
// Resets the selected items from parent state whenever modal is opened
|
||||
// This handles the case where the user closes/cancels the modal and
|
||||
// opens it again
|
||||
if (!isModalOpen) {
|
||||
let lookupSelectedItems = [];
|
||||
if (value) {
|
||||
lookupSelectedItems = multiple ? [...value] : [value];
|
||||
}
|
||||
this.setState({ lookupSelectedItems });
|
||||
} else {
|
||||
this.clearQSParams();
|
||||
if (selectCategory) {
|
||||
selectCategory(null, 'Machine');
|
||||
}
|
||||
}
|
||||
this.setState(prevState => ({
|
||||
isModalOpen: !prevState.isModalOpen,
|
||||
}));
|
||||
}
|
||||
|
||||
saveModal() {
|
||||
const { onLookupSave, name, multiple } = this.props;
|
||||
const { lookupSelectedItems } = this.state;
|
||||
const value = multiple
|
||||
? lookupSelectedItems
|
||||
: lookupSelectedItems[0] || null;
|
||||
|
||||
this.handleModalToggle();
|
||||
onLookupSave(value, name);
|
||||
}
|
||||
|
||||
clearQSParams() {
|
||||
const { history } = this.props;
|
||||
const clearQSParams = () => {
|
||||
const parts = history.location.search.replace(/^\?/, '').split('&');
|
||||
const ns = this.qsConfig.namespace;
|
||||
const ns = qsConfig.namespace;
|
||||
const otherParts = parts.filter(param => !param.startsWith(`${ns}.`));
|
||||
history.push(`${history.location.pathname}?${otherParts.join('&')}`);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
isModalOpen,
|
||||
lookupSelectedItems,
|
||||
error,
|
||||
results,
|
||||
count,
|
||||
} = this.state;
|
||||
const {
|
||||
form,
|
||||
id,
|
||||
lookupHeader,
|
||||
value,
|
||||
columns,
|
||||
multiple,
|
||||
name,
|
||||
onBlur,
|
||||
selectCategory,
|
||||
required,
|
||||
i18n,
|
||||
selectCategoryOptions,
|
||||
selectedCategory,
|
||||
} = this.props;
|
||||
const header = lookupHeader || i18n._(t`Items`);
|
||||
const canDelete = !required || (multiple && value.length > 1);
|
||||
const chips = () => {
|
||||
return selectCategoryOptions && selectCategoryOptions.length > 0 ? (
|
||||
<ChipGroup defaultIsOpen numChips={5}>
|
||||
{(multiple ? value : [value]).map(chip => (
|
||||
<CredentialChip
|
||||
key={chip.id}
|
||||
onClick={() => this.toggleSelected(chip)}
|
||||
isReadOnly={!canDelete}
|
||||
credential={chip}
|
||||
/>
|
||||
))}
|
||||
</ChipGroup>
|
||||
) : (
|
||||
<ChipGroup defaultIsOpen numChips={5}>
|
||||
{(multiple ? value : [value]).map(chip => (
|
||||
<Chip
|
||||
key={chip.id}
|
||||
onClick={() => this.toggleSelected(chip)}
|
||||
isReadOnly={!canDelete}
|
||||
>
|
||||
{chip.name}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Fragment>
|
||||
<InputGroup onBlur={onBlur}>
|
||||
<SearchButton
|
||||
aria-label="Search"
|
||||
id={id}
|
||||
onClick={this.handleModalToggle}
|
||||
variant={ButtonVariant.tertiary}
|
||||
>
|
||||
<SearchIcon />
|
||||
</SearchButton>
|
||||
<ChipHolder className="pf-c-form-control">
|
||||
{value ? chips(value) : null}
|
||||
</ChipHolder>
|
||||
</InputGroup>
|
||||
<Modal
|
||||
className="awx-c-modal"
|
||||
title={i18n._(t`Select ${header}`)}
|
||||
isOpen={isModalOpen}
|
||||
onClose={this.handleModalToggle}
|
||||
actions={[
|
||||
<Button
|
||||
key="save"
|
||||
variant="primary"
|
||||
onClick={this.saveModal}
|
||||
style={results.length === 0 ? { display: 'none' } : {}}
|
||||
>
|
||||
{i18n._(t`Save`)}
|
||||
</Button>,
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
onClick={this.handleModalToggle}
|
||||
>
|
||||
{results.length === 0 ? i18n._(t`Close`) : i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{selectCategoryOptions && selectCategoryOptions.length > 0 && (
|
||||
<ToolbarItem css=" display: flex; align-items: center;">
|
||||
<span css="flex: 0 0 25%;">Selected Category</span>
|
||||
<VerticalSeperator />
|
||||
<AnsibleSelect
|
||||
css="flex: 1 1 75%;"
|
||||
id="multiCredentialsLookUp-select"
|
||||
label="Selected Category"
|
||||
data={selectCategoryOptions}
|
||||
value={selectedCategory.label}
|
||||
onChange={selectCategory}
|
||||
form={form}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
)}
|
||||
<PaginatedDataList
|
||||
items={results}
|
||||
itemCount={count}
|
||||
pluralizedItemName={lookupHeader}
|
||||
qsConfig={this.qsConfig}
|
||||
toolbarColumns={columns}
|
||||
renderItem={item => (
|
||||
<CheckboxListItem
|
||||
key={item.id}
|
||||
itemId={item.id}
|
||||
name={multiple ? item.name : name}
|
||||
label={item.name}
|
||||
isSelected={
|
||||
selectCategoryOptions
|
||||
? value.some(i => i.id === item.id)
|
||||
: lookupSelectedItems.some(i => i.id === item.id)
|
||||
}
|
||||
onSelect={() => this.toggleSelected(item)}
|
||||
isRadio={
|
||||
!multiple ||
|
||||
(selectCategoryOptions &&
|
||||
selectCategoryOptions.length &&
|
||||
selectedCategory.value !== 'Vault')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
|
||||
showPageSizeOptions={false}
|
||||
/>
|
||||
{lookupSelectedItems.length > 0 && (
|
||||
<SelectedList
|
||||
label={i18n._(t`Selected`)}
|
||||
selected={selectCategoryOptions ? value : lookupSelectedItems}
|
||||
onRemove={this.toggleSelected}
|
||||
isReadOnly={!canDelete}
|
||||
isCredentialList={
|
||||
selectCategoryOptions && selectCategoryOptions.length > 0
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{error ? <div>error</div> : ''}
|
||||
</Modal>
|
||||
</Fragment>
|
||||
);
|
||||
const save = () => {
|
||||
const { selectedItems } = state;
|
||||
const val = multiple ? selectedItems : selectedItems[0] || null;
|
||||
onChange(val);
|
||||
clearQSParams();
|
||||
dispatch({ type: 'CLOSE_MODAL' });
|
||||
};
|
||||
|
||||
const removeItem = item => {
|
||||
if (multiple) {
|
||||
onChange(value.filter(i => i.id !== item.id));
|
||||
} else {
|
||||
onChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
clearQSParams();
|
||||
dispatch({ type: 'CLOSE_MODAL' });
|
||||
};
|
||||
|
||||
const { isModalOpen, selectedItems } = state;
|
||||
const canDelete = !required || (multiple && value.length > 1);
|
||||
let items = [];
|
||||
if (multiple) {
|
||||
items = value;
|
||||
} else if (value) {
|
||||
items.push(value);
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
<InputGroup onBlur={onBlur}>
|
||||
<SearchButton
|
||||
aria-label="Search"
|
||||
id={id}
|
||||
onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}
|
||||
variant={ButtonVariant.tertiary}
|
||||
>
|
||||
<SearchIcon />
|
||||
</SearchButton>
|
||||
<ChipHolder className="pf-c-form-control">
|
||||
<ChipGroup numChips={5}>
|
||||
{items.map(item =>
|
||||
renderItemChip({
|
||||
item,
|
||||
removeItem,
|
||||
canDelete,
|
||||
})
|
||||
)}
|
||||
</ChipGroup>
|
||||
</ChipHolder>
|
||||
</InputGroup>
|
||||
<Modal
|
||||
className="awx-c-modal"
|
||||
title={i18n._(t`Select ${header || i18n._(t`Items`)}`)}
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeModal}
|
||||
actions={[
|
||||
<Button
|
||||
key="select"
|
||||
variant="primary"
|
||||
onClick={save}
|
||||
style={
|
||||
required && selectedItems.length === 0 ? { display: 'none' } : {}
|
||||
}
|
||||
>
|
||||
{i18n._(t`Select`)}
|
||||
</Button>,
|
||||
<Button key="cancel" variant="secondary" onClick={closeModal}>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{renderOptionsList({
|
||||
state,
|
||||
dispatch,
|
||||
canDelete,
|
||||
})}
|
||||
</Modal>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const Item = shape({
|
||||
@ -371,25 +175,33 @@ const Item = shape({
|
||||
|
||||
Lookup.propTypes = {
|
||||
id: string,
|
||||
getItems: func.isRequired,
|
||||
lookupHeader: string,
|
||||
name: string,
|
||||
onLookupSave: func.isRequired,
|
||||
header: string,
|
||||
onChange: func.isRequired,
|
||||
value: oneOfType([Item, arrayOf(Item)]),
|
||||
sortedColumnKey: string.isRequired,
|
||||
multiple: bool,
|
||||
required: bool,
|
||||
qsNamespace: string,
|
||||
onBlur: func,
|
||||
qsConfig: QSConfig.isRequired,
|
||||
renderItemChip: func,
|
||||
renderOptionsList: func.isRequired,
|
||||
};
|
||||
|
||||
Lookup.defaultProps = {
|
||||
id: 'lookup-search',
|
||||
lookupHeader: null,
|
||||
name: null,
|
||||
header: null,
|
||||
value: null,
|
||||
multiple: false,
|
||||
required: false,
|
||||
qsNamespace: 'lookup',
|
||||
onBlur: () => {},
|
||||
renderItemChip: ({ item, removeItem, canDelete }) => (
|
||||
<Chip
|
||||
key={item.id}
|
||||
onClick={() => removeItem(item)}
|
||||
isReadOnly={!canDelete}
|
||||
>
|
||||
{item.name}
|
||||
</Chip>
|
||||
),
|
||||
};
|
||||
|
||||
export { Lookup as _Lookup };
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
/* eslint-disable react/jsx-pascal-case */
|
||||
import React from 'react';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||
import Lookup, { _Lookup } from './Lookup';
|
||||
|
||||
let mockData = [{ name: 'foo', id: 1, isChecked: false }];
|
||||
const mockColumns = [{ name: 'Name', key: 'name', isSortable: true }];
|
||||
import { getQSConfig } from '@util/qs';
|
||||
import Lookup from './Lookup';
|
||||
|
||||
/**
|
||||
* Check that an element is present on the document body
|
||||
@ -44,348 +42,118 @@ async function checkInputTagValues(wrapper, expected) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check lookup modal list for expected values
|
||||
* @param {wrapper} enzyme wrapper instance
|
||||
* @param {expected} array of [selected, text] pairs describing
|
||||
* the expected visible state of the modal data list
|
||||
*/
|
||||
async function checkModalListValues(wrapper, expected) {
|
||||
// fail if modal isn't actually visible
|
||||
checkRootElementPresent('body div[role="dialog"]');
|
||||
// check list item values
|
||||
const rows = await waitForElement(
|
||||
wrapper,
|
||||
'DataListItemRow',
|
||||
el => el.length === expected.length
|
||||
);
|
||||
expect(rows).toHaveLength(expected.length);
|
||||
rows.forEach((el, index) => {
|
||||
const [expectedChecked, expectedText] = expected[index];
|
||||
expect(expectedText).toEqual(el.text());
|
||||
expect(expectedChecked).toEqual(el.find('input').props().checked);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check lookup modal selection tags for expected values
|
||||
* @param {wrapper} enzyme wrapper instance
|
||||
* @param {expected} array of expected tag values
|
||||
*/
|
||||
async function checkModalTagValues(wrapper, expected) {
|
||||
// fail if modal isn't actually visible
|
||||
checkRootElementPresent('body div[role="dialog"]');
|
||||
// check modal chip values
|
||||
const chips = await waitForElement(
|
||||
wrapper,
|
||||
'Modal Chip span',
|
||||
el => el.length === expected.length
|
||||
);
|
||||
expect(chips).toHaveLength(expected.length);
|
||||
chips.forEach((el, index) => {
|
||||
expect(el.text()).toEqual(expected[index]);
|
||||
});
|
||||
}
|
||||
|
||||
describe('<Lookup multiple/>', () => {
|
||||
let wrapper;
|
||||
let onChange;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }];
|
||||
onChange = jest.fn();
|
||||
document.body.innerHTML = '';
|
||||
wrapper = mountWithContexts(
|
||||
<Lookup
|
||||
multiple
|
||||
lookupHeader="Foo Bar"
|
||||
name="foobar"
|
||||
value={mockSelected}
|
||||
onLookupSave={onChange}
|
||||
getItems={() => ({
|
||||
data: {
|
||||
count: 2,
|
||||
results: [
|
||||
...mockSelected,
|
||||
{ name: 'bar', id: 2, url: '/api/v2/item/2' },
|
||||
],
|
||||
},
|
||||
})}
|
||||
columns={mockColumns}
|
||||
sortedColumnKey="name"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
test('Initially renders succesfully', () => {
|
||||
expect(wrapper.find('Lookup')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Expected items are shown', async done => {
|
||||
expect(wrapper.find('Lookup')).toHaveLength(1);
|
||||
await checkInputTagValues(wrapper, ['foo']);
|
||||
done();
|
||||
});
|
||||
|
||||
test('Open and close modal', async done => {
|
||||
checkRootElementNotPresent('body div[role="dialog"]');
|
||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
||||
checkRootElementPresent('body div[role="dialog"]');
|
||||
// This check couldn't pass unless api response was formatted properly
|
||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
||||
wrapper.find('Modal button[aria-label="Close"]').simulate('click');
|
||||
checkRootElementNotPresent('body div[role="dialog"]');
|
||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
||||
checkRootElementPresent('body div[role="dialog"]');
|
||||
wrapper
|
||||
.find('Modal button')
|
||||
.findWhere(e => e.text() === 'Cancel')
|
||||
.first()
|
||||
.simulate('click');
|
||||
checkRootElementNotPresent('body div[role="dialog"]');
|
||||
done();
|
||||
});
|
||||
|
||||
test('Add item with checkbox then save', async done => {
|
||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
||||
wrapper
|
||||
.find('DataListItemRow')
|
||||
.findWhere(el => el.text() === 'bar')
|
||||
.find('input[type="checkbox"]')
|
||||
.simulate('change');
|
||||
await checkModalListValues(wrapper, [[true, 'foo'], [true, 'bar']]);
|
||||
wrapper
|
||||
.find('Modal button')
|
||||
.findWhere(e => e.text() === 'Save')
|
||||
.first()
|
||||
.simulate('click');
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange.mock.calls[0][0].map(({ name }) => name)).toEqual([
|
||||
'foo',
|
||||
'bar',
|
||||
]);
|
||||
done();
|
||||
});
|
||||
|
||||
test('Add item with checkbox then cancel', async done => {
|
||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
||||
wrapper
|
||||
.find('DataListItemRow')
|
||||
.findWhere(el => el.text() === 'bar')
|
||||
.find('input[type="checkbox"]')
|
||||
.simulate('change');
|
||||
await checkModalListValues(wrapper, [[true, 'foo'], [true, 'bar']]);
|
||||
wrapper
|
||||
.find('Modal button')
|
||||
.findWhere(e => e.text() === 'Cancel')
|
||||
.first()
|
||||
.simulate('click');
|
||||
expect(onChange).toHaveBeenCalledTimes(0);
|
||||
await checkInputTagValues(wrapper, ['foo']);
|
||||
done();
|
||||
});
|
||||
|
||||
test('Remove item with checkbox', async done => {
|
||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
||||
await checkModalTagValues(wrapper, ['foo']);
|
||||
wrapper
|
||||
.find('DataListItemRow')
|
||||
.findWhere(el => el.text() === 'foo')
|
||||
.find('input[type="checkbox"]')
|
||||
.simulate('change');
|
||||
await checkModalListValues(wrapper, [[false, 'foo'], [false, 'bar']]);
|
||||
await checkModalTagValues(wrapper, []);
|
||||
done();
|
||||
});
|
||||
|
||||
test('Remove item with selected icon button', async done => {
|
||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
||||
await checkModalTagValues(wrapper, ['foo']);
|
||||
wrapper
|
||||
.find('Modal Chip')
|
||||
.findWhere(el => el.text() === 'foo')
|
||||
.first()
|
||||
.find('button')
|
||||
.simulate('click');
|
||||
await checkModalListValues(wrapper, [[false, 'foo'], [false, 'bar']]);
|
||||
await checkModalTagValues(wrapper, []);
|
||||
done();
|
||||
});
|
||||
|
||||
test('Remove item with input group button', async done => {
|
||||
await checkInputTagValues(wrapper, ['foo']);
|
||||
wrapper
|
||||
.find('Lookup InputGroup Chip')
|
||||
.findWhere(el => el.text() === 'foo')
|
||||
.first()
|
||||
.find('button')
|
||||
.simulate('click');
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith([], 'foobar');
|
||||
done();
|
||||
});
|
||||
});
|
||||
const QS_CONFIG = getQSConfig('test', {});
|
||||
const TestList = () => <div />;
|
||||
|
||||
describe('<Lookup />', () => {
|
||||
let wrapper;
|
||||
let onChange;
|
||||
|
||||
async function mountWrapper() {
|
||||
const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }];
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Lookup
|
||||
id="test"
|
||||
multiple
|
||||
header="Foo Bar"
|
||||
value={mockSelected}
|
||||
onChange={onChange}
|
||||
qsConfig={QS_CONFIG}
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||
<TestList
|
||||
id="options-list"
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' };
|
||||
onChange = jest.fn();
|
||||
document.body.innerHTML = '';
|
||||
wrapper = mountWithContexts(
|
||||
<Lookup
|
||||
lookupHeader="Foo Bar"
|
||||
name="foobar"
|
||||
value={mockSelected}
|
||||
onLookupSave={onChange}
|
||||
getItems={() => ({
|
||||
data: {
|
||||
count: 2,
|
||||
results: [
|
||||
mockSelected,
|
||||
{ name: 'bar', id: 2, url: '/api/v2/item/2' },
|
||||
],
|
||||
},
|
||||
})}
|
||||
columns={mockColumns}
|
||||
sortedColumnKey="name"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
test('Initially renders succesfully', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('should render succesfully', async () => {
|
||||
wrapper = await mountWrapper();
|
||||
expect(wrapper.find('Lookup')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Expected items are shown', async done => {
|
||||
test('should show selected items', async () => {
|
||||
wrapper = await mountWrapper();
|
||||
expect(wrapper.find('Lookup')).toHaveLength(1);
|
||||
await checkInputTagValues(wrapper, ['foo']);
|
||||
done();
|
||||
});
|
||||
|
||||
test('Open and close modal', async done => {
|
||||
checkRootElementNotPresent('body div[role="dialog"]');
|
||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
||||
checkRootElementPresent('body div[role="dialog"]');
|
||||
// This check couldn't pass unless api response was formatted properly
|
||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
||||
wrapper.find('Modal button[aria-label="Close"]').simulate('click');
|
||||
test('should open and close modal', async () => {
|
||||
wrapper = await mountWrapper();
|
||||
checkRootElementNotPresent('body div[role="dialog"]');
|
||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
||||
checkRootElementPresent('body div[role="dialog"]');
|
||||
const list = wrapper.find('TestList');
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list.prop('state')).toEqual({
|
||||
selectedItems: [{ id: 1, name: 'foo', url: '/api/v2/item/1' }],
|
||||
value: [{ id: 1, name: 'foo', url: '/api/v2/item/1' }],
|
||||
multiple: true,
|
||||
isModalOpen: true,
|
||||
required: false,
|
||||
});
|
||||
expect(list.prop('dispatch')).toBeTruthy();
|
||||
expect(list.prop('canDelete')).toEqual(true);
|
||||
wrapper
|
||||
.find('Modal button')
|
||||
.findWhere(e => e.text() === 'Cancel')
|
||||
.first()
|
||||
.simulate('click');
|
||||
checkRootElementNotPresent('body div[role="dialog"]');
|
||||
done();
|
||||
});
|
||||
|
||||
test('Change selected item with radio control then save', async done => {
|
||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
||||
await checkModalTagValues(wrapper, ['foo']);
|
||||
test('should remove item when X button clicked', async () => {
|
||||
wrapper = await mountWrapper();
|
||||
await checkInputTagValues(wrapper, ['foo']);
|
||||
wrapper
|
||||
.find('DataListItemRow')
|
||||
.findWhere(el => el.text() === 'bar')
|
||||
.find('input[type="radio"]')
|
||||
.simulate('change');
|
||||
await checkModalListValues(wrapper, [[false, 'foo'], [true, 'bar']]);
|
||||
await checkModalTagValues(wrapper, ['bar']);
|
||||
wrapper
|
||||
.find('Modal button')
|
||||
.findWhere(e => e.text() === 'Save')
|
||||
.find('Lookup InputGroup Chip')
|
||||
.findWhere(el => el.text() === 'foo')
|
||||
.first()
|
||||
.simulate('click');
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
const [[{ name }]] = onChange.mock.calls;
|
||||
expect(name).toEqual('bar');
|
||||
done();
|
||||
});
|
||||
|
||||
test('Change selected item with checkbox then cancel', async done => {
|
||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
||||
await checkModalTagValues(wrapper, ['foo']);
|
||||
wrapper
|
||||
.find('DataListItemRow')
|
||||
.findWhere(el => el.text() === 'bar')
|
||||
.find('input[type="radio"]')
|
||||
.simulate('change');
|
||||
await checkModalListValues(wrapper, [[false, 'foo'], [true, 'bar']]);
|
||||
await checkModalTagValues(wrapper, ['bar']);
|
||||
wrapper
|
||||
.find('Modal button')
|
||||
.findWhere(e => e.text() === 'Cancel')
|
||||
.first()
|
||||
.simulate('click');
|
||||
expect(onChange).toHaveBeenCalledTimes(0);
|
||||
done();
|
||||
});
|
||||
|
||||
test('should re-fetch data when URL params change', async done => {
|
||||
mockData = [{ name: 'foo', id: 1, isChecked: false }];
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/organizations/add'],
|
||||
});
|
||||
const getItems = jest.fn();
|
||||
const LookupWrapper = mountWithContexts(
|
||||
<_Lookup
|
||||
multiple
|
||||
name="foo"
|
||||
lookupHeader="Foo Bar"
|
||||
onLookupSave={() => {}}
|
||||
value={mockData}
|
||||
columns={mockColumns}
|
||||
sortedColumnKey="name"
|
||||
getItems={getItems}
|
||||
location={{ history }}
|
||||
i18n={{ _: val => val.toString() }}
|
||||
/>
|
||||
);
|
||||
expect(getItems).toHaveBeenCalledTimes(1);
|
||||
history.push('organizations/add?page=2');
|
||||
LookupWrapper.setProps({
|
||||
location: { history },
|
||||
});
|
||||
LookupWrapper.update();
|
||||
expect(getItems).toHaveBeenCalledTimes(2);
|
||||
done();
|
||||
});
|
||||
|
||||
test('should clear its query params when closed', async () => {
|
||||
mockData = [{ name: 'foo', id: 1, isChecked: false }];
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/organizations/add?inventory.name=foo&bar=baz'],
|
||||
});
|
||||
wrapper = mountWithContexts(
|
||||
<_Lookup
|
||||
multiple
|
||||
name="foo"
|
||||
lookupHeader="Foo Bar"
|
||||
onLookupSave={() => {}}
|
||||
value={mockData}
|
||||
columns={mockColumns}
|
||||
sortedColumnKey="name"
|
||||
getItems={() => {}}
|
||||
location={{ history }}
|
||||
history={history}
|
||||
qsNamespace="inventory"
|
||||
i18n={{ _: val => val.toString() }}
|
||||
/>
|
||||
);
|
||||
wrapper
|
||||
.find('InputGroup Button')
|
||||
.at(0)
|
||||
.invoke('onClick')();
|
||||
wrapper.find('Modal').invoke('onClose')();
|
||||
expect(history.location.search).toEqual('?bar=baz');
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
test('should pass canDelete false if required single select', async () => {
|
||||
await act(async () => {
|
||||
const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' };
|
||||
wrapper = mountWithContexts(
|
||||
<Lookup
|
||||
id="test"
|
||||
header="Foo Bar"
|
||||
required
|
||||
value={mockSelected}
|
||||
onChange={onChange}
|
||||
qsConfig={QS_CONFIG}
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||
<TestList
|
||||
id="options-list"
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
||||
const list = wrapper.find('TestList');
|
||||
expect(list.prop('canDelete')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,146 +1,167 @@
|
||||
import React from 'react';
|
||||
import React, { Fragment, useState, useEffect } from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { FormGroup, Tooltip } from '@patternfly/react-core';
|
||||
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { FormGroup, ToolbarItem } from '@patternfly/react-core';
|
||||
import { CredentialsAPI, CredentialTypesAPI } from '@api';
|
||||
import Lookup from '@components/Lookup';
|
||||
import AnsibleSelect from '@components/AnsibleSelect';
|
||||
import { FieldTooltip } from '@components/FormField';
|
||||
import { CredentialChip } from '@components/Chip';
|
||||
import VerticalSeperator from '@components/VerticalSeparator';
|
||||
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||
import Lookup from './Lookup';
|
||||
import OptionsList from './shared/OptionsList';
|
||||
|
||||
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
||||
margin-left: 10px;
|
||||
`;
|
||||
const QS_CONFIG = getQSConfig('credentials', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
});
|
||||
|
||||
class MultiCredentialsLookup extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
async function loadCredentialTypes() {
|
||||
const { data } = await CredentialTypesAPI.read();
|
||||
const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault'];
|
||||
return data.results.filter(type => acceptableTypes.includes(type.kind));
|
||||
}
|
||||
|
||||
this.state = {
|
||||
selectedCredentialType: { label: 'Machine', id: 1, kind: 'ssh' },
|
||||
credentialTypes: [],
|
||||
};
|
||||
this.loadCredentialTypes = this.loadCredentialTypes.bind(this);
|
||||
this.handleCredentialTypeSelect = this.handleCredentialTypeSelect.bind(
|
||||
this
|
||||
);
|
||||
this.loadCredentials = this.loadCredentials.bind(this);
|
||||
this.toggleCredentialSelection = this.toggleCredentialSelection.bind(this);
|
||||
}
|
||||
async function loadCredentials(params, selectedCredentialTypeId) {
|
||||
params.credential_type = selectedCredentialTypeId || 1;
|
||||
const { data } = await CredentialsAPI.read(params);
|
||||
return data;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadCredentialTypes();
|
||||
}
|
||||
function MultiCredentialsLookup(props) {
|
||||
const { tooltip, value, onChange, onError, history, i18n } = props;
|
||||
const [credentialTypes, setCredentialTypes] = useState([]);
|
||||
const [selectedType, setSelectedType] = useState(null);
|
||||
const [credentials, setCredentials] = useState([]);
|
||||
const [credentialsCount, setCredentialsCount] = useState(0);
|
||||
|
||||
async loadCredentialTypes() {
|
||||
const { onError } = this.props;
|
||||
try {
|
||||
const { data } = await CredentialTypesAPI.read();
|
||||
const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault'];
|
||||
const credentialTypes = [];
|
||||
data.results.forEach(cred => {
|
||||
acceptableTypes.forEach(aT => {
|
||||
if (aT === cred.kind) {
|
||||
// This object has several repeated values as some of it's children
|
||||
// require different field values.
|
||||
cred = {
|
||||
id: cred.id,
|
||||
key: cred.id,
|
||||
kind: cred.kind,
|
||||
type: cred.namespace,
|
||||
value: cred.name,
|
||||
label: cred.name,
|
||||
isDisabled: false,
|
||||
};
|
||||
credentialTypes.push(cred);
|
||||
}
|
||||
});
|
||||
});
|
||||
this.setState({ credentialTypes });
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const types = await loadCredentialTypes();
|
||||
setCredentialTypes(types);
|
||||
const match = types.find(type => type.kind === 'ssh') || types[0];
|
||||
setSelectedType(match);
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
}
|
||||
})();
|
||||
}, [onError]);
|
||||
|
||||
async loadCredentials(params) {
|
||||
const { selectedCredentialType } = this.state;
|
||||
params.credential_type = selectedCredentialType.id || 1;
|
||||
return CredentialsAPI.read(params);
|
||||
}
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!selectedType) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
const { results, count } = await loadCredentials(
|
||||
params,
|
||||
selectedType.id
|
||||
);
|
||||
setCredentials(results);
|
||||
setCredentialsCount(count);
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
}
|
||||
})();
|
||||
}, [selectedType, history.location.search, onError]);
|
||||
|
||||
toggleCredentialSelection(newCredential) {
|
||||
const { onChange, credentials: credentialsToUpdate } = this.props;
|
||||
const renderChip = ({ item, removeItem, canDelete }) => (
|
||||
<CredentialChip
|
||||
key={item.id}
|
||||
onClick={() => removeItem(item)}
|
||||
isReadOnly={!canDelete}
|
||||
credential={item}
|
||||
/>
|
||||
);
|
||||
|
||||
let newCredentialsList;
|
||||
const isSelectedCredentialInState =
|
||||
credentialsToUpdate.filter(cred => cred.id === newCredential.id).length >
|
||||
0;
|
||||
const isMultiple = selectedType && selectedType.kind === 'vault';
|
||||
|
||||
if (isSelectedCredentialInState) {
|
||||
newCredentialsList = credentialsToUpdate.filter(
|
||||
cred => cred.id !== newCredential.id
|
||||
);
|
||||
} else {
|
||||
newCredentialsList = credentialsToUpdate.filter(
|
||||
credential =>
|
||||
credential.kind === 'vault' || credential.kind !== newCredential.kind
|
||||
);
|
||||
newCredentialsList = [...newCredentialsList, newCredential];
|
||||
}
|
||||
onChange(newCredentialsList);
|
||||
}
|
||||
|
||||
handleCredentialTypeSelect(value, type) {
|
||||
const { credentialTypes } = this.state;
|
||||
const selectedType = credentialTypes.filter(item => item.label === type);
|
||||
this.setState({ selectedCredentialType: selectedType[0] });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { selectedCredentialType, credentialTypes } = this.state;
|
||||
const { tooltip, i18n, credentials } = this.props;
|
||||
return (
|
||||
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential">
|
||||
{tooltip && (
|
||||
<Tooltip position="right" content={tooltip}>
|
||||
<QuestionCircleIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
{credentialTypes && (
|
||||
<Lookup
|
||||
selectCategoryOptions={credentialTypes}
|
||||
selectCategory={this.handleCredentialTypeSelect}
|
||||
selectedCategory={selectedCredentialType}
|
||||
onToggleItem={this.toggleCredentialSelection}
|
||||
onloadCategories={this.loadCredentialTypes}
|
||||
id="multiCredential"
|
||||
lookupHeader={i18n._(t`Credentials`)}
|
||||
name="credentials"
|
||||
value={credentials}
|
||||
multiple
|
||||
onLookupSave={() => {}}
|
||||
getItems={this.loadCredentials}
|
||||
qsNamespace="credentials"
|
||||
columns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isSortable: true,
|
||||
isSearchable: true,
|
||||
},
|
||||
]}
|
||||
sortedColumnKey="name"
|
||||
/>
|
||||
)}
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential">
|
||||
{tooltip && <FieldTooltip content={tooltip} />}
|
||||
<Lookup
|
||||
id="multiCredential"
|
||||
header={i18n._(t`Credentials`)}
|
||||
value={value}
|
||||
multiple
|
||||
onChange={onChange}
|
||||
qsConfig={QS_CONFIG}
|
||||
renderItemChip={renderChip}
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => {
|
||||
return (
|
||||
<Fragment>
|
||||
{credentialTypes && credentialTypes.length > 0 && (
|
||||
<ToolbarItem css=" display: flex; align-items: center;">
|
||||
<div css="flex: 0 0 25%;">{i18n._(t`Selected Category`)}</div>
|
||||
<VerticalSeperator />
|
||||
<AnsibleSelect
|
||||
css="flex: 1 1 75%;"
|
||||
id="multiCredentialsLookUp-select"
|
||||
label={i18n._(t`Selected Category`)}
|
||||
data={credentialTypes.map(type => ({
|
||||
key: type.id,
|
||||
value: type.id,
|
||||
label: type.name,
|
||||
isDisabled: false,
|
||||
}))}
|
||||
value={selectedType && selectedType.id}
|
||||
onChange={(e, id) => {
|
||||
setSelectedType(
|
||||
credentialTypes.find(o => o.id === parseInt(id, 10))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
)}
|
||||
<OptionsList
|
||||
value={state.selectedItems}
|
||||
options={credentials}
|
||||
optionCount={credentialsCount}
|
||||
columns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isSortable: true,
|
||||
isSearchable: true,
|
||||
},
|
||||
]}
|
||||
multiple={isMultiple}
|
||||
header={i18n._(t`Credentials`)}
|
||||
name="credentials"
|
||||
qsConfig={QS_CONFIG}
|
||||
readOnly={!canDelete}
|
||||
selectItem={item => {
|
||||
if (isMultiple) {
|
||||
return dispatch({ type: 'SELECT_ITEM', item });
|
||||
}
|
||||
const selectedItems = state.selectedItems.filter(
|
||||
i => i.kind !== item.kind
|
||||
);
|
||||
selectedItems.push(item);
|
||||
return dispatch({
|
||||
type: 'SET_SELECTED_ITEMS',
|
||||
selectedItems,
|
||||
});
|
||||
}}
|
||||
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||
renderItemChip={renderChip}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
MultiCredentialsLookup.propTypes = {
|
||||
tooltip: PropTypes.string,
|
||||
credentials: PropTypes.arrayOf(
|
||||
value: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.number,
|
||||
name: PropTypes.string,
|
||||
@ -155,8 +176,8 @@ MultiCredentialsLookup.propTypes = {
|
||||
|
||||
MultiCredentialsLookup.defaultProps = {
|
||||
tooltip: '',
|
||||
credentials: [],
|
||||
value: [],
|
||||
};
|
||||
export { MultiCredentialsLookup as _MultiCredentialsLookup };
|
||||
|
||||
export default withI18n()(MultiCredentialsLookup);
|
||||
export { MultiCredentialsLookup as _MultiCredentialsLookup };
|
||||
export default withI18n()(withRouter(MultiCredentialsLookup));
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||
import MultiCredentialsLookup from './MultiCredentialsLookup';
|
||||
import { CredentialsAPI, CredentialTypesAPI } from '@api';
|
||||
|
||||
@ -8,9 +8,6 @@ jest.mock('@api');
|
||||
|
||||
describe('<MultiCredentialsLookup />', () => {
|
||||
let wrapper;
|
||||
let lookup;
|
||||
let credLookup;
|
||||
let onChange;
|
||||
|
||||
const credentials = [
|
||||
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
|
||||
@ -18,8 +15,9 @@ describe('<MultiCredentialsLookup />', () => {
|
||||
{ name: 'Gatsby', id: 21, kind: 'vault' },
|
||||
{ name: 'Gatsby', id: 8, kind: 'Machine' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
CredentialTypesAPI.read.mockResolvedValue({
|
||||
CredentialTypesAPI.read.mockResolvedValueOnce({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
@ -46,17 +44,6 @@ describe('<MultiCredentialsLookup />', () => {
|
||||
count: 3,
|
||||
},
|
||||
});
|
||||
onChange = jest.fn();
|
||||
wrapper = mountWithContexts(
|
||||
<MultiCredentialsLookup
|
||||
onError={() => {}}
|
||||
credentials={credentials}
|
||||
onChange={onChange}
|
||||
tooltip="This is credentials look up"
|
||||
/>
|
||||
);
|
||||
lookup = wrapper.find('Lookup');
|
||||
credLookup = wrapper.find('MultiCredentialsLookup');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -64,16 +51,40 @@ describe('<MultiCredentialsLookup />', () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('MultiCredentialsLookup renders properly', () => {
|
||||
test('MultiCredentialsLookup renders properly', async () => {
|
||||
const onChange = jest.fn();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<MultiCredentialsLookup
|
||||
value={credentials}
|
||||
tooltip="This is credentials look up"
|
||||
onChange={onChange}
|
||||
onError={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('MultiCredentialsLookup')).toHaveLength(1);
|
||||
expect(CredentialTypesAPI.read).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('onChange is called when you click to remove a credential from input', async () => {
|
||||
const chip = wrapper.find('PFChip').find({ isOverflowChip: false });
|
||||
const button = chip.at(1).find('ChipButton');
|
||||
const onChange = jest.fn();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<MultiCredentialsLookup
|
||||
value={credentials}
|
||||
tooltip="This is credentials look up"
|
||||
onChange={onChange}
|
||||
onError={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const chip = wrapper.find('CredentialChip');
|
||||
expect(chip).toHaveLength(4);
|
||||
button.prop('onClick')();
|
||||
const button = chip.at(1).find('ChipButton');
|
||||
await act(async () => {
|
||||
button.invoke('onClick')();
|
||||
});
|
||||
expect(onChange).toBeCalledWith([
|
||||
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
|
||||
{ id: 21, kind: 'vault', name: 'Gatsby' },
|
||||
@ -81,33 +92,122 @@ describe('<MultiCredentialsLookup />', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('can change credential types', () => {
|
||||
lookup.prop('selectCategory')({}, 'Vault');
|
||||
expect(credLookup.state('selectedCredentialType')).toEqual({
|
||||
id: 500,
|
||||
key: 500,
|
||||
kind: 'vault',
|
||||
type: 'buzz',
|
||||
value: 'Vault',
|
||||
label: 'Vault',
|
||||
isDisabled: false,
|
||||
test('should change credential types', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<MultiCredentialsLookup
|
||||
value={credentials}
|
||||
tooltip="This is credentials look up"
|
||||
onChange={() => {}}
|
||||
onError={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(CredentialsAPI.read).toHaveBeenCalled();
|
||||
const searchButton = await waitForElement(wrapper, 'SearchButton');
|
||||
await act(async () => {
|
||||
searchButton.invoke('onClick')();
|
||||
});
|
||||
const select = await waitForElement(wrapper, 'AnsibleSelect');
|
||||
CredentialsAPI.read.mockResolvedValueOnce({
|
||||
data: {
|
||||
results: [
|
||||
{ id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' },
|
||||
],
|
||||
count: 1,
|
||||
},
|
||||
});
|
||||
expect(CredentialsAPI.read).toHaveBeenCalledTimes(2);
|
||||
await act(async () => {
|
||||
select.invoke('onChange')({}, 500);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(CredentialsAPI.read).toHaveBeenCalledTimes(3);
|
||||
expect(wrapper.find('OptionsList').prop('options')).toEqual([
|
||||
{ id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' },
|
||||
]);
|
||||
});
|
||||
test('Toggle credentials only adds 1 credential per credential type except vault(see below)', () => {
|
||||
lookup.prop('onToggleItem')({ name: 'Party', id: 9, kind: 'Machine' });
|
||||
|
||||
test('should only add 1 credential per credential type except vault(see below)', async () => {
|
||||
const onChange = jest.fn();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<MultiCredentialsLookup
|
||||
value={credentials}
|
||||
tooltip="This is credentials look up"
|
||||
onChange={onChange}
|
||||
onError={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const searchButton = await waitForElement(wrapper, 'SearchButton');
|
||||
await act(async () => {
|
||||
searchButton.invoke('onClick')();
|
||||
});
|
||||
wrapper.update();
|
||||
const optionsList = wrapper.find('OptionsList');
|
||||
expect(optionsList.prop('multiple')).toEqual(false);
|
||||
act(() => {
|
||||
optionsList.invoke('selectItem')({
|
||||
id: 5,
|
||||
kind: 'Machine',
|
||||
name: 'Cred 5',
|
||||
url: 'www.google.com',
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
act(() => {
|
||||
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
||||
});
|
||||
expect(onChange).toBeCalledWith([
|
||||
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
|
||||
{ id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
|
||||
{ id: 21, kind: 'vault', name: 'Gatsby' },
|
||||
{ id: 9, kind: 'Machine', name: 'Party' },
|
||||
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
|
||||
]);
|
||||
});
|
||||
test('Toggle credentials only adds 1 credential per credential type', () => {
|
||||
lookup.prop('onToggleItem')({ name: 'Party', id: 22, kind: 'vault' });
|
||||
|
||||
test('should allow multiple vault credentials', async () => {
|
||||
const onChange = jest.fn();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<MultiCredentialsLookup
|
||||
value={credentials}
|
||||
tooltip="This is credentials look up"
|
||||
onChange={onChange}
|
||||
onError={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const searchButton = await waitForElement(wrapper, 'SearchButton');
|
||||
await act(async () => {
|
||||
searchButton.invoke('onClick')();
|
||||
});
|
||||
wrapper.update();
|
||||
const typeSelect = wrapper.find('AnsibleSelect');
|
||||
act(() => {
|
||||
typeSelect.invoke('onChange')({}, 500);
|
||||
});
|
||||
wrapper.update();
|
||||
const optionsList = wrapper.find('OptionsList');
|
||||
expect(optionsList.prop('multiple')).toEqual(true);
|
||||
act(() => {
|
||||
optionsList.invoke('selectItem')({
|
||||
id: 5,
|
||||
kind: 'Machine',
|
||||
name: 'Cred 5',
|
||||
url: 'www.google.com',
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
act(() => {
|
||||
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
||||
});
|
||||
expect(onChange).toBeCalledWith([
|
||||
...credentials,
|
||||
{ name: 'Party', id: 22, kind: 'vault' },
|
||||
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
|
||||
{ id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
|
||||
{ id: 21, kind: 'vault', name: 'Gatsby' },
|
||||
{ id: 8, kind: 'Machine', name: 'Gatsby' },
|
||||
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { string, func, bool } from 'prop-types';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { string, func, bool } from 'prop-types';
|
||||
import { OrganizationsAPI } from '@api';
|
||||
import { Organization } from '@types';
|
||||
import { FormGroup } from '@patternfly/react-core';
|
||||
import Lookup from '@components/Lookup';
|
||||
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||
import Lookup from './Lookup';
|
||||
import OptionsList from './shared/OptionsList';
|
||||
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||
|
||||
const getOrganizations = async params => OrganizationsAPI.read(params);
|
||||
const QS_CONFIG = getQSConfig('organizations', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
});
|
||||
|
||||
function OrganizationLookup({
|
||||
helperTextInvalid,
|
||||
@ -17,7 +25,25 @@ function OrganizationLookup({
|
||||
onChange,
|
||||
required,
|
||||
value,
|
||||
history,
|
||||
}) {
|
||||
const [organizations, setOrganizations] = useState([]);
|
||||
const [count, setCount] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
try {
|
||||
const { data } = await OrganizationsAPI.read(params);
|
||||
setOrganizations(data.results);
|
||||
setCount(data.count);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
})();
|
||||
}, [history.location]);
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId="organization"
|
||||
@ -28,15 +54,29 @@ function OrganizationLookup({
|
||||
>
|
||||
<Lookup
|
||||
id="organization"
|
||||
lookupHeader={i18n._(t`Organization`)}
|
||||
name="organization"
|
||||
header={i18n._(t`Organization`)}
|
||||
value={value}
|
||||
onBlur={onBlur}
|
||||
onLookupSave={onChange}
|
||||
getItems={getOrganizations}
|
||||
onChange={onChange}
|
||||
qsConfig={QS_CONFIG}
|
||||
required={required}
|
||||
sortedColumnKey="name"
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||
<OptionsList
|
||||
value={state.selectedItems}
|
||||
options={organizations}
|
||||
optionCount={count}
|
||||
multiple={state.multiple}
|
||||
header={i18n._(t`Organization`)}
|
||||
name="organization"
|
||||
qsConfig={QS_CONFIG}
|
||||
readOnly={!canDelete}
|
||||
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<LookupErrorMessage error={error} />
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
@ -58,5 +98,5 @@ OrganizationLookup.defaultProps = {
|
||||
value: null,
|
||||
};
|
||||
|
||||
export default withI18n()(OrganizationLookup);
|
||||
export { OrganizationLookup as _OrganizationLookup };
|
||||
export default withI18n()(withRouter(OrganizationLookup));
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup';
|
||||
import { OrganizationsAPI } from '@api';
|
||||
@ -8,18 +9,22 @@ jest.mock('@api');
|
||||
describe('OrganizationLookup', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('initially renders successfully', () => {
|
||||
test('should render successfully', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
|
||||
});
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
test('should fetch organizations', () => {
|
||||
|
||||
test('should fetch organizations', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
|
||||
});
|
||||
expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1);
|
||||
expect(OrganizationsAPI.read).toHaveBeenCalledWith({
|
||||
order_by: 'name',
|
||||
@ -27,11 +32,19 @@ describe('OrganizationLookup', () => {
|
||||
page_size: 5,
|
||||
});
|
||||
});
|
||||
test('should display "Organization" label', () => {
|
||||
|
||||
test('should display "Organization" label', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
|
||||
});
|
||||
const title = wrapper.find('FormGroup .pf-c-form__label-text');
|
||||
expect(title.text()).toEqual('Organization');
|
||||
});
|
||||
test('should define default value for function props', () => {
|
||||
|
||||
test('should define default value for function props', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
|
||||
});
|
||||
expect(_OrganizationLookup.defaultProps.onBlur).toBeInstanceOf(Function);
|
||||
expect(_OrganizationLookup.defaultProps.onBlur).not.toThrow();
|
||||
});
|
||||
|
||||
@ -1,59 +1,90 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { string, func, bool } from 'prop-types';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { FormGroup } from '@patternfly/react-core';
|
||||
import { ProjectsAPI } from '@api';
|
||||
import { Project } from '@types';
|
||||
import Lookup from '@components/Lookup';
|
||||
import { FieldTooltip } from '@components/FormField';
|
||||
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||
import Lookup from './Lookup';
|
||||
import OptionsList from './shared/OptionsList';
|
||||
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||
|
||||
class ProjectLookup extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
helperTextInvalid,
|
||||
i18n,
|
||||
isValid,
|
||||
onChange,
|
||||
required,
|
||||
tooltip,
|
||||
value,
|
||||
onBlur,
|
||||
} = this.props;
|
||||
const QS_CONFIG = getQSConfig('project', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
});
|
||||
|
||||
const loadProjects = async params => {
|
||||
const response = await ProjectsAPI.read(params);
|
||||
const { results, count } = response.data;
|
||||
if (count === 1) {
|
||||
onChange(results[0], 'project');
|
||||
function ProjectLookup({
|
||||
helperTextInvalid,
|
||||
i18n,
|
||||
isValid,
|
||||
onChange,
|
||||
required,
|
||||
tooltip,
|
||||
value,
|
||||
onBlur,
|
||||
history,
|
||||
}) {
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [count, setCount] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
try {
|
||||
const { data } = await ProjectsAPI.read(params);
|
||||
setProjects(data.results);
|
||||
setCount(data.count);
|
||||
if (data.count === 1) {
|
||||
onChange(data.results[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
})();
|
||||
}, [onChange, history.location]);
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId="project"
|
||||
helperTextInvalid={helperTextInvalid}
|
||||
isRequired={required}
|
||||
isValid={isValid}
|
||||
label={i18n._(t`Project`)}
|
||||
>
|
||||
{tooltip && <FieldTooltip content={tooltip} />}
|
||||
<Lookup
|
||||
id="project"
|
||||
lookupHeader={i18n._(t`Project`)}
|
||||
name="project"
|
||||
value={value}
|
||||
onBlur={onBlur}
|
||||
onLookupSave={onChange}
|
||||
getItems={loadProjects}
|
||||
required={required}
|
||||
sortedColumnKey="name"
|
||||
qsNamespace="project"
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId="project"
|
||||
helperTextInvalid={helperTextInvalid}
|
||||
isRequired={required}
|
||||
isValid={isValid}
|
||||
label={i18n._(t`Project`)}
|
||||
>
|
||||
{tooltip && <FieldTooltip content={tooltip} />}
|
||||
<Lookup
|
||||
id="project"
|
||||
header={i18n._(t`Project`)}
|
||||
name="project"
|
||||
value={value}
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
required={required}
|
||||
qsConfig={QS_CONFIG}
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||
<OptionsList
|
||||
value={state.selectedItems}
|
||||
options={projects}
|
||||
optionCount={count}
|
||||
multiple={state.multiple}
|
||||
header={i18n._(t`Project`)}
|
||||
name="project"
|
||||
qsConfig={QS_CONFIG}
|
||||
readOnly={!canDelete}
|
||||
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<LookupErrorMessage error={error} />
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
ProjectLookup.propTypes = {
|
||||
@ -75,4 +106,5 @@ ProjectLookup.defaultProps = {
|
||||
onBlur: () => {},
|
||||
};
|
||||
|
||||
export default withI18n()(ProjectLookup);
|
||||
export { ProjectLookup as _ProjectLookup };
|
||||
export default withI18n()(withRouter(ProjectLookup));
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import { sleep } from '@testUtils/testUtils';
|
||||
import { ProjectsAPI } from '@api';
|
||||
@ -15,9 +16,11 @@ describe('<ProjectLookup />', () => {
|
||||
},
|
||||
});
|
||||
const onChange = jest.fn();
|
||||
mountWithContexts(<ProjectLookup onChange={onChange} />);
|
||||
await act(async () => {
|
||||
mountWithContexts(<ProjectLookup onChange={onChange} />);
|
||||
});
|
||||
await sleep(0);
|
||||
expect(onChange).toHaveBeenCalledWith({ id: 1 }, 'project');
|
||||
expect(onChange).toHaveBeenCalledWith({ id: 1 });
|
||||
});
|
||||
|
||||
test('should not auto-select project when multiple available', async () => {
|
||||
@ -28,7 +31,9 @@ describe('<ProjectLookup />', () => {
|
||||
},
|
||||
});
|
||||
const onChange = jest.fn();
|
||||
mountWithContexts(<ProjectLookup onChange={onChange} />);
|
||||
await act(async () => {
|
||||
mountWithContexts(<ProjectLookup onChange={onChange} />);
|
||||
});
|
||||
await sleep(0);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
5
awx/ui_next/src/components/Lookup/README.md
Normal file
5
awx/ui_next/src/components/Lookup/README.md
Normal 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
|
||||
@ -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);
|
||||
95
awx/ui_next/src/components/Lookup/shared/OptionsList.jsx
Normal file
95
awx/ui_next/src/components/Lookup/shared/OptionsList.jsx
Normal 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);
|
||||
@ -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]]);
|
||||
});
|
||||
});
|
||||
96
awx/ui_next/src/components/Lookup/shared/reducer.js
Normal file
96
awx/ui_next/src/components/Lookup/shared/reducer.js
Normal 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');
|
||||
}
|
||||
}
|
||||
280
awx/ui_next/src/components/Lookup/shared/reducer.test.js
Normal file
280
awx/ui_next/src/components/Lookup/shared/reducer.test.js
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -111,7 +111,14 @@ class PageHeaderToolbar extends Component {
|
||||
</DropdownToggle>
|
||||
}
|
||||
dropdownItems={[
|
||||
<DropdownItem key="user" href="#/home">
|
||||
<DropdownItem
|
||||
key="user"
|
||||
href={
|
||||
loggedInUser
|
||||
? `#/users/${loggedInUser.id}/details`
|
||||
: '#/home'
|
||||
}
|
||||
>
|
||||
{i18n._(t`User Details`)}
|
||||
</DropdownItem>,
|
||||
<DropdownItem
|
||||
|
||||
@ -79,10 +79,10 @@ class ResourceAccessListItem extends React.Component {
|
||||
<DataListCell key="name">
|
||||
{accessRecord.username && (
|
||||
<TextContent>
|
||||
{accessRecord.url ? (
|
||||
{accessRecord.id ? (
|
||||
<Text component={TextVariants.h6}>
|
||||
<Link
|
||||
to={{ pathname: accessRecord.url }}
|
||||
to={{ pathname: `/users/${accessRecord.id}/details` }}
|
||||
css="font-weight: bold"
|
||||
>
|
||||
{accessRecord.username}
|
||||
|
||||
@ -58,7 +58,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<ForwardRef
|
||||
to={
|
||||
Object {
|
||||
"pathname": "/bar",
|
||||
"pathname": "/users/2/details",
|
||||
}
|
||||
}
|
||||
>
|
||||
@ -114,7 +114,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<ForwardRef
|
||||
to={
|
||||
Object {
|
||||
"pathname": "/bar",
|
||||
"pathname": "/users/2/details",
|
||||
}
|
||||
}
|
||||
>
|
||||
@ -193,7 +193,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<ForwardRef
|
||||
to={
|
||||
Object {
|
||||
"pathname": "/bar",
|
||||
"pathname": "/users/2/details",
|
||||
}
|
||||
}
|
||||
>
|
||||
@ -260,7 +260,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Styled(Link)
|
||||
to={
|
||||
Object {
|
||||
"pathname": "/bar",
|
||||
"pathname": "/users/2/details",
|
||||
}
|
||||
}
|
||||
>
|
||||
@ -308,7 +308,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
forwardedRef={null}
|
||||
to={
|
||||
Object {
|
||||
"pathname": "/bar",
|
||||
"pathname": "/users/2/details",
|
||||
}
|
||||
}
|
||||
>
|
||||
@ -316,18 +316,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
className="sc-bdVaJa fqQVUT"
|
||||
to={
|
||||
Object {
|
||||
"pathname": "/bar",
|
||||
"pathname": "/users/2/details",
|
||||
}
|
||||
}
|
||||
>
|
||||
<LinkAnchor
|
||||
className="sc-bdVaJa fqQVUT"
|
||||
href="/bar"
|
||||
href="/users/2/details"
|
||||
navigate={[Function]}
|
||||
>
|
||||
<a
|
||||
className="sc-bdVaJa fqQVUT"
|
||||
href="/bar"
|
||||
href="/users/2/details"
|
||||
onClick={[Function]}
|
||||
>
|
||||
jane
|
||||
|
||||
@ -3,6 +3,7 @@ import { shape, string, number, arrayOf } from 'prop-types';
|
||||
import { Tab, Tabs as PFTabs } from '@patternfly/react-core';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
|
||||
const Tabs = styled(PFTabs)`
|
||||
--pf-c-tabs__button--PaddingLeft: 20px;
|
||||
@ -62,7 +63,15 @@ function RoutedTabs(props) {
|
||||
eventKey={tab.id}
|
||||
key={tab.id}
|
||||
link={tab.link}
|
||||
title={tab.name}
|
||||
title={
|
||||
tab.isNestedTabs ? (
|
||||
<>
|
||||
<CaretLeftIcon /> {tab.name}
|
||||
</>
|
||||
) : (
|
||||
tab.name
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Split as PFSplit, SplitItem } from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
import { ChipGroup, Chip, CredentialChip } from '../Chip';
|
||||
import { ChipGroup, Chip } from '../Chip';
|
||||
import VerticalSeparator from '../VerticalSeparator';
|
||||
|
||||
const Split = styled(PFSplit)`
|
||||
@ -26,34 +26,31 @@ class SelectedList extends Component {
|
||||
onRemove,
|
||||
displayKey,
|
||||
isReadOnly,
|
||||
isCredentialList,
|
||||
renderItemChip,
|
||||
} = this.props;
|
||||
const chips = isCredentialList
|
||||
? selected.map(item => (
|
||||
<CredentialChip
|
||||
key={item.id}
|
||||
isReadOnly={isReadOnly}
|
||||
onClick={() => onRemove(item)}
|
||||
credential={item}
|
||||
>
|
||||
{item[displayKey]}
|
||||
</CredentialChip>
|
||||
))
|
||||
: selected.map(item => (
|
||||
<Chip
|
||||
key={item.id}
|
||||
isReadOnly={isReadOnly}
|
||||
onClick={() => onRemove(item)}
|
||||
>
|
||||
{item[displayKey]}
|
||||
</Chip>
|
||||
));
|
||||
|
||||
const renderChip =
|
||||
renderItemChip ||
|
||||
(({ item, removeItem }) => (
|
||||
<Chip key={item.id} onClick={removeItem} isReadOnly={isReadOnly}>
|
||||
{item[displayKey]}
|
||||
</Chip>
|
||||
));
|
||||
|
||||
return (
|
||||
<Split>
|
||||
<SplitLabelItem>{label}</SplitLabelItem>
|
||||
<VerticalSeparator />
|
||||
<SplitItem>
|
||||
<ChipGroup numChips={5}>{chips}</ChipGroup>
|
||||
<ChipGroup numChips={5}>
|
||||
{selected.map(item =>
|
||||
renderChip({
|
||||
item,
|
||||
removeItem: () => onRemove(item),
|
||||
canDelete: !isReadOnly,
|
||||
})
|
||||
)}
|
||||
</ChipGroup>
|
||||
</SplitItem>
|
||||
</Split>
|
||||
);
|
||||
@ -66,6 +63,7 @@ SelectedList.propTypes = {
|
||||
onRemove: PropTypes.func,
|
||||
selected: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isReadOnly: PropTypes.bool,
|
||||
renderItemChip: PropTypes.func,
|
||||
};
|
||||
|
||||
SelectedList.defaultProps = {
|
||||
@ -73,6 +71,7 @@ SelectedList.defaultProps = {
|
||||
label: 'Selected',
|
||||
onRemove: () => null,
|
||||
isReadOnly: false,
|
||||
renderItemChip: null,
|
||||
};
|
||||
|
||||
export default SelectedList;
|
||||
|
||||
@ -15,7 +15,7 @@ import CredentialTypes from '@screens/CredentialType';
|
||||
import Dashboard from '@screens/Dashboard';
|
||||
import Hosts from '@screens/Host';
|
||||
import InstanceGroups from '@screens/InstanceGroup';
|
||||
import Inventories from '@screens/Inventory';
|
||||
import Inventory from '@screens/Inventory';
|
||||
import InventoryScripts from '@screens/InventoryScript';
|
||||
import { Jobs } from '@screens/Job';
|
||||
import Login from '@screens/Login';
|
||||
@ -139,7 +139,7 @@ export function main(render) {
|
||||
{
|
||||
title: i18n._(t`Inventories`),
|
||||
path: '/inventories',
|
||||
component: Inventories,
|
||||
component: Inventory,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Hosts`),
|
||||
|
||||
@ -1,20 +1,10 @@
|
||||
import React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
PageSection,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
|
||||
import { PageSection, Card, CardBody } from '@patternfly/react-core';
|
||||
import { HostsAPI } from '@api';
|
||||
import { Config } from '@contexts/Config';
|
||||
import CardCloseButton from '@components/CardCloseButton';
|
||||
|
||||
import HostForm from '../shared/HostForm';
|
||||
import HostForm from '../shared';
|
||||
|
||||
class HostAdd extends React.Component {
|
||||
constructor(props) {
|
||||
@ -41,16 +31,10 @@ class HostAdd extends React.Component {
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
const { i18n } = this.props;
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<CardHeader className="at-u-textRight">
|
||||
<Tooltip content={i18n._(t`Close`)} position="top">
|
||||
<CardCloseButton onClick={this.handleCancel} />
|
||||
</Tooltip>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Config>
|
||||
{({ me }) => (
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||
import HostAdd from './HostAdd';
|
||||
@ -7,8 +8,11 @@ import { HostsAPI } from '@api';
|
||||
jest.mock('@api');
|
||||
|
||||
describe('<HostAdd />', () => {
|
||||
test('handleSubmit should post to api', () => {
|
||||
const wrapper = mountWithContexts(<HostAdd />);
|
||||
test('handleSubmit should post to api', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<HostAdd />);
|
||||
});
|
||||
const updatedHostData = {
|
||||
name: 'new name',
|
||||
description: 'new description',
|
||||
@ -19,21 +23,15 @@ describe('<HostAdd />', () => {
|
||||
expect(HostsAPI.create).toHaveBeenCalledWith(updatedHostData);
|
||||
});
|
||||
|
||||
test('should navigate to hosts list when cancel is clicked', () => {
|
||||
test('should navigate to hosts list when cancel is clicked', async () => {
|
||||
const history = createMemoryHistory({});
|
||||
const wrapper = mountWithContexts(<HostAdd />, {
|
||||
context: { router: { history } },
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<HostAdd />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
||||
expect(history.location.pathname).toEqual('/hosts');
|
||||
});
|
||||
|
||||
test('should navigate to hosts list when close (x) is clicked', () => {
|
||||
const history = createMemoryHistory({});
|
||||
const wrapper = mountWithContexts(<HostAdd />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
wrapper.find('button[aria-label="Close"]').prop('onClick')();
|
||||
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||
expect(history.location.pathname).toEqual('/hosts');
|
||||
});
|
||||
|
||||
@ -51,11 +49,14 @@ describe('<HostAdd />', () => {
|
||||
...hostData,
|
||||
},
|
||||
});
|
||||
const wrapper = mountWithContexts(<HostAdd />, {
|
||||
context: { router: { history } },
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<HostAdd />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
await waitForElement(wrapper, 'button[aria-label="Save"]');
|
||||
await wrapper.find('HostForm').prop('handleSubmit')(hostData);
|
||||
await wrapper.find('HostForm').invoke('handleSubmit')(hostData);
|
||||
expect(history.location.pathname).toEqual('/hosts/5');
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,7 +6,7 @@ import { CardBody } from '@patternfly/react-core';
|
||||
import { HostsAPI } from '@api';
|
||||
import { Config } from '@contexts/Config';
|
||||
|
||||
import HostForm from '../shared/HostForm';
|
||||
import HostForm from '../shared';
|
||||
|
||||
class HostEdit extends Component {
|
||||
constructor(props) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useState } from 'react';
|
||||
import { func, shape } from 'prop-types';
|
||||
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { Formik, Field } from 'formik';
|
||||
@ -15,120 +15,86 @@ import { VariablesField } from '@components/CodeMirrorInput';
|
||||
import { required } from '@util/validators';
|
||||
import { InventoryLookup } from '@components/Lookup';
|
||||
|
||||
class HostForm extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
function HostForm({ handleSubmit, handleCancel, host, i18n }) {
|
||||
const [inventory, setInventory] = useState(
|
||||
host ? host.summary_fields.inventory : ''
|
||||
);
|
||||
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
|
||||
this.state = {
|
||||
formIsValid: true,
|
||||
inventory: props.host.summary_fields.inventory,
|
||||
};
|
||||
}
|
||||
|
||||
handleSubmit(values) {
|
||||
const { handleSubmit } = this.props;
|
||||
|
||||
handleSubmit(values);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { host, handleCancel, i18n } = this.props;
|
||||
const { formIsValid, inventory, error } = this.state;
|
||||
|
||||
const initialValues = !host.id
|
||||
? {
|
||||
name: host.name,
|
||||
description: host.description,
|
||||
inventory: host.inventory || '',
|
||||
variables: host.variables,
|
||||
}
|
||||
: {
|
||||
name: host.name,
|
||||
description: host.description,
|
||||
variables: host.variables,
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={this.handleSubmit}
|
||||
render={formik => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormRow>
|
||||
<FormField
|
||||
id="host-name"
|
||||
name="name"
|
||||
type="text"
|
||||
label={i18n._(t`Name`)}
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
/>
|
||||
<FormField
|
||||
id="host-description"
|
||||
name="description"
|
||||
type="text"
|
||||
label={i18n._(t`Description`)}
|
||||
/>
|
||||
{!host.id && (
|
||||
<Field
|
||||
name="inventory"
|
||||
validate={required(
|
||||
i18n._(t`Select a value for this field`),
|
||||
i18n
|
||||
)}
|
||||
render={({ form }) => (
|
||||
<InventoryLookup
|
||||
value={inventory}
|
||||
onBlur={() => form.setFieldTouched('inventory')}
|
||||
tooltip={i18n._(
|
||||
t`Select the inventory that this host will belong to.`
|
||||
)}
|
||||
isValid={
|
||||
!form.touched.inventory || !form.errors.inventory
|
||||
}
|
||||
helperTextInvalid={form.errors.inventory}
|
||||
onChange={value => {
|
||||
form.setFieldValue('inventory', value.id);
|
||||
this.setState({ inventory: value });
|
||||
}}
|
||||
required
|
||||
touched={form.touched.inventory}
|
||||
error={form.errors.inventory}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<VariablesField
|
||||
id="host-variables"
|
||||
name="variables"
|
||||
label={i18n._(t`Variables`)}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
submitDisabled={!formIsValid}
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: host.name,
|
||||
description: host.description,
|
||||
inventory: host.inventory || '',
|
||||
variables: host.variables,
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
render={formik => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormRow>
|
||||
<FormField
|
||||
id="host-name"
|
||||
name="name"
|
||||
type="text"
|
||||
label={i18n._(t`Name`)}
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
/>
|
||||
{error ? <div>error</div> : null}
|
||||
</Form>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
<FormField
|
||||
id="host-description"
|
||||
name="description"
|
||||
type="text"
|
||||
label={i18n._(t`Description`)}
|
||||
/>
|
||||
{!host.id && (
|
||||
<Field
|
||||
name="inventory"
|
||||
validate={required(
|
||||
i18n._(t`Select a value for this field`),
|
||||
i18n
|
||||
)}
|
||||
render={({ form }) => (
|
||||
<InventoryLookup
|
||||
value={inventory}
|
||||
onBlur={() => form.setFieldTouched('inventory')}
|
||||
tooltip={i18n._(
|
||||
t`Select the inventory that this host will belong to.`
|
||||
)}
|
||||
isValid={!form.touched.inventory || !form.errors.inventory}
|
||||
helperTextInvalid={form.errors.inventory}
|
||||
onChange={value => {
|
||||
form.setFieldValue('inventory', value.id);
|
||||
setInventory(value);
|
||||
}}
|
||||
required
|
||||
touched={form.touched.inventory}
|
||||
error={form.errors.inventory}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<VariablesField
|
||||
id="host-variables"
|
||||
name="variables"
|
||||
label={i18n._(t`Variables`)}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
FormField.propTypes = {
|
||||
label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
|
||||
};
|
||||
|
||||
HostForm.propTypes = {
|
||||
host: PropTypes.shape(),
|
||||
handleSubmit: PropTypes.func.isRequired,
|
||||
handleCancel: PropTypes.func.isRequired,
|
||||
handleSubmit: func.isRequired,
|
||||
handleCancel: func.isRequired,
|
||||
host: shape({}),
|
||||
};
|
||||
|
||||
HostForm.defaultProps = {
|
||||
|
||||
@ -65,11 +65,7 @@ describe('<HostForm />', () => {
|
||||
expect(handleSubmit).not.toHaveBeenCalled();
|
||||
wrapper.find('button[aria-label="Save"]').simulate('click');
|
||||
await sleep(1);
|
||||
expect(handleSubmit).toHaveBeenCalledWith({
|
||||
name: 'Foo',
|
||||
description: 'Bar',
|
||||
variables: '---',
|
||||
});
|
||||
expect(handleSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('calls "handleCancel" when Cancel button is clicked', () => {
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './HostForm';
|
||||
@ -27,7 +27,7 @@ class Inventories extends Component {
|
||||
};
|
||||
}
|
||||
|
||||
setBreadCrumbConfig = inventory => {
|
||||
setBreadCrumbConfig = (inventory, group) => {
|
||||
const { i18n } = this.props;
|
||||
if (!inventory) {
|
||||
return;
|
||||
@ -57,6 +57,15 @@ class Inventories extends Component {
|
||||
),
|
||||
[`/inventories/inventory/${inventory.id}/sources`]: i18n._(t`Sources`),
|
||||
[`/inventories/inventory/${inventory.id}/groups`]: i18n._(t`Groups`),
|
||||
[`/inventories/inventory/${inventory.id}/groups/add`]: i18n._(
|
||||
t`Create New Group`
|
||||
),
|
||||
[`/inventories/inventory/${inventory.id}/groups/${group &&
|
||||
group.id}`]: `${group && group.name}`,
|
||||
[`/inventories/inventory/${inventory.id}/groups/${group &&
|
||||
group.id}/details`]: i18n._(t`Group Details`),
|
||||
[`/inventories/inventory/${inventory.id}/groups/${group &&
|
||||
group.id}/edit`]: i18n._(t`Edit Details`),
|
||||
};
|
||||
this.setState({ breadcrumbConfig });
|
||||
};
|
||||
|
||||
@ -57,7 +57,11 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
|
||||
</CardHeader>
|
||||
);
|
||||
|
||||
if (location.pathname.endsWith('edit') || location.pathname.endsWith('add')) {
|
||||
if (
|
||||
location.pathname.endsWith('edit') ||
|
||||
location.pathname.endsWith('add') ||
|
||||
location.pathname.includes('groups/')
|
||||
) {
|
||||
cardHeader = null;
|
||||
}
|
||||
|
||||
@ -123,7 +127,15 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
|
||||
<Route
|
||||
key="groups"
|
||||
path="/inventories/inventory/:id/groups"
|
||||
render={() => <InventoryGroups inventory={inventory} />}
|
||||
render={() => (
|
||||
<InventoryGroups
|
||||
location={location}
|
||||
match={match}
|
||||
history={history}
|
||||
setBreadcrumb={setBreadcrumb}
|
||||
inventory={inventory}
|
||||
/>
|
||||
)}
|
||||
/>,
|
||||
<Route
|
||||
key="hosts"
|
||||
|
||||
@ -15,7 +15,7 @@ import { getAddedAndRemoved } from '../../../util/lists';
|
||||
function InventoryEdit({ history, i18n, inventory }) {
|
||||
const [error, setError] = useState(null);
|
||||
const [associatedInstanceGroups, setInstanceGroups] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [contentLoading, setContentLoading] = useState(true);
|
||||
const [credentialTypeId, setCredentialTypeId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -39,11 +39,11 @@ function InventoryEdit({ history, i18n, inventory }) {
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setContentLoading(false);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, [inventory.id, isLoading, inventory, credentialTypeId]);
|
||||
}, [inventory.id, contentLoading, inventory, credentialTypeId]);
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push('/inventories');
|
||||
@ -85,7 +85,7 @@ function InventoryEdit({ history, i18n, inventory }) {
|
||||
history.push(`${url}`);
|
||||
}
|
||||
};
|
||||
if (isLoading) {
|
||||
if (contentLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
if (error) {
|
||||
|
||||
@ -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));
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryGroup';
|
||||
@ -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 };
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryGroupAdd';
|
||||
@ -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));
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryGroupDetail';
|
||||
@ -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 };
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryGroupEdit';
|
||||
@ -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);
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -1,10 +1,45 @@
|
||||
import React, { Component } from 'react';
|
||||
import { CardBody } from '@patternfly/react-core';
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
|
||||
class InventoryGroups extends Component {
|
||||
render() {
|
||||
return <CardBody>Coming soon :)</CardBody>;
|
||||
}
|
||||
import { Switch, Route, withRouter } from 'react-router-dom';
|
||||
|
||||
import InventoryGroupAdd from '../InventoryGroupAdd/InventoryGroupAdd';
|
||||
|
||||
import InventoryGroup from '../InventoryGroup/InventoryGroup';
|
||||
import InventoryGroupsList from './InventoryGroupsList';
|
||||
|
||||
function InventoryGroups({ setBreadcrumb, inventory, location, match }) {
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
key="add"
|
||||
path="/inventories/inventory/:id/groups/add"
|
||||
render={() => {
|
||||
return (
|
||||
<InventoryGroupAdd
|
||||
setBreadcrumb={setBreadcrumb}
|
||||
inventory={inventory}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
key="details"
|
||||
path="/inventories/inventory/:id/groups/:groupId/"
|
||||
render={() => (
|
||||
<InventoryGroup inventory={inventory} setBreadcrumb={setBreadcrumb} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
key="list"
|
||||
path="/inventories/inventory/:id/groups"
|
||||
render={() => {
|
||||
return <InventoryGroupsList location={location} match={match} />;
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
export default InventoryGroups;
|
||||
export { InventoryGroups as _InventoryGroups };
|
||||
export default withI18n()(withRouter(InventoryGroups));
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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));
|
||||
@ -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')();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,8 +1,36 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { CardBody } from '@patternfly/react-core';
|
||||
import InventoryHostForm from '../shared/InventoryHostForm';
|
||||
import { InventoriesAPI } from '@api';
|
||||
|
||||
function InventoryHostAdd() {
|
||||
return <CardBody>Coming soon :)</CardBody>;
|
||||
const [formError, setFormError] = useState(null);
|
||||
const history = useHistory();
|
||||
const { id } = useParams();
|
||||
|
||||
const handleSubmit = async values => {
|
||||
try {
|
||||
const { data: response } = await InventoriesAPI.createHost(id, values);
|
||||
history.push(`/inventories/inventory/${id}/hosts/${response.id}/details`);
|
||||
} catch (error) {
|
||||
setFormError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push(`/inventories/inventory/${id}/hosts`);
|
||||
};
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
<InventoryHostForm
|
||||
handleSubmit={handleSubmit}
|
||||
handleCancel={handleCancel}
|
||||
/>
|
||||
{formError ? <div className="formSubmitError">error</div> : ''}
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default InventoryHostAdd;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user