Merge pull request #3 from ansible/devel

rebase to devel
This commit is contained in:
Sean Sullivan 2020-08-26 20:53:39 -05:00 committed by GitHub
commit 243c2cfe15
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
273 changed files with 7967 additions and 2938 deletions

View File

@ -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

View File

@ -1 +1 @@
14.0.0
14.1.0

View File

@ -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):

View File

@ -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.')

View File

@ -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)

View File

@ -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):

View File

@ -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.

View File

@ -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:

View File

@ -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")

View 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'),
),
]

View File

@ -55,6 +55,7 @@ class ProjectOptions(models.Model):
('hg', _('Mercurial')),
('svn', _('Subversion')),
('insights', _('Red Hat Insights')),
('archive', _('Remote Archive')),
]
class Meta:

View File

@ -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

View File

@ -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,

View File

@ -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()

View File

@ -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'),
]
}

View File

@ -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''

View 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

View 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

View 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 }}"
"""

View File

@ -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

View File

@ -1,4 +1,7 @@
---
- name: Mercurial support is deprecated.
hg_deprecation:
- name: update project using hg
hg:
dest: "{{project_path|quote}}"

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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
View File

@ -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"
}

View File

@ -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",

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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,

View File

@ -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,

View File

@ -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);
}

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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/`);
}

View File

@ -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/`);
}

View File

@ -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;

View File

@ -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);

View File

@ -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}
/>

View File

@ -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)}

View File

@ -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={[]}
/>
);

View File

@ -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>

View File

@ -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
/>
);

View File

@ -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,
};

View File

@ -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">

View File

@ -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 */

View File

@ -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 = {

View 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;

View File

@ -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
*/

View File

@ -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}

View File

@ -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`)}
/>

View File

@ -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');

View File

@ -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>

View File

@ -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 && (

View File

@ -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 />;
}

View File

@ -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 = {};

View File

@ -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

View File

@ -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 (

View File

@ -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}

View File

@ -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

View File

@ -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"

View File

@ -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 })}

View File

@ -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`)],
],
},

View File

@ -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);

View File

@ -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);

View File

@ -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}

View File

@ -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

View File

@ -49,7 +49,6 @@ function OptionsList({
<SelectedList
label={i18n._(t`Selected`)}
selected={value}
showOverflowAfter={5}
onRemove={item => deselectItem(item)}
isReadOnly={readOnly}
renderItemChip={renderItemChip}

View File

@ -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">

View File

@ -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>
);
}
}

View File

@ -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}

View File

@ -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 () => {

View File

@ -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;

View File

@ -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>

View File

@ -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

View File

@ -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 (
<>

View File

@ -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`));

View File

@ -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 = {

View File

@ -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 } } }

View 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,
};

View 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');
});
});

View File

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

View File

@ -96,6 +96,7 @@ function UserAndTeamAccessAdd({
displayKey="name"
onRowClick={handleResourceSelect}
fetchItems={selectedResourceType.fetchItems}
fetchOptions={selectedResourceType.fetchOptions}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={resourcesSelected}
sortedColumnKey="username"

View File

@ -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: [

View File

@ -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(),
},
];
}

View 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);

View File

@ -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

View File

@ -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 (
<>

View File

@ -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');
});

View File

@ -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} />;
}

View File

@ -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)}

View File

@ -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');
});

View File

@ -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>

View File

@ -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>

View File

@ -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>
</>
)}

View File

@ -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"
/>
)}

View File

@ -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');
});
});

View File

@ -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>
);
}

View File

@ -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