Merge branch 'fix-awx_collection-docs' of github.com:sean-m-sullivan/awx into fix-awx_collection-docs

This commit is contained in:
sean-m-sullivan 2021-02-10 15:45:38 -06:00
commit ed8b4a3c70
176 changed files with 6352 additions and 3269 deletions

View File

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

View File

@ -828,6 +828,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
return self.inventory.hosts.only(*only)
def start_job_fact_cache(self, destination, modification_times, timeout=None):
self.log_lifecycle("start_job_fact_cache")
os.makedirs(destination, mode=0o700)
hosts = self._get_inventory_hosts()
if timeout is None:
@ -852,6 +853,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
modification_times[filepath] = os.path.getmtime(filepath)
def finish_job_fact_cache(self, destination, modification_times):
self.log_lifecycle("finish_job_fact_cache")
for host in self._get_inventory_hosts():
filepath = os.sep.join(map(str, [destination, host.name]))
if not os.path.realpath(filepath).startswith(destination):

View File

@ -280,6 +280,7 @@ class JobNotificationMixin(object):
{'unified_job_template': ['id', 'name', 'description', 'unified_job_type']},
{'instance_group': ['name', 'id']},
{'created_by': ['id', 'username', 'first_name', 'last_name']},
{'schedule': ['id', 'name', 'description', 'next_run']},
{'labels': ['count', 'results']}]}]
@classmethod
@ -344,6 +345,10 @@ class JobNotificationMixin(object):
'name': 'Stub project',
'scm_type': 'git',
'status': 'successful'},
'schedule': {'description': 'Sample schedule',
'id': 42,
'name': 'Stub schedule',
'next_run': datetime.datetime(2038, 1, 1, 0, 0, 0, 0, tzinfo=datetime.timezone.utc)},
'unified_job_template': {'description': 'Sample unified job template description',
'id': 39,
'name': 'Stub Job Template',

View File

@ -55,7 +55,7 @@ from awx.main.fields import JSONField, AskForField, OrderedManyToManyField
__all__ = ['UnifiedJobTemplate', 'UnifiedJob', 'StdoutMaxBytesExceeded']
logger = logging.getLogger('awx.main.models.unified_jobs')
logger_job_lifecycle = logging.getLogger('awx.analytics.job_lifecycle')
# NOTE: ACTIVE_STATES moved to constants because it is used by parent modules
@ -420,7 +420,7 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio
# have been associated to the UJ
if unified_job.__class__ in activity_stream_registrar.models:
activity_stream_create(None, unified_job, True)
unified_job.log_lifecycle("created")
return unified_job
@classmethod
@ -862,7 +862,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
self.unified_job_template = self._get_parent_instance()
if 'unified_job_template' not in update_fields:
update_fields.append('unified_job_template')
if self.cancel_flag and not self.canceled_on:
# Record the 'canceled' time.
self.canceled_on = now()
@ -1010,6 +1010,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
event_qs = self.get_event_queryset()
except NotImplementedError:
return True # Model without events, such as WFJT
self.log_lifecycle("event_processing_finished")
return self.emitted_events == event_qs.count()
def result_stdout_raw_handle(self, enforce_max_bytes=True):
@ -1318,6 +1319,10 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
if 'extra_vars' in kwargs:
self.handle_extra_data(kwargs['extra_vars'])
# remove any job_explanations that may have been set while job was in pending
if self.job_explanation != "":
self.job_explanation = ""
return (True, opts)
def signal_start(self, **kwargs):
@ -1484,3 +1489,17 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
@property
def is_containerized(self):
return False
def log_lifecycle(self, state, blocked_by=None):
extra={'type': self._meta.model_name,
'task_id': self.id,
'state': state}
if self.unified_job_template:
extra["template_name"] = self.unified_job_template.name
if state == "blocked" and blocked_by:
blocked_by_msg = f"{blocked_by._meta.model_name}-{blocked_by.id}"
msg = f"{self._meta.model_name}-{self.id} blocked by {blocked_by_msg}"
extra["blocked_by"] = blocked_by_msg
else:
msg = f"{self._meta.model_name}-{self.id} {state.replace('_', ' ')}"
logger_job_lifecycle.debug(msg, extra=extra)

View File

@ -1,5 +1,3 @@
from django.utils.timezone import now as tz_now
from awx.main.models import (
Job,
ProjectUpdate,
@ -20,119 +18,110 @@ class DependencyGraph(object):
INVENTORY_SOURCE_UPDATES = 'inventory_source_updates'
WORKFLOW_JOB_TEMPLATES_JOBS = 'workflow_job_template_jobs'
LATEST_PROJECT_UPDATES = 'latest_project_updates'
LATEST_INVENTORY_UPDATES = 'latest_inventory_updates'
INVENTORY_SOURCES = 'inventory_source_ids'
def __init__(self, queue):
self.queue = queue
def __init__(self):
self.data = {}
# project_id -> True / False
self.data[self.PROJECT_UPDATES] = {}
# inventory_id -> True / False
# The reason for tracking both inventory and inventory sources:
# Consider InvA, which has two sources, InvSource1, InvSource2.
# JobB might depend on InvA, which launches two updates, one for each source.
# To determine if JobB can run, we can just check InvA, which is marked in
# INVENTORY_UPDATES, instead of having to check for both entries in
# INVENTORY_SOURCE_UPDATES.
self.data[self.INVENTORY_UPDATES] = {}
# job_template_id -> True / False
self.data[self.JOB_TEMPLATE_JOBS] = {}
'''
Track runnable job related project and inventory to ensure updates
don't run while a job needing those resources is running.
'''
# inventory_source_id -> True / False
self.data[self.INVENTORY_SOURCE_UPDATES] = {}
# True / False
self.data[self.SYSTEM_JOB] = True
# workflow_job_template_id -> True / False
self.data[self.JOB_TEMPLATE_JOBS] = {}
self.data[self.SYSTEM_JOB] = {}
self.data[self.WORKFLOW_JOB_TEMPLATES_JOBS] = {}
# project_id -> latest ProjectUpdateLatestDict'
self.data[self.LATEST_PROJECT_UPDATES] = {}
# inventory_source_id -> latest InventoryUpdateLatestDict
self.data[self.LATEST_INVENTORY_UPDATES] = {}
def mark_if_no_key(self, job_type, id, job):
# only mark first occurrence of a task. If 10 of JobA are launched
# (concurrent disabled), the dependency graph should return that jobs
# 2 through 10 are blocked by job1
if id not in self.data[job_type]:
self.data[job_type][id] = job
# inventory_id -> [inventory_source_ids]
self.data[self.INVENTORY_SOURCES] = {}
def get_item(self, job_type, id):
return self.data[job_type].get(id, None)
def add_latest_project_update(self, job):
self.data[self.LATEST_PROJECT_UPDATES][job.project_id] = job
def get_now(self):
return tz_now()
def mark_system_job(self):
self.data[self.SYSTEM_JOB] = False
def mark_system_job(self, job):
# Don't track different types of system jobs, so that only one can run
# at a time. Therefore id in this case is just 'system_job'.
self.mark_if_no_key(self.SYSTEM_JOB, 'system_job', job)
def mark_project_update(self, job):
self.data[self.PROJECT_UPDATES][job.project_id] = False
self.mark_if_no_key(self.PROJECT_UPDATES, job.project_id, job)
def mark_inventory_update(self, inventory_id):
self.data[self.INVENTORY_UPDATES][inventory_id] = False
def mark_inventory_update(self, job):
if type(job) is AdHocCommand:
self.mark_if_no_key(self.INVENTORY_UPDATES, job.inventory_id, job)
else:
self.mark_if_no_key(self.INVENTORY_UPDATES, job.inventory_source.inventory_id, job)
def mark_inventory_source_update(self, inventory_source_id):
self.data[self.INVENTORY_SOURCE_UPDATES][inventory_source_id] = False
def mark_inventory_source_update(self, job):
self.mark_if_no_key(self.INVENTORY_SOURCE_UPDATES, job.inventory_source_id, job)
def mark_job_template_job(self, job):
self.data[self.JOB_TEMPLATE_JOBS][job.job_template_id] = False
self.mark_if_no_key(self.JOB_TEMPLATE_JOBS, job.job_template_id, job)
def mark_workflow_job(self, job):
self.data[self.WORKFLOW_JOB_TEMPLATES_JOBS][job.workflow_job_template_id] = False
self.mark_if_no_key(self.WORKFLOW_JOB_TEMPLATES_JOBS, job.workflow_job_template_id, job)
def can_project_update_run(self, job):
return self.data[self.PROJECT_UPDATES].get(job.project_id, True)
def project_update_blocked_by(self, job):
return self.get_item(self.PROJECT_UPDATES, job.project_id)
def can_inventory_update_run(self, job):
return self.data[self.INVENTORY_SOURCE_UPDATES].get(job.inventory_source_id, True)
def inventory_update_blocked_by(self, job):
return self.get_item(self.INVENTORY_SOURCE_UPDATES, job.inventory_source_id)
def can_job_run(self, job):
if self.data[self.PROJECT_UPDATES].get(job.project_id, True) is True and \
self.data[self.INVENTORY_UPDATES].get(job.inventory_id, True) is True:
if job.allow_simultaneous is False:
return self.data[self.JOB_TEMPLATE_JOBS].get(job.job_template_id, True)
else:
return True
return False
def job_blocked_by(self, job):
project_block = self.get_item(self.PROJECT_UPDATES, job.project_id)
inventory_block = self.get_item(self.INVENTORY_UPDATES, job.inventory_id)
if job.allow_simultaneous is False:
job_block = self.get_item(self.JOB_TEMPLATE_JOBS, job.job_template_id)
else:
job_block = None
return project_block or inventory_block or job_block
def can_workflow_job_run(self, job):
if job.allow_simultaneous:
return True
return self.data[self.WORKFLOW_JOB_TEMPLATES_JOBS].get(job.workflow_job_template_id, True)
def workflow_job_blocked_by(self, job):
if job.allow_simultaneous is False:
return self.get_item(self.WORKFLOW_JOB_TEMPLATES_JOBS, job.workflow_job_template_id)
return None
def can_system_job_run(self):
return self.data[self.SYSTEM_JOB]
def system_job_blocked_by(self, job):
return self.get_item(self.SYSTEM_JOB, 'system_job')
def can_ad_hoc_command_run(self, job):
return self.data[self.INVENTORY_UPDATES].get(job.inventory_id, True)
def ad_hoc_command_blocked_by(self, job):
return self.get_item(self.INVENTORY_UPDATES, job.inventory_id)
def is_job_blocked(self, job):
def task_blocked_by(self, job):
if type(job) is ProjectUpdate:
return not self.can_project_update_run(job)
return self.project_update_blocked_by(job)
elif type(job) is InventoryUpdate:
return not self.can_inventory_update_run(job)
return self.inventory_update_blocked_by(job)
elif type(job) is Job:
return not self.can_job_run(job)
return self.job_blocked_by(job)
elif type(job) is SystemJob:
return not self.can_system_job_run()
return self.system_job_blocked_by(job)
elif type(job) is AdHocCommand:
return not self.can_ad_hoc_command_run(job)
return self.ad_hoc_command_blocked_by(job)
elif type(job) is WorkflowJob:
return not self.can_workflow_job_run(job)
return self.workflow_job_blocked_by(job)
def add_job(self, job):
if type(job) is ProjectUpdate:
self.mark_project_update(job)
elif type(job) is InventoryUpdate:
self.mark_inventory_update(job.inventory_source.inventory_id)
self.mark_inventory_source_update(job.inventory_source_id)
self.mark_inventory_update(job)
self.mark_inventory_source_update(job)
elif type(job) is Job:
self.mark_job_template_job(job)
elif type(job) is WorkflowJob:
self.mark_workflow_job(job)
elif type(job) is SystemJob:
self.mark_system_job()
self.mark_system_job(job)
elif type(job) is AdHocCommand:
self.mark_inventory_update(job.inventory_id)
self.mark_inventory_update(job)
def add_jobs(self, jobs):
for j in jobs:

View File

@ -64,6 +64,8 @@ class TaskManager():
# will no longer be started and will be started on the next task manager cycle.
self.start_task_limit = settings.START_TASK_LIMIT
self.time_delta_job_explanation = timedelta(seconds=30)
def after_lock_init(self):
'''
Init AFTER we know this instance of the task manager will run because the lock is acquired.
@ -80,7 +82,7 @@ class TaskManager():
instances_by_hostname = {i.hostname: i for i in instances_partial}
for rampart_group in InstanceGroup.objects.prefetch_related('instances'):
self.graph[rampart_group.name] = dict(graph=DependencyGraph(rampart_group.name),
self.graph[rampart_group.name] = dict(graph=DependencyGraph(),
capacity_total=rampart_group.capacity,
consumed_capacity=0,
instances=[])
@ -88,18 +90,21 @@ class TaskManager():
if instance.hostname in instances_by_hostname:
self.graph[rampart_group.name]['instances'].append(instances_by_hostname[instance.hostname])
def is_job_blocked(self, task):
def job_blocked_by(self, task):
# TODO: I'm not happy with this, I think blocking behavior should be decided outside of the dependency graph
# in the old task manager this was handled as a method on each task object outside of the graph and
# probably has the side effect of cutting down *a lot* of the logic from this task manager class
for g in self.graph:
if self.graph[g]['graph'].is_job_blocked(task):
return True
blocked_by = self.graph[g]['graph'].task_blocked_by(task)
if blocked_by:
return blocked_by
if not task.dependent_jobs_finished():
return True
blocked_by = task.dependent_jobs.first()
if blocked_by:
return blocked_by
return False
return None
def get_tasks(self, status_list=('pending', 'waiting', 'running')):
jobs = [j for j in Job.objects.filter(status__in=status_list).prefetch_related('instance_group')]
@ -312,6 +317,7 @@ class TaskManager():
with disable_activity_stream():
task.celery_task_id = str(uuid.uuid4())
task.save()
task.log_lifecycle("waiting")
if rampart_group is not None:
self.consume_capacity(task, rampart_group.name)
@ -450,6 +456,7 @@ class TaskManager():
def generate_dependencies(self, undeped_tasks):
created_dependencies = []
for task in undeped_tasks:
task.log_lifecycle("acknowledged")
dependencies = []
if not type(task) is Job:
continue
@ -489,11 +496,18 @@ class TaskManager():
def process_pending_tasks(self, pending_tasks):
running_workflow_templates = set([wf.unified_job_template_id for wf in self.get_running_workflow_jobs()])
tasks_to_update_job_explanation = []
for task in pending_tasks:
if self.start_task_limit <= 0:
break
if self.is_job_blocked(task):
logger.debug("{} is blocked from running".format(task.log_format))
blocked_by = self.job_blocked_by(task)
if blocked_by:
task.log_lifecycle("blocked", blocked_by=blocked_by)
job_explanation = gettext_noop(f"waiting for {blocked_by._meta.model_name}-{blocked_by.id} to finish")
if task.job_explanation != job_explanation:
if task.created < (tz_now() - self.time_delta_job_explanation):
task.job_explanation = job_explanation
tasks_to_update_job_explanation.append(task)
continue
preferred_instance_groups = task.preferred_instance_groups
found_acceptable_queue = False
@ -539,7 +553,17 @@ class TaskManager():
logger.debug("No instance available in group {} to run job {} w/ capacity requirement {}".format(
rampart_group.name, task.log_format, task.task_impact))
if not found_acceptable_queue:
task.log_lifecycle("needs_capacity")
job_explanation = gettext_noop("This job is not ready to start because there is not enough available capacity.")
if task.job_explanation != job_explanation:
if task.created < (tz_now() - self.time_delta_job_explanation):
# Many launched jobs are immediately blocked, but most blocks will resolve in a few seconds.
# Therefore we should only update the job_explanation after some time has elapsed to
# prevent excessive task saves.
task.job_explanation = job_explanation
tasks_to_update_job_explanation.append(task)
logger.debug("{} couldn't be scheduled on graph, waiting for next cycle".format(task.log_format))
UnifiedJob.objects.bulk_update(tasks_to_update_job_explanation, ['job_explanation'])
def timeout_approval_node(self):
workflow_approvals = WorkflowApproval.objects.filter(status='pending')

View File

@ -336,6 +336,8 @@ def send_notifications(notification_list, job_id=None):
sent = notification.notification_template.send(notification.subject, notification.body)
notification.status = "successful"
notification.notifications_sent = sent
if job_id is not None:
job_actual.log_lifecycle("notifications_sent")
except Exception as e:
logger.exception("Send Notification Failed {}".format(e))
notification.status = "failed"
@ -1186,16 +1188,19 @@ class BaseTask(object):
'''
Hook for any steps to run before the job/task starts
'''
instance.log_lifecycle("pre_run")
def post_run_hook(self, instance, status):
'''
Hook for any steps to run before job/task is marked as complete.
'''
instance.log_lifecycle("post_run")
def final_run_hook(self, instance, status, private_data_dir, fact_modification_times, isolated_manager_instance=None):
'''
Hook for any steps to run after job/task is marked as complete.
'''
instance.log_lifecycle("finalize_run")
job_profiling_dir = os.path.join(private_data_dir, 'artifacts/playbook_profiling')
awx_profiling_dir = '/var/log/tower/playbook_profiling/'
if not os.path.exists(awx_profiling_dir):
@ -1358,7 +1363,6 @@ class BaseTask(object):
# self.instance because of the update_model pattern and when it's used in callback handlers
self.instance = self.update_model(pk, status='running',
start_args='') # blank field to remove encrypted passwords
self.instance.websocket_emit_status("running")
status, rc = 'error', None
extra_update_fields = {}
@ -1383,6 +1387,7 @@ class BaseTask(object):
self.instance.send_notification_templates("running")
private_data_dir = self.build_private_data_dir(self.instance)
self.pre_run_hook(self.instance, private_data_dir)
self.instance.log_lifecycle("preparing_playbook")
if self.instance.cancel_flag:
self.instance = self.update_model(self.instance.pk, status='canceled')
if self.instance.status != 'running':
@ -1510,6 +1515,7 @@ class BaseTask(object):
res = ansible_runner.interface.run(**params)
status = res.status
rc = res.rc
self.instance.log_lifecycle("running_playbook")
if status == 'timeout':
self.instance.job_explanation = "Job terminated due to timeout"
@ -1868,6 +1874,7 @@ class RunJob(BaseTask):
return getattr(settings, 'AWX_PROOT_ENABLED', False)
def pre_run_hook(self, job, private_data_dir):
super(RunJob, self).pre_run_hook(job, private_data_dir)
if job.inventory is None:
error = _('Job could not start because it does not have a valid inventory.')
self.update_model(job.pk, status='failed', job_explanation=error)
@ -2313,6 +2320,7 @@ class RunProjectUpdate(BaseTask):
'for path {}.'.format(instance.log_format, waiting_time, lock_path))
def pre_run_hook(self, instance, private_data_dir):
super(RunProjectUpdate, self).pre_run_hook(instance, private_data_dir)
# re-create root project folder if a natural disaster has destroyed it
if not os.path.exists(settings.PROJECTS_ROOT):
os.mkdir(settings.PROJECTS_ROOT)
@ -2408,6 +2416,7 @@ class RunProjectUpdate(BaseTask):
logger.debug('{0} {1} prepared {2} from cache'.format(type(p).__name__, p.pk, dest_subpath))
def post_run_hook(self, instance, status):
super(RunProjectUpdate, self).post_run_hook(instance, status)
# To avoid hangs, very important to release lock even if errors happen here
try:
if self.playbook_new_revision:
@ -2663,6 +2672,7 @@ class RunInventoryUpdate(BaseTask):
return inventory_update.get_extra_credentials()
def pre_run_hook(self, inventory_update, private_data_dir):
super(RunInventoryUpdate, self).pre_run_hook(inventory_update, private_data_dir)
source_project = None
if inventory_update.inventory_source:
source_project = inventory_update.inventory_source.source_project
@ -2707,6 +2717,7 @@ class RunInventoryUpdate(BaseTask):
RunProjectUpdate.make_local_copy(source_project, private_data_dir)
def post_run_hook(self, inventory_update, status):
super(RunInventoryUpdate, self).post_run_hook(inventory_update, status)
if status != 'successful':
return # nothing to save, step out of the way to allow error reporting

View File

@ -393,3 +393,43 @@ def test_saml_x509cert_validation(patch, get, admin, headers):
}
})
assert resp.status_code == 200
@pytest.mark.django_db
def test_github_settings(get, put, patch, delete, admin):
url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'github'})
get(url, user=admin, expect=200)
delete(url, user=admin, expect=204)
response = get(url, user=admin, expect=200)
data = dict(response.data.items())
put(url, user=admin, data=data, expect=200)
patch(url, user=admin, data={'SOCIAL_AUTH_GITHUB_KEY': '???'}, expect=200)
response = get(url, user=admin, expect=200)
assert response.data['SOCIAL_AUTH_GITHUB_KEY'] == '???'
data.pop('SOCIAL_AUTH_GITHUB_KEY')
put(url, user=admin, data=data, expect=200)
response = get(url, user=admin, expect=200)
assert response.data['SOCIAL_AUTH_GITHUB_KEY'] == ''
@pytest.mark.django_db
def test_github_enterprise_settings(get, put, patch, delete, admin):
url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'github-enterprise'})
get(url, user=admin, expect=200)
delete(url, user=admin, expect=204)
response = get(url, user=admin, expect=200)
data = dict(response.data.items())
put(url, user=admin, data=data, expect=200)
patch(url, user=admin, data={
'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL': 'example.com',
'SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL': 'example.com',
}, expect=200)
response = get(url, user=admin, expect=200)
assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_URL'] == 'example.com'
assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL'] == 'example.com'
data.pop('SOCIAL_AUTH_GITHUB_ENTERPRISE_URL')
data.pop('SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL')
put(url, user=admin, data=data, expect=200)
response = get(url, user=admin, expect=200)
assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_URL'] == ''
assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL'] == ''

View File

@ -6,7 +6,7 @@ import pytest
#from awx.main.models import NotificationTemplates, Notifications, JobNotificationMixin
from awx.main.models import (AdHocCommand, InventoryUpdate, Job, JobNotificationMixin, ProjectUpdate,
SystemJob, WorkflowJob)
Schedule, SystemJob, WorkflowJob)
from awx.api.serializers import UnifiedJobSerializer
@ -72,6 +72,10 @@ class TestJobNotificationMixin(object):
'name': str,
'scm_type': str,
'status': str},
'schedule': {'description': str,
'id': int,
'name': str,
'next_run': datetime.datetime},
'unified_job_template': {'description': str,
'id': int,
'name': str,
@ -89,27 +93,27 @@ class TestJobNotificationMixin(object):
'workflow_url': str,
'url': str}
def check_structure(self, expected_structure, obj):
if isinstance(expected_structure, dict):
assert isinstance(obj, dict)
for key in obj:
assert key in expected_structure
if obj[key] is None:
continue
if isinstance(expected_structure[key], dict):
assert isinstance(obj[key], dict)
self.check_structure(expected_structure[key], obj[key])
else:
if key == 'job_explanation':
assert isinstance(str(obj[key]), expected_structure[key])
else:
assert isinstance(obj[key], expected_structure[key])
@pytest.mark.django_db
@pytest.mark.parametrize('JobClass', [AdHocCommand, InventoryUpdate, Job, ProjectUpdate, SystemJob, WorkflowJob])
def test_context(self, JobClass, sqlite_copy_expert, project, inventory_source):
"""The Jinja context defines all of the fields that can be used by a template. Ensure that the context generated
for each job type has the expected structure."""
def check_structure(expected_structure, obj):
if isinstance(expected_structure, dict):
assert isinstance(obj, dict)
for key in obj:
assert key in expected_structure
if obj[key] is None:
continue
if isinstance(expected_structure[key], dict):
assert isinstance(obj[key], dict)
check_structure(expected_structure[key], obj[key])
else:
if key == 'job_explanation':
assert isinstance(str(obj[key]), expected_structure[key])
else:
assert isinstance(obj[key], expected_structure[key])
kwargs = {}
if JobClass is InventoryUpdate:
kwargs['inventory_source'] = inventory_source
@ -121,8 +125,26 @@ class TestJobNotificationMixin(object):
job_serialization = UnifiedJobSerializer(job).to_representation(job)
context = job.context(job_serialization)
check_structure(TestJobNotificationMixin.CONTEXT_STRUCTURE, context)
self.check_structure(TestJobNotificationMixin.CONTEXT_STRUCTURE, context)
@pytest.mark.django_db
def test_schedule_context(self, job_template, admin_user):
schedule = Schedule.objects.create(
name='job-schedule',
rrule='DTSTART:20171129T155939z\nFREQ=MONTHLY',
unified_job_template=job_template
)
job = Job.objects.create(
name='fake-job',
launch_type='workflow',
schedule=schedule,
job_template=job_template
)
job_serialization = UnifiedJobSerializer(job).to_representation(job)
context = job.context(job_serialization)
self.check_structure(TestJobNotificationMixin.CONTEXT_STRUCTURE, context)
@pytest.mark.django_db
def test_context_job_metadata_with_unicode(self):

View File

@ -348,11 +348,11 @@ def test_job_not_blocking_project_update(default_instance_group, job_template_fa
project_update.instance_group = default_instance_group
project_update.status = "pending"
project_update.save()
assert not task_manager.is_job_blocked(project_update)
assert not task_manager.job_blocked_by(project_update)
dependency_graph = DependencyGraph(None)
dependency_graph = DependencyGraph()
dependency_graph.add_job(job)
assert not dependency_graph.is_job_blocked(project_update)
assert not dependency_graph.task_blocked_by(project_update)
@pytest.mark.django_db
@ -378,11 +378,11 @@ def test_job_not_blocking_inventory_update(default_instance_group, job_template_
inventory_update.status = "pending"
inventory_update.save()
assert not task_manager.is_job_blocked(inventory_update)
assert not task_manager.job_blocked_by(inventory_update)
dependency_graph = DependencyGraph(None)
dependency_graph = DependencyGraph()
dependency_graph.add_job(job)
assert not dependency_graph.is_job_blocked(inventory_update)
assert not dependency_graph.task_blocked_by(inventory_update)
@pytest.mark.django_db

View File

@ -35,16 +35,17 @@ data_loggly = {
# Test reconfigure logging settings function
# name this whatever you want
@pytest.mark.parametrize(
'enabled, log_type, host, port, protocol, expected_config', [
'enabled, log_type, host, port, protocol, errorfile, expected_config', [
(
True,
'loggly',
'http://logs-01.loggly.com/inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/',
None,
'https',
'/var/log/tower/rsyslog.err',
'\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="logs-01.loggly.com" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/")', # noqa
'action(type="omhttp" server="logs-01.loggly.com" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/")', # noqa
])
),
(
@ -53,6 +54,7 @@ data_loggly = {
'localhost',
9000,
'udp',
'', # empty errorfile
'\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")',
'action(type="omfwd" target="localhost" port="9000" protocol="udp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa
@ -64,6 +66,7 @@ data_loggly = {
'localhost',
9000,
'tcp',
'/var/log/tower/rsyslog.err',
'\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")',
'action(type="omfwd" target="localhost" port="9000" protocol="tcp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa
@ -75,9 +78,10 @@ data_loggly = {
'https://yoursplunk/services/collector/event',
None,
None,
'/var/log/tower/rsyslog.err',
'\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="yoursplunk" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
'action(type="omhttp" server="yoursplunk" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
])
),
(
@ -86,9 +90,10 @@ data_loggly = {
'http://yoursplunk/services/collector/event',
None,
None,
'/var/log/tower/rsyslog.err',
'\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="yoursplunk" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
'action(type="omhttp" server="yoursplunk" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
])
),
(
@ -97,9 +102,10 @@ data_loggly = {
'https://yoursplunk:8088/services/collector/event',
None,
None,
'/var/log/tower/rsyslog.err',
'\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
])
),
(
@ -108,9 +114,10 @@ data_loggly = {
'https://yoursplunk/services/collector/event',
8088,
None,
'/var/log/tower/rsyslog.err',
'\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
])
),
(
@ -119,9 +126,10 @@ data_loggly = {
'yoursplunk.org/services/collector/event',
8088,
'https',
'/var/log/tower/rsyslog.err',
'\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
])
),
(
@ -130,9 +138,10 @@ data_loggly = {
'http://yoursplunk.org/services/collector/event',
8088,
None,
'/var/log/tower/rsyslog.err',
'\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
])
),
(
@ -141,14 +150,15 @@ data_loggly = {
'https://endpoint5.collection.us2.sumologic.com/receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==', # noqa
None,
'https',
'/var/log/tower/rsyslog.err',
'\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="endpoint5.collection.us2.sumologic.com" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==")', # noqa
'action(type="omhttp" server="endpoint5.collection.us2.sumologic.com" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==")', # noqa
])
),
]
)
def test_rsyslog_conf_template(enabled, log_type, host, port, protocol, expected_config):
def test_rsyslog_conf_template(enabled, log_type, host, port, protocol, errorfile, expected_config):
mock_settings, _ = _mock_logging_defaults()
@ -159,6 +169,7 @@ def test_rsyslog_conf_template(enabled, log_type, host, port, protocol, expected
setattr(mock_settings, 'LOG_AGGREGATOR_ENABLED', enabled)
setattr(mock_settings, 'LOG_AGGREGATOR_TYPE', log_type)
setattr(mock_settings, 'LOG_AGGREGATOR_HOST', host)
setattr(mock_settings, 'LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE', errorfile)
if port:
setattr(mock_settings, 'LOG_AGGREGATOR_PORT', port)
if protocol:

View File

@ -18,6 +18,7 @@ def construct_rsyslog_conf_template(settings=settings):
timeout = getattr(settings, 'LOG_AGGREGATOR_TCP_TIMEOUT', 5)
max_disk_space = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_GB', 1)
spool_directory = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_PATH', '/var/lib/awx').rstrip('/')
error_log_file = getattr(settings, 'LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE', '')
if not os.access(spool_directory, os.W_OK):
spool_directory = '/var/lib/awx'
@ -74,9 +75,10 @@ def construct_rsyslog_conf_template(settings=settings):
f'skipverifyhost="{skip_verify}"',
'action.resumeRetryCount="-1"',
'template="awx"',
'errorfile="/var/log/tower/rsyslog.err"',
f'action.resumeInterval="{timeout}"'
]
if error_log_file:
params.append(f'errorfile="{error_log_file}"')
if parsed.path:
path = urlparse.quote(parsed.path[1:], safe='/=')
if parsed.query:

View File

@ -3,6 +3,7 @@
from copy import copy
import json
import json_log_formatter
import logging
import traceback
import socket
@ -14,6 +15,15 @@ from django.core.serializers.json import DjangoJSONEncoder
from django.conf import settings
class JobLifeCycleFormatter(json_log_formatter.JSONFormatter):
def json_record(self, message: str, extra: dict, record: logging.LogRecord):
if 'time' not in extra:
extra['time'] = now()
if record.exc_info:
extra['exc_info'] = self.formatException(record.exc_info)
return extra
class TimeFormatter(logging.Formatter):
'''
Custom log formatter used for inventory imports

View File

@ -103,6 +103,15 @@ if settings.COLOR_LOGS is True:
from logutils.colorize import ColorizingStreamHandler
class ColorHandler(ColorizingStreamHandler):
def colorize(self, line, record):
# comment out this method if you don't like the job_lifecycle
# logs rendered with cyan text
previous_level_map = self.level_map.copy()
if record.name == "awx.analytics.job_lifecycle":
self.level_map[logging.DEBUG] = (None, 'cyan', True)
msg = super(ColorHandler, self).colorize(line, record)
self.level_map = previous_level_map
return msg
def format(self, record):
message = logging.StreamHandler.format(self, record)

View File

@ -262,6 +262,7 @@ TEMPLATES = [
'DIRS': [
os.path.join(BASE_DIR, 'templates'),
os.path.join(BASE_DIR, 'ui_next', 'build'),
os.path.join(BASE_DIR, 'ui_next', 'public')
],
},
]
@ -289,6 +290,7 @@ INSTALLED_APPS = [
'awx.main',
'awx.api',
'awx.ui',
'awx.ui_next',
'awx.sso',
'solo'
]
@ -344,6 +346,9 @@ AUTHENTICATION_BACKENDS = (
'social_core.backends.github.GithubOAuth2',
'social_core.backends.github.GithubOrganizationOAuth2',
'social_core.backends.github.GithubTeamOAuth2',
'social_core.backends.github_enterprise.GithubEnterpriseOAuth2',
'social_core.backends.github_enterprise.GithubEnterpriseOrganizationOAuth2',
'social_core.backends.github_enterprise.GithubEnterpriseTeamOAuth2',
'social_core.backends.azuread.AzureADOAuth2',
'awx.sso.backends.SAMLAuth',
'django.contrib.auth.backends.ModelBackend',
@ -520,6 +525,20 @@ SOCIAL_AUTH_GITHUB_TEAM_SECRET = ''
SOCIAL_AUTH_GITHUB_TEAM_ID = ''
SOCIAL_AUTH_GITHUB_TEAM_SCOPE = ['user:email', 'read:org']
SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY = ''
SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET = ''
SOCIAL_AUTH_GITHUB_ENTERPRISE_SCOPE = ['user:email', 'read:org']
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY = ''
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET = ''
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME = ''
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SCOPE = ['user:email', 'read:org']
SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY = ''
SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET = ''
SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID = ''
SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SCOPE = ['user:email', 'read:org']
SOCIAL_AUTH_AZUREAD_OAUTH2_KEY = ''
SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET = ''
@ -770,6 +789,7 @@ LOG_AGGREGATOR_LEVEL = 'INFO'
LOG_AGGREGATOR_MAX_DISK_USAGE_GB = 1
LOG_AGGREGATOR_MAX_DISK_USAGE_PATH = '/var/lib/awx'
LOG_AGGREGATOR_RSYSLOGD_DEBUG = False
LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE = '/var/log/tower/rsyslog.err'
# The number of retry attempts for websocket session establishment
# If you're encountering issues establishing websockets in clustered Tower,
@ -824,6 +844,9 @@ LOGGING = {
'dispatcher': {
'format': '%(asctime)s %(levelname)-8s %(name)s PID:%(process)d %(message)s',
},
'job_lifecycle': {
'()': 'awx.main.utils.formatters.JobLifeCycleFormatter',
},
},
'handlers': {
'console': {
@ -853,38 +876,30 @@ LOGGING = {
},
'tower_warnings': {
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
'class': 'logging.handlers.RotatingFileHandler',
'class': 'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false', 'dynamic_level_filter'],
'filename': os.path.join(LOG_ROOT, 'tower.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple',
},
'callback_receiver': {
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
'class': 'logging.handlers.RotatingFileHandler',
'class': 'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false', 'dynamic_level_filter'],
'filename': os.path.join(LOG_ROOT, 'callback_receiver.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple',
},
'dispatcher': {
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
'class': 'logging.handlers.RotatingFileHandler',
'class': 'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false', 'dynamic_level_filter'],
'filename': os.path.join(LOG_ROOT, 'dispatcher.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'dispatcher',
},
'wsbroadcast': {
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
'class': 'logging.handlers.RotatingFileHandler',
'class': 'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false', 'dynamic_level_filter'],
'filename': os.path.join(LOG_ROOT, 'wsbroadcast.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple',
},
'celery.beat': {
@ -898,48 +913,44 @@ LOGGING = {
},
'task_system': {
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
'class': 'logging.handlers.RotatingFileHandler',
'class': 'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false', 'dynamic_level_filter'],
'filename': os.path.join(LOG_ROOT, 'task_system.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple',
},
'management_playbooks': {
'level': 'DEBUG',
'class':'logging.handlers.RotatingFileHandler',
'class':'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false'],
'filename': os.path.join(LOG_ROOT, 'management_playbooks.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple',
},
'system_tracking_migrations': {
'level': 'WARNING',
'class':'logging.handlers.RotatingFileHandler',
'class':'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false'],
'filename': os.path.join(LOG_ROOT, 'tower_system_tracking_migrations.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple',
},
'rbac_migrations': {
'level': 'WARNING',
'class':'logging.handlers.RotatingFileHandler',
'class':'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false'],
'filename': os.path.join(LOG_ROOT, 'tower_rbac_migrations.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple',
},
'isolated_manager': {
'level': 'WARNING',
'class':'logging.handlers.RotatingFileHandler',
'class':'logging.handlers.WatchedFileHandler',
'filename': os.path.join(LOG_ROOT, 'isolated_manager.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple',
},
'job_lifecycle': {
'level': 'DEBUG',
'class':'logging.handlers.WatchedFileHandler',
'filename': os.path.join(LOG_ROOT, 'job_lifecycle.log'),
'formatter': 'job_lifecycle',
},
},
'loggers': {
'django': {
@ -1029,6 +1040,11 @@ LOGGING = {
'level': 'INFO',
'propagate': False
},
'awx.analytics.job_lifecycle': {
'handlers': ['console', 'job_lifecycle'],
'level': 'DEBUG',
'propagate': False
},
'django_auth_ldap': {
'handlers': ['console', 'file', 'tower_warnings'],
'level': 'DEBUG',

View File

@ -842,6 +842,298 @@ register(
placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER,
)
###############################################################################
# GITHUB ENTERPRISE OAUTH2 AUTHENTICATION SETTINGS
###############################################################################
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_CALLBACK_URL',
field_class=fields.CharField,
read_only=True,
default=SocialAuthCallbackURL('github-enterprise'),
label=_('GitHub Enterprise OAuth2 Callback URL'),
help_text=_('Provide this URL as the callback URL for your application as part '
'of your registration process. Refer to the Ansible Tower '
'documentation for more detail.'),
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise',
depends_on=['TOWER_URL_BASE'],
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('GitHub Enterprise URL'),
help_text=_('The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise '
'documentation for more details.'),
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise',
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('GitHub Enterprise API URL'),
help_text=_('The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github '
'Enterprise documentation for more details.'),
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise',
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('GitHub Enterprise OAuth2 Key'),
help_text=_('The OAuth2 key (Client ID) from your GitHub Enterprise developer application.'),
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise',
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('GitHub Enterprise OAuth2 Secret'),
help_text=_('The OAuth2 secret (Client Secret) from your GitHub Enterprise developer application.'),
category=_('GitHub OAuth2'),
category_slug='github-enterprise',
encrypted=True,
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORGANIZATION_MAP',
field_class=SocialOrganizationMapField,
allow_null=True,
default=None,
label=_('GitHub Enterprise OAuth2 Organization Map'),
help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT,
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise',
placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER,
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_MAP',
field_class=SocialTeamMapField,
allow_null=True,
default=None,
label=_('GitHub Enterprise OAuth2 Team Map'),
help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT,
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise',
placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER,
)
###############################################################################
# GITHUB ENTERPRISE ORG OAUTH2 AUTHENTICATION SETTINGS
###############################################################################
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_CALLBACK_URL',
field_class=fields.CharField,
read_only=True,
default=SocialAuthCallbackURL('github-enterprise-org'),
label=_('GitHub Enterprise Organization OAuth2 Callback URL'),
help_text=_('Provide this URL as the callback URL for your application as part '
'of your registration process. Refer to the Ansible Tower '
'documentation for more detail.'),
category=_('GitHub Enterprise Organization OAuth2'),
category_slug='github-enterprise-org',
depends_on=['TOWER_URL_BASE'],
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('GitHub Enterprise Organization URL'),
help_text=_('The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise '
'documentation for more details.'),
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise-org',
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('GitHub Enterprise Organization API URL'),
help_text=_('The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github '
'Enterprise documentation for more details.'),
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise-org',
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('GitHub Enterprise Organization OAuth2 Key'),
help_text=_('The OAuth2 key (Client ID) from your GitHub Enterprise organization application.'),
category=_('GitHub Enterprise Organization OAuth2'),
category_slug='github-enterprise-org',
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('GitHub Enterprise Organization OAuth2 Secret'),
help_text=_('The OAuth2 secret (Client Secret) from your GitHub Enterprise organization application.'),
category=_('GitHub Enterprise Organization OAuth2'),
category_slug='github-enterprise-org',
encrypted=True,
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('GitHub Enterprise Organization Name'),
help_text=_('The name of your GitHub Enterprise organization, as used in your '
'organization\'s URL: https://github.com/<yourorg>/.'),
category=_('GitHub Enterprise Organization OAuth2'),
category_slug='github-enterprise-org',
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_ORGANIZATION_MAP',
field_class=SocialOrganizationMapField,
allow_null=True,
default=None,
label=_('GitHub Enterprise Organization OAuth2 Organization Map'),
help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT,
category=_('GitHub Enterprise Organization OAuth2'),
category_slug='github-enterprise-org',
placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER,
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_TEAM_MAP',
field_class=SocialTeamMapField,
allow_null=True,
default=None,
label=_('GitHub Enterprise Organization OAuth2 Team Map'),
help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT,
category=_('GitHub Enterprise Organization OAuth2'),
category_slug='github-enterprise-org',
placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER,
)
###############################################################################
# GITHUB ENTERPRISE TEAM OAUTH2 AUTHENTICATION SETTINGS
###############################################################################
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_CALLBACK_URL',
field_class=fields.CharField,
read_only=True,
default=SocialAuthCallbackURL('github-enterprise-team'),
label=_('GitHub Enterprise Team OAuth2 Callback URL'),
help_text=_('Create an organization-owned application at '
'https://github.com/organizations/<yourorg>/settings/applications '
'and obtain an OAuth2 key (Client ID) and secret (Client Secret). '
'Provide this URL as the callback URL for your application.'),
category=_('GitHub Enterprise Team OAuth2'),
category_slug='github-enterprise-team',
depends_on=['TOWER_URL_BASE'],
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('GitHub Enterprise Team URL'),
help_text=_('The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise '
'documentation for more details.'),
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise-team',
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('GitHub Enterprise Team API URL'),
help_text=_('The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github '
'Enterprise documentation for more details.'),
category=_('GitHub Enterprise OAuth2'),
category_slug='github-enterprise-team',
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('GitHub Enterprise Team OAuth2 Key'),
help_text=_('The OAuth2 key (Client ID) from your GitHub Enterprise organization application.'),
category=_('GitHub Enterprise Team OAuth2'),
category_slug='github-enterprise-team',
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('GitHub Enterprise Team OAuth2 Secret'),
help_text=_('The OAuth2 secret (Client Secret) from your GitHub Enterprise organization application.'),
category=_('GitHub Enterprise Team OAuth2'),
category_slug='github-enterprise-team',
encrypted=True,
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('GitHub Enterprise Team ID'),
help_text=_('Find the numeric team ID using the Github Enterprise API: '
'http://fabian-kostadinov.github.io/2015/01/16/how-to-find-a-github-team-id/.'),
category=_('GitHub Enterprise Team OAuth2'),
category_slug='github-enterprise-team',
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ORGANIZATION_MAP',
field_class=SocialOrganizationMapField,
allow_null=True,
default=None,
label=_('GitHub Enterprise Team OAuth2 Organization Map'),
help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT,
category=_('GitHub Enterprise Team OAuth2'),
category_slug='github-enterprise-team',
placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER,
)
register(
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_TEAM_MAP',
field_class=SocialTeamMapField,
allow_null=True,
default=None,
label=_('GitHub Enterprise Team OAuth2 Team Map'),
help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT,
category=_('GitHub Enterprise Team OAuth2'),
category_slug='github-enterprise-team',
placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER,
)
###############################################################################
# MICROSOFT AZURE ACTIVE DIRECTORY SETTINGS
###############################################################################

View File

@ -187,6 +187,26 @@ class AuthenticationBackendsField(fields.StringListField):
'SOCIAL_AUTH_GITHUB_TEAM_SECRET',
'SOCIAL_AUTH_GITHUB_TEAM_ID',
]),
('social_core.backends.github_enterprise.GithubEnterpriseOAuth2', [
'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL',
'SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL',
'SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY',
'SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET',
]),
('social_core.backends.github_enterprise.GithubEnterpriseOrganizationOAuth2', [
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL',
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL',
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY',
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET',
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME',
]),
('social_core.backends.github_enterprise.GithubEnterpriseTeamOAuth2', [
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL',
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL',
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY',
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET',
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID',
]),
('social_core.backends.azuread.AzureADOAuth2', [
'SOCIAL_AUTH_AZUREAD_OAUTH2_KEY',
'SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET',

View File

@ -8,8 +8,14 @@
"modules": true
}
},
"plugins": ["react-hooks", "jsx-a11y"],
"extends": ["airbnb", "prettier", "prettier/react", "plugin:jsx-a11y/strict"],
"plugins": ["react-hooks", "jsx-a11y", "i18next"],
"extends": [
"airbnb",
"prettier",
"prettier/react",
"plugin:jsx-a11y/strict",
"plugin:i18next/recommended"
],
"settings": {
"react": {
"version": "16.5.2"
@ -24,6 +30,70 @@
"window": true
},
"rules": {
"i18next/no-literal-string": [
2,
{
"markupOnly": true,
"ignoreAttribute": [
"to",
"streamType",
"path",
"component",
"variant",
"key",
"position",
"promptName",
"color",
"promptId",
"headingLevel",
"size",
"target",
"autoComplete",
"trigger",
"from",
"name",
"fieldId",
"css",
"gutter",
"dataCy",
"tooltipMaxWidth",
"mode",
"aria-labelledby",
"aria-hidden",
"sortKey",
"ouiaId",
"credentialTypeNamespace",
"link",
"value",
"credentialTypeKind",
"linkTo",
"scrollToAlignment",
"displayKey",
"sortedColumnKey",
"maxHeight",
"role",
"aria-haspopup",
"dropDirection",
"resizeOrientation",
"src",
"theme",
"gridColumns"
],
"ignore": ["Ansible", "Tower", "JSON", "YAML", "lg", "START"],
"ignoreComponent": [
"code",
"Omit",
"PotentialLink",
"TypeRedirect",
"Radio",
"RunOnRadio",
"NodeTypeLetter",
"SelectableItem",
"Dash"
],
"ignoreCallee": ["describe"]
}
],
"camelcase": "off",
"arrow-parens": "off",
"comma-dangle": "off",

View File

@ -62,7 +62,7 @@ The AWX UI requires the following:
Run the following to install all the dependencies:
```bash
(host) $ npm run install
(host) $ npm install
```
#### Build the User Interface

10
awx/ui_next/apps.py Normal file
View File

@ -0,0 +1,10 @@
# Django
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _
class UINextConfig(AppConfig):
name = 'awx.ui_next'
verbose_name = _('UI_Next')

View File

@ -7172,6 +7172,15 @@
"lodash": "^4.17.15"
}
},
"eslint-plugin-i18next": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-5.0.0.tgz",
"integrity": "sha512-ixbgSMrSb0dZsO6WPElg4JvPiQKLDA3ZpBuayxToADan1TKcbzKXT2A42Vyc0lEDhJRPL6uZnmm8vPjODDJypg==",
"dev": true,
"requires": {
"requireindex": "~1.1.0"
}
},
"eslint-plugin-import": {
"version": "2.22.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz",
@ -15163,6 +15172,12 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true
},
"requireindex": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz",
"integrity": "sha1-5UBLgVV+91225JxacgBIk/4D4WI=",
"dev": true
},
"requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",

View File

@ -43,6 +43,7 @@
"eslint-config-airbnb": "^17.1.0",
"eslint-config-prettier": "^5.0.0",
"eslint-import-resolver-webpack": "0.11.1",
"eslint-plugin-i18next": "^5.0.0",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-react": "^7.11.1",

View File

@ -1,25 +1,40 @@
{% load static %}
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'nonce-{{ csp_nonce }}'; script-src 'self' 'nonce-{{ csp_nonce }}' *.pendo.io; img-src 'self' *.pendo.io data:;"
/>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script nonce="{{ csp_nonce }}">
setInterval(function() {
window.location = '/';
}, 10000);
</script>
</head>
<body>
<div>
<span>
<p>AWX is installing.</p>
<p>This page will refresh when complete.</p>
</span>
</div>
</body>
<head>
<title>{{ title }}</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'nonce-{{ csp_nonce }}'; script-src 'self' 'nonce-{{ csp_nonce }}' *.pendo.io; img-src 'self' *.pendo.io data:;"
/>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="{% static 'css/fonts/assets/RedHatDisplay/RedHatDisplay-Medium.woff' %}" rel="stylesheet" type="application/font-woff" media="all"/>
<link href="{% static 'css/fonts/assets/RedHatText/RedHatText-Regular.woff' %}" rel="stylesheet" type="application/font-woff" media="all"/>
<link href="{% static 'css/patternfly.min.css' %}" rel="stylesheet" type="text/css" media="all"/>
<script nonce="{{ csp_nonce }}">
setInterval(function() {
window.location = '/';
}, 10000);
</script>
</head>
<body>
<div class="pf-l-bullseye pf-m-gutter">
<div class="pf-l-bullseye__item">
<div class="pf-l-bullseye">
<img src="{% static 'media/logo-header.svg' %}" width="300px" alt={{image_alt}} />
</div>
<div class="pf-l-bullseye">
<span class="pf-c-spinner" role="progressbar" aria-valuetext={{aria_spinner}}>
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</div>
<h2 class="pf-l-bullseye pf-c-title pf-m-2xl ws-heading ws-title ws-h2">{{message_upgrade}}</h2>
<h2 class="pf-l-bullseye pf-c-title pf-m-2xl ws-heading ws-title ws-h2">{{message_refresh}}</h2>
</div>
</div>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -34,7 +34,6 @@ function PageHeaderToolbar({
const handleUserSelect = () => {
setIsUserOpen(!isUserOpen);
};
return (
<PageHeaderTools>
<PageHeaderToolsGroup>
@ -90,8 +89,11 @@ function PageHeaderToolbar({
dropdownItems={[
<DropdownItem
key="user"
aria-label={i18n._(t`User details`)}
href={
loggedInUser ? `/users/${loggedInUser.id}/details` : '/home'
loggedInUser
? `/#/users/${loggedInUser.id}/details`
: '/#/home'
}
>
{i18n._(t`User Details`)}

View File

@ -25,6 +25,7 @@ describe('PageHeaderToolbar', () => {
<PageHeaderToolbar
onAboutClick={onAboutClick}
onLogoutClick={onLogoutClick}
loggedInUser={{ id: 1 }}
/>
);
expect(wrapper.find('DropdownItem')).toHaveLength(0);
@ -37,6 +38,10 @@ describe('PageHeaderToolbar', () => {
expect(wrapper.find('DropdownItem')).toHaveLength(0);
wrapper.find(pageUserDropdownSelector).simulate('click');
wrapper.update();
expect(
wrapper.find('DropdownItem[aria-label="User details"]').prop('href')
).toBe('/#/users/1/details');
expect(wrapper.find('DropdownItem')).toHaveLength(2);
const logout = wrapper.find('DropdownItem li button');

View File

@ -112,7 +112,7 @@ function AssociateModal({
<Button
aria-label={i18n._(t`Cancel`)}
key="cancel"
variant="secondary"
variant="link"
onClick={handleClose}
>
{i18n._(t`Cancel`)}

View File

@ -1,6 +1,7 @@
import 'styled-components/macro';
import React, { useState, useEffect } from 'react';
import { node, number, oneOfType, shape, string, arrayOf } from 'prop-types';
import { Trans, withI18n } from '@lingui/react';
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
import { DetailName, DetailValue } from '../DetailList';
import MultiButtonToggle from '../MultiButtonToggle';
@ -111,7 +112,7 @@ function VariablesDetail({ dataCy, helpText, value, label, rows, fullHeight }) {
css="color: var(--pf-global--danger-color--100);
font-size: var(--pf-global--FontSize--sm"
>
Error: {error.message}
<Trans>Error:</Trans> {error.message}
</div>
)}
</DetailValue>
@ -131,4 +132,4 @@ VariablesDetail.defaultProps = {
helpText: '',
};
export default VariablesDetail;
export default withI18n()(VariablesDetail);

View File

@ -1,13 +1,13 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { shallow, mount } from 'enzyme';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import VariablesDetail from './VariablesDetail';
jest.mock('../../api');
describe('<VariablesDetail>', () => {
test('should render readonly CodeMirrorInput', () => {
const wrapper = shallow(
const wrapper = mountWithContexts(
<VariablesDetail value="---foo: bar" label="Variables" />
);
const input = wrapper.find('VariablesDetail___StyledCodeMirrorInput');
@ -18,7 +18,7 @@ describe('<VariablesDetail>', () => {
});
test('should detect JSON', () => {
const wrapper = shallow(
const wrapper = mountWithContexts(
<VariablesDetail value='{"foo": "bar"}' label="Variables" />
);
const input = wrapper.find('VariablesDetail___StyledCodeMirrorInput');
@ -28,7 +28,7 @@ describe('<VariablesDetail>', () => {
});
test('should convert between modes', () => {
const wrapper = shallow(
const wrapper = mountWithContexts(
<VariablesDetail value="---foo: bar" label="Variables" />
);
wrapper.find('MultiButtonToggle').invoke('onChange')('javascript');
@ -43,7 +43,9 @@ describe('<VariablesDetail>', () => {
});
test('should render label and value= --- when there are no values', () => {
const wrapper = shallow(<VariablesDetail value="" label="Variables" />);
const wrapper = mountWithContexts(
<VariablesDetail value="" label="Variables" />
);
expect(wrapper.find('VariablesDetail___StyledCodeMirrorInput').length).toBe(
1
);
@ -51,7 +53,7 @@ describe('<VariablesDetail>', () => {
});
test('should update value if prop changes', () => {
const wrapper = mount(
const wrapper = mountWithContexts(
<VariablesDetail value="---foo: bar" label="Variables" />
);
act(() => {
@ -67,13 +69,17 @@ describe('<VariablesDetail>', () => {
});
test('should default yaml value to "---"', () => {
const wrapper = shallow(<VariablesDetail value="" label="Variables" />);
const wrapper = mountWithContexts(
<VariablesDetail value="" label="Variables" />
);
const input = wrapper.find('VariablesDetail___StyledCodeMirrorInput');
expect(input.prop('value')).toEqual('---');
});
test('should default empty json to "{}"', () => {
const wrapper = mount(<VariablesDetail value="" label="Variables" />);
const wrapper = mountWithContexts(
<VariablesDetail value="" label="Variables" />
);
act(() => {
wrapper.find('MultiButtonToggle').invoke('onChange')('javascript');
});

View File

@ -10,12 +10,13 @@ import AlertModal from '../AlertModal';
import ErrorDetail from '../ErrorDetail';
function CopyButton({
i18n,
id,
copyItem,
isDisabled,
onCopyStart,
onCopyFinish,
helperText,
i18n,
}) {
const { isLoading, error: copyError, request: copyItemToAPI } = useRequest(
copyItem
@ -34,6 +35,7 @@ function CopyButton({
<>
<Tooltip content={helperText.tooltip} position="top">
<Button
id={id}
isDisabled={isLoading || isDisabled}
aria-label={i18n._(t`Copy`)}
variant="plain"

View File

@ -42,7 +42,7 @@ function DeleteButton({
</Button>,
<Button
key="cancel"
variant="secondary"
variant="link"
aria-label={i18n._(t`Cancel`)}
onClick={() => setIsOpen(false)}
>

View File

@ -0,0 +1,51 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { t } from '@lingui/macro';
import Detail from './Detail';
const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => {
const {
created_by: createdBy,
job_template: jobTemplate,
schedule,
} = summary_fields;
const { schedule: relatedSchedule } = related;
if (!createdBy && !schedule) {
return {};
}
let link;
let value;
if (createdBy) {
link = `/users/${createdBy.id}`;
value = createdBy.username;
} else if (relatedSchedule && jobTemplate) {
link = `/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`;
value = schedule.name;
} else {
link = null;
value = schedule.name;
}
return { link, value };
};
export default function LaunchedByDetail({ job, i18n }) {
const { value: launchedByValue, link: launchedByLink } =
getLaunchedByDetails(job) || {};
return (
<Detail
label={i18n._(t`Launched By`)}
value={
launchedByLink ? (
<Link to={`${launchedByLink}`}>{launchedByValue}</Link>
) : (
launchedByValue
)
}
/>
);
}

View File

@ -4,6 +4,7 @@ export { default as DeletedDetail } from './DeletedDetail';
export { default as UserDateDetail } from './UserDateDetail';
export { default as DetailBadge } from './DetailBadge';
export { default as ArrayDetail } from './ArrayDetail';
export { default as LaunchedByDetail } from './LaunchedByDetail';
/*
NOTE: CodeDetail cannot be imported here, as it causes circular
dependencies in testing environment. Import it directly from

View File

@ -118,7 +118,7 @@ function DisassociateButton({
</Button>,
<Button
key="cancel"
variant="secondary"
variant="link"
aria-label={i18n._(t`Cancel`)}
onClick={() => setIsOpen(false)}
>

View File

@ -20,7 +20,7 @@ const FormActionGroup = ({ onCancel, onSubmit, submitDisabled, i18n }) => {
</Button>
<Button
aria-label={i18n._(t`Cancel`)}
variant="secondary"
variant="link"
type="button"
onClick={onCancel}
>

View File

@ -7,7 +7,8 @@ import { Card } from '@patternfly/react-core';
import AlertModal from '../AlertModal';
import DatalistToolbar from '../DataListToolbar';
import ErrorDetail from '../ErrorDetail';
import PaginatedDataList, { ToolbarDeleteButton } from '../PaginatedDataList';
import { ToolbarDeleteButton } from '../PaginatedDataList';
import PaginatedTable, { HeaderRow, HeaderCell } from '../PaginatedTable';
import useRequest, {
useDeleteItems,
useDismissableError,
@ -27,7 +28,7 @@ import {
} from '../../api';
function JobList({ i18n, defaultParams, showTypeColumn = false }) {
const QS_CONFIG = getQSConfig(
const qsConfig = getQSConfig(
'job',
{
page: 1,
@ -49,7 +50,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
} = useRequest(
useCallback(
async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const params = parseQueryString(qsConfig, location.search);
const [response, actionsResponse] = await Promise.all([
UnifiedJobsAPI.read({ ...params }),
UnifiedJobsAPI.readOptions(),
@ -81,7 +82,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
// TODO: update QS_CONFIG to be safe for deps array
const fetchJobsById = useCallback(
async ids => {
const params = parseQueryString(QS_CONFIG, location.search);
const params = parseQueryString(qsConfig, location.search);
params.id__in = ids.join(',');
const { data } = await UnifiedJobsAPI.read(params);
return data.results;
@ -89,7 +90,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
[location.search] // eslint-disable-line react-hooks/exhaustive-deps
);
const jobs = useWsJobs(results, fetchJobsById, QS_CONFIG);
const jobs = useWsJobs(results, fetchJobsById, qsConfig);
const isAllSelected = selected.length === jobs.length && selected.length > 0;
@ -145,7 +146,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
);
}, [selected]),
{
qsConfig: QS_CONFIG,
qsConfig,
allItemsSelected: isAllSelected,
fetchItems: fetchJobs,
}
@ -176,14 +177,13 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
return (
<>
<Card>
<PaginatedDataList
<PaginatedTable
contentError={contentError}
hasContentLoading={isLoading || isDeleteLoading || isCancelLoading}
items={jobs}
itemCount={count}
pluralizedItemName={i18n._(t`Jobs`)}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
qsConfig={qsConfig}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
@ -233,32 +233,18 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
key: 'job__limit',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Finish Time`),
key: 'finished',
},
{
name: i18n._(t`ID`),
key: 'id',
},
{
name: i18n._(t`Launched By`),
key: 'created_by__id',
},
{
name: i18n._(t`Name`),
key: 'name',
},
{
name: i18n._(t`Project`),
key: 'unified_job_template__project__id',
},
{
name: i18n._(t`Start Time`),
key: 'started',
},
]}
headerRow={
<HeaderRow qsConfig={qsConfig} isExpandable>
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
<HeaderCell sortKey="status">{i18n._(t`Status`)}</HeaderCell>
{showTypeColumn && <HeaderCell>{i18n._(t`Type`)}</HeaderCell>}
<HeaderCell sortKey="started">{i18n._(t`Start Time`)}</HeaderCell>
<HeaderCell sortKey="finished">
{i18n._(t`Finish Time`)}
</HeaderCell>
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
</HeaderRow>
}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
renderToolbar={props => (
@ -267,13 +253,13 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG}
qsConfig={qsConfig}
additionalControls={[
<ToolbarDeleteButton
key="delete"
onDelete={handleJobDelete}
itemsToDelete={selected}
pluralizedItemName="Jobs"
pluralizedItemName={i18n._(t`Jobs`)}
/>,
<JobListCancelButton
key="cancel"
@ -283,13 +269,14 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
]}
/>
)}
renderItem={job => (
renderRow={(job, index) => (
<JobListItem
key={job.id}
job={job}
showTypeColumn={showTypeColumn}
onSelect={() => handleSelect(job)}
isSelected={selected.some(row => row.id === job.id)}
rowIndex={index}
/>
)}
/>

View File

@ -1,39 +1,31 @@
import React from 'react';
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Button,
DataListAction as _DataListAction,
DataListCheck,
DataListItem,
DataListItemRow,
DataListItemCells,
Tooltip,
} from '@patternfly/react-core';
import { Button, Chip } from '@patternfly/react-core';
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
import { RocketIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import DataListCell from '../DataListCell';
import { ActionsTd, ActionItem } from '../PaginatedTable';
import LaunchButton from '../LaunchButton';
import StatusIcon from '../StatusIcon';
import StatusLabel from '../StatusLabel';
import { DetailList, Detail, LaunchedByDetail } from '../DetailList';
import ChipGroup from '../ChipGroup';
import CredentialChip from '../CredentialChip';
import { formatDateString } from '../../util/dates';
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
const DataListAction = styled(_DataListAction)`
align-items: center;
display: grid;
grid-gap: 16px;
grid-template-columns: 40px;
`;
const Dash = styled.span``;
function JobListItem({
i18n,
job,
rowIndex,
isSelected,
onSelect,
showTypeColumn = false,
}) {
const labelId = `check-action-${job.id}`;
const [isExpanded, setIsExpanded] = useState(false);
const jobTypes = {
project_update: i18n._(t`Source Control Update`),
@ -44,67 +36,123 @@ function JobListItem({
workflow_job: i18n._(t`Workflow Job`),
};
const { credentials, inventory, labels } = job.summary_fields;
return (
<DataListItem aria-labelledby={labelId} id={`${job.id}`}>
<DataListItemRow>
<DataListCheck
id={`select-job-${job.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
<>
<Tr id={`job-row-${job.id}`}>
<Td
expand={{
rowIndex: job.id,
isExpanded,
onToggle: () => setIsExpanded(!isExpanded),
}}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="status" isFilled={false}>
{job.status && <StatusIcon status={job.status} />}
</DataListCell>,
<DataListCell key="name">
<span>
<Link to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}>
<b>
{job.id} &mdash; {job.name}
</b>
</Link>
</span>
</DataListCell>,
...(showTypeColumn
? [
<DataListCell key="type" aria-label="type">
{jobTypes[job.type]}
</DataListCell>,
]
: []),
<DataListCell key="finished">
{job.finished ? formatDateString(job.finished) : ''}
</DataListCell>,
]}
<Td
select={{
rowIndex,
isSelected,
onSelect,
}}
dataLabel={i18n._(t`Select`)}
/>
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
>
{job.type !== 'system_job' &&
job.summary_fields?.user_capabilities?.start ? (
<Tooltip content={i18n._(t`Relaunch Job`)} position="top">
<LaunchButton resource={job}>
{({ handleRelaunch }) => (
<Button
variant="plain"
onClick={handleRelaunch}
aria-label={i18n._(t`Relaunch`)}
>
<RocketIcon />
</Button>
)}
</LaunchButton>
</Tooltip>
) : (
''
)}
</DataListAction>
</DataListItemRow>
</DataListItem>
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
<span>
<Link to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}>
<b>
{job.id} <Dash>&mdash;</Dash> {job.name}
</b>
</Link>
</span>
</Td>
<Td dataLabel={i18n._(t`Status`)}>
{job.status && <StatusLabel status={job.status} />}
</Td>
{showTypeColumn && (
<Td dataLabel={i18n._(t`Type`)}>{jobTypes[job.type]}</Td>
)}
<Td dataLabel={i18n._(t`Start Time`)}>
{formatDateString(job.started)}
</Td>
<Td dataLabel={i18n._(t`Finish Time`)}>
{job.finished ? formatDateString(job.finished) : ''}
</Td>
<ActionsTd dataLabel={i18n._(t`Actions`)}>
<ActionItem
visible={
job.type !== 'system_job' &&
job.summary_fields?.user_capabilities?.start
}
tooltip={i18n._(t`Relaunch Job`)}
>
<LaunchButton resource={job}>
{({ handleRelaunch }) => (
<Button
variant="plain"
onClick={handleRelaunch}
aria-label={i18n._(t`Relaunch`)}
>
<RocketIcon />
</Button>
)}
</LaunchButton>
</ActionItem>
</ActionsTd>
</Tr>
<Tr isExpanded={isExpanded} id={`expanded-job-row-${job.id}`}>
<Td colSpan={2} />
<Td colSpan={showTypeColumn ? 5 : 4}>
<ExpandableRowContent>
<DetailList>
<LaunchedByDetail job={job} i18n={i18n} />
{credentials && credentials.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Credentials`)}
value={
<ChipGroup numChips={5} totalChips={credentials.length}>
{credentials.map(c => (
<CredentialChip key={c.id} credential={c} isReadOnly />
))}
</ChipGroup>
}
/>
)}
{labels && labels.count > 0 && (
<Detail
label={i18n._(t`Labels`)}
value={
<ChipGroup numChips={5} totalChips={labels.results.length}>
{labels.results.map(l => (
<Chip key={l.id} isReadOnly>
{l.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
{inventory && (
<Detail
label={i18n._(t`Inventory`)}
value={
<Link
to={
inventory.kind === 'smart'
? `/inventories/smart_inventory/${inventory.id}`
: `/inventories/inventory/${inventory.id}`
}
>
{inventory.name}
</Link>
}
/>
)}
</DetailList>
</ExpandableRowContent>
</Td>
</Tr>
</>
);
}

View File

@ -32,7 +32,11 @@ describe('<JobListItem />', () => {
initialEntries: ['/jobs'],
});
wrapper = mountWithContexts(
<JobListItem job={mockJob} isSelected onSelect={() => {}} />,
<table>
<tbody>
<JobListItem job={mockJob} isSelected onSelect={() => {}} />
</tbody>
</table>,
{ context: { router: { history } } }
);
});
@ -51,32 +55,40 @@ describe('<JobListItem />', () => {
test('launch button hidden from users without launch capabilities', () => {
wrapper = mountWithContexts(
<JobListItem
job={{
...mockJob,
summary_fields: { user_capabilities: { start: false } },
}}
detailUrl={`/jobs/playbook/${mockJob.id}`}
onSelect={() => {}}
isSelected={false}
/>
<table>
<tbody>
<JobListItem
job={{
...mockJob,
summary_fields: { user_capabilities: { start: false } },
}}
detailUrl={`/jobs/playbook/${mockJob.id}`}
onSelect={() => {}}
isSelected={false}
/>
</tbody>
</table>
);
expect(wrapper.find('LaunchButton').length).toBe(0);
});
test('should hide type column when showTypeColumn is false', () => {
expect(wrapper.find('DataListCell[aria-label="type"]').length).toBe(0);
expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(0);
});
test('should show type column when showTypeColumn is true', () => {
wrapper = mountWithContexts(
<JobListItem
job={mockJob}
showTypeColumn
isSelected
onSelect={() => {}}
/>
<table>
<tbody>
<JobListItem
job={mockJob}
showTypeColumn
isSelected
onSelect={() => {}}
/>
</tbody>
</table>
);
expect(wrapper.find('DataListCell[aria-label="type"]').length).toBe(1);
expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(1);
});
});

View File

@ -84,7 +84,11 @@ function CredentialPasswordsStep({ launchConfig, i18n }) {
}
return (
<Form>
<Form
onSubmit={e => {
e.preventDefault();
}}
>
{showcredentialPasswordSsh && (
<PasswordField
id="launch-ssh-password"

View File

@ -22,7 +22,11 @@ const FieldHeader = styled.div`
function OtherPromptsStep({ launchConfig, i18n }) {
return (
<Form>
<Form
onSubmit={e => {
e.preventDefault();
}}
>
{launchConfig.ask_job_type_on_launch && <JobTypeField i18n={i18n} />}
{launchConfig.ask_limit_on_launch && (
<FormField

View File

@ -33,7 +33,11 @@ function SurveyStep({ surveyConfig, i18n }) {
float: NumberField,
};
return (
<Form>
<Form
onSubmit={e => {
e.preventDefault();
}}
>
{surveyConfig.spec.map(question => {
const Field = fieldTypes[question.type];
return (

View File

@ -103,7 +103,7 @@ function Lookup(props) {
<Fragment>
<InputGroup onBlur={onBlur}>
<Button
aria-label="Search"
aria-label={i18n._(t`Search`)}
id={id}
onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}
variant={ButtonVariant.control}
@ -138,7 +138,7 @@ function Lookup(props) {
>
{i18n._(t`Select`)}
</Button>,
<Button key="cancel" variant="secondary" onClick={closeModal}>
<Button key="cancel" variant="link" onClick={closeModal}>
{i18n._(t`Cancel`)}
</Button>,
]}

View File

@ -63,7 +63,7 @@ function NotificationListItem({
]}
/>
<DataListAction
aria-label="actions"
aria-label={i18n._(t`actions`)}
aria-labelledby={`items-list-item-${notification.id}`}
id={`items-list-item-${notification.id}`}
columns={showApprovalsToggle ? 4 : 3}

View File

@ -160,7 +160,7 @@ function ToolbarDeleteButton({
</Button>,
<Button
key="cancel"
variant="secondary"
variant="link"
aria-label={i18n._(t`cancel delete`)}
onClick={toggleModal}
>

View File

@ -9,7 +9,7 @@ const ActionsGrid = styled.div`
align-items: center;
${props => {
const columns = '40px '.repeat(props.numActions || 1);
const columns = props.gridColumns || '40px '.repeat(props.numActions || 1);
return css`
grid-template-columns: ${columns};
`;
@ -17,7 +17,7 @@ const ActionsGrid = styled.div`
`;
ActionsGrid.displayName = 'ActionsGrid';
export default function ActionsTd({ children, ...props }) {
export default function ActionsTd({ children, gridColumns, ...props }) {
const numActions = children.length || 1;
const width = numActions * 40;
return (
@ -28,7 +28,7 @@ export default function ActionsTd({ children, ...props }) {
`}
{...props}
>
<ActionsGrid numActions={numActions}>
<ActionsGrid numActions={numActions} gridColumns={gridColumns}>
{React.Children.map(children, (child, i) =>
React.cloneElement(child, {
column: i + 1,

View File

@ -1,3 +1,4 @@
import 'styled-components/macro';
import React from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import { Thead, Tr, Th as PFTh } from '@patternfly/react-table';
@ -12,7 +13,7 @@ const Th = styled(PFTh)`
--pf-c-table--cell--Overflow: initial;
`;
export default function HeaderRow({ qsConfig, children }) {
export default function HeaderRow({ qsConfig, isExpandable, children }) {
const location = useLocation();
const history = useHistory();
@ -41,25 +42,40 @@ export default function HeaderRow({ qsConfig, children }) {
index: sortKey || qsConfig.defaultParams?.order_by,
direction: params.order_by?.startsWith('-') ? 'desc' : 'asc',
};
const idPrefix = `${qsConfig.namespace}-table-sort`;
// empty first Th aligns with checkboxes in table rows
return (
<Thead>
<Tr>
{isExpandable && <Th />}
<Th />
{React.Children.map(children, child =>
React.cloneElement(child, {
onSort,
sortBy,
columnIndex: child.props.sortKey,
})
{React.Children.map(
children,
child =>
child &&
React.cloneElement(child, {
onSort,
sortBy,
columnIndex: child.props.sortKey,
idPrefix,
})
)}
</Tr>
</Thead>
);
}
export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) {
export function HeaderCell({
sortKey,
onSort,
sortBy,
columnIndex,
idPrefix,
className,
alignRight,
children,
}) {
const sort = sortKey
? {
onSort: (event, key, order) => onSort(sortKey, order),
@ -67,5 +83,14 @@ export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) {
columnIndex,
}
: null;
return <Th sort={sort}>{children}</Th>;
return (
<Th
id={sortKey ? `${idPrefix}-${sortKey}` : null}
className={className}
sort={sort}
css={alignRight ? 'text-align: right' : null}
>
{children}
</Th>
);
}

View File

@ -62,4 +62,20 @@ describe('<HeaderRow />', () => {
const cell = wrapper.find('Th').at(2);
expect(cell.prop('sort')).toEqual(null);
});
test('should handle null children gracefully', async () => {
const nope = false;
const wrapper = mountWithContexts(
<table>
<HeaderRow qsConfig={qsConfig}>
<HeaderCell sortKey="one">One</HeaderCell>
{nope && <HeaderCell>Hidden</HeaderCell>}
<HeaderCell>Two</HeaderCell>
</HeaderRow>
</table>
);
const cells = wrapper.find('Th');
expect(cells).toHaveLength(3);
});
});

View File

@ -1,3 +1,4 @@
import 'styled-components/macro';
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { TableComposable, Tbody } from '@patternfly/react-table';
@ -88,13 +89,13 @@ function PaginatedTable({
);
} else {
Content = (
<>
<div css="overflow: auto">
{hasContentLoading && <LoadingSpinner />}
<TableComposable aria-label={dataListLabel}>
{headerRow}
<Tbody>{items.map(renderRow)}</Tbody>
</TableComposable>
</>
</div>
);
}

View File

@ -5,7 +5,7 @@ import { withI18n } from '@lingui/react';
import { t, Trans } from '@lingui/macro';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { Chip, Divider } from '@patternfly/react-core';
import { Chip, Divider, Title } from '@patternfly/react-core';
import { toTitleCase } from '../../util/strings';
import CredentialChip from '../CredentialChip';
@ -18,9 +18,19 @@ import PromptInventorySourceDetail from './PromptInventorySourceDetail';
import PromptJobTemplateDetail from './PromptJobTemplateDetail';
import PromptWFJobTemplateDetail from './PromptWFJobTemplateDetail';
const PromptHeader = styled.h2`
font-weight: bold;
margin: var(--pf-global--spacer--lg) 0;
const PromptTitle = styled(Title)`
margin-top: var(--pf-global--spacer--xl);
--pf-c-title--m-md--FontWeight: 700;
grid-column: 1 / -1;
`;
const PromptDivider = styled(Divider)`
margin-top: var(--pf-global--spacer--lg);
margin-bottom: var(--pf-global--spacer--lg);
`;
const PromptDetailList = styled(DetailList)`
padding: 0px var(--pf-global--spacer--lg);
`;
function formatTimeout(timeout) {
@ -136,9 +146,11 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
{hasPromptData(launchConfig) && hasOverrides && (
<>
<Divider css="margin-top: var(--pf-global--spacer--lg)" />
<PromptHeader>{i18n._(t`Prompted Values`)}</PromptHeader>
<DetailList aria-label="Prompt Overrides">
<PromptTitle headingLevel="h2">
{i18n._(t`Prompted Values`)}
</PromptTitle>
<PromptDivider />
<PromptDetailList aria-label={i18n._(t`Prompt Overrides`)}>
{launchConfig.ask_job_type_on_launch && (
<Detail
label={i18n._(t`Job Type`)}
@ -250,7 +262,7 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
value={overrides.extra_vars}
/>
)}
</DetailList>
</PromptDetailList>
</>
)}
</>

View File

@ -36,7 +36,7 @@ function DeleteRoleConfirmationModal({
>
{i18n._(t`Delete`)}
</Button>,
<Button key="cancel" variant="secondary" onClick={onCancel}>
<Button key="cancel" variant="link" onClick={onCancel}>
{i18n._(t`Cancel`)}
</Button>,
]}

View File

@ -29,7 +29,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
</Button>,
<Button
onClick={[Function]}
variant="secondary"
variant="link"
>
Cancel
</Button>,
@ -56,7 +56,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
</Button>,
<Button
onClick={[Function]}
variant="secondary"
variant="link"
>
Cancel
</Button>,
@ -80,7 +80,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
</Button>,
<Button
onClick={[Function]}
variant="secondary"
variant="link"
>
Cancel
</Button>,
@ -212,8 +212,8 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
</button>
<button
aria-disabled="false"
class="pf-c-button pf-m-secondary"
data-ouia-component-id="OUIA-Generated-Button-secondary-1"
class="pf-c-button pf-m-link"
data-ouia-component-id="OUIA-Generated-Button-link-1"
data-ouia-component-type="PF4/Button"
data-ouia-safe="true"
type="button"
@ -239,7 +239,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
</Button>,
<Button
onClick={[Function]}
variant="secondary"
variant="link"
>
Cancel
</Button>,
@ -517,13 +517,13 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
<Button
key="cancel"
onClick={[Function]}
variant="secondary"
variant="link"
>
<button
aria-disabled={false}
aria-label={null}
className="pf-c-button pf-m-secondary"
data-ouia-component-id="OUIA-Generated-Button-secondary-1"
className="pf-c-button pf-m-link"
data-ouia-component-id="OUIA-Generated-Button-link-1"
data-ouia-component-type="PF4/Button"
data-ouia-safe={true}
disabled={false}

View File

@ -5,7 +5,7 @@ import { RRule, rrulestr } from 'rrule';
import styled from 'styled-components';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Chip, Title, Button } from '@patternfly/react-core';
import { Chip, Divider, Title, Button } from '@patternfly/react-core';
import { Schedule } from '../../../types';
import AlertModal from '../../AlertModal';
import { CardBody, CardActionsRow } from '../../Card';
@ -27,11 +27,21 @@ import ErrorDetail from '../../ErrorDetail';
import ChipGroup from '../../ChipGroup';
import { VariablesDetail } from '../../CodeMirrorInput';
const PromptDivider = styled(Divider)`
margin-top: var(--pf-global--spacer--lg);
margin-bottom: var(--pf-global--spacer--lg);
`;
const PromptTitle = styled(Title)`
margin-top: 40px;
--pf-c-title--m-md--FontWeight: 700;
grid-column: 1 / -1;
`;
const PromptDetailList = styled(DetailList)`
padding: 0px 20px;
`;
function ScheduleDetail({ schedule, i18n }) {
const {
id,
@ -41,6 +51,7 @@ function ScheduleDetail({ schedule, i18n }) {
dtend,
dtstart,
extra_data,
inventory,
job_tags,
job_type,
limit,
@ -52,12 +63,21 @@ function ScheduleDetail({ schedule, i18n }) {
skip_tags,
summary_fields,
timezone,
verbosity,
} = schedule;
const history = useHistory();
const { pathname } = useLocation();
const pathRoot = pathname.substr(0, pathname.indexOf('schedules'));
const VERBOSITY = {
0: i18n._(t`0 (Normal)`),
1: i18n._(t`1 (Verbose)`),
2: i18n._(t`2 (More Verbose)`),
3: i18n._(t`3 (Debug)`),
4: i18n._(t`4 (Connection Debug)`),
};
const {
request: deleteSchedule,
isLoading: isDeleteLoading,
@ -140,18 +160,34 @@ function ScheduleDetail({ schedule, i18n }) {
survey_enabled,
} = launchData || {};
const showCredentialsDetail =
ask_credential_on_launch && credentials.length > 0;
const showInventoryDetail = ask_inventory_on_launch && inventory;
const showVariablesDetail =
(ask_variables_on_launch || survey_enabled) &&
((typeof extra_data === 'string' && extra_data !== '') ||
(typeof extra_data === 'object' && Object.keys(extra_data).length > 0));
const showTagsDetail = ask_tags_on_launch && job_tags && job_tags.length > 0;
const showSkipTagsDetail =
ask_skip_tags_on_launch && skip_tags && skip_tags.length > 0;
const showDiffModeDetail =
ask_diff_mode_on_launch && typeof diff_mode === 'boolean';
const showLimitDetail = ask_limit_on_launch && limit;
const showJobTypeDetail = ask_job_type_on_launch && job_type;
const showSCMBranchDetail = ask_scm_branch_on_launch && scm_branch;
const showVerbosityDetail = ask_verbosity_on_launch && VERBOSITY[verbosity];
const showPromptedFields =
ask_credential_on_launch ||
ask_diff_mode_on_launch ||
ask_inventory_on_launch ||
ask_job_type_on_launch ||
ask_limit_on_launch ||
ask_scm_branch_on_launch ||
ask_skip_tags_on_launch ||
ask_tags_on_launch ||
ask_variables_on_launch ||
ask_verbosity_on_launch ||
survey_enabled;
showCredentialsDetail ||
showDiffModeDetail ||
showInventoryDetail ||
showJobTypeDetail ||
showLimitDetail ||
showSCMBranchDetail ||
showSkipTagsDetail ||
showTagsDetail ||
showVerbosityDetail ||
showVariablesDetail;
if (isLoading) {
return <ContentLoading />;
@ -189,15 +225,18 @@ function ScheduleDetail({ schedule, i18n }) {
date={modified}
user={summary_fields.modified_by}
/>
{showPromptedFields && (
<>
<PromptTitle headingLevel="h2">
{i18n._(t`Prompted Fields`)}
</PromptTitle>
</DetailList>
{showPromptedFields && (
<>
<PromptTitle headingLevel="h2">
{i18n._(t`Prompted Values`)}
</PromptTitle>
<PromptDivider />
<PromptDetailList>
{ask_job_type_on_launch && (
<Detail label={i18n._(t`Job Type`)} value={job_type} />
)}
{ask_inventory_on_launch && (
{showInventoryDetail && (
<Detail
label={i18n._(t`Inventory`)}
value={
@ -226,13 +265,19 @@ function ScheduleDetail({ schedule, i18n }) {
{ask_limit_on_launch && (
<Detail label={i18n._(t`Limit`)} value={limit} />
)}
{ask_diff_mode_on_launch && typeof diff_mode === 'boolean' && (
{ask_verbosity_on_launch && (
<Detail
label={i18n._(t`Verbosity`)}
value={VERBOSITY[verbosity]}
/>
)}
{showDiffModeDetail && (
<Detail
label={i18n._(t`Show Changes`)}
value={diff_mode ? i18n._(t`On`) : i18n._(t`Off`)}
/>
)}
{ask_credential_on_launch && (
{showCredentialsDetail && (
<Detail
fullWidth
label={i18n._(t`Credentials`)}
@ -245,7 +290,7 @@ function ScheduleDetail({ schedule, i18n }) {
}
/>
)}
{ask_tags_on_launch && job_tags && job_tags.length > 0 && (
{showTagsDetail && (
<Detail
fullWidth
label={i18n._(t`Job Tags`)}
@ -263,7 +308,7 @@ function ScheduleDetail({ schedule, i18n }) {
}
/>
)}
{ask_skip_tags_on_launch && skip_tags && skip_tags.length > 0 && (
{showSkipTagsDetail && (
<Detail
fullWidth
label={i18n._(t`Skip Tags`)}
@ -281,16 +326,16 @@ function ScheduleDetail({ schedule, i18n }) {
}
/>
)}
{(ask_variables_on_launch || survey_enabled) && (
{showVariablesDetail && (
<VariablesDetail
value={extra_data}
rows={4}
label={i18n._(t`Variables`)}
/>
)}
</>
)}
</DetailList>
</PromptDetailList>
</>
)}
<CardActionsRow>
{summary_fields?.user_capabilities?.edit && (
<Button

View File

@ -73,10 +73,6 @@ const schedule = {
first_name: '',
last_name: '',
},
inventory: {
id: 1,
name: 'Test Inventory',
},
},
created: '2020-03-03T20:38:54.210306Z',
modified: '2020-03-03T20:38:54.210336Z',
@ -88,6 +84,27 @@ const schedule = {
dtend: '2020-07-06T04:00:00Z',
next_run: '2020-03-16T04:00:00Z',
extra_data: {},
inventory: null,
scm_branch: null,
job_type: null,
job_tags: null,
skip_tags: null,
limit: null,
diff_mode: null,
verbosity: null,
};
const scheduleWithPrompts = {
...schedule,
job_type: 'run',
inventory: 1,
job_tags: 'tag1',
skip_tags: 'tag2',
scm_branch: 'foo/branch',
limit: 'localhost',
diff_mode: true,
verbosity: 1,
extra_data: { foo: 'fii' },
};
SchedulesAPI.createPreview.mockResolvedValue({
@ -159,13 +176,14 @@ describe('<ScheduleDetail />', () => {
expect(wrapper.find('Detail[label="Repeat Frequency"]').length).toBe(1);
expect(wrapper.find('Detail[label="Created"]').length).toBe(1);
expect(wrapper.find('Detail[label="Last Modified"]').length).toBe(1);
expect(wrapper.find('Title[children="Prompted Fields"]').length).toBe(0);
expect(wrapper.find('Title[children="Prompted Values"]').length).toBe(0);
expect(wrapper.find('Detail[label="Job Type"]').length).toBe(0);
expect(wrapper.find('Detail[label="Inventory"]').length).toBe(0);
expect(wrapper.find('Detail[label="Source Control Branch"]').length).toBe(
0
);
expect(wrapper.find('Detail[label="Limit"]').length).toBe(0);
expect(wrapper.find('Detail[label="Verbosity"]').length).toBe(0);
expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(0);
expect(wrapper.find('Detail[label="Credentials"]').length).toBe(0);
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0);
@ -189,18 +207,6 @@ describe('<ScheduleDetail />', () => {
},
});
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(allPrompts);
const scheduleWithPrompts = {
...schedule,
job_type: 'run',
inventory: 1,
job_tags: 'tag1',
skip_tags: 'tag2',
scm_branch: 'foo/branch',
limit: 'localhost',
diff_mode: true,
verbosity: 1,
extra_data: { foo: 'fii' },
};
await act(async () => {
wrapper = mountWithContexts(
<Route
@ -245,7 +251,7 @@ describe('<ScheduleDetail />', () => {
expect(wrapper.find('Detail[label="Repeat Frequency"]').length).toBe(1);
expect(wrapper.find('Detail[label="Created"]').length).toBe(1);
expect(wrapper.find('Detail[label="Last Modified"]').length).toBe(1);
expect(wrapper.find('Title[children="Prompted Fields"]').length).toBe(1);
expect(wrapper.find('Title[children="Prompted Values"]').length).toBe(1);
expect(
wrapper
.find('Detail[label="Job Type"]')
@ -265,12 +271,102 @@ describe('<ScheduleDetail />', () => {
.find('dd')
.text()
).toBe('localhost');
expect(
wrapper
.find('Detail[label="Verbosity"]')
.find('dd')
.text()
).toBe('1 (Verbose)');
expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(1);
expect(wrapper.find('Detail[label="Credentials"]').length).toBe(1);
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(1);
expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(1);
expect(wrapper.find('VariablesDetail').length).toBe(1);
});
test('prompt values section should be hidden if no overrides are present on the schedule but ask_ options are all true', async () => {
SchedulesAPI.readCredentials.mockResolvedValueOnce({
data: {
count: 0,
results: [],
},
});
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(allPrompts);
await act(async () => {
wrapper = mountWithContexts(
<Route
path="/templates/job_template/:id/schedules/:scheduleId"
component={() => <ScheduleDetail schedule={schedule} />}
/>,
{
context: {
router: {
history,
route: {
location: history.location,
match: { params: { id: 1 } },
},
},
},
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Title[children="Prompted Values"]').length).toBe(0);
expect(wrapper.find('Detail[label="Job Type"]').length).toBe(0);
expect(wrapper.find('Detail[label="Inventory"]').length).toBe(0);
expect(wrapper.find('Detail[label="Source Control Branch"]').length).toBe(
0
);
expect(wrapper.find('Detail[label="Limit"]').length).toBe(0);
expect(wrapper.find('Detail[label="Verbosity"]').length).toBe(0);
expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(0);
expect(wrapper.find('Detail[label="Credentials"]').length).toBe(0);
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0);
expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(0);
expect(wrapper.find('VariablesDetail').length).toBe(0);
});
test('prompt values section should be hidden if overrides are present on the schedule but ask_ options are all false', async () => {
SchedulesAPI.readCredentials.mockResolvedValueOnce({
data: {
count: 0,
results: [],
},
});
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(noPrompts);
await act(async () => {
wrapper = mountWithContexts(
<Route
path="/templates/job_template/:id/schedules/:scheduleId"
component={() => <ScheduleDetail schedule={scheduleWithPrompts} />}
/>,
{
context: {
router: {
history,
route: {
location: history.location,
match: { params: { id: 1 } },
},
},
},
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Title[children="Prompted Values"]').length).toBe(0);
expect(wrapper.find('Detail[label="Job Type"]').length).toBe(0);
expect(wrapper.find('Detail[label="Inventory"]').length).toBe(0);
expect(wrapper.find('Detail[label="Source Control Branch"]').length).toBe(
0
);
expect(wrapper.find('Detail[label="Limit"]').length).toBe(0);
expect(wrapper.find('Detail[label="Verbosity"]').length).toBe(0);
expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(0);
expect(wrapper.find('Detail[label="Credentials"]').length).toBe(0);
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0);
expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(0);
expect(wrapper.find('VariablesDetail').length).toBe(0);
});
test('error shown when error encountered fetching credentials', async () => {
SchedulesAPI.readCredentials.mockRejectedValueOnce(
new Error({

View File

@ -6,11 +6,9 @@ import { t } from '@lingui/macro';
import { SchedulesAPI } from '../../../api';
import AlertModal from '../../AlertModal';
import ErrorDetail from '../../ErrorDetail';
import PaginatedTable, { HeaderRow, HeaderCell } from '../../PaginatedTable';
import DataListToolbar from '../../DataListToolbar';
import PaginatedDataList, {
ToolbarAddButton,
ToolbarDeleteButton,
} from '../../PaginatedDataList';
import { ToolbarAddButton, ToolbarDeleteButton } from '../../PaginatedDataList';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import ScheduleListItem from './ScheduleListItem';
@ -119,19 +117,28 @@ function ScheduleList({
return (
<>
<PaginatedDataList
<PaginatedTable
contentError={contentError}
hasContentLoading={isLoading || isDeleteLoading}
items={schedules}
itemCount={itemCount}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
renderItem={item => (
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
<HeaderCell>{i18n._(t`Type`)}</HeaderCell>
<HeaderCell sortKey="next_run">{i18n._(t`Next Run`)}</HeaderCell>
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
</HeaderRow>
}
renderRow={(item, index) => (
<ScheduleListItem
isSelected={selected.some(row => row.id === item.id)}
key={item.id}
onSelect={() => handleSelect(item)}
schedule={item}
rowIndex={index}
/>
)}
toolbarSearchColumns={[
@ -153,16 +160,6 @@ function ScheduleList({
key: 'modified_by__username__icontains',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
{
name: i18n._(t`Next Run`),
key: 'next_run',
},
]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
renderToolbar={props => (

View File

@ -59,44 +59,61 @@ describe('ScheduleList', () => {
test('should check and uncheck the row item', async () => {
expect(
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
wrapper
.find('.pf-c-table__check')
.first()
.find('input')
.props().checked
).toBe(false);
await act(async () => {
wrapper
.find('DataListCheck[id="select-schedule-1"]')
.find('.pf-c-table__check')
.first()
.find('input')
.invoke('onChange')(true);
});
wrapper.update();
expect(
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
wrapper
.find('.pf-c-table__check')
.first()
.find('input')
.props().checked
).toBe(true);
await act(async () => {
wrapper
.find('DataListCheck[id="select-schedule-1"]')
.find('.pf-c-table__check')
.first()
.find('input')
.invoke('onChange')(false);
});
wrapper.update();
expect(
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
wrapper
.find('.pf-c-table__check')
.first()
.find('input')
.props().checked
).toBe(false);
});
test('should check all row items when select all is checked', async () => {
wrapper.find('DataListCheck').forEach(el => {
expect(wrapper.find('.pf-c-table__check input')).toHaveLength(5);
wrapper.find('.pf-c-table__check input').forEach(el => {
expect(el.props().checked).toBe(false);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
wrapper.find('.pf-c-table__check input').forEach(el => {
expect(el.props().checked).toBe(true);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(false);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
wrapper.find('.pf-c-table__check input').forEach(el => {
expect(el.props().checked).toBe(false);
});
});
@ -104,7 +121,8 @@ describe('ScheduleList', () => {
test('should call api delete schedules for each selected schedule', async () => {
await act(async () => {
wrapper
.find('DataListCheck[id="select-schedule-3"]')
.find('.pf-c-table__check input')
.at(3)
.invoke('onChange')();
});
wrapper.update();
@ -122,7 +140,8 @@ describe('ScheduleList', () => {
expect(wrapper.find('Modal').length).toBe(0);
await act(async () => {
wrapper
.find('DataListCheck[id="select-schedule-2"]')
.find('.pf-c-table__check input')
.at(2)
.invoke('onChange')();
});
wrapper.update();

View File

@ -4,31 +4,16 @@ import { bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import {
Button,
DataListAction as _DataListAction,
DataListCheck,
DataListItem,
DataListItemRow,
DataListItemCells,
Tooltip,
} from '@patternfly/react-core';
import { Button } from '@patternfly/react-core';
import { Tr, Td } from '@patternfly/react-table';
import { PencilAltIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import DataListCell from '../../DataListCell';
import { DetailList, Detail } from '../../DetailList';
import { ActionsTd, ActionItem } from '../../PaginatedTable';
import { ScheduleToggle } from '..';
import { Schedule } from '../../../types';
import { formatDateString } from '../../../util/dates';
const DataListAction = styled(_DataListAction)`
align-items: center;
display: grid;
grid-gap: 16px;
grid-template-columns: 92px 40px;
`;
function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) {
function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) {
const labelId = `check-action-${schedule.id}`;
const jobTypeLabels = {
@ -62,69 +47,56 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) {
}
return (
<DataListItem
key={schedule.id}
aria-labelledby={labelId}
id={`${schedule.id}`}
>
<DataListItemRow>
<DataListCheck
id={`select-schedule-${schedule.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="name">
<Link to={`${scheduleBaseUrl}/details`}>
<b>{schedule.name}</b>
</Link>
</DataListCell>,
<DataListCell key="type">
{
jobTypeLabels[
schedule.summary_fields.unified_job_template.unified_job_type
]
}
</DataListCell>,
<DataListCell key="next_run">
{schedule.next_run && (
<DetailList stacked>
<Detail
label={i18n._(t`Next Run`)}
value={formatDateString(schedule.next_run)}
/>
</DetailList>
)}
</DataListCell>,
]}
/>
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
key="actions"
<Tr id={`schedule-row-${schedule.id}`}>
<Td
select={{
rowIndex,
isSelected,
onSelect,
disable: false,
}}
dataLabel={i18n._(t`Selected`)}
/>
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
<Link to={`${scheduleBaseUrl}/details`}>
<b>{schedule.name}</b>
</Link>
</Td>
<Td dataLabel={i18n._(t`Type`)}>
{
jobTypeLabels[
schedule.summary_fields.unified_job_template.unified_job_type
]
}
</Td>
<Td dataLabel={i18n._(t`Next Run`)}>
{schedule.next_run && (
<DetailList stacked>
<Detail
label={i18n._(t`Next Run`)}
value={formatDateString(schedule.next_run)}
/>
</DetailList>
)}
</Td>
<ActionsTd dataLabel={i18n._(t`Actions`)} gridColumns="auto 40px">
<ScheduleToggle schedule={schedule} />
<ActionItem
visible={schedule.summary_fields.user_capabilities.edit}
tooltip={i18n._(t`Edit Schedule`)}
>
<ScheduleToggle schedule={schedule} />
{schedule.summary_fields.user_capabilities.edit ? (
<Tooltip content={i18n._(t`Edit Schedule`)} position="top">
<Button
aria-label={i18n._(t`Edit Schedule`)}
css="grid-column: 2"
variant="plain"
component={Link}
to={`${scheduleBaseUrl}/edit`}
>
<PencilAltIcon />
</Button>
</Tooltip>
) : (
''
)}
</DataListAction>
</DataListItemRow>
</DataListItem>
<Button
aria-label={i18n._(t`Edit Schedule`)}
css="grid-column: 2"
variant="plain"
component={Link}
to={`${scheduleBaseUrl}/edit`}
>
<PencilAltIcon />
</Button>
</ActionItem>
</ActionsTd>
</Tr>
);
}

View File

@ -50,39 +50,47 @@ describe('ScheduleListItem', () => {
describe('User has edit permissions', () => {
beforeAll(() => {
wrapper = mountWithContexts(
<ScheduleListItem
isSelected={false}
onSelect={onSelect}
schedule={mockSchedule}
/>
<table>
<tbody>
<ScheduleListItem
isSelected={false}
onSelect={onSelect}
schedule={mockSchedule}
/>
</tbody>
</table>
);
});
afterAll(() => {
wrapper.unmount();
});
test('Name correctly shown with correct link', () => {
expect(
wrapper
.find('DataListCell')
.first()
.find('Td')
.at(1)
.text()
).toBe('Mock Schedule');
expect(
wrapper
.find('DataListCell')
.first()
.find('Td')
.at(1)
.find('Link')
.props().to
).toBe('/templates/job_template/12/schedules/6/details');
});
test('Type correctly shown', () => {
expect(
wrapper
.find('DataListCell')
.at(1)
.find('Td')
.at(2)
.text()
).toBe('Playbook Run');
});
test('Edit button shown with correct link', () => {
expect(wrapper.find('PencilAltIcon').length).toBe(1);
expect(
@ -92,6 +100,7 @@ describe('ScheduleListItem', () => {
.props().to
).toBe('/templates/job_template/12/schedules/6/edit');
});
test('Toggle button enabled', () => {
expect(
wrapper
@ -100,63 +109,74 @@ describe('ScheduleListItem', () => {
.props().isDisabled
).toBe(false);
});
test('Clicking checkbox makes expected callback', () => {
test('Clicking checkbox selects item', () => {
wrapper
.find('DataListCheck')
.find('Td')
.first()
.find('input')
.simulate('change');
expect(onSelect).toHaveBeenCalledTimes(1);
});
});
describe('User has read-only permissions', () => {
beforeAll(() => {
wrapper = mountWithContexts(
<ScheduleListItem
isSelected={false}
onSelect={onSelect}
schedule={{
...mockSchedule,
summary_fields: {
...mockSchedule.summary_fields,
user_capabilities: {
edit: false,
delete: false,
},
},
}}
/>
<table>
<tbody>
<ScheduleListItem
isSelected={false}
onSelect={onSelect}
schedule={{
...mockSchedule,
summary_fields: {
...mockSchedule.summary_fields,
user_capabilities: {
edit: false,
delete: false,
},
},
}}
/>
</tbody>
</table>
);
});
afterAll(() => {
wrapper.unmount();
});
test('Name correctly shown with correct link', () => {
expect(
wrapper
.find('DataListCell')
.first()
.find('Td')
.at(1)
.text()
).toBe('Mock Schedule');
expect(
wrapper
.find('DataListCell')
.first()
.find('Td')
.at(1)
.find('Link')
.props().to
).toBe('/templates/job_template/12/schedules/6/details');
});
test('Type correctly shown', () => {
expect(
wrapper
.find('DataListCell')
.at(1)
.find('Td')
.at(2)
.text()
).toBe('Playbook Run');
});
test('Edit button hidden', () => {
expect(wrapper.find('PencilAltIcon').length).toBe(0);
});
test('Toggle button disabled', () => {
expect(
wrapper

View File

@ -7,29 +7,33 @@ import {
JobTemplatesAPI,
UnifiedJobTemplatesAPI,
WorkflowJobTemplatesAPI,
} from '../../../api';
import AlertModal from '../../../components/AlertModal';
import DatalistToolbar from '../../../components/DataListToolbar';
import ErrorDetail from '../../../components/ErrorDetail';
import PaginatedDataList, {
ToolbarDeleteButton,
} from '../../../components/PaginatedDataList';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import useWsTemplates from '../../../util/useWsTemplates';
import AddDropDownButton from '../../../components/AddDropDownButton';
} from '../../api';
import AlertModal from '../AlertModal';
import DatalistToolbar from '../DataListToolbar';
import ErrorDetail from '../ErrorDetail';
import { ToolbarDeleteButton } from '../PaginatedDataList';
import PaginatedTable, { HeaderRow, HeaderCell } from '../PaginatedTable';
import useRequest, { useDeleteItems } from '../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../util/qs';
import useWsTemplates from '../../util/useWsTemplates';
import AddDropDownButton from '../AddDropDownButton';
import TemplateListItem from './TemplateListItem';
// The type value in const QS_CONFIG below does not have a space between job_template and
// workflow_job_template so the params sent to the API match what the api expects.
const QS_CONFIG = getQSConfig('template', {
page: 1,
page_size: 20,
order_by: 'name',
type: 'job_template,workflow_job_template',
});
function TemplateList({ defaultParams, i18n }) {
// The type value in const qsConfig below does not have a space between job_template and
// workflow_job_template so the params sent to the API match what the api expects.
const qsConfig = getQSConfig(
'template',
{
page: 1,
page_size: 20,
order_by: 'name',
type: 'job_template,workflow_job_template',
...defaultParams,
},
['id', 'page', 'page_size']
);
function TemplateList({ i18n }) {
const location = useLocation();
const [selected, setSelected] = useState([]);
@ -47,7 +51,7 @@ function TemplateList({ i18n }) {
request: fetchTemplates,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const params = parseQueryString(qsConfig, location.search);
const responses = await Promise.all([
UnifiedJobTemplatesAPI.read(params),
JobTemplatesAPI.readOptions(),
@ -66,7 +70,7 @@ function TemplateList({ i18n }) {
responses[3].data.actions?.GET || {}
).filter(key => responses[3].data.actions?.GET[key].filterable),
};
}, [location]),
}, [location]), // eslint-disable-line react-hooks/exhaustive-deps
{
results: [],
count: 0,
@ -105,7 +109,7 @@ function TemplateList({ i18n }) {
);
}, [selected]),
{
qsConfig: QS_CONFIG,
qsConfig,
allItemsSelected: isAllSelected,
fetchItems: fetchTemplates,
}
@ -167,13 +171,13 @@ function TemplateList({ i18n }) {
return (
<Fragment>
<Card>
<PaginatedDataList
<PaginatedTable
contentError={contentError}
hasContentLoading={isLoading || isDeleteLoading}
items={templates}
itemCount={count}
pluralizedItemName={i18n._(t`Templates`)}
qsConfig={QS_CONFIG}
qsConfig={qsConfig}
onRowClick={handleSelect}
toolbarSearchColumns={[
{
@ -206,53 +210,37 @@ function TemplateList({ i18n }) {
key: 'modified_by__username__icontains',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Inventory`),
key: 'job_template__inventory__id',
},
{
name: i18n._(t`Last Job Run`),
key: 'last_job_run',
},
{
name: i18n._(t`Modified`),
key: 'modified',
},
{
name: i18n._(t`Name`),
key: 'name',
},
{
name: i18n._(t`Project`),
key: 'jobtemplate__project__id',
},
{
name: i18n._(t`Type`),
key: 'type',
},
]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
headerRow={
<HeaderRow qsConfig={qsConfig} isExpandable>
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
<HeaderCell sortKey="type">{i18n._(t`Type`)}</HeaderCell>
<HeaderCell sortKey="last_job_run">
{i18n._(t`Last Ran`)}
</HeaderCell>
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
</HeaderRow>
}
renderToolbar={props => (
<DatalistToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG}
qsConfig={qsConfig}
additionalControls={[
...(canAddJT || canAddWFJT ? [addButton] : []),
<ToolbarDeleteButton
key="delete"
onDelete={handleTemplateDelete}
itemsToDelete={selected}
pluralizedItemName="Templates"
pluralizedItemName={i18n._(t`Templates`)}
/>,
]}
/>
)}
renderItem={template => (
renderRow={(template, index) => (
<TemplateListItem
key={template.id}
value={template.name}
@ -261,6 +249,7 @@ function TemplateList({ i18n }) {
onSelect={() => handleSelect(template)}
isSelected={selected.some(row => row.id === template.id)}
fetchTemplates={fetchTemplates}
rowIndex={index}
/>
)}
emptyStateControls={(canAddJT || canAddWFJT) && addButton}

View File

@ -4,15 +4,15 @@ import {
JobTemplatesAPI,
UnifiedJobTemplatesAPI,
WorkflowJobTemplatesAPI,
} from '../../../api';
} from '../../api';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
} from '../../../testUtils/enzymeHelpers';
import TemplateList from './TemplateList';
jest.mock('../../../api');
jest.mock('../../api');
const mockTemplates = [
{

View File

@ -0,0 +1,278 @@
import 'styled-components/macro';
import React, { useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Button, Tooltip, Chip } from '@patternfly/react-core';
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import {
ExclamationTriangleIcon,
PencilAltIcon,
ProjectDiagramIcon,
RocketIcon,
} from '@patternfly/react-icons';
import { ActionsTd, ActionItem } from '../PaginatedTable';
import { DetailList, Detail, DeletedDetail } from '../DetailList';
import ChipGroup from '../ChipGroup';
import CredentialChip from '../CredentialChip';
import { timeOfDay, formatDateString } from '../../util/dates';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api';
import LaunchButton from '../LaunchButton';
import Sparkline from '../Sparkline';
import { toTitleCase } from '../../util/strings';
import CopyButton from '../CopyButton';
function TemplateListItem({
i18n,
template,
isSelected,
onSelect,
detailUrl,
fetchTemplates,
rowIndex,
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [isDisabled, setIsDisabled] = useState(false);
const labelId = `check-action-${template.id}`;
const copyTemplate = useCallback(async () => {
if (template.type === 'job_template') {
await JobTemplatesAPI.copy(template.id, {
name: `${template.name} @ ${timeOfDay()}`,
});
} else {
await WorkflowJobTemplatesAPI.copy(template.id, {
name: `${template.name} @ ${timeOfDay()}`,
});
}
await fetchTemplates();
}, [fetchTemplates, template.id, template.name, template.type]);
const handleCopyStart = useCallback(() => {
setIsDisabled(true);
}, []);
const handleCopyFinish = useCallback(() => {
setIsDisabled(false);
}, []);
const {
summary_fields: summaryFields,
ask_inventory_on_launch: askInventoryOnLaunch,
} = template;
const missingResourceIcon =
template.type === 'job_template' &&
(!summaryFields.project ||
(!summaryFields.inventory && !askInventoryOnLaunch));
const inventoryValue = (kind, id) => {
const inventorykind = kind === 'smart' ? 'smart_inventory' : 'inventory';
return askInventoryOnLaunch ? (
<>
<Link to={`/inventories/${inventorykind}/${id}/details`}>
{summaryFields.inventory.name}
</Link>
<span> {i18n._(t`(Prompt on launch)`)}</span>
</>
) : (
<Link to={`/inventories/${inventorykind}/${id}/details`}>
{summaryFields.inventory.name}
</Link>
);
};
let lastRun = '';
const mostRecentJob = template.summary_fields.recent_jobs
? template.summary_fields.recent_jobs[0]
: null;
if (mostRecentJob) {
lastRun = mostRecentJob.finished
? formatDateString(mostRecentJob.finished)
: i18n._(t`Running`);
}
return (
<>
<Tr id={`template-row-${template.id}`}>
<Td
expand={{
rowIndex,
isExpanded,
onToggle: () => setIsExpanded(!isExpanded),
}}
/>
<Td
select={{
rowIndex,
isSelected,
onSelect,
}}
dataLabel={i18n._(t`Selected`)}
/>
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
<Link to={`${detailUrl}`}>
<b>{template.name}</b>
</Link>
{missingResourceIcon && (
<span>
<Tooltip
content={i18n._(t`Resources are missing from this template.`)}
position="right"
>
<ExclamationTriangleIcon css="color: #c9190b; margin-left: 20px;" />
</Tooltip>
</span>
)}
</Td>
<Td dataLabel={i18n._(t`Type`)}>{toTitleCase(template.type)}</Td>
<Td dataLabel={i18n._(t`Last Ran`)}>{lastRun}</Td>
<ActionsTd dataLabel={i18n._(t`Actions`)}>
<ActionItem
visible={template.type === 'workflow_job_template'}
tooltip={i18n._(t`Visualizer`)}
>
<Button
id={`template-action-visualizer-${template.id}`}
isDisabled={isDisabled}
aria-label={i18n._(t`Visualizer`)}
variant="plain"
component={Link}
to={`/templates/workflow_job_template/${template.id}/visualizer`}
>
<ProjectDiagramIcon />
</Button>
</ActionItem>
<ActionItem
visible={template.summary_fields.user_capabilities.start}
tooltip={i18n._(t`Launch Template`)}
>
<LaunchButton resource={template}>
{({ handleLaunch }) => (
<Button
id={`template-action-launch-${template.id}`}
isDisabled={isDisabled}
aria-label={i18n._(t`Launch template`)}
variant="plain"
onClick={handleLaunch}
>
<RocketIcon />
</Button>
)}
</LaunchButton>
</ActionItem>
<ActionItem
visible={template.summary_fields.user_capabilities.edit}
tooltip={i18n._(t`Edit Template`)}
>
<Button
id={`template-action-edit-${template.id}`}
isDisabled={isDisabled}
aria-label={i18n._(t`Edit Template`)}
variant="plain"
component={Link}
to={`/templates/${template.type}/${template.id}/edit`}
>
<PencilAltIcon />
</Button>
</ActionItem>
<ActionItem visible={template.summary_fields.user_capabilities.copy}>
<CopyButton
id={`template-action-copy-${template.id}`}
helperText={{
errorMessage: i18n._(t`Failed to copy template.`),
tooltip: i18n._(t`Copy Template`),
}}
isDisabled={isDisabled}
onCopyStart={handleCopyStart}
onCopyFinish={handleCopyFinish}
copyItem={copyTemplate}
/>
</ActionItem>
</ActionsTd>
</Tr>
<Tr isExpanded={isExpanded}>
<Td colSpan={2} />
<Td colSpan={4}>
<ExpandableRowContent>
<DetailList>
<Detail
label={i18n._(t`Activity`)}
value={<Sparkline jobs={summaryFields.recent_jobs} />}
dataCy={`template-${template.id}-activity`}
/>
{summaryFields.credentials && summaryFields.credentials.length && (
<Detail
label={i18n._(t`Credentials`)}
value={
<ChipGroup
numChips={5}
totalChips={summaryFields.credentials.length}
>
{summaryFields.credentials.map(c => (
<CredentialChip key={c.id} credential={c} isReadOnly />
))}
</ChipGroup>
}
dataCy={`template-${template.id}-credentials`}
/>
)}
{summaryFields.inventory ? (
<Detail
label={i18n._(t`Inventory`)}
value={inventoryValue(
summaryFields.inventory.kind,
summaryFields.inventory.id
)}
dataCy={`template-${template.id}-inventory`}
/>
) : (
!askInventoryOnLaunch && (
<DeletedDetail label={i18n._(t`Inventory`)} />
)
)}
{summaryFields.labels && summaryFields.labels.results.length > 0 && (
<Detail
label={i18n._(t`Labels`)}
value={
<ChipGroup
numChips={5}
totalChips={summaryFields.labels.results.length}
>
{summaryFields.labels.results.map(l => (
<Chip key={l.id} isReadOnly>
{l.name}
</Chip>
))}
</ChipGroup>
}
dataCy={`template-${template.id}-labels`}
/>
)}
{summaryFields.project && (
<Detail
label={i18n._(t`Project`)}
value={
<Link to={`/projects/${summaryFields.project.id}/details`}>
{summaryFields.project.name}
</Link>
}
dataCy={`template-${template.id}-project`}
/>
)}
<Detail
label={i18n._(t`Last Modified`)}
value={formatDateString(template.modified)}
dataCy={`template-${template.id}-last-modified`}
/>
</DetailList>
</ExpandableRowContent>
</Td>
</Tr>
</>
);
}
export { TemplateListItem as _TemplateListItem };
export default withI18n()(TemplateListItem);

View File

@ -0,0 +1,323 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import { JobTemplatesAPI } from '../../api';
import mockJobTemplateData from './data.job_template.json';
import TemplateListItem from './TemplateListItem';
jest.mock('../../api');
describe('<TemplateListItem />', () => {
test('launch button shown to users with start capabilities', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
start: true,
},
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('LaunchButton').exists()).toBeTruthy();
});
test('launch button hidden from users without start capabilities', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
start: false,
},
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('LaunchButton').exists()).toBeFalsy();
});
test('edit button shown to users with edit capabilities', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: true,
},
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
});
test('edit button hidden from users without edit capabilities', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: false,
},
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
test('missing resource icon is shown.', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: false,
},
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(true);
});
test('missing resource icon is not shown when there is a project and an inventory.', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: false,
},
project: { name: 'Foo', id: 2 },
inventory: { name: 'Bar', id: 2 },
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
});
test('missing resource icon is not shown when inventory is prompt_on_launch, and a project', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
ask_inventory_on_launch: true,
summary_fields: {
user_capabilities: {
edit: false,
},
project: { name: 'Foo', id: 2 },
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
});
test('missing resource icon is not shown type is workflow_job_template', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'workflow_job_template',
summary_fields: {
user_capabilities: {
edit: false,
},
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
});
test('clicking on template from templates list navigates properly', () => {
const history = createMemoryHistory({
initialEntries: ['/templates'],
});
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
isSelected={false}
detailUrl="/templates/job_template/1/details"
template={{
id: 1,
name: 'Template 1',
summary_fields: {
user_capabilities: {
edit: false,
},
},
}}
/>
</tbody>
</table>,
{ context: { router: { history } } }
);
wrapper.find('Link').simulate('click', { button: 0 });
expect(history.location.pathname).toEqual(
'/templates/job_template/1/details'
);
});
test('should call api to copy template', async () => {
JobTemplatesAPI.copy.mockResolvedValue();
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
isSelected={false}
detailUrl="/templates/job_template/1/details"
template={mockJobTemplateData}
/>
</tbody>
</table>
);
await act(async () =>
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
);
expect(JobTemplatesAPI.copy).toHaveBeenCalled();
jest.clearAllMocks();
});
test('should render proper alert modal on copy error', async () => {
JobTemplatesAPI.copy.mockRejectedValue(new Error());
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
isSelected={false}
detailUrl="/templates/job_template/1/details"
template={mockJobTemplateData}
/>
</tbody>
</table>
);
await act(async () =>
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
);
wrapper.update();
expect(wrapper.find('Modal').prop('isOpen')).toBe(true);
jest.clearAllMocks();
});
test('should not render copy button', async () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
isSelected={false}
detailUrl="/templates/job_template/1/details"
template={{
...mockJobTemplateData,
summary_fields: { user_capabilities: { copy: false } },
}}
/>
</tbody>
</table>
);
expect(wrapper.find('CopyButton').length).toBe(0);
});
test('should render visualizer button for workflow', async () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
isSelected={false}
detailUrl="/templates/job_template/1/details"
template={{
...mockJobTemplateData,
type: 'workflow_job_template',
}}
/>
</tbody>
</table>
);
expect(wrapper.find('ProjectDiagramIcon').length).toBe(1);
});
test('should not render visualizer button for job template', async () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
isSelected={false}
detailUrl="/templates/job_template/1/details"
template={mockJobTemplateData}
/>
</tbody>
</table>
);
expect(wrapper.find('ProjectDiagramIcon').length).toBe(0);
});
});

View File

@ -0,0 +1,181 @@
{
"id": 7,
"type": "job_template",
"url": "/api/v2/job_templates/7/",
"related": {
"named_url": "/api/v2/job_templates/Mike's JT/",
"created_by": "/api/v2/users/1/",
"modified_by": "/api/v2/users/1/",
"labels": "/api/v2/job_templates/7/labels/",
"inventory": "/api/v2/inventories/1/",
"project": "/api/v2/projects/6/",
"credentials": "/api/v2/job_templates/7/credentials/",
"last_job": "/api/v2/jobs/12/",
"jobs": "/api/v2/job_templates/7/jobs/",
"schedules": "/api/v2/job_templates/7/schedules/",
"activity_stream": "/api/v2/job_templates/7/activity_stream/",
"launch": "/api/v2/job_templates/7/launch/",
"notification_templates_started": "/api/v2/job_templates/7/notification_templates_started/",
"notification_templates_success": "/api/v2/job_templates/7/notification_templates_success/",
"notification_templates_error": "/api/v2/job_templates/7/notification_templates_error/",
"access_list": "/api/v2/job_templates/7/access_list/",
"survey_spec": "/api/v2/job_templates/7/survey_spec/",
"object_roles": "/api/v2/job_templates/7/object_roles/",
"instance_groups": "/api/v2/job_templates/7/instance_groups/",
"slice_workflow_jobs": "/api/v2/job_templates/7/slice_workflow_jobs/",
"copy": "/api/v2/job_templates/7/copy/",
"webhook_receiver": "/api/v2/job_templates/7/github/",
"webhook_key": "/api/v2/job_templates/7/webhook_key/"
},
"summary_fields": {
"inventory": {
"id": 1,
"name": "Mike's Inventory",
"description": "",
"has_active_failures": false,
"total_hosts": 1,
"hosts_with_active_failures": 0,
"total_groups": 0,
"groups_with_active_failures": 0,
"has_inventory_sources": false,
"total_inventory_sources": 0,
"inventory_sources_with_failures": 0,
"organization_id": 1,
"kind": ""
},
"project": {
"id": 6,
"name": "Mike's Project",
"description": "",
"status": "successful",
"scm_type": "git"
},
"last_job": {
"id": 12,
"name": "Mike's JT",
"description": "",
"finished": "2019-10-01T14:34:35.142483Z",
"status": "successful",
"failed": false
},
"last_update": {
"id": 12,
"name": "Mike's JT",
"description": "",
"status": "successful",
"failed": false
},
"created_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"modified_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"object_roles": {
"admin_role": {
"description": "Can manage all aspects of the job template",
"name": "Admin",
"id": 24
},
"execute_role": {
"description": "May run the job template",
"name": "Execute",
"id": 25
},
"read_role": {
"description": "May view settings for the job template",
"name": "Read",
"id": 26
}
},
"user_capabilities": {
"edit": true,
"delete": true,
"start": true,
"schedule": true,
"copy": true
},
"labels": {
"count": 1,
"results": [{
"id": 91,
"name": "L_91o2"
}]
},
"survey": {
"title": "",
"description": ""
},
"recent_jobs": [{
"id": 12,
"status": "successful",
"finished": "2019-10-01T14:34:35.142483Z",
"type": "job"
}],
"credentials": [{
"id": 1,
"kind": "ssh",
"name": "Credential 1"
},
{
"id": 2,
"kind": "awx",
"name": "Credential 2"
}
],
"webhook_credential": {
"id": "1",
"name": "Webhook Credential"
}
},
"created": "2019-09-30T16:18:34.564820Z",
"modified": "2019-10-01T14:47:31.818431Z",
"name": "Mike's JT",
"description": "",
"job_type": "run",
"inventory": 1,
"project": 6,
"playbook": "ping.yml",
"scm_branch": "Foo branch",
"forks": 0,
"limit": "",
"verbosity": 0,
"extra_vars": "",
"job_tags": "T_100,T_200",
"force_handlers": false,
"skip_tags": "S_100,S_200",
"start_at_task": "",
"timeout": 0,
"use_fact_cache": true,
"last_job_run": "2019-10-01T14:34:35.142483Z",
"last_job_failed": false,
"next_job_run": null,
"status": "successful",
"host_config_key": "",
"ask_scm_branch_on_launch": false,
"ask_diff_mode_on_launch": false,
"ask_variables_on_launch": false,
"ask_limit_on_launch": false,
"ask_tags_on_launch": false,
"ask_skip_tags_on_launch": false,
"ask_job_type_on_launch": false,
"ask_verbosity_on_launch": false,
"ask_inventory_on_launch": false,
"ask_credential_on_launch": false,
"survey_enabled": true,
"become_enabled": false,
"diff_mode": false,
"allow_simultaneous": false,
"custom_virtualenv": null,
"job_slice_count": 1,
"webhook_credential": 1,
"webhook_key": "asertdyuhjkhgfd234567kjgfds",
"webhook_service": "github"
}

View File

@ -1,2 +1,2 @@
export { default as TemplateList } from './TemplateList';
export { default } from './TemplateList';
export { default as TemplateListItem } from './TemplateListItem';

View File

@ -78,7 +78,7 @@ function ApplicationListItem({
]}
/>
<DataListAction
aria-label="actions"
aria-label={i18n._(t`actions`)}
aria-labelledby={labelId}
id={labelId}
>

View File

@ -7,10 +7,14 @@ import { CredentialsAPI } from '../../../api';
import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
import DataListToolbar from '../../../components/DataListToolbar';
import PaginatedDataList, {
import {
ToolbarAddButton,
ToolbarDeleteButton,
} from '../../../components/PaginatedDataList';
import PaginatedTable, {
HeaderRow,
HeaderCell,
} from '../../../components/PaginatedTable';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import CredentialListItem from './CredentialListItem';
@ -114,7 +118,7 @@ function CredentialList({ i18n }) {
return (
<PageSection>
<Card>
<PaginatedDataList
<PaginatedTable
contentError={contentError}
hasContentLoading={isLoading || isDeleteLoading}
items={credentials}
@ -142,7 +146,14 @@ function CredentialList({ i18n }) {
key: 'modified_by__username__icontains',
},
]}
renderItem={item => (
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
<HeaderCell>{i18n._(t`Type`)}</HeaderCell>
<HeaderCell alignRight>{i18n._(t`Actions`)}</HeaderCell>
</HeaderRow>
}
renderRow={(item, index) => (
<CredentialListItem
key={item.id}
credential={item}
@ -150,6 +161,7 @@ function CredentialList({ i18n }) {
detailUrl={`/credentials/${item.id}/details`}
isSelected={selected.some(row => row.id === item.id)}
onSelect={() => handleSelect(item)}
rowIndex={index}
/>
)}
renderToolbar={props => (

View File

@ -57,25 +57,41 @@ describe('<CredentialList />', () => {
test('should check and uncheck the row item', async () => {
expect(
wrapper.find('DataListCheck[id="select-credential-1"]').props().checked
wrapper
.find('.pf-c-table__check')
.first()
.find('input')
.props().checked
).toBe(false);
await act(async () => {
wrapper
.find('DataListCheck[id="select-credential-1"]')
.find('.pf-c-table__check')
.first()
.find('input')
.invoke('onChange')(true);
});
wrapper.update();
expect(
wrapper.find('DataListCheck[id="select-credential-1"]').props().checked
wrapper
.find('.pf-c-table__check')
.first()
.find('input')
.props().checked
).toBe(true);
await act(async () => {
wrapper
.find('DataListCheck[id="select-credential-1"]')
.find('.pf-c-table__check')
.first()
.find('input')
.invoke('onChange')(false);
});
wrapper.update();
expect(
wrapper.find('DataListCheck[id="select-credential-1"]').props().checked
wrapper
.find('.pf-c-table__check')
.first()
.find('input')
.props().checked
).toBe(false);
});
@ -105,7 +121,9 @@ describe('<CredentialList />', () => {
await act(async () => {
wrapper
.find('DataListCheck[id="select-credential-3"]')
.find('.pf-c-table__check')
.at(2)
.find('input')
.invoke('onChange')();
});
wrapper.update();
@ -122,7 +140,9 @@ describe('<CredentialList />', () => {
);
await act(async () => {
wrapper
.find('DataListCheck[id="select-credential-2"]')
.find('.pf-c-table__check')
.at(1)
.find('input')
.invoke('onChange')();
});
wrapper.update();

View File

@ -3,31 +3,16 @@ import { string, bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import {
Button,
DataListAction as _DataListAction,
DataListCheck,
DataListItem,
DataListItemRow,
DataListItemCells,
Tooltip,
} from '@patternfly/react-core';
import { Button } from '@patternfly/react-core';
import { Tr, Td } from '@patternfly/react-table';
import { PencilAltIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import DataListCell from '../../../components/DataListCell';
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
import { timeOfDay } from '../../../util/dates';
import { Credential } from '../../../types';
import { CredentialsAPI } from '../../../api';
import CopyButton from '../../../components/CopyButton';
const DataListAction = styled(_DataListAction)`
align-items: center;
display: grid;
grid-gap: 16px;
grid-template-columns: repeat(2, 40px);
`;
function CredentialListItem({
credential,
detailUrl,
@ -35,6 +20,7 @@ function CredentialListItem({
onSelect,
i18n,
fetchCredentials,
rowIndex,
}) {
const [isDisabled, setIsDisabled] = useState(false);
@ -57,64 +43,49 @@ function CredentialListItem({
}, []);
return (
<DataListItem
key={credential.id}
aria-labelledby={labelId}
id={`${credential.id}`}
>
<DataListItemRow>
<DataListCheck
isDisabled={isDisabled}
id={`select-credential-${credential.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="name">
<Link to={`${detailUrl}`}>
<b>{credential.name}</b>
</Link>
</DataListCell>,
<DataListCell key="type">
{credential.summary_fields.credential_type.name}
</DataListCell>,
]}
/>
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
>
{canEdit && (
<Tooltip content={i18n._(t`Edit Credential`)} position="top">
<Button
isDisabled={isDisabled}
aria-label={i18n._(t`Edit Credential`)}
variant="plain"
component={Link}
to={`/credentials/${credential.id}/edit`}
>
<PencilAltIcon />
</Button>
</Tooltip>
)}
{credential.summary_fields.user_capabilities.copy && (
<CopyButton
isDisabled={isDisabled}
onCopyStart={handleCopyStart}
onCopyFinish={handleCopyFinish}
copyItem={copyCredential}
helperText={{
tooltip: i18n._(t`Copy Credential`),
errorMessage: i18n._(t`Failed to copy credential.`),
}}
/>
)}
</DataListAction>
</DataListItemRow>
</DataListItem>
<Tr id={`${credential.id}`}>
<Td
select={{
rowIndex,
isSelected,
onSelect,
}}
dataLabel={i18n._(t`Selected`)}
/>
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
<Link to={`${detailUrl}`}>
<b>{credential.name}</b>
</Link>
</Td>
<Td dataLabel={i18n._(t`Type`)}>
{credential.summary_fields.credential_type.name}
</Td>
<ActionsTd dataLabel={i18n._(t`Actions`)}>
<ActionItem visible={canEdit} tooltip={i18n._(t`Edit Credential`)}>
<Button
isDisabled={isDisabled}
aria-label={i18n._(t`Edit Credential`)}
variant="plain"
component={Link}
to={`/credentials/${credential.id}/edit`}
>
<PencilAltIcon />
</Button>
</ActionItem>
<ActionItem visible={credential.summary_fields.user_capabilities.copy}>
<CopyButton
isDisabled={isDisabled}
onCopyStart={handleCopyStart}
onCopyFinish={handleCopyFinish}
copyItem={copyCredential}
helperText={{
tooltip: i18n._(t`Copy Credential`),
errorMessage: i18n._(t`Failed to copy credential.`),
}}
/>
</ActionItem>
</ActionsTd>
</Tr>
);
}

View File

@ -16,24 +16,32 @@ describe('<CredentialListItem />', () => {
test('edit button shown to users with edit capabilities', () => {
wrapper = mountWithContexts(
<CredentialListItem
credential={mockCredentials.results[0]}
detailUrl="/foo/bar"
isSelected={false}
onSelect={() => {}}
/>
<table>
<tbody>
<CredentialListItem
credential={mockCredentials.results[0]}
detailUrl="/foo/bar"
isSelected={false}
onSelect={() => {}}
/>
</tbody>
</table>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
});
test('edit button hidden from users without edit capabilities', () => {
wrapper = mountWithContexts(
<CredentialListItem
credential={mockCredentials.results[1]}
detailUrl="/foo/bar"
isSelected={false}
onSelect={() => {}}
/>
<table>
<tbody>
<CredentialListItem
credential={mockCredentials.results[1]}
detailUrl="/foo/bar"
isSelected={false}
onSelect={() => {}}
/>
</tbody>
</table>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
@ -41,12 +49,16 @@ describe('<CredentialListItem />', () => {
CredentialsAPI.copy.mockResolvedValue();
wrapper = mountWithContexts(
<CredentialListItem
isSelected={false}
detailUrl="/foo/bar"
credential={mockCredentials.results[0]}
onSelect={() => {}}
/>
<table>
<tbody>
<CredentialListItem
isSelected={false}
detailUrl="/foo/bar"
credential={mockCredentials.results[0]}
onSelect={() => {}}
/>
</tbody>
</table>
);
await act(async () =>
@ -60,12 +72,16 @@ describe('<CredentialListItem />', () => {
CredentialsAPI.copy.mockRejectedValue(new Error());
wrapper = mountWithContexts(
<CredentialListItem
isSelected={false}
detailUrl="/foo/bar"
onSelect={() => {}}
credential={mockCredentials.results[0]}
/>
<table>
<tbody>
<CredentialListItem
isSelected={false}
detailUrl="/foo/bar"
onSelect={() => {}}
credential={mockCredentials.results[0]}
/>
</tbody>
</table>
);
await act(async () =>
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
@ -77,12 +93,16 @@ describe('<CredentialListItem />', () => {
test('should not render copy button', async () => {
wrapper = mountWithContexts(
<CredentialListItem
isSelected={false}
detailUrl="/foo/bar"
onSelect={() => {}}
credential={mockCredentials.results[1]}
/>
<table>
<tbody>
<CredentialListItem
isSelected={false}
detailUrl="/foo/bar"
onSelect={() => {}}
credential={mockCredentials.results[1]}
/>
</tbody>
</table>
);
expect(wrapper.find('CopyButton').length).toBe(0);
});

View File

@ -270,7 +270,7 @@ function CredentialForm({
<Button
id="credential-form-cancel-button"
aria-label={i18n._(t`Cancel`)}
variant="secondary"
variant="link"
type="button"
onClick={onCancel}
>

View File

@ -60,7 +60,7 @@ function CredentialTypeListItem({
]}
/>
<DataListAction
aria-label="actions"
aria-label={i18n._(t`actions`)}
aria-labelledby={labelId}
id={labelId}
>

View File

@ -23,7 +23,7 @@ import JobList from '../../components/JobList';
import ContentLoading from '../../components/ContentLoading';
import LineChart from './shared/LineChart';
import Count from './shared/Count';
import DashboardTemplateList from './shared/DashboardTemplateList';
import TemplateList from '../../components/TemplateList';
const Counts = styled.div`
display: grid;
@ -247,7 +247,9 @@ function Dashboard({ i18n }) {
</Fragment>
)}
{activeTabId === 1 && <JobList defaultParams={{ page_size: 5 }} />}
{activeTabId === 2 && <DashboardTemplateList />}
{activeTabId === 2 && (
<TemplateList defaultParams={{ page_size: 5 }} />
)}
</Card>
</div>
</MainPageSection>

View File

@ -44,7 +44,7 @@ describe('<Dashboard />', () => {
.simulate('click');
});
pageWrapper.update();
expect(pageWrapper.find('DashboardTemplateList').length).toBe(1);
expect(pageWrapper.find('TemplateList').length).toBe(1);
});
test('renders month-based/all job type chart by default', () => {

View File

@ -1,286 +0,0 @@
import React, { Fragment, useEffect, useState, useCallback } from 'react';
import { useLocation, Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Card, DropdownItem } from '@patternfly/react-core';
import {
JobTemplatesAPI,
UnifiedJobTemplatesAPI,
WorkflowJobTemplatesAPI,
} from '../../../api';
import AlertModal from '../../../components/AlertModal';
import DatalistToolbar from '../../../components/DataListToolbar';
import ErrorDetail from '../../../components/ErrorDetail';
import PaginatedDataList, {
ToolbarDeleteButton,
} from '../../../components/PaginatedDataList';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import useWsTemplates from '../../../util/useWsTemplates';
import AddDropDownButton from '../../../components/AddDropDownButton';
import DashboardTemplateListItem from './DashboardTemplateListItem';
const QS_CONFIG = getQSConfig(
'template',
{
page: 1,
page_size: 5,
order_by: 'name',
type: 'job_template,workflow_job_template',
},
['id', 'page', 'page_size']
);
function DashboardTemplateList({ i18n }) {
// The type value in const QS_CONFIG below does not have a space between job_template and
// workflow_job_template so the params sent to the API match what the api expects.
const location = useLocation();
const [selected, setSelected] = useState([]);
const {
result: {
results,
count,
jtActions,
wfjtActions,
relatedSearchableKeys,
searchableKeys,
},
error: contentError,
isLoading,
request: fetchTemplates,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const responses = await Promise.all([
UnifiedJobTemplatesAPI.read(params),
JobTemplatesAPI.readOptions(),
WorkflowJobTemplatesAPI.readOptions(),
UnifiedJobTemplatesAPI.readOptions(),
]);
return {
results: responses[0].data.results,
count: responses[0].data.count,
jtActions: responses[1].data.actions,
wfjtActions: responses[2].data.actions,
relatedSearchableKeys: (
responses[3]?.data?.related_search_fields || []
).map(val => val.slice(0, -8)),
searchableKeys: Object.keys(
responses[3].data.actions?.GET || {}
).filter(key => responses[3].data.actions?.GET[key].filterable),
};
}, [location]),
{
results: [],
count: 0,
jtActions: {},
wfjtActions: {},
relatedSearchableKeys: [],
searchableKeys: [],
}
);
useEffect(() => {
fetchTemplates();
}, [fetchTemplates]);
const templates = useWsTemplates(results);
const isAllSelected =
selected.length === templates.length && selected.length > 0;
const {
isLoading: isDeleteLoading,
deleteItems: deleteTemplates,
deletionError,
clearDeletionError,
} = useDeleteItems(
useCallback(() => {
return Promise.all(
selected.map(({ type, id }) => {
if (type === 'job_template') {
return JobTemplatesAPI.destroy(id);
}
if (type === 'workflow_job_template') {
return WorkflowJobTemplatesAPI.destroy(id);
}
return false;
})
);
}, [selected]),
{
qsConfig: QS_CONFIG,
allItemsSelected: isAllSelected,
fetchItems: fetchTemplates,
}
);
const handleTemplateDelete = async () => {
await deleteTemplates();
setSelected([]);
};
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...templates] : []);
};
const handleSelect = template => {
if (selected.some(s => s.id === template.id)) {
setSelected(selected.filter(s => s.id !== template.id));
} else {
setSelected(selected.concat(template));
}
};
const canAddJT =
jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST');
const canAddWFJT =
wfjtActions && Object.prototype.hasOwnProperty.call(wfjtActions, 'POST');
const addTemplate = i18n._(t`Add job template`);
const addWFTemplate = i18n._(t`Add workflow template`);
const addButton = (
<AddDropDownButton
key="add"
dropdownItems={[
<DropdownItem
key={addTemplate}
component={Link}
to="/templates/job_template/add/"
aria-label={addTemplate}
>
{addTemplate}
</DropdownItem>,
<DropdownItem
component={Link}
to="/templates/workflow_job_template/add/"
key={addWFTemplate}
aria-label={addWFTemplate}
>
{addWFTemplate}
</DropdownItem>,
]}
/>
);
return (
<Fragment>
<Card>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading || isDeleteLoading}
items={templates}
itemCount={count}
pluralizedItemName={i18n._(t`Templates`)}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name__icontains',
isDefault: true,
},
{
name: i18n._(t`Description`),
key: 'description__icontains',
},
{
name: i18n._(t`Type`),
key: 'or__type',
options: [
[`job_template`, i18n._(t`Job Template`)],
[`workflow_job_template`, i18n._(t`Workflow Template`)],
],
},
{
name: i18n._(t`Playbook name`),
key: 'job_template__playbook__icontains',
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username__icontains',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username__icontains',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Inventory`),
key: 'job_template__inventory__id',
},
{
name: i18n._(t`Last Job Run`),
key: 'last_job_run',
},
{
name: i18n._(t`Modified`),
key: 'modified',
},
{
name: i18n._(t`Name`),
key: 'name',
},
{
name: i18n._(t`Project`),
key: 'jobtemplate__project__id',
},
{
name: i18n._(t`Type`),
key: 'type',
},
]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
renderToolbar={props => (
<DatalistToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAddJT || canAddWFJT ? [addButton] : []),
<ToolbarDeleteButton
key="delete"
onDelete={handleTemplateDelete}
itemsToDelete={selected}
pluralizedItemName="Templates"
/>,
]}
/>
)}
renderItem={template => (
<DashboardTemplateListItem
key={template.id}
value={template.name}
template={template}
detailUrl={`/templates/${template.type}/${template.id}`}
onSelect={() => handleSelect(template)}
isSelected={selected.some(row => row.id === template.id)}
fetchTemplates={fetchTemplates}
/>
)}
emptyStateControls={(canAddJT || canAddWFJT) && addButton}
/>
</Card>
<AlertModal
aria-label={i18n._(t`Deletion Error`)}
isOpen={deletionError}
variant="error"
title={i18n._(t`Error!`)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to delete one or more templates.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
</Fragment>
);
}
export default withI18n()(DashboardTemplateList);

View File

@ -1,336 +0,0 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import {
JobTemplatesAPI,
UnifiedJobTemplatesAPI,
WorkflowJobTemplatesAPI,
} from '../../../api';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import DashboardTemplateList from './DashboardTemplateList';
jest.mock('../../../api');
const mockTemplates = [
{
id: 1,
name: 'Job Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
delete: true,
edit: true,
copy: true,
},
},
},
{
id: 2,
name: 'Job Template 2',
url: '/templates/job_template/2',
type: 'job_template',
summary_fields: {
user_capabilities: {
delete: true,
},
},
},
{
id: 3,
name: 'Job Template 3',
url: '/templates/job_template/3',
type: 'job_template',
summary_fields: {
user_capabilities: {
delete: true,
},
},
},
{
id: 4,
name: 'Workflow Job Template 1',
url: '/templates/workflow_job_template/4',
type: 'workflow_job_template',
summary_fields: {
user_capabilities: {
delete: true,
},
},
},
{
id: 5,
name: 'Workflow Job Template 2',
url: '/templates/workflow_job_template/5',
type: 'workflow_job_template',
summary_fields: {
user_capabilities: {
delete: false,
},
},
},
];
describe('<DashboardTemplateList />', () => {
let debug;
beforeEach(() => {
UnifiedJobTemplatesAPI.read.mockResolvedValue({
data: {
count: mockTemplates.length,
results: mockTemplates,
},
});
UnifiedJobTemplatesAPI.readOptions.mockResolvedValue({
data: {
actions: [],
},
});
debug = global.console.debug; // eslint-disable-line prefer-destructuring
global.console.debug = () => {};
});
afterEach(() => {
jest.clearAllMocks();
global.console.debug = debug;
});
test('initially renders successfully', async () => {
await act(async () => {
mountWithContexts(
<DashboardTemplateList
match={{ path: '/templates', url: '/templates' }}
location={{ search: '', pathname: '/templates' }}
/>
);
});
});
test('Templates are retrieved from the api and the components finishes loading', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<DashboardTemplateList />);
});
expect(UnifiedJobTemplatesAPI.read).toBeCalled();
await act(async () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
expect(wrapper.find('DashboardTemplateListItem').length).toEqual(5);
});
test('handleSelect is called when a template list item is selected', async () => {
const wrapper = mountWithContexts(<DashboardTemplateList />);
await act(async () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
const checkBox = wrapper
.find('DashboardTemplateListItem')
.at(1)
.find('input');
checkBox.simulate('change', {
target: {
id: 2,
name: 'Job Template 2',
url: '/templates/job_template/2',
type: 'job_template',
summary_fields: { user_capabilities: { delete: true } },
},
});
expect(
wrapper
.find('DashboardTemplateListItem')
.at(1)
.prop('isSelected')
).toBe(true);
});
test('handleSelectAll is called when a template list item is selected', async () => {
const wrapper = mountWithContexts(<DashboardTemplateList />);
await act(async () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(false);
const toolBarCheckBox = wrapper.find('Checkbox#select-all');
act(() => {
toolBarCheckBox.prop('onChange')(true);
});
wrapper.update();
expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(true);
});
test('delete button is disabled if user does not have delete capabilities on a selected template', async () => {
const wrapper = mountWithContexts(<DashboardTemplateList />);
await act(async () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
const deleteableItem = wrapper
.find('DashboardTemplateListItem')
.at(0)
.find('input');
const nonDeleteableItem = wrapper
.find('DashboardTemplateListItem')
.at(4)
.find('input');
deleteableItem.simulate('change', {
id: 1,
name: 'Job Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
delete: true,
},
},
});
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
false
);
deleteableItem.simulate('change', {
id: 1,
name: 'Job Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
delete: true,
},
},
});
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
true
);
nonDeleteableItem.simulate('change', {
id: 5,
name: 'Workflow Job Template 2',
url: '/templates/workflow_job_template/5',
type: 'workflow_job_template',
summary_fields: {
user_capabilities: {
delete: false,
},
},
});
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
true
);
});
test('api is called to delete templates for each selected template.', async () => {
const wrapper = mountWithContexts(<DashboardTemplateList />);
await act(async () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
const jobTemplate = wrapper
.find('DashboardTemplateListItem')
.at(1)
.find('input');
const workflowJobTemplate = wrapper
.find('DashboardTemplateListItem')
.at(3)
.find('input');
jobTemplate.simulate('change', {
target: {
id: 2,
name: 'Job Template 2',
url: '/templates/job_template/2',
type: 'job_template',
summary_fields: { user_capabilities: { delete: true } },
},
});
workflowJobTemplate.simulate('change', {
target: {
id: 4,
name: 'Workflow Job Template 1',
url: '/templates/workflow_job_template/4',
type: 'workflow_job_template',
summary_fields: {
user_capabilities: {
delete: true,
},
},
},
});
await act(async () => {
wrapper.find('button[aria-label="Delete"]').prop('onClick')();
});
wrapper.update();
await act(async () => {
await wrapper
.find('button[aria-label="confirm delete"]')
.prop('onClick')();
});
expect(JobTemplatesAPI.destroy).toBeCalledWith(2);
expect(WorkflowJobTemplatesAPI.destroy).toBeCalledWith(4);
});
test('error is shown when template not successfully deleted from api', async () => {
JobTemplatesAPI.destroy.mockRejectedValue(
new Error({
response: {
config: {
method: 'delete',
url: '/api/v2/job_templates/1',
},
data: 'An error occurred',
},
})
);
const wrapper = mountWithContexts(<DashboardTemplateList />);
await act(async () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
const checkBox = wrapper
.find('DashboardTemplateListItem')
.at(1)
.find('input');
checkBox.simulate('change', {
target: {
id: 'a',
name: 'Job Template 2',
url: '/templates/job_template/2',
type: 'job_template',
summary_fields: { user_capabilities: { delete: true } },
},
});
await act(async () => {
wrapper.find('button[aria-label="Delete"]').prop('onClick')();
});
wrapper.update();
await act(async () => {
await wrapper
.find('button[aria-label="confirm delete"]')
.prop('onClick')();
});
await waitForElement(
wrapper,
'Modal[aria-label="Deletion Error"]',
el => el.props().isOpen === true && el.props().title === 'Error!'
);
});
test('should properly copy template', async () => {
JobTemplatesAPI.copy.mockResolvedValue({});
const wrapper = mountWithContexts(<DashboardTemplateList />);
await act(async () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
await act(async () =>
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
);
expect(JobTemplatesAPI.copy).toHaveBeenCalled();
expect(UnifiedJobTemplatesAPI.read).toHaveBeenCalled();
wrapper.update();
});
});

View File

@ -1,179 +0,0 @@
import 'styled-components/macro';
import React, { useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
Button,
DataListAction as _DataListAction,
DataListCheck,
DataListItem,
DataListItemRow,
DataListItemCells,
Tooltip,
} from '@patternfly/react-core';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import {
ExclamationTriangleIcon,
PencilAltIcon,
ProjectDiagramIcon,
RocketIcon,
} from '@patternfly/react-icons';
import styled from 'styled-components';
import DataListCell from '../../../components/DataListCell';
import { timeOfDay } from '../../../util/dates';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../api';
import LaunchButton from '../../../components/LaunchButton';
import Sparkline from '../../../components/Sparkline';
import { toTitleCase } from '../../../util/strings';
import CopyButton from '../../../components/CopyButton';
const DataListAction = styled(_DataListAction)`
align-items: center;
display: grid;
grid-gap: 16px;
grid-template-columns: repeat(4, 40px);
`;
function DashboardTemplateListItem({
i18n,
template,
isSelected,
onSelect,
detailUrl,
fetchTemplates,
}) {
const [isDisabled, setIsDisabled] = useState(false);
const labelId = `check-action-${template.id}`;
const copyTemplate = useCallback(async () => {
if (template.type === 'job_template') {
await JobTemplatesAPI.copy(template.id, {
name: `${template.name} @ ${timeOfDay()}`,
});
} else {
await WorkflowJobTemplatesAPI.copy(template.id, {
name: `${template.name} @ ${timeOfDay()}`,
});
}
await fetchTemplates();
}, [fetchTemplates, template.id, template.name, template.type]);
const handleCopyStart = useCallback(() => {
setIsDisabled(true);
}, []);
const handleCopyFinish = useCallback(() => {
setIsDisabled(false);
}, []);
const missingResourceIcon =
template.type === 'job_template' &&
(!template.summary_fields.project ||
(!template.summary_fields.inventory &&
!template.ask_inventory_on_launch));
return (
<DataListItem aria-labelledby={labelId} id={`${template.id}`}>
<DataListItemRow>
<DataListCheck
isDisabled={isDisabled}
id={`select-jobTemplate-${template.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="name" id={labelId}>
<span>
<Link to={`${detailUrl}`}>
<b>{template.name}</b>
</Link>
</span>
{missingResourceIcon && (
<span>
<Tooltip
content={i18n._(
t`Resources are missing from this template.`
)}
position="right"
>
<ExclamationTriangleIcon css="color: #c9190b; margin-left: 20px;" />
</Tooltip>
</span>
)}
</DataListCell>,
<DataListCell key="type">
{toTitleCase(template.type)}
</DataListCell>,
<DataListCell key="sparkline">
<Sparkline jobs={template.summary_fields.recent_jobs} />
</DataListCell>,
]}
/>
<DataListAction aria-label="actions" aria-labelledby={labelId}>
{template.type === 'workflow_job_template' && (
<Tooltip content={i18n._(t`Visualizer`)} position="top">
<Button
isDisabled={isDisabled}
aria-label={i18n._(t`Visualizer`)}
css="grid-column: 1"
variant="plain"
component={Link}
to={`/templates/workflow_job_template/${template.id}/visualizer`}
>
<ProjectDiagramIcon />
</Button>
</Tooltip>
)}
{template.summary_fields.user_capabilities.start && (
<Tooltip content={i18n._(t`Launch Template`)} position="top">
<LaunchButton resource={template}>
{({ handleLaunch }) => (
<Button
isDisabled={isDisabled}
aria-label={i18n._(t`Launch template`)}
css="grid-column: 2"
variant="plain"
onClick={handleLaunch}
>
<RocketIcon />
</Button>
)}
</LaunchButton>
</Tooltip>
)}
{template.summary_fields.user_capabilities.edit && (
<Tooltip content={i18n._(t`Edit Template`)} position="top">
<Button
isDisabled={isDisabled}
aria-label={i18n._(t`Edit Template`)}
css="grid-column: 3"
variant="plain"
component={Link}
to={`/templates/${template.type}/${template.id}/edit`}
>
<PencilAltIcon />
</Button>
</Tooltip>
)}
{template.summary_fields.user_capabilities.copy && (
<CopyButton
helperText={{
tooltip: i18n._(t`Copy Template`),
errorMessage: i18n._(t`Failed to copy template.`),
}}
isDisabled={isDisabled}
onCopyStart={handleCopyStart}
onCopyFinish={handleCopyFinish}
copyItem={copyTemplate}
/>
)}
</DataListAction>
</DataListItemRow>
</DataListItem>
);
}
export { DashboardTemplateListItem as _TemplateListItem };
export default withI18n()(DashboardTemplateListItem);

View File

@ -1,268 +0,0 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { JobTemplatesAPI } from '../../../api';
import mockJobTemplateData from './data.job_template.json';
import DashboardTemplateListItem from './DashboardTemplateListItem';
jest.mock('../../../api');
describe('<DashboardTemplateListItem />', () => {
test('launch button shown to users with start capabilities', () => {
const wrapper = mountWithContexts(
<DashboardTemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
start: true,
},
},
}}
/>
);
expect(wrapper.find('LaunchButton').exists()).toBeTruthy();
});
test('launch button hidden from users without start capabilities', () => {
const wrapper = mountWithContexts(
<DashboardTemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
start: false,
},
},
}}
/>
);
expect(wrapper.find('LaunchButton').exists()).toBeFalsy();
});
test('edit button shown to users with edit capabilities', () => {
const wrapper = mountWithContexts(
<DashboardTemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: true,
},
},
}}
/>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
});
test('edit button hidden from users without edit capabilities', () => {
const wrapper = mountWithContexts(
<DashboardTemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: false,
},
},
}}
/>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
test('missing resource icon is shown.', () => {
const wrapper = mountWithContexts(
<DashboardTemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: false,
},
},
}}
/>
);
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(true);
});
test('missing resource icon is not shown when there is a project and an inventory.', () => {
const wrapper = mountWithContexts(
<DashboardTemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: false,
},
project: { name: 'Foo', id: 2 },
inventory: { name: 'Bar', id: 2 },
},
}}
/>
);
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
});
test('missing resource icon is not shown when inventory is prompt_on_launch, and a project', () => {
const wrapper = mountWithContexts(
<DashboardTemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
ask_inventory_on_launch: true,
summary_fields: {
user_capabilities: {
edit: false,
},
project: { name: 'Foo', id: 2 },
},
}}
/>
);
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
});
test('missing resource icon is not shown type is workflow_job_template', () => {
const wrapper = mountWithContexts(
<DashboardTemplateListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'workflow_job_template',
summary_fields: {
user_capabilities: {
edit: false,
},
},
}}
/>
);
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
});
test('clicking on template from templates list navigates properly', () => {
const history = createMemoryHistory({
initialEntries: ['/templates'],
});
const wrapper = mountWithContexts(
<DashboardTemplateListItem
isSelected={false}
detailUrl="/templates/job_template/1/details"
template={{
id: 1,
name: 'Template 1',
summary_fields: {
user_capabilities: {
edit: false,
},
},
}}
/>,
{ context: { router: { history } } }
);
wrapper.find('Link').simulate('click', { button: 0 });
expect(history.location.pathname).toEqual(
'/templates/job_template/1/details'
);
});
test('should call api to copy template', async () => {
JobTemplatesAPI.copy.mockResolvedValue();
const wrapper = mountWithContexts(
<DashboardTemplateListItem
isSelected={false}
detailUrl="/templates/job_template/1/details"
template={mockJobTemplateData}
/>
);
await act(async () =>
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
);
expect(JobTemplatesAPI.copy).toHaveBeenCalled();
jest.clearAllMocks();
});
test('should render proper alert modal on copy error', async () => {
JobTemplatesAPI.copy.mockRejectedValue(new Error());
const wrapper = mountWithContexts(
<DashboardTemplateListItem
isSelected={false}
detailUrl="/templates/job_template/1/details"
template={mockJobTemplateData}
/>
);
await act(async () =>
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
);
wrapper.update();
expect(wrapper.find('Modal').prop('isOpen')).toBe(true);
jest.clearAllMocks();
});
test('should not render copy button', async () => {
const wrapper = mountWithContexts(
<DashboardTemplateListItem
isSelected={false}
detailUrl="/templates/job_template/1/details"
template={{
...mockJobTemplateData,
summary_fields: { user_capabilities: { copy: false } },
}}
/>
);
expect(wrapper.find('CopyButton').length).toBe(0);
});
test('should render visualizer button for workflow', async () => {
const wrapper = mountWithContexts(
<DashboardTemplateListItem
isSelected={false}
detailUrl="/templates/job_template/1/details"
template={{
...mockJobTemplateData,
type: 'workflow_job_template',
}}
/>
);
expect(wrapper.find('ProjectDiagramIcon').length).toBe(1);
});
test('should not render visualizer button for job template', async () => {
const wrapper = mountWithContexts(
<DashboardTemplateListItem
isSelected={false}
detailUrl="/templates/job_template/1/details"
template={mockJobTemplateData}
/>
);
expect(wrapper.find('ProjectDiagramIcon').length).toBe(0);
});
});

View File

@ -42,7 +42,7 @@ function HostGroupItem({ i18n, group, inventoryId, isSelected, onSelect }) {
]}
/>
<DataListAction
aria-label="actions"
aria-label={i18n._(t`actions`)}
aria-labelledby={labelId}
id={labelId}
>

View File

@ -8,10 +8,14 @@ import { HostsAPI } from '../../../api';
import AlertModal from '../../../components/AlertModal';
import DataListToolbar from '../../../components/DataListToolbar';
import ErrorDetail from '../../../components/ErrorDetail';
import PaginatedDataList, {
import {
ToolbarAddButton,
ToolbarDeleteButton,
} from '../../../components/PaginatedDataList';
import PaginatedTable, {
HeaderRow,
HeaderCell,
} from '../../../components/PaginatedTable';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import {
encodeQueryString,
@ -130,7 +134,7 @@ function HostList({ i18n }) {
return (
<PageSection>
<Card>
<PaginatedDataList
<PaginatedTable
contentError={contentError}
hasContentLoading={isLoading || isDeleteLoading}
items={hosts}
@ -157,14 +161,15 @@ function HostList({ i18n }) {
key: 'modified_by__username__icontains',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
<HeaderCell>{i18n._(t`Inventory`)}</HeaderCell>
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
</HeaderRow>
}
renderToolbar={props => (
<DataListToolbar
{...props}
@ -193,13 +198,14 @@ function HostList({ i18n }) {
]}
/>
)}
renderItem={host => (
renderRow={(host, index) => (
<HostListItem
key={host.id}
host={host}
detailUrl={`${match.url}/${host.id}/details`}
isSelected={selected.some(row => row.id === host.id)}
onSelect={() => handleSelect(host)}
rowIndex={index}
/>
)}
emptyStateControls={

View File

@ -134,8 +134,9 @@ describe('<HostList />', () => {
act(() => {
wrapper
.find('input#select-host-1')
.closest('DataListCheck')
.find('.pf-c-table__check')
.first()
.find('input')
.invoke('onChange')();
});
wrapper.update();
@ -147,8 +148,9 @@ describe('<HostList />', () => {
).toEqual(true);
act(() => {
wrapper
.find('input#select-host-1')
.closest('DataListCheck')
.find('.pf-c-table__check')
.first()
.find('input')
.invoke('onChange')();
});
wrapper.update();

View File

@ -1,91 +1,67 @@
import 'styled-components/macro';
import React, { Fragment } from 'react';
import React from 'react';
import { string, bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Button,
DataListAction as _DataListAction,
DataListCheck,
DataListItem,
DataListItemRow,
DataListItemCells,
Tooltip,
} from '@patternfly/react-core';
import { Button } from '@patternfly/react-core';
import { Tr, Td } from '@patternfly/react-table';
import { Link } from 'react-router-dom';
import { PencilAltIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import DataListCell from '../../../components/DataListCell';
import Sparkline from '../../../components/Sparkline';
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
import { Host } from '../../../types';
import HostToggle from '../../../components/HostToggle';
const DataListAction = styled(_DataListAction)`
align-items: center;
display: grid;
grid-gap: 24px;
grid-template-columns: 92px 40px;
`;
function HostListItem({ i18n, host, isSelected, onSelect, detailUrl }) {
function HostListItem({
i18n,
host,
isSelected,
onSelect,
detailUrl,
rowIndex,
}) {
const labelId = `check-action-${host.id}`;
return (
<DataListItem key={host.id} aria-labelledby={labelId} id={`${host.id}`}>
<DataListItemRow>
<DataListCheck
id={`select-host-${host.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="name">
<Link to={`${detailUrl}`}>
<b>{host.name}</b>
</Link>
</DataListCell>,
<DataListCell key="recentJobs">
<Sparkline jobs={host.summary_fields.recent_jobs} />
</DataListCell>,
<DataListCell key="inventory">
{host.summary_fields.inventory && (
<Fragment>
<b css="margin-right: 24px">{i18n._(t`Inventory`)}</b>
<Link
to={`/inventories/inventory/${host.summary_fields.inventory.id}/details`}
>
{host.summary_fields.inventory.name}
</Link>
</Fragment>
)}
</DataListCell>,
]}
/>
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
<Tr id={`host-row-${host.id}`}>
<Td
select={{
rowIndex,
isSelected,
onSelect,
}}
dataLabel={i18n._(t`Selected`)}
/>
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
<Link to={`${detailUrl}`}>
<b>{host.name}</b>
</Link>
</Td>
<Td dataLabel={i18n._(t`Inventory`)}>
{host.summary_fields.inventory && (
<Link
to={`/inventories/inventory/${host.summary_fields.inventory.id}/details`}
>
{host.summary_fields.inventory.name}
</Link>
)}
</Td>
<ActionsTd dataLabel={i18n._(t`Actions`)} gridColumns="auto 40px">
<HostToggle host={host} />
<ActionItem
visible={host.summary_fields.user_capabilities.edit}
tooltip={i18n._(t`Edit Host`)}
>
<HostToggle host={host} />
{host.summary_fields.user_capabilities.edit ? (
<Tooltip content={i18n._(t`Edit Host`)} position="top">
<Button
aria-label={i18n._(t`Edit Host`)}
variant="plain"
component={Link}
to={`/hosts/${host.id}/edit`}
>
<PencilAltIcon />
</Button>
</Tooltip>
) : (
''
)}
</DataListAction>
</DataListItemRow>
</DataListItem>
<Button
aria-label={i18n._(t`Edit Host`)}
variant="plain"
component={Link}
to={`/hosts/${host.id}/edit`}
>
<PencilAltIcon />
</Button>
</ActionItem>
</ActionsTd>
</Tr>
);
}

View File

@ -25,12 +25,16 @@ describe('<HostsListItem />', () => {
beforeEach(() => {
wrapper = mountWithContexts(
<HostsListItem
isSelected={false}
detailUrl="/host/1"
onSelect={() => {}}
host={mockHost}
/>
<table>
<tbody>
<HostsListItem
isSelected={false}
detailUrl="/host/1"
onSelect={() => {}}
host={mockHost}
/>
</tbody>
</table>
);
});
@ -46,12 +50,16 @@ describe('<HostsListItem />', () => {
const copyMockHost = Object.assign({}, mockHost);
copyMockHost.summary_fields.user_capabilities.edit = false;
wrapper = mountWithContexts(
<HostsListItem
isSelected={false}
detailUrl="/host/1"
onSelect={() => {}}
host={copyMockHost}
/>
<table>
<tbody>
<HostsListItem
isSelected={false}
detailUrl="/host/1"
onSelect={() => {}}
host={copyMockHost}
/>
</tbody>
</table>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});

View File

@ -173,7 +173,7 @@ function InstanceGroupListItem({
]}
/>
<DataListAction
aria-label="actions"
aria-label={i18n._(t`actions`)}
aria-labelledby={labelId}
id={labelId}
>

View File

@ -113,7 +113,7 @@ function InstanceListItem({
]}
/>
<DataListAction
aria-label="actions"
aria-label={i18n._(t`actions`)}
aria-labelledby={labelId}
id={labelId}
>

View File

@ -66,7 +66,7 @@ function InventoryGroupHostListItem({
]}
/>
<DataListAction
aria-label="actions"
aria-label={i18n._(t`actions`)}
aria-labelledby={labelId}
id={labelId}
>

View File

@ -48,7 +48,7 @@ function InventoryGroupItem({
]}
/>
<DataListAction
aria-label="actions"
aria-label={i18n._(t`actions`)}
aria-labelledby={labelId}
id={labelId}
>

View File

@ -48,7 +48,7 @@ function InventoryHostGroupItem({
]}
/>
<DataListAction
aria-label="actions"
aria-label={i18n._(t`actions`)}
aria-labelledby={labelId}
id={labelId}
>

View File

@ -59,7 +59,7 @@ function InventoryHostItem(props) {
]}
/>
<DataListAction
aria-label="actions"
aria-label={i18n._(t`actions`)}
aria-labelledby={labelId}
id={labelId}
>

View File

@ -195,9 +195,6 @@ function InventoryList({ i18n }) {
<HeaderCell>{i18n._(t`Status`)}</HeaderCell>
<HeaderCell>{i18n._(t`Type`)}</HeaderCell>
<HeaderCell>{i18n._(t`Organization`)}</HeaderCell>
<HeaderCell>{i18n._(t`Groups`)}</HeaderCell>
<HeaderCell>{i18n._(t`Hosts`)}</HeaderCell>
<HeaderCell>{i18n._(t`Sources`)}</HeaderCell>
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
</HeaderRow>
}

View File

@ -89,11 +89,6 @@ function InventoryListItem({
{inventory?.summary_fields?.organization?.name}
</Link>
</Td>
<Td dataLabel={i18n._(t`Groups`)}>{inventory.total_groups}</Td>
<Td dataLabel={i18n._(t`Hosts`)}>{inventory.total_hosts}</Td>
<Td dataLabel={i18n._(t`Sources`)}>
{inventory.total_inventory_sources}
</Td>
{inventory.pending_deletion ? (
<Td dataLabel={i18n._(t`Groups`)}>
<Label color="red">{i18n._(t`Pending delete`)}</Label>

View File

@ -56,7 +56,7 @@ function InventoryRelatedGroupListItem({
]}
/>
<DataListAction
aria-label="actions"
aria-label={i18n._(t`actions`)}
aria-labelledby={labelId}
id={labelId}
>

View File

@ -88,7 +88,7 @@ function InventorySourceListItem({
<DataListAction
id="actions"
aria-labelledby="actions"
aria-label="actions"
aria-label={i18n._(t`actions`)}
>
{source.summary_fields.user_capabilities.start && (
<InventorySourceSyncButton source={source} />

View File

@ -152,7 +152,7 @@ function SmartInventoryDetail({ inventory, i18n }) {
{user_capabilities?.edit && (
<Button
component={Link}
aria-label="edit"
aria-label={i18n._(t`edit`)}
to={`/inventories/smart_inventory/${id}/edit`}
>
{i18n._(t`Edit`)}

View File

@ -105,7 +105,7 @@ const InventoryGroupsDeleteModal = ({
<Button
aria-label={i18n._(t`Close`)}
onClick={() => setIsModalOpen(false)}
variant="secondary"
variant="link"
key="cancel"
>
{i18n._(t`Cancel`)}

View File

@ -11,6 +11,7 @@ import {
DetailList,
Detail,
UserDateDetail,
LaunchedByDetail,
} from '../../../components/DetailList';
import { CardBody, CardActionsRow } from '../../../components/Card';
import ChipGroup from '../../../components/ChipGroup';
@ -53,35 +54,6 @@ const VERBOSITY = {
4: '4 (Connection Debug)',
};
const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => {
const {
created_by: createdBy,
job_template: jobTemplate,
schedule,
} = summary_fields;
const { schedule: relatedSchedule } = related;
if (!createdBy && !schedule) {
return null;
}
let link;
let value;
if (createdBy) {
link = `/users/${createdBy.id}`;
value = createdBy.username;
} else if (relatedSchedule && jobTemplate) {
link = `/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`;
value = schedule.name;
} else {
link = null;
value = schedule.name;
}
return { link, value };
};
function JobDetail({ job, i18n }) {
const {
created_by,
@ -107,9 +79,6 @@ function JobDetail({ job, i18n }) {
workflow_job: i18n._(t`Workflow Job`),
};
const { value: launchedByValue, link: launchedByLink } =
getLaunchedByDetails(job) || {};
const deleteJob = async () => {
try {
switch (job.type) {
@ -137,7 +106,7 @@ function JobDetail({ job, i18n }) {
}
};
const isIsolatedInstanceGroup = item => {
const buildInstanceGroupLink = item => {
if (item.is_isolated) {
return (
<>
@ -153,16 +122,26 @@ function JobDetail({ job, i18n }) {
return <Link to={`/instance_groups/${item.id}`}>{item.name}</Link>;
};
const buildContainerGroupLink = item => {
return (
<Link to={`/instance_groups/container_group/${item.id}`}>
{item.name}
</Link>
);
};
return (
<CardBody>
<DetailList>
{/* TODO: hookup status to websockets */}
<Detail
fullWidth={Boolean(job.job_explanation)}
label={i18n._(t`Status`)}
value={
<StatusDetailValue>
{job.status && <StatusIcon status={job.status} />}
{toTitleCase(job.status)}
{job.job_explanation
? job.job_explanation
: toTitleCase(job.status)}
</StatusDetailValue>
}
/>
@ -207,16 +186,7 @@ function JobDetail({ job, i18n }) {
/>
)}
<Detail label={i18n._(t`Job Type`)} value={jobTypes[job.type]} />
<Detail
label={i18n._(t`Launched By`)}
value={
launchedByLink ? (
<Link to={`${launchedByLink}`}>{launchedByValue}</Link>
) : (
launchedByValue
)
}
/>
<LaunchedByDetail job={job} i18n={i18n} />
{inventory && (
<Detail
label={i18n._(t`Inventory`)}
@ -250,10 +220,16 @@ function JobDetail({ job, i18n }) {
<Detail label={i18n._(t`Verbosity`)} value={VERBOSITY[job.verbosity]} />
<Detail label={i18n._(t`Environment`)} value={job.custom_virtualenv} />
<Detail label={i18n._(t`Execution Node`)} value={job.execution_node} />
{instanceGroup && (
{instanceGroup && !instanceGroup?.is_containerized && (
<Detail
label={i18n._(t`Instance Group`)}
value={isIsolatedInstanceGroup(instanceGroup)}
value={buildInstanceGroupLink(instanceGroup)}
/>
)}
{instanceGroup && instanceGroup?.is_containerized && (
<Detail
label={i18n._(t`Container Group`)}
value={buildContainerGroupLink(instanceGroup)}
/>
)}
{typeof job.job_slice_number === 'number' &&

View File

@ -1,6 +1,6 @@
import React, { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import { I18n } from '@lingui/react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import {
@ -10,6 +10,7 @@ import {
InfiniteLoader,
List,
} from 'react-virtualized';
import { Button } from '@patternfly/react-core';
import Ansi from 'ansi-to-html';
import hasAnsi from 'has-ansi';
import { AllHtmlEntities } from 'html-entities';
@ -225,6 +226,7 @@ class JobOutput extends Component {
this.state = {
contentError: null,
deletionError: null,
cancelError: null,
hasContentLoading: true,
results: {},
currentlyLoading: [],
@ -232,6 +234,9 @@ class JobOutput extends Component {
isHostModalOpen: false,
hostEvent: {},
cssMap: {},
jobStatus: props.job.status ?? 'waiting',
showCancelPrompt: false,
cancelInProgress: false,
};
this.cache = new CellMeasurerCache({
@ -242,6 +247,9 @@ class JobOutput extends Component {
this._isMounted = false;
this.loadJobEvents = this.loadJobEvents.bind(this);
this.handleDeleteJob = this.handleDeleteJob.bind(this);
this.handleCancelOpen = this.handleCancelOpen.bind(this);
this.handleCancelConfirm = this.handleCancelConfirm.bind(this);
this.handleCancelClose = this.handleCancelClose.bind(this);
this.rowRenderer = this.rowRenderer.bind(this);
this.handleHostEventClick = this.handleHostEventClick.bind(this);
this.handleHostModalClose = this.handleHostModalClose.bind(this);
@ -261,11 +269,21 @@ class JobOutput extends Component {
this._isMounted = true;
this.loadJobEvents();
if (job.result_traceback) return;
connectJobSocket(job, data => {
if (data.counter && data.counter > this.jobSocketCounter) {
this.jobSocketCounter = data.counter;
} else if (data.final_counter && data.unified_job_id === job.id) {
this.jobSocketCounter = data.final_counter;
if (data.group_name === 'job_events') {
if (data.counter && data.counter > this.jobSocketCounter) {
this.jobSocketCounter = data.counter;
}
}
if (data.group_name === 'jobs' && data.unified_job_id === job.id) {
if (data.final_counter) {
this.jobSocketCounter = data.final_counter;
}
if (data.status) {
this.setState({ jobStatus: data.status });
}
}
});
this.interval = setInterval(() => this.monitorJobSocketCounter(), 5000);
@ -326,10 +344,32 @@ class JobOutput extends Component {
});
this._isMounted &&
this.setState(({ results }) => {
let countOffset = 1;
if (job?.result_traceback) {
const tracebackEvent = {
counter: 1,
created: null,
event: null,
type: null,
stdout: job?.result_traceback,
start_line: 0,
};
const firstIndex = newResults.findIndex(
jobEvent => jobEvent.counter === 1
);
if (firstIndex && newResults[firstIndex]?.stdout) {
const stdoutLines = newResults[firstIndex].stdout.split('\r\n');
stdoutLines[0] = tracebackEvent.stdout;
newResults[firstIndex].stdout = stdoutLines.join('\r\n');
} else {
countOffset += 1;
newResults.unshift(tracebackEvent);
}
}
newResults.forEach(jobEvent => {
results[jobEvent.counter] = jobEvent;
});
return { results, remoteRowCount: count + 1 };
return { results, remoteRowCount: count + countOffset };
});
} catch (err) {
this.setState({ contentError: err });
@ -344,6 +384,26 @@ class JobOutput extends Component {
}
}
handleCancelOpen() {
this.setState({ showCancelPrompt: true });
}
handleCancelClose() {
this.setState({ showCancelPrompt: false });
}
async handleCancelConfirm() {
const { job, type } = this.props;
this.setState({ cancelInProgress: true });
try {
await JobsAPI.cancel(job.id, type);
} catch (cancelError) {
this.setState({ cancelError });
} finally {
this.setState({ showCancelPrompt: false, cancelInProgress: false });
}
}
async handleDeleteJob() {
const { job, history } = this.props;
try {
@ -518,7 +578,7 @@ class JobOutput extends Component {
}
render() {
const { job } = this.props;
const { job, i18n } = this.props;
const {
contentError,
@ -528,6 +588,10 @@ class JobOutput extends Component {
isHostModalOpen,
remoteRowCount,
cssMap,
jobStatus,
showCancelPrompt,
cancelError,
cancelInProgress,
} = this.state;
if (hasContentLoading) {
@ -553,7 +617,12 @@ class JobOutput extends Component {
<StatusIcon status={job.status} />
<h1>{job.name}</h1>
</HeaderTitle>
<OutputToolbar job={job} onDelete={this.handleDeleteJob} />
<OutputToolbar
job={job}
jobStatus={jobStatus}
onDelete={this.handleDeleteJob}
onCancel={this.handleCancelOpen}
/>
</OutputHeader>
<HostStatusBar counts={job.host_status_counts} />
<PageControls
@ -595,21 +664,65 @@ class JobOutput extends Component {
<OutputFooter />
</OutputWrapper>
</CardBody>
{showCancelPrompt &&
['pending', 'waiting', 'running'].includes(jobStatus) && (
<AlertModal
isOpen={showCancelPrompt}
variant="danger"
onClose={this.handleCancelClose}
title={i18n._(t`Cancel Job`)}
label={i18n._(t`Cancel Job`)}
actions={[
<Button
id="cancel-job-confirm-button"
key="delete"
variant="danger"
isDisabled={cancelInProgress}
aria-label={i18n._(t`Cancel job`)}
onClick={this.handleCancelConfirm}
>
{i18n._(t`Cancel job`)}
</Button>,
<Button
id="cancel-job-return-button"
key="cancel"
variant="secondary"
aria-label={i18n._(t`Return`)}
onClick={this.handleCancelClose}
>
{i18n._(t`Return`)}
</Button>,
]}
>
{i18n._(
t`Are you sure you want to submit the request to cancel this job?`
)}
</AlertModal>
)}
{cancelError && (
<>
<AlertModal
isOpen={cancelError}
variant="danger"
onClose={() => this.setState({ cancelError: null })}
title={i18n._(t`Job Cancel Error`)}
label={i18n._(t`Job Cancel Error`)}
>
<ErrorDetail error={cancelError} />
</AlertModal>
</>
)}
{deletionError && (
<>
<I18n>
{({ i18n }) => (
<AlertModal
isOpen={deletionError}
variant="danger"
onClose={() => this.setState({ deletionError: null })}
title={i18n._(t`Job Delete Error`)}
label={i18n._(t`Job Delete Error`)}
>
<ErrorDetail error={deletionError} />
</AlertModal>
)}
</I18n>
<AlertModal
isOpen={deletionError}
variant="danger"
onClose={() => this.setState({ deletionError: null })}
title={i18n._(t`Job Delete Error`)}
label={i18n._(t`Job Delete Error`)}
>
<ErrorDetail error={deletionError} />
</AlertModal>
</>
)}
</Fragment>
@ -618,4 +731,4 @@ class JobOutput extends Component {
}
export { JobOutput as _JobOutput };
export default withRouter(JobOutput);
export default withI18n()(withRouter(JobOutput));

View File

@ -4,6 +4,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { shape, func } from 'prop-types';
import {
MinusCircleIcon,
DownloadIcon,
RocketIcon,
TrashAltIcon,
@ -58,7 +59,7 @@ const OUTPUT_NO_COUNT_JOB_TYPES = [
'inventory_update',
];
const OutputToolbar = ({ i18n, job, onDelete }) => {
const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type);
const playCount = job?.playbook_counts?.play_count;
@ -148,19 +149,34 @@ const OutputToolbar = ({ i18n, job, onDelete }) => {
</a>
</Tooltip>
)}
{job.summary_fields.user_capabilities.start &&
['pending', 'waiting', 'running'].includes(jobStatus) && (
<Tooltip content={i18n._(t`Cancel Job`)}>
<Button
variant="plain"
aria-label={i18n._(t`Cancel Job`)}
onClick={onCancel}
>
<MinusCircleIcon />
</Button>
</Tooltip>
)}
{job.summary_fields.user_capabilities.delete && (
<Tooltip content={i18n._(t`Delete Job`)}>
<DeleteButton
name={job.name}
modalTitle={i18n._(t`Delete Job`)}
onConfirm={onDelete}
variant="plain"
>
<TrashAltIcon />
</DeleteButton>
</Tooltip>
)}
{job.summary_fields.user_capabilities.delete &&
['new', 'successful', 'failed', 'error', 'canceled'].includes(
jobStatus
) && (
<Tooltip content={i18n._(t`Delete Job`)}>
<DeleteButton
name={job.name}
modalTitle={i18n._(t`Delete Job`)}
onConfirm={onDelete}
variant="plain"
>
<TrashAltIcon />
</DeleteButton>
</Tooltip>
)}
</Wrapper>
);
};

View File

@ -16,6 +16,7 @@ describe('<OutputToolbar />', () => {
failures: 2,
},
}}
jobStatus="successful"
onDelete={() => {}}
/>
);
@ -33,6 +34,7 @@ describe('<OutputToolbar />', () => {
wrapper = mountWithContexts(
<OutputToolbar
job={{ ...mockJobData, type: 'system_job' }}
jobStatus="successful"
onDelete={() => {}}
/>
);
@ -54,6 +56,7 @@ describe('<OutputToolbar />', () => {
host_status_counts: {},
playbook_counts: {},
}}
jobStatus="successful"
onDelete={() => {}}
/>
);
@ -74,6 +77,7 @@ describe('<OutputToolbar />', () => {
...mockJobData,
elapsed: 274265,
}}
jobStatus="successful"
onDelete={() => {}}
/>
);
@ -95,6 +99,7 @@ describe('<OutputToolbar />', () => {
},
},
}}
jobStatus="successful"
onDelete={() => {}}
/>
);
@ -113,6 +118,7 @@ describe('<OutputToolbar />', () => {
},
},
}}
jobStatus="successful"
onDelete={() => {}}
/>
);

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