mirror of
https://github.com/ansible/awx.git
synced 2026-01-15 20:00:43 -03:30
commit
243c2cfe15
14
CHANGELOG.md
14
CHANGELOG.md
@ -2,6 +2,20 @@
|
||||
|
||||
This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<version>`.
|
||||
|
||||
## 14.1.0 (Aug 25, 2020)
|
||||
- AWX images can now be built on ARM64 - https://github.com/ansible/awx/pull/7607
|
||||
- Added the Remote Archive SCM Type to support using immutable artifacts and releases (such as tarballs and zip files) as projects - https://github.com/ansible/awx/issues/7954
|
||||
- Deprecated official support for Mercurial-based project updates - https://github.com/ansible/awx/issues/7932
|
||||
- Added resource import/export support to the official AWX collection - https://github.com/ansible/awx/issues/7329
|
||||
- Added the ability to import YAML-based resources (instead of just JSON) when using the AWX CLI - https://github.com/ansible/awx/pull/7808
|
||||
- Updated the AWX CLI to export labels associated with Workflow Job Templates - https://github.com/ansible/awx/pull/7847
|
||||
- Updated to the latest python-ldap to address a bug - https://github.com/ansible/awx/issues/7868
|
||||
- Upgraded git-python to fix a bug that caused workflows to sometimes fail - https://github.com/ansible/awx/issues/6119
|
||||
- Worked around a bug in the channels_redis library that slowly causes Daphne processes to leak memory over time - https://github.com/django/channels_redis/issues/212
|
||||
- Fixed a bug in the AWX CLI that prevented Workflow nodes from importing properly - https://github.com/ansible/awx/issues/7793
|
||||
- Fixed a bug in the awx.awx collection release process that templated the wrong version - https://github.com/ansible/awx/issues/7870
|
||||
- Fixed a bug that caused errors rendering stdout that contained UTF-16 surrogate pairs - https://github.com/ansible/awx/pull/7918
|
||||
|
||||
## 14.0.0 (Aug 6, 2020)
|
||||
- As part of our commitment to inclusivity in open source, we recently took some time to audit AWX's source code and user interface and replace certain terminology with more inclusive language. Strictly speaking, this isn't a bug or a feature, but we think it's important and worth calling attention to:
|
||||
* https://github.com/ansible/awx/commit/78229f58715fbfbf88177e54031f532543b57acc
|
||||
|
||||
@ -7,6 +7,24 @@ from prometheus_client.parser import text_string_to_metric_families
|
||||
# Django REST Framework
|
||||
from rest_framework import renderers
|
||||
from rest_framework.request import override_method
|
||||
from rest_framework.utils import encoders
|
||||
|
||||
|
||||
class SurrogateEncoder(encoders.JSONEncoder):
|
||||
|
||||
def encode(self, obj):
|
||||
ret = super(SurrogateEncoder, self).encode(obj)
|
||||
try:
|
||||
ret.encode()
|
||||
except UnicodeEncodeError as e:
|
||||
if 'surrogates not allowed' in e.reason:
|
||||
ret = ret.encode('utf-8', 'replace').decode()
|
||||
return ret
|
||||
|
||||
|
||||
class DefaultJSONRenderer(renderers.JSONRenderer):
|
||||
|
||||
encoder_class = SurrogateEncoder
|
||||
|
||||
|
||||
class BrowsableAPIRenderer(renderers.BrowsableAPIRenderer):
|
||||
|
||||
@ -1336,6 +1336,8 @@ class ProjectOptionsSerializer(BaseSerializer):
|
||||
attrs.pop('local_path', None)
|
||||
if 'local_path' in attrs and attrs['local_path'] not in valid_local_paths:
|
||||
errors['local_path'] = _('This path is already being used by another manual project.')
|
||||
if attrs.get('scm_branch') and scm_type == 'archive':
|
||||
errors['scm_branch'] = _('SCM branch cannot be used with archive projects.')
|
||||
if attrs.get('scm_refspec') and scm_type != 'git':
|
||||
errors['scm_refspec'] = _('SCM refspec can only be used with git projects.')
|
||||
|
||||
|
||||
@ -242,6 +242,8 @@ class DashboardView(APIView):
|
||||
svn_failed_projects = svn_projects.filter(last_job_failed=True)
|
||||
hg_projects = user_projects.filter(scm_type='hg')
|
||||
hg_failed_projects = hg_projects.filter(last_job_failed=True)
|
||||
archive_projects = user_projects.filter(scm_type='archive')
|
||||
archive_failed_projects = archive_projects.filter(last_job_failed=True)
|
||||
data['scm_types'] = {}
|
||||
data['scm_types']['git'] = {'url': reverse('api:project_list', request=request) + "?scm_type=git",
|
||||
'label': 'Git',
|
||||
@ -258,6 +260,11 @@ class DashboardView(APIView):
|
||||
'failures_url': reverse('api:project_list', request=request) + "?scm_type=hg&last_job_failed=True",
|
||||
'total': hg_projects.count(),
|
||||
'failed': hg_failed_projects.count()}
|
||||
data['scm_types']['archive'] = {'url': reverse('api:project_list', request=request) + "?scm_type=archive",
|
||||
'label': 'Remote Archive',
|
||||
'failures_url': reverse('api:project_list', request=request) + "?scm_type=archive&last_job_failed=True",
|
||||
'total': archive_projects.count(),
|
||||
'failed': archive_failed_projects.count()}
|
||||
|
||||
user_list = get_user_queryset(request.user, models.User)
|
||||
team_list = get_user_queryset(request.user, models.Team)
|
||||
|
||||
@ -134,7 +134,8 @@ class InventoryDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, Retri
|
||||
|
||||
# Do not allow changes to an Inventory kind.
|
||||
if kind is not None and obj.kind != kind:
|
||||
return self.http_method_not_allowed(request, *args, **kwargs)
|
||||
return Response(dict(error=_('You cannot turn a regular inventory into a "smart" inventory.')),
|
||||
status=status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
return super(InventoryDetail, self).update(request, *args, **kwargs)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import collections
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
@ -12,12 +14,40 @@ from django.contrib.auth.models import User
|
||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
from channels.layers import get_channel_layer
|
||||
from channels.db import database_sync_to_async
|
||||
from channels_redis.core import RedisChannelLayer
|
||||
|
||||
|
||||
logger = logging.getLogger('awx.main.consumers')
|
||||
XRF_KEY = '_auth_user_xrf'
|
||||
|
||||
|
||||
class BoundedQueue(asyncio.Queue):
|
||||
|
||||
def put_nowait(self, item):
|
||||
if self.full():
|
||||
# dispose the oldest item
|
||||
# if we actually get into this code block, it likely means that
|
||||
# this specific consumer has stopped reading
|
||||
# unfortunately, channels_redis will just happily continue to
|
||||
# queue messages specific to their channel until the heat death
|
||||
# of the sun: https://github.com/django/channels_redis/issues/212
|
||||
# this isn't a huge deal for browser clients that disconnect,
|
||||
# but it *does* cause a problem for our global broadcast topic
|
||||
# that's used to broadcast messages to peers in a cluster
|
||||
# if we get into this code block, it's better to drop messages
|
||||
# than to continue to malloc() forever
|
||||
self.get_nowait()
|
||||
return super(BoundedQueue, self).put_nowait(item)
|
||||
|
||||
|
||||
class ExpiringRedisChannelLayer(RedisChannelLayer):
|
||||
def __init__(self, *args, **kw):
|
||||
super(ExpiringRedisChannelLayer, self).__init__(*args, **kw)
|
||||
self.receive_buffer = collections.defaultdict(
|
||||
functools.partial(BoundedQueue, self.capacity)
|
||||
)
|
||||
|
||||
|
||||
class WebsocketSecretAuthHelper:
|
||||
"""
|
||||
Middlewareish for websockets to verify node websocket broadcast interconnect.
|
||||
|
||||
@ -58,7 +58,7 @@ class IsolatedManager(object):
|
||||
os.chmod(temp.name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
|
||||
for host in hosts:
|
||||
inventory['all']['hosts'][host] = {
|
||||
"ansible_connection": "community.kubernetes.kubectl",
|
||||
"ansible_connection": "kubectl",
|
||||
"ansible_kubectl_config": path,
|
||||
}
|
||||
else:
|
||||
|
||||
@ -32,4 +32,7 @@ class Command(BaseCommand):
|
||||
sys.exit(1)
|
||||
i = i.first()
|
||||
ig.instances.remove(i)
|
||||
if i.hostname in ig.policy_instance_list:
|
||||
ig.policy_instance_list.remove(i.hostname)
|
||||
ig.save()
|
||||
print("Instance removed from instance group")
|
||||
|
||||
23
awx/main/migrations/0118_add_remote_archive_scm_type.py
Normal file
23
awx/main/migrations/0118_add_remote_archive_scm_type.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.11 on 2020-08-18 22:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('main', '0117_v400_remove_cloudforms_inventory'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='project',
|
||||
name='scm_type',
|
||||
field=models.CharField(blank=True, choices=[('', 'Manual'), ('git', 'Git'), ('hg', 'Mercurial'), ('svn', 'Subversion'), ('insights', 'Red Hat Insights'), ('archive', 'Remote Archive')], default='', help_text='Specifies the source control system used to store the project.', max_length=8, verbose_name='SCM Type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='projectupdate',
|
||||
name='scm_type',
|
||||
field=models.CharField(blank=True, choices=[('', 'Manual'), ('git', 'Git'), ('hg', 'Mercurial'), ('svn', 'Subversion'), ('insights', 'Red Hat Insights'), ('archive', 'Remote Archive')], default='', help_text='Specifies the source control system used to store the project.', max_length=8, verbose_name='SCM Type'),
|
||||
),
|
||||
]
|
||||
@ -55,6 +55,7 @@ class ProjectOptions(models.Model):
|
||||
('hg', _('Mercurial')),
|
||||
('svn', _('Subversion')),
|
||||
('insights', _('Red Hat Insights')),
|
||||
('archive', _('Remote Archive')),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
|
||||
@ -14,8 +14,10 @@ from django.utils.translation import ugettext_lazy as _, gettext_noop
|
||||
from django.utils.timezone import now as tz_now
|
||||
|
||||
# AWX
|
||||
from awx.main.dispatch.reaper import reap_job
|
||||
from awx.main.models import (
|
||||
AdHocCommand,
|
||||
Instance,
|
||||
InstanceGroup,
|
||||
InventorySource,
|
||||
InventoryUpdate,
|
||||
@ -515,6 +517,20 @@ class TaskManager():
|
||||
task.job_explanation = timeout_message
|
||||
task.save(update_fields=['status', 'job_explanation', 'timed_out'])
|
||||
|
||||
def reap_jobs_from_orphaned_instances(self):
|
||||
# discover jobs that are in running state but aren't on an execution node
|
||||
# that we know about; this is a fairly rare event, but it can occur if you,
|
||||
# for example, SQL backup an awx install with running jobs and restore it
|
||||
# elsewhere
|
||||
for j in UnifiedJob.objects.filter(
|
||||
status__in=['pending', 'waiting', 'running'],
|
||||
).exclude(
|
||||
execution_node__in=Instance.objects.values_list('hostname', flat=True)
|
||||
):
|
||||
if j.execution_node and not j.is_containerized:
|
||||
logger.error(f'{j.execution_node} is not a registered instance; reaping {j.log_format}')
|
||||
reap_job(j, 'failed')
|
||||
|
||||
def calculate_capacity_consumed(self, tasks):
|
||||
self.graph = InstanceGroup.objects.capacity_values(tasks=tasks, graph=self.graph)
|
||||
|
||||
@ -567,6 +583,7 @@ class TaskManager():
|
||||
self.spawn_workflow_graph_jobs(running_workflow_tasks)
|
||||
|
||||
self.timeout_approval_node()
|
||||
self.reap_jobs_from_orphaned_instances()
|
||||
|
||||
self.process_tasks(all_sorted_tasks)
|
||||
return finished_wfjs
|
||||
|
||||
@ -2105,7 +2105,7 @@ class RunProjectUpdate(BaseTask):
|
||||
scm_username = False
|
||||
elif scm_url_parts.scheme.endswith('ssh'):
|
||||
scm_password = False
|
||||
elif scm_type == 'insights':
|
||||
elif scm_type in ('insights', 'archive'):
|
||||
extra_vars['scm_username'] = scm_username
|
||||
extra_vars['scm_password'] = scm_password
|
||||
scm_url = update_scm_url(scm_type, scm_url, scm_username,
|
||||
|
||||
@ -12,7 +12,8 @@ def test_empty():
|
||||
'git': 0,
|
||||
'svn': 0,
|
||||
'hg': 0,
|
||||
'insights': 0
|
||||
'insights': 0,
|
||||
'archive': 0,
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +25,8 @@ def test_multiple(scm_type):
|
||||
'git': 0,
|
||||
'svn': 0,
|
||||
'hg': 0,
|
||||
'insights': 0
|
||||
'insights': 0,
|
||||
'archive': 0,
|
||||
}
|
||||
for i in range(random.randint(0, 10)):
|
||||
Project(scm_type=scm_type).save()
|
||||
|
||||
@ -1792,16 +1792,19 @@ class TestProjectUpdateCredentials(TestJobExecution):
|
||||
dict(scm_type='git'),
|
||||
dict(scm_type='hg'),
|
||||
dict(scm_type='svn'),
|
||||
dict(scm_type='archive'),
|
||||
],
|
||||
'test_ssh_key_auth': [
|
||||
dict(scm_type='git'),
|
||||
dict(scm_type='hg'),
|
||||
dict(scm_type='svn'),
|
||||
dict(scm_type='archive'),
|
||||
],
|
||||
'test_awx_task_env': [
|
||||
dict(scm_type='git'),
|
||||
dict(scm_type='hg'),
|
||||
dict(scm_type='svn'),
|
||||
dict(scm_type='archive'),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@ -257,7 +257,7 @@ def update_scm_url(scm_type, url, username=True, password=True,
|
||||
# git: https://www.kernel.org/pub/software/scm/git/docs/git-clone.html#URLS
|
||||
# hg: http://www.selenic.com/mercurial/hg.1.html#url-paths
|
||||
# svn: http://svnbook.red-bean.com/en/1.7/svn-book.html#svn.advanced.reposurls
|
||||
if scm_type not in ('git', 'hg', 'svn', 'insights'):
|
||||
if scm_type not in ('git', 'hg', 'svn', 'insights', 'archive'):
|
||||
raise ValueError(_('Unsupported SCM type "%s"') % str(scm_type))
|
||||
if not url.strip():
|
||||
return ''
|
||||
@ -303,7 +303,8 @@ def update_scm_url(scm_type, url, username=True, password=True,
|
||||
'git': ('ssh', 'git', 'git+ssh', 'http', 'https', 'ftp', 'ftps', 'file'),
|
||||
'hg': ('http', 'https', 'ssh', 'file'),
|
||||
'svn': ('http', 'https', 'svn', 'svn+ssh', 'file'),
|
||||
'insights': ('http', 'https')
|
||||
'insights': ('http', 'https'),
|
||||
'archive': ('http', 'https'),
|
||||
}
|
||||
if parts.scheme not in scm_type_schemes.get(scm_type, ()):
|
||||
raise ValueError(_('Unsupported %s URL') % scm_type)
|
||||
@ -339,7 +340,7 @@ def update_scm_url(scm_type, url, username=True, password=True,
|
||||
#raise ValueError('Password not supported for SSH with Mercurial.')
|
||||
netloc_password = ''
|
||||
|
||||
if netloc_username and parts.scheme != 'file' and scm_type != "insights":
|
||||
if netloc_username and parts.scheme != 'file' and scm_type not in ("insights", "archive"):
|
||||
netloc = u':'.join([urllib.parse.quote(x,safe='') for x in (netloc_username, netloc_password) if x])
|
||||
else:
|
||||
netloc = u''
|
||||
|
||||
15
awx/playbooks/action_plugins/hg_deprecation.py
Normal file
15
awx/playbooks/action_plugins/hg_deprecation.py
Normal file
@ -0,0 +1,15 @@
|
||||
from __future__ import (absolute_import, division, print_function)
|
||||
__metaclass__ = type
|
||||
|
||||
from ansible.plugins.action import ActionBase
|
||||
|
||||
|
||||
class ActionModule(ActionBase):
|
||||
|
||||
def run(self, tmp=None, task_vars=None):
|
||||
self._supports_check_mode = False
|
||||
result = super(ActionModule, self).run(tmp, task_vars)
|
||||
result['changed'] = result['failed'] = False
|
||||
result['msg'] = ''
|
||||
self._display.deprecated("Mercurial support is deprecated")
|
||||
return result
|
||||
82
awx/playbooks/action_plugins/project_archive.py
Normal file
82
awx/playbooks/action_plugins/project_archive.py
Normal file
@ -0,0 +1,82 @@
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import zipfile
|
||||
import tarfile
|
||||
import os
|
||||
|
||||
from ansible.plugins.action import ActionBase
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class ActionModule(ActionBase):
|
||||
def run(self, tmp=None, task_vars=None):
|
||||
self._supports_check_mode = False
|
||||
|
||||
result = super(ActionModule, self).run(tmp, task_vars)
|
||||
|
||||
src = self._task.args.get("src")
|
||||
proj_path = self._task.args.get("project_path")
|
||||
force = self._task.args.get("force", False)
|
||||
|
||||
try:
|
||||
archive = zipfile.ZipFile(src)
|
||||
get_filenames = archive.namelist
|
||||
get_members = archive.infolist
|
||||
except zipfile.BadZipFile:
|
||||
archive = tarfile.open(src)
|
||||
get_filenames = archive.getnames
|
||||
get_members = archive.getmembers
|
||||
except tarfile.ReadError:
|
||||
result["failed"] = True
|
||||
result["msg"] = "{0} is not a valid archive".format(src)
|
||||
return result
|
||||
|
||||
# Most well formed archives contain a single root directory, typically named
|
||||
# project-name-1.0.0. The project contents should be inside that directory.
|
||||
start_index = 0
|
||||
root_contents = set(
|
||||
[filename.split(os.path.sep)[0] for filename in get_filenames()]
|
||||
)
|
||||
if len(root_contents) == 1:
|
||||
start_index = len(list(root_contents)[0]) + 1
|
||||
|
||||
for member in get_members():
|
||||
try:
|
||||
filename = member.filename
|
||||
except AttributeError:
|
||||
filename = member.name
|
||||
|
||||
# Skip the archive base directory
|
||||
if not filename[start_index:]:
|
||||
continue
|
||||
|
||||
dest = os.path.join(proj_path, filename[start_index:])
|
||||
|
||||
if not force and os.path.exists(dest):
|
||||
continue
|
||||
|
||||
try:
|
||||
is_dir = member.is_dir()
|
||||
except AttributeError:
|
||||
is_dir = member.isdir()
|
||||
|
||||
if is_dir:
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
else:
|
||||
try:
|
||||
member_f = archive.open(member)
|
||||
except TypeError:
|
||||
member_f = tarfile.ExFileObject(archive, member)
|
||||
|
||||
with open(dest, "wb") as f:
|
||||
f.write(member_f.read())
|
||||
member_f.close()
|
||||
|
||||
archive.close()
|
||||
|
||||
result["changed"] = True
|
||||
return result
|
||||
40
awx/playbooks/library/project_archive.py
Normal file
40
awx/playbooks/library/project_archive.py
Normal file
@ -0,0 +1,40 @@
|
||||
ANSIBLE_METADATA = {
|
||||
"metadata_version": "1.0",
|
||||
"status": ["stableinterface"],
|
||||
"supported_by": "community",
|
||||
}
|
||||
|
||||
|
||||
DOCUMENTATION = """
|
||||
---
|
||||
module: project_archive
|
||||
short_description: unpack a project archive
|
||||
description:
|
||||
- Unpacks an archive that contains a project, in order to support handling versioned
|
||||
artifacts from (for example) GitHub Releases or Artifactory builds.
|
||||
- Handles projects in the archive root, or in a single base directory of the archive.
|
||||
version_added: "2.9"
|
||||
options:
|
||||
src:
|
||||
description:
|
||||
- The source archive of the project artifact
|
||||
required: true
|
||||
project_path:
|
||||
description:
|
||||
- Directory to write the project archive contents
|
||||
required: true
|
||||
force:
|
||||
description:
|
||||
- Files in the project_path will be overwritten by matching files in the archive
|
||||
default: False
|
||||
|
||||
author:
|
||||
- "Philip Douglass" @philipsd6
|
||||
"""
|
||||
|
||||
EXAMPLES = """
|
||||
- project_archive:
|
||||
src: "{{ project_path }}/.archive/project.tar.gz"
|
||||
project_path: "{{ project_path }}"
|
||||
force: "{{ scm_clean }}"
|
||||
"""
|
||||
@ -101,6 +101,50 @@
|
||||
tags:
|
||||
- update_insights
|
||||
|
||||
- block:
|
||||
- name: Ensure the project archive directory is present
|
||||
file:
|
||||
dest: "{{ project_path|quote }}/.archive"
|
||||
state: directory
|
||||
|
||||
- name: Get archive from url
|
||||
get_url:
|
||||
url: "{{ scm_url|quote }}"
|
||||
dest: "{{ project_path|quote }}/.archive/"
|
||||
url_username: "{{ scm_username|default(omit) }}"
|
||||
url_password: "{{ scm_password|default(omit) }}"
|
||||
force_basic_auth: true
|
||||
register: get_archive
|
||||
|
||||
- name: Unpack archive
|
||||
project_archive:
|
||||
src: "{{ get_archive.dest }}"
|
||||
project_path: "{{ project_path|quote }}"
|
||||
force: "{{ scm_clean }}"
|
||||
when: get_archive.changed or scm_clean
|
||||
register: unarchived
|
||||
|
||||
- name: Find previous archives
|
||||
find:
|
||||
paths: "{{ project_path|quote }}/.archive/"
|
||||
excludes:
|
||||
- "{{ get_archive.dest|basename }}"
|
||||
when: unarchived.changed
|
||||
register: previous_archive
|
||||
|
||||
- name: Remove previous archives
|
||||
file:
|
||||
path: "{{ item.path }}"
|
||||
state: absent
|
||||
loop: "{{ previous_archive.files }}"
|
||||
when: previous_archive.files|default([])
|
||||
|
||||
- name: Set scm_version to archive sha1 checksum
|
||||
set_fact:
|
||||
scm_version: "{{ get_archive.checksum_src }}"
|
||||
tags:
|
||||
- update_archive
|
||||
|
||||
- name: Repository Version
|
||||
debug:
|
||||
msg: "Repository Version {{ scm_version }}"
|
||||
@ -109,6 +153,7 @@
|
||||
- update_hg
|
||||
- update_svn
|
||||
- update_insights
|
||||
- update_archive
|
||||
|
||||
- hosts: localhost
|
||||
gather_facts: false
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
---
|
||||
- name: Mercurial support is deprecated.
|
||||
hg_deprecation:
|
||||
|
||||
- name: update project using hg
|
||||
hg:
|
||||
dest: "{{project_path|quote}}"
|
||||
|
||||
@ -310,7 +310,7 @@ REST_FRAMEWORK = {
|
||||
'awx.api.parsers.JSONParser',
|
||||
),
|
||||
'DEFAULT_RENDERER_CLASSES': (
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
'awx.api.renderers.DefaultJSONRenderer',
|
||||
'awx.api.renderers.BrowsableAPIRenderer',
|
||||
),
|
||||
'DEFAULT_METADATA_CLASS': 'awx.api.metadata.Metadata',
|
||||
@ -916,7 +916,7 @@ ASGI_APPLICATION = "awx.main.routing.application"
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"BACKEND": "awx.main.consumers.ExpiringRedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [BROKER_URL],
|
||||
"capacity": 10000,
|
||||
|
||||
@ -23,7 +23,7 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
|
||||
$scope.canEditOrg = true;
|
||||
const virtualEnvs = ConfigData.custom_virtualenvs || [];
|
||||
$scope.custom_virtualenvs_options = virtualEnvs;
|
||||
|
||||
|
||||
const [ProjectModel] = resolvedModels;
|
||||
$scope.canAdd = ProjectModel.options('actions.POST');
|
||||
|
||||
@ -170,6 +170,14 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
|
||||
$scope.lookupType = 'scm_credential';
|
||||
$scope.scmBranchLabel = i18n._('SCM Branch/Tag/Revision');
|
||||
break;
|
||||
case 'archive':
|
||||
$scope.credentialLabel = "SCM " + i18n._("Credential");
|
||||
$scope.urlPopover = '<p>' + i18n._('Example URLs for Remote Archive SCM include:') + '</p>' +
|
||||
'<ul class=\"no-bullets\"><li>https://github.com/username/project/archive/v0.0.1.tar.gz</li>' +
|
||||
'<li>http://github.com/username/project/archive/v0.0.2.zip</li></ul>';
|
||||
$scope.credRequired = false;
|
||||
$scope.lookupType = 'scm_credential';
|
||||
break;
|
||||
case 'insights':
|
||||
$scope.pathRequired = false;
|
||||
$scope.scmRequired = false;
|
||||
|
||||
@ -291,6 +291,14 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
|
||||
$scope.lookupType = 'scm_credential';
|
||||
$scope.scmBranchLabel = i18n._('SCM Branch/Tag/Revision');
|
||||
break;
|
||||
case 'archive':
|
||||
$scope.credentialLabel = "SCM " + i18n._("Credential");
|
||||
$scope.urlPopover = '<p>' + i18n._('Example URLs for Remote Archive SCM include:') + '</p>' +
|
||||
'<ul class=\"no-bullets\"><li>https://github.com/username/project/archive/v0.0.1.tar.gz</li>' +
|
||||
'<li>http://github.com/username/project/archive/v0.0.2.zip</li></ul>';
|
||||
$scope.credRequired = false;
|
||||
$scope.lookupType = 'scm_credential';
|
||||
break;
|
||||
case 'insights':
|
||||
$scope.pathRequired = false;
|
||||
$scope.scmRequired = false;
|
||||
|
||||
@ -124,7 +124,7 @@ export default ['i18n', 'NotificationsList', 'TemplateList',
|
||||
scm_branch: {
|
||||
labelBind: "scmBranchLabel",
|
||||
type: 'text',
|
||||
ngShow: "scm_type && scm_type.value !== 'manual' && scm_type.value !== 'insights'",
|
||||
ngShow: "scm_type && scm_type.value !== 'manual' && scm_type.value !== 'insights' && scm_type.value !== 'archive'",
|
||||
ngDisabled: '!(project_obj.summary_fields.user_capabilities.edit || canAdd)',
|
||||
awPopOver: '<p>' + i18n._("Branch to checkout. In addition to branches, you can input tags, commit hashes, and arbitrary refs. Some commit hashes and refs may not be availble unless you also provide a custom refspec.") + '</p>',
|
||||
dataTitle: i18n._('SCM Branch'),
|
||||
|
||||
340
awx/ui/package-lock.json
generated
340
awx/ui/package-lock.json
generated
@ -4,6 +4,28 @@
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
"@types/glob": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz",
|
||||
"integrity": "sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/minimatch": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/minimatch": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
|
||||
"integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "14.0.26",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.26.tgz",
|
||||
"integrity": "sha512-W+fpe5s91FBGE0pEa0lnqGLL4USgpLgs4nokw16SrBBco/gQxuua7KnArSEOd5iaMqbbSHV10vUDkJYJJqpXKA==",
|
||||
"dev": true
|
||||
},
|
||||
"@uirouter/angularjs": {
|
||||
"version": "1.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@uirouter/angularjs/-/angularjs-1.0.18.tgz",
|
||||
@ -740,7 +762,8 @@
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.7.0.tgz",
|
||||
"integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"axios": {
|
||||
"version": "0.16.2",
|
||||
@ -2299,16 +2322,215 @@
|
||||
"dev": true
|
||||
},
|
||||
"chromedriver": {
|
||||
"version": "2.40.0",
|
||||
"resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-2.40.0.tgz",
|
||||
"integrity": "sha512-ewvRQ1HMk0vpFSWYCk5hKDoEz5QMPplx5w3C6/Me+03y1imr67l3Hxl9U0jn3mu2N7+c7BoC7JtNW6HzbRAwDQ==",
|
||||
"version": "77.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-77.0.0.tgz",
|
||||
"integrity": "sha512-mZa1IVx4HD8rDaItWbnS470mmypgiWsDiu98r0NkiT4uLm3qrANl4vOU6no6vtWtLQiW5kt1POcIbjeNpsLbXA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"del": "^3.0.0",
|
||||
"del": "^4.1.1",
|
||||
"extract-zip": "^1.6.7",
|
||||
"kew": "^0.7.0",
|
||||
"mkdirp": "^0.5.1",
|
||||
"request": "^2.87.0"
|
||||
"request": "^2.88.0",
|
||||
"tcp-port-used": "^1.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": {
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.3.tgz",
|
||||
"integrity": "sha512-4K0cK3L1hsqk9xIb2z9vs/XU+PGJZ9PNpJRDS9YLzmNdX6jmVPfamLvTJr0aDAusnHyCHO6MjzlkAsgtqp9teA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
}
|
||||
},
|
||||
"aws4": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.0.tgz",
|
||||
"integrity": "sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA==",
|
||||
"dev": true
|
||||
},
|
||||
"del": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/del/-/del-4.1.1.tgz",
|
||||
"integrity": "sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/glob": "^7.1.1",
|
||||
"globby": "^6.1.0",
|
||||
"is-path-cwd": "^2.0.0",
|
||||
"is-path-in-cwd": "^2.0.0",
|
||||
"p-map": "^2.0.0",
|
||||
"pify": "^4.0.1",
|
||||
"rimraf": "^2.6.3"
|
||||
}
|
||||
},
|
||||
"extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"dev": true
|
||||
},
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
},
|
||||
"glob": {
|
||||
"version": "7.1.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
|
||||
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.0.4",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"har-validator": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz",
|
||||
"integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ajv": "^6.5.5",
|
||||
"har-schema": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true
|
||||
},
|
||||
"is-path-cwd": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz",
|
||||
"integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"is-path-in-cwd": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz",
|
||||
"integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-path-inside": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"is-path-inside": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz",
|
||||
"integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"path-is-inside": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true
|
||||
},
|
||||
"mime-db": {
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz",
|
||||
"integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==",
|
||||
"dev": true
|
||||
},
|
||||
"mime-types": {
|
||||
"version": "2.1.27",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz",
|
||||
"integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"mime-db": "1.44.0"
|
||||
}
|
||||
},
|
||||
"oauth-sign": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
|
||||
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
|
||||
"dev": true
|
||||
},
|
||||
"p-map": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
|
||||
"integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==",
|
||||
"dev": true
|
||||
},
|
||||
"pify": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
|
||||
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
|
||||
"dev": true
|
||||
},
|
||||
"punycode": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
|
||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
|
||||
"dev": true
|
||||
},
|
||||
"request": {
|
||||
"version": "2.88.2",
|
||||
"resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
|
||||
"integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"aws-sign2": "~0.7.0",
|
||||
"aws4": "^1.8.0",
|
||||
"caseless": "~0.12.0",
|
||||
"combined-stream": "~1.0.6",
|
||||
"extend": "~3.0.2",
|
||||
"forever-agent": "~0.6.1",
|
||||
"form-data": "~2.3.2",
|
||||
"har-validator": "~5.1.3",
|
||||
"http-signature": "~1.2.0",
|
||||
"is-typedarray": "~1.0.0",
|
||||
"isstream": "~0.1.2",
|
||||
"json-stringify-safe": "~5.0.1",
|
||||
"mime-types": "~2.1.19",
|
||||
"oauth-sign": "~0.9.0",
|
||||
"performance-now": "^2.1.0",
|
||||
"qs": "~6.5.2",
|
||||
"safe-buffer": "^5.1.2",
|
||||
"tough-cookie": "~2.5.0",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"uuid": "^3.3.2"
|
||||
}
|
||||
},
|
||||
"rimraf": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
|
||||
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
},
|
||||
"tough-cookie": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
|
||||
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"psl": "^1.1.28",
|
||||
"punycode": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"uuid": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
|
||||
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"cipher-base": {
|
||||
@ -6458,16 +6680,6 @@
|
||||
"integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=",
|
||||
"dev": true
|
||||
},
|
||||
"har-validator": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz",
|
||||
"integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ajv": "^5.1.0",
|
||||
"har-schema": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"hard-source-webpack-plugin": {
|
||||
"version": "0.5.18",
|
||||
"resolved": "https://registry.npmjs.org/hard-source-webpack-plugin/-/hard-source-webpack-plugin-0.5.18.tgz",
|
||||
@ -7349,6 +7561,12 @@
|
||||
"integrity": "sha1-x+NWzeoiWucbNtcPLnGpK6TkJZA=",
|
||||
"dev": true
|
||||
},
|
||||
"ip-regex": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz",
|
||||
"integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=",
|
||||
"dev": true
|
||||
},
|
||||
"ipaddr.js": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.6.0.tgz",
|
||||
@ -7658,6 +7876,12 @@
|
||||
"unc-path-regex": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"is-url": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||
"dev": true
|
||||
},
|
||||
"is-utf8": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz",
|
||||
@ -7676,6 +7900,17 @@
|
||||
"integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=",
|
||||
"dev": true
|
||||
},
|
||||
"is2": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is2/-/is2-2.0.1.tgz",
|
||||
"integrity": "sha512-+WaJvnaA7aJySz2q/8sLjMb2Mw14KTplHmSwcSpZ/fWJPkUmqw3YTzSWbPJ7OAwRvdYTWF2Wg+yYJ1AdP5Z8CA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"deep-is": "^0.1.3",
|
||||
"ip-regex": "^2.1.0",
|
||||
"is-url": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
@ -8173,12 +8408,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"kew": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/kew/-/kew-0.7.0.tgz",
|
||||
"integrity": "sha1-edk9LTM2PW/dKXCzNdkUGtWR15s=",
|
||||
"dev": true
|
||||
},
|
||||
"killable": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/killable/-/killable-1.0.0.tgz",
|
||||
@ -9826,7 +10055,8 @@
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz",
|
||||
"integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
@ -11196,6 +11426,12 @@
|
||||
"integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=",
|
||||
"dev": true
|
||||
},
|
||||
"psl": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
|
||||
"integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==",
|
||||
"dev": true
|
||||
},
|
||||
"public-encrypt": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.2.tgz",
|
||||
@ -11816,34 +12052,6 @@
|
||||
"is-finite": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"version": "2.87.0",
|
||||
"resolved": "https://registry.npmjs.org/request/-/request-2.87.0.tgz",
|
||||
"integrity": "sha512-fcogkm7Az5bsS6Sl0sibkbhcKsnyon/jV1kF3ajGmF0c8HrttdKTPRT9hieOaQHA5HEq6r8OyWOo/o781C1tNw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"aws-sign2": "~0.7.0",
|
||||
"aws4": "^1.6.0",
|
||||
"caseless": "~0.12.0",
|
||||
"combined-stream": "~1.0.5",
|
||||
"extend": "~3.0.1",
|
||||
"forever-agent": "~0.6.1",
|
||||
"form-data": "~2.3.1",
|
||||
"har-validator": "~5.0.3",
|
||||
"http-signature": "~1.2.0",
|
||||
"is-typedarray": "~1.0.0",
|
||||
"isstream": "~0.1.2",
|
||||
"json-stringify-safe": "~5.0.1",
|
||||
"mime-types": "~2.1.17",
|
||||
"oauth-sign": "~0.8.2",
|
||||
"performance-now": "^2.1.0",
|
||||
"qs": "~6.5.1",
|
||||
"safe-buffer": "^5.1.1",
|
||||
"tough-cookie": "~2.3.3",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"uuid": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@ -13171,6 +13379,33 @@
|
||||
"xtend": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"tcp-port-used": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tcp-port-used/-/tcp-port-used-1.0.1.tgz",
|
||||
"integrity": "sha512-rwi5xJeU6utXoEIiMvVBMc9eJ2/ofzB+7nLOdnZuFTmNCLqRiQh2sMG9MqCxHU/69VC/Fwp5dV9306Qd54ll1Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"debug": "4.1.0",
|
||||
"is2": "2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.1.0.tgz",
|
||||
"integrity": "sha512-heNPJUJIqC+xB6ayLAMHaIrmN9HKa7aQO8MGqKpvCA+uJYVcvR6l5kgdrhRuwPFHU7P5/A1w0BjByPHwpfTDKg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"test-exclude": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-4.2.1.tgz",
|
||||
@ -13355,6 +13590,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz",
|
||||
"integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"punycode": "^1.4.1"
|
||||
}
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
"babel-loader": "^7.1.2",
|
||||
"babel-plugin-istanbul": "^4.1.5",
|
||||
"babel-preset-env": "^1.6.0",
|
||||
"chromedriver": "^2.35.0",
|
||||
"chromedriver": "^77.0.0",
|
||||
"clean-webpack-plugin": "^0.1.16",
|
||||
"copy-webpack-plugin": "^4.0.1",
|
||||
"css-loader": "^0.28.5",
|
||||
|
||||
2875
awx/ui_next/package-lock.json
generated
2875
awx/ui_next/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -49,7 +49,7 @@
|
||||
"jest-websocket-mock": "^2.0.2",
|
||||
"mock-socket": "^9.0.3",
|
||||
"prettier": "^1.18.2",
|
||||
"react-scripts": "^3.4.1"
|
||||
"react-scripts": "^3.4.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "PORT=3001 HTTPS=true DANGEROUSLY_DISABLE_HOST_CHECK=true react-scripts start",
|
||||
|
||||
@ -87,6 +87,13 @@ const NotificationsMixin = parent =>
|
||||
notificationId,
|
||||
notificationType
|
||||
) {
|
||||
if (notificationType === 'approvals') {
|
||||
return this.associateNotificationTemplatesApprovals(
|
||||
resourceId,
|
||||
notificationId
|
||||
);
|
||||
}
|
||||
|
||||
if (notificationType === 'started') {
|
||||
return this.associateNotificationTemplatesStarted(
|
||||
resourceId,
|
||||
@ -126,6 +133,13 @@ const NotificationsMixin = parent =>
|
||||
notificationId,
|
||||
notificationType
|
||||
) {
|
||||
if (notificationType === 'approvals') {
|
||||
return this.disassociateNotificationTemplatesApprovals(
|
||||
resourceId,
|
||||
notificationId
|
||||
);
|
||||
}
|
||||
|
||||
if (notificationType === 'started') {
|
||||
return this.disassociateNotificationTemplatesStarted(
|
||||
resourceId,
|
||||
|
||||
@ -6,6 +6,7 @@ class Credentials extends Base {
|
||||
this.baseUrl = '/api/v2/credentials/';
|
||||
|
||||
this.readAccessList = this.readAccessList.bind(this);
|
||||
this.readAccessOptions = this.readAccessOptions.bind(this);
|
||||
this.readInputSources = this.readInputSources.bind(this);
|
||||
}
|
||||
|
||||
@ -15,6 +16,10 @@ class Credentials extends Base {
|
||||
});
|
||||
}
|
||||
|
||||
readAccessOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/access_list/`);
|
||||
}
|
||||
|
||||
readInputSources(id, params) {
|
||||
return this.http.get(`${this.baseUrl}${id}/input_sources/`, {
|
||||
params,
|
||||
|
||||
@ -7,6 +7,7 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
||||
this.baseUrl = '/api/v2/inventories/';
|
||||
|
||||
this.readAccessList = this.readAccessList.bind(this);
|
||||
this.readAccessOptions = this.readAccessOptions.bind(this);
|
||||
this.readHosts = this.readHosts.bind(this);
|
||||
this.readHostDetail = this.readHostDetail.bind(this);
|
||||
this.readGroups = this.readGroups.bind(this);
|
||||
@ -20,6 +21,10 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
||||
});
|
||||
}
|
||||
|
||||
readAccessOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/access_list/`);
|
||||
}
|
||||
|
||||
createHost(id, data) {
|
||||
return this.http.post(`${this.baseUrl}${id}/hosts/`, data);
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ class JobTemplates extends SchedulesMixin(
|
||||
this.disassociateLabel = this.disassociateLabel.bind(this);
|
||||
this.readCredentials = this.readCredentials.bind(this);
|
||||
this.readAccessList = this.readAccessList.bind(this);
|
||||
this.readAccessOptions = this.readAccessOptions.bind(this);
|
||||
this.readWebhookKey = this.readWebhookKey.bind(this);
|
||||
}
|
||||
|
||||
@ -66,6 +67,10 @@ class JobTemplates extends SchedulesMixin(
|
||||
});
|
||||
}
|
||||
|
||||
readAccessOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/access_list/`);
|
||||
}
|
||||
|
||||
readScheduleList(id, params) {
|
||||
return this.http.get(`${this.baseUrl}${id}/schedules/`, {
|
||||
params,
|
||||
|
||||
@ -5,6 +5,10 @@ class NotificationTemplates extends Base {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/notification_templates/';
|
||||
}
|
||||
|
||||
test(id) {
|
||||
return this.http.post(`${this.baseUrl}${id}/test/`);
|
||||
}
|
||||
}
|
||||
|
||||
export default NotificationTemplates;
|
||||
|
||||
@ -12,9 +12,42 @@ class Organizations extends InstanceGroupsMixin(NotificationsMixin(Base)) {
|
||||
return this.http.get(`${this.baseUrl}${id}/access_list/`, { params });
|
||||
}
|
||||
|
||||
readAccessOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/access_list/`);
|
||||
}
|
||||
|
||||
readTeams(id, params) {
|
||||
return this.http.get(`${this.baseUrl}${id}/teams/`, { params });
|
||||
}
|
||||
|
||||
readTeamsOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/teams/`);
|
||||
}
|
||||
|
||||
createUser(id, data) {
|
||||
return this.http.post(`${this.baseUrl}${id}/users/`, data);
|
||||
}
|
||||
|
||||
readNotificationTemplatesApprovals(id, params) {
|
||||
return this.http.get(
|
||||
`${this.baseUrl}${id}/notification_templates_approvals/`,
|
||||
{ params }
|
||||
);
|
||||
}
|
||||
|
||||
associateNotificationTemplatesApprovals(resourceId, notificationId) {
|
||||
return this.http.post(
|
||||
`${this.baseUrl}${resourceId}/notification_templates_approvals/`,
|
||||
{ id: notificationId }
|
||||
);
|
||||
}
|
||||
|
||||
disassociateNotificationTemplatesApprovals(resourceId, notificationId) {
|
||||
return this.http.post(
|
||||
`${this.baseUrl}${resourceId}/notification_templates_approvals/`,
|
||||
{ id: notificationId, disassociate: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Organizations;
|
||||
|
||||
@ -11,6 +11,7 @@ class Projects extends SchedulesMixin(
|
||||
this.baseUrl = '/api/v2/projects/';
|
||||
|
||||
this.readAccessList = this.readAccessList.bind(this);
|
||||
this.readAccessOptions = this.readAccessOptions.bind(this);
|
||||
this.readInventories = this.readInventories.bind(this);
|
||||
this.readPlaybooks = this.readPlaybooks.bind(this);
|
||||
this.readSync = this.readSync.bind(this);
|
||||
@ -21,6 +22,10 @@ class Projects extends SchedulesMixin(
|
||||
return this.http.get(`${this.baseUrl}${id}/access_list/`, { params });
|
||||
}
|
||||
|
||||
readAccessOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/access_list/`);
|
||||
}
|
||||
|
||||
readInventories(id) {
|
||||
return this.http.get(`${this.baseUrl}${id}/inventories/`);
|
||||
}
|
||||
|
||||
@ -35,6 +35,10 @@ class Teams extends Base {
|
||||
});
|
||||
}
|
||||
|
||||
readAccessOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/access_list/`);
|
||||
}
|
||||
|
||||
readUsersAccessOptions(teamId) {
|
||||
return this.http.options(`${this.baseUrl}${teamId}/users/`);
|
||||
}
|
||||
|
||||
@ -54,6 +54,10 @@ class WorkflowJobTemplates extends SchedulesMixin(NotificationsMixin(Base)) {
|
||||
});
|
||||
}
|
||||
|
||||
readAccessOptions(id) {
|
||||
return this.http.options(`${this.baseUrl}${id}/access_list/`);
|
||||
}
|
||||
|
||||
readSurvey(id) {
|
||||
return this.http.get(`${this.baseUrl}${id}/survey_spec/`);
|
||||
}
|
||||
@ -65,6 +69,27 @@ class WorkflowJobTemplates extends SchedulesMixin(NotificationsMixin(Base)) {
|
||||
destroySurvey(id) {
|
||||
return this.http.delete(`${this.baseUrl}${id}/survey_spec/`);
|
||||
}
|
||||
|
||||
readNotificationTemplatesApprovals(id, params) {
|
||||
return this.http.get(
|
||||
`${this.baseUrl}${id}/notification_templates_approvals/`,
|
||||
{ params }
|
||||
);
|
||||
}
|
||||
|
||||
associateNotificationTemplatesApprovals(resourceId, notificationId) {
|
||||
return this.http.post(
|
||||
`${this.baseUrl}${resourceId}/notification_templates_approvals/`,
|
||||
{ id: notificationId }
|
||||
);
|
||||
}
|
||||
|
||||
disassociateNotificationTemplatesApprovals(resourceId, notificationId) {
|
||||
return this.http.post(
|
||||
`${this.baseUrl}${resourceId}/notification_templates_approvals/`,
|
||||
{ id: notificationId, disassociate: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkflowJobTemplates;
|
||||
|
||||
@ -1,25 +1,46 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, Fragment } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown, DropdownPosition } from '@patternfly/react-core';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownPosition,
|
||||
DropdownItem,
|
||||
} from '@patternfly/react-core';
|
||||
import { ToolbarAddButton } from '../PaginatedDataList';
|
||||
import { toTitleCase } from '../../util/strings';
|
||||
import { useKebabifiedMenu } from '../../contexts/Kebabified';
|
||||
|
||||
function AddDropDownButton({ dropdownItems }) {
|
||||
function AddDropDownButton({ dropdownItems, i18n }) {
|
||||
const { isKebabified } = useKebabifiedMenu();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const element = useRef(null);
|
||||
|
||||
const toggle = e => {
|
||||
if (!element || !element.current.contains(e.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const toggle = e => {
|
||||
if (!isKebabified && (!element || !element.current.contains(e.target))) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', toggle, false);
|
||||
return () => {
|
||||
document.removeEventListener('click', toggle);
|
||||
};
|
||||
}, []);
|
||||
}, [isKebabified]);
|
||||
|
||||
if (isKebabified) {
|
||||
return (
|
||||
<Fragment>
|
||||
{dropdownItems.map(item => (
|
||||
<DropdownItem key={item.url} component={Link} to={item.url}>
|
||||
{toTitleCase(`${i18n._(t`Add`)} ${item.label}`)}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={element} key="add">
|
||||
@ -52,4 +73,4 @@ AddDropDownButton.propTypes = {
|
||||
};
|
||||
|
||||
export { AddDropDownButton as _AddDropDownButton };
|
||||
export default AddDropDownButton;
|
||||
export default withI18n()(AddDropDownButton);
|
||||
|
||||
@ -11,8 +11,12 @@ import { TeamsAPI, UsersAPI } from '../../api';
|
||||
const readUsers = async queryParams =>
|
||||
UsersAPI.read(Object.assign(queryParams, { is_superuser: false }));
|
||||
|
||||
const readUsersOptions = async () => UsersAPI.readOptions();
|
||||
|
||||
const readTeams = async queryParams => TeamsAPI.read(queryParams);
|
||||
|
||||
const readTeamsOptions = async () => TeamsAPI.readOptions();
|
||||
|
||||
class AddResourceRole extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@ -259,6 +263,7 @@ class AddResourceRole extends React.Component {
|
||||
displayKey="username"
|
||||
onRowClick={this.handleResourceCheckboxClick}
|
||||
fetchItems={readUsers}
|
||||
fetchOptions={readUsersOptions}
|
||||
selectedLabel={i18n._(t`Selected`)}
|
||||
selectedResourceRows={selectedResourceRows}
|
||||
sortedColumnKey="username"
|
||||
@ -270,6 +275,7 @@ class AddResourceRole extends React.Component {
|
||||
sortColumns={teamSortColumns}
|
||||
onRowClick={this.handleResourceCheckboxClick}
|
||||
fetchItems={readTeams}
|
||||
fetchOptions={readTeamsOptions}
|
||||
selectedLabel={i18n._(t`Selected`)}
|
||||
selectedResourceRows={selectedResourceRows}
|
||||
/>
|
||||
|
||||
@ -29,6 +29,7 @@ function SelectResourceStep({
|
||||
selectedLabel,
|
||||
selectedResourceRows,
|
||||
fetchItems,
|
||||
fetchOptions,
|
||||
i18n,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
@ -37,7 +38,7 @@ function SelectResourceStep({
|
||||
isLoading,
|
||||
error,
|
||||
request: readResourceList,
|
||||
result: { resources, itemCount },
|
||||
result: { resources, itemCount, relatedSearchableKeys, searchableKeys },
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const queryParams = parseQueryString(
|
||||
@ -45,14 +46,28 @@ function SelectResourceStep({
|
||||
location.search
|
||||
);
|
||||
|
||||
const {
|
||||
data: { count, results },
|
||||
} = await fetchItems(queryParams);
|
||||
return { resources: results, itemCount: count };
|
||||
}, [location, fetchItems, sortColumns]),
|
||||
const [
|
||||
{
|
||||
data: { count, results },
|
||||
},
|
||||
actionsResponse,
|
||||
] = await Promise.all([fetchItems(queryParams), fetchOptions()]);
|
||||
return {
|
||||
resources: results,
|
||||
itemCount: count,
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [location, fetchItems, fetchOptions, sortColumns]),
|
||||
{
|
||||
resources: [],
|
||||
itemCount: 0,
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -84,6 +99,8 @@ function SelectResourceStep({
|
||||
onRowClick={onRowClick}
|
||||
toolbarSearchColumns={searchColumns}
|
||||
toolbarSortColumns={sortColumns}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
renderItem={item => (
|
||||
<CheckboxListItem
|
||||
isSelected={selectedResourceRows.some(i => i.id === item.id)}
|
||||
|
||||
@ -35,6 +35,7 @@ describe('<SelectResourceStep />', () => {
|
||||
displayKey="username"
|
||||
onRowClick={() => {}}
|
||||
fetchItems={() => {}}
|
||||
fetchOptions={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -49,6 +50,15 @@ describe('<SelectResourceStep />', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
const options = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
related_search_fields: [],
|
||||
},
|
||||
});
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
@ -58,6 +68,7 @@ describe('<SelectResourceStep />', () => {
|
||||
displayKey="username"
|
||||
onRowClick={() => {}}
|
||||
fetchItems={handleSearch}
|
||||
fetchOptions={options}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -78,6 +89,15 @@ describe('<SelectResourceStep />', () => {
|
||||
{ id: 2, username: 'bar', url: 'item/2' },
|
||||
],
|
||||
};
|
||||
const options = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
related_search_fields: [],
|
||||
},
|
||||
});
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
@ -87,6 +107,7 @@ describe('<SelectResourceStep />', () => {
|
||||
displayKey="username"
|
||||
onRowClick={handleRowClick}
|
||||
fetchItems={() => ({ data })}
|
||||
fetchOptions={options}
|
||||
selectedResourceRows={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -21,6 +21,7 @@ function AssociateModal({
|
||||
onClose,
|
||||
onAssociate,
|
||||
fetchRequest,
|
||||
optionsRequest,
|
||||
isModalOpen = false,
|
||||
}) {
|
||||
const history = useHistory();
|
||||
@ -28,24 +29,35 @@ function AssociateModal({
|
||||
|
||||
const {
|
||||
request: fetchItems,
|
||||
result: { items, itemCount },
|
||||
result: { items, itemCount, relatedSearchableKeys, searchableKeys },
|
||||
error: contentError,
|
||||
isLoading,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
const {
|
||||
data: { count, results },
|
||||
} = await fetchRequest(params);
|
||||
const [
|
||||
{
|
||||
data: { count, results },
|
||||
},
|
||||
actionsResponse,
|
||||
] = await Promise.all([fetchRequest(params), optionsRequest()]);
|
||||
|
||||
return {
|
||||
items: results,
|
||||
itemCount: count,
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [fetchRequest, history.location.search]),
|
||||
}, [fetchRequest, optionsRequest, history.location.search]),
|
||||
{
|
||||
items: [],
|
||||
itemCount: 0,
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -132,6 +144,8 @@ function AssociateModal({
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
searchableKeys={searchableKeys}
|
||||
relatedSearchableKeys={relatedSearchableKeys}
|
||||
/>
|
||||
</Modal>
|
||||
</Fragment>
|
||||
|
||||
@ -15,6 +15,15 @@ describe('<AssociateModal />', () => {
|
||||
const onClose = jest.fn();
|
||||
const onAssociate = jest.fn().mockResolvedValue();
|
||||
const fetchRequest = jest.fn().mockReturnValue({ data: { ...mockHosts } });
|
||||
const optionsRequest = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
related_search_fields: [],
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
@ -23,6 +32,7 @@ describe('<AssociateModal />', () => {
|
||||
onClose={onClose}
|
||||
onAssociate={onAssociate}
|
||||
fetchRequest={fetchRequest}
|
||||
optionsRequest={optionsRequest}
|
||||
isModalOpen
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import 'styled-components/macro';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { string, node, number } from 'prop-types';
|
||||
import { node, number, oneOfType, shape, string } from 'prop-types';
|
||||
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
|
||||
import { DetailName, DetailValue } from '../DetailList';
|
||||
import MultiButtonToggle from '../MultiButtonToggle';
|
||||
import { yamlToJson, jsonToYaml, isJson } from '../../util/yaml';
|
||||
import {
|
||||
yamlToJson,
|
||||
jsonToYaml,
|
||||
isJsonObject,
|
||||
isJsonString,
|
||||
} from '../../util/yaml';
|
||||
import CodeMirrorInput from './CodeMirrorInput';
|
||||
import { JSON_MODE, YAML_MODE } from './constants';
|
||||
|
||||
@ -15,7 +20,7 @@ function getValueAsMode(value, mode) {
|
||||
}
|
||||
return '---';
|
||||
}
|
||||
const modeMatches = isJson(value) === (mode === JSON_MODE);
|
||||
const modeMatches = isJsonString(value) === (mode === JSON_MODE);
|
||||
if (modeMatches) {
|
||||
return value;
|
||||
}
|
||||
@ -23,12 +28,21 @@ function getValueAsMode(value, mode) {
|
||||
}
|
||||
|
||||
function VariablesDetail({ value, label, rows, fullHeight }) {
|
||||
const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE);
|
||||
const [currentValue, setCurrentValue] = useState(value || '---');
|
||||
const [mode, setMode] = useState(
|
||||
isJsonObject(value) || isJsonString(value) ? JSON_MODE : YAML_MODE
|
||||
);
|
||||
const [currentValue, setCurrentValue] = useState(
|
||||
isJsonObject(value) ? JSON.stringify(value, null, 2) : value || '---'
|
||||
);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentValue(getValueAsMode(value, mode));
|
||||
setCurrentValue(
|
||||
getValueAsMode(
|
||||
isJsonObject(value) ? JSON.stringify(value, null, 2) : value,
|
||||
mode
|
||||
)
|
||||
);
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [value]);
|
||||
|
||||
@ -95,7 +109,7 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
|
||||
);
|
||||
}
|
||||
VariablesDetail.propTypes = {
|
||||
value: string.isRequired,
|
||||
value: oneOfType([shape({}), string]).isRequired,
|
||||
label: node.isRequired,
|
||||
rows: number,
|
||||
};
|
||||
|
||||
@ -7,7 +7,7 @@ import styled from 'styled-components';
|
||||
import { Split, SplitItem } from '@patternfly/react-core';
|
||||
import { CheckboxField, FieldTooltip } from '../FormField';
|
||||
import MultiButtonToggle from '../MultiButtonToggle';
|
||||
import { yamlToJson, jsonToYaml, isJson } from '../../util/yaml';
|
||||
import { yamlToJson, jsonToYaml, isJsonString } from '../../util/yaml';
|
||||
import CodeMirrorInput from './CodeMirrorInput';
|
||||
import { JSON_MODE, YAML_MODE } from './constants';
|
||||
|
||||
@ -30,7 +30,9 @@ function VariablesField({
|
||||
tooltip,
|
||||
}) {
|
||||
const [field, meta, helpers] = useField(name);
|
||||
const [mode, setMode] = useState(isJson(field.value) ? JSON_MODE : YAML_MODE);
|
||||
const [mode, setMode] = useState(
|
||||
isJsonString(field.value) ? JSON_MODE : YAML_MODE
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pf-c-form__group">
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { string, func, bool, number } from 'prop-types';
|
||||
import { Split, SplitItem } from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
import { yamlToJson, jsonToYaml, isJson } from '../../util/yaml';
|
||||
import { yamlToJson, jsonToYaml, isJsonString } from '../../util/yaml';
|
||||
import MultiButtonToggle from '../MultiButtonToggle';
|
||||
import CodeMirrorInput from './CodeMirrorInput';
|
||||
import { JSON_MODE, YAML_MODE } from './constants';
|
||||
@ -18,11 +18,11 @@ const SplitItemRight = styled(SplitItem)`
|
||||
function VariablesInput(props) {
|
||||
const { id, label, readOnly, rows, error, onError, className } = props;
|
||||
/* eslint-disable react/destructuring-assignment */
|
||||
const defaultValue = isJson(props.value)
|
||||
const defaultValue = isJsonString(props.value)
|
||||
? formatJson(props.value)
|
||||
: props.value;
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE);
|
||||
const [mode, setMode] = useState(isJsonString(value) ? JSON_MODE : YAML_MODE);
|
||||
const isControlled = !!props.onChange;
|
||||
/* eslint-enable react/destructuring-assignment */
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
@ -9,103 +9,128 @@ import {
|
||||
ToolbarGroup,
|
||||
ToolbarItem,
|
||||
ToolbarToggleGroup,
|
||||
Dropdown,
|
||||
KebabToggle,
|
||||
} from '@patternfly/react-core';
|
||||
import { SearchIcon } from '@patternfly/react-icons';
|
||||
import ExpandCollapse from '../ExpandCollapse';
|
||||
import Search from '../Search';
|
||||
import Sort from '../Sort';
|
||||
|
||||
import { SearchColumns, SortColumns, QSConfig } from '../../types';
|
||||
import { KebabifiedProvider } from '../../contexts/Kebabified';
|
||||
|
||||
class DataListToolbar extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
itemCount,
|
||||
clearAllFilters,
|
||||
searchColumns,
|
||||
searchableKeys,
|
||||
relatedSearchableKeys,
|
||||
sortColumns,
|
||||
showSelectAll,
|
||||
isAllSelected,
|
||||
isCompact,
|
||||
onSort,
|
||||
onSearch,
|
||||
onReplaceSearch,
|
||||
onRemove,
|
||||
onCompact,
|
||||
onExpand,
|
||||
onSelectAll,
|
||||
additionalControls,
|
||||
i18n,
|
||||
qsConfig,
|
||||
pagination,
|
||||
} = this.props;
|
||||
function DataListToolbar({
|
||||
itemCount,
|
||||
clearAllFilters,
|
||||
searchColumns,
|
||||
searchableKeys,
|
||||
relatedSearchableKeys,
|
||||
sortColumns,
|
||||
showSelectAll,
|
||||
isAllSelected,
|
||||
isCompact,
|
||||
onSort,
|
||||
onSearch,
|
||||
onReplaceSearch,
|
||||
onRemove,
|
||||
onCompact,
|
||||
onExpand,
|
||||
onSelectAll,
|
||||
additionalControls,
|
||||
i18n,
|
||||
qsConfig,
|
||||
pagination,
|
||||
}) {
|
||||
const showExpandCollapse = onCompact && onExpand;
|
||||
const [kebabIsOpen, setKebabIsOpen] = useState(false);
|
||||
const [advancedSearchShown, setAdvancedSearchShown] = useState(false);
|
||||
|
||||
const showExpandCollapse = onCompact && onExpand;
|
||||
return (
|
||||
<Toolbar
|
||||
id={`${qsConfig.namespace}-list-toolbar`}
|
||||
clearAllFilters={clearAllFilters}
|
||||
collapseListedFiltersBreakpoint="lg"
|
||||
>
|
||||
<ToolbarContent>
|
||||
{showSelectAll && (
|
||||
<ToolbarGroup>
|
||||
<ToolbarItem>
|
||||
<Checkbox
|
||||
isChecked={isAllSelected}
|
||||
onChange={onSelectAll}
|
||||
aria-label={i18n._(t`Select all`)}
|
||||
id="select-all"
|
||||
/>
|
||||
</ToolbarItem>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
<ToolbarToggleGroup toggleIcon={<SearchIcon />} breakpoint="lg">
|
||||
const onShowAdvancedSearch = shown => {
|
||||
setAdvancedSearchShown(shown);
|
||||
setKebabIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Toolbar
|
||||
id={`${qsConfig.namespace}-list-toolbar`}
|
||||
clearAllFilters={clearAllFilters}
|
||||
collapseListedFiltersBreakpoint="lg"
|
||||
>
|
||||
<ToolbarContent>
|
||||
{showSelectAll && (
|
||||
<ToolbarGroup>
|
||||
<ToolbarItem>
|
||||
<Search
|
||||
qsConfig={qsConfig}
|
||||
columns={[
|
||||
...searchColumns,
|
||||
{ name: i18n._(t`Advanced`), key: 'advanced' },
|
||||
]}
|
||||
searchableKeys={searchableKeys}
|
||||
relatedSearchableKeys={relatedSearchableKeys}
|
||||
onSearch={onSearch}
|
||||
onReplaceSearch={onReplaceSearch}
|
||||
onRemove={onRemove}
|
||||
<Checkbox
|
||||
isChecked={isAllSelected}
|
||||
onChange={onSelectAll}
|
||||
aria-label={i18n._(t`Select all`)}
|
||||
id="select-all"
|
||||
/>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Sort qsConfig={qsConfig} columns={sortColumns} onSort={onSort} />
|
||||
</ToolbarItem>
|
||||
</ToolbarToggleGroup>
|
||||
{showExpandCollapse && (
|
||||
<ToolbarGroup>
|
||||
<Fragment>
|
||||
<ToolbarItem>
|
||||
<ExpandCollapse
|
||||
isCompact={isCompact}
|
||||
onCompact={onCompact}
|
||||
onExpand={onExpand}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
</Fragment>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
<ToolbarToggleGroup toggleIcon={<SearchIcon />} breakpoint="lg">
|
||||
<ToolbarItem>
|
||||
<Search
|
||||
qsConfig={qsConfig}
|
||||
columns={[
|
||||
...searchColumns,
|
||||
{ name: i18n._(t`Advanced`), key: 'advanced' },
|
||||
]}
|
||||
searchableKeys={searchableKeys}
|
||||
relatedSearchableKeys={relatedSearchableKeys}
|
||||
onSearch={onSearch}
|
||||
onReplaceSearch={onReplaceSearch}
|
||||
onShowAdvancedSearch={onShowAdvancedSearch}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Sort qsConfig={qsConfig} columns={sortColumns} onSort={onSort} />
|
||||
</ToolbarItem>
|
||||
</ToolbarToggleGroup>
|
||||
{showExpandCollapse && (
|
||||
<ToolbarGroup>
|
||||
<Fragment>
|
||||
<ToolbarItem>
|
||||
<ExpandCollapse
|
||||
isCompact={isCompact}
|
||||
onCompact={onCompact}
|
||||
onExpand={onExpand}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
</Fragment>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
{advancedSearchShown && (
|
||||
<ToolbarItem>
|
||||
<Dropdown
|
||||
toggle={<KebabToggle onToggle={setKebabIsOpen} />}
|
||||
isOpen={kebabIsOpen}
|
||||
isPlain
|
||||
dropdownItems={additionalControls.map(control => {
|
||||
return (
|
||||
<KebabifiedProvider value={{ isKebabified: true }}>
|
||||
{control}
|
||||
</KebabifiedProvider>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
)}
|
||||
{!advancedSearchShown && (
|
||||
<ToolbarGroup>
|
||||
{additionalControls.map(control => (
|
||||
<ToolbarItem key={control.key}>{control}</ToolbarItem>
|
||||
))}
|
||||
</ToolbarGroup>
|
||||
{pagination && itemCount > 0 && (
|
||||
<ToolbarItem variant="pagination">{pagination}</ToolbarItem>
|
||||
)}
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{!advancedSearchShown && pagination && itemCount > 0 && (
|
||||
<ToolbarItem variant="pagination">{pagination}</ToolbarItem>
|
||||
)}
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
DataListToolbar.propTypes = {
|
||||
|
||||
51
awx/ui_next/src/components/DetailList/ObjectDetail.jsx
Normal file
51
awx/ui_next/src/components/DetailList/ObjectDetail.jsx
Normal file
@ -0,0 +1,51 @@
|
||||
import 'styled-components/macro';
|
||||
import React from 'react';
|
||||
import { shape, node, number } from 'prop-types';
|
||||
import { TextListItemVariants } from '@patternfly/react-core';
|
||||
import { DetailName, DetailValue } from './Detail';
|
||||
import CodeMirrorInput from '../CodeMirrorInput';
|
||||
|
||||
function ObjectDetail({ value, label, rows, fullHeight }) {
|
||||
return (
|
||||
<>
|
||||
<DetailName
|
||||
component={TextListItemVariants.dt}
|
||||
fullWidth
|
||||
css="grid-column: 1 / -1"
|
||||
>
|
||||
<div className="pf-c-form__label">
|
||||
<span
|
||||
className="pf-c-form__label-text"
|
||||
css="font-weight: var(--pf-global--FontWeight--bold)"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</DetailName>
|
||||
<DetailValue
|
||||
component={TextListItemVariants.dd}
|
||||
fullWidth
|
||||
css="grid-column: 1 / -1; margin-top: -20px"
|
||||
>
|
||||
<CodeMirrorInput
|
||||
mode="json"
|
||||
value={JSON.stringify(value)}
|
||||
readOnly
|
||||
rows={rows}
|
||||
fullHeight={fullHeight}
|
||||
css="margin-top: 10px"
|
||||
/>
|
||||
</DetailValue>
|
||||
</>
|
||||
);
|
||||
}
|
||||
ObjectDetail.propTypes = {
|
||||
value: shape.isRequired,
|
||||
label: node.isRequired,
|
||||
rows: number,
|
||||
};
|
||||
ObjectDetail.defaultProps = {
|
||||
rows: null,
|
||||
};
|
||||
|
||||
export default ObjectDetail;
|
||||
@ -3,3 +3,8 @@ export { default as Detail, DetailName, DetailValue } from './Detail';
|
||||
export { default as DeletedDetail } from './DeletedDetail';
|
||||
export { default as UserDateDetail } from './UserDateDetail';
|
||||
export { default as DetailBadge } from './DetailBadge';
|
||||
/*
|
||||
NOTE: ObjectDetail cannot be imported here, as it causes circular
|
||||
dependencies in testing environment. Import it directly from
|
||||
DetailList/ObjectDetail
|
||||
*/
|
||||
|
||||
@ -61,7 +61,7 @@ function DisassociateButton({
|
||||
<Tooltip content={renderTooltip()} position="top">
|
||||
<div>
|
||||
<Button
|
||||
variant="danger"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Disassociate`)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
isDisabled={isDisabled}
|
||||
|
||||
@ -8,7 +8,18 @@ import ErrorDetail from '../ErrorDetail';
|
||||
import useRequest from '../../util/useRequest';
|
||||
import { HostsAPI } from '../../api';
|
||||
|
||||
function HostToggle({ host, onToggle, className, i18n }) {
|
||||
function HostToggle({
|
||||
i18n,
|
||||
className,
|
||||
host,
|
||||
isDisabled = false,
|
||||
onToggle,
|
||||
tooltip = i18n._(
|
||||
t`Indicates if a host is available and should be included in running
|
||||
jobs. For hosts that are part of an external inventory, this may be
|
||||
reset by the inventory sync process.`
|
||||
),
|
||||
}) {
|
||||
const [isEnabled, setIsEnabled] = useState(host.enabled);
|
||||
const [showError, setShowError] = useState(false);
|
||||
|
||||
@ -39,14 +50,7 @@ function HostToggle({ host, onToggle, className, i18n }) {
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Indicates if a host is available and should be included in running
|
||||
jobs. For hosts that are part of an external inventory, this may be
|
||||
reset by the inventory sync process.`
|
||||
)}
|
||||
position="top"
|
||||
>
|
||||
<Tooltip content={tooltip} position="top">
|
||||
<Switch
|
||||
className={className}
|
||||
css="display: inline-flex;"
|
||||
@ -54,7 +58,11 @@ function HostToggle({ host, onToggle, className, i18n }) {
|
||||
label={i18n._(t`On`)}
|
||||
labelOff={i18n._(t`Off`)}
|
||||
isChecked={isEnabled}
|
||||
isDisabled={isLoading || !host.summary_fields.user_capabilities.edit}
|
||||
isDisabled={
|
||||
isLoading ||
|
||||
isDisabled ||
|
||||
!host.summary_fields.user_capabilities.edit
|
||||
}
|
||||
onChange={toggleHost}
|
||||
aria-label={i18n._(t`Toggle host`)}
|
||||
/>
|
||||
|
||||
@ -19,7 +19,7 @@ const mockHost = {
|
||||
},
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
update: true,
|
||||
edit: true,
|
||||
},
|
||||
recent_jobs: [],
|
||||
},
|
||||
@ -68,6 +68,18 @@ describe('<HostToggle>', () => {
|
||||
expect(onToggle).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('should be enabled', async () => {
|
||||
const wrapper = mountWithContexts(<HostToggle host={mockHost} />);
|
||||
expect(wrapper.find('Switch').prop('isDisabled')).toEqual(false);
|
||||
});
|
||||
|
||||
test('should be disabled', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<HostToggle isDisabled host={mockHost} />
|
||||
);
|
||||
expect(wrapper.find('Switch').prop('isDisabled')).toEqual(true);
|
||||
});
|
||||
|
||||
test('should show error modal', async () => {
|
||||
HostsAPI.update.mockImplementation(() => {
|
||||
throw new Error('nope');
|
||||
|
||||
@ -38,7 +38,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
const [selected, setSelected] = useState([]);
|
||||
const location = useLocation();
|
||||
const {
|
||||
result: { results, count, actions, relatedSearchFields },
|
||||
result: { results, count, relatedSearchableKeys, searchableKeys },
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchJobs,
|
||||
@ -53,10 +53,12 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
return {
|
||||
results: response.data.results,
|
||||
count: response.data.count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
},
|
||||
[location] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@ -64,8 +66,8 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
{
|
||||
results: [],
|
||||
count: 0,
|
||||
actions: {},
|
||||
relatedSearchFields: [],
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
@ -138,11 +140,6 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
}
|
||||
};
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
|
||||
@ -52,7 +52,7 @@ function CredentialsStep({ i18n }) {
|
||||
}, [fetchTypes]);
|
||||
|
||||
const {
|
||||
result: { credentials, count, actions, relatedSearchFields },
|
||||
result: { credentials, count, relatedSearchableKeys, searchableKeys },
|
||||
error: credentialsError,
|
||||
isLoading: isCredentialsLoading,
|
||||
request: fetchCredentials,
|
||||
@ -72,13 +72,15 @@ function CredentialsStep({ i18n }) {
|
||||
return {
|
||||
credentials: data.results,
|
||||
count: data.count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [selectedType, history.location.search]),
|
||||
{ credentials: [], count: 0, actions: {}, relatedSearchFields: [] }
|
||||
{ credentials: [], count: 0, relatedSearchableKeys: [], searchableKeys: [] }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -104,11 +106,6 @@ function CredentialsStep({ i18n }) {
|
||||
/>
|
||||
);
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{types && types.length > 0 && (
|
||||
|
||||
@ -9,7 +9,6 @@ import useRequest from '../../../util/useRequest';
|
||||
import OptionsList from '../../OptionsList';
|
||||
import ContentLoading from '../../ContentLoading';
|
||||
import ContentError from '../../ContentError';
|
||||
import { required } from '../../../util/validators';
|
||||
|
||||
const QS_CONFIG = getQSConfig('inventory', {
|
||||
page: 1,
|
||||
@ -20,14 +19,13 @@ const QS_CONFIG = getQSConfig('inventory', {
|
||||
function InventoryStep({ i18n }) {
|
||||
const [field, , helpers] = useField({
|
||||
name: 'inventory',
|
||||
validate: required(null, i18n),
|
||||
});
|
||||
const history = useHistory();
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
result: { inventories, count, actions, relatedSearchFields },
|
||||
result: { inventories, count, relatedSearchableKeys, searchableKeys },
|
||||
request: fetchInventories,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
@ -39,17 +37,19 @@ function InventoryStep({ i18n }) {
|
||||
return {
|
||||
inventories: data.results,
|
||||
count: data.count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [history.location]),
|
||||
{
|
||||
count: 0,
|
||||
inventories: [],
|
||||
actions: {},
|
||||
relatedSearchFields: [],
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -57,11 +57,6 @@ function InventoryStep({ i18n }) {
|
||||
fetchInventories();
|
||||
}, [fetchInventories]);
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
@ -9,7 +9,11 @@ export default function useInventoryStep(config, resource, visitedSteps, i18n) {
|
||||
const [stepErrors, setStepErrors] = useState({});
|
||||
|
||||
const validate = values => {
|
||||
if (!config.ask_inventory_on_launch) {
|
||||
if (
|
||||
!config.ask_inventory_on_launch ||
|
||||
(['workflow_job', 'workflow_job_template'].includes(resource.type) &&
|
||||
!resource.inventory)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
const errors = {};
|
||||
|
||||
@ -22,7 +22,7 @@ function ApplicationLookup({ i18n, onChange, value, label }) {
|
||||
const location = useLocation();
|
||||
const {
|
||||
error,
|
||||
result: { applications, itemCount, actions, relatedSearchFields },
|
||||
result: { applications, itemCount, relatedSearchableKeys, searchableKeys },
|
||||
request: fetchApplications,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
@ -40,23 +40,24 @@ function ApplicationLookup({ i18n, onChange, value, label }) {
|
||||
return {
|
||||
applications: results,
|
||||
itemCount: count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse?.data?.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [location]),
|
||||
{ applications: [], itemCount: 0, actions: {}, relatedSearchFields: [] }
|
||||
{
|
||||
applications: [],
|
||||
itemCount: 0,
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
fetchApplications();
|
||||
}, [fetchApplications]);
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<FormGroup fieldId="application" label={label}>
|
||||
<Lookup
|
||||
|
||||
@ -35,7 +35,7 @@ function CredentialLookup({
|
||||
tooltip,
|
||||
}) {
|
||||
const {
|
||||
result: { count, credentials, actions, relatedSearchFields },
|
||||
result: { count, credentials, relatedSearchableKeys, searchableKeys },
|
||||
error,
|
||||
request: fetchCredentials,
|
||||
} = useRequest(
|
||||
@ -64,10 +64,12 @@ function CredentialLookup({
|
||||
return {
|
||||
count: data.count,
|
||||
credentials: data.results,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data?.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data?.actions?.GET[key]?.filterable),
|
||||
};
|
||||
}, [
|
||||
credentialTypeId,
|
||||
@ -78,8 +80,8 @@ function CredentialLookup({
|
||||
{
|
||||
count: 0,
|
||||
credentials: [],
|
||||
actions: {},
|
||||
relatedSearchFields: [],
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -87,11 +89,6 @@ function CredentialLookup({
|
||||
fetchCredentials();
|
||||
}, [fetchCredentials]);
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
// TODO: replace credential type search with REST-based grabbing of cred types
|
||||
|
||||
return (
|
||||
|
||||
@ -30,7 +30,7 @@ function InstanceGroupsLookup(props) {
|
||||
} = props;
|
||||
|
||||
const {
|
||||
result: { instanceGroups, count, actions, relatedSearchFields },
|
||||
result: { instanceGroups, count, relatedSearchableKeys, searchableKeys },
|
||||
request: fetchInstanceGroups,
|
||||
error,
|
||||
isLoading,
|
||||
@ -44,24 +44,26 @@ function InstanceGroupsLookup(props) {
|
||||
return {
|
||||
instanceGroups: data.results,
|
||||
count: data.count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [history.location]),
|
||||
{ instanceGroups: [], count: 0, actions: {}, relatedSearchFields: [] }
|
||||
{
|
||||
instanceGroups: [],
|
||||
count: 0,
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstanceGroups();
|
||||
}, [fetchInstanceGroups]);
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
className={className}
|
||||
|
||||
@ -19,7 +19,7 @@ const QS_CONFIG = getQSConfig('inventory', {
|
||||
|
||||
function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
|
||||
const {
|
||||
result: { inventories, count, actions, relatedSearchFields },
|
||||
result: { inventories, count, relatedSearchableKeys, searchableKeys },
|
||||
request: fetchInventories,
|
||||
error,
|
||||
isLoading,
|
||||
@ -33,24 +33,21 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
|
||||
return {
|
||||
inventories: data.results,
|
||||
count: data.count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [history.location]),
|
||||
{ inventories: [], count: 0, actions: {}, relatedSearchFields: [] }
|
||||
{ inventories: [], count: 0, relatedSearchableKeys: [], searchableKeys: [] }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInventories();
|
||||
}, [fetchInventories]);
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Lookup
|
||||
|
||||
@ -49,7 +49,12 @@ function MultiCredentialsLookup(props) {
|
||||
}, [fetchTypes]);
|
||||
|
||||
const {
|
||||
result: { credentials, credentialsCount, actions, relatedSearchFields },
|
||||
result: {
|
||||
credentials,
|
||||
credentialsCount,
|
||||
relatedSearchableKeys,
|
||||
searchableKeys,
|
||||
},
|
||||
request: fetchCredentials,
|
||||
error: credentialsError,
|
||||
isLoading: isCredentialsLoading,
|
||||
@ -69,17 +74,19 @@ function MultiCredentialsLookup(props) {
|
||||
return {
|
||||
credentials: results,
|
||||
credentialsCount: count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [selectedType, history.location]),
|
||||
{
|
||||
credentials: [],
|
||||
credentialsCount: 0,
|
||||
actions: {},
|
||||
relatedSearchFields: [],
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -104,11 +111,6 @@ function MultiCredentialsLookup(props) {
|
||||
|
||||
const isVault = selectedType?.kind === 'vault';
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<Lookup
|
||||
id="multiCredential"
|
||||
|
||||
@ -29,21 +29,32 @@ function OrganizationLookup({
|
||||
history,
|
||||
}) {
|
||||
const {
|
||||
result: { itemCount, organizations },
|
||||
result: { itemCount, organizations, relatedSearchableKeys, searchableKeys },
|
||||
error: contentError,
|
||||
request: fetchOrganizations,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
const { data } = await OrganizationsAPI.read(params);
|
||||
const [response, actionsResponse] = await Promise.all([
|
||||
OrganizationsAPI.read(params),
|
||||
OrganizationsAPI.readOptions(),
|
||||
]);
|
||||
return {
|
||||
organizations: data.results,
|
||||
itemCount: data.count,
|
||||
organizations: response.data.results,
|
||||
itemCount: response.data.count,
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [history.location.search]),
|
||||
{
|
||||
organizations: [],
|
||||
itemCount: 0,
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -98,6 +109,8 @@ function OrganizationLookup({
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
searchableKeys={searchableKeys}
|
||||
relatedSearchableKeys={relatedSearchableKeys}
|
||||
readOnly={!canDelete}
|
||||
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||
|
||||
@ -32,7 +32,7 @@ function ProjectLookup({
|
||||
history,
|
||||
}) {
|
||||
const {
|
||||
result: { projects, count, actions, relatedSearchFields },
|
||||
result: { projects, count, relatedSearchableKeys, searchableKeys },
|
||||
request: fetchProjects,
|
||||
error,
|
||||
isLoading,
|
||||
@ -49,17 +49,19 @@ function ProjectLookup({
|
||||
return {
|
||||
count: data.count,
|
||||
projects: data.results,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [history.location.search, autocomplete]),
|
||||
{
|
||||
count: 0,
|
||||
projects: [],
|
||||
actions: {},
|
||||
relatedSearchFields: [],
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -67,11 +69,6 @@ function ProjectLookup({
|
||||
fetchProjects();
|
||||
}, [fetchProjects]);
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId="project"
|
||||
@ -108,6 +105,7 @@ function ProjectLookup({
|
||||
[`git`, i18n._(t`Git`)],
|
||||
[`hg`, i18n._(t`Mercurial`)],
|
||||
[`svn`, i18n._(t`Subversion`)],
|
||||
[`archive`, i18n._(t`Remote Archive`)],
|
||||
[`insights`, i18n._(t`Red Hat Insights`)],
|
||||
],
|
||||
},
|
||||
|
||||
@ -17,20 +17,29 @@ const QS_CONFIG = getQSConfig('notification', {
|
||||
order_by: 'name',
|
||||
});
|
||||
|
||||
function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
function NotificationList({
|
||||
apiModel,
|
||||
canToggleNotifications,
|
||||
id,
|
||||
i18n,
|
||||
showApprovalsToggle,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const [isToggleLoading, setIsToggleLoading] = useState(false);
|
||||
const [loadingToggleIds, setLoadingToggleIds] = useState([]);
|
||||
const [toggleError, setToggleError] = useState(null);
|
||||
|
||||
const {
|
||||
result: fetchNotificationsResult,
|
||||
result: fetchNotificationsResults,
|
||||
result: {
|
||||
notifications,
|
||||
itemCount,
|
||||
approvalsTemplateIds,
|
||||
startedTemplateIds,
|
||||
successTemplateIds,
|
||||
errorTemplateIds,
|
||||
typeLabels,
|
||||
relatedSearchableKeys,
|
||||
searchableKeys,
|
||||
},
|
||||
error: contentError,
|
||||
isLoading,
|
||||
@ -43,15 +52,13 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
{
|
||||
data: { results: notificationsResults, count: notificationsCount },
|
||||
},
|
||||
{
|
||||
data: { actions },
|
||||
},
|
||||
actionsResponse,
|
||||
] = await Promise.all([
|
||||
NotificationTemplatesAPI.read(params),
|
||||
NotificationTemplatesAPI.readOptions(),
|
||||
]);
|
||||
|
||||
const labels = actions.GET.notification_type.choices.reduce(
|
||||
const labels = actionsResponse.data.actions.GET.notification_type.choices.reduce(
|
||||
(map, notifType) => ({ ...map, [notifType[0]]: notifType[1] }),
|
||||
{}
|
||||
);
|
||||
@ -71,22 +78,47 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
apiModel.readNotificationTemplatesError(id, idMatchParams),
|
||||
]);
|
||||
|
||||
return {
|
||||
const rtnObj = {
|
||||
notifications: notificationsResults,
|
||||
itemCount: notificationsCount,
|
||||
startedTemplateIds: startedTemplates.results.map(st => st.id),
|
||||
successTemplateIds: successTemplates.results.map(su => su.id),
|
||||
errorTemplateIds: errorTemplates.results.map(e => e.id),
|
||||
typeLabels: labels,
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [apiModel, id, location]),
|
||||
|
||||
if (showApprovalsToggle) {
|
||||
const {
|
||||
data: approvalsTemplates,
|
||||
} = await apiModel.readNotificationTemplatesApprovals(
|
||||
id,
|
||||
idMatchParams
|
||||
);
|
||||
rtnObj.approvalsTemplateIds = approvalsTemplates.results.map(
|
||||
st => st.id
|
||||
);
|
||||
} else {
|
||||
rtnObj.approvalsTemplateIds = [];
|
||||
}
|
||||
|
||||
return rtnObj;
|
||||
}, [apiModel, id, location, showApprovalsToggle]),
|
||||
{
|
||||
notifications: [],
|
||||
itemCount: 0,
|
||||
approvalsTemplateIds: [],
|
||||
startedTemplateIds: [],
|
||||
successTemplateIds: [],
|
||||
errorTemplateIds: [],
|
||||
typeLabels: {},
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -99,7 +131,7 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
isCurrentlyOn,
|
||||
status
|
||||
) => {
|
||||
setIsToggleLoading(true);
|
||||
setLoadingToggleIds(loadingToggleIds.concat([notificationId]));
|
||||
try {
|
||||
if (isCurrentlyOn) {
|
||||
await apiModel.disassociateNotificationTemplate(
|
||||
@ -108,8 +140,8 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
status
|
||||
);
|
||||
setValue({
|
||||
...fetchNotificationsResult,
|
||||
[`${status}TemplateIds`]: fetchNotificationsResult[
|
||||
...fetchNotificationsResults,
|
||||
[`${status}TemplateIds`]: fetchNotificationsResults[
|
||||
`${status}TemplateIds`
|
||||
].filter(i => i !== notificationId),
|
||||
});
|
||||
@ -120,8 +152,8 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
status
|
||||
);
|
||||
setValue({
|
||||
...fetchNotificationsResult,
|
||||
[`${status}TemplateIds`]: fetchNotificationsResult[
|
||||
...fetchNotificationsResults,
|
||||
[`${status}TemplateIds`]: fetchNotificationsResults[
|
||||
`${status}TemplateIds`
|
||||
].concat(notificationId),
|
||||
});
|
||||
@ -129,7 +161,9 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
} catch (err) {
|
||||
setToggleError(err);
|
||||
} finally {
|
||||
setIsToggleLoading(false);
|
||||
setLoadingToggleIds(
|
||||
loadingToggleIds.filter(item => item !== notificationId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -179,17 +213,24 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
renderItem={notification => (
|
||||
<NotificationListItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
detailUrl={`/notifications/${notification.id}`}
|
||||
canToggleNotifications={canToggleNotifications && !isToggleLoading}
|
||||
canToggleNotifications={
|
||||
canToggleNotifications &&
|
||||
!loadingToggleIds.includes(notification.id)
|
||||
}
|
||||
toggleNotification={handleNotificationToggle}
|
||||
approvalsTurnedOn={approvalsTemplateIds.includes(notification.id)}
|
||||
errorTurnedOn={errorTemplateIds.includes(notification.id)}
|
||||
startedTurnedOn={startedTemplateIds.includes(notification.id)}
|
||||
successTurnedOn={successTemplateIds.includes(notification.id)}
|
||||
typeLabels={typeLabels}
|
||||
showApprovalsToggle={showApprovalsToggle}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -197,7 +238,7 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
|
||||
<AlertModal
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
isOpen={!isToggleLoading}
|
||||
isOpen={loadingToggleIds.length === 0}
|
||||
onClose={() => setToggleError(null)}
|
||||
>
|
||||
{i18n._(t`Failed to toggle notification.`)}
|
||||
@ -212,6 +253,11 @@ NotificationList.propTypes = {
|
||||
apiModel: shape({}).isRequired,
|
||||
id: number.isRequired,
|
||||
canToggleNotifications: bool.isRequired,
|
||||
showApprovalsToggle: bool,
|
||||
};
|
||||
|
||||
NotificationList.defaultProps = {
|
||||
showApprovalsToggle: false,
|
||||
};
|
||||
|
||||
export default withI18n()(NotificationList);
|
||||
|
||||
@ -17,25 +17,25 @@ const DataListAction = styled(_DataListAction)`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: repeat(3, max-content);
|
||||
grid-template-columns: ${props => `repeat(${props.columns}, max-content)`};
|
||||
`;
|
||||
const Label = styled.b`
|
||||
margin-right: 20px;
|
||||
`;
|
||||
|
||||
function NotificationListItem(props) {
|
||||
const {
|
||||
canToggleNotifications,
|
||||
notification,
|
||||
detailUrl,
|
||||
startedTurnedOn,
|
||||
successTurnedOn,
|
||||
errorTurnedOn,
|
||||
toggleNotification,
|
||||
i18n,
|
||||
typeLabels,
|
||||
} = props;
|
||||
|
||||
function NotificationListItem({
|
||||
canToggleNotifications,
|
||||
notification,
|
||||
detailUrl,
|
||||
approvalsTurnedOn,
|
||||
startedTurnedOn,
|
||||
successTurnedOn,
|
||||
errorTurnedOn,
|
||||
toggleNotification,
|
||||
i18n,
|
||||
typeLabels,
|
||||
showApprovalsToggle,
|
||||
}) {
|
||||
return (
|
||||
<DataListItem
|
||||
aria-labelledby={`items-list-item-${notification.id}`}
|
||||
@ -66,7 +66,25 @@ function NotificationListItem(props) {
|
||||
aria-label="actions"
|
||||
aria-labelledby={`items-list-item-${notification.id}`}
|
||||
id={`items-list-item-${notification.id}`}
|
||||
columns={showApprovalsToggle ? 4 : 3}
|
||||
>
|
||||
{showApprovalsToggle && (
|
||||
<Switch
|
||||
id={`notification-${notification.id}-approvals-toggle`}
|
||||
label={i18n._(t`Approval`)}
|
||||
labelOff={i18n._(t`Approval`)}
|
||||
isChecked={approvalsTurnedOn}
|
||||
isDisabled={!canToggleNotifications}
|
||||
onChange={() =>
|
||||
toggleNotification(
|
||||
notification.id,
|
||||
approvalsTurnedOn,
|
||||
'approvals'
|
||||
)
|
||||
}
|
||||
aria-label={i18n._(t`Toggle notification approvals`)}
|
||||
/>
|
||||
)}
|
||||
<Switch
|
||||
id={`notification-${notification.id}-started-toggle`}
|
||||
label={i18n._(t`Start`)}
|
||||
@ -114,17 +132,21 @@ NotificationListItem.propTypes = {
|
||||
}).isRequired,
|
||||
canToggleNotifications: bool.isRequired,
|
||||
detailUrl: string.isRequired,
|
||||
approvalsTurnedOn: bool,
|
||||
errorTurnedOn: bool,
|
||||
startedTurnedOn: bool,
|
||||
successTurnedOn: bool,
|
||||
toggleNotification: func.isRequired,
|
||||
typeLabels: shape().isRequired,
|
||||
showApprovalsToggle: bool,
|
||||
};
|
||||
|
||||
NotificationListItem.defaultProps = {
|
||||
approvalsTurnedOn: false,
|
||||
errorTurnedOn: false,
|
||||
startedTurnedOn: false,
|
||||
successTurnedOn: false,
|
||||
showApprovalsToggle: false,
|
||||
};
|
||||
|
||||
export default withI18n()(NotificationListItem);
|
||||
|
||||
@ -39,6 +39,21 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('NotificationListItem')).toMatchSnapshot();
|
||||
expect(wrapper.find('Switch').length).toBe(3);
|
||||
});
|
||||
|
||||
test('shows approvals toggle when configured', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
showApprovalsToggle
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('Switch').length).toBe(4);
|
||||
});
|
||||
|
||||
test('displays correct label in correct column', () => {
|
||||
@ -58,7 +73,46 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
||||
expect(typeCell.text()).toContain('Slack');
|
||||
});
|
||||
|
||||
test('handles start click when toggle is on', () => {
|
||||
test('handles approvals click when toggle is on', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
approvalsTurnedOn
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
showApprovalsToggle
|
||||
/>
|
||||
);
|
||||
wrapper
|
||||
.find('Switch[aria-label="Toggle notification approvals"]')
|
||||
.first()
|
||||
.find('input')
|
||||
.simulate('change');
|
||||
expect(toggleNotification).toHaveBeenCalledWith(9000, true, 'approvals');
|
||||
});
|
||||
|
||||
test('handles approvals click when toggle is off', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
approvalsTurnedOn={false}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
showApprovalsToggle
|
||||
/>
|
||||
);
|
||||
wrapper
|
||||
.find('Switch[aria-label="Toggle notification approvals"]')
|
||||
.find('input')
|
||||
.simulate('change');
|
||||
expect(toggleNotification).toHaveBeenCalledWith(9000, false, 'approvals');
|
||||
});
|
||||
|
||||
test('handles started click when toggle is on', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
@ -70,14 +124,13 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
||||
/>
|
||||
);
|
||||
wrapper
|
||||
.find('Switch')
|
||||
.first()
|
||||
.find('Switch[aria-label="Toggle notification start"]')
|
||||
.find('input')
|
||||
.simulate('change');
|
||||
expect(toggleNotification).toHaveBeenCalledWith(9000, true, 'started');
|
||||
});
|
||||
|
||||
test('handles start click when toggle is off', () => {
|
||||
test('handles started click when toggle is off', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
@ -95,7 +148,7 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
||||
expect(toggleNotification).toHaveBeenCalledWith(9000, false, 'started');
|
||||
});
|
||||
|
||||
test('handles error click when toggle is on', () => {
|
||||
test('handles success click when toggle is on', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
@ -113,7 +166,7 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
||||
expect(toggleNotification).toHaveBeenCalledWith(9000, true, 'success');
|
||||
});
|
||||
|
||||
test('handles error click when toggle is off', () => {
|
||||
test('handles success click when toggle is off', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
exports[`<NotificationListItem canToggleNotifications /> initially renders succesfully and displays correct label 1`] = `
|
||||
<NotificationListItem
|
||||
approvalsTurnedOn={false}
|
||||
canToggleNotifications={true}
|
||||
detailUrl="/foo"
|
||||
errorTurnedOn={false}
|
||||
@ -13,6 +14,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
"notification_type": "slack",
|
||||
}
|
||||
}
|
||||
showApprovalsToggle={false}
|
||||
startedTurnedOn={false}
|
||||
successTurnedOn={false}
|
||||
toggleNotification={[MockFunction]}
|
||||
@ -215,6 +217,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
<Styled(DataListAction)
|
||||
aria-label="actions"
|
||||
aria-labelledby="items-list-item-9000"
|
||||
columns={3}
|
||||
id="items-list-item-9000"
|
||||
key=".1"
|
||||
rowid="items-list-item-9000"
|
||||
@ -222,6 +225,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
<StyledComponent
|
||||
aria-label="actions"
|
||||
aria-labelledby="items-list-item-9000"
|
||||
columns={3}
|
||||
forwardedComponent={
|
||||
Object {
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
@ -235,7 +239,9 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: repeat(3, max-content);
|
||||
grid-template-columns: ",
|
||||
[Function],
|
||||
";
|
||||
",
|
||||
],
|
||||
},
|
||||
@ -257,11 +263,13 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
aria-label="actions"
|
||||
aria-labelledby="items-list-item-9000"
|
||||
className="sc-bwzfXH llKtln"
|
||||
columns={3}
|
||||
id="items-list-item-9000"
|
||||
rowid="items-list-item-9000"
|
||||
>
|
||||
<div
|
||||
className="pf-c-data-list__item-action sc-bwzfXH llKtln"
|
||||
columns={3}
|
||||
rowid="items-list-item-9000"
|
||||
>
|
||||
<Switch
|
||||
|
||||
@ -49,7 +49,6 @@ function OptionsList({
|
||||
<SelectedList
|
||||
label={i18n._(t`Selected`)}
|
||||
selected={value}
|
||||
showOverflowAfter={5}
|
||||
onRemove={item => deselectItem(item)}
|
||||
isReadOnly={readOnly}
|
||||
renderItemChip={renderItemChip}
|
||||
|
||||
@ -1,16 +1,32 @@
|
||||
import React from 'react';
|
||||
import { string, func } from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Tooltip } from '@patternfly/react-core';
|
||||
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useKebabifiedMenu } from '../../contexts/Kebabified';
|
||||
|
||||
function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) {
|
||||
const { isKebabified } = useKebabifiedMenu();
|
||||
|
||||
if (!linkTo && !onClick) {
|
||||
throw new Error(
|
||||
'ToolbarAddButton requires either `linkTo` or `onClick` prop'
|
||||
);
|
||||
}
|
||||
if (isKebabified) {
|
||||
return (
|
||||
<DropdownItem
|
||||
key="add"
|
||||
isDisabled={isDisabled}
|
||||
component={linkTo ? Link : Button}
|
||||
to={linkTo}
|
||||
onClick={!onClick ? undefined : onClick}
|
||||
>
|
||||
{i18n._(t`Add`)}
|
||||
</DropdownItem>
|
||||
);
|
||||
}
|
||||
if (linkTo) {
|
||||
return (
|
||||
<Tooltip content={i18n._(t`Add`)} position="top">
|
||||
|
||||
@ -8,10 +8,11 @@ import {
|
||||
shape,
|
||||
checkPropTypes,
|
||||
} from 'prop-types';
|
||||
import { Button, Tooltip } from '@patternfly/react-core';
|
||||
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import AlertModal from '../AlertModal';
|
||||
import { Kebabified } from '../../contexts/Kebabified';
|
||||
|
||||
const requireNameOrUsername = props => {
|
||||
const { name, username } = props;
|
||||
@ -138,54 +139,69 @@ class ToolbarDeleteButton extends React.Component {
|
||||
// we can delete the extra <div> around the <DeleteButton> below.
|
||||
// See: https://github.com/patternfly/patternfly-react/issues/1894
|
||||
return (
|
||||
<Fragment>
|
||||
<Tooltip content={this.renderTooltip()} position="top">
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Delete`)}
|
||||
onClick={this.handleConfirmDelete}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{isModalOpen && (
|
||||
<AlertModal
|
||||
variant="danger"
|
||||
title={modalTitle}
|
||||
isOpen={isModalOpen}
|
||||
onClose={this.handleCancelDelete}
|
||||
actions={[
|
||||
<Button
|
||||
key="delete"
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`confirm delete`)}
|
||||
onClick={this.handleDelete}
|
||||
<Kebabified>
|
||||
{({ isKebabified }) => (
|
||||
<Fragment>
|
||||
{isKebabified ? (
|
||||
<DropdownItem
|
||||
key="add"
|
||||
isDisabled={isDisabled}
|
||||
component="Button"
|
||||
onClick={this.handleConfirmDelete}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>,
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`cancel delete`)}
|
||||
onClick={this.handleCancelDelete}
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<Tooltip content={this.renderTooltip()} position="top">
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Delete`)}
|
||||
onClick={this.handleConfirmDelete}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isModalOpen && (
|
||||
<AlertModal
|
||||
variant="danger"
|
||||
title={modalTitle}
|
||||
isOpen={isModalOpen}
|
||||
onClose={this.handleCancelDelete}
|
||||
actions={[
|
||||
<Button
|
||||
key="delete"
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`confirm delete`)}
|
||||
onClick={this.handleDelete}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>,
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`cancel delete`)}
|
||||
onClick={this.handleCancelDelete}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div>{i18n._(t`This action will delete the following:`)}</div>
|
||||
{itemsToDelete.map(item => (
|
||||
<span key={item.id}>
|
||||
<strong>{item.name || item.username}</strong>
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
</AlertModal>
|
||||
<div>{i18n._(t`This action will delete the following:`)}</div>
|
||||
{itemsToDelete.map(item => (
|
||||
<span key={item.id}>
|
||||
<strong>{item.name || item.username}</strong>
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
</AlertModal>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</Fragment>
|
||||
</Kebabified>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,22 +26,33 @@ function ResourceAccessList({ i18n, apiModel, resource }) {
|
||||
const location = useLocation();
|
||||
|
||||
const {
|
||||
result: { accessRecords, itemCount },
|
||||
result: { accessRecords, itemCount, relatedSearchableKeys, searchableKeys },
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchAccessRecords,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const response = await apiModel.readAccessList(resource.id, params);
|
||||
const [response, actionsResponse] = await Promise.all([
|
||||
apiModel.readAccessList(resource.id, params),
|
||||
apiModel.readAccessOptions(resource.id),
|
||||
]);
|
||||
return {
|
||||
accessRecords: response.data.results,
|
||||
itemCount: response.data.count,
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [apiModel, location, resource.id]),
|
||||
{
|
||||
accessRecords: [],
|
||||
itemCount: 0,
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -106,6 +117,8 @@ function ResourceAccessList({ i18n, apiModel, resource }) {
|
||||
key: 'last_name',
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
renderToolbar={props => (
|
||||
<DataListToolbar
|
||||
{...props}
|
||||
|
||||
@ -76,6 +76,15 @@ describe('<ResourceAccessList />', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
OrganizationsAPI.readAccessList.mockResolvedValue({ data });
|
||||
OrganizationsAPI.readAccessOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
related_search_fields: [],
|
||||
},
|
||||
});
|
||||
TeamsAPI.disassociateRole.mockResolvedValue({});
|
||||
UsersAPI.disassociateRole.mockResolvedValue({});
|
||||
await act(async () => {
|
||||
|
||||
@ -6,10 +6,12 @@ import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../testUtils/enzymeHelpers';
|
||||
import { SchedulesAPI } from '../../api';
|
||||
import { JobTemplatesAPI, SchedulesAPI } from '../../api';
|
||||
import Schedule from './Schedule';
|
||||
|
||||
jest.mock('../../api/models/JobTemplates');
|
||||
jest.mock('../../api/models/Schedules');
|
||||
jest.mock('../../api/models/WorkflowJobTemplates');
|
||||
|
||||
SchedulesAPI.readDetail.mockResolvedValue({
|
||||
data: {
|
||||
@ -62,6 +64,22 @@ SchedulesAPI.readCredentials.mockResolvedValue({
|
||||
},
|
||||
});
|
||||
|
||||
JobTemplatesAPI.readLaunch.mockResolvedValue({
|
||||
data: {
|
||||
ask_credential_on_launch: false,
|
||||
ask_diff_mode_on_launch: false,
|
||||
ask_inventory_on_launch: false,
|
||||
ask_job_type_on_launch: false,
|
||||
ask_limit_on_launch: false,
|
||||
ask_scm_branch_on_launch: false,
|
||||
ask_skip_tags_on_launch: false,
|
||||
ask_tags_on_launch: false,
|
||||
ask_variables_on_launch: false,
|
||||
ask_verbosity_on_launch: false,
|
||||
survey_enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
describe('<Schedule />', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
|
||||
@ -17,10 +17,15 @@ import ScheduleOccurrences from '../ScheduleOccurrences';
|
||||
import ScheduleToggle from '../ScheduleToggle';
|
||||
import { formatDateString } from '../../../util/dates';
|
||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||
import { SchedulesAPI } from '../../../api';
|
||||
import {
|
||||
JobTemplatesAPI,
|
||||
SchedulesAPI,
|
||||
WorkflowJobTemplatesAPI,
|
||||
} from '../../../api';
|
||||
import DeleteButton from '../../DeleteButton';
|
||||
import ErrorDetail from '../../ErrorDetail';
|
||||
import ChipGroup from '../../ChipGroup';
|
||||
import { VariablesDetail } from '../../CodeMirrorInput';
|
||||
|
||||
const PromptTitle = styled(Title)`
|
||||
--pf-c-title--m-md--FontWeight: 700;
|
||||
@ -35,9 +40,9 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
diff_mode,
|
||||
dtend,
|
||||
dtstart,
|
||||
extra_data,
|
||||
job_tags,
|
||||
job_type,
|
||||
inventory,
|
||||
limit,
|
||||
modified,
|
||||
name,
|
||||
@ -67,20 +72,47 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
const { error, dismissError } = useDismissableError(deleteError);
|
||||
|
||||
const {
|
||||
result: [credentials, preview],
|
||||
result: [credentials, preview, launchData],
|
||||
isLoading,
|
||||
error: readContentError,
|
||||
request: fetchCredentialsAndPreview,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const [{ data }, { data: schedulePreview }] = await Promise.all([
|
||||
const promises = [
|
||||
SchedulesAPI.readCredentials(id),
|
||||
SchedulesAPI.createPreview({
|
||||
rrule,
|
||||
}),
|
||||
]);
|
||||
return [data.results, schedulePreview];
|
||||
}, [id, rrule]),
|
||||
];
|
||||
|
||||
if (
|
||||
schedule?.summary_fields?.unified_job_template?.unified_job_type ===
|
||||
'job'
|
||||
) {
|
||||
promises.push(
|
||||
JobTemplatesAPI.readLaunch(
|
||||
schedule.summary_fields.unified_job_template.id
|
||||
)
|
||||
);
|
||||
} else if (
|
||||
schedule?.summary_fields?.unified_job_template?.unified_job_type ===
|
||||
'workflow_job'
|
||||
) {
|
||||
promises.push(
|
||||
WorkflowJobTemplatesAPI.readLaunch(
|
||||
schedule.summary_fields.unified_job_template.id
|
||||
)
|
||||
);
|
||||
} else {
|
||||
promises.push(Promise.resolve());
|
||||
}
|
||||
|
||||
const [{ data }, { data: schedulePreview }, launch] = await Promise.all(
|
||||
promises
|
||||
);
|
||||
|
||||
return [data.results, schedulePreview, launch?.data];
|
||||
}, [id, schedule, rrule]),
|
||||
[]
|
||||
);
|
||||
|
||||
@ -93,15 +125,33 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
rule.options.freq === RRule.MINUTELY && dtstart === dtend
|
||||
? i18n._(t`None (Run Once)`)
|
||||
: rule.toText().replace(/^\w/, c => c.toUpperCase());
|
||||
|
||||
const {
|
||||
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,
|
||||
} = launchData || {};
|
||||
|
||||
const showPromptedFields =
|
||||
(credentials && credentials.length > 0) ||
|
||||
job_type ||
|
||||
(inventory && summary_fields.inventory) ||
|
||||
scm_branch ||
|
||||
limit ||
|
||||
typeof diff_mode === 'boolean' ||
|
||||
(job_tags && job_tags.length > 0) ||
|
||||
(skip_tags && skip_tags.length > 0);
|
||||
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;
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoading />;
|
||||
@ -144,8 +194,10 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
<PromptTitle headingLevel="h2">
|
||||
{i18n._(t`Prompted Fields`)}
|
||||
</PromptTitle>
|
||||
<Detail label={i18n._(t`Job Type`)} value={job_type} />
|
||||
{inventory && summary_fields.inventory && (
|
||||
{ask_job_type_on_launch && (
|
||||
<Detail label={i18n._(t`Job Type`)} value={job_type} />
|
||||
)}
|
||||
{ask_inventory_on_launch && (
|
||||
<Detail
|
||||
label={i18n._(t`Inventory`)}
|
||||
value={
|
||||
@ -161,18 +213,22 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Detail
|
||||
label={i18n._(t`Source Control Branch`)}
|
||||
value={scm_branch}
|
||||
/>
|
||||
<Detail label={i18n._(t`Limit`)} value={limit} />
|
||||
{typeof diff_mode === 'boolean' && (
|
||||
{ask_scm_branch_on_launch && (
|
||||
<Detail
|
||||
label={i18n._(t`Source Control Branch`)}
|
||||
value={scm_branch}
|
||||
/>
|
||||
)}
|
||||
{ask_limit_on_launch && (
|
||||
<Detail label={i18n._(t`Limit`)} value={limit} />
|
||||
)}
|
||||
{ask_diff_mode_on_launch && typeof diff_mode === 'boolean' && (
|
||||
<Detail
|
||||
label={i18n._(t`Show Changes`)}
|
||||
value={diff_mode ? 'On' : 'Off'}
|
||||
/>
|
||||
)}
|
||||
{credentials && credentials.length > 0 && (
|
||||
{ask_credential_on_launch && (
|
||||
<Detail
|
||||
fullWidth
|
||||
label={i18n._(t`Credentials`)}
|
||||
@ -185,7 +241,7 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{job_tags && job_tags.length > 0 && (
|
||||
{ask_tags_on_launch && job_tags && job_tags.length > 0 && (
|
||||
<Detail
|
||||
fullWidth
|
||||
label={i18n._(t`Job Tags`)}
|
||||
@ -203,7 +259,7 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{skip_tags && skip_tags.length > 0 && (
|
||||
{ask_skip_tags_on_launch && skip_tags && skip_tags.length > 0 && (
|
||||
<Detail
|
||||
fullWidth
|
||||
label={i18n._(t`Skip Tags`)}
|
||||
@ -221,6 +277,13 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(ask_variables_on_launch || survey_enabled) && (
|
||||
<VariablesDetail
|
||||
value={extra_data}
|
||||
rows={4}
|
||||
label={i18n._(t`Variables`)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DetailList>
|
||||
|
||||
@ -2,14 +2,48 @@ import React from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { SchedulesAPI } from '../../../api';
|
||||
import { SchedulesAPI, JobTemplatesAPI } from '../../../api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import ScheduleDetail from './ScheduleDetail';
|
||||
|
||||
jest.mock('../../../api/models/JobTemplates');
|
||||
jest.mock('../../../api/models/Schedules');
|
||||
jest.mock('../../../api/models/WorkflowJobTemplates');
|
||||
|
||||
const allPrompts = {
|
||||
data: {
|
||||
ask_credential_on_launch: true,
|
||||
ask_diff_mode_on_launch: true,
|
||||
ask_inventory_on_launch: true,
|
||||
ask_job_type_on_launch: true,
|
||||
ask_limit_on_launch: true,
|
||||
ask_scm_branch_on_launch: true,
|
||||
ask_skip_tags_on_launch: true,
|
||||
ask_tags_on_launch: true,
|
||||
ask_variables_on_launch: true,
|
||||
ask_verbosity_on_launch: true,
|
||||
survey_enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const noPrompts = {
|
||||
data: {
|
||||
ask_credential_on_launch: false,
|
||||
ask_diff_mode_on_launch: false,
|
||||
ask_inventory_on_launch: false,
|
||||
ask_job_type_on_launch: false,
|
||||
ask_limit_on_launch: false,
|
||||
ask_scm_branch_on_launch: false,
|
||||
ask_skip_tags_on_launch: false,
|
||||
ask_tags_on_launch: false,
|
||||
ask_variables_on_launch: false,
|
||||
ask_verbosity_on_launch: false,
|
||||
survey_enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
const schedule = {
|
||||
url: '/api/v2/schedules/1',
|
||||
@ -53,6 +87,7 @@ const schedule = {
|
||||
dtstart: '2020-03-16T04:00:00Z',
|
||||
dtend: '2020-07-06T04:00:00Z',
|
||||
next_run: '2020-03-16T04:00:00Z',
|
||||
extra_data: {},
|
||||
};
|
||||
|
||||
SchedulesAPI.createPreview.mockResolvedValue({
|
||||
@ -79,6 +114,7 @@ describe('<ScheduleDetail />', () => {
|
||||
results: [],
|
||||
},
|
||||
});
|
||||
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(noPrompts);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route
|
||||
@ -134,6 +170,7 @@ describe('<ScheduleDetail />', () => {
|
||||
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('details should render with the proper values with prompts', async () => {
|
||||
SchedulesAPI.readCredentials.mockResolvedValue({
|
||||
@ -151,6 +188,7 @@ describe('<ScheduleDetail />', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(allPrompts);
|
||||
const scheduleWithPrompts = {
|
||||
...schedule,
|
||||
job_type: 'run',
|
||||
@ -161,6 +199,7 @@ describe('<ScheduleDetail />', () => {
|
||||
limit: 'localhost',
|
||||
diff_mode: true,
|
||||
verbosity: 1,
|
||||
extra_data: { foo: 'fii' },
|
||||
};
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
@ -182,7 +221,6 @@ describe('<ScheduleDetail />', () => {
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
// await waitForElement(wrapper, 'Title', el => el.length > 0);
|
||||
expect(
|
||||
wrapper
|
||||
.find('Detail[label="Name"]')
|
||||
@ -231,6 +269,7 @@ describe('<ScheduleDetail />', () => {
|
||||
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('error shown when error encountered fetching credentials', async () => {
|
||||
SchedulesAPI.readCredentials.mockRejectedValueOnce(
|
||||
@ -245,6 +284,7 @@ describe('<ScheduleDetail />', () => {
|
||||
},
|
||||
})
|
||||
);
|
||||
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(noPrompts);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route
|
||||
@ -274,6 +314,7 @@ describe('<ScheduleDetail />', () => {
|
||||
results: [],
|
||||
},
|
||||
});
|
||||
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(noPrompts);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route
|
||||
@ -313,6 +354,7 @@ describe('<ScheduleDetail />', () => {
|
||||
results: [],
|
||||
},
|
||||
});
|
||||
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(noPrompts);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route
|
||||
|
||||
@ -32,7 +32,13 @@ function ScheduleList({
|
||||
const location = useLocation();
|
||||
|
||||
const {
|
||||
result: { schedules, itemCount, actions, relatedSearchFields },
|
||||
result: {
|
||||
schedules,
|
||||
itemCount,
|
||||
actions,
|
||||
relatedSearchableKeys,
|
||||
searchableKeys,
|
||||
},
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchSchedules,
|
||||
@ -49,16 +55,20 @@ function ScheduleList({
|
||||
schedules: results,
|
||||
itemCount: count,
|
||||
actions: scheduleActions.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
scheduleActions?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
scheduleActions.data.actions?.GET || {}
|
||||
).filter(key => scheduleActions.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [location, loadSchedules, loadScheduleOptions]),
|
||||
{
|
||||
schedules: [],
|
||||
itemCount: 0,
|
||||
actions: {},
|
||||
relatedSearchFields: [],
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -106,10 +116,6 @@ function ScheduleList({
|
||||
actions &&
|
||||
Object.prototype.hasOwnProperty.call(actions, 'POST') &&
|
||||
!hideAddButton;
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -181,42 +181,32 @@ const FrequencyDetailSubform = ({ i18n }) => {
|
||||
};
|
||||
|
||||
const getRunEveryLabel = () => {
|
||||
const intervalValue = interval.value;
|
||||
|
||||
switch (frequency.value) {
|
||||
case 'minute':
|
||||
return i18n.plural({
|
||||
value: interval.value,
|
||||
one: 'minute',
|
||||
other: 'minutes',
|
||||
return i18n._('{intervalValue, plural, one {minute} other {minutes}}', {
|
||||
intervalValue,
|
||||
});
|
||||
case 'hour':
|
||||
return i18n.plural({
|
||||
value: interval.value,
|
||||
one: 'hour',
|
||||
other: 'hours',
|
||||
return i18n._('{intervalValue, plural, one {hour} other {hours}}', {
|
||||
intervalValue,
|
||||
});
|
||||
case 'day':
|
||||
return i18n.plural({
|
||||
value: interval.value,
|
||||
one: 'day',
|
||||
other: 'days',
|
||||
return i18n._('{intervalValue, plural, one {day} other {days}}', {
|
||||
intervalValue,
|
||||
});
|
||||
case 'week':
|
||||
return i18n.plural({
|
||||
value: interval.value,
|
||||
one: 'week',
|
||||
other: 'weeks',
|
||||
return i18n._('{intervalValue, plural, one {week} other {weeks}}', {
|
||||
intervalValue,
|
||||
});
|
||||
case 'month':
|
||||
return i18n.plural({
|
||||
value: interval.value,
|
||||
one: 'month',
|
||||
other: 'months',
|
||||
return i18n._('{intervalValue, plural, one {month} other {months}}', {
|
||||
intervalValue,
|
||||
});
|
||||
case 'year':
|
||||
return i18n.plural({
|
||||
value: interval.value,
|
||||
one: 'year',
|
||||
other: 'years',
|
||||
return i18n._('{intervalValue, plural, one {year} other {years}}', {
|
||||
intervalValue,
|
||||
});
|
||||
default:
|
||||
throw new Error(i18n._(t`Frequency did not match an expected value`));
|
||||
|
||||
@ -40,6 +40,7 @@ function Search({
|
||||
location,
|
||||
searchableKeys,
|
||||
relatedSearchableKeys,
|
||||
onShowAdvancedSearch,
|
||||
}) {
|
||||
const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false);
|
||||
const [searchKey, setSearchKey] = useState(
|
||||
@ -62,7 +63,7 @@ function Search({
|
||||
const { key: actualSearchKey } = columns.find(
|
||||
({ name }) => name === target.innerText
|
||||
);
|
||||
|
||||
onShowAdvancedSearch(actualSearchKey === 'advanced');
|
||||
setIsFilterDropdownOpen(false);
|
||||
setSearchKey(actualSearchKey);
|
||||
};
|
||||
@ -301,6 +302,7 @@ Search.propTypes = {
|
||||
columns: SearchColumns.isRequired,
|
||||
onSearch: PropTypes.func,
|
||||
onRemove: PropTypes.func,
|
||||
onShowAdvancedSearch: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
Search.defaultProps = {
|
||||
|
||||
@ -36,7 +36,12 @@ describe('<Search />', () => {
|
||||
collapseListedFiltersBreakpoint="lg"
|
||||
>
|
||||
<ToolbarContent>
|
||||
<Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} />
|
||||
<Search
|
||||
qsConfig={QS_CONFIG}
|
||||
columns={columns}
|
||||
onSearch={onSearch}
|
||||
onShowAdvancedSearch={jest.fn}
|
||||
/>
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
);
|
||||
@ -64,7 +69,12 @@ describe('<Search />', () => {
|
||||
collapseListedFiltersBreakpoint="lg"
|
||||
>
|
||||
<ToolbarContent>
|
||||
<Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} />
|
||||
<Search
|
||||
qsConfig={QS_CONFIG}
|
||||
columns={columns}
|
||||
onSearch={onSearch}
|
||||
onShowAdvancedSearch={jest.fn}
|
||||
/>
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
);
|
||||
@ -83,6 +93,50 @@ describe('<Search />', () => {
|
||||
expect(onSearch).toBeCalledWith('description__icontains', 'test-321');
|
||||
});
|
||||
|
||||
test('changing key select to and from advanced causes onShowAdvancedSearch callback to be invoked', () => {
|
||||
const columns = [
|
||||
{ name: 'Name', key: 'name__icontains', isDefault: true },
|
||||
{ name: 'Description', key: 'description__icontains' },
|
||||
{ name: 'Advanced', key: 'advanced' },
|
||||
];
|
||||
const onSearch = jest.fn();
|
||||
const onShowAdvancedSearch = jest.fn();
|
||||
const wrapper = mountWithContexts(
|
||||
<Toolbar
|
||||
id={`${QS_CONFIG.namespace}-list-toolbar`}
|
||||
clearAllFilters={() => {}}
|
||||
collapseListedFiltersBreakpoint="lg"
|
||||
>
|
||||
<ToolbarContent>
|
||||
<Search
|
||||
qsConfig={QS_CONFIG}
|
||||
columns={columns}
|
||||
onSearch={onSearch}
|
||||
onShowAdvancedSearch={onShowAdvancedSearch}
|
||||
/>
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
);
|
||||
|
||||
act(() => {
|
||||
wrapper
|
||||
.find('Select[aria-label="Simple key select"]')
|
||||
.invoke('onSelect')({ target: { innerText: 'Advanced' } });
|
||||
});
|
||||
wrapper.update();
|
||||
expect(onShowAdvancedSearch).toHaveBeenCalledTimes(1);
|
||||
expect(onShowAdvancedSearch).toBeCalledWith(true);
|
||||
jest.clearAllMocks();
|
||||
act(() => {
|
||||
wrapper
|
||||
.find('Select[aria-label="Simple key select"]')
|
||||
.invoke('onSelect')({ target: { innerText: 'Description' } });
|
||||
});
|
||||
wrapper.update();
|
||||
expect(onShowAdvancedSearch).toHaveBeenCalledTimes(1);
|
||||
expect(onShowAdvancedSearch).toBeCalledWith(false);
|
||||
});
|
||||
|
||||
test('attempt to search with empty string', () => {
|
||||
const searchButton = 'button[aria-label="Search submit button"]';
|
||||
const searchTextInput = 'input[aria-label="Search text input"]';
|
||||
@ -95,7 +149,12 @@ describe('<Search />', () => {
|
||||
collapseListedFiltersBreakpoint="lg"
|
||||
>
|
||||
<ToolbarContent>
|
||||
<Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} />
|
||||
<Search
|
||||
qsConfig={QS_CONFIG}
|
||||
columns={columns}
|
||||
onSearch={onSearch}
|
||||
onShowAdvancedSearch={jest.fn}
|
||||
/>
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
);
|
||||
@ -119,7 +178,12 @@ describe('<Search />', () => {
|
||||
collapseListedFiltersBreakpoint="lg"
|
||||
>
|
||||
<ToolbarContent>
|
||||
<Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} />
|
||||
<Search
|
||||
qsConfig={QS_CONFIG}
|
||||
columns={columns}
|
||||
onSearch={onSearch}
|
||||
onShowAdvancedSearch={jest.fn}
|
||||
/>
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
);
|
||||
@ -150,7 +214,11 @@ describe('<Search />', () => {
|
||||
collapseListedFiltersBreakpoint="lg"
|
||||
>
|
||||
<ToolbarContent>
|
||||
<Search qsConfig={QS_CONFIG} columns={columns} />
|
||||
<Search
|
||||
qsConfig={QS_CONFIG}
|
||||
columns={columns}
|
||||
onShowAdvancedSearch={jest.fn}
|
||||
/>
|
||||
</ToolbarContent>
|
||||
</Toolbar>,
|
||||
{ context: { router: { history } } }
|
||||
@ -197,6 +265,7 @@ describe('<Search />', () => {
|
||||
qsConfig={qsConfigNew}
|
||||
columns={columns}
|
||||
onRemove={onRemove}
|
||||
onShowAdvancedSearch={jest.fn}
|
||||
/>
|
||||
</ToolbarContent>
|
||||
</Toolbar>,
|
||||
@ -243,6 +312,7 @@ describe('<Search />', () => {
|
||||
qsConfig={qsConfigNew}
|
||||
columns={columns}
|
||||
onRemove={onRemove}
|
||||
onShowAdvancedSearch={jest.fn}
|
||||
/>
|
||||
</ToolbarContent>
|
||||
</Toolbar>,
|
||||
@ -277,7 +347,11 @@ describe('<Search />', () => {
|
||||
collapseListedFiltersBreakpoint="lg"
|
||||
>
|
||||
<ToolbarContent>
|
||||
<Search qsConfig={QS_CONFIG} columns={columns} />
|
||||
<Search
|
||||
qsConfig={QS_CONFIG}
|
||||
columns={columns}
|
||||
onShowAdvancedSearch={jest.fn}
|
||||
/>
|
||||
</ToolbarContent>
|
||||
</Toolbar>,
|
||||
{ context: { router: { history } } }
|
||||
|
||||
68
awx/ui_next/src/components/StatusLabel/StatusLabel.jsx
Normal file
68
awx/ui_next/src/components/StatusLabel/StatusLabel.jsx
Normal file
@ -0,0 +1,68 @@
|
||||
import 'styled-components/macro';
|
||||
import React from 'react';
|
||||
import { oneOf } from 'prop-types';
|
||||
import { Label } from '@patternfly/react-core';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
SyncAltIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ClockIcon,
|
||||
} from '@patternfly/react-icons';
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
|
||||
const Spin = keyframes`
|
||||
from {
|
||||
transform: rotate(0);
|
||||
}
|
||||
to {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
`;
|
||||
|
||||
const RunningIcon = styled(SyncAltIcon)`
|
||||
animation: ${Spin} 1.75s linear infinite;
|
||||
`;
|
||||
|
||||
const colors = {
|
||||
success: 'green',
|
||||
failed: 'red',
|
||||
error: 'red',
|
||||
running: 'blue',
|
||||
pending: 'blue',
|
||||
waiting: 'grey',
|
||||
canceled: 'orange',
|
||||
};
|
||||
const icons = {
|
||||
success: CheckCircleIcon,
|
||||
failed: ExclamationCircleIcon,
|
||||
error: ExclamationCircleIcon,
|
||||
running: RunningIcon,
|
||||
pending: ClockIcon,
|
||||
waiting: ClockIcon,
|
||||
canceled: ExclamationTriangleIcon,
|
||||
};
|
||||
|
||||
export default function StatusLabel({ status }) {
|
||||
const label = status.charAt(0).toUpperCase() + status.slice(1);
|
||||
const color = colors[status] || 'grey';
|
||||
const Icon = icons[status];
|
||||
|
||||
return (
|
||||
<Label variant="outline" color={color} icon={Icon ? <Icon /> : null}>
|
||||
{label}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
StatusLabel.propTypes = {
|
||||
status: oneOf([
|
||||
'success',
|
||||
'failed',
|
||||
'error',
|
||||
'running',
|
||||
'pending',
|
||||
'waiting',
|
||||
'canceled',
|
||||
]).isRequired,
|
||||
};
|
||||
61
awx/ui_next/src/components/StatusLabel/StatusLabel.test.jsx
Normal file
61
awx/ui_next/src/components/StatusLabel/StatusLabel.test.jsx
Normal file
@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import StatusLabel from './StatusLabel';
|
||||
|
||||
describe('StatusLabel', () => {
|
||||
test('should render success', () => {
|
||||
const wrapper = mount(<StatusLabel status="success" />);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.find('CheckCircleIcon')).toHaveLength(1);
|
||||
expect(wrapper.find('Label').prop('color')).toEqual('green');
|
||||
expect(wrapper.text()).toEqual('Success');
|
||||
});
|
||||
|
||||
test('should render failed', () => {
|
||||
const wrapper = mount(<StatusLabel status="failed" />);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.find('ExclamationCircleIcon')).toHaveLength(1);
|
||||
expect(wrapper.find('Label').prop('color')).toEqual('red');
|
||||
expect(wrapper.text()).toEqual('Failed');
|
||||
});
|
||||
|
||||
test('should render error', () => {
|
||||
const wrapper = mount(<StatusLabel status="error" />);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.find('ExclamationCircleIcon')).toHaveLength(1);
|
||||
expect(wrapper.find('Label').prop('color')).toEqual('red');
|
||||
expect(wrapper.text()).toEqual('Error');
|
||||
});
|
||||
|
||||
test('should render running', () => {
|
||||
const wrapper = mount(<StatusLabel status="running" />);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.find('SyncAltIcon')).toHaveLength(1);
|
||||
expect(wrapper.find('Label').prop('color')).toEqual('blue');
|
||||
expect(wrapper.text()).toEqual('Running');
|
||||
});
|
||||
|
||||
test('should render pending', () => {
|
||||
const wrapper = mount(<StatusLabel status="pending" />);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.find('ClockIcon')).toHaveLength(1);
|
||||
expect(wrapper.find('Label').prop('color')).toEqual('blue');
|
||||
expect(wrapper.text()).toEqual('Pending');
|
||||
});
|
||||
|
||||
test('should render waiting', () => {
|
||||
const wrapper = mount(<StatusLabel status="waiting" />);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.find('ClockIcon')).toHaveLength(1);
|
||||
expect(wrapper.find('Label').prop('color')).toEqual('grey');
|
||||
expect(wrapper.text()).toEqual('Waiting');
|
||||
});
|
||||
|
||||
test('should render canceled', () => {
|
||||
const wrapper = mount(<StatusLabel status="canceled" />);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.find('ExclamationTriangleIcon')).toHaveLength(1);
|
||||
expect(wrapper.find('Label').prop('color')).toEqual('orange');
|
||||
expect(wrapper.text()).toEqual('Canceled');
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/components/StatusLabel/index.js
Normal file
1
awx/ui_next/src/components/StatusLabel/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './StatusLabel';
|
||||
@ -96,6 +96,7 @@ function UserAndTeamAccessAdd({
|
||||
displayKey="name"
|
||||
onRowClick={handleResourceSelect}
|
||||
fetchItems={selectedResourceType.fetchItems}
|
||||
fetchOptions={selectedResourceType.fetchOptions}
|
||||
selectedLabel={i18n._(t`Selected`)}
|
||||
selectedResourceRows={resourcesSelected}
|
||||
sortedColumnKey="username"
|
||||
|
||||
@ -43,6 +43,15 @@ describe('<UserAndTeamAccessAdd/>', () => {
|
||||
count: 1,
|
||||
},
|
||||
};
|
||||
const options = {
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
related_search_fields: [],
|
||||
},
|
||||
};
|
||||
let wrapper;
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
@ -111,11 +120,13 @@ describe('<UserAndTeamAccessAdd/>', () => {
|
||||
|
||||
test('should call api to associate role', async () => {
|
||||
JobTemplatesAPI.read.mockResolvedValue(resources);
|
||||
JobTemplatesAPI.readOptions.mockResolvedValue(options);
|
||||
UsersAPI.associateRole.mockResolvedValue({});
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({
|
||||
fetchItems: JobTemplatesAPI.read,
|
||||
fetchOptions: JobTemplatesAPI.readOptions,
|
||||
label: 'Job template',
|
||||
selectedResource: 'jobTemplate',
|
||||
searchColumns: [
|
||||
@ -169,6 +180,7 @@ describe('<UserAndTeamAccessAdd/>', () => {
|
||||
|
||||
test('should throw error', async () => {
|
||||
JobTemplatesAPI.read.mockResolvedValue(resources);
|
||||
JobTemplatesAPI.readOptions.mockResolvedValue(options);
|
||||
UsersAPI.associateRole.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
@ -192,6 +204,7 @@ describe('<UserAndTeamAccessAdd/>', () => {
|
||||
await act(async () =>
|
||||
wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({
|
||||
fetchItems: JobTemplatesAPI.read,
|
||||
fetchOptions: JobTemplatesAPI.readOptions,
|
||||
label: 'Job template',
|
||||
selectedResource: 'jobTemplate',
|
||||
searchColumns: [
|
||||
|
||||
@ -39,6 +39,7 @@ export default function getResourceAccessConfig(i18n) {
|
||||
},
|
||||
],
|
||||
fetchItems: queryParams => JobTemplatesAPI.read(queryParams),
|
||||
fetchOptions: () => JobTemplatesAPI.readOptions(),
|
||||
},
|
||||
{
|
||||
selectedResource: 'workflowJobTemplate',
|
||||
@ -69,6 +70,7 @@ export default function getResourceAccessConfig(i18n) {
|
||||
},
|
||||
],
|
||||
fetchItems: queryParams => WorkflowJobTemplatesAPI.read(queryParams),
|
||||
fetchOptions: () => WorkflowJobTemplatesAPI.readOptions(),
|
||||
},
|
||||
{
|
||||
selectedResource: 'credential',
|
||||
@ -87,6 +89,7 @@ export default function getResourceAccessConfig(i18n) {
|
||||
[`git`, i18n._(t`Git`)],
|
||||
[`hg`, i18n._(t`Mercurial`)],
|
||||
[`svn`, i18n._(t`Subversion`)],
|
||||
[`archive`, i18n._(t`Remote Archive`)],
|
||||
[`insights`, i18n._(t`Red Hat Insights`)],
|
||||
],
|
||||
},
|
||||
@ -110,6 +113,7 @@ export default function getResourceAccessConfig(i18n) {
|
||||
},
|
||||
],
|
||||
fetchItems: queryParams => CredentialsAPI.read(queryParams),
|
||||
fetchOptions: () => CredentialsAPI.readOptions(),
|
||||
},
|
||||
{
|
||||
selectedResource: 'inventory',
|
||||
@ -136,6 +140,7 @@ export default function getResourceAccessConfig(i18n) {
|
||||
},
|
||||
],
|
||||
fetchItems: queryParams => InventoriesAPI.read(queryParams),
|
||||
fetchOptions: () => InventoriesAPI.readOptions(),
|
||||
},
|
||||
{
|
||||
selectedResource: 'project',
|
||||
@ -154,6 +159,7 @@ export default function getResourceAccessConfig(i18n) {
|
||||
[`git`, i18n._(t`Git`)],
|
||||
[`hg`, i18n._(t`Mercurial`)],
|
||||
[`svn`, i18n._(t`Subversion`)],
|
||||
[`archive`, i18n._(t`Remote Archive`)],
|
||||
[`insights`, i18n._(t`Red Hat Insights`)],
|
||||
],
|
||||
},
|
||||
@ -177,6 +183,7 @@ export default function getResourceAccessConfig(i18n) {
|
||||
},
|
||||
],
|
||||
fetchItems: queryParams => ProjectsAPI.read(queryParams),
|
||||
fetchOptions: () => ProjectsAPI.readOptions(),
|
||||
},
|
||||
{
|
||||
selectedResource: 'organization',
|
||||
@ -203,6 +210,7 @@ export default function getResourceAccessConfig(i18n) {
|
||||
},
|
||||
],
|
||||
fetchItems: queryParams => OrganizationsAPI.read(queryParams),
|
||||
fetchOptions: () => OrganizationsAPI.readOptions(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
8
awx/ui_next/src/contexts/Kebabified.jsx
Normal file
8
awx/ui_next/src/contexts/Kebabified.jsx
Normal file
@ -0,0 +1,8 @@
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const KebabifiedContext = React.createContext({});
|
||||
|
||||
export const KebabifiedProvider = KebabifiedContext.Provider;
|
||||
export const Kebabified = KebabifiedContext.Consumer;
|
||||
export const useKebabifiedMenu = () => useContext(KebabifiedContext);
|
||||
@ -26,7 +26,7 @@ function ApplicationTokenList({ i18n }) {
|
||||
const {
|
||||
error,
|
||||
isLoading,
|
||||
result: { tokens, itemCount, actions, relatedSearchFields },
|
||||
result: { tokens, itemCount, relatedSearchableKeys, searchableKeys },
|
||||
request: fetchTokens,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
@ -52,13 +52,15 @@ function ApplicationTokenList({ i18n }) {
|
||||
return {
|
||||
tokens: modifiedResults,
|
||||
itemCount: count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [id, location.search]),
|
||||
{ tokens: [], itemCount: 0, actions: {}, relatedSearchFields: [] }
|
||||
{ tokens: [], itemCount: 0, relatedSearchableKeys: [], searchableKeys: [] }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -91,11 +93,6 @@ function ApplicationTokenList({ i18n }) {
|
||||
setSelected([]);
|
||||
};
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaginatedDataList
|
||||
|
||||
@ -32,7 +32,13 @@ function ApplicationsList({ i18n }) {
|
||||
isLoading,
|
||||
error,
|
||||
request: fetchApplications,
|
||||
result: { applications, itemCount, actions, relatedSearchFields },
|
||||
result: {
|
||||
applications,
|
||||
itemCount,
|
||||
actions,
|
||||
relatedSearchableKeys,
|
||||
searchableKeys,
|
||||
},
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
@ -46,16 +52,20 @@ function ApplicationsList({ i18n }) {
|
||||
applications: response.data.results,
|
||||
itemCount: response.data.count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [location]),
|
||||
{
|
||||
applications: [],
|
||||
itemCount: 0,
|
||||
actions: {},
|
||||
relatedSearchFields: [],
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -89,10 +99,6 @@ function ApplicationsList({ i18n }) {
|
||||
};
|
||||
|
||||
const canAdd = actions && actions.POST;
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -268,7 +268,10 @@ describe('<CredentialEdit />', () => {
|
||||
|
||||
test('handleCancel returns the user to credential detail', async () => {
|
||||
await waitForElement(wrapper, 'isLoading', el => el.length === 0);
|
||||
wrapper.find('Button[aria-label="Cancel"]').simulate('click');
|
||||
await act(async () => {
|
||||
wrapper.find('Button[aria-label="Cancel"]').simulate('click');
|
||||
});
|
||||
wrapper.update();
|
||||
expect(history.location.pathname).toEqual('/credentials/3/details');
|
||||
});
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@ function CredentialsStep({ i18n }) {
|
||||
const history = useHistory();
|
||||
|
||||
const {
|
||||
result: { credentials, count, actions, relatedSearchFields },
|
||||
result: { credentials, count, relatedSearchableKeys, searchableKeys },
|
||||
error: credentialsError,
|
||||
isLoading: isCredentialsLoading,
|
||||
request: fetchCredentials,
|
||||
@ -39,24 +39,21 @@ function CredentialsStep({ i18n }) {
|
||||
return {
|
||||
credentials: data.results,
|
||||
count: data.count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [history.location.search]),
|
||||
{ credentials: [], count: 0, actions: {}, relatedSearchFields: [] }
|
||||
{ credentials: [], count: 0, relatedSearchableKeys: [], searchableKeys: [] }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredentials();
|
||||
}, [fetchCredentials]);
|
||||
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
if (credentialsError) {
|
||||
return <ContentError error={credentialsError} />;
|
||||
}
|
||||
|
||||
@ -33,7 +33,13 @@ function HostGroupsList({ i18n, host }) {
|
||||
const invId = host.summary_fields.inventory.id;
|
||||
|
||||
const {
|
||||
result: { groups, itemCount, actions, relatedSearchFields },
|
||||
result: {
|
||||
groups,
|
||||
itemCount,
|
||||
actions,
|
||||
relatedSearchableKeys,
|
||||
searchableKeys,
|
||||
},
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchGroups,
|
||||
@ -55,16 +61,20 @@ function HostGroupsList({ i18n, host }) {
|
||||
groups: results,
|
||||
itemCount: count,
|
||||
actions: actionsResponse.data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [hostId, search]),
|
||||
{
|
||||
groups: [],
|
||||
itemCount: 0,
|
||||
actions: {},
|
||||
relatedSearchFields: [],
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -108,6 +118,11 @@ function HostGroupsList({ i18n, host }) {
|
||||
[invId, hostId]
|
||||
);
|
||||
|
||||
const fetchGroupsOptions = useCallback(
|
||||
() => InventoriesAPI.readGroupsOptions(invId),
|
||||
[invId]
|
||||
);
|
||||
|
||||
const { request: handleAssociate, error: associateError } = useRequest(
|
||||
useCallback(
|
||||
async groupsToAssociate => {
|
||||
@ -128,10 +143,6 @@ function HostGroupsList({ i18n, host }) {
|
||||
|
||||
const canAdd =
|
||||
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -218,6 +229,7 @@ function HostGroupsList({ i18n, host }) {
|
||||
<AssociateModal
|
||||
header={i18n._(t`Groups`)}
|
||||
fetchRequest={fetchGroupsToAssociate}
|
||||
optionsRequest={fetchGroupsOptions}
|
||||
isModalOpen={isModalOpen}
|
||||
onAssociate={handleAssociate}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
|
||||
@ -207,6 +207,15 @@ describe('<HostGroupsList />', () => {
|
||||
results: [{ id: 123, name: 'foo', url: '/api/v2/groups/123/' }],
|
||||
},
|
||||
});
|
||||
InventoriesAPI.readGroupsOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
related_search_fields: [],
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.find('ToolbarAddButton').simulate('click');
|
||||
});
|
||||
|
||||
@ -29,7 +29,7 @@ function HostList({ i18n }) {
|
||||
const [selected, setSelected] = useState([]);
|
||||
|
||||
const {
|
||||
result: { hosts, count, actions, relatedSearchFields },
|
||||
result: { hosts, count, actions, relatedSearchableKeys, searchableKeys },
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchHosts,
|
||||
@ -44,16 +44,20 @@ function HostList({ i18n }) {
|
||||
hosts: results[0].data.results,
|
||||
count: results[0].data.count,
|
||||
actions: results[1].data.actions,
|
||||
relatedSearchFields: (
|
||||
relatedSearchableKeys: (
|
||||
results[1]?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(results[1].data.actions?.GET || {}).filter(
|
||||
key => results[1].data.actions?.GET[key].filterable
|
||||
),
|
||||
};
|
||||
}, [location]),
|
||||
{
|
||||
hosts: [],
|
||||
count: 0,
|
||||
actions: {},
|
||||
relatedSearchFields: [],
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -97,10 +101,6 @@ function HostList({ i18n }) {
|
||||
|
||||
const canAdd =
|
||||
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
||||
const relatedSearchableKeys = relatedSearchFields || [];
|
||||
const searchableKeys = Object.keys(actions?.GET || {}).filter(
|
||||
key => actions.GET[key].filterable
|
||||
);
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
|
||||
@ -54,11 +54,7 @@ function HostListItem({ i18n, host, isSelected, onSelect, detailUrl }) {
|
||||
<Fragment>
|
||||
<b css="margin-right: 24px">{i18n._(t`Inventory`)}</b>
|
||||
<Link
|
||||
to={`/inventories/${
|
||||
host.summary_fields.inventory.kind === 'smart'
|
||||
? 'smart_inventory'
|
||||
: 'inventory'
|
||||
}/${host.summary_fields.inventory.id}/details`}
|
||||
to={`/inventories/inventory/${host.summary_fields.inventory.id}/details`}
|
||||
>
|
||||
{host.summary_fields.inventory.name}
|
||||
</Link>
|
||||
|
||||
@ -17,10 +17,10 @@ import { InstanceGroupsAPI } from '../../api';
|
||||
import RoutedTabs from '../../components/RoutedTabs';
|
||||
import ContentError from '../../components/ContentError';
|
||||
import ContentLoading from '../../components/ContentLoading';
|
||||
import JobList from '../../components/JobList';
|
||||
|
||||
import InstanceGroupDetails from './InstanceGroupDetails';
|
||||
import InstanceGroupEdit from './InstanceGroupEdit';
|
||||
import Jobs from './Jobs';
|
||||
import Instances from './Instances';
|
||||
|
||||
function InstanceGroup({ i18n, setBreadcrumb }) {
|
||||
@ -126,7 +126,10 @@ function InstanceGroup({ i18n, setBreadcrumb }) {
|
||||
<Instances />
|
||||
</Route>
|
||||
<Route path="/instance_groups/:id/jobs">
|
||||
<Jobs />
|
||||
<JobList
|
||||
showTypeColumn
|
||||
defaultParams={{ instance_group: instanceGroup.id }}
|
||||
/>
|
||||
</Route>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -2,8 +2,8 @@ import React, { useCallback } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import 'styled-components/macro';
|
||||
import styled from 'styled-components';
|
||||
import { Button, Label } from '@patternfly/react-core';
|
||||
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import { CardBody, CardActionsRow } from '../../../components/Card';
|
||||
@ -17,6 +17,10 @@ import {
|
||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||
import { InstanceGroupsAPI } from '../../../api';
|
||||
|
||||
const Unavailable = styled.span`
|
||||
color: var(--pf-global--danger-color--200);
|
||||
`;
|
||||
|
||||
function InstanceGroupDetails({ instanceGroup, i18n }) {
|
||||
const { id, name } = instanceGroup;
|
||||
|
||||
@ -42,12 +46,28 @@ function InstanceGroupDetails({ instanceGroup, i18n }) {
|
||||
);
|
||||
};
|
||||
|
||||
const verifyIsIsolated = item => {
|
||||
if (item.is_isolated) {
|
||||
return (
|
||||
<>
|
||||
{item.name}
|
||||
<span css="margin-left: 12px">
|
||||
<Label aria-label={i18n._(t`isolated instance`)}>
|
||||
{i18n._(t`Isolated`)}
|
||||
</Label>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <>{item.name}</>;
|
||||
};
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
<DetailList>
|
||||
<Detail
|
||||
label={i18n._(t`Name`)}
|
||||
value={name}
|
||||
value={verifyIsIsolated(instanceGroup)}
|
||||
dataCy="instance-group-detail-name"
|
||||
/>
|
||||
<Detail
|
||||
@ -78,7 +98,7 @@ function InstanceGroupDetails({ instanceGroup, i18n }) {
|
||||
) : (
|
||||
<Detail
|
||||
label={i18n._(t`Used capacity`)}
|
||||
value={<span css="color: red">{i18n._(t`Unavailable`)}</span>}
|
||||
value={<Unavailable>{i18n._(t`Unavailable`)}</Unavailable>}
|
||||
dataCy="instance-group-used-capacity"
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -21,6 +21,7 @@ const instanceGroups = [
|
||||
policy_instance_percentage: 50,
|
||||
percent_capacity_remaining: 60,
|
||||
is_containerized: false,
|
||||
is_isolated: false,
|
||||
created: '2020-07-21T18:41:02.818081Z',
|
||||
modified: '2020-07-24T20:32:03.121079Z',
|
||||
summary_fields: {
|
||||
@ -40,6 +41,7 @@ const instanceGroups = [
|
||||
policy_instance_percentage: 0,
|
||||
percent_capacity_remaining: 0,
|
||||
is_containerized: true,
|
||||
is_isolated: false,
|
||||
created: '2020-07-21T18:41:02.818081Z',
|
||||
modified: '2020-07-24T20:32:03.121079Z',
|
||||
summary_fields: {
|
||||
@ -67,7 +69,11 @@ describe('<InstanceGroupDetails/>', () => {
|
||||
});
|
||||
|
||||
wrapper.update();
|
||||
expectDetailToMatch(wrapper, 'Name', instanceGroups[0].name);
|
||||
|
||||
expect(wrapper.find('Detail[label="Name"]').text()).toEqual(
|
||||
expect.stringContaining(instanceGroups[0].name)
|
||||
);
|
||||
expect(wrapper.find('Detail[label="Name"]')).toHaveLength(1);
|
||||
expectDetailToMatch(wrapper, 'Type', `Instance group`);
|
||||
const dates = wrapper.find('UserDateDetail');
|
||||
expect(dates).toHaveLength(2);
|
||||
@ -144,4 +150,18 @@ describe('<InstanceGroupDetails/>', () => {
|
||||
|
||||
expect(wrapper.find('Button[aria-label="Edit"]').length).toBe(0);
|
||||
});
|
||||
|
||||
test('should display isolated label', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InstanceGroupDetails
|
||||
instanceGroup={{ ...instanceGroups[0], is_isolated: true }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('Label[aria-label="isolated instance"]').prop('children')
|
||||
).toEqual('Isolated');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,13 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { CardBody } from '../../../components/Card';
|
||||
import { InstanceGroupsAPI } from '../../../api';
|
||||
import InstanceGroupForm from '../shared/InstanceGroupForm';
|
||||
|
||||
function InstanceGroupEdit({ instanceGroup }) {
|
||||
const history = useHistory();
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
const detailsUrl = `/instance_groups/${instanceGroup.id}/details`;
|
||||
|
||||
const handleSubmit = async values => {
|
||||
try {
|
||||
await InstanceGroupsAPI.update(instanceGroup.id, values);
|
||||
history.push(detailsUrl);
|
||||
} catch (error) {
|
||||
setSubmitError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push(detailsUrl);
|
||||
};
|
||||
|
||||
function InstanceGroupEdit() {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<div>Edit instance group</div>
|
||||
</Card>
|
||||
</PageSection>
|
||||
<CardBody>
|
||||
<InstanceGroupForm
|
||||
instanceGroup={instanceGroup}
|
||||
onSubmit={handleSubmit}
|
||||
submitError={submitError}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { InstanceGroupsAPI } from '../../../api';
|
||||
|
||||
import InstanceGroupEdit from './InstanceGroupEdit';
|
||||
|
||||
jest.mock('../../../api');
|
||||
|
||||
const instanceGroupData = {
|
||||
id: 42,
|
||||
type: 'instance_group',
|
||||
url: '/api/v2/instance_groups/42/',
|
||||
related: {
|
||||
jobs: '/api/v2/instance_groups/42/jobs/',
|
||||
instances: '/api/v2/instance_groups/7/instances/',
|
||||
},
|
||||
name: 'Foo',
|
||||
created: '2020-07-21T18:41:02.818081Z',
|
||||
modified: '2020-07-24T20:32:03.121079Z',
|
||||
capacity: 24,
|
||||
committed_capacity: 0,
|
||||
consumed_capacity: 0,
|
||||
percent_capacity_remaining: 100.0,
|
||||
jobs_running: 0,
|
||||
jobs_total: 0,
|
||||
instances: 1,
|
||||
controller: null,
|
||||
is_controller: false,
|
||||
is_isolated: false,
|
||||
is_containerized: false,
|
||||
credential: null,
|
||||
policy_instance_percentage: 46,
|
||||
policy_instance_minimum: 12,
|
||||
policy_instance_list: [],
|
||||
pod_spec_override: '',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
delete: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updatedInstanceGroup = {
|
||||
name: 'Bar',
|
||||
policy_instance_percentage: 42,
|
||||
};
|
||||
|
||||
describe('<InstanceGroupEdit>', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
|
||||
beforeAll(async () => {
|
||||
history = createMemoryHistory();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InstanceGroupEdit instanceGroup={instanceGroupData} />,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('tower instance group name can not be updated', async () => {
|
||||
let towerWrapper;
|
||||
await act(async () => {
|
||||
towerWrapper = mountWithContexts(
|
||||
<InstanceGroupEdit
|
||||
instanceGroup={{ ...instanceGroupData, name: 'tower' }}
|
||||
/>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
});
|
||||
expect(
|
||||
towerWrapper.find('input#instance-group-name').prop('disabled')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
towerWrapper.find('input#instance-group-name').prop('value')
|
||||
).toEqual('tower');
|
||||
});
|
||||
|
||||
test('handleSubmit should call the api and redirect to details page', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('InstanceGroupForm').invoke('onSubmit')(
|
||||
updatedInstanceGroup
|
||||
);
|
||||
});
|
||||
expect(InstanceGroupsAPI.update).toHaveBeenCalledWith(
|
||||
42,
|
||||
updatedInstanceGroup
|
||||
);
|
||||
});
|
||||
|
||||
test('should navigate to instance group details when cancel is clicked', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
||||
});
|
||||
expect(history.location.pathname).toEqual('/instance_groups/42/details');
|
||||
});
|
||||
|
||||
test('should navigate to instance group details after successful submission', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('InstanceGroupForm').invoke('onSubmit')(
|
||||
updatedInstanceGroup
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('FormSubmitError').length).toBe(0);
|
||||
expect(history.location.pathname).toEqual('/instance_groups/42/details');
|
||||
});
|
||||
|
||||
test('failed form submission should show an error message', async () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: { detail: 'An error occurred' },
|
||||
},
|
||||
};
|
||||
InstanceGroupsAPI.update.mockImplementationOnce(() =>
|
||||
Promise.reject(error)
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper.find('InstanceGroupForm').invoke('onSubmit')(
|
||||
updatedInstanceGroup
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('FormSubmitError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user