diff options
525 files changed, 15879 insertions, 4024 deletions
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 7a7c6954c8..384b1dc78b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -3,6 +3,12 @@ name: "\U0001F41B Bug report" about: Create a report to help us improve --- +<!-- Issues are for **concrete, actionable bugs and feature requests** only - if you're just asking for debugging help or technical support, please use: + +- http://webchat.freenode.net/?channels=ansible-awx +- https://groups.google.com/forum/#!forum/awx-project + +We have to limit this because of limited volunteer time to respond to issues! --> ##### ISSUE TYPE - Bug Report diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 097706e6d8..98fe2f5869 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -3,6 +3,12 @@ name: "✨ Feature request" about: Suggest an idea for this project --- +<!-- Issues are for **concrete, actionable bugs and feature requests** only - if you're just asking for debugging help or technical support, please use: + +- http://webchat.freenode.net/?channels=ansible-awx +- https://groups.google.com/forum/#!forum/awx-project + +We have to limit this because of limited volunteer time to respond to issues! --> ##### ISSUE TYPE - Feature Idea diff --git a/CHANGELOG.md b/CHANGELOG.md index aaabcfd2e9..b316f49d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ 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 (TBD) +- 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 +- 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 + +## 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 + * https://www.redhat.com/en/blog/making-open-source-more-inclusive-eradicating-problematic-language +- Installing roles and collections via requirements.yml as part of Project Updates now requires at least Ansible 2.9 - https://github.com/ansible/awx/issues/7769 +- Deprecated the use of the `PRIMARY_GALAXY_USERNAME` and `PRIMARY_GALAXY_PASSWORD` settings. We recommend using tokens to access Galaxy or Automation Hub. +- Added local caching for downloaded roles and collections so they are not re-downloaded on nodes where they are up to date with the project - https://github.com/ansible/awx/issues/5518 +- Added the ability to associate K8S/OpenShift credentials to Job Template for playbook interaction with the `community.kubernetes` collection - https://github.com/ansible/awx/issues/5735 +- Added the ability to include HTML in the Custom Login Info presented on the login page - https://github.com/ansible/awx/issues/7600 +- Fixed https://access.redhat.com/security/cve/cve-2020-14327 - Server-side request forgery on credentials +- Fixed https://access.redhat.com/security/cve/cve-2020-14328 - Server-side request forgery on webhooks +- Fixed https://access.redhat.com/security/cve/cve-2020-14329 - Sensitive data exposure on labels +- Fixed https://access.redhat.com/security/cve/cve-2020-14337 - Named URLs allow for testing the presence or absence of objects +- Fixed a number of bugs in the user interface related to an upgrade of jQuery: + * https://github.com/ansible/awx/issues/7530 + * https://github.com/ansible/awx/issues/7546 + * https://github.com/ansible/awx/issues/7534 + * https://github.com/ansible/awx/issues/7606 +- Fixed a bug that caused the `-f yaml` flag of the AWX CLI to not print properly formatted YAML - https://github.com/ansible/awx/issues/7795 +- Fixed a bug in the installer that caused errors when `docker_registry_password` was set - https://github.com/ansible/awx/issues/7695 +- Fixed a permissions error that prevented certain users from starting AWX services - https://github.com/ansible/awx/issues/7545 +- Fixed a bug that allows superusers to run unsafe Jinja code when defining custom Credential Types - https://github.com/ansible/awx/pull/7584/ +- Fixed a bug that prevented users from creating (or editing) custom Credential Types containing boolean fields - https://github.com/ansible/awx/issues/7483 +- Fixed a bug that prevented users with postgres usernames containing uppercase letters from restoring backups succesfully - https://github.com/ansible/awx/pull/7519 +- Fixed a bug which allowed the creation (in the Tower API) of Groups and Hosts with the same name - https://github.com/ansible/awx/issues/4680 + ## 13.0.0 (Jun 23, 2020) - Added import and export commands to the official AWX CLI, replacing send and receive from the old tower-cli (https://github.com/ansible/awx/pull/6125). - Removed scripts as a means of running inventory updates of built-in types (https://github.com/ansible/awx/pull/6911) @@ -15,7 +49,7 @@ This is a list of high-level changes for each release of AWX. A full list of com - Moved to a single container image build instead of separate awx_web and awx_task images. The container image is just `awx` (https://github.com/ansible/awx/pull/7228) - Official AWX container image builds now use a two-stage container build process that notably reduces the size of our published images (https://github.com/ansible/awx/pull/7017) - Removed support for HipChat notifications ([EoL announcement](https://www.atlassian.com/partnerships/slack/faq#faq-98b17ca3-247f-423b-9a78-70a91681eff0)); all previously-created HipChat notification templates will be deleted due to this removal. -- Fixed a bug which broke AWX installations with oc version 4.3 (https://github.com/ansible/awx/pull/6948/files) +- Fixed a bug which broke AWX installations with oc version 4.3 (https://github.com/ansible/awx/pull/6948/) - Fixed a performance issue that caused notable delay of stdout processing for playbooks run against large numbers of hosts (https://github.com/ansible/awx/issues/6991) - Fixed a bug that caused CyberArk AIM credential plugin looks to hang forever in some environments (https://github.com/ansible/awx/issues/6986) - Fixed a bug that caused ANY/ALL converage settings not to properly save when editing approval nodes in the UI (https://github.com/ansible/awx/issues/6998) diff --git a/INSTALL.md b/INSTALL.md index db7e71ba0a..51a4d12acf 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -43,7 +43,7 @@ This document provides a guide for installing AWX. - [Installing the AWX CLI](#installing-the-awx-cli) * [Building the CLI Documentation](#building-the-cli-documentation) - + ## Getting started ### Clone the repo @@ -351,7 +351,7 @@ Once you access the AWX server, you will be prompted with a login dialog. The de A Kubernetes deployment will require you to have access to a Kubernetes cluster as well as the following tools: - [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) -- [helm](https://docs.helm.sh/using_helm/#quickstart-guide) +- [helm](https://helm.sh/docs/intro/quickstart/) The installation program will reference `kubectl` directly. `helm` is only necessary if you are letting the installer configure PostgreSQL for you. @@ -382,9 +382,11 @@ Before starting the install process, review the [inventory](./installer/inventor ### Configuring Helm -If you want the AWX installer to manage creating the database pod (rather than installing and configuring postgres on your own). Then you will need to have a working `helm` installation, you can find details here: [https://docs.helm.sh/using_helm/#quickstart-guide](https://docs.helm.sh/using_helm/#quickstart-guide). +If you want the AWX installer to manage creating the database pod (rather than installing and configuring postgres on your own). Then you will need to have a working `helm` installation, you can find details here: [https://helm.sh/docs/intro/quickstart/](https://helm.sh/docs/intro/quickstart/). + +You do not need to create a [Persistent Volume Claim](https://docs.openshift.org/latest/dev_guide/persistent_volumes.html) as Helm does it for you. However, an existing one may be used by setting the `pg_persistence_existingclaim` variable. -Newer Kubernetes clusters with RBAC enabled will need to make sure a service account is created, make sure to follow the instructions here [https://docs.helm.sh/using_helm/#role-based-access-control](https://docs.helm.sh/using_helm/#role-based-access-control) +Newer Kubernetes clusters with RBAC enabled will need to make sure a service account is created, make sure to follow the instructions here [https://helm.sh/docs/topics/rbac/](https://helm.sh/docs/topics/rbac/) ### Run the installer @@ -369,7 +369,7 @@ test: PYTHONDONTWRITEBYTECODE=1 py.test -p no:cacheprovider -n auto $(TEST_DIRS) cmp VERSION awxkit/VERSION || "VERSION and awxkit/VERSION *must* match" cd awxkit && $(VENV_BASE)/awx/bin/tox -re py3 - awx-manage check_migrations --dry-run --check -n 'vNNN_missing_migration_file' + awx-manage check_migrations --dry-run --check -n 'missing_migration_file' COLLECTION_TEST_DIRS ?= awx_collection/test/awx COLLECTION_TEST_TARGET ?= @@ -1 +1 @@ -13.0.0 +14.0.0 diff --git a/awx/api/filters.py b/awx/api/filters.py index d7b6c5dd0b..6d51441c28 100644 --- a/awx/api/filters.py +++ b/awx/api/filters.py @@ -257,6 +257,11 @@ class FieldLookupBackend(BaseFilterBackend): if key in self.RESERVED_NAMES: continue + # HACK: make `created` available via API for the Django User ORM model + # so it keep compatiblity with other objects which exposes the `created` attr. + if queryset.model._meta.object_name == 'User' and key.startswith('created'): + key = key.replace('created', 'date_joined') + # HACK: Make job event filtering by host name mostly work even # when not capturing job event hosts M2M. if queryset.model._meta.object_name == 'JobEvent' and key.startswith('hosts__name'): diff --git a/awx/api/generics.py b/awx/api/generics.py index c7e68bfc49..fce5bb9b49 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -51,6 +51,7 @@ from awx.main.utils import ( StubLicense ) from awx.main.utils.db import get_all_field_names +from awx.main.views import ApiErrorView from awx.api.serializers import ResourceAccessListElementSerializer, CopySerializer, UserSerializer from awx.api.versioning import URLPathVersioning from awx.api.metadata import SublistAttachDetatchMetadata, Metadata @@ -188,6 +189,29 @@ class APIView(views.APIView): ''' Log warning for 400 requests. Add header with elapsed time. ''' + + # + # If the URL was rewritten, and we get a 404, we should entirely + # replace the view in the request context with an ApiErrorView() + # Without this change, there will be subtle differences in the BrowseableAPIRenderer + # + # These differences could provide contextual clues which would allow + # anonymous users to determine if usernames were valid or not + # (e.g., if an anonymous user visited `/api/v2/users/valid/`, and got a 404, + # but also saw that the page heading said "User Detail", they might notice + # that's a difference in behavior from a request to `/api/v2/users/not-valid/`, which + # would show a page header of "Not Found"). Changing the view here + # guarantees that the rendered response will look exactly like the response + # when you visit a URL that has no matching URL paths in `awx.api.urls`. + # + if response.status_code == 404 and 'awx.named_url_rewritten' in request.environ: + self.headers.pop('Allow', None) + response = super(APIView, self).finalize_response(request, response, *args, **kwargs) + view = ApiErrorView() + setattr(view, 'request', request) + response.renderer_context['view'] = view + return response + if response.status_code >= 400: status_msg = "status %s received by user %s attempting to access %s from %s" % \ (response.status_code, request.user, request.path, request.META.get('REMOTE_ADDR', None)) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 34924e30ea..be6a9d640b 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1697,6 +1697,7 @@ class HostSerializer(BaseSerializerWithVariables): d.setdefault('recent_jobs', [{ 'id': j.job.id, 'name': j.job.job_template.name if j.job.job_template is not None else "", + 'type': j.job.job_type_name, 'status': j.job.status, 'finished': j.job.finished, } for j in obj.job_host_summaries.select_related('job__job_template').order_by('-created')[:5]]) @@ -2841,7 +2842,7 @@ class JobTemplateMixin(object): return [{ 'id': x.id, 'status': x.status, 'finished': x.finished, 'canceled_on': x.canceled_on, # Make type consistent with API top-level key, for instance workflow_job - 'type': x.get_real_instance_class()._meta.verbose_name.replace(' ', '_') + 'type': x.job_type_name } for x in optimized_qs[:10]] def get_summary_fields(self, obj): @@ -4099,7 +4100,8 @@ class JobLaunchSerializer(BaseSerializer): errors.setdefault('credentials', []).append(_( 'Cannot assign multiple {} credentials.' ).format(cred.unique_hash(display=True))) - if cred.credential_type.kind not in ('ssh', 'vault', 'cloud', 'net'): + if cred.credential_type.kind not in ('ssh', 'vault', 'cloud', + 'net', 'kubernetes'): errors.setdefault('credentials', []).append(_( 'Cannot assign a Credential of kind `{}`' ).format(cred.credential_type.kind)) @@ -4663,6 +4665,8 @@ class InstanceSerializer(BaseSerializer): class InstanceGroupSerializer(BaseSerializer): + show_capabilities = ['edit', 'delete'] + committed_capacity = serializers.SerializerMethodField() consumed_capacity = serializers.SerializerMethodField() percent_capacity_remaining = serializers.SerializerMethodField() diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index a950ff118f..f6378f5282 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -14,6 +14,8 @@ import time from base64 import b64encode from collections import OrderedDict +from urllib3.exceptions import ConnectTimeoutError + # Django from django.conf import settings @@ -171,6 +173,15 @@ def api_exception_handler(exc, context): exc = ParseError(exc.args[0]) if isinstance(context['view'], UnifiedJobStdout): context['view'].renderer_classes = [renderers.BrowsableAPIRenderer, JSONRenderer] + if isinstance(exc, APIException): + req = context['request']._request + if 'awx.named_url_rewritten' in req.environ and not str(getattr(exc, 'status_code', 0)).startswith('2'): + # if the URL was rewritten, and it's not a 2xx level status code, + # revert the request.path to its original value to avoid leaking + # any context about the existance of resources + req.path = req.environ['awx.named_url_rewritten'] + if exc.status_code == 403: + exc = NotFound(detail=_('Not found.')) return exception_handler(exc, context) @@ -1397,10 +1408,18 @@ class CredentialExternalTest(SubDetailAPIView): obj.credential_type.plugin.backend(**backend_kwargs) return Response({}, status=status.HTTP_202_ACCEPTED) except requests.exceptions.HTTPError as exc: - message = 'HTTP {}\n{}'.format(exc.response.status_code, exc.response.text) + message = 'HTTP {}'.format(exc.response.status_code) return Response({'inputs': message}, status=status.HTTP_400_BAD_REQUEST) except Exception as exc: - return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + message = exc.__class__.__name__ + args = getattr(exc, 'args', []) + for a in args: + if isinstance( + getattr(a, 'reason', None), + ConnectTimeoutError + ): + message = str(a.reason) + return Response({'inputs': message}, status=status.HTTP_400_BAD_REQUEST) class CredentialInputSourceDetail(RetrieveUpdateDestroyAPIView): @@ -1449,10 +1468,18 @@ class CredentialTypeExternalTest(SubDetailAPIView): obj.plugin.backend(**backend_kwargs) return Response({}, status=status.HTTP_202_ACCEPTED) except requests.exceptions.HTTPError as exc: - message = 'HTTP {}\n{}'.format(exc.response.status_code, exc.response.text) + message = 'HTTP {}'.format(exc.response.status_code) return Response({'inputs': message}, status=status.HTTP_400_BAD_REQUEST) except Exception as exc: - return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + message = exc.__class__.__name__ + args = getattr(exc, 'args', []) + for a in args: + if isinstance( + getattr(a, 'reason', None), + ConnectTimeoutError + ): + message = str(a.reason) + return Response({'inputs': message}, status=status.HTTP_400_BAD_REQUEST) class HostRelatedSearchMixin(object): @@ -2657,7 +2684,7 @@ class JobTemplateCredentialsList(SubListCreateAttachDetachAPIView): return {"error": _("Cannot assign multiple {credential_type} credentials.").format( credential_type=sub.unique_hash(display=True))} kind = sub.credential_type.kind - if kind not in ('ssh', 'vault', 'cloud', 'net'): + if kind not in ('ssh', 'vault', 'cloud', 'net', 'kubernetes'): return {'error': _('Cannot assign a Credential of kind `{}`.').format(kind)} return super(JobTemplateCredentialsList, self).is_valid_relation(parent, sub, created) diff --git a/awx/main/access.py b/awx/main/access.py index 72f25bc914..4f54be6e12 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2479,13 +2479,16 @@ class NotificationAccess(BaseAccess): class LabelAccess(BaseAccess): ''' - I can see/use a Label if I have permission to associated organization + I can see/use a Label if I have permission to associated organization, or to a JT that the label is on ''' model = Label prefetch_related = ('modified_by', 'created_by', 'organization',) def filtered_queryset(self): - return self.model.objects.all() + return self.model.objects.filter( + Q(organization__in=Organization.accessible_pk_qs(self.user, 'read_role')) | + Q(unifiedjobtemplate_labels__in=UnifiedJobTemplate.accessible_pk_qs(self.user, 'read_role')) + ) @check_superuser def can_add(self, data): diff --git a/awx/main/conf.py b/awx/main/conf.py index 5517a438f6..8d091894d6 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -458,7 +458,8 @@ register( required=False, allow_blank=True, label=_('Primary Galaxy Server Username'), - help_text=_('For using a galaxy server at higher precedence than the public Ansible Galaxy. ' + help_text=_('(This setting is deprecated and will be removed in a future release) ' + 'For using a galaxy server at higher precedence than the public Ansible Galaxy. ' 'The username to use for basic authentication against the Galaxy instance, ' 'this is mutually exclusive with PRIMARY_GALAXY_TOKEN.'), category=_('Jobs'), @@ -472,7 +473,8 @@ register( required=False, allow_blank=True, label=_('Primary Galaxy Server Password'), - help_text=_('For using a galaxy server at higher precedence than the public Ansible Galaxy. ' + help_text=_('(This setting is deprecated and will be removed in a future release) ' + 'For using a galaxy server at higher precedence than the public Ansible Galaxy. ' 'The password to use for basic authentication against the Galaxy instance, ' 'this is mutually exclusive with PRIMARY_GALAXY_TOKEN.'), category=_('Jobs'), diff --git a/awx/main/credential_plugins/aim.py b/awx/main/credential_plugins/aim.py index 23036efda1..7c99665bf0 100644 --- a/awx/main/credential_plugins/aim.py +++ b/awx/main/credential_plugins/aim.py @@ -1,4 +1,4 @@ -from .plugin import CredentialPlugin, CertFiles +from .plugin import CredentialPlugin, CertFiles, raise_for_status from urllib.parse import quote, urlencode, urljoin @@ -82,8 +82,9 @@ def aim_backend(**kwargs): timeout=30, cert=cert, verify=verify, + allow_redirects=False, ) - res.raise_for_status() + raise_for_status(res) return res.json()['Content'] diff --git a/awx/main/credential_plugins/conjur.py b/awx/main/credential_plugins/conjur.py index 718eebbc64..5cd87007fc 100644 --- a/awx/main/credential_plugins/conjur.py +++ b/awx/main/credential_plugins/conjur.py @@ -1,4 +1,4 @@ -from .plugin import CredentialPlugin, CertFiles +from .plugin import CredentialPlugin, CertFiles, raise_for_status import base64 from urllib.parse import urljoin, quote @@ -58,7 +58,8 @@ def conjur_backend(**kwargs): auth_kwargs = { 'headers': {'Content-Type': 'text/plain'}, - 'data': api_key + 'data': api_key, + 'allow_redirects': False, } with CertFiles(cacert) as cert: @@ -68,11 +69,12 @@ def conjur_backend(**kwargs): urljoin(url, '/'.join(['authn', account, username, 'authenticate'])), **auth_kwargs ) - resp.raise_for_status() + raise_for_status(resp) token = base64.b64encode(resp.content).decode('utf-8') lookup_kwargs = { 'headers': {'Authorization': 'Token token="{}"'.format(token)}, + 'allow_redirects': False, } # https://www.conjur.org/api.html#secrets-retrieve-a-secret-get @@ -88,7 +90,7 @@ def conjur_backend(**kwargs): with CertFiles(cacert) as cert: lookup_kwargs['verify'] = cert resp = requests.get(path, timeout=30, **lookup_kwargs) - resp.raise_for_status() + raise_for_status(resp) return resp.text diff --git a/awx/main/credential_plugins/hashivault.py b/awx/main/credential_plugins/hashivault.py index 6e033efb43..2406623231 100644 --- a/awx/main/credential_plugins/hashivault.py +++ b/awx/main/credential_plugins/hashivault.py @@ -3,7 +3,7 @@ import os import pathlib from urllib.parse import urljoin -from .plugin import CredentialPlugin, CertFiles +from .plugin import CredentialPlugin, CertFiles, raise_for_status import requests from django.utils.translation import ugettext_lazy as _ @@ -145,7 +145,10 @@ def kv_backend(**kwargs): cacert = kwargs.get('cacert', None) api_version = kwargs['api_version'] - request_kwargs = {'timeout': 30} + request_kwargs = { + 'timeout': 30, + 'allow_redirects': False, + } sess = requests.Session() sess.headers['Authorization'] = 'Bearer {}'.format(token) @@ -175,7 +178,7 @@ def kv_backend(**kwargs): with CertFiles(cacert) as cert: request_kwargs['verify'] = cert response = sess.get(request_url, **request_kwargs) - response.raise_for_status() + raise_for_status(response) json = response.json() if api_version == 'v2': @@ -198,7 +201,10 @@ def ssh_backend(**kwargs): role = kwargs['role'] cacert = kwargs.get('cacert', None) - request_kwargs = {'timeout': 30} + request_kwargs = { + 'timeout': 30, + 'allow_redirects': False, + } request_kwargs['json'] = {'public_key': kwargs['public_key']} if kwargs.get('valid_principals'): @@ -215,7 +221,7 @@ def ssh_backend(**kwargs): request_kwargs['verify'] = cert resp = sess.post(request_url, **request_kwargs) - resp.raise_for_status() + raise_for_status(resp) return resp.json()['data']['signed_key'] diff --git a/awx/main/credential_plugins/plugin.py b/awx/main/credential_plugins/plugin.py index def2676a02..fa5c770fd1 100644 --- a/awx/main/credential_plugins/plugin.py +++ b/awx/main/credential_plugins/plugin.py @@ -3,9 +3,19 @@ import tempfile from collections import namedtuple +from requests.exceptions import HTTPError + CredentialPlugin = namedtuple('CredentialPlugin', ['name', 'inputs', 'backend']) +def raise_for_status(resp): + resp.raise_for_status() + if resp.status_code >= 300: + exc = HTTPError() + setattr(exc, 'response', resp) + raise exc + + class CertFiles(): """ A context manager used for writing a certificate and (optional) key diff --git a/awx/main/dispatch/control.py b/awx/main/dispatch/control.py index 186acee5cf..6b3c13499d 100644 --- a/awx/main/dispatch/control.py +++ b/awx/main/dispatch/control.py @@ -43,7 +43,7 @@ class Control(object): for reply in conn.events(select_timeout=timeout, yield_timeouts=True): if reply is None: logger.error(f'{self.service} did not reply within {timeout}s') - raise RuntimeError("{self.service} did not reply within {timeout}s") + raise RuntimeError(f"{self.service} did not reply within {timeout}s") break return json.loads(reply.payload) diff --git a/awx/main/fields.py b/awx/main/fields.py index a1900294fa..0122b0ab80 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -7,8 +7,8 @@ import json import re import urllib.parse -from jinja2 import Environment, StrictUndefined -from jinja2.exceptions import UndefinedError, TemplateSyntaxError +from jinja2 import sandbox, StrictUndefined +from jinja2.exceptions import UndefinedError, TemplateSyntaxError, SecurityError # Django from django.contrib.postgres.fields import JSONField as upstream_JSONBField @@ -940,7 +940,7 @@ class CredentialTypeInjectorField(JSONSchemaField): self.validate_env_var_allowed(key) for key, tmpl in injector.items(): try: - Environment( + sandbox.ImmutableSandboxedEnvironment( undefined=StrictUndefined ).from_string(tmpl).render(valid_namespace) except UndefinedError as e: @@ -950,6 +950,10 @@ class CredentialTypeInjectorField(JSONSchemaField): code='invalid', params={'value': value}, ) + except SecurityError as e: + raise django_exceptions.ValidationError( + _('Encountered unsafe code execution: {}').format(e) + ) except TemplateSyntaxError as e: raise django_exceptions.ValidationError( _('Syntax error rendering template for {sub_key} inside of {type} ({error_msg})').format( diff --git a/awx/main/management/commands/profile_sql.py b/awx/main/management/commands/profile_sql.py index bbcf10dd27..5bbc4c80ca 100644 --- a/awx/main/management/commands/profile_sql.py +++ b/awx/main/management/commands/profile_sql.py @@ -19,3 +19,7 @@ class Command(BaseCommand): profile_sql.delay( threshold=options['threshold'], minutes=options['minutes'] ) + print(f"Logging initiated with a threshold of {options['threshold']} second(s) and a duration of" + f" {options['minutes']} minute(s), any queries that meet criteria can" + f" be found in /var/log/tower/profile/." + ) diff --git a/awx/main/middleware.py b/awx/main/middleware.py index 112ae17aa5..781266e8dd 100644 --- a/awx/main/middleware.py +++ b/awx/main/middleware.py @@ -14,7 +14,7 @@ from django.conf import settings from django.contrib.auth.models import User from django.db.migrations.executor import MigrationExecutor from django.db import connection -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import redirect from django.apps import apps from django.utils.deprecation import MiddlewareMixin from django.utils.translation import ugettext_lazy as _ @@ -148,7 +148,21 @@ class URLModificationMiddleware(MiddlewareMixin): def _named_url_to_pk(cls, node, resource, named_url): kwargs = {} if node.populate_named_url_query_kwargs(kwargs, named_url): - return str(get_object_or_404(node.model, **kwargs).pk) + match = node.model.objects.filter(**kwargs).first() + if match: + return str(match.pk) + else: + # if the name does *not* resolve to any actual resource, + # we should still attempt to route it through so that 401s are + # respected + # using "zero" here will cause the URL regex to match e.g., + # /api/v2/users/<integer>/, but it also means that anonymous + # users will go down the path of having their credentials + # verified; in this way, *anonymous* users will that visit + # /api/v2/users/invalid-username/ *won't* see a 404, they'll + # see a 401 as if they'd gone to /api/v2/users/0/ + # + return '0' if resource == 'job_templates' and '++' not in named_url: # special case for deprecated job template case # will not raise a 404 on its own @@ -178,6 +192,7 @@ class URLModificationMiddleware(MiddlewareMixin): old_path = request.path_info new_path = self._convert_named_url(old_path) if request.path_info != new_path: + request.environ['awx.named_url_rewritten'] = request.path request.path = request.path.replace(request.path_info, new_path) request.path_info = new_path diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 5c43c2d516..509378fcf5 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -127,9 +127,15 @@ def user_get_auditor_of_organizations(user): return Organization.objects.filter(auditor_role__members=user) +@property +def created(user): + return user.date_joined + + User.add_to_class('organizations', user_get_organizations) User.add_to_class('admin_of_organizations', user_get_admin_of_organizations) User.add_to_class('auditor_of_organizations', user_get_auditor_of_organizations) +User.add_to_class('created', created) @property diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 6ba5df45b5..36bb2684ea 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -11,7 +11,7 @@ import tempfile from types import SimpleNamespace # Jinja2 -from jinja2 import Template +from jinja2 import sandbox # Django from django.db import models @@ -514,8 +514,11 @@ class CredentialType(CommonModelNameNotUnique): # If any file templates are provided, render the files and update the # special `tower` template namespace so the filename can be # referenced in other injectors + + sandbox_env = sandbox.ImmutableSandboxedEnvironment() + for file_label, file_tmpl in file_tmpls.items(): - data = Template(file_tmpl).render(**namespace) + data = sandbox_env.from_string(file_tmpl).render(**namespace) _, path = tempfile.mkstemp(dir=private_data_dir) with open(path, 'w') as f: f.write(data) @@ -537,14 +540,14 @@ class CredentialType(CommonModelNameNotUnique): except ValidationError as e: logger.error('Ignoring prohibited env var {}, reason: {}'.format(env_var, e)) continue - env[env_var] = Template(tmpl).render(**namespace) - safe_env[env_var] = Template(tmpl).render(**safe_namespace) + env[env_var] = sandbox_env.from_string(tmpl).render(**namespace) + safe_env[env_var] = sandbox_env.from_string(tmpl).render(**safe_namespace) if 'INVENTORY_UPDATE_ID' not in env: # awx-manage inventory_update does not support extra_vars via -e extra_vars = {} for var_name, tmpl in self.injectors.get('extra_vars', {}).items(): - extra_vars[var_name] = Template(tmpl).render(**namespace) + extra_vars[var_name] = sandbox_env.from_string(tmpl).render(**namespace) def build_extra_vars_file(vars, private_dir): handle, path = tempfile.mkstemp(dir = private_dir) diff --git a/awx/main/models/credential/injectors.py b/awx/main/models/credential/injectors.py index 15b8229ea2..75d1f17bfe 100644 --- a/awx/main/models/credential/injectors.py +++ b/awx/main/models/credential/injectors.py @@ -101,3 +101,17 @@ def openstack(cred, env, private_data_dir): f.close() os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) env['OS_CLIENT_CONFIG_FILE'] = path + + +def kubernetes_bearer_token(cred, env, private_data_dir): + env['K8S_AUTH_HOST'] = cred.get_input('host', default='') + env['K8S_AUTH_API_KEY'] = cred.get_input('bearer_token', default='') + if cred.get_input('verify_ssl') and 'ssl_ca_cert' in cred.inputs: + env['K8S_AUTH_VERIFY_SSL'] = 'True' + handle, path = tempfile.mkstemp(dir=private_data_dir) + with os.fdopen(handle, 'w') as f: + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) + f.write(cred.get_input('ssl_ca_cert')) + env['K8S_AUTH_SSL_CA_CERT'] = path + else: + env['K8S_AUTH_VERIFY_SSL'] = 'False' diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index de0432e312..c374f60420 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -267,7 +267,7 @@ class JobNotificationMixin(object): 'timeout', 'use_fact_cache', 'launch_type', 'status', 'failed', 'started', 'finished', 'elapsed', 'job_explanation', 'execution_node', 'controller_node', 'allow_simultaneous', 'scm_revision', 'diff_mode', 'job_slice_number', 'job_slice_count', 'custom_virtualenv', - 'approval_status', 'approval_node_name', 'workflow_url', 'scm_branch', + 'approval_status', 'approval_node_name', 'workflow_url', 'scm_branch', 'artifacts', {'host_status_counts': ['skipped', 'ok', 'changed', 'failed', 'failures', 'dark' 'processed', 'rescued', 'ignored']}, {'summary_fields': [{'inventory': ['id', 'name', 'description', 'has_active_failures', @@ -288,6 +288,7 @@ class JobNotificationMixin(object): Context has the same structure as the context that will actually be used to render a notification message.""" context = {'job': {'allow_simultaneous': False, + 'artifacts': {}, 'controller_node': 'foo_controller', 'created': datetime.datetime(2018, 11, 13, 6, 4, 0, 0, tzinfo=datetime.timezone.utc), 'custom_virtualenv': 'my_venv', diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index 0d085dcd25..5d8cfd5290 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -194,6 +194,11 @@ class ProjectOptions(models.Model): if not check_if_exists or os.path.exists(smart_str(proj_path)): return proj_path + def get_cache_path(self): + local_path = os.path.basename(self.local_path) + if local_path: + return os.path.join(settings.PROJECTS_ROOT, '.__awx_cache', local_path) + @property def playbooks(self): results = [] @@ -419,6 +424,10 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn return False @property + def cache_id(self): + return str(self.last_job_id) + + @property def notification_templates(self): base_notification_templates = NotificationTemplate.objects error_notification_templates = list(base_notification_templates @@ -455,11 +464,12 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn ) def delete(self, *args, **kwargs): - path_to_delete = self.get_project_path(check_if_exists=False) + paths_to_delete = (self.get_project_path(check_if_exists=False), self.get_cache_path()) r = super(Project, self).delete(*args, **kwargs) - if self.scm_type and path_to_delete: # non-manual, concrete path - from awx.main.tasks import delete_project_files - delete_project_files.delay(path_to_delete) + for path_to_delete in paths_to_delete: + if self.scm_type and path_to_delete: # non-manual, concrete path + from awx.main.tasks import delete_project_files + delete_project_files.delay(path_to_delete) return r @@ -554,6 +564,19 @@ class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManage def result_stdout_raw(self): return self._result_stdout_raw(redact_sensitive=True) + @property + def branch_override(self): + """Whether a branch other than the project default is used.""" + if not self.project: + return True + return bool(self.scm_branch and self.scm_branch != self.project.scm_branch) + + @property + def cache_id(self): + if self.branch_override or self.job_type == 'check' or (not self.project): + return str(self.id) + return self.project.cache_id + def result_stdout_raw_limited(self, start_line=0, end_line=None, redact_sensitive=True): return self._result_stdout_raw_limited(start_line, end_line, redact_sensitive=redact_sensitive) @@ -597,10 +620,7 @@ class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManage def save(self, *args, **kwargs): added_update_fields = [] if not self.job_tags: - job_tags = ['update_{}'.format(self.scm_type)] - if self.job_type == 'run': - job_tags.append('install_roles') - job_tags.append('install_collections') + job_tags = ['update_{}'.format(self.scm_type), 'install_roles', 'install_collections'] self.job_tags = ','.join(job_tags) added_update_fields.append('job_tags') if self.scm_delete_on_update and 'delete' not in self.job_tags and self.job_type == 'check': diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 2cf6c3ac85..1abbb29fcb 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -963,6 +963,10 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique raise NotImplementedError() @property + def job_type_name(self): + return self.get_real_instance_class()._meta.verbose_name.replace(' ', '_') + + @property def result_stdout_text(self): related = UnifiedJobDeprecatedStdout.objects.get(pk=self.pk) return related.result_stdout_text or '' @@ -1221,7 +1225,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique def websocket_emit_data(self): ''' Return extra data that should be included when submitting data to the browser over the websocket connection ''' - websocket_data = dict(type=self.get_real_instance_class()._meta.verbose_name.replace(' ', '_')) + websocket_data = dict(type=self.job_type_name) if self.spawned_by_workflow: websocket_data.update(dict(workflow_job_id=self.workflow_job_id, workflow_node_id=self.workflow_node_id)) @@ -1362,7 +1366,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique running = self.celery_task_id in ControlDispatcher( 'dispatcher', self.controller_node or self.execution_node ).running(timeout=timeout) - except socket.timeout: + except (socket.timeout, RuntimeError): logger.error('could not reach dispatcher on {} within {}s'.format( self.execution_node, timeout )) diff --git a/awx/main/notifications/grafana_backend.py b/awx/main/notifications/grafana_backend.py index 28fc1b90b3..8e8b648952 100644 --- a/awx/main/notifications/grafana_backend.py +++ b/awx/main/notifications/grafana_backend.py @@ -94,8 +94,8 @@ class GrafanaBackend(AWXBaseEmailBackend, CustomNotificationBase): headers=grafana_headers, verify=(not self.grafana_no_verify_ssl)) if r.status_code >= 400: - logger.error(smart_text(_("Error sending notification grafana: {}").format(r.text))) + logger.error(smart_text(_("Error sending notification grafana: {}").format(r.status_code))) if not self.fail_silently: - raise Exception(smart_text(_("Error sending notification grafana: {}").format(r.text))) + raise Exception(smart_text(_("Error sending notification grafana: {}").format(r.status_code))) sent_messages += 1 return sent_messages diff --git a/awx/main/notifications/mattermost_backend.py b/awx/main/notifications/mattermost_backend.py index 78a23c72d1..59a1c6f5e1 100644 --- a/awx/main/notifications/mattermost_backend.py +++ b/awx/main/notifications/mattermost_backend.py @@ -46,8 +46,8 @@ class MattermostBackend(AWXBaseEmailBackend, CustomNotificationBase): r = requests.post("{}".format(m.recipients()[0]), json=payload, verify=(not self.mattermost_no_verify_ssl)) if r.status_code >= 400: - logger.error(smart_text(_("Error sending notification mattermost: {}").format(r.text))) + logger.error(smart_text(_("Error sending notification mattermost: {}").format(r.status_code))) if not self.fail_silently: - raise Exception(smart_text(_("Error sending notification mattermost: {}").format(r.text))) + raise Exception(smart_text(_("Error sending notification mattermost: {}").format(r.status_code))) sent_messages += 1 return sent_messages diff --git a/awx/main/notifications/rocketchat_backend.py b/awx/main/notifications/rocketchat_backend.py index 1ad367fb57..df271bf80d 100644 --- a/awx/main/notifications/rocketchat_backend.py +++ b/awx/main/notifications/rocketchat_backend.py @@ -46,9 +46,9 @@ class RocketChatBackend(AWXBaseEmailBackend, CustomNotificationBase): if r.status_code >= 400: logger.error(smart_text( - _("Error sending notification rocket.chat: {}").format(r.text))) + _("Error sending notification rocket.chat: {}").format(r.status_code))) if not self.fail_silently: raise Exception(smart_text( - _("Error sending notification rocket.chat: {}").format(r.text))) + _("Error sending notification rocket.chat: {}").format(r.status_code))) sent_messages += 1 return sent_messages diff --git a/awx/main/notifications/webhook_backend.py b/awx/main/notifications/webhook_backend.py index b9c2c35d22..a33cf026f8 100644 --- a/awx/main/notifications/webhook_backend.py +++ b/awx/main/notifications/webhook_backend.py @@ -72,8 +72,8 @@ class WebhookBackend(AWXBaseEmailBackend, CustomNotificationBase): headers=self.headers, verify=(not self.disable_ssl_verification)) if r.status_code >= 400: - logger.error(smart_text(_("Error sending notification webhook: {}").format(r.text))) + logger.error(smart_text(_("Error sending notification webhook: {}").format(r.status_code))) if not self.fail_silently: - raise Exception(smart_text(_("Error sending notification webhook: {}").format(r.text))) + raise Exception(smart_text(_("Error sending notification webhook: {}").format(r.status_code))) sent_messages += 1 return sent_messages diff --git a/awx/main/tasks.py b/awx/main/tasks.py index a4aee391e7..06c740c129 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -1865,44 +1865,31 @@ class RunJob(BaseTask): project_path = job.project.get_project_path(check_if_exists=False) job_revision = job.project.scm_revision sync_needs = [] - all_sync_needs = ['update_{}'.format(job.project.scm_type), 'install_roles', 'install_collections'] + source_update_tag = 'update_{}'.format(job.project.scm_type) + branch_override = bool(job.scm_branch and job.scm_branch != job.project.scm_branch) if not job.project.scm_type: pass # manual projects are not synced, user has responsibility for that elif not os.path.exists(project_path): logger.debug('Performing fresh clone of {} on this instance.'.format(job.project)) - sync_needs = all_sync_needs - elif not job.project.scm_revision: - logger.debug('Revision not known for {}, will sync with remote'.format(job.project)) - sync_needs = all_sync_needs - elif job.project.scm_type == 'git': + sync_needs.append(source_update_tag) + elif job.project.scm_type == 'git' and job.project.scm_revision and (not branch_override): git_repo = git.Repo(project_path) try: - desired_revision = job.project.scm_revision - if job.scm_branch and job.scm_branch != job.project.scm_branch: - desired_revision = job.scm_branch # could be commit or not, but will try as commit - current_revision = git_repo.head.commit.hexsha - if desired_revision == current_revision: - job_revision = desired_revision + if job_revision == git_repo.head.commit.hexsha: logger.debug('Skipping project sync for {} because commit is locally available'.format(job.log_format)) else: - sync_needs = all_sync_needs + sync_needs.append(source_update_tag) except (ValueError, BadGitName): logger.debug('Needed commit for {} not in local source tree, will sync with remote'.format(job.log_format)) - sync_needs = all_sync_needs + sync_needs.append(source_update_tag) else: - sync_needs = all_sync_needs + logger.debug('Project not available locally, {} will sync with remote'.format(job.log_format)) + sync_needs.append(source_update_tag) + + has_cache = os.path.exists(os.path.join(job.project.get_cache_path(), job.project.cache_id)) # Galaxy requirements are not supported for manual projects - if not sync_needs and job.project.scm_type: - # see if we need a sync because of presence of roles - galaxy_req_path = os.path.join(project_path, 'roles', 'requirements.yml') - if os.path.exists(galaxy_req_path): - logger.debug('Running project sync for {} because of galaxy role requirements.'.format(job.log_format)) - sync_needs.append('install_roles') - - galaxy_collections_req_path = os.path.join(project_path, 'collections', 'requirements.yml') - if os.path.exists(galaxy_collections_req_path): - logger.debug('Running project sync for {} because of galaxy collections requirements.'.format(job.log_format)) - sync_needs.append('install_collections') + if job.project.scm_type and ((not has_cache) or branch_override): + sync_needs.extend(['install_roles', 'install_collections']) if sync_needs: pu_ig = job.instance_group @@ -1920,7 +1907,7 @@ class RunJob(BaseTask): execution_node=pu_en, celery_task_id=job.celery_task_id ) - if job.scm_branch and job.scm_branch != job.project.scm_branch: + if branch_override: sync_metafields['scm_branch'] = job.scm_branch if 'update_' not in sync_metafields['job_tags']: sync_metafields['scm_revision'] = job_revision @@ -1952,10 +1939,7 @@ class RunJob(BaseTask): if job_revision: job = self.update_model(job.pk, scm_revision=job_revision) # Project update does not copy the folder, so copy here - RunProjectUpdate.make_local_copy( - project_path, os.path.join(private_data_dir, 'project'), - job.project.scm_type, job_revision - ) + RunProjectUpdate.make_local_copy(job.project, private_data_dir, scm_revision=job_revision) if job.inventory.kind == 'smart': # cache smart inventory memberships so that the host_filter query is not @@ -1995,10 +1979,7 @@ class RunProjectUpdate(BaseTask): @property def proot_show_paths(self): - show_paths = [settings.PROJECTS_ROOT] - if self.job_private_data_dir: - show_paths.append(self.job_private_data_dir) - return show_paths + return [settings.PROJECTS_ROOT] def __init__(self, *args, job_private_data_dir=None, **kwargs): super(RunProjectUpdate, self).__init__(*args, **kwargs) @@ -2032,12 +2013,6 @@ class RunProjectUpdate(BaseTask): credential = project_update.credential if credential.has_input('ssh_key_data'): private_data['credentials'][credential] = credential.get_input('ssh_key_data', default='') - - # Create dir where collections will live for the job run - if project_update.job_type != 'check' and getattr(self, 'job_private_data_dir'): - for folder_name in ('requirements_collections', 'requirements_roles'): - folder_path = os.path.join(self.job_private_data_dir, folder_name) - os.mkdir(folder_path, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC) return private_data def build_passwords(self, project_update, runtime_passwords): @@ -2165,8 +2140,7 @@ class RunProjectUpdate(BaseTask): extra_vars.update(extra_vars_new) scm_branch = project_update.scm_branch - branch_override = bool(scm_branch and project_update.scm_branch != project_update.project.scm_branch) - if project_update.job_type == 'run' and (not branch_override): + if project_update.job_type == 'run' and (not project_update.branch_override): if project_update.project.scm_revision: scm_branch = project_update.project.scm_revision elif not scm_branch: @@ -2174,7 +2148,9 @@ class RunProjectUpdate(BaseTask): elif not scm_branch: scm_branch = {'hg': 'tip'}.get(project_update.scm_type, 'HEAD') extra_vars.update({ - 'project_path': project_update.get_project_path(check_if_exists=False), + 'projects_root': settings.PROJECTS_ROOT.rstrip('/'), + 'local_path': os.path.basename(project_update.project.local_path), + 'project_path': project_update.get_project_path(check_if_exists=False), # deprecated 'insights_url': settings.INSIGHTS_URL_BASE, 'awx_license_type': get_license(show_key=False).get('license_type', 'UNLICENSED'), 'awx_version': get_awx_version(), @@ -2184,9 +2160,6 @@ class RunProjectUpdate(BaseTask): 'roles_enabled': settings.AWX_ROLES_ENABLED, 'collections_enabled': settings.AWX_COLLECTIONS_ENABLED, }) - if project_update.job_type != 'check' and self.job_private_data_dir: - extra_vars['collections_destination'] = os.path.join(self.job_private_data_dir, 'requirements_collections') - extra_vars['roles_destination'] = os.path.join(self.job_private_data_dir, 'requirements_roles') # apply custom refspec from user for PR refs and the like if project_update.scm_refspec: extra_vars['scm_refspec'] = project_update.scm_refspec @@ -2322,8 +2295,7 @@ class RunProjectUpdate(BaseTask): os.mkdir(settings.PROJECTS_ROOT) self.acquire_lock(instance) self.original_branch = None - if (instance.scm_type == 'git' and instance.job_type == 'run' and instance.project and - instance.scm_branch != instance.project.scm_branch): + if instance.scm_type == 'git' and instance.branch_override: project_path = instance.project.get_project_path(check_if_exists=False) if os.path.exists(project_path): git_repo = git.Repo(project_path) @@ -2332,17 +2304,48 @@ class RunProjectUpdate(BaseTask): else: self.original_branch = git_repo.active_branch + stage_path = os.path.join(instance.get_cache_path(), 'stage') + if os.path.exists(stage_path): + logger.warning('{0} unexpectedly existed before update'.format(stage_path)) + shutil.rmtree(stage_path) + os.makedirs(stage_path) # presence of empty cache indicates lack of roles or collections + @staticmethod - def make_local_copy(project_path, destination_folder, scm_type, scm_revision): - if scm_type == 'git': + def clear_project_cache(cache_dir, keep_value): + if os.path.isdir(cache_dir): + for entry in os.listdir(cache_dir): + old_path = os.path.join(cache_dir, entry) + if entry not in (keep_value, 'stage'): + # invalidate, then delete + new_path = os.path.join(cache_dir,'.~~delete~~' + entry) + try: + os.rename(old_path, new_path) + shutil.rmtree(new_path) + except OSError: + logger.warning(f"Could not remove cache directory {old_path}") + + @staticmethod + def make_local_copy(p, job_private_data_dir, scm_revision=None): + """Copy project content (roles and collections) to a job private_data_dir + + :param object p: Either a project or a project update + :param str job_private_data_dir: The root of the target ansible-runner folder + :param str scm_revision: For branch_override cases, the git revision to copy + """ + project_path = p.get_project_path(check_if_exists=False) + destination_folder = os.path.join(job_private_data_dir, 'project') + if not scm_revision: + scm_revision = p.scm_revision + + if p.scm_type == 'git': git_repo = git.Repo(project_path) if not os.path.exists(destination_folder): os.mkdir(destination_folder, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC) tmp_branch_name = 'awx_internal/{}'.format(uuid4()) # always clone based on specific job revision - if not scm_revision: + if not p.scm_revision: raise RuntimeError('Unexpectedly could not determine a revision to run from project.') - source_branch = git_repo.create_head(tmp_branch_name, scm_revision) + source_branch = git_repo.create_head(tmp_branch_name, p.scm_revision) # git clone must take file:// syntax for source repo or else options like depth will be ignored source_as_uri = Path(project_path).as_uri() git.Repo.clone_from( @@ -2361,19 +2364,48 @@ class RunProjectUpdate(BaseTask): else: copy_tree(project_path, destination_folder, preserve_symlinks=1) + # copy over the roles and collection cache to job folder + cache_path = os.path.join(p.get_cache_path(), p.cache_id) + subfolders = [] + if settings.AWX_COLLECTIONS_ENABLED: + subfolders.append('requirements_collections') + if settings.AWX_ROLES_ENABLED: + subfolders.append('requirements_roles') + for subfolder in subfolders: + cache_subpath = os.path.join(cache_path, subfolder) + if os.path.exists(cache_subpath): + dest_subpath = os.path.join(job_private_data_dir, subfolder) + copy_tree(cache_subpath, dest_subpath, preserve_symlinks=1) + logger.debug('{0} {1} prepared {2} from cache'.format(type(p).__name__, p.pk, dest_subpath)) + def post_run_hook(self, instance, status): # To avoid hangs, very important to release lock even if errors happen here try: if self.playbook_new_revision: instance.scm_revision = self.playbook_new_revision instance.save(update_fields=['scm_revision']) + + # Roles and collection folders copy to durable cache + base_path = instance.get_cache_path() + stage_path = os.path.join(base_path, 'stage') + if status == 'successful' and 'install_' in instance.job_tags: + # Clear other caches before saving this one, and if branch is overridden + # do not clear cache for main branch, but do clear it for other branches + self.clear_project_cache(base_path, keep_value=instance.project.cache_id) + cache_path = os.path.join(base_path, instance.cache_id) + if os.path.exists(stage_path): + if os.path.exists(cache_path): + logger.warning('Rewriting cache at {0}, performance may suffer'.format(cache_path)) + shutil.rmtree(cache_path) + os.rename(stage_path, cache_path) + logger.debug('{0} wrote to cache at {1}'.format(instance.log_format, cache_path)) + elif os.path.exists(stage_path): + shutil.rmtree(stage_path) # cannot trust content update produced + if self.job_private_data_dir: # copy project folder before resetting to default branch # because some git-tree-specific resources (like submodules) might matter - self.make_local_copy( - instance.get_project_path(check_if_exists=False), os.path.join(self.job_private_data_dir, 'project'), - instance.scm_type, instance.scm_revision - ) + self.make_local_copy(instance, self.job_private_data_dir) if self.original_branch: # for git project syncs, non-default branches can be problems # restore to branch the repo was on before this run @@ -2626,13 +2658,21 @@ class RunInventoryUpdate(BaseTask): source_project = None if inventory_update.inventory_source: source_project = inventory_update.inventory_source.source_project - if (inventory_update.source=='scm' and inventory_update.launch_type!='scm' and source_project): - # In project sync, pulling galaxy roles is not needed + if (inventory_update.source=='scm' and inventory_update.launch_type!='scm' and + source_project and source_project.scm_type): # never ever update manual projects + + # Check if the content cache exists, so that we do not unnecessarily re-download roles + sync_needs = ['update_{}'.format(source_project.scm_type)] + has_cache = os.path.exists(os.path.join(source_project.get_cache_path(), source_project.cache_id)) + # Galaxy requirements are not supported for manual projects + if not has_cache: + sync_needs.extend(['install_roles', 'install_collections']) + local_project_sync = source_project.create_project_update( _eager_fields=dict( launch_type="sync", job_type='run', - job_tags='update_{},install_collections'.format(source_project.scm_type), # roles are never valid for inventory + job_tags=','.join(sync_needs), status='running', execution_node=inventory_update.execution_node, instance_group = inventory_update.instance_group, @@ -2656,11 +2696,7 @@ class RunInventoryUpdate(BaseTask): raise elif inventory_update.source == 'scm' and inventory_update.launch_type == 'scm' and source_project: # This follows update, not sync, so make copy here - project_path = source_project.get_project_path(check_if_exists=False) - RunProjectUpdate.make_local_copy( - project_path, os.path.join(private_data_dir, 'project'), - source_project.scm_type, source_project.scm_revision - ) + RunProjectUpdate.make_local_copy(source_project, private_data_dir) @task(queue=get_local_queuename) diff --git a/awx/main/tests/functional/api/test_credential_type.py b/awx/main/tests/functional/api/test_credential_type.py index 45b5e79994..c8f87f0c57 100644 --- a/awx/main/tests/functional/api/test_credential_type.py +++ b/awx/main/tests/functional/api/test_credential_type.py @@ -220,7 +220,7 @@ def test_create_valid_kind(kind, get, post, admin): @pytest.mark.django_db -@pytest.mark.parametrize('kind', ['ssh', 'vault', 'scm', 'insights']) +@pytest.mark.parametrize('kind', ['ssh', 'vault', 'scm', 'insights', 'kubernetes']) def test_create_invalid_kind(kind, get, post, admin): response = post(reverse('api:credential_type_list'), { 'kind': kind, diff --git a/awx/main/tests/functional/api/test_job_runtime_params.py b/awx/main/tests/functional/api/test_job_runtime_params.py index d792ec656d..e628623cf1 100644 --- a/awx/main/tests/functional/api/test_job_runtime_params.py +++ b/awx/main/tests/functional/api/test_job_runtime_params.py @@ -483,25 +483,26 @@ def test_job_launch_pass_with_prompted_vault_password(machine_credential, vault_ @pytest.mark.django_db -def test_job_launch_JT_with_credentials(machine_credential, credential, net_credential, deploy_jobtemplate): +def test_job_launch_JT_with_credentials(machine_credential, credential, net_credential, kube_credential, deploy_jobtemplate): deploy_jobtemplate.ask_credential_on_launch = True deploy_jobtemplate.save() - kv = dict(credentials=[credential.pk, net_credential.pk, machine_credential.pk]) + kv = dict(credentials=[credential.pk, net_credential.pk, machine_credential.pk, kube_credential.pk]) serializer = JobLaunchSerializer(data=kv, context={'template': deploy_jobtemplate}) validated = serializer.is_valid() assert validated, serializer.errors - kv['credentials'] = [credential, net_credential, machine_credential] # convert to internal value + kv['credentials'] = [credential, net_credential, machine_credential, kube_credential] # convert to internal value prompted_fields, ignored_fields, errors = deploy_jobtemplate._accept_or_ignore_job_kwargs( _exclude_errors=['required', 'prompts'], **kv) job_obj = deploy_jobtemplate.create_unified_job(**prompted_fields) creds = job_obj.credentials.all() - assert len(creds) == 3 + assert len(creds) == 4 assert credential in creds assert net_credential in creds assert machine_credential in creds + assert kube_credential in creds @pytest.mark.django_db diff --git a/awx/main/tests/functional/api/test_project.py b/awx/main/tests/functional/api/test_project.py index af46363557..09fed17c67 100644 --- a/awx/main/tests/functional/api/test_project.py +++ b/awx/main/tests/functional/api/test_project.py @@ -54,7 +54,9 @@ def test_no_changing_overwrite_behavior_if_used(post, patch, organization, admin data={ 'name': 'fooo', 'organization': organization.id, - 'allow_override': True + 'allow_override': True, + 'scm_type': 'git', + 'scm_url': 'https://github.com/ansible/test-playbooks.git' }, user=admin_user, expect=201 @@ -83,7 +85,9 @@ def test_changing_overwrite_behavior_okay_if_not_used(post, patch, organization, data={ 'name': 'fooo', 'organization': organization.id, - 'allow_override': True + 'allow_override': True, + 'scm_type': 'git', + 'scm_url': 'https://github.com/ansible/test-playbooks.git' }, user=admin_user, expect=201 diff --git a/awx/main/tests/functional/api/test_user.py b/awx/main/tests/functional/api/test_user.py index d91c4fb2d4..821b37d6ae 100644 --- a/awx/main/tests/functional/api/test_user.py +++ b/awx/main/tests/functional/api/test_user.py @@ -1,3 +1,5 @@ +from datetime import date + import pytest from django.contrib.sessions.middleware import SessionMiddleware @@ -61,3 +63,21 @@ def test_user_cannot_update_last_login(patch, admin): middleware=SessionMiddleware() ) assert User.objects.get(pk=admin.pk).last_login is None + + +@pytest.mark.django_db +def test_user_verify_attribute_created(admin, get): + assert admin.created == admin.date_joined + resp = get( + reverse('api:user_detail', kwargs={'pk': admin.pk}), + admin + ) + assert resp.data['created'] == admin.date_joined + + past = date(2020, 1, 1).isoformat() + for op, count in (('gt', 1), ('lt', 0)): + resp = get( + reverse('api:user_list') + f'?created__{op}={past}', + admin + ) + assert resp.data['count'] == count diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index f6accff877..7111950003 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -145,7 +145,6 @@ def project(instance, organization): description="test-proj-desc", organization=organization, playbook_files=['helloworld.yml', 'alt-helloworld.yml'], - local_path='_92__test_proj', scm_revision='1234567890123456789012345678901234567890', scm_url='localhost', scm_type='git' diff --git a/awx/main/tests/functional/models/test_inventory.py b/awx/main/tests/functional/models/test_inventory.py index adc7aa245c..6765f0e73b 100644 --- a/awx/main/tests/functional/models/test_inventory.py +++ b/awx/main/tests/functional/models/test_inventory.py @@ -169,7 +169,8 @@ class TestSCMUpdateFeatures: inventory_update = InventoryUpdate( inventory_source=scm_inventory_source, source_path=scm_inventory_source.source_path) - assert inventory_update.get_actual_source_path().endswith('_92__test_proj/inventory_file') + p = scm_inventory_source.source_project + assert inventory_update.get_actual_source_path().endswith(f'_{p.id}__test_proj/inventory_file') def test_no_unwanted_updates(self, scm_inventory_source): # Changing the non-sensitive fields should not trigger update diff --git a/awx/main/tests/functional/models/test_notifications.py b/awx/main/tests/functional/models/test_notifications.py index 4208843969..8d514312ae 100644 --- a/awx/main/tests/functional/models/test_notifications.py +++ b/awx/main/tests/functional/models/test_notifications.py @@ -12,6 +12,7 @@ from awx.api.serializers import UnifiedJobSerializer class TestJobNotificationMixin(object): CONTEXT_STRUCTURE = {'job': {'allow_simultaneous': bool, + 'artifacts': {}, 'custom_virtualenv': str, 'controller_node': str, 'created': datetime.datetime, diff --git a/awx/main/tests/functional/models/test_project.py b/awx/main/tests/functional/models/test_project.py index 3f57691ac3..2cf43c5690 100644 --- a/awx/main/tests/functional/models/test_project.py +++ b/awx/main/tests/functional/models/test_project.py @@ -35,6 +35,18 @@ def test_sensitive_change_triggers_update(project): @pytest.mark.django_db +def test_local_path_autoset(organization): + with mock.patch.object(Project, "update"): + p = Project.objects.create( + name="test-proj", + organization=organization, + scm_url='localhost', + scm_type='git' + ) + assert p.local_path == f'_{p.id}__test_proj' + + +@pytest.mark.django_db def test_foreign_key_change_changes_modified_by(project, organization): assert project._get_fields_snapshot()['organization_id'] == organization.id project.organization = Organization(name='foo', pk=41) diff --git a/awx/main/tests/functional/test_jobs.py b/awx/main/tests/functional/test_jobs.py index 9dab9c5d57..2bc10fa0df 100644 --- a/awx/main/tests/functional/test_jobs.py +++ b/awx/main/tests/functional/test_jobs.py @@ -2,7 +2,9 @@ import pytest from unittest import mock import json -from awx.main.models import Job, Instance, JobHostSummary +from awx.main.models import (Job, Instance, JobHostSummary, InventoryUpdate, + InventorySource, Project, ProjectUpdate, + SystemJob, AdHocCommand) from awx.main.tasks import cluster_node_heartbeat from django.test.utils import override_settings @@ -34,6 +36,31 @@ def test_job_capacity_and_with_inactive_node(): @pytest.mark.django_db +def test_job_type_name(): + job = Job.objects.create() + assert job.job_type_name == 'job' + + ahc = AdHocCommand.objects.create() + assert ahc.job_type_name == 'ad_hoc_command' + + source = InventorySource.objects.create(source='ec2') + source.save() + iu = InventoryUpdate.objects.create( + inventory_source=source, + source='ec2' + ) + assert iu.job_type_name == 'inventory_update' + + proj = Project.objects.create() + proj.save() + pu = ProjectUpdate.objects.create(project=proj) + assert pu.job_type_name == 'project_update' + + sjob = SystemJob.objects.create() + assert sjob.job_type_name == 'system_job' + + +@pytest.mark.django_db def test_job_notification_data(inventory, machine_credential, project): encrypted_str = "$encrypted$" job = Job.objects.create( diff --git a/awx/main/tests/functional/test_named_url.py b/awx/main/tests/functional/test_named_url.py index dcf2111992..6482dac3a8 100644 --- a/awx/main/tests/functional/test_named_url.py +++ b/awx/main/tests/functional/test_named_url.py @@ -219,3 +219,27 @@ def test_credential(get, admin_user, credentialtype_ssh): url = reverse('api:credential_detail', kwargs={'pk': test_cred.pk}) response = get(url, user=admin_user, expect=200) assert response.data['related']['named_url'].endswith('/test_cred++Machine+ssh++/') + + +@pytest.mark.django_db +def test_403_vs_404(get): + cindy = User.objects.create( + username='cindy', + password='test_user', + is_superuser=False + ) + bob = User.objects.create( + username='bob', + password='test_user', + is_superuser=False + ) + + # bob cannot see cindy, pk lookup should be a 403 + url = reverse('api:user_detail', kwargs={'pk': cindy.pk}) + get(url, user=bob, expect=403) + + # bob cannot see cindy, username lookup should be a 404 + get('/api/v2/users/cindy/', user=bob, expect=404) + + get(f'/api/v2/users/{cindy.pk}/', expect=401) + get('/api/v2/users/cindy/', expect=404) diff --git a/awx/main/tests/functional/test_projects.py b/awx/main/tests/functional/test_projects.py index ef4b59630d..ccfbd06627 100644 --- a/awx/main/tests/functional/test_projects.py +++ b/awx/main/tests/functional/test_projects.py @@ -29,8 +29,8 @@ def team_project_list(organization_factory): @pytest.mark.django_db def test_get_project_path(project): # Test combining projects root with project local path - with mock.patch('awx.main.models.projects.settings.PROJECTS_ROOT', '/var/lib/awx'): - assert project.get_project_path(check_if_exists=False) == '/var/lib/awx/_92__test_proj' + with mock.patch('awx.main.models.projects.settings.PROJECTS_ROOT', '/var/lib/foo'): + assert project.get_project_path(check_if_exists=False) == f'/var/lib/foo/_{project.id}__test_proj' @pytest.mark.django_db diff --git a/awx/main/tests/functional/test_rbac_label.py b/awx/main/tests/functional/test_rbac_label.py index 955894c06f..ed819df9f0 100644 --- a/awx/main/tests/functional/test_rbac_label.py +++ b/awx/main/tests/functional/test_rbac_label.py @@ -20,8 +20,19 @@ def test_label_get_queryset_su(label, user): @pytest.mark.django_db -def test_label_access(label, user): +def test_label_read_access(label, user): access = LabelAccess(user('user', False)) + assert not access.can_read(label) + label.organization.member_role.members.add(user('user', False)) + assert access.can_read(label) + + +@pytest.mark.django_db +def test_label_jt_read_access(label, user, job_template): + access = LabelAccess(user('user', False)) + assert not access.can_read(label) + job_template.read_role.members.add(user('user', False)) + job_template.labels.add(label) assert access.can_read(label) diff --git a/awx/main/tests/functional/test_tasks.py b/awx/main/tests/functional/test_tasks.py index f1cf382a7c..c7bc50c8d2 100644 --- a/awx/main/tests/functional/test_tasks.py +++ b/awx/main/tests/functional/test_tasks.py @@ -30,7 +30,7 @@ class TestDependentInventoryUpdate: def test_dependent_inventory_updates_is_called(self, scm_inventory_source, scm_revision_file): task = RunProjectUpdate() task.revision_path = scm_revision_file - proj_update = ProjectUpdate.objects.create(project=scm_inventory_source.source_project) + proj_update = scm_inventory_source.source_project.create_project_update() with mock.patch.object(RunProjectUpdate, '_update_dependent_inventories') as inv_update_mck: with mock.patch.object(RunProjectUpdate, 'release_lock'): task.post_run_hook(proj_update, 'successful') @@ -39,7 +39,7 @@ class TestDependentInventoryUpdate: def test_no_unwanted_dependent_inventory_updates(self, project, scm_revision_file): task = RunProjectUpdate() task.revision_path = scm_revision_file - proj_update = ProjectUpdate.objects.create(project=project) + proj_update = project.create_project_update() with mock.patch.object(RunProjectUpdate, '_update_dependent_inventories') as inv_update_mck: with mock.patch.object(RunProjectUpdate, 'release_lock'): task.post_run_hook(proj_update, 'successful') diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index f8c8094ac0..71bcd8d03c 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -61,7 +61,10 @@ def patch_Job(): @pytest.fixture def job(): - return Job(pk=1, id=1, project=Project(), inventory=Inventory(), job_template=JobTemplate(id=1, name='foo')) + return Job( + pk=1, id=1, + project=Project(local_path='/projects/_23_foo'), + inventory=Inventory(), job_template=JobTemplate(id=1, name='foo')) @pytest.fixture @@ -406,7 +409,9 @@ class TestExtraVarSanitation(TestJobExecution): class TestGenericRun(): def test_generic_failure(self, patch_Job): - job = Job(status='running', inventory=Inventory(), project=Project()) + job = Job( + status='running', inventory=Inventory(), + project=Project(local_path='/projects/_23_foo')) job.websocket_emit_status = mock.Mock() task = tasks.RunJob() @@ -1037,6 +1042,43 @@ class TestJobCredentials(TestJobExecution): assert '--vault-id dev@prompt' in ' '.join(args) assert '--vault-id prod@prompt' in ' '.join(args) + @pytest.mark.parametrize("verify", (True, False)) + def test_k8s_credential(self, job, private_data_dir, verify): + k8s = CredentialType.defaults['kubernetes_bearer_token']() + inputs = { + 'host': 'https://example.org/', + 'bearer_token': 'token123', + } + if verify: + inputs['verify_ssl'] = True + inputs['ssl_ca_cert'] = 'CERTDATA' + credential = Credential( + pk=1, + credential_type=k8s, + inputs = inputs, + ) + credential.inputs['bearer_token'] = encrypt_field(credential, 'bearer_token') + job.credentials.add(credential) + + env = {} + safe_env = {} + credential.credential_type.inject_credential( + credential, env, safe_env, [], private_data_dir + ) + + assert env['K8S_AUTH_HOST'] == 'https://example.org/' + assert env['K8S_AUTH_API_KEY'] == 'token123' + + if verify: + assert env['K8S_AUTH_VERIFY_SSL'] == 'True' + cert = open(env['K8S_AUTH_SSL_CA_CERT'], 'r').read() + assert cert == 'CERTDATA' + else: + assert env['K8S_AUTH_VERIFY_SSL'] == 'False' + assert 'K8S_AUTH_SSL_CA_CERT' not in env + + assert safe_env['K8S_AUTH_API_KEY'] == tasks.HIDDEN_PASSWORD + def test_aws_cloud_credential(self, job, private_data_dir): aws = CredentialType.defaults['aws']() credential = Credential( diff --git a/awx/playbooks/project_update.yml b/awx/playbooks/project_update.yml index 7c82c0b6e7..e572496497 100644 --- a/awx/playbooks/project_update.yml +++ b/awx/playbooks/project_update.yml @@ -1,6 +1,9 @@ --- # The following variables will be set by the runner of this playbook: -# project_path: PROJECTS_DIR/_local_path_ +# projects_root: Global location for caching project checkouts and roles and collections +# should not have trailing slash on end +# local_path: Path within projects_root to use for this project +# project_path: A simple join of projects_root/local_path folders # scm_url: https://server/repo # insights_url: Insights service URL (from configuration) # scm_branch: branch/tag/revision (HEAD if unset) @@ -11,8 +14,6 @@ # scm_refspec: a refspec to fetch in addition to obtaining version # roles_enabled: Value of the global setting to enable roles downloading # collections_enabled: Value of the global setting to enable collections downloading -# roles_destination: Path to save roles from galaxy to -# collections_destination: Path to save collections from galaxy to # awx_version: Current running version of the awx or tower as a string # awx_license_type: "open" for AWX; else presume Tower @@ -122,7 +123,10 @@ register: doesRequirementsExist - name: fetch galaxy roles from requirements.yml - command: ansible-galaxy role install -r roles/requirements.yml -p {{roles_destination|quote}}{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }} + command: > + ansible-galaxy role install -r roles/requirements.yml + --roles-path {{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_roles + {{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }} args: chdir: "{{project_path|quote}}" register: galaxy_result @@ -143,7 +147,10 @@ register: doesCollectionRequirementsExist - name: fetch galaxy collections from collections/requirements.yml - command: ansible-galaxy collection install -r collections/requirements.yml -p {{collections_destination|quote}}{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }} + command: > + ansible-galaxy collection install -r collections/requirements.yml + --collections-path {{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_collections + {{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }} args: chdir: "{{project_path|quote}}" register: galaxy_collection_result @@ -151,11 +158,11 @@ changed_when: "'Installing ' in galaxy_collection_result.stdout" environment: ANSIBLE_FORCE_COLOR: false - ANSIBLE_COLLECTIONS_PATHS: "{{ collections_destination }}" + ANSIBLE_COLLECTIONS_PATHS: "{{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_collections" GIT_SSH_COMMAND: "ssh -o StrictHostKeyChecking=no" when: - - "ansible_version.full is version_compare('2.8', '>=')" + - "ansible_version.full is version_compare('2.9', '>=')" - collections_enabled|bool tags: - install_collections diff --git a/awx/ui/client/features/credentials/add-edit-credentials.controller.js b/awx/ui/client/features/credentials/add-edit-credentials.controller.js index f82fd6e3df..57c8eed1dd 100644 --- a/awx/ui/client/features/credentials/add-edit-credentials.controller.js +++ b/awx/ui/client/features/credentials/add-edit-credentials.controller.js @@ -53,16 +53,19 @@ function AddEditCredentialsController ( vm.form.disabled = !isEditable; } - vm.form.organization._disabled = !isOrgEditableByUser; + vm.form._organization._disabled = !isOrgEditableByUser; // Only exists for permissions compatibility $scope.credential_obj = credential.get(); - vm.form.organization._resource = 'organization'; - vm.form.organization._model = organization; - vm.form.organization._route = 'credentials.edit.organization'; - vm.form.organization._value = credential.get('summary_fields.organization.id'); - vm.form.organization._displayValue = credential.get('summary_fields.organization.name'); - vm.form.organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER'); + // Custom credentials can have input fields named 'name', 'organization', + // 'description', etc. Underscore these variables to make collisions + // less likely to occur. + vm.form._organization._resource = 'organization'; + vm.form._organization._model = organization; + vm.form._organization._route = 'credentials.edit.organization'; + vm.form._organization._value = credential.get('summary_fields.organization.id'); + vm.form._organization._displayValue = credential.get('summary_fields.organization.name'); + vm.form._organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER'); vm.form.credential_type._resource = 'credential_type'; vm.form.credential_type._model = credentialType; @@ -98,10 +101,10 @@ function AddEditCredentialsController ( vm.form._formName = 'credential'; vm.form.disabled = !credential.isCreatable(); - vm.form.organization._resource = 'organization'; - vm.form.organization._route = 'credentials.add.organization'; - vm.form.organization._model = organization; - vm.form.organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER'); + vm.form._organization._resource = 'organization'; + vm.form._organization._route = 'credentials.add.organization'; + vm.form._organization._model = organization; + vm.form._organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER'); vm.form.credential_type._resource = 'credential_type'; vm.form.credential_type._route = 'credentials.add.credentialType'; @@ -112,7 +115,7 @@ function AddEditCredentialsController ( $scope.$watch('organization', () => { if ($scope.organization) { - vm.form.organization._idFromModal = $scope.organization; + vm.form._organization._idFromModal = $scope.organization; } }); diff --git a/awx/ui/client/features/credentials/add-edit-credentials.view.html b/awx/ui/client/features/credentials/add-edit-credentials.view.html index b642a6da07..aa3e581b6d 100644 --- a/awx/ui/client/features/credentials/add-edit-credentials.view.html +++ b/awx/ui/client/features/credentials/add-edit-credentials.view.html @@ -10,9 +10,9 @@ <at-panel-body> <at-form state="vm.form" autocomplete="off" id="credential_form"> - <at-input-text col="4" tab="1" state="vm.form.name" id="credential_name_group"></at-input-text> - <at-input-text col="4" tab="2" state="vm.form.description" id="credential_description_group"></at-input-text> - <at-input-lookup col="4" tab="3" state="vm.form.organization" id="credential_organization_group"></at-input-lookup> + <at-input-text col="4" tab="1" state="vm.form._name" id="credential_name_group"></at-input-text> + <at-input-text col="4" tab="2" state="vm.form._description" id="credential_description_group"></at-input-text> + <at-input-lookup col="4" tab="3" state="vm.form._organization" id="credential_organization_group"></at-input-lookup> <at-divider></at-divider> @@ -56,11 +56,11 @@ on-item-select="vm.onInputSourceItemSelect" on-test="vm.onInputSourceTest" results-filter="vm.filterInputSourceCredentialResults" -/> +></at-input-source-lookup> <at-external-credential-test ng-if="vm.externalTest.metadataInputs" on-close="vm.onExternalTestClose" on-submit="vm.onExternalTest" form="vm.externalTest.form" -/> +></at-external-credential-test> <div ng-if="$state.current.name.includes('permissions.add')" ui-view="modal"></div> diff --git a/awx/ui/client/features/credentials/input-source-lookup.partial.html b/awx/ui/client/features/credentials/input-source-lookup.partial.html index 3c5b0478e2..dc9461ba31 100644 --- a/awx/ui/client/features/credentials/input-source-lookup.partial.html +++ b/awx/ui/client/features/credentials/input-source-lookup.partial.html @@ -23,7 +23,7 @@ ng-show="vm.selectedName" tag="vm.selectedName" icon="external" - /> + ></at-tag> </div> <div class="InputSourceLookup-selectedItemText" @@ -45,7 +45,8 @@ selected-id="vm.selectedId" on-ready="vm.onReady" on-item-select="vm.onItemSelect" - /> + > + </at-lookup-list> <at-form state="vm.form" autocomplete="off" id="input_source_form"> <at-input-group ng-if="vm.tabs.metadata._active" diff --git a/awx/ui/client/lib/components/input/base.controller.js b/awx/ui/client/lib/components/input/base.controller.js index eabd536e34..9a08ed0f2e 100644 --- a/awx/ui/client/lib/components/input/base.controller.js +++ b/awx/ui/client/lib/components/input/base.controller.js @@ -12,7 +12,13 @@ function BaseInputController (strings) { scope.state._touched = false; scope.state._required = scope.state.required || false; - scope.state._isValid = scope.state._isValid || false; + + if (scope.state.type === 'boolean') { + scope.state._isValid = scope.state._isValid || true; + } else { + scope.state._isValid = scope.state._isValid || false; + } + scope.state._disabled = scope.state._disabled || false; scope.state._activeModel = scope.state._activeModel || '_value'; @@ -59,6 +65,10 @@ function BaseInputController (strings) { scope.state._touched = true; } + if (scope.state.type === 'boolean') { + return { isValid, message }; + } + if (scope.state._required && (!scope.state._value || !scope.state._value[0]) && !scope.state._displayValue) { isValid = false; diff --git a/awx/ui/client/lib/components/input/text.partial.html b/awx/ui/client/lib/components/input/text.partial.html index a0f05d1ca7..0d9fb78e17 100644 --- a/awx/ui/client/lib/components/input/text.partial.html +++ b/awx/ui/client/lib/components/input/text.partial.html @@ -22,12 +22,13 @@ icon="external" tag="state._tagValue" remove-tag="state._onRemoveTag(state)" - /> + > + </at-tag> <at-tag ng-show="state._disabled && state._tagValue" icon="external" tag="state._tagValue" - /> + ></at-tag> </div> </span> <input ng-if="!state.asTag" type="text" class="form-control at-Input" diff --git a/awx/ui/client/lib/components/input/textarea-secret.partial.html b/awx/ui/client/lib/components/input/textarea-secret.partial.html index 1c48193b9a..912dc4160b 100644 --- a/awx/ui/client/lib/components/input/textarea-secret.partial.html +++ b/awx/ui/client/lib/components/input/textarea-secret.partial.html @@ -19,7 +19,7 @@ ng-class="{'at-InputFile--drag': drag }" type="file" name="files" - /> + ></input> <div ng-if="state.asTag" ng-disabled="state._disabled || form.disabled" @@ -31,12 +31,12 @@ icon="external" tag="state._tagValue" remove-tag="state._onRemoveTag(state)" - /> + ></at-tag> <at-tag ng-show="state._disabled && state._tagValue" icon="external" tag="state._tagValue" - /> + ></at-tag> </div> </div> <textarea @@ -49,7 +49,7 @@ ng-attr-tabindex="{{ tab || undefined }}" ng-attr-placeholder="{{state._placeholder || undefined }}" ng-disabled="state._disabled || form.disabled" - /> + ></textarea> <div ng-if="state._edit" class="input-group-btn at-InputGroup-button input-group-append"> <button aria-label="{{:: vm.strings.get('secret.REPLACE')}}" diff --git a/awx/ui/client/lib/components/tag/_index.less b/awx/ui/client/lib/components/tag/_index.less index d53a5e19e6..486b304337 100644 --- a/awx/ui/client/lib/components/tag/_index.less +++ b/awx/ui/client/lib/components/tag/_index.less @@ -67,6 +67,10 @@ &--external:before { content: '\f14c' } + + &--kubernetes:before, &--kubernetes_bearer_token:before { + content: '\f0c2'; + } } .TagComponent-button { diff --git a/awx/ui/client/lib/models/Credential.js b/awx/ui/client/lib/models/Credential.js index 27b6a04533..6fbce5e141 100644 --- a/awx/ui/client/lib/models/Credential.js +++ b/awx/ui/client/lib/models/Credential.js @@ -27,6 +27,16 @@ function createFormSchema (method, config) { } }); + // Custom credentials can have input fields named 'name', 'organization', + // 'description', etc. Underscore these variables to make collisions + // less likely to occur. + schema._name = schema.name; + schema._organization = schema.organization; + schema._description = schema.description; + delete schema.name; + delete schema.organization; + delete schema.description; + return schema; } diff --git a/awx/ui/client/src/inventories-hosts/inventories/related/hosts/edit/host-edit.controller.js b/awx/ui/client/src/inventories-hosts/inventories/related/hosts/edit/host-edit.controller.js index 098a6113a2..e54127f176 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/related/hosts/edit/host-edit.controller.js +++ b/awx/ui/client/src/inventories-hosts/inventories/related/hosts/edit/host-edit.controller.js @@ -26,6 +26,9 @@ description: $scope.description, enabled: $scope.host.enabled }; + if (typeof $scope.host.instance_id !== 'undefined') { + host.instance_id = $scope.host.instance_id; + } HostsService.put(host).then(function(){ $state.go('.', null, {reload: true}); }); diff --git a/awx/ui/client/src/inventories-hosts/inventories/related/sources/sources.form.js b/awx/ui/client/src/inventories-hosts/inventories/related/sources/sources.form.js index 2dda6bf73d..3c76dd2e61 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/related/sources/sources.form.js +++ b/awx/ui/client/src/inventories-hosts/inventories/related/sources/sources.form.js @@ -215,7 +215,7 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){ dataTitle: i18n._("Source Variables"), dataPlacement: 'right', awPopOver: "<p>" + i18n._("Override variables found in ec2.ini and used by the inventory update script. For a detailed description of these variables ") + - "<a href=\"https://github.com/ansible-collections/community.aws/blob/master/scripts/inventory/ec2.ini\" target=\"_blank\">" + + "<a href=\"https://github.com/ansible-collections/community.aws/blob/main/scripts/inventory/ec2.ini\" target=\"_blank\">" + i18n._("view ec2.ini in the community.aws repo.") + "</a></p>" + "<p>" + i18n._("Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "</p>" + i18n._("JSON:") + "<br />\n" + @@ -239,7 +239,7 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){ dataTitle: i18n._("Source Variables"), dataPlacement: 'right', awPopOver: "<p>" + i18n._("Override variables found in vmware.ini and used by the inventory update script. For a detailed description of these variables ") + - "<a href=\"https://github.com/ansible-collections/vmware/blob/master/scripts/inventory/vmware_inventory.ini\" target=\"_blank\">" + + "<a href=\"https://github.com/ansible-collections/vmware/blob/main/scripts/inventory/vmware_inventory.ini\" target=\"_blank\">" + i18n._("view vmware_inventory.ini in the vmware community repo.") + "</a></p>" + "<p>" + i18n._("Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "</p>" + i18n._("JSON:") + "<br />\n" + @@ -262,9 +262,9 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){ parseTypeName: 'envParseType', dataTitle: i18n._("Source Variables"), dataPlacement: 'right', - awPopOver: i18n._(`Override variables found in openstack.yml and used by the inventory update script. For an example variable configuration - <a href=\"https://github.com/openstack/ansible-collections-openstack/blob/master/scripts/inventory/openstack.yml\" target=\"_blank\"> - view openstack.yml in the Openstack github repo.</a> Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Tower documentation for example syntax.`), + awPopOver: i18n._("Override variables found in openstack.yml and used by the inventory update script. For an example variable configuration") + + '<a href=\"https://github.com/openstack/ansible-collections-openstack/blob/master/scripts/inventory/openstack.yml\" target=\"_blank\">' + + i18n._("view openstack.yml in the Openstack github repo.") + "</a>" + i18n._("Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Tower documentation for example syntax."), dataContainer: 'body', subForm: 'sourceSubForm' }, @@ -279,9 +279,9 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){ parseTypeName: 'envParseType', dataTitle: i18n._("Source Variables"), dataPlacement: 'right', - awPopOver: i18n._(`Override variables found in cloudforms.ini and used by the inventory update script. For an example variable configuration - <a href=\"https://github.com/ansible-collections/community.general/blob/master/scripts/inventory/cloudforms.ini\" target=\"_blank\"> - view cloudforms.ini in the Ansible Collections github repo.</a> Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Tower documentation for example syntax.`), + awPopOver: i18n._("Override variables found in cloudforms.ini and used by the inventory update script. For an example variable configuration") + + '<a href=\"https://github.com/ansible-collections/community.general/blob/main/scripts/inventory/cloudforms.ini\" target=\"_blank\">' + + i18n._("view cloudforms.ini in the Ansible Collections github repo.") + "</a>" + i18n._(" Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Tower documentation for example syntax."), dataContainer: 'body', subForm: 'sourceSubForm' }, @@ -296,9 +296,9 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){ parseTypeName: 'envParseType', dataTitle: i18n._("Source Variables"), dataPlacement: 'right', - awPopOver: i18n._(`Override variables found in foreman.ini and used by the inventory update script. For an example variable configuration - <a href=\"https://github.com/ansible-collections/community.general/blob/master/scripts/inventory/foreman.ini\" target=\"_blank\"> - view foreman.ini in the Ansible Collections github repo.</a> Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Tower documentation for example syntax.`), + awPopOver: i18n._("Override variables found in foreman.ini and used by the inventory update script. For an example variable configuration") + + '<a href=\"https://github.com/ansible-collections/community.general/blob/main/scripts/inventory/foreman.ini\" target=\"_blank\">' + + i18n._("view foreman.ini in the Ansible Collections github repo.") + "</a>" + i18n._("Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Tower documentation for example syntax."), dataContainer: 'body', subForm: 'sourceSubForm' }, @@ -314,7 +314,7 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){ dataTitle: i18n._("Source Variables"), dataPlacement: 'right', awPopOver: "<p>" + i18n._("Override variables found in azure_rm.ini and used by the inventory update script. For a detailed description of these variables ") + - "<a href=\"https://github.com/ansible-collections/community.general/blob/master/scripts/inventory/azure_rm.ini\" target=\"_blank\">" + + "<a href=\"https://github.com/ansible-collections/community.general/blob/main/scripts/inventory/azure_rm.ini\" target=\"_blank\">" + i18n._("view azure_rm.ini in the Ansible community.general github repo.") + "</a></p>" + "<p>" + i18n._("Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "</p>" + i18n._("JSON:") + "<br />\n" + diff --git a/awx/ui/client/src/login/loginModal/loginModal.controller.js b/awx/ui/client/src/login/loginModal/loginModal.controller.js index 6ce18559f6..657cb55553 100644 --- a/awx/ui/client/src/login/loginModal/loginModal.controller.js +++ b/awx/ui/client/src/login/loginModal/loginModal.controller.js @@ -88,6 +88,7 @@ export default ['$log', '$cookies', '$rootScope', 'ProcessErrors', } scope.customLoginInfo = $AnsibleConfig.custom_login_info; scope.customLoginInfoPresent = (scope.customLoginInfo) ? true : false; + scope.customLoginInfoIsHTML = /<\/?[a-z][\s\S]*>/i.test(scope.customLoginInfo); }); if (scope.removeAuthorizationGetLicense) { diff --git a/awx/ui/client/src/login/loginModal/loginModal.partial.html b/awx/ui/client/src/login/loginModal/loginModal.partial.html index e3133f84c9..d9fa2c0219 100644 --- a/awx/ui/client/src/login/loginModal/loginModal.partial.html +++ b/awx/ui/client/src/login/loginModal/loginModal.partial.html @@ -98,7 +98,12 @@ </div> </div> </form> - <div id="login_modal_notice" class="LoginModalNotice" ng-if="customLoginInfoPresent"><div class="LoginModalNotice-title" translate>NOTICE</div>{{ customLoginInfo | sanitize }}</div> + <div id="login_modal_notice" class="LoginModalNotice" ng-if="customLoginInfoPresent"> + <div class="LoginModalNotice-title" translate>NOTICE</div> + <ng-bind-html ng-bind-html="customLoginInfo" + ng-style="{'white-space' : customLoginInfoIsHTML ? 'initial' : 'pre-wrap'}"> + </ng-bind-html> + </div> </div> <div class="LoginModal-footer"> <div class="LoginModal-footerBlock"> diff --git a/awx/ui/client/src/login/loginModal/loginModalNotice.block.less b/awx/ui/client/src/login/loginModal/loginModalNotice.block.less index 57cb035e8c..d5e6d4b517 100644 --- a/awx/ui/client/src/login/loginModal/loginModalNotice.block.less +++ b/awx/ui/client/src/login/loginModal/loginModalNotice.block.less @@ -12,7 +12,6 @@ color: @login-notice-text; overflow-y: scroll; overflow-x: visible; - white-space: pre-wrap; } .LoginModalNotice-title { diff --git a/awx/ui/client/src/scheduler/schedulerDatePicker.partial.html b/awx/ui/client/src/scheduler/schedulerDatePicker.partial.html index 092c0c7645..bcb2fa01fd 100644 --- a/awx/ui/client/src/scheduler/schedulerDatePicker.partial.html +++ b/awx/ui/client/src/scheduler/schedulerDatePicker.partial.html @@ -6,5 +6,5 @@ type="text" readonly ng-model="dateValue" - ng-class="inputClass()"> + ng-class="inputClass()"/> </div> diff --git a/awx/ui/client/src/scheduler/schedulerForm.partial.html b/awx/ui/client/src/scheduler/schedulerForm.partial.html index 068851e533..0daad2b3b1 100644 --- a/awx/ui/client/src/scheduler/schedulerForm.partial.html +++ b/awx/ui/client/src/scheduler/schedulerForm.partial.html @@ -29,7 +29,7 @@ id="schedulerName" ng-model="schedulerName" required ng-disabled="!(schedule_obj.summary_fields.user_capabilities.edit || canAdd) || credentialRequiresPassword" - placeholder="{{strings.get('form.SCHEDULE_NAME')}}"> + placeholder="{{strings.get('form.SCHEDULE_NAME')}}" /> <div class="error" ng-show="scheduler_form.$dirty && scheduler_form.schedulerName.$error.required"> {{ strings.get('form.NAME_REQUIRED_MESSAGE') }} @@ -74,7 +74,7 @@ placeholder="{{strings.get('form.HH24')}}" aw-min="0" min="0" aw-max="23" max="23" data-zero-pad="2" required - ng-change="timeChange()" > + ng-change="timeChange()" /> <span class="SchedulerTime-separator"> : @@ -90,7 +90,7 @@ placeholder="{{strings.get('form.MM')}}" min="0" max="59" data-zero-pad="2" required - ng-change="timeChange()" > + ng-change="timeChange()" /> <span class="SchedulerTime-separator"> : @@ -106,7 +106,7 @@ placeholder="{{strings.get('form.SS')}}" min="0" max="59" data-zero-pad="2" required - ng-change="timeChange()" > + ng-change="timeChange()" /> </div> <div class="error" ng-show="scheduler_startTime_error"> @@ -173,7 +173,7 @@ min="1" max="999" ng-change="resetError('scheduler_interval_error')" - > + /> <label class="inline-label RepeatFrequencyOptions-inlineLabel" ng-bind="schedulerIntervalLabel"> @@ -196,7 +196,7 @@ ng-model="$parent.monthlyRepeatOption" ng-change="monthlyRepeatChange()" name="monthlyRepeatOption" - id="monthlyRepeatOption"> + id="monthlyRepeatOption" /> {{ strings.get('form.ON_DAY') }} </label> </div> @@ -209,7 +209,7 @@ aw-spinner="$parent.monthDay" ng-model="$parent.monthDay" min="1" max="31" - ng-change="resetError('scheduler_monthDay_error')" > + ng-change="resetError('scheduler_monthDay_error')" /> <div class="error" ng-show="$parent.scheduler_monthDay_error"> {{ strings.get('form.MONTH_DAY_ERROR_MESSAGE') }} @@ -228,7 +228,7 @@ ng-model="$parent.monthlyRepeatOption" ng-change="monthlyRepeatChange()" name="monthlyRepeatOption" - id="monthlyRepeatOption"> + id="monthlyRepeatOption" /> {{ strings.get('form.ON_THE') }} </label> </div> @@ -267,7 +267,7 @@ ng-model="$parent.yearlyRepeatOption" ng-change="yearlyRepeatChange()" name="yearlyRepeatOption" - id="yearlyRepeatOption"> + id="yearlyRepeatOption" /> {{ strings.get('form.ON') }} </label> </div> @@ -292,7 +292,7 @@ ng-model="$parent.yearlyMonthDay" min="1" max="31" ng-change="resetError('scheduler_yearlyMonthDay_error')" - > + /> </div> <div class="error" ng-show="$parent.scheduler_yearlyMonthDay_error"> @@ -312,7 +312,7 @@ ng-model="$parent.yearlyRepeatOption" ng-change="yearlyRepeatChange()" name="yearlyRepeatOption" - id="yearlyRepeatOption"> + id="yearlyRepeatOption" /> {{ strings.get('form.ON_THE') }} </label> </div> @@ -524,7 +524,7 @@ placeholder="{{strings.get('form.HH24')}}" aw-min="0" min="0" aw-max="23" max="23" data-zero-pad="2" required - ng-change="schedulerEndChange('schedulerEndHour', $parent.schedulerEndHour)" > + ng-change="schedulerEndChange('schedulerEndHour', $parent.schedulerEndHour)" /> <span class="SchedulerTime-separator"> : @@ -540,7 +540,7 @@ placeholder="{{strings.get('form.MM')}}" min="0" max="59" data-zero-pad="2" required - ng-change="schedulerEndChange('schedulerEndMinute', $parent.schedulerEndMinute)" > + ng-change="schedulerEndChange('schedulerEndMinute', $parent.schedulerEndMinute)" /> <span class="SchedulerTime-separator"> : @@ -556,7 +556,7 @@ placeholder="{{strings.get('form.SS')}}" min="0" max="59" data-zero-pad="2" required - ng-change="schedulerEndChange('schedulerEndSecond', $parent.schedulerEndSecond)" > + ng-change="schedulerEndChange('schedulerEndSecond', $parent.schedulerEndSecond)" /> </div> <div class="error" ng-show="scheduler_startTime_error"> @@ -604,7 +604,7 @@ class="SchedulerFormDetail-radioButton" ng-model="dateChoice" id="date-choice-local" - value="local" > + value="local" /> {{ strings.get('form.LOCAL_TIME_ZONE') }} </label> <label class="radio-inline @@ -613,7 +613,7 @@ class="SchedulerFormDetail-radioButton" ng-model="dateChoice" id="date-choice-utc" - value="utc" > + value="utc" /> UTC </label> </div> diff --git a/awx/ui/client/src/templates/job_templates/multi-credential/multi-credential-modal.directive.js b/awx/ui/client/src/templates/job_templates/multi-credential/multi-credential-modal.directive.js index 19e98d7ca3..fee9395c5c 100644 --- a/awx/ui/client/src/templates/job_templates/multi-credential/multi-credential-modal.directive.js +++ b/awx/ui/client/src/templates/job_templates/multi-credential/multi-credential-modal.directive.js @@ -111,7 +111,7 @@ function multiCredentialModalController(GetBasePath, qs, MultiCredentialService) scope.credentialTypes.forEach((credentialType => { if(credentialType.kind - .match(/^(machine|cloud|net|ssh|vault)$/)) { + .match(/^(machine|cloud|net|ssh|vault|kubernetes)$/)) { scope.displayedCredentialTypes.push(credentialType); } })); diff --git a/awx/ui/client/src/templates/job_templates/multi-credential/multi-credential-modal.partial.html b/awx/ui/client/src/templates/job_templates/multi-credential/multi-credential-modal.partial.html index 22c019d4a0..f40c9c55a4 100644 --- a/awx/ui/client/src/templates/job_templates/multi-credential/multi-credential-modal.partial.html +++ b/awx/ui/client/src/templates/job_templates/multi-credential/multi-credential-modal.partial.html @@ -29,6 +29,7 @@ <i class="fa fa-code-fork MultiCredential-tagIcon" ng-switch-when="scm"></i> <i class="fa fa-key MultiCredential-tagIcon" ng-switch-when="ssh"></i> <i class="fa fa-archive MultiCredential-tagIcon" ng-switch-when="vault"></i> + <i class="fa fa-cloud MultiCredential-tagIcon" ng-switch-when="kubernetes"></i> </div> <div class="MultiCredential-tag MultiCredential-tag--deletable"> <span ng-if="!tag.info" class="MultiCredential-name--label ng-binding"> diff --git a/awx/ui/client/src/templates/job_templates/multi-credential/multi-credential.partial.html b/awx/ui/client/src/templates/job_templates/multi-credential/multi-credential.partial.html index 052c81f94f..054d48a29c 100644 --- a/awx/ui/client/src/templates/job_templates/multi-credential/multi-credential.partial.html +++ b/awx/ui/client/src/templates/job_templates/multi-credential/multi-credential.partial.html @@ -26,6 +26,7 @@ <i class="fa fa-code-fork MultiCredential-tagIcon" ng-switch-when="scm"></i> <i class="fa fa-key MultiCredential-tagIcon" ng-switch-when="ssh"></i> <i class="fa fa-archive MultiCredential-tagIcon" ng-switch-when="vault"></i> + <i class="fa fa-cloud MultiCredential-tagIcon" ng-switch-when="kubernetes"></i> </div> <div class="MultiCredential-iconContainer" ng-switch="tag.kind" ng-if="!fieldIsDisabled"> <i class="fa fa-cloud MultiCredential-tagIcon" ng-switch-when="cloud"></i> @@ -34,6 +35,7 @@ <i class="fa fa-code-fork MultiCredential-tagIcon" ng-switch-when="scm"></i> <i class="fa fa-key MultiCredential-tagIcon" ng-switch-when="ssh"></i> <i class="fa fa-archive MultiCredential-tagIcon" ng-switch-when="vault"></i> + <i class="fa fa-cloud MultiCredential-tagIcon" ng-switch-when="kubernetes"></i> </div> <div class="MultiCredential-tag" ng-class="{'MultiCredential-tag--deletable': !fieldIsDisabled, 'MultiCredential-tag--disabled': fieldIsDisabled}"> diff --git a/awx/ui/client/src/templates/prompt/prompt.controller.js b/awx/ui/client/src/templates/prompt/prompt.controller.js index 53a8370a0c..f923c8018e 100644 --- a/awx/ui/client/src/templates/prompt/prompt.controller.js +++ b/awx/ui/client/src/templates/prompt/prompt.controller.js @@ -55,7 +55,7 @@ export default [ 'ProcessErrors', 'CredentialTypeModel', 'TemplatesStrings', '$f vm.promptDataClone.prompts.credentials.credentialTypeOptions = []; response.data.results.forEach((credentialTypeRow => { vm.promptDataClone.prompts.credentials.credentialTypes[credentialTypeRow.id] = credentialTypeRow.kind; - if(credentialTypeRow.kind.match(/^(cloud|net|ssh|vault)$/)) { + if(credentialTypeRow.kind.match(/^(cloud|net|ssh|vault|kubernetes)$/)) { if(credentialTypeRow.kind === 'ssh') { vm.promptDataClone.prompts.credentials.credentialKind = credentialTypeRow.id.toString(); } diff --git a/awx/ui/conf.py b/awx/ui/conf.py index c8f6d3491b..335ca0d386 100644 --- a/awx/ui/conf.py +++ b/awx/ui/conf.py @@ -31,8 +31,8 @@ register( label=_('Custom Login Info'), help_text=_('If needed, you can add specific information (such as a legal ' 'notice or a disclaimer) to a text box in the login modal using ' - 'this setting. Any content added must be in plain text, as ' - 'custom HTML or other markup languages are not supported.'), + 'this setting. Any content added must be in plain text or an ' + 'HTML fragment, as other markup languages are not supported.'), category=_('UI'), category_slug='ui', ) diff --git a/awx/ui/package-lock.json b/awx/ui/package-lock.json index 27bdc044cc..24b7837312 100644 --- a/awx/ui/package-lock.json +++ b/awx/ui/package-lock.json @@ -241,11 +241,11 @@ "integrity": "sha512-nB/xe7JQWF9nLvhHommAICQ3eWrfRETo0EVGFESi952CDzDa+GAJ/2BFBNw44QqQPxj1Xua/uYKrbLsOGWZdbQ==" }, "angular-scheduler": { - "version": "git+https://git@github.com/ansible/angular-scheduler.git#6a2d33b06b1143e7449c4427f222fd05559f3a23", - "from": "git+https://git@github.com/ansible/angular-scheduler.git#v0.4.3", + "version": "git+https://git@github.com/ansible/angular-scheduler.git#d72b62f47fb5c11b3284eaaea11c4d5525fa3b99", + "from": "git+https://git@github.com/ansible/angular-scheduler.git#v0.4.4", "requires": { "angular": "^1.7.9", - "angular-tz-extensions": "github:ansible/angular-tz-extensions#5c594b5756d29637601020bba16274f10ee0ed65", + "angular-tz-extensions": "github:ansible/angular-tz-extensions", "jquery": "^3.5.1", "jquery-ui": "*", "lodash": "^4.17.15", @@ -261,14 +261,9 @@ "angular-filters": "^1.1.2", "jquery": "^3.5.1", "jstimezonedetect": "1.0.5", - "timezone-js": "github:ansible/timezone-js#6937de14ce0c193961538bb5b3b12b7ef62a358f" + "timezone-js": "github:ansible/timezone-js#0.4.14" } }, - "jquery": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz", - "integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg==" - }, "rrule": { "version": "github:jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c", "from": "github:jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c" @@ -283,13 +278,17 @@ "angular-filters": "^1.1.2", "jquery": "^3.5.1", "jstimezonedetect": "1.0.5", - "timezone-js": "github:ansible/timezone-js#6937de14ce0c193961538bb5b3b12b7ef62a358f" + "timezone-js": "github:ansible/timezone-js#0.4.14" }, "dependencies": { "jquery": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz", "integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg==" + }, + "timezone-js": { + "version": "github:ansible/timezone-js#6937de14ce0c193961538bb5b3b12b7ef62a358f", + "from": "github:ansible/timezone-js#0.4.14" } } }, @@ -1803,6 +1802,7 @@ "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz", "integrity": "sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8=", "dev": true, + "optional": true, "requires": { "hoek": "2.x.x" } @@ -5374,7 +5374,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -5398,13 +5399,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5414,19 +5417,22 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -5547,7 +5553,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -5561,6 +5568,7 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -5577,6 +5585,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -5682,7 +5691,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -5696,6 +5706,7 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -5791,7 +5802,8 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -5833,6 +5845,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -5854,6 +5867,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -5886,7 +5900,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true + "dev": true, + "optional": true } } }, @@ -6633,7 +6648,8 @@ "version": "2.16.3", "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=", - "dev": true + "dev": true, + "optional": true }, "home-or-tmp": { "version": "2.0.0", @@ -9131,6 +9147,7 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz", "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==", "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -9140,7 +9157,8 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", - "dev": true + "dev": true, + "optional": true } } }, diff --git a/awx/ui/package.json b/awx/ui/package.json index 1f6b603665..59cb964b5c 100644 --- a/awx/ui/package.json +++ b/awx/ui/package.json @@ -107,7 +107,7 @@ "angular-moment": "^1.3.0", "angular-mousewheel": "^1.0.5", "angular-sanitize": "^1.7.9", - "angular-scheduler": "git+https://git@github.com/ansible/angular-scheduler.git#v0.4.3", + "angular-scheduler": "git+https://git@github.com/ansible/angular-scheduler.git#v0.4.4", "angular-tz-extensions": "git+https://git@github.com/ansible/angular-tz-extensions.git#v0.6.1", "angular-xeditable": "~0.8.0", "ansi-to-html": "^0.6.3", diff --git a/awx/ui_next/.npmrc b/awx/ui_next/.npmrc index e69de29bb2..c42da845b4 100644 --- a/awx/ui_next/.npmrc +++ b/awx/ui_next/.npmrc @@ -0,0 +1 @@ +engine-strict = true diff --git a/awx/ui_next/package-lock.json b/awx/ui_next/package-lock.json index 827baecd33..6e5590c6db 100644 --- a/awx/ui_next/package-lock.json +++ b/awx/ui_next/package-lock.json @@ -3238,45 +3238,38 @@ "dev": true }, "@patternfly/patternfly": { - "version": "4.10.31", - "resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-4.10.31.tgz", - "integrity": "sha512-UxdZ/apWRowXYZ5qPz5LPfXwyB4YGpomrCJPX7c36+Zg8jFpYyVqgVYainL8Yf/GrChtC2LKyoHg7UUTtMtp4A==" + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-4.23.3.tgz", + "integrity": "sha512-q8C98ihcRYBY+FB+KY3bQ9y1Pn/NjBff4hwKsxatrs/MSO/++CuEncg4q7WHjIq2zadA4/7W+Vg3CXuiOP0geg==" }, "@patternfly/react-core": { - "version": "4.18.14", - "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.18.14.tgz", - "integrity": "sha512-aFOBX02ud78eCu7rtbUTr+Rj/J7BertxDssSWFb+uDQrUN268dSH9/tvHcDd3//YZrsCoBbky9ngRa4Jt1fryg==", + "version": "4.32.1", + "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.32.1.tgz", + "integrity": "sha512-4FrKJvMfjHjWtmvGu1QVxo/nCdUgePlkdNzMs91r0wdL16CpfoQVtZcfZe4343fRAQ2ObeYfZ2GuiwvS1sw8Og==", "requires": { - "@patternfly/react-icons": "^4.3.6", - "@patternfly/react-styles": "^4.3.6", - "@patternfly/react-tokens": "^4.4.5", + "@patternfly/react-icons": "^4.5.0", + "@patternfly/react-styles": "^4.5.0", + "@patternfly/react-tokens": "^4.6.0", "focus-trap": "4.0.2", "react-dropzone": "9.0.0", "tippy.js": "5.1.2", "tslib": "^1.11.1" - }, - "dependencies": { - "@patternfly/react-icons": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.4.0.tgz", - "integrity": "sha512-UKQI5luZ6Bd3SLljl4WNFVhtteUiM2lbKz5qjgBZn3zLOW2Zigv6M1zkgII6rMW9Rxql/UEDZkvNmilf84HW+g==" - } } }, "@patternfly/react-icons": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.3.5.tgz", - "integrity": "sha512-+GublxpFXR+y/5zygf9q00/LvIvso8jr0mxZGhVxsKmi2dUu7xAvN+T+5vjS9fiMbXf7WXsSPXST/UTiBIVTdQ==" + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.5.0.tgz", + "integrity": "sha512-wXAENYa6nST4D8DBkiCrZXf4aRTmVQNA4cyImMJ3aQWAzwJ7Xc1zIBBuYSX5EP0JOuf9DzWVCrzvgfQz1Fcx8g==" }, "@patternfly/react-styles": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.4.0.tgz", - "integrity": "sha512-0guVqVVvLgDMKAqLM9Vb3T9sjSPBGm9DzTURuZrIz/gx9gKuckSA42OS1aTTtZLXz6ryYoOn7uQJiIhaJu1F0Q==" + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.5.0.tgz", + "integrity": "sha512-6w8mvxx/cC+yUzBKlWY8YRnavlWCTLWly1si0skleYPF1t69f3P+jeXNy39kH6+o2vXJR5MeecLrnuMV0XtKvg==" }, "@patternfly/react-tokens": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.5.0.tgz", - "integrity": "sha512-cfxWduAIIFuRnuTuTkColGCoGPmdXy2ousabpGd+Yi3vbwWcWYIRlrLuetK1VMmddnt2PW9PnaLDW6bH3+oagQ==" + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.6.0.tgz", + "integrity": "sha512-zpA4AlYqJNJm5aqsarBVjod1gjP9muJ5oWI2ZwGUFiw4YNRn8eY7QKQ1VvNZxqwI+WSXl98jTqJiKuJGF3DEvw==" }, "@svgr/babel-plugin-add-jsx-attribute": { "version": "4.2.0", @@ -11189,9 +11182,9 @@ } }, "lodash": { - "version": "4.17.15", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + "version": "4.17.19", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==" }, "lodash-es": { "version": "4.17.15", diff --git a/awx/ui_next/package.json b/awx/ui_next/package.json index b7e7cc6b69..83818e3a86 100644 --- a/awx/ui_next/package.json +++ b/awx/ui_next/package.json @@ -2,11 +2,14 @@ "name": "ui_next", "version": "0.1.0", "private": true, + "engines": { + "node": "10.x" + }, "dependencies": { "@lingui/react": "^2.9.1", - "@patternfly/patternfly": "^4.10.31", - "@patternfly/react-core": "4.18.14", - "@patternfly/react-icons": "^4.3.5", + "@patternfly/patternfly": "^4.23.3", + "@patternfly/react-core": "^4.32.1", + "@patternfly/react-icons": "^4.5.0", "ansi-to-html": "^0.6.11", "axios": "^0.18.1", "codemirror": "^5.47.0", diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index 2de6a235e0..c3cfc1167f 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -24,6 +24,7 @@ import Roles from './models/Roles'; import Schedules from './models/Schedules'; import SystemJobs from './models/SystemJobs'; import Teams from './models/Teams'; +import Tokens from './models/Tokens'; import UnifiedJobTemplates from './models/UnifiedJobTemplates'; import UnifiedJobs from './models/UnifiedJobs'; import Users from './models/Users'; @@ -58,6 +59,7 @@ const RolesAPI = new Roles(); const SchedulesAPI = new Schedules(); const SystemJobsAPI = new SystemJobs(); const TeamsAPI = new Teams(); +const TokensAPI = new Tokens(); const UnifiedJobTemplatesAPI = new UnifiedJobTemplates(); const UnifiedJobsAPI = new UnifiedJobs(); const UsersAPI = new Users(); @@ -93,6 +95,7 @@ export { SchedulesAPI, SystemJobsAPI, TeamsAPI, + TokensAPI, UnifiedJobTemplatesAPI, UnifiedJobsAPI, UsersAPI, diff --git a/awx/ui_next/src/api/models/Applications.js b/awx/ui_next/src/api/models/Applications.js index 50b709bdca..a8fe15f694 100644 --- a/awx/ui_next/src/api/models/Applications.js +++ b/awx/ui_next/src/api/models/Applications.js @@ -5,6 +5,16 @@ class Applications extends Base { super(http); this.baseUrl = '/api/v2/applications/'; } + + readTokens(appId, params) { + return this.http.get(`${this.baseUrl}${appId}/tokens/`, { + params, + }); + } + + readTokenOptions(appId) { + return this.http.options(`${this.baseUrl}${appId}/tokens/`); + } } export default Applications; diff --git a/awx/ui_next/src/api/models/CredentialTypes.js b/awx/ui_next/src/api/models/CredentialTypes.js index d2d993091c..dab1676231 100644 --- a/awx/ui_next/src/api/models/CredentialTypes.js +++ b/awx/ui_next/src/api/models/CredentialTypes.js @@ -7,7 +7,7 @@ class CredentialTypes extends Base { } async loadAllTypes( - acceptableKinds = ['machine', 'cloud', 'net', 'ssh', 'vault'] + acceptableKinds = ['machine', 'cloud', 'net', 'ssh', 'vault', 'kubernetes'] ) { const pageSize = 200; // The number of credential types a user can have is unlimited. In practice, it is unlikely for diff --git a/awx/ui_next/src/api/models/NotificationTemplates.js b/awx/ui_next/src/api/models/NotificationTemplates.js index 7736921ad2..69cd5f4022 100644 --- a/awx/ui_next/src/api/models/NotificationTemplates.js +++ b/awx/ui_next/src/api/models/NotificationTemplates.js @@ -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; diff --git a/awx/ui_next/src/api/models/Teams.js b/awx/ui_next/src/api/models/Teams.js index de2d3db077..1a205993d4 100644 --- a/awx/ui_next/src/api/models/Teams.js +++ b/awx/ui_next/src/api/models/Teams.js @@ -28,6 +28,16 @@ class Teams extends Base { readRoleOptions(teamId) { return this.http.options(`${this.baseUrl}${teamId}/roles/`); } + + readAccessList(teamId, params) { + return this.http.get(`${this.baseUrl}${teamId}/access_list/`, { + params, + }); + } + + readUsersAccessOptions(teamId) { + return this.http.options(`${this.baseUrl}${teamId}/users/`); + } } export default Teams; diff --git a/awx/ui_next/src/api/models/Tokens.js b/awx/ui_next/src/api/models/Tokens.js new file mode 100644 index 0000000000..5dd490808d --- /dev/null +++ b/awx/ui_next/src/api/models/Tokens.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Tokens extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/tokens/'; + } +} + +export default Tokens; diff --git a/awx/ui_next/src/api/models/Users.js b/awx/ui_next/src/api/models/Users.js index 3d4ec4aac9..c9d47826e2 100644 --- a/awx/ui_next/src/api/models/Users.js +++ b/awx/ui_next/src/api/models/Users.js @@ -12,6 +12,10 @@ class Users extends Base { }); } + createToken(userId, data) { + return this.http.post(`${this.baseUrl}${userId}/authorized_tokens/`, data); + } + disassociateRole(userId, roleId) { return this.http.post(`${this.baseUrl}${userId}/roles/`, { id: roleId, @@ -50,6 +54,16 @@ class Users extends Base { params, }); } + + readAdminOfOrganizations(userId, params) { + return this.http.get(`${this.baseUrl}${userId}/admin_of_organizations/`, { + params, + }); + } + + readTokenOptions(userId) { + return this.http.options(`${this.baseUrl}${userId}/tokens/`); + } } export default Users; diff --git a/awx/ui_next/src/components/AddDropDownButton/AddDropDownButton.jsx b/awx/ui_next/src/components/AddDropDownButton/AddDropDownButton.jsx index 78655e44d9..ca4c4a40b6 100644 --- a/awx/ui_next/src/components/AddDropDownButton/AddDropDownButton.jsx +++ b/awx/ui_next/src/components/AddDropDownButton/AddDropDownButton.jsx @@ -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); diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 5727a78c74..2da3b02e9d 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx @@ -156,16 +156,16 @@ class AddResourceRole extends React.Component { const userSearchColumns = [ { name: i18n._(t`Username`), - key: 'username', + key: 'username__icontains', isDefault: true, }, { name: i18n._(t`First Name`), - key: 'first_name', + key: 'first_name__icontains', }, { name: i18n._(t`Last Name`), - key: 'last_name', + key: 'last_name__icontains', }, ]; diff --git a/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx b/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx index d309ea706f..c4c83f9d3b 100644 --- a/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx +++ b/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx @@ -13,7 +13,7 @@ describe('<SelectResourceStep />', () => { const searchColumns = [ { name: 'Username', - key: 'username', + key: 'username__icontains', isDefault: true, }, ]; diff --git a/awx/ui_next/src/components/AppContainer/NavExpandableGroup.jsx b/awx/ui_next/src/components/AppContainer/NavExpandableGroup.jsx index 370ac52f40..1ebbb7e9ae 100644 --- a/awx/ui_next/src/components/AppContainer/NavExpandableGroup.jsx +++ b/awx/ui_next/src/components/AppContainer/NavExpandableGroup.jsx @@ -1,16 +1,18 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { withRouter, Link } from 'react-router-dom'; +import { matchPath, Link, withRouter } from 'react-router-dom'; import { NavExpandable, NavItem } from '@patternfly/react-core'; class NavExpandableGroup extends Component { constructor(props) { super(props); const { routes } = this.props; + this.state = { isExpanded: false }; + // Extract a list of paths from the route params and store them for later. This creates // an array of url paths associated with any NavItem component rendered by this component. this.navItemPaths = routes.map(({ path }) => path); - + this.handleExpand = this.handleExpand.bind(this); this.isActiveGroup = this.isActiveGroup.bind(this); this.isActivePath = this.isActivePath.bind(this); } @@ -21,20 +23,33 @@ class NavExpandableGroup extends Component { isActivePath(path) { const { history } = this.props; + return Boolean(matchPath(history.location.pathname, { path })); + } - return history.location.pathname.startsWith(path); + handleExpand(e, isExpanded) { + this.setState({ isExpanded }); } render() { const { groupId, groupTitle, routes } = this.props; - const isActive = this.isActiveGroup(); + const { isExpanded } = this.state; + + if (routes.length === 1) { + const [{ path }] = routes; + return ( + <NavItem itemId={groupId} isActive={this.isActivePath(path)} key={path}> + <Link to={path}>{groupTitle}</Link> + </NavItem> + ); + } return ( <NavExpandable - isActive={isActive} - isExpanded={isActive} + isActive={this.isActiveGroup()} + isExpanded={isExpanded} groupId={groupId} title={groupTitle} + onExpand={this.handleExpand} > {routes.map(({ path, title }) => ( <NavItem diff --git a/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx b/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx index e813e1f70b..339b5ed744 100644 --- a/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx +++ b/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx @@ -114,16 +114,16 @@ function AssociateModal({ searchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} sortColumns={[ diff --git a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx index 7db0bae778..1ddfb57df6 100644 --- a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx +++ b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx @@ -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,97 +9,137 @@ 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 { - clearAllFilters, - searchColumns, - sortColumns, - showSelectAll, - isAllSelected, - isCompact, - onSort, - onSearch, - onReplaceSearch, - onRemove, - onCompact, - onExpand, - onSelectAll, - additionalControls, - i18n, - qsConfig, - } = 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} - 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> - </ToolbarContent> - </Toolbar> - ); - } + )} + {!advancedSearchShown && pagination && itemCount > 0 && ( + <ToolbarItem variant="pagination">{pagination}</ToolbarItem> + )} + </ToolbarContent> + </Toolbar> + ); } DataListToolbar.propTypes = { + itemCount: PropTypes.number, clearAllFilters: PropTypes.func, qsConfig: QSConfig.isRequired, searchColumns: SearchColumns.isRequired, + searchableKeys: PropTypes.arrayOf(PropTypes.string), + relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string), sortColumns: SortColumns.isRequired, showSelectAll: PropTypes.bool, isAllSelected: PropTypes.bool, @@ -114,6 +154,9 @@ DataListToolbar.propTypes = { }; DataListToolbar.defaultProps = { + itemCount: 0, + searchableKeys: [], + relatedSearchableKeys: [], clearAllFilters: null, showSelectAll: false, isAllSelected: false, diff --git a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx index b8b83ee6e2..6f90cc5d89 100644 --- a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx +++ b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx @@ -25,7 +25,9 @@ describe('<DataListToolbar />', () => { const onSelectAll = jest.fn(); test('it triggers the expected callbacks', () => { - const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }]; + const searchColumns = [ + { name: 'Name', key: 'name__icontains', isDefault: true }, + ]; const sortColumns = [{ name: 'Name', key: 'name' }]; const search = 'button[aria-label="Search submit button"]'; const searchTextInput = 'input[aria-label="Search text input"]'; @@ -36,7 +38,6 @@ describe('<DataListToolbar />', () => { <DataListToolbar qsConfig={QS_CONFIG} isAllSelected={false} - showExpandCollapse searchColumns={searchColumns} sortColumns={sortColumns} onSearch={onSearch} @@ -67,11 +68,12 @@ describe('<DataListToolbar />', () => { test('dropdown items sortable/searchable columns work', () => { const sortDropdownToggleSelector = 'button[id="awx-sort"]'; - const searchDropdownToggleSelector = 'button[id="awx-search"]'; + const searchDropdownToggleSelector = + 'Select[aria-label="Simple key select"] SelectToggle'; const sortDropdownMenuItems = 'DropdownMenu > ul[aria-labelledby="awx-sort"]'; const searchDropdownMenuItems = - 'DropdownMenu > ul[aria-labelledby="awx-search"]'; + 'Select[aria-label="Simple key select"] SelectOption'; const NEW_QS_CONFIG = { namespace: 'organization', @@ -109,7 +111,7 @@ describe('<DataListToolbar />', () => { searchDropdownToggle.simulate('click'); toolbar.update(); let searchDropdownItems = toolbar.find(searchDropdownMenuItems).children(); - expect(searchDropdownItems.length).toBe(1); + expect(searchDropdownItems.length).toBe(2); const mockedSortEvent = { target: { innerText: 'Bar' } }; searchDropdownItems.at(0).simulate('click', mockedSortEvent); toolbar = mountWithContexts( @@ -145,7 +147,7 @@ describe('<DataListToolbar />', () => { toolbar.update(); searchDropdownItems = toolbar.find(searchDropdownMenuItems).children(); - expect(searchDropdownItems.length).toBe(1); + expect(searchDropdownItems.length).toBe(2); const mockedSearchEvent = { target: { innerText: 'Bar' } }; searchDropdownItems.at(0).simulate('click', mockedSearchEvent); @@ -272,7 +274,6 @@ describe('<DataListToolbar />', () => { <DataListToolbar qsConfig={QS_CONFIG} isAllSelected - showExpandCollapse searchColumns={searchColumns} sortColumns={sortColumns} onSearch={onSearch} @@ -285,4 +286,31 @@ describe('<DataListToolbar />', () => { const checkbox = toolbar.find('Checkbox'); expect(checkbox.prop('isChecked')).toBe(true); }); + + test('always adds advanced item to search column array', () => { + const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }]; + const sortColumns = [{ name: 'Name', key: 'name' }]; + + toolbar = mountWithContexts( + <DataListToolbar + qsConfig={QS_CONFIG} + searchColumns={searchColumns} + sortColumns={sortColumns} + onSearch={onSearch} + onReplaceSearch={onReplaceSearch} + onSort={onSort} + onSelectAll={onSelectAll} + additionalControls={[ + <button key="1" id="test" type="button"> + click + </button>, + ]} + /> + ); + + const search = toolbar.find('Search'); + expect( + search.prop('columns').filter(col => col.key === 'advanced').length + ).toBe(1); + }); }); diff --git a/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx b/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx index 383d5fab97..19e499952d 100644 --- a/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx +++ b/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx @@ -18,7 +18,7 @@ function DeleteButton({ return ( <> <Button - variant={variant || 'danger'} + variant={variant || 'secondary'} aria-label={i18n._(t`Delete`)} isDisabled={isDisabled} onClick={() => setIsOpen(true)} diff --git a/awx/ui_next/src/components/DetailList/DetailBadge.jsx b/awx/ui_next/src/components/DetailList/DetailBadge.jsx new file mode 100644 index 0000000000..7447a896f8 --- /dev/null +++ b/awx/ui_next/src/components/DetailList/DetailBadge.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { node } from 'prop-types'; +import styled from 'styled-components'; +import { Badge } from '@patternfly/react-core'; + +import _Detail from './Detail'; + +const Detail = styled(_Detail)` + word-break: break-word; +`; + +function DetailBadge({ label, content, dataCy = null }) { + return ( + <Detail + label={label} + dataCy={dataCy} + value={<Badge isRead>{content}</Badge>} + /> + ); +} +DetailBadge.propTypes = { + label: node.isRequired, + content: node.isRequired, +}; + +export default DetailBadge; diff --git a/awx/ui_next/src/components/DetailList/ObjectDetail.jsx b/awx/ui_next/src/components/DetailList/ObjectDetail.jsx new file mode 100644 index 0000000000..bf008866a8 --- /dev/null +++ b/awx/ui_next/src/components/DetailList/ObjectDetail.jsx @@ -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; diff --git a/awx/ui_next/src/components/DetailList/index.js b/awx/ui_next/src/components/DetailList/index.js index 8573f1a614..8bebb27ce4 100644 --- a/awx/ui_next/src/components/DetailList/index.js +++ b/awx/ui_next/src/components/DetailList/index.js @@ -2,3 +2,9 @@ export { default as DetailList } from './DetailList'; 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 +*/ diff --git a/awx/ui_next/src/components/FormField/FormField.jsx b/awx/ui_next/src/components/FormField/FormField.jsx index 0efa21887a..68aab35976 100644 --- a/awx/ui_next/src/components/FormField/FormField.jsx +++ b/awx/ui_next/src/components/FormField/FormField.jsx @@ -31,8 +31,10 @@ function FormField(props) { isRequired={isRequired} validated={isValid ? 'default' : 'error'} label={label} + labelIcon={ + <FieldTooltip content={tooltip} maxWidth={tooltipMaxWidth} /> + } > - <FieldTooltip content={tooltip} maxWidth={tooltipMaxWidth} /> <TextArea id={id} isRequired={isRequired} @@ -53,8 +55,10 @@ function FormField(props) { isRequired={isRequired} validated={isValid ? 'default' : 'error'} label={label} + labelIcon={ + <FieldTooltip content={tooltip} maxWidth={tooltipMaxWidth} /> + } > - <FieldTooltip content={tooltip} maxWidth={tooltipMaxWidth} /> <TextInput id={id} isRequired={isRequired} diff --git a/awx/ui_next/src/components/FormField/FormSubmitError.jsx b/awx/ui_next/src/components/FormField/FormSubmitError.jsx index 7b6edc6cb3..44ad062f59 100644 --- a/awx/ui_next/src/components/FormField/FormSubmitError.jsx +++ b/awx/ui_next/src/components/FormField/FormSubmitError.jsx @@ -2,6 +2,26 @@ import React, { useState, useEffect } from 'react'; import { useFormikContext } from 'formik'; import { Alert } from '@patternfly/react-core'; +const findErrorStrings = (obj, messages = []) => { + if (typeof obj === 'string') { + messages.push(obj); + } else if (typeof obj === 'object') { + Object.keys(obj).forEach(key => { + const value = obj[key]; + if (typeof value === 'string') { + messages.push(value); + } else if (Array.isArray(value)) { + value.forEach(arrValue => { + messages = findErrorStrings(arrValue, messages); + }); + } else if (typeof value === 'object') { + messages = findErrorStrings(value, messages); + } + }); + } + return messages; +}; + function FormSubmitError({ error }) { const [errorMessage, setErrorMessage] = useState(null); const { setErrors } = useFormikContext(); @@ -18,12 +38,7 @@ function FormSubmitError({ error }) { const errorMessages = error.response.data; setErrors(errorMessages); - let messages = []; - Object.values(error.response.data).forEach(value => { - if (Array.isArray(value)) { - messages = messages.concat(value); - } - }); + const messages = findErrorStrings(error.response.data); setErrorMessage(messages.length > 0 ? messages : null); } else { /* eslint-disable-next-line no-console */ diff --git a/awx/ui_next/src/components/FormField/FormSubmitError.test.jsx b/awx/ui_next/src/components/FormField/FormSubmitError.test.jsx index 7dd41922bd..30656b4d62 100644 --- a/awx/ui_next/src/components/FormField/FormSubmitError.test.jsx +++ b/awx/ui_next/src/components/FormField/FormSubmitError.test.jsx @@ -52,4 +52,30 @@ describe('<FormSubmitError>', () => { expect(global.console.error).toHaveBeenCalledWith(error); global.console = realConsole; }); + + test('should display error message if field error is nested', async () => { + const error = { + response: { + data: { + name: 'There was an error with name', + inputs: { + url: 'Error with url', + }, + }, + }, + }; + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + <Formik>{() => <FormSubmitError error={error} />}</Formik> + ); + }); + wrapper.update(); + expect( + wrapper.find('Alert').contains(<div>There was an error with name</div>) + ).toEqual(true); + expect(wrapper.find('Alert').contains(<div>Error with url</div>)).toEqual( + true + ); + }); }); diff --git a/awx/ui_next/src/components/HostForm/HostForm.jsx b/awx/ui_next/src/components/HostForm/HostForm.jsx index f7bf4d70c3..a552b2cd40 100644 --- a/awx/ui_next/src/components/HostForm/HostForm.jsx +++ b/awx/ui_next/src/components/HostForm/HostForm.jsx @@ -25,6 +25,13 @@ const InventoryLookupField = withI18n()(({ i18n, host }) => { return ( <FormGroup label={i18n._(t`Inventory`)} + labelIcon={ + <FieldTooltip + content={i18n._( + t`Select the inventory that this host will belong to.` + )} + /> + } isRequired fieldId="inventory-lookup" validated={ @@ -32,9 +39,6 @@ const InventoryLookupField = withI18n()(({ i18n, host }) => { } helperTextInvalid={inventoryMeta.error} > - <FieldTooltip - content={i18n._(t`Select the inventory that this host will belong to.`)} - /> <InventoryLookup value={inventory} onBlur={() => inventoryHelpers.setTouched()} diff --git a/awx/ui_next/src/components/HostToggle/HostToggle.jsx b/awx/ui_next/src/components/HostToggle/HostToggle.jsx index d08a15a70c..638ccb2347 100644 --- a/awx/ui_next/src/components/HostToggle/HostToggle.jsx +++ b/awx/ui_next/src/components/HostToggle/HostToggle.jsx @@ -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`)} /> diff --git a/awx/ui_next/src/components/HostToggle/HostToggle.test.jsx b/awx/ui_next/src/components/HostToggle/HostToggle.test.jsx index 63dd971285..7391036bf2 100644 --- a/awx/ui_next/src/components/HostToggle/HostToggle.test.jsx +++ b/awx/ui_next/src/components/HostToggle/HostToggle.test.jsx @@ -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'); diff --git a/awx/ui_next/src/components/JobList/JobList.jsx b/awx/ui_next/src/components/JobList/JobList.jsx index 6e6d57653b..502b4378c7 100644 --- a/awx/ui_next/src/components/JobList/JobList.jsx +++ b/awx/ui_next/src/components/JobList/JobList.jsx @@ -38,7 +38,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { const [selected, setSelected] = useState([]); const location = useLocation(); const { - result: { results, count }, + result: { results, count, relatedSearchableKeys, searchableKeys }, error: contentError, isLoading, request: fetchJobs, @@ -46,12 +46,29 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { useCallback( async () => { const params = parseQueryString(QS_CONFIG, location.search); - const { data } = await UnifiedJobsAPI.read({ ...params }); - return data; + const [response, actionsResponse] = await Promise.all([ + UnifiedJobsAPI.read({ ...params }), + UnifiedJobsAPI.readOptions(), + ]); + return { + results: response.data.results, + count: 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), + }; }, [location] // eslint-disable-line react-hooks/exhaustive-deps ), - { results: [], count: 0 } + { + results: [], + count: 0, + relatedSearchableKeys: [], + searchableKeys: [], + } ); useEffect(() => { fetchJobs(); @@ -137,7 +154,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { @@ -146,11 +163,11 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { }, { name: i18n._(t`Label Name`), - key: 'labels__name', + key: 'labels__name__icontains', }, { name: i18n._(t`Job Type`), - key: `type`, + key: `or__type`, options: [ [`project_update`, i18n._(t`Source Control Update`)], [`inventory_update`, i18n._(t`Inventory Sync`)], @@ -162,7 +179,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { }, { name: i18n._(t`Launched By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Status`), @@ -209,11 +226,12 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { key: 'started', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( <DatalistToolbar {...props} showSelectAll - showExpandCollapse isAllSelected={isAllSelected} onSelectAll={handleSelectAll} qsConfig={QS_CONFIG} diff --git a/awx/ui_next/src/components/JobList/JobList.test.jsx b/awx/ui_next/src/components/JobList/JobList.test.jsx index 31bae1ed95..767243c59b 100644 --- a/awx/ui_next/src/components/JobList/JobList.test.jsx +++ b/awx/ui_next/src/components/JobList/JobList.test.jsx @@ -96,6 +96,16 @@ UnifiedJobsAPI.read.mockResolvedValue({ data: { count: 3, results: mockResults }, }); +UnifiedJobsAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [], + }, +}); + function waitForLoaded(wrapper) { return waitForElement( wrapper, diff --git a/awx/ui_next/src/components/JobList/useWsJobs.js b/awx/ui_next/src/components/JobList/useWsJobs.js index 46068353af..4560930818 100644 --- a/awx/ui_next/src/components/JobList/useWsJobs.js +++ b/awx/ui_next/src/components/JobList/useWsJobs.js @@ -1,15 +1,20 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; -import useThrottle from './useThrottle'; +import useWebsocket from '../../util/useWebsocket'; +import useThrottle from '../../util/useThrottle'; import { parseQueryString } from '../../util/qs'; import sortJobs from './sortJobs'; export default function useWsJobs(initialJobs, fetchJobsById, qsConfig) { const location = useLocation(); const [jobs, setJobs] = useState(initialJobs); - const [lastMessage, setLastMessage] = useState(null); const [jobsToFetch, setJobsToFetch] = useState([]); const throttledJobsToFetch = useThrottle(jobsToFetch, 5000); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + schedules: ['changed'], + control: ['limit_reached_1'], + }); useEffect(() => { setJobs(initialJobs); @@ -37,8 +42,6 @@ export default function useWsJobs(initialJobs, fetchJobsById, qsConfig) { })(); }, [throttledJobsToFetch, fetchJobsById]); // eslint-disable-line react-hooks/exhaustive-deps - const ws = useRef(null); - useEffect(() => { if (!lastMessage || !lastMessage.unified_job_id) { return; @@ -61,51 +64,6 @@ export default function useWsJobs(initialJobs, fetchJobsById, qsConfig) { } }, [lastMessage]); // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { - ws.current = new WebSocket(`wss://${window.location.host}/websocket/`); - - const connect = () => { - const xrftoken = `; ${document.cookie}` - .split('; csrftoken=') - .pop() - .split(';') - .shift(); - ws.current.send( - JSON.stringify({ - xrftoken, - groups: { - jobs: ['status_changed'], - schedules: ['changed'], - control: ['limit_reached_1'], - }, - }) - ); - }; - ws.current.onopen = connect; - - ws.current.onmessage = e => { - setLastMessage(JSON.parse(e.data)); - }; - - ws.current.onclose = e => { - // eslint-disable-next-line no-console - console.debug('Socket closed. Reconnecting...', e); - setTimeout(() => { - connect(); - }, 1000); - }; - - ws.current.onerror = err => { - // eslint-disable-next-line no-console - console.debug('Socket error: ', err, 'Disconnecting...'); - ws.current.close(); - }; - - return () => { - ws.current.close(); - }; - }, []); - return jobs; } diff --git a/awx/ui_next/src/components/JobList/useWsJobs.test.jsx b/awx/ui_next/src/components/JobList/useWsJobs.test.jsx index c7782b2427..77e5b714a3 100644 --- a/awx/ui_next/src/components/JobList/useWsJobs.test.jsx +++ b/awx/ui_next/src/components/JobList/useWsJobs.test.jsx @@ -8,7 +8,7 @@ import useWsJobs from './useWsJobs'; Jest mock timers don’t play well with jest-websocket-mock, so we'll stub out throttling to resolve immediately */ -jest.mock('./useThrottle', () => ({ +jest.mock('../../util/useThrottle', () => ({ __esModule: true, default: jest.fn(val => val), })); @@ -90,6 +90,7 @@ describe('useWsJobs hook', () => { mockServer.send( JSON.stringify({ unified_job_id: 1, + type: 'job', status: 'successful', }) ); @@ -116,6 +117,7 @@ describe('useWsJobs hook', () => { mockServer.send( JSON.stringify({ unified_job_id: 2, + type: 'job', status: 'running', }) ); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.jsx index 50f0f8630a..1b736c6ad0 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.jsx @@ -52,7 +52,7 @@ function CredentialsStep({ i18n }) { }, [fetchTypes]); const { - result: { credentials, count }, + result: { credentials, count, relatedSearchableKeys, searchableKeys }, error: credentialsError, isLoading: isCredentialsLoading, request: fetchCredentials, @@ -62,16 +62,25 @@ function CredentialsStep({ i18n }) { return { credentials: [], count: 0 }; } const params = parseQueryString(QS_CONFIG, history.location.search); - const { data } = await CredentialsAPI.read({ - ...params, - credential_type: selectedType.id, - }); + const [{ data }, actionsResponse] = await Promise.all([ + CredentialsAPI.read({ + ...params, + credential_type: selectedType.id, + }), + CredentialsAPI.readOptions(), + ]); return { credentials: data.results, count: 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), }; }, [selectedType, history.location.search]), - { credentials: [], count: 0 } + { credentials: [], count: 0, relatedSearchableKeys: [], searchableKeys: [] } ); useEffect(() => { @@ -129,16 +138,16 @@ function CredentialsStep({ i18n }) { searchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} sortColumns={[ @@ -147,6 +156,8 @@ function CredentialsStep({ i18n }) { key: 'name', }, ]} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} multiple={isVault} header={i18n._(t`Credentials`)} name="credentials" diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx index 543ac0baad..aec480547b 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx @@ -31,6 +31,15 @@ describe('CredentialsStep', () => { count: 5, }, }); + CredentialsAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [], + }, + }); }); test('should load credentials', async () => { diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx index 7360a53f07..e7026c66b7 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx @@ -27,20 +27,31 @@ function InventoryStep({ i18n }) { const { isLoading, error, - result: { inventories, count }, + result: { inventories, count, relatedSearchableKeys, searchableKeys }, request: fetchInventories, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, history.location.search); - const { data } = await InventoriesAPI.read(params); + const [{ data }, actionsResponse] = await Promise.all([ + InventoriesAPI.read(params), + InventoriesAPI.readOptions(), + ]); return { inventories: data.results, count: 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]), { count: 0, inventories: [], + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -63,16 +74,16 @@ function InventoryStep({ i18n }) { searchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} sortColumns={[ @@ -81,6 +92,8 @@ function InventoryStep({ i18n }) { key: 'name', }, ]} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} header={i18n._(t`Inventory`)} name="inventory" qsConfig={QS_CONFIG} diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.test.jsx index f2bf29d90a..21380b07a9 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.test.jsx @@ -21,6 +21,16 @@ describe('InventoryStep', () => { count: 3, }, }); + + InventoriesAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [], + }, + }); }); test('should load inventories', async () => { diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx index 02dc8c54b4..91ec37a68c 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx @@ -101,13 +101,15 @@ function JobTypeField({ i18n }) { <FormGroup fieldId="propmt-job-type" label={i18n._(t`Job Type`)} - validated={isValid ? 'default' : 'error'} - > - <FieldTooltip - content={i18n._(t`For job templates, select run to execute the playbook. + labelIcon={ + <FieldTooltip + content={i18n._(t`For job templates, select run to execute the playbook. Select check to only check playbook syntax, test environment setup, and report problems without executing the playbook.`)} - /> + /> + } + validated={isValid ? 'default' : 'error'} + > <AnsibleSelect id="prompt-job-type" data={options} @@ -134,11 +136,13 @@ function VerbosityField({ i18n }) { fieldId="prompt-verbosity" validated={isValid ? 'default' : 'error'} label={i18n._(t`Verbosity`)} - > - <FieldTooltip - content={i18n._(t`Control the level of output ansible + labelIcon={ + <FieldTooltip + content={i18n._(t`Control the level of output ansible will produce as the playbook executes.`)} - /> + /> + } + > <AnsibleSelect id="prompt-verbosity" data={options} @@ -180,8 +184,11 @@ function ShowChangesToggle({ i18n }) { function TagField({ id, name, label, tooltip }) { const [field, , helpers] = useField(name); return ( - <FormGroup fieldId={id} label={label}> - <FieldTooltip content={tooltip} /> + <FormGroup + fieldId={id} + label={label} + labelIcon={<FieldTooltip content={tooltip} />} + > <TagMultiSelect value={field.value} onChange={helpers.setValue} /> </FormGroup> ); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx index aaaf0bbbee..c3b0b79c5e 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx @@ -98,8 +98,8 @@ function MultipleChoiceField({ question }) { isRequired={question.required} validated={isValid ? 'default' : 'error'} label={question.question_name} + labelIcon={<FieldTooltip content={question.question_description} />} > - <FieldTooltip content={question.question_description} /> <AnsibleSelect id={id} isValid={isValid} @@ -126,8 +126,8 @@ function MultiSelectField({ question }) { isRequired={question.required} validated={isValid ? 'default' : 'error'} label={question.question_name} + labelIcon={<FieldTooltip content={question.question_description} />} > - <FieldTooltip content={question.question_description} /> <Select variant={SelectVariant.typeaheadMulti} id={id} diff --git a/awx/ui_next/src/components/ListHeader/ListHeader.jsx b/awx/ui_next/src/components/ListHeader/ListHeader.jsx index f1ac2a5ae8..f383c24130 100644 --- a/awx/ui_next/src/components/ListHeader/ListHeader.jsx +++ b/awx/ui_next/src/components/ListHeader/ListHeader.jsx @@ -94,10 +94,13 @@ class ListHeader extends React.Component { emptyStateControls, itemCount, searchColumns, + searchableKeys, + relatedSearchableKeys, sortColumns, renderToolbar, qsConfig, location, + pagination, } = this.props; const params = parseQueryString(qsConfig, location.search); const isEmpty = itemCount === 0 && Object.keys(params).length === 0; @@ -118,14 +121,18 @@ class ListHeader extends React.Component { ) : ( <Fragment> {renderToolbar({ + itemCount, searchColumns, sortColumns, + searchableKeys, + relatedSearchableKeys, onSearch: this.handleSearch, onReplaceSearch: this.handleReplaceSearch, onSort: this.handleSort, onRemove: this.handleRemove, clearAllFilters: this.handleRemoveAll, qsConfig, + pagination, })} </Fragment> )} @@ -138,12 +145,16 @@ ListHeader.propTypes = { itemCount: PropTypes.number.isRequired, qsConfig: QSConfig.isRequired, searchColumns: SearchColumns.isRequired, + searchableKeys: PropTypes.arrayOf(PropTypes.string), + relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string), sortColumns: SortColumns.isRequired, renderToolbar: PropTypes.func, }; ListHeader.defaultProps = { renderToolbar: props => <DataListToolbar {...props} />, + searchableKeys: [], + relatedSearchableKeys: [], }; export default withRouter(ListHeader); diff --git a/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx b/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx index 52263d2ec7..d501418c44 100644 --- a/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx +++ b/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx @@ -16,7 +16,9 @@ describe('ListHeader', () => { <ListHeader itemCount={50} qsConfig={qsConfig} - searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]} + searchColumns={[ + { name: 'foo', key: 'foo__icontains', isDefault: true }, + ]} sortColumns={[{ name: 'foo', key: 'foo' }]} renderToolbar={renderToolbarFn} /> @@ -33,7 +35,9 @@ describe('ListHeader', () => { <ListHeader itemCount={7} qsConfig={qsConfig} - searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]} + searchColumns={[ + { name: 'foo', key: 'foo__icontains', isDefault: true }, + ]} sortColumns={[{ name: 'foo', key: 'foo' }]} />, { context: { router: { history } } } @@ -56,7 +60,9 @@ describe('ListHeader', () => { <ListHeader itemCount={7} qsConfig={qsConfig} - searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]} + searchColumns={[ + { name: 'foo', key: 'foo__icontains', isDefault: true }, + ]} sortColumns={[{ name: 'foo', key: 'foo' }]} />, { context: { router: { history } } } @@ -77,7 +83,9 @@ describe('ListHeader', () => { <ListHeader itemCount={7} qsConfig={qsConfig} - searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]} + searchColumns={[ + { name: 'foo', key: 'foo__icontains', isDefault: true }, + ]} sortColumns={[{ name: 'foo', key: 'foo' }]} />, { context: { router: { history } } } @@ -100,7 +108,9 @@ describe('ListHeader', () => { <ListHeader itemCount={7} qsConfig={qsConfig} - searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]} + searchColumns={[ + { name: 'foo', key: 'foo__icontains', isDefault: true }, + ]} sortColumns={[{ name: 'foo', key: 'foo' }]} />, { context: { router: { history } } } diff --git a/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx b/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx new file mode 100644 index 0000000000..ca5871c2cc --- /dev/null +++ b/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx @@ -0,0 +1,128 @@ +import React, { useCallback, useEffect } from 'react'; +import { func, node } from 'prop-types'; +import { withRouter, useLocation } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { FormGroup } from '@patternfly/react-core'; +import { ApplicationsAPI } from '../../api'; +import { Application } from '../../types'; +import { getQSConfig, parseQueryString } from '../../util/qs'; +import Lookup from './Lookup'; +import OptionsList from '../OptionsList'; +import useRequest from '../../util/useRequest'; +import LookupErrorMessage from './shared/LookupErrorMessage'; + +const QS_CONFIG = getQSConfig('applications', { + page: 1, + page_size: 5, + order_by: 'name', +}); + +function ApplicationLookup({ i18n, onChange, value, label }) { + const location = useLocation(); + const { + error, + result: { applications, itemCount, relatedSearchableKeys, searchableKeys }, + request: fetchApplications, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + + const [ + { + data: { results, count }, + }, + actionsResponse, + ] = await Promise.all([ + ApplicationsAPI.read(params), + ApplicationsAPI.readOptions, + ]); + return { + applications: 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]), + { + applications: [], + itemCount: 0, + relatedSearchableKeys: [], + searchableKeys: [], + } + ); + useEffect(() => { + fetchApplications(); + }, [fetchApplications]); + return ( + <FormGroup fieldId="application" label={label}> + <Lookup + id="application" + header={i18n._(t`Application`)} + value={value} + onChange={onChange} + qsConfig={QS_CONFIG} + renderOptionsList={({ state, dispatch, canDelete }) => ( + <OptionsList + value={state.selectedItems} + options={applications} + optionCount={itemCount} + header={i18n._(t`Applications`)} + qsConfig={QS_CONFIG} + searchColumns={[ + { + name: i18n._(t`Name`), + key: 'name__icontains', + isDefault: true, + }, + { + name: i18n._(t`Description`), + key: 'description__icontains', + }, + ]} + sortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + { + name: i18n._(t`Created`), + key: 'created', + }, + { + name: i18n._(t`Organization`), + key: 'organization', + }, + { + name: i18n._(t`Description`), + key: 'description', + }, + ]} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} + readOnly={!canDelete} + name="application" + selectItem={item => dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} + /> + <LookupErrorMessage error={error} /> + </FormGroup> + ); +} +ApplicationLookup.propTypes = { + label: node.isRequired, + onChange: func.isRequired, + value: Application, +}; + +ApplicationLookup.defaultProps = { + value: null, +}; + +export default withI18n()(withRouter(ApplicationLookup)); diff --git a/awx/ui_next/src/components/Lookup/ApplicationLookup.test.jsx b/awx/ui_next/src/components/Lookup/ApplicationLookup.test.jsx new file mode 100644 index 0000000000..5d2e2e33a0 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/ApplicationLookup.test.jsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import ApplicationLookup from './ApplicationLookup'; +import { ApplicationsAPI } from '../../api'; + +jest.mock('../../api'); +const application = { + id: 1, + name: 'app', + description: '', +}; + +const fetchedApplications = { + count: 2, + results: [ + { + id: 1, + name: 'app', + description: '', + }, + { + id: 4, + name: 'application that should not crach', + description: '', + }, + ], +}; +describe('ApplicationLookup', () => { + let wrapper; + + beforeEach(() => { + ApplicationsAPI.read.mockResolvedValueOnce(fetchedApplications); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should render successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + <ApplicationLookup + label="Application" + value={application} + onChange={() => {}} + /> + ); + }); + expect(wrapper.find('ApplicationLookup')).toHaveLength(1); + }); + + test('should fetch applications', async () => { + await act(async () => { + wrapper = mountWithContexts( + <ApplicationLookup + label="Application" + value={application} + onChange={() => {}} + /> + ); + }); + expect(ApplicationsAPI.read).toHaveBeenCalledTimes(1); + }); + + test('should display label', async () => { + await act(async () => { + wrapper = mountWithContexts( + <ApplicationLookup + label="Application" + value={application} + onChange={() => {}} + /> + ); + }); + const title = wrapper.find('FormGroup .pf-c-form__label-text'); + expect(title.text()).toEqual('Application'); + }); +}); diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index bea2317f41..da62a053ce 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -35,7 +35,7 @@ function CredentialLookup({ tooltip, }) { const { - result: { count, credentials }, + result: { count, credentials, relatedSearchableKeys, searchableKeys }, error, request: fetchCredentials, } = useRequest( @@ -51,16 +51,25 @@ function CredentialLookup({ ? { credential_type__namespace: credentialTypeNamespace } : {}; - const { data } = await CredentialsAPI.read( - mergeParams(params, { - ...typeIdParams, - ...typeKindParams, - ...typeNamespaceParams, - }) - ); + const [{ data }, actionsResponse] = await Promise.all([ + CredentialsAPI.read( + mergeParams(params, { + ...typeIdParams, + ...typeKindParams, + ...typeNamespaceParams, + }) + ), + CredentialsAPI.readOptions, + ]); return { count: data.count, credentials: data.results, + 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, @@ -71,6 +80,8 @@ function CredentialLookup({ { count: 0, credentials: [], + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -86,9 +97,9 @@ function CredentialLookup({ isRequired={required} validated={isValid ? 'default' : 'error'} label={label} + labelIcon={tooltip && <FieldTooltip content={tooltip} />} helperTextInvalid={helperTextInvalid} > - {tooltip && <FieldTooltip content={tooltip} />} <Lookup id="credential" header={label} @@ -107,16 +118,16 @@ function CredentialLookup({ searchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} sortColumns={[ @@ -125,6 +136,8 @@ function CredentialLookup({ key: 'name', }, ]} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} readOnly={!canDelete} name="credential" selectItem={item => dispatch({ type: 'SELECT_ITEM', item })} diff --git a/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx b/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx new file mode 100644 index 0000000000..e819973083 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx @@ -0,0 +1,342 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { withRouter, useHistory, useLocation } from 'react-router-dom'; +import { number, func, bool, string } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import styled from 'styled-components'; +import { t } from '@lingui/macro'; +import { SearchIcon } from '@patternfly/react-icons'; +import { + Button, + ButtonVariant, + Chip, + FormGroup, + InputGroup, + Modal, +} from '@patternfly/react-core'; +import ChipGroup from '../ChipGroup'; +import DataListToolbar from '../DataListToolbar'; +import LookupErrorMessage from './shared/LookupErrorMessage'; +import PaginatedDataList, { PaginatedDataListItem } from '../PaginatedDataList'; +import { HostsAPI } from '../../api'; +import { getQSConfig, mergeParams, parseQueryString } from '../../util/qs'; +import useRequest, { useDismissableError } from '../../util/useRequest'; +import { + removeDefaultParams, + removeNamespacedKeys, + toHostFilter, + toQueryString, + toSearchParams, +} from './shared/HostFilterUtils'; + +const ChipHolder = styled.div` + --pf-c-form-control--Height: auto; + .pf-c-chip-group { + margin-right: 8px; + } +`; + +const ModalList = styled.div` + .pf-c-toolbar__content { + padding: 0 !important; + } +`; + +const useModal = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + + function toggleModal() { + setIsModalOpen(!isModalOpen); + } + + function closeModal() { + setIsModalOpen(false); + } + + return { + isModalOpen, + toggleModal, + closeModal, + }; +}; + +const QS_CONFIG = getQSConfig( + 'smart_hosts', + { + page: 1, + page_size: 5, + order_by: 'name', + }, + ['id', 'page', 'page_size', 'inventory'] +); + +const buildSearchColumns = i18n => [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`ID`), + key: 'id', + }, + { + name: i18n._(t`Group`), + key: 'groups__name', + }, + { + name: i18n._(t`Inventory ID`), + key: 'inventory', + }, + { + name: i18n._(t`Enabled`), + key: 'enabled', + isBoolean: true, + }, + { + name: i18n._(t`Instance ID`), + key: 'instance_id', + }, + { + name: i18n._(t`Last job`), + key: 'last_job', + }, + { + name: i18n._(t`Insights system ID`), + key: 'insights_system_id', + }, +]; + +function HostFilterLookup({ + helperTextInvalid, + i18n, + isValid, + isDisabled, + onBlur, + onChange, + organizationId, + value, +}) { + const history = useHistory(); + const location = useLocation(); + const [chips, setChips] = useState({}); + const [queryString, setQueryString] = useState(''); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const searchColumns = buildSearchColumns(i18n); + + const { + result: { count, hosts }, + error: contentError, + request: fetchHosts, + isLoading, + } = useRequest( + useCallback( + async orgId => { + const params = parseQueryString(QS_CONFIG, location.search); + const { data } = await HostsAPI.read( + mergeParams(params, { inventory__organization: orgId }) + ); + return { + count: data.count, + hosts: data.results, + }; + }, + [location.search] + ), + { + count: 0, + hosts: [], + } + ); + + const { error, dismissError } = useDismissableError(contentError); + + useEffect(() => { + if (isModalOpen && organizationId) { + dismissError(); + fetchHosts(organizationId); + } + }, [fetchHosts, organizationId, isModalOpen]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const filters = toSearchParams(value); + setQueryString(toQueryString(QS_CONFIG, filters)); + setChips(buildChips(filters)); + }, [value]); + + function qsToHostFilter(qs) { + const searchParams = toSearchParams(qs); + const withoutNamespace = removeNamespacedKeys(QS_CONFIG, searchParams); + const withoutDefaultParams = removeDefaultParams( + QS_CONFIG, + withoutNamespace + ); + return toHostFilter(withoutDefaultParams); + } + + const save = () => { + const hostFilterString = qsToHostFilter(location.search); + onChange(hostFilterString); + closeModal(); + history.replace({ + pathname: `${location.pathname}`, + search: '', + }); + }; + + function buildChips(filter = {}) { + const inputGroupChips = Object.keys(filter).reduce((obj, param) => { + const parsedKey = param.replace('__icontains', '').replace('or__', ''); + const chipsArray = []; + + if (Array.isArray(filter[param])) { + filter[param].forEach(val => + chipsArray.push({ + key: `${param}:${val}`, + node: `${val}`, + }) + ); + } else { + chipsArray.push({ + key: `${param}:${filter[param]}`, + node: `${filter[param]}`, + }); + } + + obj[parsedKey] = { + key: parsedKey, + label: filter[param], + chips: [...chipsArray], + }; + + return obj; + }, {}); + + return inputGroupChips; + } + + const handleOpenModal = () => { + history.replace({ + pathname: `${location.pathname}`, + search: queryString, + }); + toggleModal(); + }; + + const handleClose = () => { + closeModal(); + history.replace({ + pathname: `${location.pathname}`, + search: '', + }); + }; + + return ( + <FormGroup + fieldId="host-filter" + helperTextInvalid={helperTextInvalid} + isRequired + label={i18n._(t`Host filter`)} + validated={isValid ? 'default' : 'error'} + > + <InputGroup onBlur={onBlur}> + <Button + aria-label="Search" + id="host-filter" + isDisabled={isDisabled} + onClick={handleOpenModal} + variant={ButtonVariant.control} + > + <SearchIcon /> + </Button> + <ChipHolder className="pf-c-form-control"> + {searchColumns.map(({ name, key }) => ( + <ChipGroup + categoryName={name} + key={name} + numChips={5} + totalChips={chips[key]?.chips?.length || 0} + > + {chips[key]?.chips?.map((chip, index) => ( + <Chip key={index} isReadOnly> + {chip.node} + </Chip> + ))} + </ChipGroup> + ))} + </ChipHolder> + </InputGroup> + <Modal + aria-label={i18n._(t`Lookup modal`)} + isOpen={isModalOpen} + onClose={handleClose} + title={i18n._(t`Perform a search to define a host filter`)} + variant="large" + actions={[ + <Button + isDisabled={!location.search} + key="select" + onClick={save} + variant="primary" + > + {i18n._(t`Select`)} + </Button>, + <Button key="cancel" variant="link" onClick={handleClose}> + {i18n._(t`Cancel`)} + </Button>, + ]} + > + <ModalList> + <PaginatedDataList + contentError={error} + hasContentLoading={isLoading} + itemCount={count} + items={hosts} + onRowClick={() => {}} + pluralizedItemName={i18n._(t`hosts`)} + qsConfig={QS_CONFIG} + renderItem={item => ( + <PaginatedDataListItem + key={item.id} + item={{ ...item, url: `/hosts/${item.id}/details` }} + /> + )} + renderToolbar={props => <DataListToolbar {...props} fillWidth />} + toolbarSearchColumns={searchColumns} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + { + name: i18n._(t`Created`), + key: 'created', + }, + { + name: i18n._(t`Modified`), + key: 'modified', + }, + ]} + /> + </ModalList> + </Modal> + <LookupErrorMessage error={error} /> + </FormGroup> + ); +} + +HostFilterLookup.propTypes = { + isValid: bool, + onBlur: func, + onChange: func, + organizationId: number, + value: string, +}; +HostFilterLookup.defaultProps = { + isValid: true, + onBlur: () => {}, + onChange: () => {}, + organizationId: null, + value: '', +}; + +export default withI18n()(withRouter(HostFilterLookup)); diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index ecc5aa142b..49c257cb6a 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -30,20 +30,34 @@ function InstanceGroupsLookup(props) { } = props; const { - result: { instanceGroups, count }, + result: { instanceGroups, count, relatedSearchableKeys, searchableKeys }, request: fetchInstanceGroups, error, isLoading, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, history.location.search); - const { data } = await InstanceGroupsAPI.read(params); + const [{ data }, actionsResponse] = await Promise.all([ + InstanceGroupsAPI.read(params), + InstanceGroupsAPI.readOptions(), + ]); return { instanceGroups: data.results, count: 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]), - { instanceGroups: [], count: 0 } + { + instanceGroups: [], + count: 0, + relatedSearchableKeys: [], + searchableKeys: [], + } ); useEffect(() => { @@ -54,9 +68,9 @@ function InstanceGroupsLookup(props) { <FormGroup className={className} label={i18n._(t`Instance Groups`)} + labelIcon={tooltip && <FieldTooltip content={tooltip} />} fieldId="org-instance-groups" > - {tooltip && <FieldTooltip content={tooltip} />} <Lookup id="org-instance-groups" header={i18n._(t`Instance Groups`)} @@ -74,12 +88,12 @@ function InstanceGroupsLookup(props) { searchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Credential Name`), - key: 'credential__name', + key: 'credential__name__icontains', }, ]} sortColumns={[ @@ -88,6 +102,8 @@ function InstanceGroupsLookup(props) { key: 'name', }, ]} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} multiple={state.multiple} header={i18n._(t`Instance Groups`)} name="instanceGroups" diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx index f3afb847f8..3abb85604e 100644 --- a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx @@ -19,20 +19,29 @@ const QS_CONFIG = getQSConfig('inventory', { function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) { const { - result: { inventories, count }, + result: { inventories, count, relatedSearchableKeys, searchableKeys }, request: fetchInventories, error, isLoading, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, history.location.search); - const { data } = await InventoriesAPI.read(params); + const [{ data }, actionsResponse] = await Promise.all([ + InventoriesAPI.read(params), + InventoriesAPI.readOptions(), + ]); return { inventories: data.results, count: 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]), - { inventories: [], count: 0 } + { inventories: [], count: 0, relatedSearchableKeys: [], searchableKeys: [] } ); useEffect(() => { @@ -58,16 +67,16 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) { searchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} sortColumns={[ @@ -76,6 +85,8 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) { key: 'name', }, ]} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} multiple={state.multiple} header={i18n._(t`Inventory`)} name="inventory" diff --git a/awx/ui_next/src/components/Lookup/InventoryScriptLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryScriptLookup.jsx deleted file mode 100644 index 3b74d79109..0000000000 --- a/awx/ui_next/src/components/Lookup/InventoryScriptLookup.jsx +++ /dev/null @@ -1,137 +0,0 @@ -import React, { useCallback, useEffect } from 'react'; -import { withRouter } from 'react-router-dom'; -import { func, bool, number, node, string, oneOfType } from 'prop-types'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; - -import { FormGroup } from '@patternfly/react-core'; -import Lookup from './Lookup'; -import LookupErrorMessage from './shared/LookupErrorMessage'; -import OptionsList from '../OptionsList'; -import { InventoriesAPI, InventoryScriptsAPI } from '../../api'; -import { InventoryScript } from '../../types'; -import useRequest from '../../util/useRequest'; -import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs'; - -const QS_CONFIG = getQSConfig('inventory_scripts', { - order_by: 'name', - page: 1, - page_size: 5, - role_level: 'admin_role', -}); - -function InventoryScriptLookup({ - helperTextInvalid, - history, - i18n, - inventoryId, - isValid, - onBlur, - onChange, - required, - value, -}) { - const { - result: { count, inventoryScripts }, - error, - request: fetchInventoryScripts, - } = useRequest( - useCallback(async () => { - const parsedParams = parseQueryString(QS_CONFIG, history.location.search); - const { - data: { organization }, - } = await InventoriesAPI.readDetail(inventoryId); - const { data } = await InventoryScriptsAPI.read( - mergeParams(parsedParams, { organization }) - ); - return { - count: data.count, - inventoryScripts: data.results, - }; - }, [history.location.search, inventoryId]), - { - count: 0, - inventoryScripts: [], - } - ); - - useEffect(() => { - fetchInventoryScripts(); - }, [fetchInventoryScripts]); - - return ( - <FormGroup - fieldId="inventory-script" - helperTextInvalid={helperTextInvalid} - isRequired={required} - validated={isValid ? 'default' : 'error'} - label={i18n._(t`Inventory script`)} - > - <Lookup - id="inventory-script-lookup" - header={i18n._(t`Inventory script`)} - value={value} - onChange={onChange} - onBlur={onBlur} - required={required} - qsConfig={QS_CONFIG} - renderOptionsList={({ state, dispatch, canDelete }) => ( - <OptionsList - header={i18n._(t`Inventory script`)} - multiple={state.multiple} - name="inventory-script" - optionCount={count} - options={inventoryScripts} - qsConfig={QS_CONFIG} - readOnly={!canDelete} - deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} - selectItem={item => dispatch({ type: 'SELECT_ITEM', item })} - value={state.selectedItems} - searchColumns={[ - { - name: i18n._(t`Name`), - key: 'name', - isDefault: true, - }, - { - name: i18n._(t`Created By (Username)`), - key: 'created_by__username', - }, - { - name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', - }, - ]} - sortColumns={[ - { - name: i18n._(t`Name`), - key: 'name', - }, - ]} - /> - )} - /> - <LookupErrorMessage error={error} /> - </FormGroup> - ); -} - -InventoryScriptLookup.propTypes = { - helperTextInvalid: node, - inventoryId: oneOfType([number, string]).isRequired, - isValid: bool, - onBlur: func, - onChange: func.isRequired, - required: bool, - value: InventoryScript, -}; - -InventoryScriptLookup.defaultProps = { - helperTextInvalid: '', - isValid: true, - onBlur: () => {}, - required: false, - value: null, -}; - -export default withI18n()(withRouter(InventoryScriptLookup)); diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index d76bd905c8..c4fa96dbfe 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -49,7 +49,12 @@ function MultiCredentialsLookup(props) { }, [fetchTypes]); const { - result: { credentials, credentialsCount }, + result: { + credentials, + credentialsCount, + relatedSearchableKeys, + searchableKeys, + }, request: fetchCredentials, error: credentialsError, isLoading: isCredentialsLoading, @@ -62,15 +67,26 @@ function MultiCredentialsLookup(props) { }; } const params = parseQueryString(QS_CONFIG, history.location.search); - const { results, count } = await loadCredentials(params, selectedType.id); + const [{ results, count }, actionsResponse] = await Promise.all([ + loadCredentials(params, selectedType.id), + CredentialsAPI.readOptions(), + ]); return { credentials: results, credentialsCount: 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), }; }, [selectedType, history.location]), { credentials: [], credentialsCount: 0, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -149,16 +165,16 @@ function MultiCredentialsLookup(props) { searchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} sortColumns={[ @@ -167,6 +183,8 @@ function MultiCredentialsLookup(props) { key: 'name', }, ]} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} multiple={isVault} header={i18n._(t`Credentials`)} name="credentials" diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx index ae6475deca..3f763788d9 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx @@ -43,6 +43,15 @@ describe('<MultiCredentialsLookup />', () => { count: 3, }, }); + CredentialsAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [], + }, + }); }); afterEach(() => { diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx index b8675b134a..ec60c553cd 100644 --- a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx +++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { node, func, bool } from 'prop-types'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; @@ -7,6 +7,7 @@ import { FormGroup } from '@patternfly/react-core'; import { OrganizationsAPI } from '../../api'; import { Organization } from '../../types'; import { getQSConfig, parseQueryString } from '../../util/qs'; +import useRequest from '../../util/useRequest'; import OptionsList from '../OptionsList'; import Lookup from './Lookup'; import LookupErrorMessage from './shared/LookupErrorMessage'; @@ -27,22 +28,28 @@ function OrganizationLookup({ value, history, }) { - const [organizations, setOrganizations] = useState([]); - const [count, setCount] = useState(0); - const [error, setError] = useState(null); + const { + result: { itemCount, organizations }, + error: contentError, + request: fetchOrganizations, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + const { data } = await OrganizationsAPI.read(params); + return { + organizations: data.results, + itemCount: data.count, + }; + }, [history.location.search]), + { + organizations: [], + itemCount: 0, + } + ); useEffect(() => { - (async () => { - const params = parseQueryString(QS_CONFIG, history.location.search); - try { - const { data } = await OrganizationsAPI.read(params); - setOrganizations(data.results); - setCount(data.count); - } catch (err) { - setError(err); - } - })(); - }, [history.location]); + fetchOrganizations(); + }, [fetchOrganizations]); return ( <FormGroup @@ -65,7 +72,7 @@ function OrganizationLookup({ <OptionsList value={state.selectedItems} options={organizations} - optionCount={count} + optionCount={itemCount} multiple={state.multiple} header={i18n._(t`Organization`)} name="organization" @@ -73,16 +80,16 @@ function OrganizationLookup({ searchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} sortColumns={[ @@ -97,7 +104,7 @@ function OrganizationLookup({ /> )} /> - <LookupErrorMessage error={error} /> + <LookupErrorMessage error={contentError} /> </FormGroup> ); } diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index b05931c151..dcc16669b8 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -32,25 +32,36 @@ function ProjectLookup({ history, }) { const { - result: { projects, count }, + result: { projects, count, relatedSearchableKeys, searchableKeys }, request: fetchProjects, error, isLoading, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, history.location.search); - const { data } = await ProjectsAPI.read(params); + const [{ data }, actionsResponse] = await Promise.all([ + ProjectsAPI.read(params), + ProjectsAPI.readOptions(), + ]); if (data.count === 1 && autocomplete) { autocomplete(data.results[0]); } return { count: data.count, projects: data.results, + 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: [], + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -65,8 +76,8 @@ function ProjectLookup({ isRequired={required} validated={isValid ? 'default' : 'error'} label={i18n._(t`Project`)} + labelIcon={tooltip && <FieldTooltip content={tooltip} />} > - {tooltip && <FieldTooltip content={tooltip} />} <Lookup id="project" header={i18n._(t`Project`)} @@ -83,12 +94,12 @@ function ProjectLookup({ searchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Type`), - key: 'scm_type', + key: 'or__scm_type', options: [ [``, i18n._(t`Manual`)], [`git`, i18n._(t`Git`)], @@ -99,15 +110,15 @@ function ProjectLookup({ }, { name: i18n._(t`Source Control URL`), - key: 'scm_url', + key: 'scm_url__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, ]} sortColumns={[ @@ -116,6 +127,8 @@ function ProjectLookup({ key: 'name', }, ]} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} options={projects} optionCount={count} multiple={state.multiple} diff --git a/awx/ui_next/src/components/Lookup/index.js b/awx/ui_next/src/components/Lookup/index.js index 9321fb08e9..2b9b147941 100644 --- a/awx/ui_next/src/components/Lookup/index.js +++ b/awx/ui_next/src/components/Lookup/index.js @@ -4,3 +4,5 @@ export { default as InventoryLookup } from './InventoryLookup'; export { default as ProjectLookup } from './ProjectLookup'; export { default as MultiCredentialsLookup } from './MultiCredentialsLookup'; export { default as CredentialLookup } from './CredentialLookup'; +export { default as ApplicationLookup } from './ApplicationLookup'; +export { default as HostFilterLookup } from './HostFilterLookup'; diff --git a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx new file mode 100644 index 0000000000..27be7b3c99 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx @@ -0,0 +1,104 @@ +/** + * Convert host filter string to params object + * @param {string} string host filter string + * @return {object} A string or array of strings keyed by query param key + */ +export function toSearchParams(string = '') { + if (string === '') { + return {}; + } + return string + .replace(/^\?/, '') + .replace(/&/g, ' and ') + .split(/ and | or /) + .map(s => s.split('=')) + .reduce((searchParams, [k, v]) => { + const key = decodeURIComponent(k); + const value = decodeURIComponent(v); + if (searchParams[key] === undefined) { + searchParams[key] = value; + } else if (Array.isArray(searchParams[key])) { + searchParams[key] = [...searchParams[key], value]; + } else { + searchParams[key] = [searchParams[key], value]; + } + return searchParams; + }, {}); +} + +/** + * Convert params object to an encoded namespaced url query string + * Used to put into url bar when modal opens + * @param {object} config Config object for namespacing params + * @param {object} searchParams A string or array of strings keyed by query param key + * @return {string} URL query string + */ +export function toQueryString(config, searchParams = {}) { + if (Object.keys(searchParams).length === 0) return ''; + + return Object.keys(searchParams) + .flatMap(key => { + if (Array.isArray(searchParams[key])) { + return searchParams[key].map( + val => + `${config.namespace}.${encodeURIComponent( + key + )}=${encodeURIComponent(val)}` + ); + } + return `${config.namespace}.${encodeURIComponent( + key + )}=${encodeURIComponent(searchParams[key])}`; + }) + .join('&'); +} + +/** + * Convert params object to host filter string + * @param {object} searchParams A string or array of strings keyed by query param key + * @return {string} Host filter string + */ +export function toHostFilter(searchParams = {}) { + return Object.keys(searchParams) + .flatMap(key => { + if (Array.isArray(searchParams[key])) { + return searchParams[key].map(val => `${key}=${val}`); + } + return `${key}=${searchParams[key]}`; + }) + .join(' and '); +} + +/** + * Helper function to remove namespace from params object + * @param {object} config Config object with namespace param + * @param {object} obj A string or array of strings keyed by query param key + * @return {object} Params object without namespaced keys + */ +export function removeNamespacedKeys(config, obj = {}) { + const clonedObj = Object.assign({}, obj); + const newObj = {}; + Object.keys(clonedObj).forEach(nsKey => { + let key = nsKey; + if (nsKey.startsWith(config.namespace)) { + key = nsKey.substr(config.namespace.length + 1); + } + newObj[key] = clonedObj[nsKey]; + }); + return newObj; +} + +/** + * Helper function to remove default params from params object + * @param {object} config Config object with default params + * @param {object} obj A string or array of strings keyed by query param key + * @return {string} Params object without default params + */ +export function removeDefaultParams(config, obj = {}) { + const clonedObj = Object.assign({}, obj); + const defaultKeys = Object.keys(config.defaultParams); + defaultKeys.forEach(keyToOmit => { + delete clonedObj[keyToOmit]; + }); + return clonedObj; +} diff --git a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.test.jsx b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.test.jsx new file mode 100644 index 0000000000..c381ec4082 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.test.jsx @@ -0,0 +1,109 @@ +import { + removeDefaultParams, + removeNamespacedKeys, + toHostFilter, + toQueryString, + toSearchParams, +} from './HostFilterUtils'; + +const QS_CONFIG = { + namespace: 'mock', + defaultParams: { page: 1, page_size: 5, order_by: 'name' }, + integerFields: ['page', 'page_size', 'id', 'inventory'], +}; + +describe('toSearchParams', () => { + let string; + let paramsObject; + + test('should return an empty object', () => { + expect(toSearchParams(undefined)).toEqual({}); + expect(toSearchParams('')).toEqual({}); + }); + test('should take a query string and return search params object', () => { + string = '?foo=bar'; + paramsObject = { foo: 'bar' }; + expect(toSearchParams(string)).toEqual(paramsObject); + }); + test('should take a host filter string and return search params object', () => { + string = 'foo=bar and foo=baz and foo=qux and isa=sampu'; + paramsObject = { + foo: ['bar', 'baz', 'qux'], + isa: 'sampu', + }; + expect(toSearchParams(string)).toEqual(paramsObject); + }); +}); + +describe('toQueryString', () => { + test('should return an empty string', () => { + expect(toQueryString(QS_CONFIG, undefined)).toEqual(''); + }); + test('should return namespaced query string with a single key-value pair', () => { + const object = { + foo: 'bar', + }; + expect(toQueryString(QS_CONFIG, object)).toEqual('mock.foo=bar'); + }); + test('should return namespaced query string with multiple values per key', () => { + const object = { + foo: ['bar', 'baz'], + }; + expect(toQueryString(QS_CONFIG, object)).toEqual( + 'mock.foo=bar&mock.foo=baz' + ); + }); + test('should return namespaced query string with multiple key-value pairs', () => { + const object = { + foo: ['bar', 'baz', 'qux'], + isa: 'sampu', + }; + expect(toQueryString(QS_CONFIG, object)).toEqual( + 'mock.foo=bar&mock.foo=baz&mock.foo=qux&mock.isa=sampu' + ); + }); +}); + +describe('toHostFilter', () => { + test('should return an empty string', () => { + expect(toHostFilter(undefined)).toEqual(''); + }); + test('should return a host filter string', () => { + const object = { + isa: '2', + tatlo: ['foo', 'bar', 'baz'], + }; + expect(toHostFilter(object)).toEqual( + 'isa=2 and tatlo=foo and tatlo=bar and tatlo=baz' + ); + }); +}); + +describe('removeNamespacedKeys', () => { + test('should return an empty object', () => { + expect(removeNamespacedKeys(QS_CONFIG, undefined)).toEqual({}); + }); + test('should remove namespace from keys', () => { + expect(removeNamespacedKeys(QS_CONFIG, { 'mock.foo': 'bar' })).toEqual({ + foo: 'bar', + }); + }); +}); + +describe('removeDefaultParams', () => { + test('should return an empty object', () => { + expect(removeDefaultParams(QS_CONFIG, undefined)).toEqual({}); + }); + test('should remove default params', () => { + const object = { + foo: ['bar', 'baz', 'qux'], + apat: 'lima', + page: 10, + order_by: '-name', + }; + expect(removeDefaultParams(QS_CONFIG, object)).toEqual({ + foo: ['bar', 'baz', 'qux'], + apat: 'lima', + }); + }); +}); diff --git a/awx/ui_next/src/components/NotificationList/NotificationList.jsx b/awx/ui_next/src/components/NotificationList/NotificationList.jsx index 0854c16f99..b1bca91d37 100644 --- a/awx/ui_next/src/components/NotificationList/NotificationList.jsx +++ b/awx/ui_next/src/components/NotificationList/NotificationList.jsx @@ -1,15 +1,14 @@ -import React, { Component, Fragment } from 'react'; -import { number, shape, string, bool } from 'prop-types'; -import { withRouter } from 'react-router-dom'; +import React, { useEffect, useCallback, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { number, shape, bool } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; - import AlertModal from '../AlertModal'; import ErrorDetail from '../ErrorDetail'; import NotificationListItem from './NotificationListItem'; import PaginatedDataList from '../PaginatedDataList'; import { getQSConfig, parseQueryString } from '../../util/qs'; - +import useRequest from '../../util/useRequest'; import { NotificationTemplatesAPI } from '../../api'; const QS_CONFIG = getQSConfig('notification', { @@ -18,64 +17,49 @@ const QS_CONFIG = getQSConfig('notification', { order_by: 'name', }); -class NotificationList extends Component { - constructor(props) { - super(props); - this.state = { - contentError: null, - hasContentLoading: true, - toggleError: false, - loadingToggleIds: [], - itemCount: 0, - notifications: [], - startedTemplateIds: [], - successTemplateIds: [], - errorTemplateIds: [], - typeLabels: null, - }; - this.handleNotificationToggle = this.handleNotificationToggle.bind(this); - this.handleNotificationErrorClose = this.handleNotificationErrorClose.bind( - this - ); - this.loadNotifications = this.loadNotifications.bind(this); - } - - componentDidMount() { - this.loadNotifications(); - } +function NotificationList({ apiModel, canToggleNotifications, id, i18n }) { + const location = useLocation(); + const [isToggleLoading, setIsToggleLoading] = useState(false); + const [toggleError, setToggleError] = useState(null); - componentDidUpdate(prevProps) { - const { location } = this.props; - if (location !== prevProps.location) { - this.loadNotifications(); - } - } - - async loadNotifications() { - const { id, location, apiModel } = this.props; - const { typeLabels } = this.state; - const params = parseQueryString(QS_CONFIG, location.search); - - const promises = [NotificationTemplatesAPI.read(params)]; - - if (!typeLabels) { - promises.push(NotificationTemplatesAPI.readOptions()); - } - - this.setState({ contentError: null, hasContentLoading: true }); - try { - const { - data: { count: itemCount = 0, results: notifications = [] }, - } = await NotificationTemplatesAPI.read(params); + const { + result: fetchNotificationsResult, + result: { + notifications, + itemCount, + startedTemplateIds, + successTemplateIds, + errorTemplateIds, + typeLabels, + }, + error: contentError, + isLoading, + request: fetchNotifications, + setValue, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const [ + { + data: { results: notificationsResults, count: notificationsCount }, + }, + { + data: { actions }, + }, + ] = await Promise.all([ + NotificationTemplatesAPI.read(params), + NotificationTemplatesAPI.readOptions(), + ]); - const optionsResponse = await NotificationTemplatesAPI.readOptions(); + const labels = actions.GET.notification_type.choices.reduce( + (map, notifType) => ({ ...map, [notifType[0]]: notifType[1] }), + {} + ); - let idMatchParams; - if (notifications.length > 0) { - idMatchParams = { id__in: notifications.map(n => n.id).join(',') }; - } else { - idMatchParams = {}; - } + const idMatchParams = + notificationsResults.length > 0 + ? { id__in: notificationsResults.map(n => n.id).join(',') } + : {}; const [ { data: startedTemplates }, @@ -87,69 +71,35 @@ class NotificationList extends Component { apiModel.readNotificationTemplatesError(id, idMatchParams), ]); - const stateToUpdate = { - itemCount, - notifications, + return { + 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, }; - - if (!typeLabels) { - const { - data: { - actions: { - GET: { - notification_type: { choices }, - }, - }, - }, - } = optionsResponse; - // The structure of choices looks like [['slack', 'Slack'], ['email', 'Email'], ...] - stateToUpdate.typeLabels = choices.reduce( - (map, notifType) => ({ ...map, [notifType[0]]: notifType[1] }), - {} - ); - } - - this.setState(stateToUpdate); - } catch (err) { - this.setState({ contentError: err }); - } finally { - this.setState({ hasContentLoading: false }); - } - } - - async handleNotificationToggle(notificationId, isCurrentlyOn, status) { - const { id, apiModel } = this.props; - - let stateArrayName; - if (status === 'success') { - stateArrayName = 'successTemplateIds'; - } else if (status === 'error') { - stateArrayName = 'errorTemplateIds'; - } else if (status === 'started') { - stateArrayName = 'startedTemplateIds'; - } - - let stateUpdateFunction; - if (isCurrentlyOn) { - // when switching off, remove the toggled notification id from the array - stateUpdateFunction = prevState => ({ - [stateArrayName]: prevState[stateArrayName].filter( - i => i !== notificationId - ), - }); - } else { - // when switching on, add the toggled notification id to the array - stateUpdateFunction = prevState => ({ - [stateArrayName]: prevState[stateArrayName].concat(notificationId), - }); + }, [apiModel, id, location]), + { + notifications: [], + itemCount: 0, + startedTemplateIds: [], + successTemplateIds: [], + errorTemplateIds: [], + typeLabels: {}, } - - this.setState(({ loadingToggleIds }) => ({ - loadingToggleIds: loadingToggleIds.concat([notificationId]), - })); + ); + + useEffect(() => { + fetchNotifications(); + }, [fetchNotifications]); + + const handleNotificationToggle = async ( + notificationId, + isCurrentlyOn, + status + ) => { + setIsToggleLoading(true); try { if (isCurrentlyOn) { await apiModel.disassociateNotificationTemplate( @@ -157,128 +107,111 @@ class NotificationList extends Component { notificationId, status ); + setValue({ + ...fetchNotificationsResult, + [`${status}TemplateIds`]: fetchNotificationsResult[ + `${status}TemplateIds` + ].filter(i => i !== notificationId), + }); } else { await apiModel.associateNotificationTemplate( id, notificationId, status ); + setValue({ + ...fetchNotificationsResult, + [`${status}TemplateIds`]: fetchNotificationsResult[ + `${status}TemplateIds` + ].concat(notificationId), + }); } - this.setState(stateUpdateFunction); } catch (err) { - this.setState({ toggleError: err }); + setToggleError(err); } finally { - this.setState(({ loadingToggleIds }) => ({ - loadingToggleIds: loadingToggleIds.filter( - item => item !== notificationId - ), - })); + setIsToggleLoading(false); } - } - - handleNotificationErrorClose() { - this.setState({ toggleError: false }); - } - - render() { - const { canToggleNotifications, i18n } = this.props; - const { - contentError, - hasContentLoading, - toggleError, - loadingToggleIds, - itemCount, - notifications, - startedTemplateIds, - successTemplateIds, - errorTemplateIds, - typeLabels, - } = this.state; - - return ( - <Fragment> - <PaginatedDataList - contentError={contentError} - hasContentLoading={hasContentLoading} - items={notifications} - itemCount={itemCount} - pluralizedItemName={i18n._(t`Notifications`)} - qsConfig={QS_CONFIG} - toolbarSearchColumns={[ - { - name: i18n._(t`Name`), - key: 'name', - isDefault: true, - }, - { - name: i18n._(t`Type`), - key: 'type', - options: [ - ['email', i18n._(t`Email`)], - ['grafana', i18n._(t`Grafana`)], - ['hipchat', i18n._(t`Hipchat`)], - ['irc', i18n._(t`IRC`)], - ['mattermost', i18n._(t`Mattermost`)], - ['pagerduty', i18n._(t`Pagerduty`)], - ['rocketchat', i18n._(t`Rocket.Chat`)], - ['slack', i18n._(t`Slack`)], - ['twilio', i18n._(t`Twilio`)], - ['webhook', i18n._(t`Webhook`)], - ], - }, - { - name: i18n._(t`Created By (Username)`), - key: 'created_by__username', - }, - { - name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', - }, - ]} - toolbarSortColumns={[ - { - name: i18n._(t`Name`), - key: 'name', - }, - ]} - renderItem={notification => ( - <NotificationListItem - key={notification.id} - notification={notification} - detailUrl={`/notifications/${notification.id}`} - canToggleNotifications={ - canToggleNotifications && - !loadingToggleIds.includes(notification.id) - } - toggleNotification={this.handleNotificationToggle} - errorTurnedOn={errorTemplateIds.includes(notification.id)} - startedTurnedOn={startedTemplateIds.includes(notification.id)} - successTurnedOn={successTemplateIds.includes(notification.id)} - typeLabels={typeLabels} - /> - )} - /> + }; + + return ( + <> + <PaginatedDataList + contentError={contentError} + hasContentLoading={isLoading} + items={notifications} + itemCount={itemCount} + pluralizedItemName={i18n._(t`Notifications`)} + qsConfig={QS_CONFIG} + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name__icontains', + isDefault: true, + }, + { + name: i18n._(t`Type`), + key: 'or__type', + options: [ + ['email', i18n._(t`Email`)], + ['grafana', i18n._(t`Grafana`)], + ['hipchat', i18n._(t`Hipchat`)], + ['irc', i18n._(t`IRC`)], + ['mattermost', i18n._(t`Mattermost`)], + ['pagerduty', i18n._(t`Pagerduty`)], + ['rocketchat', i18n._(t`Rocket.Chat`)], + ['slack', i18n._(t`Slack`)], + ['twilio', i18n._(t`Twilio`)], + ['webhook', i18n._(t`Webhook`)], + ], + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username__icontains', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username__icontains', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + ]} + renderItem={notification => ( + <NotificationListItem + key={notification.id} + notification={notification} + detailUrl={`/notifications/${notification.id}`} + canToggleNotifications={canToggleNotifications && !isToggleLoading} + toggleNotification={handleNotificationToggle} + errorTurnedOn={errorTemplateIds.includes(notification.id)} + startedTurnedOn={startedTemplateIds.includes(notification.id)} + successTurnedOn={successTemplateIds.includes(notification.id)} + typeLabels={typeLabels} + /> + )} + /> + {toggleError && ( <AlertModal variant="error" title={i18n._(t`Error!`)} - isOpen={toggleError && loadingToggleIds.length === 0} - onClose={this.handleNotificationErrorClose} + isOpen={!isToggleLoading} + onClose={() => setToggleError(null)} > {i18n._(t`Failed to toggle notification.`)} <ErrorDetail error={toggleError} /> </AlertModal> - </Fragment> - ); - } + )} + </> + ); } NotificationList.propTypes = { + apiModel: shape({}).isRequired, id: number.isRequired, canToggleNotifications: bool.isRequired, - location: shape({ - search: string.isRequired, - }).isRequired, }; -export { NotificationList as _NotificationList }; -export default withI18n()(withRouter(NotificationList)); +export default withI18n()(NotificationList); diff --git a/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx b/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx index ee278ab4bf..071eb45e9f 100644 --- a/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx +++ b/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx @@ -1,15 +1,13 @@ import React from 'react'; - +import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; -import { sleep } from '../../../testUtils/testUtils'; - import { NotificationTemplatesAPI } from '../../api'; - import NotificationList from './NotificationList'; jest.mock('../../api'); describe('<NotificationList />', () => { + let wrapper; const data = { count: 2, results: [ @@ -58,220 +56,185 @@ describe('<NotificationList />', () => { }, }); - beforeEach(() => { - NotificationTemplatesAPI.read.mockReturnValue({ data }); - MockModelAPI.readNotificationTemplatesSuccess.mockReturnValue({ - data: { results: [{ id: 1 }] }, - }); - MockModelAPI.readNotificationTemplatesError.mockReturnValue({ - data: { results: [{ id: 2 }] }, - }); - MockModelAPI.readNotificationTemplatesStarted.mockReturnValue({ - data: { results: [{ id: 3 }] }, - }); + NotificationTemplatesAPI.read.mockReturnValue({ data }); + + MockModelAPI.readNotificationTemplatesSuccess.mockReturnValue({ + data: { results: [{ id: 1 }] }, }); - afterEach(() => { - jest.clearAllMocks(); + MockModelAPI.readNotificationTemplatesError.mockReturnValue({ + data: { results: [{ id: 2 }] }, }); - test('initially renders succesfully', async () => { - const wrapper = mountWithContexts( - <NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} /> - ); - await sleep(0); - wrapper.update(); - const dataList = wrapper.find('PaginatedDataList'); - expect(dataList).toHaveLength(1); - expect(dataList.prop('items')).toEqual(data.results); + MockModelAPI.readNotificationTemplatesStarted.mockReturnValue({ + data: { results: [{ id: 3 }] }, }); - test('should render list fetched of items', async () => { - const wrapper = mountWithContexts( - <NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} /> - ); - await sleep(0); + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( + <NotificationList + id={1} + canToggleNotifications + apiModel={MockModelAPI} + /> + ); + }); wrapper.update(); + }); + afterEach(() => { + wrapper.unmount(); + }); + + test('initially renders succesfully', () => { + expect(wrapper.find('PaginatedDataList')).toHaveLength(1); + }); + + test('should render list fetched of items', () => { expect(NotificationTemplatesAPI.read).toHaveBeenCalled(); - expect(wrapper.find('NotificationList').state('notifications')).toEqual( - data.results - ); - const items = wrapper.find('NotificationListItem'); - expect(items).toHaveLength(3); - expect(items.at(0).prop('successTurnedOn')).toEqual(true); - expect(items.at(0).prop('errorTurnedOn')).toEqual(false); - expect(items.at(0).prop('startedTurnedOn')).toEqual(false); - expect(items.at(1).prop('successTurnedOn')).toEqual(false); - expect(items.at(1).prop('errorTurnedOn')).toEqual(true); - expect(items.at(1).prop('startedTurnedOn')).toEqual(false); - expect(items.at(2).prop('successTurnedOn')).toEqual(false); - expect(items.at(2).prop('errorTurnedOn')).toEqual(false); - expect(items.at(2).prop('startedTurnedOn')).toEqual(true); + expect(NotificationTemplatesAPI.readOptions).toHaveBeenCalled(); + expect(MockModelAPI.readNotificationTemplatesSuccess).toHaveBeenCalled(); + expect(MockModelAPI.readNotificationTemplatesError).toHaveBeenCalled(); + expect(MockModelAPI.readNotificationTemplatesStarted).toHaveBeenCalled(); + expect(wrapper.find('NotificationListItem').length).toBe(3); + expect( + wrapper.find('input#notification-1-success-toggle').props().checked + ).toBe(true); + expect( + wrapper.find('input#notification-1-error-toggle').props().checked + ).toBe(false); + expect( + wrapper.find('input#notification-1-started-toggle').props().checked + ).toBe(false); + expect( + wrapper.find('input#notification-2-success-toggle').props().checked + ).toBe(false); + expect( + wrapper.find('input#notification-2-error-toggle').props().checked + ).toBe(true); + expect( + wrapper.find('input#notification-2-started-toggle').props().checked + ).toBe(false); + expect( + wrapper.find('input#notification-3-success-toggle').props().checked + ).toBe(false); + expect( + wrapper.find('input#notification-3-error-toggle').props().checked + ).toBe(false); + expect( + wrapper.find('input#notification-3-started-toggle').props().checked + ).toBe(true); }); test('should enable success notification', async () => { - const wrapper = mountWithContexts( - <NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} /> - ); - await sleep(0); - wrapper.update(); - expect( - wrapper.find('NotificationList').state('successTemplateIds') - ).toEqual([1]); - const items = wrapper.find('NotificationListItem'); - items - .at(1) - .find('Switch[aria-label="Toggle notification success"]') - .prop('onChange')(); + wrapper.find('input#notification-2-success-toggle').props().checked + ).toBe(false); + await act(async () => { + wrapper.find('Switch#notification-2-success-toggle').prop('onChange')(); + }); + wrapper.update(); expect(MockModelAPI.associateNotificationTemplate).toHaveBeenCalledWith( 1, 2, 'success' ); - await sleep(0); - wrapper.update(); expect( - wrapper.find('NotificationList').state('successTemplateIds') - ).toEqual([1, 2]); + wrapper.find('input#notification-2-success-toggle').props().checked + ).toBe(true); }); test('should enable error notification', async () => { - const wrapper = mountWithContexts( - <NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} /> - ); - await sleep(0); + expect( + wrapper.find('input#notification-1-error-toggle').props().checked + ).toBe(false); + await act(async () => { + wrapper.find('Switch#notification-1-error-toggle').prop('onChange')(); + }); wrapper.update(); - - expect(wrapper.find('NotificationList').state('errorTemplateIds')).toEqual([ - 2, - ]); - const items = wrapper.find('NotificationListItem'); - items - .at(0) - .find('Switch[aria-label="Toggle notification failure"]') - .prop('onChange')(); expect(MockModelAPI.associateNotificationTemplate).toHaveBeenCalledWith( 1, 1, 'error' ); - await sleep(0); - wrapper.update(); - expect(wrapper.find('NotificationList').state('errorTemplateIds')).toEqual([ - 2, - 1, - ]); + expect( + wrapper.find('input#notification-1-error-toggle').props().checked + ).toBe(true); }); test('should enable start notification', async () => { - const wrapper = mountWithContexts( - <NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} /> - ); - await sleep(0); - wrapper.update(); - expect( - wrapper.find('NotificationList').state('startedTemplateIds') - ).toEqual([3]); - const items = wrapper.find('NotificationListItem'); - items - .at(0) - .find('Switch[aria-label="Toggle notification start"]') - .prop('onChange')(); + wrapper.find('input#notification-1-started-toggle').props().checked + ).toBe(false); + await act(async () => { + wrapper.find('Switch#notification-1-started-toggle').prop('onChange')(); + }); + wrapper.update(); expect(MockModelAPI.associateNotificationTemplate).toHaveBeenCalledWith( 1, 1, 'started' ); - await sleep(0); - wrapper.update(); expect( - wrapper.find('NotificationList').state('startedTemplateIds') - ).toEqual([3, 1]); + wrapper.find('input#notification-1-started-toggle').props().checked + ).toBe(true); }); test('should disable success notification', async () => { - const wrapper = mountWithContexts( - <NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} /> - ); - await sleep(0); - wrapper.update(); - expect( - wrapper.find('NotificationList').state('successTemplateIds') - ).toEqual([1]); - const items = wrapper.find('NotificationListItem'); - items - .at(0) - .find('Switch[aria-label="Toggle notification success"]') - .prop('onChange')(); + wrapper.find('input#notification-1-success-toggle').props().checked + ).toBe(true); + await act(async () => { + wrapper.find('Switch#notification-1-success-toggle').prop('onChange')(); + }); + wrapper.update(); expect(MockModelAPI.disassociateNotificationTemplate).toHaveBeenCalledWith( 1, 1, 'success' ); - await sleep(0); - wrapper.update(); expect( - wrapper.find('NotificationList').state('successTemplateIds') - ).toEqual([]); + wrapper.find('input#notification-1-success-toggle').props().checked + ).toBe(false); }); test('should disable error notification', async () => { - const wrapper = mountWithContexts( - <NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} /> - ); - await sleep(0); + expect( + wrapper.find('input#notification-2-error-toggle').props().checked + ).toBe(true); + await act(async () => { + wrapper.find('Switch#notification-2-error-toggle').prop('onChange')(); + }); wrapper.update(); - - expect(wrapper.find('NotificationList').state('errorTemplateIds')).toEqual([ - 2, - ]); - const items = wrapper.find('NotificationListItem'); - items - .at(1) - .find('Switch[aria-label="Toggle notification failure"]') - .prop('onChange')(); expect(MockModelAPI.disassociateNotificationTemplate).toHaveBeenCalledWith( 1, 2, 'error' ); - await sleep(0); - wrapper.update(); - expect(wrapper.find('NotificationList').state('errorTemplateIds')).toEqual( - [] - ); + expect( + wrapper.find('input#notification-2-error-toggle').props().checked + ).toBe(false); }); test('should disable start notification', async () => { - const wrapper = mountWithContexts( - <NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} /> - ); - await sleep(0); - wrapper.update(); - expect( - wrapper.find('NotificationList').state('startedTemplateIds') - ).toEqual([3]); - const items = wrapper.find('NotificationListItem'); - items - .at(2) - .find('Switch[aria-label="Toggle notification start"]') - .prop('onChange')(); + wrapper.find('input#notification-3-started-toggle').props().checked + ).toBe(true); + await act(async () => { + wrapper.find('Switch#notification-3-started-toggle').prop('onChange')(); + }); + wrapper.update(); expect(MockModelAPI.disassociateNotificationTemplate).toHaveBeenCalledWith( 1, 3, 'started' ); - await sleep(0); - wrapper.update(); expect( - wrapper.find('NotificationList').state('startedTemplateIds') - ).toEqual([]); + wrapper.find('input#notification-3-started-toggle').props().checked + ).toBe(false); }); + test('should throw toggle error', async () => { MockModelAPI.associateNotificationTemplate.mockRejectedValue( new Error({ @@ -284,27 +247,16 @@ describe('<NotificationList />', () => { }, }) ); - const wrapper = mountWithContexts( - <NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} /> - ); - await sleep(0); + expect(wrapper.find('ErrorDetail').length).toBe(0); + await act(async () => { + wrapper.find('Switch#notification-1-started-toggle').prop('onChange')(); + }); wrapper.update(); - - expect( - wrapper.find('NotificationList').state('startedTemplateIds') - ).toEqual([3]); - const items = wrapper.find('NotificationListItem'); - items - .at(0) - .find('Switch[aria-label="Toggle notification start"]') - .prop('onChange')(); expect(MockModelAPI.associateNotificationTemplate).toHaveBeenCalledWith( 1, 1, 'started' ); - await sleep(0); - wrapper.update(); expect(wrapper.find('ErrorDetail').length).toBe(1); }); }); diff --git a/awx/ui_next/src/components/OptionsList/OptionsList.jsx b/awx/ui_next/src/components/OptionsList/OptionsList.jsx index 3ff42f9f71..b2242d7202 100644 --- a/awx/ui_next/src/components/OptionsList/OptionsList.jsx +++ b/awx/ui_next/src/components/OptionsList/OptionsList.jsx @@ -30,6 +30,8 @@ function OptionsList({ optionCount, searchColumns, sortColumns, + searchableKeys, + relatedSearchableKeys, multiple, header, name, @@ -47,7 +49,6 @@ function OptionsList({ <SelectedList label={i18n._(t`Selected`)} selected={value} - showOverflowAfter={5} onRemove={item => deselectItem(item)} isReadOnly={readOnly} renderItemChip={renderItemChip} @@ -61,6 +62,8 @@ function OptionsList({ qsConfig={qsConfig} toolbarSearchColumns={searchColumns} toolbarSortColumns={sortColumns} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} hasContentLoading={isLoading} onRowClick={selectItem} renderItem={item => ( diff --git a/awx/ui_next/src/components/OptionsList/OptionsList.test.jsx b/awx/ui_next/src/components/OptionsList/OptionsList.test.jsx index 34bfed560b..5c4498a748 100644 --- a/awx/ui_next/src/components/OptionsList/OptionsList.test.jsx +++ b/awx/ui_next/src/components/OptionsList/OptionsList.test.jsx @@ -17,7 +17,9 @@ describe('<OptionsList />', () => { value={[]} options={options} optionCount={3} - searchColumns={[{ name: 'Foo', key: 'foo', isDefault: true }]} + searchColumns={[ + { name: 'Foo', key: 'foo__icontains', isDefault: true }, + ]} sortColumns={[{ name: 'Foo', key: 'foo' }]} qsConfig={qsConfig} selectItem={() => {}} @@ -40,7 +42,9 @@ describe('<OptionsList />', () => { value={[options[1]]} options={options} optionCount={3} - searchColumns={[{ name: 'Foo', key: 'foo', isDefault: true }]} + searchColumns={[ + { name: 'Foo', key: 'foo__icontains', isDefault: true }, + ]} sortColumns={[{ name: 'Foo', key: 'foo' }]} qsConfig={qsConfig} selectItem={() => {}} diff --git a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx index e69775fd6f..8bd449e851 100644 --- a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx @@ -69,6 +69,8 @@ class PaginatedDataList extends React.Component { qsConfig, renderItem, toolbarSearchColumns, + toolbarSearchableKeys, + toolbarRelatedSearchableKeys, toolbarSortColumns, pluralizedItemName, showPageSizeOptions, @@ -121,6 +123,28 @@ class PaginatedDataList extends React.Component { ); } + const ToolbarPagination = ( + <Pagination + isCompact + dropDirection="down" + itemCount={itemCount} + page={queryParams.page || 1} + perPage={queryParams.page_size} + perPageOptions={ + showPageSizeOptions + ? [ + { title: '5', value: 5 }, + { title: '10', value: 10 }, + { title: '20', value: 20 }, + { title: '50', value: 50 }, + ] + : [] + } + onSetPage={this.handleSetPage} + onPerPageSelect={this.handleSetPageSize} + /> + ); + return ( <Fragment> <ListHeader @@ -129,7 +153,10 @@ class PaginatedDataList extends React.Component { emptyStateControls={emptyStateControls} searchColumns={searchColumns} sortColumns={sortColumns} + searchableKeys={toolbarSearchableKeys} + relatedSearchableKeys={toolbarRelatedSearchableKeys} qsConfig={qsConfig} + pagination={ToolbarPagination} /> {Content} {items.length ? ( @@ -170,6 +197,8 @@ PaginatedDataList.propTypes = { qsConfig: QSConfig.isRequired, renderItem: PropTypes.func, toolbarSearchColumns: SearchColumns, + toolbarSearchableKeys: PropTypes.arrayOf(PropTypes.string), + toolbarRelatedSearchableKeys: PropTypes.arrayOf(PropTypes.string), toolbarSortColumns: SortColumns, showPageSizeOptions: PropTypes.bool, renderToolbar: PropTypes.func, @@ -182,6 +211,8 @@ PaginatedDataList.defaultProps = { hasContentLoading: false, contentError: null, toolbarSearchColumns: [], + toolbarSearchableKeys: [], + toolbarRelatedSearchableKeys: [], toolbarSortColumns: [], pluralizedItemName: 'Items', showPageSizeOptions: true, diff --git a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.test.jsx b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.test.jsx index b3a60aa2da..fe4b85b0b0 100644 --- a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.test.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.test.jsx @@ -55,7 +55,7 @@ describe('<PaginatedDataList />', () => { { context: { router: { history } } } ); - const pagination = wrapper.find('Pagination'); + const pagination = wrapper.find('Pagination').at(1); pagination.prop('onSetPage')(null, 2); expect(history.location.search).toEqual('?item.page=2'); wrapper.update(); @@ -82,7 +82,7 @@ describe('<PaginatedDataList />', () => { { context: { router: { history } } } ); - const pagination = wrapper.find('Pagination'); + const pagination = wrapper.find('Pagination').at(1); pagination.prop('onPerPageSelect')(null, 25, 2); expect(history.location.search).toEqual('?item.page=2&item.page_size=25'); wrapper.update(); diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarAddButton.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarAddButton.jsx index 19ae9a68c9..4c5c295976 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarAddButton.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarAddButton.jsx @@ -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"> diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx index b50277c550..7be476dfc1 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx @@ -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; @@ -63,10 +64,12 @@ class ToolbarDeleteButton extends React.Component { onDelete: func.isRequired, itemsToDelete: arrayOf(ItemToDelete).isRequired, pluralizedItemName: string, + errorMessage: string, }; static defaultProps = { pluralizedItemName: 'Items', + errorMessage: '', }; constructor(props) { @@ -96,7 +99,12 @@ class ToolbarDeleteButton extends React.Component { } renderTooltip() { - const { itemsToDelete, pluralizedItemName, i18n } = this.props; + const { + itemsToDelete, + pluralizedItemName, + errorMessage, + i18n, + } = this.props; const itemsUnableToDelete = itemsToDelete .filter(cannotDelete) @@ -105,9 +113,11 @@ class ToolbarDeleteButton extends React.Component { if (itemsToDelete.some(cannotDelete)) { return ( <div> - {i18n._( - t`You do not have permission to delete the following ${pluralizedItemName}: ${itemsUnableToDelete}` - )} + {errorMessage.length > 0 + ? errorMessage + : i18n._( + t`You do not have permission to delete ${pluralizedItemName}: ${itemsUnableToDelete}` + )} </div> ); } @@ -129,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="danger" - 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> ); } } diff --git a/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap b/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap index e837c39658..9d60823722 100644 --- a/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap +++ b/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap @@ -2,68 +2,20 @@ exports[`<ToolbarDeleteButton /> should render button 1`] = ` <ToolbarDeleteButton + errorMessage="" i18n={"/i18n/"} itemsToDelete={Array []} onDelete={[Function]} pluralizedItemName="Items" > <Tooltip - appendTo={[Function]} - aria="describedby" - boundary="window" - className="" content="Select a row to delete" - distance={15} - enableFlip={true} - entryDelay={500} - exitDelay={500} - flipBehavior={ - Array [ - "top", - "right", - "bottom", - "left", - "top", - "right", - "bottom", - ] - } - id="" - isAppLauncher={false} - isContentLeftAligned={false} - isVisible={false} - maxWidth="18.75rem" position="top" - tippyProps={Object {}} - trigger="mouseenter focus" - zIndex={9999} > - <PopoverBase + <Popper appendTo={[Function]} - aria="describedby" - arrow={true} - boundary="window" - content={ - <div - className="" - id="" - role="tooltip" - > - <TooltipContent - isLeftAligned={false} - > - Select a row to delete - </TooltipContent> - </div> - } - delay={ - Array [ - 500, - 500, - ] - } distance={15} - flip={true} + enableFlip={true} flipBehavior={ Array [ "top", @@ -76,83 +28,90 @@ exports[`<ToolbarDeleteButton /> should render button 1`] = ` ] } isVisible={false} - lazy={true} - maxWidth="18.75rem" - onCreate={[Function]} + onBlur={[Function]} + onDocumentClick={false} + onDocumentKeyDown={[Function]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} + onTriggerEnter={[Function]} placement="top" - popperOptions={ + popper={ + <div + className="pf-c-tooltip" + id="pf-tooltip-1" + role="tooltip" + style={ + Object { + "maxWidth": null, + "opacity": 0, + "transition": "opacity 300ms cubic-bezier(.54, 1.5, .38, 1.11)", + } + } + > + <TooltipArrow /> + <TooltipContent + isLeftAligned={false} + > + Select a row to delete + </TooltipContent> + </div> + } + popperMatchesTriggerWidth={false} + positionModifiers={ Object { - "modifiers": Object { - "hide": Object { - "enabled": true, - }, - "preventOverflow": Object { - "enabled": true, - }, - }, + "bottom": "pf-m-bottom", + "left": "pf-m-left", + "right": "pf-m-right", + "top": "pf-m-top", } } - theme="pf-tooltip" - trigger="mouseenter focus" - zIndex={9999} - > - <div> - <Button - aria-label="Delete" - isDisabled={true} - onClick={[Function]} - variant="danger" + trigger={ + <div + aria-describedby="pf-tooltip-1" > - <button - aria-disabled={null} + <Button aria-label="Delete" - className="pf-c-button pf-m-danger" - data-ouia-component-id={null} - data-ouia-component-type="PF4/Button" - data-ouia-safe={true} - disabled={true} + isDisabled={true} onClick={[Function]} - tabIndex={null} - type="button" + variant="secondary" > Delete - </button> - </Button> - </div> - <Portal - containerInfo={ - <div> - <div - class="" - id="" - role="tooltip" - > - <div - class="pf-c-tooltip__content" - > - Select a row to delete - </div> - </div> - </div> - } + </Button> + </div> + } + zIndex={9999} + > + <FindRefWrapper + onFoundRef={[Function]} > <div - className="" - id="" - role="tooltip" + aria-describedby="pf-tooltip-1" > - <TooltipContent - isLeftAligned={false} + <Button + aria-label="Delete" + isDisabled={true} + onClick={[Function]} + variant="secondary" > - <div - className="pf-c-tooltip__content" + <button + aria-disabled={true} + aria-label="Delete" + className="pf-c-button pf-m-secondary pf-m-disabled" + data-ouia-component-id={0} + data-ouia-component-type="PF4/Button" + data-ouia-safe={true} + disabled={true} + onClick={[Function]} + tabIndex={null} + type="button" > - Select a row to delete - </div> - </TooltipContent> + Delete + </button> + </Button> </div> - </Portal> - </PopoverBase> + </FindRefWrapper> + </Popper> </Tooltip> </ToolbarDeleteButton> `; diff --git a/awx/ui_next/src/components/PromptDetail/PromptInventorySourceDetail.jsx b/awx/ui_next/src/components/PromptDetail/PromptInventorySourceDetail.jsx index 46c89b7b4a..6f4204bb67 100644 --- a/awx/ui_next/src/components/PromptDetail/PromptInventorySourceDetail.jsx +++ b/awx/ui_next/src/components/PromptDetail/PromptInventorySourceDetail.jsx @@ -98,10 +98,6 @@ function PromptInventorySourceDetail({ i18n, resource }) { /> )} <Detail label={i18n._(t`Inventory File`)} value={source_path} /> - <Detail - label={i18n._(t`Custom Inventory Script`)} - value={summary_fields?.source_script?.name} - /> <Detail label={i18n._(t`Verbosity`)} value={VERBOSITY[verbosity]} /> <Detail label={i18n._(t`Cache Timeout`)} diff --git a/awx/ui_next/src/components/PromptDetail/PromptInventorySourceDetail.test.jsx b/awx/ui_next/src/components/PromptDetail/PromptInventorySourceDetail.test.jsx index 142ce6cf91..44efcec8d1 100644 --- a/awx/ui_next/src/components/PromptDetail/PromptInventorySourceDetail.test.jsx +++ b/awx/ui_next/src/components/PromptDetail/PromptInventorySourceDetail.test.jsx @@ -30,7 +30,6 @@ describe('PromptInventorySourceDetail', () => { assertDetail(wrapper, 'Source', 'scm'); assertDetail(wrapper, 'Project', 'Mock Project'); assertDetail(wrapper, 'Inventory File', 'foo'); - assertDetail(wrapper, 'Custom Inventory Script', 'Mock Script'); assertDetail(wrapper, 'Verbosity', '2 (More Verbose)'); assertDetail(wrapper, 'Cache Timeout', '2 Seconds'); expect( diff --git a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx index 10ac42b77f..c74d6b5865 100644 --- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx @@ -1,19 +1,14 @@ -import React, { Fragment } from 'react'; -import { withRouter } from 'react-router-dom'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; - import { TeamsAPI, UsersAPI } from '../../api'; import AddResourceRole from '../AddRole/AddResourceRole'; import AlertModal from '../AlertModal'; import DataListToolbar from '../DataListToolbar'; import PaginatedDataList, { ToolbarAddButton } from '../PaginatedDataList'; -import { - getQSConfig, - encodeQueryString, - parseQueryString, -} from '../../util/qs'; - +import { getQSConfig, parseQueryString } from '../../util/qs'; +import useRequest, { useDeleteItems } from '../../util/useRequest'; import DeleteRoleConfirmationModal from './DeleteRoleConfirmationModal'; import ResourceAccessListItem from './ResourceAccessListItem'; @@ -23,227 +18,160 @@ const QS_CONFIG = getQSConfig('access', { order_by: 'first_name', }); -class ResourceAccessList extends React.Component { - constructor(props) { - super(props); - this.state = { +function ResourceAccessList({ i18n, apiModel, resource }) { + const [deletionRecord, setDeletionRecord] = useState(null); + const [deletionRole, setDeletionRole] = useState(null); + const [showAddModal, setShowAddModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const location = useLocation(); + + const { + result: { accessRecords, itemCount }, + error: contentError, + isLoading, + request: fetchAccessRecords, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const response = await apiModel.readAccessList(resource.id, params); + return { + accessRecords: response.data.results, + itemCount: response.data.count, + }; + }, [apiModel, location, resource.id]), + { accessRecords: [], - contentError: null, - hasContentLoading: true, - hasDeletionError: false, - deletionRecord: null, - deletionRole: null, - isAddModalOpen: false, itemCount: 0, - }; - this.loadAccessList = this.loadAccessList.bind(this); - this.handleAddClose = this.handleAddClose.bind(this); - this.handleAddOpen = this.handleAddOpen.bind(this); - this.handleAddSuccess = this.handleAddSuccess.bind(this); - this.handleDeleteCancel = this.handleDeleteCancel.bind(this); - this.handleDeleteConfirm = this.handleDeleteConfirm.bind(this); - this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this); - this.handleDeleteOpen = this.handleDeleteOpen.bind(this); - } - - componentDidMount() { - this.loadAccessList(); - } - - componentDidUpdate(prevProps) { - const { location } = this.props; - - const prevParams = parseQueryString(QS_CONFIG, prevProps.location.search); - const currentParams = parseQueryString(QS_CONFIG, location.search); - - if (encodeQueryString(currentParams) !== encodeQueryString(prevParams)) { - this.loadAccessList(); - } - } - - async loadAccessList() { - const { apiModel, resource, location } = this.props; - const params = parseQueryString(QS_CONFIG, location.search); - - this.setState({ contentError: null, hasContentLoading: true }); - try { - const { - data: { results: accessRecords = [], count: itemCount = 0 }, - } = await apiModel.readAccessList(resource.id, params); - this.setState({ itemCount, accessRecords }); - } catch (err) { - this.setState({ contentError: err }); - } finally { - this.setState({ hasContentLoading: false }); } - } - - handleDeleteOpen(deletionRole, deletionRecord) { - this.setState({ deletionRole, deletionRecord }); - } - - handleDeleteCancel() { - this.setState({ deletionRole: null, deletionRecord: null }); - } - - handleDeleteErrorClose() { - this.setState({ - hasDeletionError: false, - deletionRecord: null, - deletionRole: null, - }); - } - - async handleDeleteConfirm() { - const { deletionRole, deletionRecord } = this.state; - - if (!deletionRole || !deletionRecord) { - return; + ); + + useEffect(() => { + fetchAccessRecords(); + }, [fetchAccessRecords]); + + const { + isLoading: isDeleteLoading, + deleteItems: deleteRole, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + if (typeof deletionRole.team_id !== 'undefined') { + return TeamsAPI.disassociateRole(deletionRole.team_id, deletionRole.id); + } + return UsersAPI.disassociateRole(deletionRecord.id, deletionRole.id); + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [deletionRole]), + { + qsConfig: QS_CONFIG, + fetchItems: fetchAccessRecords, } - - let promise; - if (typeof deletionRole.team_id !== 'undefined') { - promise = TeamsAPI.disassociateRole( - deletionRole.team_id, - deletionRole.id - ); - } else { - promise = UsersAPI.disassociateRole(deletionRecord.id, deletionRole.id); - } - - this.setState({ hasContentLoading: true }); - try { - await promise.then(this.loadAccessList); - this.setState({ - deletionRole: null, - deletionRecord: null, - }); - } catch (error) { - this.setState({ - hasContentLoading: false, - hasDeletionError: true, - }); - } - } - - handleAddClose() { - this.setState({ isAddModalOpen: false }); - } - - handleAddOpen() { - this.setState({ isAddModalOpen: true }); - } - - handleAddSuccess() { - this.setState({ isAddModalOpen: false }); - this.loadAccessList(); - } - - render() { - const { resource, i18n } = this.props; - const { - accessRecords, - contentError, - hasContentLoading, - deletionRole, - deletionRecord, - hasDeletionError, - itemCount, - isAddModalOpen, - } = this.state; - const canEdit = resource.summary_fields.user_capabilities.edit; - const isDeleteModalOpen = - !hasContentLoading && !hasDeletionError && deletionRole; - - return ( - <Fragment> - <PaginatedDataList - error={contentError} - hasContentLoading={hasContentLoading} - items={accessRecords} - itemCount={itemCount} - pluralizedItemName={i18n._(t`Roles`)} - qsConfig={QS_CONFIG} - toolbarSearchColumns={[ - { - name: i18n._(t`Username`), - key: 'username', - isDefault: true, - }, - { - name: i18n._(t`First Name`), - key: 'first_name', - }, - { - name: i18n._(t`Last Name`), - key: 'last_name', - }, - ]} - toolbarSortColumns={[ - { - name: i18n._(t`Username`), - key: 'username', - }, - { - name: i18n._(t`First Name`), - key: 'first_name', - }, - { - name: i18n._(t`Last Name`), - key: 'last_name', - }, - ]} - renderToolbar={props => ( - <DataListToolbar - {...props} - qsConfig={QS_CONFIG} - additionalControls={ - canEdit - ? [ - <ToolbarAddButton - key="add" - onClick={this.handleAddOpen} - />, - ] - : [] - } - /> - )} - renderItem={accessRecord => ( - <ResourceAccessListItem - key={accessRecord.id} - accessRecord={accessRecord} - onRoleDelete={this.handleDeleteOpen} - /> - )} - /> - {isAddModalOpen && ( - <AddResourceRole - onClose={this.handleAddClose} - onSave={this.handleAddSuccess} - roles={resource.summary_fields.object_roles} + ); + + return ( + <> + <PaginatedDataList + error={contentError} + hasContentLoading={isLoading || isDeleteLoading} + items={accessRecords} + itemCount={itemCount} + pluralizedItemName={i18n._(t`Roles`)} + qsConfig={QS_CONFIG} + toolbarSearchColumns={[ + { + name: i18n._(t`Username`), + key: 'username__icontains', + isDefault: true, + }, + { + name: i18n._(t`First Name`), + key: 'first_name__icontains', + }, + { + name: i18n._(t`Last Name`), + key: 'last_name__icontains', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Username`), + key: 'username', + }, + { + name: i18n._(t`First Name`), + key: 'first_name', + }, + { + name: i18n._(t`Last Name`), + key: 'last_name', + }, + ]} + renderToolbar={props => ( + <DataListToolbar + {...props} + qsConfig={QS_CONFIG} + additionalControls={ + resource?.summary_fields?.user_capabilities?.edit + ? [ + <ToolbarAddButton + key="add" + onClick={() => setShowAddModal(true)} + />, + ] + : [] + } /> )} - {isDeleteModalOpen && ( - <DeleteRoleConfirmationModal - role={deletionRole} - username={deletionRecord.username} - onCancel={this.handleDeleteCancel} - onConfirm={this.handleDeleteConfirm} + renderItem={accessRecord => ( + <ResourceAccessListItem + key={accessRecord.id} + accessRecord={accessRecord} + onRoleDelete={(role, record) => { + setDeletionRecord(record); + setDeletionRole(role); + setShowDeleteModal(true); + }} /> )} + /> + {showAddModal && ( + <AddResourceRole + onClose={() => setShowAddModal(false)} + onSave={() => { + setShowAddModal(false); + fetchAccessRecords(); + }} + roles={resource.summary_fields.object_roles} + /> + )} + {showDeleteModal && ( + <DeleteRoleConfirmationModal + role={deletionRole} + username={deletionRecord.username} + onCancel={() => { + setDeletionRecord(null); + setDeletionRole(null); + setShowDeleteModal(false); + }} + onConfirm={async () => { + await deleteRole(); + setShowDeleteModal(false); + setDeletionRecord(null); + setDeletionRole(null); + }} + /> + )} + {deletionError && ( <AlertModal - isOpen={hasDeletionError} + isOpen={deletionError} variant="error" title={i18n._(t`Error!`)} - onClose={this.handleDeleteErrorClose} + onClose={clearDeletionError} > {i18n._(t`Failed to delete role`)} </AlertModal> - </Fragment> - ); - } + )} + </> + ); } - -export { ResourceAccessList as _ResourceAccessList }; -export default withI18n()(withRouter(ResourceAccessList)); +export default withI18n()(ResourceAccessList); diff --git a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.test.jsx b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.test.jsx index c4f88e825a..4bdb9c08f0 100644 --- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.test.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.test.jsx @@ -1,6 +1,5 @@ import React from 'react'; - -import { sleep } from '../../../testUtils/testUtils'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts, waitForElement, @@ -13,6 +12,7 @@ import ResourceAccessList from './ResourceAccessList'; jest.mock('../../api'); describe('<ResourceAccessList />', () => { + let wrapper; const organization = { id: 1, name: 'Default', @@ -74,108 +74,68 @@ describe('<ResourceAccessList />', () => { ], }; - beforeEach(() => { + beforeEach(async () => { OrganizationsAPI.readAccessList.mockResolvedValue({ data }); TeamsAPI.disassociateRole.mockResolvedValue({}); UsersAPI.disassociateRole.mockResolvedValue({}); + await act(async () => { + wrapper = mountWithContexts( + <ResourceAccessList + resource={organization} + apiModel={OrganizationsAPI} + /> + ); + }); + wrapper.update(); }); afterEach(() => { + wrapper.unmount(); jest.clearAllMocks(); }); test('initially renders succesfully', () => { - const wrapper = mountWithContexts( - <ResourceAccessList resource={organization} apiModel={OrganizationsAPI} /> - ); expect(wrapper.find('PaginatedDataList')).toHaveLength(1); }); test('should fetch and display access records on mount', async done => { - const wrapper = mountWithContexts( - <ResourceAccessList resource={organization} apiModel={OrganizationsAPI} /> - ); - await waitForElement( - wrapper, - 'ResourceAccessListItem', - el => el.length === 2 - ); - expect(wrapper.find('PaginatedDataList').prop('items')).toEqual( - data.results - ); - expect(wrapper.find('ResourceAccessList').state('hasContentLoading')).toBe( - false - ); - expect(wrapper.find('ResourceAccessList').state('contentError')).toBe(null); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(OrganizationsAPI.readAccessList).toHaveBeenCalled(); + expect(wrapper.find('ResourceAccessListItem').length).toBe(2); done(); }); - test('should open confirmation dialog when deleting role', async done => { - const wrapper = mountWithContexts( - <ResourceAccessList resource={organization} apiModel={OrganizationsAPI} /> - ); - await sleep(0); - wrapper.update(); - + test('should open and close confirmation dialog when deleting role', async done => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('DeleteRoleConfirmationModal')).toHaveLength(0); const button = wrapper.find('Chip Button').at(0); - button.prop('onClick')(); - wrapper.update(); - - const component = wrapper.find('ResourceAccessList'); - expect(component.state('deletionRole')).toEqual( - data.results[0].summary_fields.direct_access[0].role - ); - expect(component.state('deletionRecord')).toEqual(data.results[0]); - expect(component.find('DeleteRoleConfirmationModal')).toHaveLength(1); - done(); - }); - - it('should close dialog when cancel button clicked', async done => { - const wrapper = mountWithContexts( - <ResourceAccessList resource={organization} apiModel={OrganizationsAPI} /> - ); - await sleep(0); + await act(async () => { + button.prop('onClick')(); + }); wrapper.update(); - const button = wrapper.find('Chip Button').at(0); - button.prop('onClick')(); + expect(wrapper.find('DeleteRoleConfirmationModal')).toHaveLength(1); + await act(async () => { + wrapper.find('DeleteRoleConfirmationModal').prop('onCancel')(); + }); wrapper.update(); - - wrapper.find('DeleteRoleConfirmationModal').prop('onCancel')(); - const component = wrapper.find('ResourceAccessList'); - expect(component.state('deletionRole')).toBeNull(); - expect(component.state('deletionRecord')).toBeNull(); + expect(wrapper.find('DeleteRoleConfirmationModal')).toHaveLength(0); expect(TeamsAPI.disassociateRole).not.toHaveBeenCalled(); expect(UsersAPI.disassociateRole).not.toHaveBeenCalled(); done(); }); it('should delete user role', async done => { - const wrapper = mountWithContexts( - <ResourceAccessList resource={organization} apiModel={OrganizationsAPI} /> - ); - const button = await waitForElement( - wrapper, - 'Chip Button', - el => el.length === 2 - ); - button.at(0).prop('onClick')(); - - const confirmation = await waitForElement( - wrapper, - 'DeleteRoleConfirmationModal' - ); - confirmation.prop('onConfirm')(); - await waitForElement( - wrapper, - 'DeleteRoleConfirmationModal', - el => el.length === 0 - ); - - await sleep(0); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + const button = wrapper.find('Chip Button').at(0); + await act(async () => { + button.prop('onClick')(); + }); wrapper.update(); - const component = wrapper.find('ResourceAccessList'); - expect(component.state('deletionRole')).toBeNull(); - expect(component.state('deletionRecord')).toBeNull(); + await act(async () => { + wrapper.find('DeleteRoleConfirmationModal').prop('onConfirm')(); + }); + wrapper.update(); + expect(wrapper.find('DeleteRoleConfirmationModal')).toHaveLength(0); expect(TeamsAPI.disassociateRole).not.toHaveBeenCalled(); expect(UsersAPI.disassociateRole).toHaveBeenCalledWith(1, 1); expect(OrganizationsAPI.readAccessList).toHaveBeenCalledTimes(2); @@ -183,32 +143,17 @@ describe('<ResourceAccessList />', () => { }); it('should delete team role', async done => { - const wrapper = mountWithContexts( - <ResourceAccessList resource={organization} apiModel={OrganizationsAPI} /> - ); - const button = await waitForElement( - wrapper, - 'Chip Button', - el => el.length === 2 - ); - button.at(1).prop('onClick')(); - - const confirmation = await waitForElement( - wrapper, - 'DeleteRoleConfirmationModal' - ); - confirmation.prop('onConfirm')(); - await waitForElement( - wrapper, - 'DeleteRoleConfirmationModal', - el => el.length === 0 - ); - - await sleep(0); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + const button = wrapper.find('Chip Button').at(1); + await act(async () => { + button.prop('onClick')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('DeleteRoleConfirmationModal').prop('onConfirm')(); + }); wrapper.update(); - const component = wrapper.find('ResourceAccessList'); - expect(component.state('deletionRole')).toBeNull(); - expect(component.state('deletionRecord')).toBeNull(); + expect(wrapper.find('DeleteRoleConfirmationModal')).toHaveLength(0); expect(TeamsAPI.disassociateRole).toHaveBeenCalledWith(5, 3); expect(UsersAPI.disassociateRole).not.toHaveBeenCalled(); expect(OrganizationsAPI.readAccessList).toHaveBeenCalledTimes(2); diff --git a/awx/ui_next/src/components/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap index 130f418117..d010f43f4c 100644 --- a/awx/ui_next/src/components/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap +++ b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap @@ -103,12 +103,17 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = ` aria-labelledby="pf-modal-part-0 alert-modal-header-label pf-modal-part-1" aria-modal="true" class="pf-c-modal-box pf-m-sm" + data-ouia-component-id="0" + data-ouia-component-type="PF4/ModalContent" + data-ouia-safe="true" id="pf-modal-part-0" role="dialog" > <button + aria-disabled="false" aria-label="Close" class="pf-c-button pf-m-plain" + data-ouia-component-id="1" data-ouia-component-type="PF4/Button" data-ouia-safe="true" type="button" @@ -170,8 +175,10 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = ` class="pf-c-modal-box__footer" > <button + aria-disabled="false" aria-label="Confirm delete" class="pf-c-button pf-m-danger" + data-ouia-component-id="2" data-ouia-component-type="PF4/Button" data-ouia-safe="true" type="button" @@ -179,7 +186,9 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = ` Delete </button> <button + aria-disabled="false" class="pf-c-button pf-m-secondary" + data-ouia-component-id="3" data-ouia-component-type="PF4/Button" data-ouia-safe="true" type="button" @@ -214,6 +223,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = ` } isOpen={true} onClose={[Function]} + ouiaSafe={true} showClose={true} title="Remove Team Access" variant="small" @@ -233,12 +243,17 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = ` aria-labelledby="pf-modal-part-0 alert-modal-header-label pf-modal-part-1" aria-modal="true" class="pf-c-modal-box pf-m-sm" + data-ouia-component-id="0" + data-ouia-component-type="PF4/ModalContent" + data-ouia-safe="true" id="pf-modal-part-0" role="dialog" > <button + aria-disabled="false" aria-label="Close" class="pf-c-button pf-m-plain" + data-ouia-component-id="1" data-ouia-component-type="PF4/Button" data-ouia-safe="true" type="button" @@ -300,8 +315,10 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = ` class="pf-c-modal-box__footer" > <button + aria-disabled="false" aria-label="Confirm delete" class="pf-c-button pf-m-danger" + data-ouia-component-id="2" data-ouia-component-type="PF4/Button" data-ouia-safe="true" type="button" @@ -309,7 +326,9 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = ` Delete </button> <button + aria-disabled="false" class="pf-c-button pf-m-secondary" + data-ouia-component-id="3" data-ouia-component-type="PF4/Button" data-ouia-safe="true" type="button" @@ -365,6 +384,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = ` isOpen={true} labelId="pf-modal-part-1" onClose={[Function]} + ouiaSafe={true} showClose={true} title="Remove Team Access" variant="small" @@ -391,6 +411,9 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = ` aria-label="Alert modal" aria-labelledby="pf-modal-part-0 alert-modal-header-label pf-modal-part-1" className="" + data-ouia-component-id={0} + data-ouia-component-type="PF4/ModalContent" + data-ouia-safe={true} id="pf-modal-part-0" style={Object {}} variant="small" @@ -401,6 +424,9 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = ` aria-labelledby="pf-modal-part-0 alert-modal-header-label pf-modal-part-1" aria-modal="true" className="pf-c-modal-box pf-m-sm" + data-ouia-component-id={0} + data-ouia-component-type="PF4/ModalContent" + data-ouia-safe={true} id="pf-modal-part-0" role="dialog" style={Object {}} @@ -415,15 +441,14 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = ` variant="plain" > <button - aria-disabled={null} + aria-disabled={false} aria-label="Close" className="pf-c-button pf-m-plain" - data-ouia-component-id={null} + data-ouia-component-id={1} data-ouia-component-type="PF4/Button" data-ouia-safe={true} disabled={false} onClick={[Function]} - tabIndex={null} type="button" > <TimesIcon @@ -586,15 +611,14 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = ` variant="danger" > <button - aria-disabled={null} + aria-disabled={false} aria-label="Confirm delete" className="pf-c-button pf-m-danger" - data-ouia-component-id={null} + data-ouia-component-id={2} data-ouia-component-type="PF4/Button" data-ouia-safe={true} disabled={false} onClick={[Function]} - tabIndex={null} type="button" > Delete @@ -606,15 +630,14 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = ` variant="secondary" > <button - aria-disabled={null} + aria-disabled={false} aria-label={null} className="pf-c-button pf-m-secondary" - data-ouia-component-id={null} + data-ouia-component-id={3} data-ouia-component-type="PF4/Button" data-ouia-safe={true} disabled={false} onClick={[Function]} - tabIndex={null} type="button" > Cancel diff --git a/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap index f26a2377cb..7c8126fb4d 100644 --- a/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap +++ b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap @@ -862,7 +862,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = ` > <div className="pf-c-chip" - data-ouia-component-id={1} + data-ouia-component-id={2} data-ouia-component-type="PF4/Chip" data-ouia-safe={true} > @@ -880,17 +880,16 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = ` variant="plain" > <button - aria-disabled={null} + aria-disabled={false} aria-label="close" aria-labelledby="remove_pf-random-id-1 pf-random-id-1" className="pf-c-button pf-m-plain" - data-ouia-component-id={null} + data-ouia-component-id={3} data-ouia-component-type="PF4/Button" data-ouia-safe={true} disabled={false} id="remove_pf-random-id-1" onClick={[Function]} - tabIndex={null} type="button" > <TimesIcon diff --git a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx index 16ce15c4b4..bd3b7497d0 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx @@ -32,7 +32,13 @@ function ScheduleList({ const location = useLocation(); const { - result: { schedules, itemCount, actions }, + result: { + schedules, + itemCount, + actions, + relatedSearchableKeys, + searchableKeys, + }, error: contentError, isLoading, request: fetchSchedules, @@ -49,12 +55,20 @@ function ScheduleList({ schedules: results, itemCount: count, actions: scheduleActions.data.actions, + 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: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -123,7 +137,7 @@ function ScheduleList({ toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, ]} @@ -141,6 +155,8 @@ function ScheduleList({ key: 'unified_job_template__polymorphic_ctype__model', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( <DataListToolbar {...props} diff --git a/awx/ui_next/src/components/Search/AdvancedSearch.jsx b/awx/ui_next/src/components/Search/AdvancedSearch.jsx new file mode 100644 index 0000000000..cb1d8b72bd --- /dev/null +++ b/awx/ui_next/src/components/Search/AdvancedSearch.jsx @@ -0,0 +1,276 @@ +import 'styled-components/macro'; +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Button, + ButtonVariant, + InputGroup, + Select, + SelectOption, + SelectVariant, + TextInput, +} from '@patternfly/react-core'; +import { SearchIcon } from '@patternfly/react-icons'; +import styled from 'styled-components'; + +const AdvancedGroup = styled.div` + display: flex; + + @media (max-width: 991px) { + display: grid; + grid-gap: var(--pf-c-toolbar__expandable-content--m-expanded--GridRowGap); + } +`; + +function AdvancedSearch({ + i18n, + onSearch, + searchableKeys, + relatedSearchableKeys, +}) { + // TODO: blocked by pf bug, eventually separate these into two groups in the select + // for now, I'm spreading set to get rid of duplicate keys...when they are grouped + // we might want to revisit that. + const allKeys = [ + ...new Set([...(searchableKeys || []), ...(relatedSearchableKeys || [])]), + ]; + + const [isPrefixDropdownOpen, setIsPrefixDropdownOpen] = useState(false); + const [isLookupDropdownOpen, setIsLookupDropdownOpen] = useState(false); + const [isKeyDropdownOpen, setIsKeyDropdownOpen] = useState(false); + const [prefixSelection, setPrefixSelection] = useState(null); + const [lookupSelection, setLookupSelection] = useState(null); + const [keySelection, setKeySelection] = useState(null); + const [searchValue, setSearchValue] = useState(''); + + const handleAdvancedSearch = e => { + // keeps page from fully reloading + e.preventDefault(); + + if (searchValue) { + const actualPrefix = prefixSelection === 'and' ? null : prefixSelection; + let actualSearchKey; + // TODO: once we are able to group options for the key typeahead, we will + // probably want to be able to which group a key was clicked in for duplicates, + // rather than checking to make sure it's not in both for this appending + // __search logic + if ( + relatedSearchableKeys.indexOf(keySelection) > -1 && + searchableKeys.indexOf(keySelection) === -1 && + keySelection.indexOf('__') === -1 + ) { + actualSearchKey = `${keySelection}__search`; + } else { + actualSearchKey = [actualPrefix, keySelection, lookupSelection] + .filter(val => !!val) + .join('__'); + } + onSearch(actualSearchKey, searchValue); + setSearchValue(''); + } + }; + + const handleAdvancedTextKeyDown = e => { + if (e.key && e.key === 'Enter') { + handleAdvancedSearch(e); + } + }; + + return ( + <AdvancedGroup> + <Select + aria-label={i18n._(t`Set type select`)} + className="setTypeSelect" + variant={SelectVariant.typeahead} + typeAheadAriaLabel={i18n._(t`Set type typeahead`)} + onToggle={setIsPrefixDropdownOpen} + onSelect={(event, selection) => setPrefixSelection(selection)} + onClear={() => setPrefixSelection(null)} + selections={prefixSelection} + isOpen={isPrefixDropdownOpen} + placeholderText={i18n._(t`Set type`)} + maxHeight="500px" + > + <SelectOption + key="and" + value="and" + description={i18n._( + t`Returns results that satisfy this one as well as other filters. This is the default set type if nothing is selected.` + )} + /> + <SelectOption + key="or" + value="or" + description={i18n._( + t`Returns results that satisfy this one or any other filters.` + )} + /> + <SelectOption + key="not" + value="not" + description={i18n._( + t`Returns results that have values other than this one as well as other filters.` + )} + /> + </Select> + <Select + aria-label={i18n._(t`Key select`)} + className="keySelect" + variant={SelectVariant.typeahead} + typeAheadAriaLabel={i18n._(t`Key typeahead`)} + onToggle={setIsKeyDropdownOpen} + onSelect={(event, selection) => setKeySelection(selection)} + onClear={() => setKeySelection(null)} + selections={keySelection} + isOpen={isKeyDropdownOpen} + placeholderText={i18n._(t`Key`)} + isCreatable + onCreateOption={setKeySelection} + maxHeight="500px" + > + {allKeys.map(optionKey => ( + <SelectOption key={optionKey} value={optionKey}> + {optionKey} + </SelectOption> + ))} + </Select> + <Select + aria-label={i18n._(t`Lookup select`)} + className="lookupSelect" + variant={SelectVariant.typeahead} + typeAheadAriaLabel={i18n._(t`Lookup typeahead`)} + onToggle={setIsLookupDropdownOpen} + onSelect={(event, selection) => setLookupSelection(selection)} + onClear={() => setLookupSelection(null)} + selections={lookupSelection} + isOpen={isLookupDropdownOpen} + placeholderText={i18n._(t`Lookup type`)} + maxHeight="500px" + > + <SelectOption + key="exact" + value="exact" + description={i18n._( + t`Exact match (default lookup if not specified).` + )} + /> + <SelectOption + key="iexact" + value="iexact" + description={i18n._(t`Case-insensitive version of exact.`)} + /> + <SelectOption + key="contains" + value="contains" + description={i18n._(t`Field contains value.`)} + /> + <SelectOption + key="icontains" + value="icontains" + description={i18n._(t`Case-insensitive version of contains`)} + /> + <SelectOption + key="startswith" + value="startswith" + description={i18n._(t`Field starts with value.`)} + /> + <SelectOption + key="istartswith" + value="istartswith" + description={i18n._(t`Case-insensitive version of startswith.`)} + /> + <SelectOption + key="endswith" + value="endswith" + description={i18n._(t`Field ends with value.`)} + /> + <SelectOption + key="iendswith" + value="iendswith" + description={i18n._(t`Case-insensitive version of endswith.`)} + /> + <SelectOption + key="regex" + value="regex" + description={i18n._(t`Field matches the given regular expression.`)} + /> + <SelectOption + key="iregex" + value="iregex" + description={i18n._(t`Case-insensitive version of regex.`)} + /> + <SelectOption + key="gt" + value="gt" + description={i18n._(t`Greater than comparison.`)} + /> + <SelectOption + key="gte" + value="gte" + description={i18n._(t`Greater than or equal to comparison.`)} + /> + <SelectOption + key="lt" + value="lt" + description={i18n._(t`Less than comparison.`)} + /> + <SelectOption + key="lte" + value="lte" + description={i18n._(t`Less than or equal to comparison.`)} + /> + <SelectOption + key="isnull" + value="isnull" + description={i18n._( + t`Check whether the given field or related object is null; expects a boolean value.` + )} + /> + <SelectOption + key="in" + value="in" + description={i18n._( + t`Check whether the given field's value is present in the list provided; expects a comma-separated list of items.` + )} + /> + </Select> + <InputGroup> + <TextInput + type="search" + aria-label={i18n._(t`Advanced search value input`)} + isDisabled={!keySelection} + value={ + (!keySelection && i18n._(t`First, select a key`)) || searchValue + } + onChange={setSearchValue} + onKeyDown={handleAdvancedTextKeyDown} + /> + <div css={!searchValue && `cursor:not-allowed`}> + <Button + variant={ButtonVariant.control} + isDisabled={!searchValue} + aria-label={i18n._(t`Search submit button`)} + onClick={handleAdvancedSearch} + > + <SearchIcon /> + </Button> + </div> + </InputGroup> + </AdvancedGroup> + ); +} + +AdvancedSearch.propTypes = { + onSearch: PropTypes.func.isRequired, + searchableKeys: PropTypes.arrayOf(PropTypes.string), + relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string), +}; + +AdvancedSearch.defaultProps = { + searchableKeys: [], + relatedSearchableKeys: [], +}; + +export default withI18n()(AdvancedSearch); diff --git a/awx/ui_next/src/components/Search/AdvancedSearch.test.jsx b/awx/ui_next/src/components/Search/AdvancedSearch.test.jsx new file mode 100644 index 0000000000..32c784a01f --- /dev/null +++ b/awx/ui_next/src/components/Search/AdvancedSearch.test.jsx @@ -0,0 +1,342 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import AdvancedSearch from './AdvancedSearch'; + +describe('<AdvancedSearch />', () => { + let wrapper; + + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('initially renders without crashing', () => { + wrapper = mountWithContexts( + <AdvancedSearch + onSearch={jest.fn} + searchableKeys={[]} + relatedSearchableKeys={[]} + /> + ); + expect(wrapper.length).toBe(1); + }); + + test('Remove duplicates from searchableKeys/relatedSearchableKeys list', () => { + wrapper = mountWithContexts( + <AdvancedSearch + onSearch={jest.fn} + searchableKeys={['foo', 'bar']} + relatedSearchableKeys={['bar', 'baz']} + /> + ); + wrapper + .find('Select[aria-label="Key select"] SelectToggle') + .simulate('click'); + expect( + wrapper.find('Select[aria-label="Key select"] SelectOption') + ).toHaveLength(3); + }); + + test("Don't call onSearch unless a search value is set", () => { + const advancedSearchMock = jest.fn(); + wrapper = mountWithContexts( + <AdvancedSearch + onSearch={advancedSearchMock} + searchableKeys={['foo', 'bar']} + relatedSearchableKeys={['bar', 'baz']} + /> + ); + wrapper + .find('Select[aria-label="Key select"] SelectToggle') + .simulate('click'); + wrapper + .find('Select[aria-label="Key select"] SelectOption') + .at(1) + .simulate('click'); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + expect(advancedSearchMock).toBeCalledTimes(0); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('foo'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledTimes(1); + }); + + test('Disable searchValue input until a key is set', () => { + wrapper = mountWithContexts( + <AdvancedSearch + onSearch={jest.fn} + searchableKeys={[]} + relatedSearchableKeys={[]} + /> + ); + expect( + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('isDisabled') + ).toBe(true); + act(() => { + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'foo' + ); + }); + wrapper.update(); + expect( + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('isDisabled') + ).toBe(false); + }); + + test('Strip and__ set type from key', () => { + const advancedSearchMock = jest.fn(); + wrapper = mountWithContexts( + <AdvancedSearch + onSearch={advancedSearchMock} + searchableKeys={[]} + relatedSearchableKeys={[]} + /> + ); + act(() => { + wrapper.find('Select[aria-label="Set type select"]').invoke('onSelect')( + {}, + 'and' + ); + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'foo' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('foo', 'bar'); + jest.clearAllMocks(); + act(() => { + wrapper.find('Select[aria-label="Set type select"]').invoke('onSelect')( + {}, + 'or' + ); + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'foo' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('or__foo', 'bar'); + }); + + test('Add __search lookup to key when applicable', () => { + const advancedSearchMock = jest.fn(); + wrapper = mountWithContexts( + <AdvancedSearch + onSearch={advancedSearchMock} + searchableKeys={['foo', 'bar']} + relatedSearchableKeys={['bar', 'baz']} + /> + ); + act(() => { + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'foo' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('foo', 'bar'); + jest.clearAllMocks(); + act(() => { + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'bar' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('bar', 'bar'); + jest.clearAllMocks(); + act(() => { + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'baz' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('baz__search', 'bar'); + }); + + test('Key should be properly constructed from three typeaheads', () => { + const advancedSearchMock = jest.fn(); + wrapper = mountWithContexts( + <AdvancedSearch + onSearch={advancedSearchMock} + searchableKeys={[]} + relatedSearchableKeys={[]} + /> + ); + act(() => { + wrapper.find('Select[aria-label="Set type select"]').invoke('onSelect')( + {}, + 'or' + ); + wrapper.find('Select[aria-label="Key select"]').invoke('onSelect')( + {}, + 'foo' + ); + wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')( + {}, + 'exact' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('or__foo__exact', 'bar'); + }); + + test('searchValue should clear after onSearch is called', () => { + const advancedSearchMock = jest.fn(); + wrapper = mountWithContexts( + <AdvancedSearch + onSearch={advancedSearchMock} + searchableKeys={[]} + relatedSearchableKeys={[]} + /> + ); + act(() => { + wrapper.find('Select[aria-label="Set type select"]').invoke('onSelect')( + {}, + 'or' + ); + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'foo' + ); + wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')( + {}, + 'exact' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('or__foo__exact', 'bar'); + expect( + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('value') + ).toBe(''); + }); + + test('typeahead onClear should remove key components', () => { + const advancedSearchMock = jest.fn(); + wrapper = mountWithContexts( + <AdvancedSearch + onSearch={advancedSearchMock} + searchableKeys={[]} + relatedSearchableKeys={[]} + /> + ); + act(() => { + wrapper.find('Select[aria-label="Set type select"]').invoke('onSelect')( + {}, + 'or' + ); + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'foo' + ); + wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')( + {}, + 'exact' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('or__foo__exact', 'bar'); + jest.clearAllMocks(); + act(() => { + wrapper.find('Select[aria-label="Set type select"]').invoke('onClear')(); + wrapper.find('Select[aria-label="Key select"]').invoke('onClear')(); + wrapper.find('Select[aria-label="Lookup select"]').invoke('onClear')(); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('baz'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('', 'baz'); + }); +}); diff --git a/awx/ui_next/src/components/Search/Search.jsx b/awx/ui_next/src/components/Search/Search.jsx index 0fb59e0806..e92f5c2d16 100644 --- a/awx/ui_next/src/components/Search/Search.jsx +++ b/awx/ui_next/src/components/Search/Search.jsx @@ -1,5 +1,5 @@ import 'styled-components/macro'; -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'; @@ -7,10 +7,6 @@ import { withRouter } from 'react-router-dom'; import { Button, ButtonVariant, - Dropdown, - DropdownPosition, - DropdownToggle, - DropdownItem, InputGroup, Select, SelectOption, @@ -24,6 +20,7 @@ import { SearchIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; import { parseQueryString } from '../../util/qs'; import { QSConfig, SearchColumns } from '../../types'; +import AdvancedSearch from './AdvancedSearch'; const NoOptionDropdown = styled.div` align-self: stretch; @@ -33,288 +30,271 @@ const NoOptionDropdown = styled.div` border-bottom-color: var(--pf-global--BorderColor--200); `; -class Search extends React.Component { - constructor(props) { - super(props); - - const { columns } = this.props; +function Search({ + columns, + i18n, + onSearch, + onReplaceSearch, + onRemove, + qsConfig, + location, + searchableKeys, + relatedSearchableKeys, + onShowAdvancedSearch, +}) { + const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false); + const [searchKey, setSearchKey] = useState( + (() => { + const defaultColumn = columns.filter(col => col.isDefault); + + if (defaultColumn.length !== 1) { + throw new Error( + 'One (and only one) searchColumn must be marked isDefault: true' + ); + } - this.state = { - isSearchDropdownOpen: false, - searchKey: columns.find(col => col.isDefault).key, - searchValue: '', - isFilterDropdownOpen: false, - }; + return defaultColumn[0]?.key; + })() + ); + const [searchValue, setSearchValue] = useState(''); + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); - this.handleSearchInputChange = this.handleSearchInputChange.bind(this); - this.handleDropdownToggle = this.handleDropdownToggle.bind(this); - this.handleDropdownSelect = this.handleDropdownSelect.bind(this); - this.handleSearch = this.handleSearch.bind(this); - this.handleTextKeyDown = this.handleTextKeyDown.bind(this); - this.handleFilterDropdownToggle = this.handleFilterDropdownToggle.bind( - this - ); - this.handleFilterDropdownSelect = this.handleFilterDropdownSelect.bind( - this + const handleDropdownSelect = ({ target }) => { + const { key: actualSearchKey } = columns.find( + ({ name }) => name === target.innerText ); - this.handleFilterBooleanSelect = this.handleFilterBooleanSelect.bind(this); - } - - handleDropdownToggle(isSearchDropdownOpen) { - this.setState({ isSearchDropdownOpen }); - } - - handleDropdownSelect({ target }) { - const { columns } = this.props; - const { innerText } = target; + onShowAdvancedSearch(actualSearchKey === 'advanced'); + setIsFilterDropdownOpen(false); + setSearchKey(actualSearchKey); + }; - const { key: searchKey } = columns.find(({ name }) => name === innerText); - this.setState({ isSearchDropdownOpen: false, searchKey }); - } - - handleSearch(e) { + const handleSearch = e => { // keeps page from fully reloading e.preventDefault(); - const { searchKey, searchValue } = this.state; - const { onSearch, qsConfig } = this.props; - if (searchValue) { - const isNonStringField = - qsConfig.integerFields.find(field => field === searchKey) || - qsConfig.dateFields.find(field => field === searchKey); - - const actualSearchKey = isNonStringField - ? searchKey - : `${searchKey}__icontains`; - - onSearch(actualSearchKey, searchValue); - - this.setState({ searchValue: '' }); + onSearch(searchKey, searchValue); + setSearchValue(''); } - } + }; - handleSearchInputChange(searchValue) { - this.setState({ searchValue }); - } - - handleTextKeyDown(e) { + const handleTextKeyDown = e => { if (e.key && e.key === 'Enter') { - this.handleSearch(e); + handleSearch(e); } - } - - handleFilterDropdownToggle(isFilterDropdownOpen) { - this.setState({ isFilterDropdownOpen }); - } - - handleFilterDropdownSelect(key, event, actualValue) { - const { onSearch, onRemove } = this.props; + }; + const handleFilterDropdownSelect = (key, event, actualValue) => { if (event.target.checked) { - onSearch(`or__${key}`, actualValue); + onSearch(key, actualValue); } else { - onRemove(`or__${key}`, actualValue); + onRemove(key, actualValue); } - } - - handleFilterBooleanSelect(key, selection) { - const { onReplaceSearch } = this.props; - onReplaceSearch(key, selection); - } - - render() { - const { up } = DropdownPosition; - const { columns, i18n, onRemove, qsConfig, location } = this.props; - const { - isSearchDropdownOpen, - searchKey, - searchValue, - isFilterDropdownOpen, - } = this.state; - const { name: searchColumnName } = columns.find( - ({ key }) => key === searchKey - ); - - const searchDropdownItems = columns - .filter(({ key }) => key !== searchKey) - .map(({ key, name }) => ( - <DropdownItem key={key} component="button"> - {name} - </DropdownItem> - )); - - const filterDefaultParams = (paramsArr, config) => { - const defaultParamsKeys = Object.keys(config.defaultParams || {}); - return paramsArr.filter(key => defaultParamsKeys.indexOf(key) === -1); - }; - - const getLabelFromValue = (value, colKey) => { - const currentSearchColumn = columns.find(({ key }) => key === colKey); - if (currentSearchColumn?.options?.length) { - return currentSearchColumn.options.find( - ([optVal]) => optVal === value - )[1]; - } - return value.toString(); - }; - - const getChipsByKey = () => { - const queryParams = parseQueryString(qsConfig, location.search); - - const queryParamsByKey = {}; - columns.forEach(({ name, key }) => { - queryParamsByKey[key] = { key, label: name, chips: [] }; - }); - const nonDefaultParams = filterDefaultParams( - Object.keys(queryParams || {}), - qsConfig + }; + + const filterDefaultParams = (paramsArr, config) => { + const defaultParamsKeys = Object.keys(config.defaultParams || {}); + return paramsArr.filter(key => defaultParamsKeys.indexOf(key) === -1); + }; + + const getLabelFromValue = (value, colKey) => { + let label = value; + const currentSearchColumn = columns.find(({ key }) => key === colKey); + if (currentSearchColumn?.options?.length) { + [, label] = currentSearchColumn.options.find( + ([optVal]) => optVal === value ); + } else if (currentSearchColumn?.booleanLabels) { + label = currentSearchColumn.booleanLabels[value]; + } + return label.toString(); + }; + + const getChipsByKey = () => { + const queryParams = parseQueryString(qsConfig, location.search); + + const queryParamsByKey = {}; + columns.forEach(({ name, key }) => { + queryParamsByKey[key] = { key, label: name, chips: [] }; + }); + const nonDefaultParams = filterDefaultParams( + Object.keys(queryParams || {}), + qsConfig + ); - nonDefaultParams.forEach(key => { - const columnKey = key.replace('__icontains', '').replace('or__', ''); - const label = columns.filter( - ({ key: keyToCheck }) => columnKey === keyToCheck - ).length - ? columns.filter(({ key: keyToCheck }) => columnKey === keyToCheck)[0] - .name - : columnKey; + nonDefaultParams.forEach(key => { + const columnKey = key; + const label = columns.filter( + ({ key: keyToCheck }) => columnKey === keyToCheck + ).length + ? `${ + columns.find(({ key: keyToCheck }) => columnKey === keyToCheck).name + } (${key})` + : columnKey; - queryParamsByKey[columnKey] = { key, label, chips: [] }; + queryParamsByKey[columnKey] = { key, label, chips: [] }; - if (Array.isArray(queryParams[key])) { - queryParams[key].forEach(val => - queryParamsByKey[columnKey].chips.push({ - key: `${key}:${val}`, - node: getLabelFromValue(val, columnKey), - }) - ); - } else { + if (Array.isArray(queryParams[key])) { + queryParams[key].forEach(val => queryParamsByKey[columnKey].chips.push({ - key: `${key}:${queryParams[key]}`, - node: getLabelFromValue(queryParams[key], columnKey), - }); - } - }); - - return queryParamsByKey; - }; - - const chipsByKey = getChipsByKey(); - - return ( - <ToolbarGroup variant="filter-group"> - <ToolbarItem> - {searchDropdownItems.length > 0 ? ( - <Dropdown - onToggle={this.handleDropdownToggle} - onSelect={this.handleDropdownSelect} - direction={up} - toggle={ - <DropdownToggle - id="awx-search" - onToggle={this.handleDropdownToggle} - style={{ width: '100%' }} - > - {searchColumnName} - </DropdownToggle> - } - isOpen={isSearchDropdownOpen} - dropdownItems={searchDropdownItems} + key: `${key}:${val}`, + node: getLabelFromValue(val, columnKey), + }) + ); + } else { + queryParamsByKey[columnKey].chips.push({ + key: `${key}:${queryParams[key]}`, + node: getLabelFromValue(queryParams[key], columnKey), + }); + } + }); + return queryParamsByKey; + }; + + const chipsByKey = getChipsByKey(); + + const { name: searchColumnName } = columns.find( + ({ key }) => key === searchKey + ); + + const searchOptions = columns + .filter(({ key }) => key !== searchKey) + .map(({ key, name }) => ( + <SelectOption key={key} value={name}> + {name} + </SelectOption> + )); + + return ( + <ToolbarGroup variant="filter-group"> + <ToolbarItem> + {searchOptions.length > 0 ? ( + <Select + variant={SelectVariant.single} + className="simpleKeySelect" + aria-label={i18n._(t`Simple key select`)} + onToggle={setIsSearchDropdownOpen} + onSelect={handleDropdownSelect} + selections={searchColumnName} + isOpen={isSearchDropdownOpen} + > + {searchOptions} + </Select> + ) : ( + <NoOptionDropdown>{searchColumnName}</NoOptionDropdown> + )} + </ToolbarItem> + {columns.map(({ key, name, options, isBoolean, booleanLabels = {} }) => ( + <ToolbarFilter + chips={chipsByKey[key] ? chipsByKey[key].chips : []} + deleteChip={(unusedKey, chip) => { + const [columnKey, ...value] = chip.key.split(':'); + onRemove(columnKey, value.join(':')); + }} + categoryName={chipsByKey[key] ? chipsByKey[key].label : key} + key={key} + showToolbarItem={searchKey === key} + > + {(key === 'advanced' && ( + <AdvancedSearch + onSearch={onSearch} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} /> - ) : ( - <NoOptionDropdown>{searchColumnName}</NoOptionDropdown> - )} - </ToolbarItem> - {columns.map( - ({ key, name, options, isBoolean, booleanLabels = {} }) => ( - <ToolbarFilter - chips={chipsByKey[key] ? chipsByKey[key].chips : []} - deleteChip={(unusedKey, chip) => { - const [columnKey, ...value] = chip.key.split(':'); - onRemove(columnKey, value.join(':')); - }} - categoryName={chipsByKey[key] ? chipsByKey[key].label : key} - key={key} - showToolbarItem={searchKey === key} - > - {(options && ( - <Fragment> - <Select - variant={SelectVariant.checkbox} - aria-label={name} - onToggle={this.handleFilterDropdownToggle} - onSelect={(event, selection) => - this.handleFilterDropdownSelect(key, event, selection) - } - selections={chipsByKey[key].chips.map(chip => { - const [, ...value] = chip.key.split(':'); - return value.join(':'); - })} - isOpen={isFilterDropdownOpen} - placeholderText={`Filter By ${name}`} - > - {options.map(([optionKey, optionLabel]) => ( - <SelectOption key={optionKey} value={optionKey}> - {optionLabel} - </SelectOption> - ))} - </Select> - </Fragment> - )) || - (isBoolean && ( - <Select - aria-label={name} - onToggle={this.handleFilterDropdownToggle} - onSelect={(event, selection) => - this.handleFilterBooleanSelect(key, selection) - } - selections={chipsByKey[key].chips[0]} - isOpen={isFilterDropdownOpen} - placeholderText={`Filter By ${name}`} - > - <SelectOption key="true" value="true"> - {booleanLabels.true || i18n._(t`Yes`)} - </SelectOption> - <SelectOption key="false" value="false"> - {booleanLabels.false || i18n._(t`No`)} + )) || + (options && ( + <Fragment> + <Select + variant={SelectVariant.checkbox} + aria-label={name} + onToggle={setIsFilterDropdownOpen} + onSelect={(event, selection) => + handleFilterDropdownSelect(key, event, selection) + } + selections={chipsByKey[key].chips.map(chip => { + const [, ...value] = chip.key.split(':'); + return value.join(':'); + })} + isOpen={isFilterDropdownOpen} + placeholderText={`Filter By ${name}`} + > + {options.map(([optionKey, optionLabel]) => ( + <SelectOption key={optionKey} value={optionKey}> + {optionLabel} </SelectOption> - </Select> - )) || ( - <InputGroup> - {/* TODO: add support for dates: - qsConfig.dateFields.filter(field => field === key).length && "date" */} - <TextInput - type={ - (qsConfig.integerFields.find( - field => field === searchKey - ) && - 'number') || - 'search' - } - aria-label={i18n._(t`Search text input`)} - value={searchValue} - onChange={this.handleSearchInputChange} - onKeyDown={this.handleTextKeyDown} - /> - <div css={!searchValue && `cursor:not-allowed`}> - <Button - variant={ButtonVariant.control} - isDisabled={!searchValue} - aria-label={i18n._(t`Search submit button`)} - onClick={this.handleSearch} - > - <SearchIcon /> - </Button> - </div> - </InputGroup> - )} - </ToolbarFilter> - ) - )} - </ToolbarGroup> - ); - } + ))} + </Select> + </Fragment> + )) || + (isBoolean && ( + <Select + aria-label={name} + onToggle={setIsFilterDropdownOpen} + onSelect={(event, selection) => onReplaceSearch(key, selection)} + selections={chipsByKey[key].chips[0]?.label} + isOpen={isFilterDropdownOpen} + placeholderText={`Filter By ${name}`} + > + <SelectOption key="true" value="true"> + {booleanLabels.true || i18n._(t`Yes`)} + </SelectOption> + <SelectOption key="false" value="false"> + {booleanLabels.false || i18n._(t`No`)} + </SelectOption> + </Select> + )) || ( + <InputGroup> + {/* TODO: add support for dates: + qsConfig.dateFields.filter(field => field === key).length && "date" */} + <TextInput + type={ + (qsConfig.integerFields.find( + field => field === searchKey + ) && + 'number') || + 'search' + } + aria-label={i18n._(t`Search text input`)} + value={searchValue} + onChange={setSearchValue} + onKeyDown={handleTextKeyDown} + /> + <div css={!searchValue && `cursor:not-allowed`}> + <Button + variant={ButtonVariant.control} + isDisabled={!searchValue} + aria-label={i18n._(t`Search submit button`)} + onClick={handleSearch} + > + <SearchIcon /> + </Button> + </div> + </InputGroup> + )} + </ToolbarFilter> + ))} + {/* Add a ToolbarFilter for any key that doesn't have it's own + search column so the chips show up */} + {Object.keys(chipsByKey) + .filter(val => chipsByKey[val].chips.length > 0) + .filter(val => columns.map(val2 => val2.key).indexOf(val) === -1) + .map(leftoverKey => ( + <ToolbarFilter + chips={chipsByKey[leftoverKey] ? chipsByKey[leftoverKey].chips : []} + deleteChip={(unusedKey, chip) => { + const [columnKey, ...value] = chip.key.split(':'); + onRemove(columnKey, value.join(':')); + }} + categoryName={ + chipsByKey[leftoverKey] + ? chipsByKey[leftoverKey].label + : leftoverKey + } + key={leftoverKey} + /> + ))} + </ToolbarGroup> + ); } Search.propTypes = { @@ -322,6 +302,7 @@ Search.propTypes = { columns: SearchColumns.isRequired, onSearch: PropTypes.func, onRemove: PropTypes.func, + onShowAdvancedSearch: PropTypes.func.isRequired, }; Search.defaultProps = { diff --git a/awx/ui_next/src/components/Search/Search.test.jsx b/awx/ui_next/src/components/Search/Search.test.jsx index 5eac1c532b..6c1badfa56 100644 --- a/awx/ui_next/src/components/Search/Search.test.jsx +++ b/awx/ui_next/src/components/Search/Search.test.jsx @@ -22,7 +22,7 @@ describe('<Search />', () => { }); test('it triggers the expected callbacks', () => { - const columns = [{ name: 'Name', key: 'name', isDefault: true }]; + const columns = [{ name: 'Name', key: 'name__icontains', isDefault: true }]; const searchBtn = 'button[aria-label="Search submit button"]'; const searchTextInput = 'input[aria-label="Search text input"]'; @@ -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> ); @@ -49,8 +54,13 @@ describe('<Search />', () => { expect(onSearch).toBeCalledWith('name__icontains', 'test-321'); }); - test('handleDropdownToggle properly updates state', async () => { - const columns = [{ name: 'Name', key: 'name', isDefault: true }]; + test('changing key select updates which key is called for onSearch', () => { + const searchButton = 'button[aria-label="Search submit button"]'; + const searchTextInput = 'input[aria-label="Search text input"]'; + const columns = [ + { name: 'Name', key: 'name__icontains', isDefault: true }, + { name: 'Description', key: 'description__icontains' }, + ]; const onSearch = jest.fn(); const wrapper = mountWithContexts( <Toolbar @@ -59,21 +69,38 @@ 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> - ).find('Search'); - expect(wrapper.state('isSearchDropdownOpen')).toEqual(false); - wrapper.instance().handleDropdownToggle(true); - expect(wrapper.state('isSearchDropdownOpen')).toEqual(true); + ); + + act(() => { + wrapper + .find('Select[aria-label="Simple key select"]') + .invoke('onSelect')({ target: { innerText: 'Description' } }); + }); + wrapper.update(); + wrapper.find(searchTextInput).instance().value = 'test-321'; + wrapper.find(searchTextInput).simulate('change'); + wrapper.find(searchButton).simulate('click'); + + expect(onSearch).toHaveBeenCalledTimes(1); + expect(onSearch).toBeCalledWith('description__icontains', 'test-321'); }); - test('handleDropdownSelect properly updates state', async () => { + test('changing key select to and from advanced causes onShowAdvancedSearch callback to be invoked', () => { const columns = [ - { name: 'Name', key: 'name', isDefault: true }, - { name: 'Description', key: 'description' }, + { 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`} @@ -81,21 +108,39 @@ describe('<Search />', () => { collapseListedFiltersBreakpoint="lg" > <ToolbarContent> - <Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} /> + <Search + qsConfig={QS_CONFIG} + columns={columns} + onSearch={onSearch} + onShowAdvancedSearch={onShowAdvancedSearch} + /> </ToolbarContent> </Toolbar> - ).find('Search'); - expect(wrapper.state('searchKey')).toEqual('name'); - wrapper - .instance() - .handleDropdownSelect({ target: { innerText: 'Description' } }); - expect(wrapper.state('searchKey')).toEqual('description'); + ); + + 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"]'; - const columns = [{ name: 'Name', key: 'name', isDefault: true }]; + const columns = [{ name: 'Name', key: 'name__icontains', isDefault: true }]; const onSearch = jest.fn(); const wrapper = mountWithContexts( <Toolbar @@ -104,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 +169,7 @@ describe('<Search />', () => { test('search with a valid string', () => { const searchButton = 'button[aria-label="Search submit button"]'; const searchTextInput = 'input[aria-label="Search text input"]'; - const columns = [{ name: 'Name', key: 'name', isDefault: true }]; + const columns = [{ name: 'Name', key: 'name__icontains', isDefault: true }]; const onSearch = jest.fn(); const wrapper = mountWithContexts( <Toolbar @@ -128,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> ); @@ -143,12 +198,12 @@ describe('<Search />', () => { test('filter keys are properly labeled', () => { const columns = [ - { name: 'Name', key: 'name', isDefault: true }, - { name: 'Type', key: 'type', options: [['foo', 'Foo Bar!']] }, + { name: 'Name', key: 'name__icontains', isDefault: true }, + { name: 'Type', key: 'or__scm_type', options: [['foo', 'Foo Bar!']] }, { name: 'Description', key: 'description' }, ]; const query = - '?organization.or__type=foo&organization.name=bar&item.page_size=10'; + '?organization.or__scm_type=foo&organization.name__icontains=bar&item.page_size=10'; const history = createMemoryHistory({ initialEntries: [`/organizations/${query}`], }); @@ -159,19 +214,25 @@ 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 } } } ); const typeFilterWrapper = wrapper.find( - 'ToolbarFilter[categoryName="Type"]' + 'ToolbarFilter[categoryName="Type (or__scm_type)"]' ); - expect(typeFilterWrapper.prop('chips')[0].key).toEqual('or__type:foo'); + expect(typeFilterWrapper.prop('chips')[0].key).toEqual('or__scm_type:foo'); const nameFilterWrapper = wrapper.find( - 'ToolbarFilter[categoryName="Name"]' + 'ToolbarFilter[categoryName="Name (name__icontains)"]' + ); + expect(nameFilterWrapper.prop('chips')[0].key).toEqual( + 'name__icontains:bar' ); - expect(nameFilterWrapper.prop('chips')[0].key).toEqual('name:bar'); }); test('should test handle remove of option-based key', async () => { @@ -204,6 +265,7 @@ describe('<Search />', () => { qsConfig={qsConfigNew} columns={columns} onRemove={onRemove} + onShowAdvancedSearch={jest.fn} /> </ToolbarContent> </Toolbar>, @@ -250,6 +312,7 @@ describe('<Search />', () => { qsConfig={qsConfigNew} columns={columns} onRemove={onRemove} + onShowAdvancedSearch={jest.fn} /> </ToolbarContent> </Toolbar>, @@ -265,4 +328,41 @@ describe('<Search />', () => { }); expect(onRemove).toBeCalledWith('or__type', ''); }); + + test("ToolbarFilter added for any key that doesn't have search column", () => { + const columns = [ + { name: 'Name', key: 'name__icontains', isDefault: true }, + { name: 'Type', key: 'or__scm_type', options: [['foo', 'Foo Bar!']] }, + { name: 'Description', key: 'description' }, + ]; + const query = + '?organization.or__scm_type=foo&organization.name__icontains=bar&organization.name__exact=baz&item.page_size=10&organization.foo=bar'; + const history = createMemoryHistory({ + initialEntries: [`/organizations/${query}`], + }); + const wrapper = mountWithContexts( + <Toolbar + id={`${QS_CONFIG.namespace}-list-toolbar`} + clearAllFilters={() => {}} + collapseListedFiltersBreakpoint="lg" + > + <ToolbarContent> + <Search + qsConfig={QS_CONFIG} + columns={columns} + onShowAdvancedSearch={jest.fn} + /> + </ToolbarContent> + </Toolbar>, + { context: { router: { history } } } + ); + const nameExactFilterWrapper = wrapper.find( + 'ToolbarFilter[categoryName="name__exact"]' + ); + expect(nameExactFilterWrapper.prop('chips')[0].key).toEqual( + 'name__exact:baz' + ); + const fooFilterWrapper = wrapper.find('ToolbarFilter[categoryName="foo"]'); + expect(fooFilterWrapper.prop('chips')[0].key).toEqual('foo:bar'); + }); }); diff --git a/awx/ui_next/src/components/Sort/Sort.jsx b/awx/ui_next/src/components/Sort/Sort.jsx index c90d120055..c0a513cd48 100644 --- a/awx/ui_next/src/components/Sort/Sort.jsx +++ b/awx/ui_next/src/components/Sort/Sort.jsx @@ -102,9 +102,16 @@ class Sort extends React.Component { const { up } = DropdownPosition; const { columns, i18n } = this.props; const { isSortDropdownOpen, sortKey, sortOrder, isNumeric } = this.state; - const [{ name: sortedColumnName }] = columns.filter( - ({ key }) => key === sortKey - ); + + const defaultSortedColumn = columns.find(({ key }) => key === sortKey); + + if (!defaultSortedColumn) { + throw new Error( + 'sortKey must match one of the column keys, check the sortColumns prop passed to <Sort />' + ); + } + + const sortedColumnName = defaultSortedColumn?.name; const sortDropdownItems = columns .filter(({ key }) => key !== sortKey) diff --git a/awx/ui_next/src/components/StatusLabel/StatusLabel.jsx b/awx/ui_next/src/components/StatusLabel/StatusLabel.jsx new file mode 100644 index 0000000000..0f2be56fdc --- /dev/null +++ b/awx/ui_next/src/components/StatusLabel/StatusLabel.jsx @@ -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, +}; diff --git a/awx/ui_next/src/components/StatusLabel/StatusLabel.test.jsx b/awx/ui_next/src/components/StatusLabel/StatusLabel.test.jsx new file mode 100644 index 0000000000..58fb6c1a28 --- /dev/null +++ b/awx/ui_next/src/components/StatusLabel/StatusLabel.test.jsx @@ -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'); + }); +}); diff --git a/awx/ui_next/src/components/StatusLabel/index.js b/awx/ui_next/src/components/StatusLabel/index.js new file mode 100644 index 0000000000..b9dfc8cd99 --- /dev/null +++ b/awx/ui_next/src/components/StatusLabel/index.js @@ -0,0 +1 @@ +export { default } from './StatusLabel'; diff --git a/awx/ui_next/src/components/SyncStatusIndicator/SyncStatusIndicator.jsx b/awx/ui_next/src/components/SyncStatusIndicator/SyncStatusIndicator.jsx new file mode 100644 index 0000000000..77cb158548 --- /dev/null +++ b/awx/ui_next/src/components/SyncStatusIndicator/SyncStatusIndicator.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import 'styled-components/macro'; +import styled, { keyframes } from 'styled-components'; +import { oneOf, string } from 'prop-types'; +import { CloudIcon } from '@patternfly/react-icons'; + +const COLORS = { + success: '--pf-global--palette--green-400', + syncing: '--pf-global--palette--green-400', + error: '--pf-global--danger-color--100', + disabled: '--pf-global--disabled-color--200', +}; + +const Pulse = keyframes` + from { + opacity: 0; + } + to { + opacity: 1.0; + } +`; + +const PulseWrapper = styled.div` + animation: ${Pulse} 1.5s linear infinite alternate; +`; + +export default function SyncStatusIndicator({ status, title }) { + const color = COLORS[status] || COLORS.disabled; + + if (status === 'syncing') { + return ( + <PulseWrapper> + <CloudIcon color={`var(${color})`} title={title} /> + </PulseWrapper> + ); + } + + return <CloudIcon color={`var(${color})`} title={title} />; +} +SyncStatusIndicator.propTypes = { + status: oneOf(['success', 'error', 'disabled', 'syncing']).isRequired, + title: string, +}; +SyncStatusIndicator.defaultProps = { + title: null, +}; diff --git a/awx/ui_next/src/components/SyncStatusIndicator/index.js b/awx/ui_next/src/components/SyncStatusIndicator/index.js new file mode 100644 index 0000000000..8a25d03365 --- /dev/null +++ b/awx/ui_next/src/components/SyncStatusIndicator/index.js @@ -0,0 +1 @@ +export { default } from './SyncStatusIndicator'; diff --git a/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx index b5f986c5db..a46d5d87a3 100644 --- a/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx @@ -82,7 +82,9 @@ describe('<UserAndTeamAccessAdd/>', () => { fetchItems: JobTemplatesAPI.read, label: 'Job template', selectedResource: 'jobTemplate', - searchColumns: [{ name: 'Name', key: 'name', isDefault: true }], + searchColumns: [ + { name: 'Name', key: 'name__icontains', isDefault: true }, + ], sortColumns: [{ name: 'Name', key: 'name' }], }) ); @@ -116,7 +118,9 @@ describe('<UserAndTeamAccessAdd/>', () => { fetchItems: JobTemplatesAPI.read, label: 'Job template', selectedResource: 'jobTemplate', - searchColumns: [{ name: 'Name', key: 'name', isDefault: true }], + searchColumns: [ + { name: 'Name', key: 'name__icontains', isDefault: true }, + ], sortColumns: [{ name: 'Name', key: 'name' }], }) ); @@ -190,7 +194,9 @@ describe('<UserAndTeamAccessAdd/>', () => { fetchItems: JobTemplatesAPI.read, label: 'Job template', selectedResource: 'jobTemplate', - searchColumns: [{ name: 'Name', key: 'name', isDefault: true }], + searchColumns: [ + { name: 'Name', key: 'name__icontains', isDefault: true }, + ], sortColumns: [{ name: 'Name', key: 'name' }], }) ); diff --git a/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js b/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js index 718476e70e..cd922c23aa 100644 --- a/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js @@ -16,20 +16,20 @@ export default function getResourceAccessConfig(i18n) { searchColumns: [ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Playbook name`), - key: 'playbook', + key: 'playbook__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ], sortColumns: [ @@ -46,20 +46,20 @@ export default function getResourceAccessConfig(i18n) { searchColumns: [ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Playbook name`), - key: 'playbook', + key: 'playbook__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ], sortColumns: [ @@ -76,12 +76,12 @@ export default function getResourceAccessConfig(i18n) { searchColumns: [ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Type`), - key: 'scm_type', + key: 'or__scm_type', options: [ [``, i18n._(t`Manual`)], [`git`, i18n._(t`Git`)], @@ -92,15 +92,15 @@ export default function getResourceAccessConfig(i18n) { }, { name: i18n._(t`Source Control URL`), - key: 'scm_url', + key: 'scm_url__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, ], sortColumns: [ @@ -117,16 +117,16 @@ export default function getResourceAccessConfig(i18n) { searchColumns: [ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ], sortColumns: [ @@ -143,12 +143,12 @@ export default function getResourceAccessConfig(i18n) { searchColumns: [ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Type`), - key: 'scm_type', + key: 'or__scm_type', options: [ [``, i18n._(t`Manual`)], [`git`, i18n._(t`Git`)], @@ -159,15 +159,15 @@ export default function getResourceAccessConfig(i18n) { }, { name: i18n._(t`Source Control URL`), - key: 'scm_url', + key: 'scm_url__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, ], sortColumns: [ @@ -184,16 +184,16 @@ export default function getResourceAccessConfig(i18n) { searchColumns: [ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ], sortColumns: [ diff --git a/awx/ui_next/src/contexts/Config.jsx b/awx/ui_next/src/contexts/Config.jsx index 231e6f8301..e8674c955c 100644 --- a/awx/ui_next/src/contexts/Config.jsx +++ b/awx/ui_next/src/contexts/Config.jsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { useContext } from 'react'; // eslint-disable-next-line import/prefer-default-export export const ConfigContext = React.createContext({}); export const ConfigProvider = ConfigContext.Provider; export const Config = ConfigContext.Consumer; +export const useConfig = () => useContext(ConfigContext); diff --git a/awx/ui_next/src/contexts/Kebabified.jsx b/awx/ui_next/src/contexts/Kebabified.jsx new file mode 100644 index 0000000000..c50431c73f --- /dev/null +++ b/awx/ui_next/src/contexts/Kebabified.jsx @@ -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); diff --git a/awx/ui_next/src/index.jsx b/awx/ui_next/src/index.jsx index ea4b815a21..ad616077ef 100644 --- a/awx/ui_next/src/index.jsx +++ b/awx/ui_next/src/index.jsx @@ -7,6 +7,8 @@ import { BrandName } from './variables'; document.title = `Ansible ${BrandName}`; ReactDOM.render( - <App />, + <React.StrictMode> + <App /> + </React.StrictMode>, document.getElementById('app') || document.createElement('div') ); diff --git a/awx/ui_next/src/index.test.jsx b/awx/ui_next/src/index.test.jsx index 82dec86c56..44cd66e67c 100644 --- a/awx/ui_next/src/index.test.jsx +++ b/awx/ui_next/src/index.test.jsx @@ -12,6 +12,11 @@ require('./index.jsx'); describe('index.jsx', () => { it('renders ok', () => { - expect(ReactDOM.render).toHaveBeenCalledWith(<App />, div); + expect(ReactDOM.render).toHaveBeenCalledWith( + <React.StrictMode> + <App /> + </React.StrictMode>, + div + ); }); }); diff --git a/awx/ui_next/src/routeConfig.js b/awx/ui_next/src/routeConfig.js index 4b43dc4993..cb936764cb 100644 --- a/awx/ui_next/src/routeConfig.js +++ b/awx/ui_next/src/routeConfig.js @@ -7,19 +7,13 @@ import Dashboard from './screens/Dashboard'; import Hosts from './screens/Host'; import InstanceGroups from './screens/InstanceGroup'; import Inventory from './screens/Inventory'; -import InventoryScripts from './screens/InventoryScript'; import { Jobs } from './screens/Job'; import ManagementJobs from './screens/ManagementJob'; import NotificationTemplates from './screens/NotificationTemplate'; import Organizations from './screens/Organization'; -import Portal from './screens/Portal'; import Projects from './screens/Project'; import Schedules from './screens/Schedule'; -import AuthSettings from './screens/AuthSetting'; -import JobsSettings from './screens/JobsSetting'; -import SystemSettings from './screens/SystemSetting'; -import UISettings from './screens/UISetting'; -import License from './screens/License'; +import Settings from './screens/Setting'; import Teams from './screens/Team'; import Templates from './screens/Template'; import Users from './screens/User'; @@ -49,11 +43,6 @@ function getRouteConfig(i18n) { path: '/schedules', screen: Schedules, }, - { - title: i18n._(t`My View`), - path: '/portal', - screen: Portal, - }, ], }, { @@ -85,11 +74,6 @@ function getRouteConfig(i18n) { path: '/hosts', screen: Hosts, }, - { - title: i18n._(t`Inventory Scripts`), - path: '/inventory_scripts', - screen: InventoryScripts, - }, ], }, { @@ -146,32 +130,12 @@ function getRouteConfig(i18n) { }, { groupTitle: i18n._(t`Settings`), - groupId: 'settings_group', + groupId: 'settings', routes: [ { - title: i18n._(t`Authentication`), - path: '/auth_settings', - screen: AuthSettings, - }, - { - title: i18n._(t`Jobs`), - path: '/jobs_settings', - screen: JobsSettings, - }, - { - title: i18n._(t`System`), - path: '/system_settings', - screen: SystemSettings, - }, - { - title: i18n._(t`User Interface`), - path: '/ui_settings', - screen: UISettings, - }, - { - title: i18n._(t`License`), - path: '/license', - screen: License, + title: i18n._(t`Settings`), + path: '/settings', + screen: Settings, }, ], }, diff --git a/awx/ui_next/src/screens/Application/Application/Application.jsx b/awx/ui_next/src/screens/Application/Application/Application.jsx index 8e5fef66bb..2414d8b4c3 100644 --- a/awx/ui_next/src/screens/Application/Application/Application.jsx +++ b/awx/ui_next/src/screens/Application/Application/Application.jsx @@ -15,9 +15,9 @@ import { Card, PageSection } from '@patternfly/react-core'; import useRequest from '../../../util/useRequest'; import { ApplicationsAPI } from '../../../api'; import ContentError from '../../../components/ContentError'; -import ContentLoading from '../../../components/ContentLoading'; import ApplicationEdit from '../ApplicationEdit'; import ApplicationDetails from '../ApplicationDetails'; +import ApplicationTokens from '../ApplicationTokens'; import RoutedTabs from '../../../components/RoutedTabs'; function Application({ setBreadcrumb, i18n }) { @@ -82,6 +82,7 @@ function Application({ setBreadcrumb, i18n }) { if (pathname.endsWith('edit')) { cardHeader = null; } + if (!isLoading && error) { return ( <PageSection> @@ -101,10 +102,6 @@ function Application({ setBreadcrumb, i18n }) { ); } - if (isLoading) { - return <ContentLoading />; - } - return ( <PageSection> <Card> @@ -131,6 +128,9 @@ function Application({ setBreadcrumb, i18n }) { clientTypeOptions={clientTypeOptions} /> </Route> + <Route path="/applications/:id/tokens"> + <ApplicationTokens application={application} /> + </Route> </> )} </Switch> diff --git a/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.jsx b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.jsx new file mode 100644 index 0000000000..2ad56eaa7f --- /dev/null +++ b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.jsx @@ -0,0 +1,180 @@ +import React, { useCallback, useEffect } from 'react'; +import { useParams, useLocation } from 'react-router-dom'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; +import PaginatedDataList, { + ToolbarDeleteButton, +} from '../../../components/PaginatedDataList'; +import { getQSConfig, parseQueryString } from '../../../util/qs'; +import { TokensAPI, ApplicationsAPI } from '../../../api'; +import ErrorDetail from '../../../components/ErrorDetail'; +import AlertModal from '../../../components/AlertModal'; +import useRequest, { useDeleteItems } from '../../../util/useRequest'; +import useSelected from '../../../util/useSelected'; +import ApplicationTokenListItem from './ApplicationTokenListItem'; +import DatalistToolbar from '../../../components/DataListToolbar'; + +const QS_CONFIG = getQSConfig('applications', { + page: 1, + page_size: 20, + order_by: 'user__username', +}); + +function ApplicationTokenList({ i18n }) { + const { id } = useParams(); + const location = useLocation(); + const { + error, + isLoading, + result: { tokens, itemCount, relatedSearchableKeys, searchableKeys }, + request: fetchTokens, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const [ + { + data: { results, count }, + }, + actionsResponse, + ] = await Promise.all([ + ApplicationsAPI.readTokens(id, params), + ApplicationsAPI.readTokenOptions(id), + ]); + const modifiedResults = results.map(result => { + result.summary_fields = { + user: result.summary_fields.user, + application: result.summary_fields.application, + user_capabilities: { delete: true }, + }; + result.name = result.summary_fields.user?.username; + return result; + }); + return { + tokens: modifiedResults, + 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), + }; + }, [id, location.search]), + { tokens: [], itemCount: 0, relatedSearchableKeys: [], searchableKeys: [] } + ); + + useEffect(() => { + fetchTokens(); + }, [fetchTokens]); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + tokens + ); + const { + isLoading: deleteLoading, + deletionError, + deleteItems: handleDeleteApplications, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + await Promise.all( + selected.map(({ id: tokenId }) => TokensAPI.destroy(tokenId)) + ); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchTokens, + } + ); + + const handleDelete = async () => { + await handleDeleteApplications(); + setSelected([]); + }; + + return ( + <> + <PaginatedDataList + contentError={error} + hasContentLoading={isLoading || deleteLoading} + items={tokens} + itemCount={itemCount} + pluralizedItemName={i18n._(t`Tokens`)} + qsConfig={QS_CONFIG} + onRowClick={handleSelect} + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'user__username__icontains', + isDefault: true, + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'user__username', + }, + { + name: i18n._(t`Scope`), + key: 'scope', + }, + { + name: i18n._(t`Expiration`), + key: 'expires', + }, + { + name: i18n._(t`Created`), + key: 'created', + }, + { + name: i18n._(t`Modified`), + key: 'modified', + }, + ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} + renderToolbar={props => ( + <DatalistToolbar + {...props} + showSelectAll + isAllSelected={isAllSelected} + onSelectAll={isSelected => + setSelected(isSelected ? [...tokens] : []) + } + qsConfig={QS_CONFIG} + additionalControls={[ + <ToolbarDeleteButton + key="delete" + onDelete={handleDelete} + itemsToDelete={selected} + pluralizedItemName={i18n._(t`Tokens`)} + />, + ]} + /> + )} + renderItem={token => ( + <ApplicationTokenListItem + key={token.id} + value={token.name} + token={token} + detailUrl={`/users/${token.summary_fields.user.id}/details`} + onSelect={() => handleSelect(token)} + isSelected={selected.some(row => row.id === token.id)} + /> + )} + /> + <AlertModal + isOpen={deletionError} + variant="error" + title={i18n._(t`Error!`)} + onClose={clearDeletionError} + > + {i18n._(t`Failed to delete one or more tokens.`)} + <ErrorDetail error={deletionError} /> + </AlertModal> + </> + ); +} + +export default withI18n()(ApplicationTokenList); diff --git a/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.test.jsx b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.test.jsx new file mode 100644 index 0000000000..ccc9c5f4cc --- /dev/null +++ b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.test.jsx @@ -0,0 +1,206 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import { ApplicationsAPI, TokensAPI } from '../../../api'; +import ApplicationTokenList from './ApplicationTokenList'; + +jest.mock('../../../api/models/Applications'); +jest.mock('../../../api/models/Tokens'); + +const tokens = { + data: { + results: [ + { + id: 2, + type: 'o_auth2_access_token', + url: '/api/v2/tokens/2/', + related: { + user: '/api/v2/users/1/', + application: '/api/v2/applications/3/', + activity_stream: '/api/v2/tokens/2/activity_stream/', + }, + summary_fields: { + user: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + application: { + id: 3, + name: 'hg', + }, + }, + created: '2020-06-23T19:56:38.422053Z', + modified: '2020-06-23T19:56:38.441353Z', + description: 'cdfsg', + user: 1, + token: '************', + refresh_token: '************', + application: 3, + expires: '3019-10-25T19:56:38.395635Z', + scope: 'read', + }, + { + id: 3, + type: 'o_auth2_access_token', + url: '/api/v2/tokens/3/', + related: { + user: '/api/v2/users/1/', + application: '/api/v2/applications/3/', + activity_stream: '/api/v2/tokens/3/activity_stream/', + }, + summary_fields: { + user: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + application: { + id: 3, + name: 'hg', + }, + }, + created: '2020-06-23T19:56:50.536169Z', + modified: '2020-06-23T19:56:50.549521Z', + description: 'fgds', + user: 1, + token: '************', + refresh_token: '************', + application: 3, + expires: '3019-10-25T19:56:50.529306Z', + scope: 'write', + }, + ], + count: 2, + }, +}; +describe('<ApplicationTokenList/>', () => { + let wrapper; + + beforeEach(() => { + ApplicationsAPI.readTokenOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [], + }, + }); + }); + + test('should mount properly', async () => { + ApplicationsAPI.readTokens.mockResolvedValue(tokens); + await act(async () => { + wrapper = mountWithContexts(<ApplicationTokenList />); + }); + await waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0); + }); + test('should have data fetched and render 2 rows', async () => { + ApplicationsAPI.readTokens.mockResolvedValue(tokens); + await act(async () => { + wrapper = mountWithContexts(<ApplicationTokenList />); + }); + await waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0); + expect(wrapper.find('ApplicationTokenListItem').length).toBe(2); + expect(ApplicationsAPI.readTokens).toBeCalled(); + }); + + test('should delete item successfully', async () => { + ApplicationsAPI.readTokens.mockResolvedValue(tokens); + await act(async () => { + wrapper = mountWithContexts(<ApplicationTokenList />); + }); + waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0); + + wrapper + .find('input#select-token-2') + .simulate('change', tokens.data.results[0]); + + wrapper.update(); + + expect(wrapper.find('input#select-token-2').prop('checked')).toBe(true); + await act(async () => + wrapper.find('Button[aria-label="Delete"]').prop('onClick')() + ); + + wrapper.update(); + + await act(async () => + wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')() + ); + expect(TokensAPI.destroy).toBeCalledWith(tokens.data.results[0].id); + }); + + test('should throw content error', async () => { + ApplicationsAPI.readTokens.mockRejectedValue( + new Error({ + response: { + config: { + method: 'get', + url: '/api/v2/applications/', + }, + data: 'An error occurred', + }, + }) + ); + await act(async () => { + wrapper = mountWithContexts(<ApplicationTokenList />); + }); + + await waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); + + test('should render deletion error modal', async () => { + TokensAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: '/api/v2/tokens/', + }, + data: 'An error occurred', + }, + }) + ); + ApplicationsAPI.readTokens.mockResolvedValue(tokens); + await act(async () => { + wrapper = mountWithContexts(<ApplicationTokenList />); + }); + waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0); + + wrapper.find('input#select-token-2').simulate('change', 'a'); + + wrapper.update(); + + expect(wrapper.find('input#select-token-2').prop('checked')).toBe(true); + await act(async () => + wrapper.find('Button[aria-label="Delete"]').prop('onClick')() + ); + + wrapper.update(); + + await act(async () => + wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('ErrorDetail').length).toBe(1); + }); + + test('should not render add button', async () => { + ApplicationsAPI.readTokens.mockResolvedValue(tokens); + + await act(async () => { + wrapper = mountWithContexts(<ApplicationTokenList />); + }); + waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0); + expect(wrapper.find('ToolbarAddButton').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenListItem.jsx b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenListItem.jsx new file mode 100644 index 0000000000..142561ea7e --- /dev/null +++ b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenListItem.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { string, bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Link } from 'react-router-dom'; +import { + DataListCheck, + DataListItem, + DataListItemCells, + DataListItemRow, +} from '@patternfly/react-core'; +import styled from 'styled-components'; + +import { Token } from '../../../types'; +import { formatDateString } from '../../../util/dates'; +import { toTitleCase } from '../../../util/strings'; +import DataListCell from '../../../components/DataListCell'; + +const Label = styled.b` + margin-right: 20px; +`; + +function ApplicationTokenListItem({ + token, + isSelected, + onSelect, + detailUrl, + i18n, +}) { + const labelId = `check-action-${token.id}`; + return ( + <DataListItem key={token.id} aria-labelledby={labelId} id={`${token.id}`}> + <DataListItemRow> + <DataListCheck + id={`select-token-${token.id}`} + checked={isSelected} + onChange={onSelect} + aria-labelledby={labelId} + /> + <DataListItemCells + dataListCells={[ + <DataListCell key="divider" aria-label={i18n._(t`token name`)}> + <Link to={`${detailUrl}`}> + <b>{token.summary_fields.user.username}</b> + </Link> + </DataListCell>, + <DataListCell key="scope" aria-label={i18n._(t`scope`)}> + <Label>{i18n._(t`Scope`)}</Label> + <span>{toTitleCase(token.scope)}</span> + </DataListCell>, + <DataListCell key="expiration" aria-label={i18n._(t`expiration`)}> + <Label>{i18n._(t`Expiration`)}</Label> + <span>{formatDateString(token.expires)}</span> + </DataListCell>, + ]} + /> + </DataListItemRow> + </DataListItem> + ); +} + +ApplicationTokenListItem.propTypes = { + token: Token.isRequired, + detailUrl: string.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(ApplicationTokenListItem); diff --git a/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenListItem.test.jsx b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenListItem.test.jsx new file mode 100644 index 0000000000..94d0355951 --- /dev/null +++ b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenListItem.test.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import ApplicationTokenListItem from './ApplicationTokenListItem'; + +describe('<ApplicationTokenListItem/>', () => { + let wrapper; + const token = { + id: 2, + type: 'o_auth2_access_token', + url: '/api/v2/tokens/2/', + related: { + user: '/api/v2/users/1/', + application: '/api/v2/applications/3/', + activity_stream: '/api/v2/tokens/2/activity_stream/', + }, + summary_fields: { + user: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + application: { + id: 3, + name: 'hg', + }, + }, + created: '2020-06-23T19:56:38.422053Z', + modified: '2020-06-23T19:56:38.441353Z', + description: 'cdfsg', + user: 1, + token: '************', + refresh_token: '************', + application: 3, + expires: '3019-10-25T19:56:38.395635Z', + scope: 'read', + }; + + test('should mount successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + <ApplicationTokenListItem + token={token} + detailUrl="/users/2/details" + isSelected={false} + onSelect={() => {}} + /> + ); + }); + expect(wrapper.find('ApplicationTokenListItem').length).toBe(1); + }); + test('should render the proper data', async () => { + await act(async () => { + wrapper = mountWithContexts( + <ApplicationTokenListItem + token={token} + detailUrl="/users/2/details" + isSelected={false} + onSelect={() => {}} + /> + ); + }); + expect(wrapper.find('DataListCell[aria-label="token name"]').text()).toBe( + 'admin' + ); + expect(wrapper.find('DataListCell[aria-label="scope"]').text()).toBe( + 'ScopeRead' + ); + expect(wrapper.find('DataListCell[aria-label="expiration"]').text()).toBe( + 'Expiration10/25/3019, 7:56:38 PM' + ); + expect(wrapper.find('input#select-token-2').prop('checked')).toBe(false); + }); + test('should be checked', async () => { + await act(async () => { + wrapper = mountWithContexts( + <ApplicationTokenListItem + token={token} + detailUrl="/users/2/details" + isSelected + onSelect={() => {}} + /> + ); + }); + expect(wrapper.find('input#select-token-2').prop('checked')).toBe(true); + }); +}); diff --git a/awx/ui_next/src/screens/Application/ApplicationTokens/index.js b/awx/ui_next/src/screens/Application/ApplicationTokens/index.js new file mode 100644 index 0000000000..34dd462061 --- /dev/null +++ b/awx/ui_next/src/screens/Application/ApplicationTokens/index.js @@ -0,0 +1 @@ +export { default } from './ApplicationTokenList'; diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx index 5870c341a3..28cad08057 100644 --- a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx @@ -32,7 +32,13 @@ function ApplicationsList({ i18n }) { isLoading, error, request: fetchApplications, - result: { applications, itemCount, actions }, + result: { + applications, + itemCount, + actions, + relatedSearchableKeys, + searchableKeys, + }, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); @@ -46,12 +52,20 @@ function ApplicationsList({ i18n }) { applications: response.data.results, itemCount: response.data.count, actions: actionsResponse.data.actions, + 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: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -101,12 +115,12 @@ function ApplicationsList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Description`), - key: 'description', + key: 'description__icontains', }, ]} toolbarSortColumns={[ @@ -127,11 +141,12 @@ function ApplicationsList({ i18n }) { key: 'description', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( <DatalistToolbar {...props} showSelectAll - showExpandCollapse isAllSelected={isAllSelected} onSelectAll={isSelected => setSelected(isSelected ? [...applications] : []) diff --git a/awx/ui_next/src/screens/Application/shared/ApplicationForm.jsx b/awx/ui_next/src/screens/Application/shared/ApplicationForm.jsx index 2ffb2ebfb8..8d729ca073 100644 --- a/awx/ui_next/src/screens/Application/shared/ApplicationForm.jsx +++ b/awx/ui_next/src/screens/Application/shared/ApplicationForm.jsx @@ -39,6 +39,7 @@ function ApplicationFormFields({ name: 'client_type', validate: required(null, i18n), }); + return ( <> <FormField @@ -68,17 +69,26 @@ function ApplicationFormFields({ <FormGroup fieldId="authType" helperTextInvalid={authorizationTypeMeta.error} + validated={ + !authorizationTypeMeta.touched || !authorizationTypeMeta.error + ? 'default' + : 'error' + } isRequired - isValid={!authorizationTypeMeta.touched || !authorizationTypeMeta.error} label={i18n._(t`Authorization grant type`)} + labelIcon={ + <FieldTooltip + content={i18n._( + t`The Grant type the user must use for acquire tokens for this application` + )} + /> + } > - <FieldTooltip - content={i18n._( - t`The Grant type the user must use for acquire tokens for this application` - )} - /> <AnsibleSelect {...authorizationTypeField} + isValid={ + !authorizationTypeMeta.touched || !authorizationTypeMeta.error + } isDisabled={match.url.endsWith('edit')} id="authType" data={[{ label: '', key: 1, value: '' }, ...authorizationOptions]} @@ -105,17 +115,22 @@ function ApplicationFormFields({ <FormGroup fieldId="clientType" helperTextInvalid={clientTypeMeta.error} + validated={ + !clientTypeMeta.touched || !clientTypeMeta.error ? 'default' : 'error' + } isRequired - isValid={!clientTypeMeta.touched || !clientTypeMeta.error} label={i18n._(t`Client type`)} + labelIcon={ + <FieldTooltip + content={i18n._( + t`Set to Public or Confidential depending on how secure the client device is.` + )} + /> + } > - <FieldTooltip - content={i18n._( - t`Set to Public or Confidential depending on how secure the client device is.` - )} - /> <AnsibleSelect {...clientTypeField} + isValid={!clientTypeMeta.touched || !clientTypeMeta.error} id="clientType" data={[{ label: '', key: 1, value: '' }, ...clientTypeOptions]} onChange={(event, value) => { diff --git a/awx/ui_next/src/screens/AuthSetting/AuthSettings.jsx b/awx/ui_next/src/screens/AuthSetting/AuthSettings.jsx deleted file mode 100644 index e9f79a452a..0000000000 --- a/awx/ui_next/src/screens/AuthSetting/AuthSettings.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { - PageSection, - PageSectionVariants, - Title, -} from '@patternfly/react-core'; - -class AuthSettings extends Component { - render() { - const { i18n } = this.props; - const { light } = PageSectionVariants; - - return ( - <Fragment> - <PageSection variant={light} className="pf-m-condensed"> - <Title size="2xl" headingLevel="h2"> - {i18n._(t`Authentication Settings`)} - </Title> - </PageSection> - <PageSection /> - </Fragment> - ); - } -} - -export default withI18n()(AuthSettings); diff --git a/awx/ui_next/src/screens/AuthSetting/AuthSettings.test.jsx b/awx/ui_next/src/screens/AuthSetting/AuthSettings.test.jsx deleted file mode 100644 index 0c0ca67b4b..0000000000 --- a/awx/ui_next/src/screens/AuthSetting/AuthSettings.test.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; - -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; - -import AuthSettings from './AuthSettings'; - -describe('<AuthSettings />', () => { - let pageWrapper; - let pageSections; - let title; - - beforeEach(() => { - pageWrapper = mountWithContexts(<AuthSettings />); - pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); - }); - - afterEach(() => { - pageWrapper.unmount(); - }); - - test('initially renders without crashing', () => { - expect(pageWrapper.length).toBe(1); - expect(pageSections.length).toBe(2); - expect(title.length).toBe(1); - expect(title.props().size).toBe('2xl'); - expect(pageSections.first().props().variant).toBe('light'); - }); -}); diff --git a/awx/ui_next/src/screens/AuthSetting/index.js b/awx/ui_next/src/screens/AuthSetting/index.js deleted file mode 100644 index 880b6544c0..0000000000 --- a/awx/ui_next/src/screens/AuthSetting/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './AuthSettings'; diff --git a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx index 6d77aeba8b..09d57417bb 100644 --- a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx @@ -181,7 +181,10 @@ describe('<CredentialAdd />', () => { test('handleCancel should return the user back to the credentials list', 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'); }); }); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx index 88a81eaf2c..d23e5347ee 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx @@ -208,8 +208,10 @@ describe('<CredentialForm />', () => { }); wrapper.update(); expect( - wrapper.find('FormGroup[fieldId="credential-gce-file"]').prop('isValid') - ).toBe(false); + wrapper + .find('FormGroup[fieldId="credential-gce-file"]') + .prop('validated') + ).toBe('error'); expect( wrapper diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/BecomeMethodField.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/BecomeMethodField.jsx index ee97e43d27..fb51b1f4c6 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/BecomeMethodField.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/BecomeMethodField.jsx @@ -35,12 +35,14 @@ function BecomeMethodField({ fieldOptions, isRequired }) { fieldId={`credential-${fieldOptions.id}`} helperTextInvalid={meta.error} label={fieldOptions.label} + labelIcon={ + fieldOptions.help_text && ( + <FieldTooltip content={fieldOptions.help_text} /> + ) + } isRequired={isRequired} - isValid={!(meta.touched && meta.error)} + validated={!(meta.touched && meta.error) ? 'default' : 'error'} > - {fieldOptions.help_text && ( - <FieldTooltip content={fieldOptions.help_text} /> - )} <Select maxHeight={200} variant={SelectVariant.typeahead} diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx index 8adcf10190..aafa1c74fe 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx @@ -29,7 +29,7 @@ function CredentialInput({ fieldOptions, credentialKind, ...rest }) { onChange={(value, event) => { subFormField.onChange(event); }} - isValid={isValid} + validated={isValid ? 'default' : 'error'} /> ); } @@ -38,7 +38,6 @@ function CredentialInput({ fieldOptions, credentialKind, ...rest }) { <PasswordInput {...subFormField} id={`credential-${fieldOptions.id}`} - isValid={isValid} {...rest} /> ); @@ -55,7 +54,7 @@ function CredentialInput({ fieldOptions, credentialKind, ...rest }) { onChange={(value, event) => { subFormField.onChange(event); }} - isValid={isValid} + validated={isValid ? 'default' : 'error'} /> ); } @@ -107,7 +106,7 @@ function CredentialField({ credentialType, fieldOptions, i18n }) { helperTextInvalid={meta.error} label={fieldOptions.label} isRequired={isRequired} - isValid={isValid} + validated={isValid ? 'default' : 'error'} > <AnsibleSelect {...subFormField} @@ -126,12 +125,14 @@ function CredentialField({ credentialType, fieldOptions, i18n }) { fieldId={`credential-${fieldOptions.id}`} helperTextInvalid={meta.error} label={fieldOptions.label} + labelIcon={ + fieldOptions.help_text && ( + <FieldTooltip content={fieldOptions.help_text} /> + ) + } isRequired={isRequired} - isValid={isValid} + validated={isValid ? 'default' : 'error'} > - {fieldOptions.help_text && ( - <FieldTooltip content={fieldOptions.help_text} /> - )} <CredentialInput credentialKind={credentialType.kind} fieldOptions={fieldOptions} @@ -148,7 +149,7 @@ function CredentialField({ credentialType, fieldOptions, i18n }) { <CredentialPluginField fieldOptions={fieldOptions} isRequired={isRequired} - isValid={isValid} + validated={isValid ? 'default' : 'error'} > <CredentialInput fieldOptions={fieldOptions} /> </CredentialPluginField> diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginField.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginField.jsx index 0df0ccc1d1..3a90676982 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginField.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginField.jsx @@ -43,7 +43,7 @@ function CredentialPluginInput(props) { {React.cloneElement(children, { ...inputField, isRequired, - isValid, + validated: isValid ? 'default' : 'error', isDisabled: !!passwordPromptField.value, onChange: (_, event) => { inputField.onChange(event); @@ -125,12 +125,14 @@ function CredentialPluginField(props) { fieldId={`credential-${fieldOptions.id}`} helperTextInvalid={meta.error} isRequired={isRequired} - isValid={isValid} + validated={isValid ? 'default' : 'error'} label={fieldOptions.label} + labelIcon={ + fieldOptions.help_text && ( + <FieldTooltip content={fieldOptions.help_text} /> + ) + } > - {fieldOptions.help_text && ( - <FieldTooltip content={fieldOptions.help_text} /> - )} <CredentialPluginInput {...props} /> </FormGroup> )} diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.test.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.test.jsx index 93725cb3e8..bdcd89398a 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.test.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.test.jsx @@ -20,6 +20,16 @@ CredentialsAPI.read.mockResolvedValue({ }, }); +CredentialsAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [], + }, +}); + CredentialTypesAPI.readDetail.mockResolvedValue({ data: { id: 20, diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx index 8a04079b99..bc91a98894 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx @@ -25,22 +25,29 @@ function CredentialsStep({ i18n }) { const history = useHistory(); const { - result: { credentials, count }, + result: { credentials, count, relatedSearchableKeys, searchableKeys }, error: credentialsError, isLoading: isCredentialsLoading, request: fetchCredentials, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, history.location.search); - const { data } = await CredentialsAPI.read({ - ...params, - }); + const [{ data }, actionsResponse] = await Promise.all([ + CredentialsAPI.read({ ...params }), + CredentialsAPI.readOptions(), + ]); return { credentials: data.results, count: 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]), - { credentials: [], count: 0 } + { credentials: [], count: 0, relatedSearchableKeys: [], searchableKeys: [] } ); useEffect(() => { @@ -76,16 +83,16 @@ function CredentialsStep({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -94,6 +101,8 @@ function CredentialsStep({ i18n }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} /> ); } diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/GceFileUploadField.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/GceFileUploadField.jsx index 1332aa85c5..e49f44c275 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/GceFileUploadField.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/GceFileUploadField.jsx @@ -20,7 +20,7 @@ function GceFileUploadField({ i18n }) { return ( <FormGroup fieldId="credential-gce-file" - isValid={!fileError} + validated={!fileError ? 'default' : 'error'} label={i18n._(t`Service account JSON file`)} helperText={i18n._( t`Select a JSON formatted service account key to autopopulate the following fields.` diff --git a/awx/ui_next/src/screens/CredentialType/CredentialType.jsx b/awx/ui_next/src/screens/CredentialType/CredentialType.jsx index 121020b569..17dc115a44 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialType.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialType.jsx @@ -65,11 +65,6 @@ function CredentialType({ i18n, setBreadcrumb }) { }, ]; - let cardHeader = <RoutedTabs tabsArray={tabsArray} />; - if (pathname.endsWith('edit')) { - cardHeader = null; - } - if (!isLoading && contentError) { return ( <PageSection> @@ -89,6 +84,11 @@ function CredentialType({ i18n, setBreadcrumb }) { ); } + let cardHeader = <RoutedTabs tabsArray={tabsArray} />; + if (pathname.endsWith('edit')) { + cardHeader = null; + } + return ( <PageSection> <Card> @@ -104,7 +104,7 @@ function CredentialType({ i18n, setBreadcrumb }) { {credentialType && ( <> <Route path="/credential_types/:id/edit"> - <CredentialTypeEdit /> + <CredentialTypeEdit credentialType={credentialType} /> </Route> <Route path="/credential_types/:id/details"> <CredentialTypeDetails credentialType={credentialType} /> diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx index d622c9d6f1..1b99c16867 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx @@ -70,7 +70,7 @@ function CredentialTypeDetails({ credentialType, i18n }) { <Button aria-label={i18n._(t`edit`)} component={Link} - to={`credential_types/${id}/edit`} + to={`/credential_types/${id}/edit`} > {i18n._(t`Edit`)} </Button> diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeEdit/CredentialTypeEdit.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeEdit/CredentialTypeEdit.jsx index 9ccbf329d0..c188e97e97 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeEdit/CredentialTypeEdit.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeEdit/CredentialTypeEdit.jsx @@ -1,11 +1,41 @@ -import React from 'react'; -import { Card, PageSection } from '@patternfly/react-core'; +import React, { useState } from 'react'; +import { useHistory } from 'react-router-dom'; -function CredentialTypeEdit() { +import { CardBody } from '../../../components/Card'; +import { CredentialTypesAPI } from '../../../api'; +import CredentialTypeForm from '../shared/CredentialTypeForm'; +import { parseVariableField } from '../../../util/yaml'; + +function CredentialTypeEdit({ credentialType }) { + const history = useHistory(); + const [submitError, setSubmitError] = useState(null); + const detailsUrl = `/credential_types/${credentialType.id}/details`; + + const handleSubmit = async values => { + try { + await CredentialTypesAPI.update(credentialType.id, { + ...values, + injectors: parseVariableField(values.injectors), + inputs: parseVariableField(values.inputs), + }); + history.push(detailsUrl); + } catch (error) { + setSubmitError(error); + } + }; + + const handleCancel = () => { + history.push(detailsUrl); + }; return ( - <PageSection> - <Card>Credential Type Edit</Card> - </PageSection> + <CardBody> + <CredentialTypeForm + credentialType={credentialType} + onSubmit={handleSubmit} + submitError={submitError} + onCancel={handleCancel} + /> + </CardBody> ); } diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeEdit/CredentialTypeEdit.test.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeEdit/CredentialTypeEdit.test.jsx new file mode 100644 index 0000000000..f4800e81b4 --- /dev/null +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeEdit/CredentialTypeEdit.test.jsx @@ -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 { CredentialTypesAPI } from '../../../api'; + +import CredentialTypeEdit from './CredentialTypeEdit'; + +jest.mock('../../../api'); + +const credentialTypeData = { + id: 42, + name: 'Foo', + description: 'New credential', + kind: 'cloud', + inputs: JSON.stringify({ + fields: [ + { + id: 'username', + type: 'string', + label: 'Jenkins username', + }, + { + id: 'password', + type: 'string', + label: 'Jenkins password', + secret: true, + }, + ], + required: ['username', 'password'], + }), + injectors: JSON.stringify({ + extra_vars: { + Jenkins_password: '{{ password }}', + Jenkins_username: '{{ username }}', + }, + }), + summary_fields: { + created_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + modified_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + user_capabilities: { + edit: true, + delete: true, + }, + }, + created: '2020-06-25T16:52:36.127008Z', + modified: '2020-06-25T16:52:36.127022Z', +}; + +const updateCredentialTypeData = { + name: 'Bar', + description: 'Updated new Credential Type', + injectors: credentialTypeData.injectors, + inputs: credentialTypeData.inputs, +}; + +describe('<CredentialTypeEdit>', () => { + let wrapper; + let history; + + beforeAll(async () => { + history = createMemoryHistory(); + await act(async () => { + wrapper = mountWithContexts( + <CredentialTypeEdit credentialType={credentialTypeData} />, + { + context: { router: { history } }, + } + ); + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('handleSubmit should call the api and redirect to details page', async () => { + await act(async () => { + wrapper.find('CredentialTypeForm').invoke('onSubmit')( + updateCredentialTypeData + ); + wrapper.update(); + expect(CredentialTypesAPI.update).toHaveBeenCalledWith(42, { + ...updateCredentialTypeData, + injectors: JSON.parse(credentialTypeData.injectors), + inputs: JSON.parse(credentialTypeData.inputs), + }); + }); + }); + + test('should navigate to credential types detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + }); + expect(history.location.pathname).toEqual('/credential_types/42/details'); + }); + + test('should navigate to credential type detail after successful submission', async () => { + await act(async () => { + wrapper.find('CredentialTypeForm').invoke('onSubmit')({ + ...updateCredentialTypeData, + injectors: JSON.parse(credentialTypeData.injectors), + inputs: JSON.parse(credentialTypeData.inputs), + }); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(history.location.pathname).toEqual('/credential_types/42/details'); + }); + + test('failed form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + CredentialTypesAPI.update.mockImplementationOnce(() => + Promise.reject(error) + ); + await act(async () => { + wrapper.find('CredentialTypeForm').invoke('onSubmit')( + updateCredentialTypeData + ); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx index 7375a61b63..4051adee05 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx @@ -21,7 +21,6 @@ import CredentialTypeListItem from './CredentialTypeListItem'; const QS_CONFIG = getQSConfig('credential_type', { page: 1, page_size: 20, - order_by: 'name', managed_by_tower: false, }); @@ -67,7 +66,7 @@ function CredentialTypeList({ i18n }) { const { isLoading: deleteLoading, deletionError, - deleteItems: handleDeleteCredentialTypes, + deleteItems: deleteCredentialTypes, clearDeletionError, } = useDeleteItems( useCallback(async () => { @@ -83,7 +82,7 @@ function CredentialTypeList({ i18n }) { ); const handleDelete = async () => { - await handleDeleteCredentialTypes(); + await deleteCredentialTypes(); setSelected([]); }; @@ -105,7 +104,6 @@ function CredentialTypeList({ i18n }) { <DatalistToolbar {...props} showSelectAll - showExpandCollapse isAllSelected={isAllSelected} onSelectAll={isSelected => setSelected(isSelected ? [...credentialTypes] : []) @@ -131,7 +129,7 @@ function CredentialTypeList({ i18n }) { )} renderItem={credentialType => ( <CredentialTypeListItem - key={credentialTypes.id} + key={credentialType.id} value={credentialType.name} credentialType={credentialType} detailUrl={`${match.url}/${credentialType.id}/details`} diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx index 9f6b5b734a..7cdbbccb00 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx @@ -11,7 +11,7 @@ import Breadcrumbs from '../../components/Breadcrumbs'; function CredentialTypes({ i18n }) { const [breadcrumbConfig, setBreadcrumbConfig] = useState({ '/credential_types': i18n._(t`Credential Types`), - '/credential_types/add': i18n._(t`Create Credential Types`), + '/credential_types/add': i18n._(t`Create new credential type`), }); const buildBreadcrumbConfig = useCallback( @@ -21,10 +21,10 @@ function CredentialTypes({ i18n }) { } setBreadcrumbConfig({ '/credential_types': i18n._(t`Credential Types`), - '/credential_types/add': i18n._(t`Create Credential Types`), + '/credential_types/add': i18n._(t`Create new credential Type`), [`/credential_types/${credentialTypes.id}`]: `${credentialTypes.name}`, [`/credential_types/${credentialTypes.id}/edit`]: i18n._( - t`Edit Details` + t`Edit details` ), [`/credential_types/${credentialTypes.id}/details`]: i18n._(t`Details`), }); diff --git a/awx/ui_next/src/screens/CredentialType/shared/CredentialTypeForm.jsx b/awx/ui_next/src/screens/CredentialType/shared/CredentialTypeForm.jsx index 6f86ad2462..8bb5f113ef 100644 --- a/awx/ui_next/src/screens/CredentialType/shared/CredentialTypeForm.jsx +++ b/awx/ui_next/src/screens/CredentialType/shared/CredentialTypeForm.jsx @@ -14,6 +14,8 @@ import { FormFullWidthLayout, } from '../../../components/FormLayout'; +import { jsonToYaml } from '../../../util/yaml'; + function CredentialTypeFormFields({ i18n }) { return ( <> @@ -65,8 +67,12 @@ function CredentialTypeForm({ const initialValues = { name: credentialType.name || '', description: credentialType.description || '', - inputs: credentialType.inputs || '---', - injectors: credentialType.injectors || '---', + inputs: credentialType.inputs + ? jsonToYaml(JSON.stringify(credentialType.inputs)) + : '---', + injectors: credentialType.injectors + ? jsonToYaml(JSON.stringify(credentialType.injectors)) + : '---', }; return ( <Formik initialValues={initialValues} onSubmit={values => onSubmit(values)}> @@ -74,7 +80,7 @@ function CredentialTypeForm({ <Form autoComplete="off" onSubmit={formik.handleSubmit}> <FormColumnLayout> <CredentialTypeFormFields {...rest} /> - <FormSubmitError error={submitError} /> + {submitError && <FormSubmitError error={submitError} />} <FormActionGroup onCancel={onCancel} onSubmit={formik.handleSubmit} diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx index 9e8737912d..6233df73bf 100644 --- a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx @@ -33,7 +33,13 @@ function HostGroupsList({ i18n, host }) { const invId = host.summary_fields.inventory.id; const { - result: { groups, itemCount, actions }, + result: { + groups, + itemCount, + actions, + relatedSearchableKeys, + searchableKeys, + }, error: contentError, isLoading, request: fetchGroups, @@ -55,11 +61,20 @@ function HostGroupsList({ i18n, host }) { groups: results, itemCount: count, actions: actionsResponse.data.actions, + 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: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -136,16 +151,16 @@ function HostGroupsList({ i18n, host }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -154,6 +169,8 @@ function HostGroupsList({ i18n, host }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderItem={item => ( <HostGroupItem key={item.id} diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.jsx index 3e65c49ce5..a11ec3f4d1 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.jsx @@ -29,7 +29,7 @@ function HostList({ i18n }) { const [selected, setSelected] = useState([]); const { - result: { hosts, count, actions }, + result: { hosts, count, actions, relatedSearchableKeys, searchableKeys }, error: contentError, isLoading, request: fetchHosts, @@ -44,12 +44,20 @@ function HostList({ i18n }) { hosts: results[0].data.results, count: results[0].data.count, actions: results[1].data.actions, + 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: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -108,16 +116,16 @@ function HostList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -126,6 +134,8 @@ function HostList({ i18n }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( <DataListToolbar {...props} diff --git a/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx b/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx index 22a999c378..377fb453ab 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx @@ -30,11 +30,6 @@ const DataListAction = styled(_DataListAction)` function HostListItem({ i18n, host, isSelected, onSelect, detailUrl }) { const labelId = `check-action-${host.id}`; - const recentPlaybookJobs = host.summary_fields.recent_jobs.map(job => ({ - ...job, - type: 'job', - })); - return ( <DataListItem key={host.id} aria-labelledby={labelId} id={`${host.id}`}> <DataListItemRow> @@ -52,18 +47,14 @@ function HostListItem({ i18n, host, isSelected, onSelect, detailUrl }) { </Link> </DataListCell>, <DataListCell key="recentJobs"> - <Sparkline jobs={recentPlaybookJobs} /> + <Sparkline jobs={host.summary_fields.recent_jobs} /> </DataListCell>, <DataListCell key="inventory"> {host.summary_fields.inventory && ( <Fragment> <b css="margin-right: 24px">{i18n._(t`Inventory`)}</b> <Link - to={`/inventories/${ - 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> diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroup.jsx b/awx/ui_next/src/screens/InstanceGroup/ContainerGroup.jsx new file mode 100644 index 0000000000..265c27c379 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/ContainerGroup.jsx @@ -0,0 +1,131 @@ +import React, { useEffect, useCallback } from 'react'; +import { + Link, + Redirect, + Route, + Switch, + useLocation, + useParams, +} from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { CaretLeftIcon } from '@patternfly/react-icons'; +import { Card, PageSection } from '@patternfly/react-core'; + +import useRequest from '../../util/useRequest'; +import { InstanceGroupsAPI } from '../../api'; +import RoutedTabs from '../../components/RoutedTabs'; +import ContentError from '../../components/ContentError'; +import ContentLoading from '../../components/ContentLoading'; + +import ContainerGroupDetails from './ContainerGroupDetails'; +import ContainerGroupEdit from './ContainerGroupEdit'; +import Jobs from './Jobs'; + +function ContainerGroup({ i18n, setBreadcrumb }) { + const { id } = useParams(); + const { pathname } = useLocation(); + + const { + isLoading, + error: contentError, + request: fetchInstanceGroups, + result: instanceGroup, + } = useRequest( + useCallback(async () => { + const { data } = await InstanceGroupsAPI.readDetail(id); + return data; + }, [id]) + ); + + useEffect(() => { + fetchInstanceGroups(); + }, [fetchInstanceGroups, pathname]); + + useEffect(() => { + if (instanceGroup) { + setBreadcrumb(instanceGroup); + } + }, [instanceGroup, setBreadcrumb]); + + const tabsArray = [ + { + name: ( + <> + <CaretLeftIcon /> + {i18n._(t`Back to instance groups`)} + </> + ), + link: '/instance_groups', + id: 99, + }, + { + name: i18n._(t`Details`), + link: `/instance_groups/container_group/${id}/details`, + id: 0, + }, + { + name: i18n._(t`Jobs`), + link: `/instance_groups/container_group/${id}/jobs`, + id: 1, + }, + ]; + + if (!isLoading && contentError) { + return ( + <PageSection> + <Card> + <ContentError error={contentError}> + {contentError.response?.status === 404 && ( + <span> + {i18n._(t`Container group not found.`)} + {''} + <Link to="/instance_groups"> + {i18n._(t`View all instance groups`)} + </Link> + </span> + )} + </ContentError> + </Card> + </PageSection> + ); + } + + let cardHeader = <RoutedTabs tabsArray={tabsArray} />; + if (pathname.endsWith('edit')) { + cardHeader = null; + } + + return ( + <PageSection> + <Card> + {cardHeader} + {isLoading && <ContentLoading />} + {!isLoading && instanceGroup && ( + <Switch> + <Redirect + from="/instance_groups/container_group/:id" + to="/instance_groups/container_group/:id/details" + exact + /> + {instanceGroup && ( + <> + <Route path="/instance_groups/container_group/:id/edit"> + <ContainerGroupEdit /> + </Route> + <Route path="/instance_groups/container_group/:id/details"> + <ContainerGroupDetails /> + </Route> + <Route path="/instance_groups/container_group/:id/jobs"> + <Jobs /> + </Route> + </> + )} + </Switch> + )} + </Card> + </PageSection> + ); +} + +export default withI18n()(ContainerGroup); diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroup.test.jsx b/awx/ui_next/src/screens/InstanceGroup/ContainerGroup.test.jsx new file mode 100644 index 0000000000..308b21f7a5 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/ContainerGroup.test.jsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; + +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; +import { InstanceGroupsAPI } from '../../api'; + +import ContainerGroup from './ContainerGroup'; + +jest.mock('../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useRouteMatch: () => ({ + url: '/instance_groups/container_group', + }), + useParams: () => ({ id: 42 }), +})); + +describe('<ContainerGroup/>', () => { + let wrapper; + test('should render details properly', async () => { + await act(async () => { + wrapper = mountWithContexts(<ContainerGroup setBreadcrumb={() => {}} />); + }); + wrapper.update(); + expect(wrapper.find('ContainerGroup').length).toBe(1); + expect(InstanceGroupsAPI.readDetail).toBeCalledWith(42); + }); + + test('should render expected tabs', async () => { + const expectedTabs = ['Back to instance groups', 'Details', 'Jobs']; + await act(async () => { + wrapper = mountWithContexts(<ContainerGroup setBreadcrumb={() => {}} />); + }); + wrapper.find('RoutedTabs li').forEach((tab, index) => { + expect(tab.text()).toEqual(expectedTabs[index]); + }); + }); + + test('should show content error when user attempts to navigate to erroneous route', async () => { + const history = createMemoryHistory({ + initialEntries: ['/instance_groups/container_group/42/foobar'], + }); + await act(async () => { + wrapper = mountWithContexts(<ContainerGroup setBreadcrumb={() => {}} />, { + context: { + router: { + history, + }, + }, + }); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); +}); diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupAdd/ContainerGroupAdd.jsx b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupAdd/ContainerGroupAdd.jsx new file mode 100644 index 0000000000..f4dd75787e --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupAdd/ContainerGroupAdd.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Card, PageSection } from '@patternfly/react-core'; + +function ContainerGroupAdd() { + return ( + <PageSection> + <Card> + <div>Add container group</div> + </Card> + </PageSection> + ); +} + +export default ContainerGroupAdd; diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupAdd/index.js b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupAdd/index.js new file mode 100644 index 0000000000..d693720aac --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupAdd/index.js @@ -0,0 +1 @@ +export { default } from './ContainerGroupAdd'; diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupDetails/ContainerGroupDetails.jsx b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupDetails/ContainerGroupDetails.jsx new file mode 100644 index 0000000000..80db274d98 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupDetails/ContainerGroupDetails.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Card, PageSection } from '@patternfly/react-core'; + +function ContainerGroupDetails() { + return ( + <PageSection> + <Card> + <div>Container group details</div> + </Card> + </PageSection> + ); +} + +export default ContainerGroupDetails; diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupDetails/index.js b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupDetails/index.js new file mode 100644 index 0000000000..b1b7e0d8c5 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupDetails/index.js @@ -0,0 +1 @@ +export { default } from './ContainerGroupDetails'; diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.jsx b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.jsx new file mode 100644 index 0000000000..5df56a03f3 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Card, PageSection } from '@patternfly/react-core'; + +function ContainerGroupEdit() { + return ( + <PageSection> + <Card> + <div>Edit container group</div> + </Card> + </PageSection> + ); +} + +export default ContainerGroupEdit; diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/index.js b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/index.js new file mode 100644 index 0000000000..cb97abe8ab --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/index.js @@ -0,0 +1 @@ +export { default } from './ContainerGroupEdit'; diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroup.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroup.jsx new file mode 100644 index 0000000000..a42048f503 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroup.jsx @@ -0,0 +1,140 @@ +import React, { useEffect, useCallback } from 'react'; +import { + Link, + Redirect, + Route, + Switch, + useLocation, + useParams, +} from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { CaretLeftIcon } from '@patternfly/react-icons'; +import { Card, PageSection } from '@patternfly/react-core'; + +import useRequest from '../../util/useRequest'; +import { InstanceGroupsAPI } from '../../api'; +import RoutedTabs from '../../components/RoutedTabs'; +import ContentError from '../../components/ContentError'; +import ContentLoading from '../../components/ContentLoading'; + +import InstanceGroupDetails from './InstanceGroupDetails'; +import InstanceGroupEdit from './InstanceGroupEdit'; +import Jobs from './Jobs'; +import Instances from './Instances'; + +function InstanceGroup({ i18n, setBreadcrumb }) { + const { id } = useParams(); + const { pathname } = useLocation(); + + const { + isLoading, + error: contentError, + request: fetchInstanceGroups, + result: instanceGroup, + } = useRequest( + useCallback(async () => { + const { data } = await InstanceGroupsAPI.readDetail(id); + return data; + }, [id]) + ); + + useEffect(() => { + fetchInstanceGroups(); + }, [fetchInstanceGroups, pathname]); + + useEffect(() => { + if (instanceGroup) { + setBreadcrumb(instanceGroup); + } + }, [instanceGroup, setBreadcrumb]); + + const tabsArray = [ + { + name: ( + <> + <CaretLeftIcon /> + {i18n._(t`Back to instance groups`)} + </> + ), + link: '/instance_groups', + id: 99, + }, + { + name: i18n._(t`Details`), + link: `/instance_groups/${id}/details`, + id: 0, + }, + { + name: i18n._(t`Instances`), + link: `/instance_groups/${id}/instances`, + id: 1, + }, + { + name: i18n._(t`Jobs`), + link: `/instance_groups/${id}/jobs`, + id: 2, + }, + ]; + + if (!isLoading && contentError) { + return ( + <PageSection> + <Card> + <ContentError error={contentError}> + {contentError.response?.status === 404 && ( + <span> + {i18n._(t`Instance group not found.`)} + {''} + <Link to="/instance_groups"> + {i18n._(t`View all instance groups`)} + </Link> + </span> + )} + </ContentError> + </Card> + </PageSection> + ); + } + + let cardHeader = <RoutedTabs tabsArray={tabsArray} />; + if (pathname.endsWith('edit')) { + cardHeader = null; + } + + return ( + <PageSection> + <Card> + {cardHeader} + {isLoading && <ContentLoading />} + {!isLoading && instanceGroup && ( + <Switch> + <Redirect + from="/instance_groups/:id" + to="/instance_groups/:id/details" + exact + /> + {instanceGroup && ( + <> + <Route path="/instance_groups/:id/edit"> + <InstanceGroupEdit instanceGroup={instanceGroup} /> + </Route> + <Route path="/instance_groups/:id/details"> + <InstanceGroupDetails instanceGroup={instanceGroup} /> + </Route> + <Route path="/instance_groups/:id/instances"> + <Instances /> + </Route> + <Route path="/instance_groups/:id/jobs"> + <Jobs /> + </Route> + </> + )} + </Switch> + )} + </Card> + </PageSection> + ); +} + +export default withI18n()(InstanceGroup); diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroup.test.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroup.test.jsx new file mode 100644 index 0000000000..68c47ed20c --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroup.test.jsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; + +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; +import { InstanceGroupsAPI } from '../../api'; + +import InstanceGroup from './InstanceGroup'; + +jest.mock('../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useRouteMatch: () => ({ + url: '/instance_groups', + }), + useParams: () => ({ id: 42 }), +})); + +describe('<InstanceGroup/>', () => { + let wrapper; + test('should render details properly', async () => { + await act(async () => { + wrapper = mountWithContexts(<InstanceGroup setBreadcrumb={() => {}} />); + }); + wrapper.update(); + expect(wrapper.find('InstanceGroup').length).toBe(1); + expect(InstanceGroupsAPI.readDetail).toBeCalledWith(42); + }); + + test('should render expected tabs', async () => { + const expectedTabs = [ + 'Back to instance groups', + 'Details', + 'Instances', + 'Jobs', + ]; + await act(async () => { + wrapper = mountWithContexts(<InstanceGroup setBreadcrumb={() => {}} />); + }); + wrapper.find('RoutedTabs li').forEach((tab, index) => { + expect(tab.text()).toEqual(expectedTabs[index]); + }); + }); + + test('should show content error when user attempts to navigate to erroneous route', async () => { + const history = createMemoryHistory({ + initialEntries: ['/instance_groups/42/foobar'], + }); + await act(async () => { + wrapper = mountWithContexts(<InstanceGroup setBreadcrumb={() => {}} />, { + context: { + router: { + history, + }, + }, + }); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); +}); diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/InstanceGroupAdd.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/InstanceGroupAdd.jsx new file mode 100644 index 0000000000..3f14129826 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/InstanceGroupAdd.jsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; +import { Card, PageSection } from '@patternfly/react-core'; +import { useHistory } from 'react-router-dom'; + +import InstanceGroupForm from '../shared/InstanceGroupForm'; +import { CardBody } from '../../../components/Card'; +import { InstanceGroupsAPI } from '../../../api'; + +function InstanceGroupAdd() { + const history = useHistory(); + const [submitError, setSubmitError] = useState(null); + + const handleSubmit = async values => { + try { + const { data: response } = await InstanceGroupsAPI.create(values); + history.push(`/instance_groups/${response.id}/details`); + } catch (error) { + setSubmitError(error); + } + }; + + const handleCancel = () => { + history.push(`/instance_groups`); + }; + + return ( + <PageSection> + <Card> + <CardBody> + <InstanceGroupForm + onSubmit={handleSubmit} + submitError={submitError} + onCancel={handleCancel} + /> + </CardBody> + </Card> + </PageSection> + ); +} + +export default InstanceGroupAdd; diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/InstanceGroupAdd.test.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/InstanceGroupAdd.test.jsx new file mode 100644 index 0000000000..4b2d879398 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/InstanceGroupAdd.test.jsx @@ -0,0 +1,101 @@ +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 InstanceGroupAdd from './InstanceGroupAdd'; + +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: 'Bar', + 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, + }, + }, +}; + +InstanceGroupsAPI.create.mockResolvedValue({ + data: { + id: 42, + }, +}); + +describe('<InstanceGroupAdd/>', () => { + let wrapper; + let history; + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/instance_groups'], + }); + await act(async () => { + wrapper = mountWithContexts(<InstanceGroupAdd />, { + context: { router: { history } }, + }); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('handleSubmit should call the api and redirect to details page', async () => { + await act(async () => { + wrapper.find('InstanceGroupForm').prop('onSubmit')(instanceGroupData); + }); + wrapper.update(); + expect(InstanceGroupsAPI.create).toHaveBeenCalledWith(instanceGroupData); + expect(history.location.pathname).toBe('/instance_groups/42/details'); + }); + + test('handleCancel should return the user back to the instance group list', async () => { + wrapper.find('Button[aria-label="Cancel"]').simulate('click'); + expect(history.location.pathname).toEqual('/instance_groups'); + }); + + test('failed form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + InstanceGroupsAPI.create.mockImplementationOnce(() => + Promise.reject(error) + ); + await act(async () => { + wrapper.find('InstanceGroupForm').invoke('onSubmit')(instanceGroupData); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/index.js b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/index.js new file mode 100644 index 0000000000..b60610120d --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/index.js @@ -0,0 +1 @@ +export { default } from './InstanceGroupAdd'; diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupDetails/InstanceGroupDetails.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupDetails/InstanceGroupDetails.jsx new file mode 100644 index 0000000000..8df5e5b863 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupDetails/InstanceGroupDetails.jsx @@ -0,0 +1,134 @@ +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 AlertModal from '../../../components/AlertModal'; +import { CardBody, CardActionsRow } from '../../../components/Card'; +import DeleteButton from '../../../components/DeleteButton'; +import { + Detail, + DetailList, + UserDateDetail, + DetailBadge, +} from '../../../components/DetailList'; +import useRequest, { useDismissableError } from '../../../util/useRequest'; +import { InstanceGroupsAPI } from '../../../api'; + +function InstanceGroupDetails({ instanceGroup, i18n }) { + const { id, name } = instanceGroup; + + const history = useHistory(); + + const { + request: deleteInstanceGroup, + isLoading, + error: deleteError, + } = useRequest( + useCallback(async () => { + await InstanceGroupsAPI.destroy(id); + history.push(`/instance_groups`); + }, [id, history]) + ); + + const { error, dismissError } = useDismissableError(deleteError); + + const isAvailable = item => { + return ( + (item.policy_instance_minimum || item.policy_instance_percentage) && + item.capacity + ); + }; + + return ( + <CardBody> + <DetailList> + <Detail + label={i18n._(t`Name`)} + value={name} + dataCy="instance-group-detail-name" + /> + <Detail + label={i18n._(t`Type`)} + value={ + instanceGroup.is_containerized + ? i18n._(t`Container group`) + : i18n._(t`Instance group`) + } + dataCy="instance-group-type" + /> + <DetailBadge + label={i18n._(t`Policy instance minimum`)} + dataCy="instance-group-policy-instance-minimum" + content={instanceGroup.policy_instance_minimum} + /> + <DetailBadge + label={i18n._(t`Policy instance percentage`)} + dataCy="instance-group-policy-instance-percentage" + content={`${instanceGroup.policy_instance_percentage} %`} + /> + {isAvailable(instanceGroup) ? ( + <DetailBadge + label={i18n._(t`Used capacity`)} + content={`${100 - instanceGroup.percent_capacity_remaining} %`} + dataCy="instance-group-used-capacity" + /> + ) : ( + <Detail + label={i18n._(t`Used capacity`)} + value={<span css="color: red">{i18n._(t`Unavailable`)}</span>} + dataCy="instance-group-used-capacity" + /> + )} + + <UserDateDetail + label={i18n._(t`Created`)} + date={instanceGroup.created} + user={instanceGroup.summary_fields.created_by} + /> + <UserDateDetail + label={i18n._(t`Last Modified`)} + date={instanceGroup.modified} + user={instanceGroup.summary_fields.modified_by} + /> + </DetailList> + + <CardActionsRow> + {instanceGroup.summary_fields.user_capabilities && + instanceGroup.summary_fields.user_capabilities.edit && ( + <Button + aria-label={i18n._(t`edit`)} + component={Link} + to={`/instance_groups/${id}/edit`} + > + {i18n._(t`Edit`)} + </Button> + )} + {name !== 'tower' && + instanceGroup.summary_fields.user_capabilities && + instanceGroup.summary_fields.user_capabilities.delete && ( + <DeleteButton + name={name} + modalTitle={i18n._(t`Delete instance group`)} + onConfirm={deleteInstanceGroup} + isDisabled={isLoading} + > + {i18n._(t`Delete`)} + </DeleteButton> + )} + </CardActionsRow> + {error && ( + <AlertModal + isOpen={error} + onClose={dismissError} + title={i18n._(t`Error`)} + variant="error" + /> + )} + </CardBody> + ); +} + +export default withI18n()(InstanceGroupDetails); diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupDetails/InstanceGroupDetails.test.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupDetails/InstanceGroupDetails.test.jsx new file mode 100644 index 0000000000..7df34cf91d --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupDetails/InstanceGroupDetails.test.jsx @@ -0,0 +1,147 @@ +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 InstanceGroupDetails from './InstanceGroupDetails'; + +jest.mock('../../../api'); + +const instanceGroups = [ + { + id: 1, + name: 'Foo', + type: 'instance_group', + url: '/api/v2/instance_groups/1/', + capacity: 10, + policy_instance_minimum: 10, + policy_instance_percentage: 50, + percent_capacity_remaining: 60, + is_containerized: false, + created: '2020-07-21T18:41:02.818081Z', + modified: '2020-07-24T20:32:03.121079Z', + summary_fields: { + user_capabilities: { + edit: true, + delete: true, + }, + }, + }, + { + id: 2, + name: 'Bar', + type: 'instance_group', + url: '/api/v2/instance_groups/2/', + capacity: 0, + policy_instance_minimum: 0, + policy_instance_percentage: 0, + percent_capacity_remaining: 0, + is_containerized: true, + created: '2020-07-21T18:41:02.818081Z', + modified: '2020-07-24T20:32:03.121079Z', + summary_fields: { + user_capabilities: { + edit: false, + delete: false, + }, + }, + }, +]; + +function expectDetailToMatch(wrapper, label, value) { + const detail = wrapper.find(`Detail[label="${label}"]`); + expect(detail).toHaveLength(1); + expect(detail.prop('value')).toEqual(value); +} + +describe('<InstanceGroupDetails/>', () => { + let wrapper; + test('should render details properly', async () => { + await act(async () => { + wrapper = mountWithContexts( + <InstanceGroupDetails instanceGroup={instanceGroups[0]} /> + ); + }); + + wrapper.update(); + expectDetailToMatch(wrapper, 'Name', instanceGroups[0].name); + expectDetailToMatch(wrapper, 'Type', `Instance group`); + const dates = wrapper.find('UserDateDetail'); + expect(dates).toHaveLength(2); + expect(dates.at(0).prop('date')).toEqual(instanceGroups[0].created); + expect(dates.at(1).prop('date')).toEqual(instanceGroups[0].modified); + + expect( + wrapper.find('DetailBadge[label="Used capacity"]').prop('content') + ).toBe(`${100 - instanceGroups[0].percent_capacity_remaining} %`); + + expect( + wrapper + .find('DetailBadge[label="Policy instance minimum"]') + .prop('content') + ).toBe(instanceGroups[0].policy_instance_minimum); + + expect( + wrapper + .find('DetailBadge[label="Policy instance percentage"]') + .prop('content') + ).toBe(`${instanceGroups[0].policy_instance_percentage} %`); + }); + + test('expected api call is made for delete', async () => { + const history = createMemoryHistory({ + initialEntries: ['/instance_groups/1/details'], + }); + await act(async () => { + wrapper = mountWithContexts( + <InstanceGroupDetails instanceGroup={instanceGroups[0]} />, + { + context: { router: { history } }, + } + ); + }); + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); + expect(InstanceGroupsAPI.destroy).toHaveBeenCalledTimes(1); + expect(history.location.pathname).toBe('/instance_groups'); + }); + + test('should not render delete button for tower instance group', async () => { + await act(async () => { + wrapper = mountWithContexts( + <InstanceGroupDetails instanceGroup={instanceGroups[1]} /> + ); + }); + wrapper.update(); + + expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(0); + }); + + test('should not render delete button', async () => { + instanceGroups[0].summary_fields.user_capabilities.delete = false; + await act(async () => { + wrapper = mountWithContexts( + <InstanceGroupDetails instanceGroup={instanceGroups[0]} /> + ); + }); + wrapper.update(); + + expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(0); + }); + + test('should not render edit button', async () => { + instanceGroups[0].summary_fields.user_capabilities.edit = false; + await act(async () => { + wrapper = mountWithContexts( + <InstanceGroupDetails instanceGroup={instanceGroups[0]} /> + ); + }); + wrapper.update(); + + expect(wrapper.find('Button[aria-label="Edit"]').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupDetails/index.js b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupDetails/index.js new file mode 100644 index 0000000000..d92e89e961 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupDetails/index.js @@ -0,0 +1 @@ +export { default } from './InstanceGroupDetails'; diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupEdit/InstanceGroupEdit.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupEdit/InstanceGroupEdit.jsx new file mode 100644 index 0000000000..2f724479ee --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupEdit/InstanceGroupEdit.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Card, PageSection } from '@patternfly/react-core'; + +function InstanceGroupEdit() { + return ( + <PageSection> + <Card> + <div>Edit instance group</div> + </Card> + </PageSection> + ); +} + +export default InstanceGroupEdit; diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupEdit/index.js b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupEdit/index.js new file mode 100644 index 0000000000..324bdabb31 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupEdit/index.js @@ -0,0 +1 @@ +export { default } from './InstanceGroupEdit'; diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx new file mode 100644 index 0000000000..b366e9eb25 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx @@ -0,0 +1,223 @@ +import React, { useEffect, useCallback } from 'react'; +import { useLocation, useRouteMatch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Card, PageSection } from '@patternfly/react-core'; + +import { InstanceGroupsAPI } from '../../../api'; +import { getQSConfig, parseQueryString } from '../../../util/qs'; +import useRequest, { useDeleteItems } from '../../../util/useRequest'; +import useSelected from '../../../util/useSelected'; +import PaginatedDataList, { + ToolbarDeleteButton, +} from '../../../components/PaginatedDataList'; +import ErrorDetail from '../../../components/ErrorDetail'; +import AlertModal from '../../../components/AlertModal'; +import DatalistToolbar from '../../../components/DataListToolbar'; +import AddDropDownButton from '../../../components/AddDropDownButton'; + +import InstanceGroupListItem from './InstanceGroupListItem'; + +const QS_CONFIG = getQSConfig('instance_group', { + page: 1, + page_size: 20, +}); + +function modifyInstanceGroups(items = []) { + return items.map(item => { + const clonedItem = { + ...item, + summary_fields: { + ...item.summary_fields, + user_capabilities: { + ...item.summary_fields.user_capabilities, + }, + }, + }; + if (clonedItem.name === 'tower') { + clonedItem.summary_fields.user_capabilities.delete = false; + } + return clonedItem; + }); +} + +function InstanceGroupList({ i18n }) { + const location = useLocation(); + const match = useRouteMatch(); + + const { + error: contentError, + isLoading, + request: fetchInstanceGroups, + result: { instanceGroups, instanceGroupsCount, actions }, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + + const [response, responseActions] = await Promise.all([ + InstanceGroupsAPI.read(params), + InstanceGroupsAPI.readOptions(), + ]); + + return { + instanceGroups: response.data.results, + instanceGroupsCount: response.data.count, + actions: responseActions.data.actions, + }; + }, [location]), + { + instanceGroups: [], + instanceGroupsCount: 0, + actions: {}, + } + ); + + useEffect(() => { + fetchInstanceGroups(); + }, [fetchInstanceGroups]); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + instanceGroups + ); + + const modifiedSelected = modifyInstanceGroups(selected); + + const { + isLoading: deleteLoading, + deletionError, + deleteItems: deleteInstanceGroups, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + await Promise.all( + selected.map(({ id }) => InstanceGroupsAPI.destroy(id)) + ); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchInstanceGroups, + } + ); + + const handleDelete = async () => { + await deleteInstanceGroups(); + setSelected([]); + }; + + const canAdd = actions && actions.POST; + + function cannotDelete(item) { + return !item.summary_fields.user_capabilities.delete; + } + + const pluralizedItemName = i18n._(t`Instance Groups`); + + let errorMessageDelete = ''; + + if (modifiedSelected.some(item => item.name === 'tower')) { + const itemsUnableToDelete = modifiedSelected + .filter(cannotDelete) + .filter(item => item.name !== 'tower') + .map(item => item.name) + .join(', '); + + if (itemsUnableToDelete) { + if (modifiedSelected.some(cannotDelete)) { + errorMessageDelete = i18n._( + t`You do not have permission to delete ${pluralizedItemName}: ${itemsUnableToDelete}. ` + ); + } + } + + if (errorMessageDelete.length > 0) { + errorMessageDelete = errorMessageDelete.concat('\n'); + } + errorMessageDelete = errorMessageDelete.concat( + i18n._(t`The tower instance group cannot be deleted.`) + ); + } + + const addButtonOptions = [ + { + label: i18n._(t`Instance group`), + url: '/instance_groups/add', + }, + { + label: i18n._(t`Container group`), + url: '/instance_groups/container_group/add', + }, + ]; + + const addButton = ( + <AddDropDownButton key="add" dropdownItems={addButtonOptions} /> + ); + + const getDetailUrl = item => { + return item.is_containerized + ? `${match.url}/container_group/${item.id}/details` + : `${match.url}/${item.id}/details`; + }; + + return ( + <> + <PageSection> + <Card> + <PaginatedDataList + contentError={contentError} + hasContentLoading={isLoading || deleteLoading} + items={instanceGroups} + itemCount={instanceGroupsCount} + pluralizedItemName={pluralizedItemName} + qsConfig={QS_CONFIG} + onRowClick={handleSelect} + renderToolbar={props => ( + <DatalistToolbar + {...props} + showSelectAll + isAllSelected={isAllSelected} + onSelectAll={isSelected => + setSelected(isSelected ? [...instanceGroups] : []) + } + qsConfig={QS_CONFIG} + additionalControls={[ + ...(canAdd ? [addButton] : []), + <ToolbarDeleteButton + key="delete" + onDelete={handleDelete} + itemsToDelete={modifiedSelected} + pluralizedItemName={i18n._(t`Instance Groups`)} + errorMessage={errorMessageDelete} + />, + ]} + /> + )} + renderItem={instanceGroup => ( + <InstanceGroupListItem + key={instanceGroup.id} + value={instanceGroup.name} + instanceGroup={instanceGroup} + detailUrl={getDetailUrl(instanceGroup)} + onSelect={() => handleSelect(instanceGroup)} + isSelected={selected.some(row => row.id === instanceGroup.id)} + /> + )} + emptyStateControls={canAdd && addButton} + /> + </Card> + </PageSection> + <AlertModal + aria-label={i18n._(t`Deletion error`)} + isOpen={deletionError} + onClose={clearDeletionError} + title={i18n._(t`Error`)} + variant="error" + > + {i18n._(t`Failed to delete one or more instance groups.`)} + <ErrorDetail error={deletionError} /> + </AlertModal> + </> + ); +} + +export default withI18n()(InstanceGroupList); diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.test.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.test.jsx new file mode 100644 index 0000000000..338dea2cbb --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.test.jsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; + +import { InstanceGroupsAPI } from '../../../api'; +import InstanceGroupList from './InstanceGroupList'; + +jest.mock('../../../api/models/InstanceGroups'); + +const instanceGroups = { + data: { + results: [ + { + id: 1, + name: 'Foo', + type: 'instance_group', + url: '/api/v2/instance_groups/1', + consumed_capacity: 10, + summary_fields: { user_capabilities: { edit: true, delete: true } }, + }, + { + id: 2, + name: 'tower', + type: 'instance_group', + url: '/api/v2/instance_groups/2', + consumed_capacity: 42, + summary_fields: { user_capabilities: { edit: true, delete: true } }, + }, + { + id: 3, + name: 'Bar', + type: 'instance_group', + url: '/api/v2/instance_groups/3', + consumed_capacity: 42, + summary_fields: { user_capabilities: { edit: true, delete: false } }, + }, + ], + count: 3, + }, +}; + +const options = { data: { actions: { POST: true } } }; + +describe('<InstanceGroupList', () => { + let wrapper; + + test('should have data fetched and render 3 rows', async () => { + InstanceGroupsAPI.read.mockResolvedValue(instanceGroups); + InstanceGroupsAPI.readOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(<InstanceGroupList />); + }); + await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); + expect(wrapper.find('InstanceGroupListItem').length).toBe(3); + expect(InstanceGroupsAPI.read).toBeCalled(); + expect(InstanceGroupsAPI.readOptions).toBeCalled(); + }); + + test('should delete item successfully', async () => { + InstanceGroupsAPI.read.mockResolvedValue(instanceGroups); + InstanceGroupsAPI.readOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(<InstanceGroupList />); + }); + await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); + + wrapper + .find('input#select-instance-groups-1') + .simulate('change', instanceGroups); + wrapper.update(); + + expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe( + true + ); + + await act(async () => { + wrapper.find('Button[aria-label="Delete"]').prop('onClick')(); + }); + wrapper.update(); + + await act(async () => + wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')() + ); + + expect(InstanceGroupsAPI.destroy).toBeCalledWith( + instanceGroups.data.results[0].id + ); + }); + + test('should not be able to delete tower instance group', async () => { + InstanceGroupsAPI.read.mockResolvedValue(instanceGroups); + InstanceGroupsAPI.readOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(<InstanceGroupList />); + }); + await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); + + const instanceGroupIndex = [1, 2, 3]; + + instanceGroupIndex.forEach(element => { + wrapper + .find(`input#select-instance-groups-${element}`) + .simulate('change', instanceGroups); + wrapper.update(); + + expect( + wrapper.find(`input#select-instance-groups-${element}`).prop('checked') + ).toBe(true); + }); + + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + true + ); + }); + + test('should thrown content error', async () => { + InstanceGroupsAPI.read.mockRejectedValue( + new Error({ + response: { + config: { + method: 'GET', + url: '/api/v2/instance_groups', + }, + data: 'An error occurred', + }, + }) + ); + InstanceGroupsAPI.readOptions.mockResolvedValue(options); + await act(async () => { + wrapper = mountWithContexts(<InstanceGroupList />); + }); + await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); + + test('should render deletion error modal', async () => { + InstanceGroupsAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'DELETE', + url: '/api/v2/instance_groups', + }, + data: 'An error occurred', + }, + }) + ); + InstanceGroupsAPI.read.mockResolvedValue(instanceGroups); + InstanceGroupsAPI.readOptions.mockResolvedValue(options); + await act(async () => { + wrapper = mountWithContexts(<InstanceGroupList />); + }); + waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); + + wrapper.find('input#select-instance-groups-1').simulate('change', 'a'); + wrapper.update(); + expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe( + true + ); + + await act(async () => + wrapper.find('Button[aria-label="Delete"]').prop('onClick')() + ); + wrapper.update(); + + await act(async () => + wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('ErrorDetail').length).toBe(1); + }); + + test('should not render add button', async () => { + InstanceGroupsAPI.read.mockResolvedValue(instanceGroups); + InstanceGroupsAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: false } }, + }); + await act(async () => { + wrapper = mountWithContexts(<InstanceGroupList />); + }); + waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); + expect(wrapper.find('ToolbarAddButton').length).toBe(0); + }); +}); + +describe('modifyInstanceGroups', () => {}); diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx new file mode 100644 index 0000000000..93e334d367 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx @@ -0,0 +1,187 @@ +import React from 'react'; +import { string, bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Link } from 'react-router-dom'; +import 'styled-components/macro'; +import { + Badge as PFBadge, + Progress, + ProgressMeasureLocation, + ProgressSize, + Button, + DataListAction as _DataListAction, + DataListCheck, + DataListItem, + DataListItemRow, + DataListItemCells, + Tooltip, +} from '@patternfly/react-core'; +import { PencilAltIcon } from '@patternfly/react-icons'; +import styled from 'styled-components'; + +import _DataListCell from '../../../components/DataListCell'; +import { InstanceGroup } from '../../../types'; + +const DataListCell = styled(_DataListCell)` + white-space: nowrap; +`; + +const Badge = styled(PFBadge)` + margin-left: 8px; +`; + +const ListGroup = styled.span` + margin-left: 12px; + + &:first-of-type { + margin-left: 0; + } +`; + +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 16px; + grid-template-columns: 40px; +`; + +function InstanceGroupListItem({ + instanceGroup, + detailUrl, + isSelected, + onSelect, + i18n, +}) { + const labelId = `check-action-${instanceGroup.id}`; + + const isAvailable = item => { + return ( + (item.policy_instance_minimum || item.policy_instance_percentage) && + item.capacity + ); + }; + + const isContainerGroup = item => { + return item.is_containerized; + }; + + function usedCapacity(item) { + if (!isContainerGroup(item)) { + if (isAvailable(item)) { + return ( + <Progress + value={100 - item.percent_capacity_remaining} + measureLocation={ProgressMeasureLocation.top} + size={ProgressSize.sm} + title={i18n._(t`Used capacity`)} + /> + ); + } + return <span css="color: red">{i18n._(t`Unavailable`)}</span>; + } + return null; + } + + return ( + <DataListItem + key={instanceGroup.id} + aria-labelledby={labelId} + id={`${instanceGroup.id} `} + > + <DataListItemRow> + <DataListCheck + id={`select-instance-groups-${instanceGroup.id}`} + checked={isSelected} + onChange={onSelect} + aria-labelledby={labelId} + /> + + <DataListItemCells + dataListCells={[ + <DataListCell + key="name" + aria-label={i18n._(t`instance group name`)} + > + <span id={labelId}> + <Link to={`${detailUrl}`}> + <b>{instanceGroup.name}</b> + </Link> + </span> + </DataListCell>, + + <DataListCell + key="type" + aria-label={i18n._(t`instance group type`)} + > + <b css="margin-right: 24px">{i18n._(t`Type`)}</b> + <span id={labelId}> + {isContainerGroup(instanceGroup) + ? i18n._(t`Container group`) + : i18n._(t`Instance group`)} + </span> + </DataListCell>, + <DataListCell + key="related-field-counts" + aria-label={i18n._(t`instance counts`)} + width={2} + > + <ListGroup> + <b>{i18n._(t`Running jobs`)}</b> + <Badge isRead>{instanceGroup.jobs_running}</Badge> + </ListGroup> + <ListGroup> + <b>{i18n._(t`Total jobs`)}</b> + <Badge isRead>{instanceGroup.jobs_total}</Badge> + </ListGroup> + + {!instanceGroup.is_containerized ? ( + <ListGroup> + <b>{i18n._(t`Instances`)}</b> + <Badge isRead>{instanceGroup.instances}</Badge> + </ListGroup> + ) : null} + </DataListCell>, + + <DataListCell + key="capacity" + aria-label={i18n._(t`instance group used capacity`)} + > + {usedCapacity(instanceGroup)} + </DataListCell>, + ]} + /> + <DataListAction + aria-label="actions" + aria-labelledby={labelId} + id={labelId} + > + {instanceGroup.summary_fields.user_capabilities.edit && ( + <Tooltip content={i18n._(t`Edit instance group`)} position="top"> + <Button + aria-label={i18n._(t`Edit instance group`)} + variant="plain" + component={Link} + to={ + isContainerGroup(instanceGroup) + ? `/instance_groups/container_group/${instanceGroup.id}/edit` + : `/instance_groups/${instanceGroup.id}/edit` + } + > + <PencilAltIcon /> + </Button> + </Tooltip> + )} + </DataListAction> + </DataListItemRow> + </DataListItem> + ); +} +InstanceGroupListItem.prototype = { + instanceGroup: InstanceGroup.isRequired, + detailUrl: string.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(InstanceGroupListItem); diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.test.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.test.jsx new file mode 100644 index 0000000000..9c819dd964 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.test.jsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import InstanceGroupListItem from './InstanceGroupListItem'; + +describe('<InstanceGroupListItem/>', () => { + let wrapper; + const instanceGroups = [ + { + id: 1, + name: 'Foo', + type: 'instance_group', + url: '/api/v2/instance_groups/1', + capacity: 10, + policy_instance_minimum: 10, + policy_instance_percentage: 50, + percent_capacity_remaining: 60, + is_containerized: false, + summary_fields: { + user_capabilities: { + edit: true, + delete: true, + }, + }, + }, + { + id: 2, + name: 'Bar', + type: 'instance_group', + url: '/api/v2/instance_groups/2', + capacity: 0, + policy_instance_minimum: 0, + policy_instance_percentage: 0, + percent_capacity_remaining: 0, + is_containerized: true, + summary_fields: { + user_capabilities: { + edit: false, + delete: false, + }, + }, + }, + ]; + + test('should mount successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + <InstanceGroupListItem + instanceGroup={instanceGroups[1]} + detailUrl="instance_groups/1/details" + isSelected={false} + onSelect={() => {}} + /> + ); + }); + expect(wrapper.find('InstanceGroupListItem').length).toBe(1); + }); + + test('should render the proper data instance group', async () => { + await act(async () => { + wrapper = mountWithContexts( + <InstanceGroupListItem + instanceGroup={instanceGroups[0]} + detailUrl="instance_groups/1/details" + isSelected={false} + onSelect={() => {}} + /> + ); + }); + expect( + wrapper.find('PFDataListCell[aria-label="instance group name"]').text() + ).toBe('Foo'); + expect(wrapper.find('Progress').prop('value')).toBe(40); + expect( + wrapper.find('PFDataListCell[aria-label="instance group type"]').text() + ).toBe('TypeInstance group'); + expect(wrapper.find('PencilAltIcon').length).toBe(1); + expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe( + false + ); + }); + + test('should render the proper data container group', async () => { + await act(async () => { + wrapper = mountWithContexts( + <InstanceGroupListItem + instanceGroup={instanceGroups[1]} + detailUrl="instance_groups/2/details" + isSelected={false} + onSelect={() => {}} + /> + ); + }); + expect( + wrapper.find('PFDataListCell[aria-label="instance group name"]').text() + ).toBe('Bar'); + + expect( + wrapper.find('PFDataListCell[aria-label="instance group type"]').text() + ).toBe('TypeContainer group'); + expect(wrapper.find('PencilAltIcon').length).toBe(0); + }); + + test('should be checked', async () => { + await act(async () => { + wrapper = mountWithContexts( + <InstanceGroupListItem + instanceGroup={instanceGroups[0]} + detailUrl="instance_groups/1/details" + isSelected + onSelect={() => {}} + /> + ); + }); + expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe( + true + ); + }); + + test('edit button shown to users with edit capabilities', async () => { + await act(async () => { + wrapper = mountWithContexts( + <InstanceGroupListItem + instanceGroup={instanceGroups[0]} + detailUrl="instance_groups/1/details" + isSelected + onSelect={() => {}} + /> + ); + }); + + expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + }); + + test('edit button hidden from users without edit capabilities', async () => { + await act(async () => { + wrapper = mountWithContexts( + <InstanceGroupListItem + instanceGroup={instanceGroups[1]} + detailsUrl="instance_group/2/details" + isSelected + onSelect={() => {}} + /> + ); + }); + + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); +}); diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/index.js b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/index.js new file mode 100644 index 0000000000..3b1a71ec1b --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/index.js @@ -0,0 +1 @@ +export { default } from './InstanceGroupList'; diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx index 9a973bccc0..4fbdd5d9b2 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx @@ -1,28 +1,79 @@ -import React, { Component, Fragment } from 'react'; +import React, { useState, useCallback } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { - PageSection, - PageSectionVariants, - Title, -} from '@patternfly/react-core'; +import { Route, Switch } from 'react-router-dom'; -class InstanceGroups extends Component { - render() { - const { i18n } = this.props; - const { light } = PageSectionVariants; +import InstanceGroupAdd from './InstanceGroupAdd'; +import InstanceGroupList from './InstanceGroupList'; +import InstanceGroup from './InstanceGroup'; - return ( - <Fragment> - <PageSection variant={light} className="pf-m-condensed"> - <Title size="2xl" headingLevel="h2"> - {i18n._(t`Instance Groups`)} - </Title> - </PageSection> - <PageSection /> - </Fragment> - ); - } +import ContainerGroupAdd from './ContainerGroupAdd'; +import ContainerGroup from './ContainerGroup'; +import Breadcrumbs from '../../components/Breadcrumbs'; + +function InstanceGroups({ i18n }) { + const [breadcrumbConfig, setBreadcrumbConfig] = useState({ + '/instance_groups': i18n._(t`Instance groups`), + '/instance_groups/add': i18n._(t`Create instance group`), + '/instance_groups/container_group/add': i18n._(t`Create container group`), + }); + + const buildBreadcrumbConfig = useCallback( + instanceGroups => { + if (!instanceGroups) { + return; + } + setBreadcrumbConfig({ + '/instance_groups': i18n._(t`Instance group`), + '/instance_groups/add': i18n._(t`Create instance group`), + '/instance_groups/container_group/add': i18n._( + t`Create container group` + ), + + [`/instance_groups/${instanceGroups.id}/details`]: i18n._(t`Details`), + [`/instance_groups/${instanceGroups.id}/instances`]: i18n._( + t`Instances` + ), + [`/instance_groups/${instanceGroups.id}/jobs`]: i18n._(t`Jobs`), + [`/instance_groups/${instanceGroups.id}/edit`]: i18n._(t`Edit details`), + [`/instance_groups/${instanceGroups.id}`]: `${instanceGroups.name}`, + + [`/instance_groups/container_group/${instanceGroups.id}/details`]: i18n._( + t`Details` + ), + [`/instance_groups/container_group/${instanceGroups.id}/jobs`]: i18n._( + t`Jobs` + ), + [`/instance_groups/container_group/${instanceGroups.id}/edit`]: i18n._( + t`Edit details` + ), + [`/instance_groups/container_group/${instanceGroups.id}`]: `${instanceGroups.name}`, + }); + }, + [i18n] + ); + return ( + <> + <Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> + <Switch> + <Route path="/instance_groups/container_group/add"> + <ContainerGroupAdd /> + </Route> + <Route path="/instance_groups/container_group/:id"> + <ContainerGroup setBreadcrumb={buildBreadcrumbConfig} /> + </Route> + <Route path="/instance_groups/add"> + <InstanceGroupAdd /> + </Route> + <Route path="/instance_groups/:id"> + <InstanceGroup setBreadcrumb={buildBreadcrumbConfig} /> + </Route> + <Route path="/instance_groups"> + <InstanceGroupList /> + </Route> + </Switch> + </> + ); } export default withI18n()(InstanceGroups); diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx index dfc33e37de..321b6ca71b 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx @@ -4,15 +4,13 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import InstanceGroups from './InstanceGroups'; -describe('<InstanceGroups />', () => { +describe('<InstanceGroups/>', () => { let pageWrapper; let pageSections; - let title; beforeEach(() => { pageWrapper = mountWithContexts(<InstanceGroups />); pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); }); afterEach(() => { @@ -21,9 +19,7 @@ describe('<InstanceGroups />', () => { test('initially renders without crashing', () => { expect(pageWrapper.length).toBe(1); - expect(pageSections.length).toBe(2); - expect(title.length).toBe(1); - expect(title.props().size).toBe('2xl'); + expect(pageSections.length).toBe(1); expect(pageSections.first().props().variant).toBe('light'); }); }); diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/Instances.jsx b/awx/ui_next/src/screens/InstanceGroup/Instances/Instances.jsx new file mode 100644 index 0000000000..b41760edd5 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/Instances.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Card, PageSection } from '@patternfly/react-core'; + +function Instances() { + return ( + <PageSection> + <Card> + <div>Instances</div> + </Card> + </PageSection> + ); +} + +export default Instances; diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/index.js b/awx/ui_next/src/screens/InstanceGroup/Instances/index.js new file mode 100644 index 0000000000..b018ebb049 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/index.js @@ -0,0 +1 @@ +export { default } from './Instances'; diff --git a/awx/ui_next/src/screens/InstanceGroup/Jobs/Jobs.jsx b/awx/ui_next/src/screens/InstanceGroup/Jobs/Jobs.jsx new file mode 100644 index 0000000000..aad6a4061d --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/Jobs/Jobs.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Card, PageSection } from '@patternfly/react-core'; + +function Jobs() { + return ( + <PageSection> + <Card> + <div>Jobs</div> + </Card> + </PageSection> + ); +} + +export default Jobs; diff --git a/awx/ui_next/src/screens/InstanceGroup/Jobs/index.js b/awx/ui_next/src/screens/InstanceGroup/Jobs/index.js new file mode 100644 index 0000000000..9fc254c85c --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/Jobs/index.js @@ -0,0 +1 @@ +export { default } from './Jobs'; diff --git a/awx/ui_next/src/screens/InstanceGroup/shared/InstanceGroupForm.jsx b/awx/ui_next/src/screens/InstanceGroup/shared/InstanceGroupForm.jsx new file mode 100644 index 0000000000..a2477d2f53 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/shared/InstanceGroupForm.jsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { func, shape } from 'prop-types'; +import { Formik } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Form } from '@patternfly/react-core'; + +import FormField, { FormSubmitError } from '../../../components/FormField'; +import FormActionGroup from '../../../components/FormActionGroup'; +import { required, minMaxValue } from '../../../util/validators'; +import { FormColumnLayout } from '../../../components/FormLayout'; + +function InstanceGroupFormFields({ i18n }) { + return ( + <> + <FormField + id="instance-group-name" + label={i18n._(t`Name`)} + name="name" + type="text" + validate={required(null, i18n)} + isRequired + /> + <FormField + id="instance-group-policy-instance-minimum" + label={i18n._(t`Policy instance minimum`)} + name="policy_instance_minimum" + type="number" + validate={minMaxValue(0, 2147483647, i18n)} + tooltip={i18n._( + t`Minimum number of instances that will be automatically + assigned to this group when new instances come online.` + )} + /> + <FormField + id="instance-group-policy-instance-percentage" + label={i18n._(t`Policy instance percentage`)} + name="policy_instance_percentage" + type="number" + tooltip={i18n._( + t`Minimum percentage of all instances that will be automatically + assigned to this group when new instances come online.` + )} + validate={minMaxValue(0, 100, i18n)} + /> + </> + ); +} + +function InstanceGroupForm({ + instanceGroup = {}, + onSubmit, + onCancel, + submitError, + ...rest +}) { + const initialValues = { + name: instanceGroup.name || '', + policy_instance_minimum: instanceGroup.policy_instance_minimum || 0, + policy_instance_percentage: instanceGroup.policy_instance_percentage || 0, + }; + return ( + <Formik initialValues={initialValues} onSubmit={values => onSubmit(values)}> + {formik => ( + <Form autoComplete="off" onSubmit={formik.handleSubmit}> + <FormColumnLayout> + <InstanceGroupFormFields {...rest} /> + {submitError && <FormSubmitError error={submitError} />} + <FormActionGroup + onCancel={onCancel} + onSubmit={formik.handleSubmit} + /> + </FormColumnLayout> + </Form> + )} + </Formik> + ); +} + +InstanceGroupForm.propTypes = { + instanceGroup: shape({}), + onCancel: func.isRequired, + onSubmit: func.isRequired, + submitError: shape({}), +}; + +InstanceGroupForm.defaultProps = { + instanceGroup: {}, + submitError: null, +}; + +export default withI18n()(InstanceGroupForm); diff --git a/awx/ui_next/src/screens/InstanceGroup/shared/InstanceGroupForm.test.jsx b/awx/ui_next/src/screens/InstanceGroup/shared/InstanceGroupForm.test.jsx new file mode 100644 index 0000000000..233ce7f849 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/shared/InstanceGroupForm.test.jsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import InstanceGroupForm from './InstanceGroupForm'; + +jest.mock('../../../api'); + +const instanceGroup = { + id: 7, + type: 'instance_group', + url: '/api/v2/instance_groups/7/', + related: { + jobs: '/api/v2/instance_groups/7/jobs/', + instances: '/api/v2/instance_groups/7/instances/', + }, + name: 'Bar', + 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, + }, + }, +}; + +describe('<InstanceGroupForm/>', () => { + let wrapper; + let onCancel; + let onSubmit; + + beforeEach(async () => { + onCancel = jest.fn(); + onSubmit = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + <InstanceGroupForm + onCancel={onCancel} + onSubmit={onSubmit} + instanceGroup={instanceGroup} + /> + ); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('Initially renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + + test('should display form fields properly', () => { + expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="Policy instance minimum"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="Policy instance percentage"]').length + ).toBe(1); + }); + + test('should call onSubmit when form submitted', async () => { + expect(onSubmit).not.toHaveBeenCalled(); + await act(async () => { + wrapper.find('button[aria-label="Save"]').simulate('click'); + }); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + test('should update form values', () => { + act(() => { + wrapper.find('input#instance-group-name').simulate('change', { + target: { value: 'Foo', name: 'name' }, + }); + wrapper + .find('input#instance-group-policy-instance-minimum') + .simulate('change', { + target: { value: 10, name: 'policy_instance_minimum' }, + }); + }); + wrapper.update(); + expect(wrapper.find('input#instance-group-name').prop('value')).toEqual( + 'Foo' + ); + expect( + wrapper.find('input#instance-group-policy-instance-minimum').prop('value') + ).toEqual(10); + expect( + wrapper + .find('input#instance-group-policy-instance-percentage') + .prop('value') + ).toEqual(46); + }); + + test('should call handleCancel when Cancel button is clicked', async () => { + expect(onCancel).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + expect(onCancel).toBeCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index 0ad93adbe9..30a46b64c3 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -105,14 +105,7 @@ function Inventories({ i18n }) { </Config> </Route> <Route path="/inventories/smart_inventory/:id"> - <Config> - {({ me }) => ( - <SmartInventory - setBreadcrumb={buildBreadcrumbConfig} - me={me || {}} - /> - )} - </Config> + <SmartInventory setBreadcrumb={buildBreadcrumbConfig} /> </Route> <Route path="/inventories"> <InventoryList /> diff --git a/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.test.jsx index fc371c7afc..e7be152653 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.test.jsx @@ -51,11 +51,13 @@ describe('<InventoryAdd />', () => { ]; await waitForElement(wrapper, 'isLoading', el => el.length === 0); - wrapper.find('InventoryForm').prop('onSubmit')({ - name: 'new Foo', - organization: { id: 2 }, - insights_credential: { id: 47 }, - instanceGroups, + await act(async () => { + wrapper.find('InventoryForm').prop('onSubmit')({ + name: 'new Foo', + organization: { id: 2 }, + insights_credential: { id: 47 }, + instanceGroups, + }); }); await sleep(1); expect(InventoriesAPI.create).toHaveBeenCalledWith({ @@ -74,7 +76,9 @@ describe('<InventoryAdd />', () => { test('handleCancel should return the user back to the inventories list', 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'); + }); expect(history.location.pathname).toEqual('/inventories'); }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.test.jsx index e2e86956bb..0bb768e895 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.test.jsx @@ -102,7 +102,9 @@ describe('<InventoryEdit />', () => { test('handleCancel returns the user to inventory 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'); + }); expect(history.location.pathname).toEqual( '/inventories/inventory/1/details' ); @@ -114,12 +116,14 @@ describe('<InventoryEdit />', () => { { name: 'Bizz', id: 2 }, { name: 'Buzz', id: 3 }, ]; - wrapper.find('InventoryForm').prop('onSubmit')({ - name: 'Foo', - id: 13, - organization: { id: 1 }, - insights_credential: { id: 13 }, - instanceGroups, + await act(async () => { + wrapper.find('InventoryForm').prop('onSubmit')({ + name: 'Foo', + id: 13, + organization: { id: 1 }, + insights_credential: { id: 13 }, + instanceGroups, + }); }); await sleep(0); instanceGroups.map(IG => diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx index 0697750773..8584c01207 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx @@ -32,7 +32,13 @@ function InventoryGroupHostList({ i18n }) { const history = useHistory(); const { - result: { hosts, hostCount, actions }, + result: { + hosts, + hostCount, + actions, + relatedSearchableKeys, + searchableKeys, + }, error: contentError, isLoading, request: fetchHosts, @@ -48,11 +54,20 @@ function InventoryGroupHostList({ i18n }) { hosts: response.data.results, hostCount: response.data.count, actions: actionsResponse.data.actions, + 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), }; }, [groupId, inventoryId, location.search]), { hosts: [], hostCount: 0, + actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -136,16 +151,16 @@ function InventoryGroupHostList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -154,6 +169,8 @@ function InventoryGroupHostList({ i18n }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( <DataListToolbar {...props} diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx index 0122fe8ae0..3f3dcbdb1c 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx @@ -1,9 +1,11 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useState, useEffect } from 'react'; import { useParams, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Button, Tooltip } from '@patternfly/react-core'; import { getQSConfig, parseQueryString } from '../../../util/qs'; +import useSelected from '../../../util/useSelected'; +import useRequest from '../../../util/useRequest'; import { InventoriesAPI, GroupsAPI } from '../../../api'; import AlertModal from '../../../components/AlertModal'; import ErrorDetail from '../../../components/ErrorDetail'; @@ -38,60 +40,44 @@ const useModal = () => { }; function InventoryGroupsList({ i18n }) { - const [actions, setActions] = useState(null); - const [contentError, setContentError] = useState(null); const [deletionError, setDeletionError] = useState(null); - const [groupCount, setGroupCount] = useState(0); - const [groups, setGroups] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [selected, setSelected] = useState([]); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const location = useLocation(); const { isModalOpen, toggleModal } = useModal(); - const { id: inventoryId } = useParams(); - const { search } = useLocation(); - const fetchGroups = (id, queryString) => { - const params = parseQueryString(QS_CONFIG, queryString); - return InventoriesAPI.readGroups(id, params); - }; - useEffect(() => { - async function fetchData() { - try { - const [ - { - data: { count, results }, - }, - { - data: { actions: optionActions }, - }, - ] = await Promise.all([ - fetchGroups(inventoryId, search), - InventoriesAPI.readGroupsOptions(inventoryId), - ]); - - setGroups(results); - setGroupCount(count); - setActions(optionActions); - } catch (error) { - setContentError(error); - } finally { - setIsLoading(false); - } + const { + result: { groups, groupCount, actions }, + error: contentError, + isLoading, + request: fetchGroups, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const [response, actionsResponse] = await Promise.all([ + InventoriesAPI.readGroups(inventoryId, params), + InventoriesAPI.readGroupsOptions(inventoryId), + ]); + return { + groups: response.data.results, + groupCount: response.data.count, + actions: actionsResponse.data.actions, + }; + }, [inventoryId, location]), + { + groups: [], + groupCount: 0, + actions: {}, } - fetchData(); - }, [inventoryId, search]); + ); - const handleSelectAll = isSelected => { - setSelected(isSelected ? [...groups] : []); - }; + useEffect(() => { + fetchGroups(); + }, [fetchGroups]); - const handleSelect = row => { - if (selected.some(s => s.id === row.id)) { - setSelected(selected.filter(s => s.id !== row.id)); - } else { - setSelected(selected.concat(row)); - } - }; + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + groups + ); const renderTooltip = () => { const itemsUnableToDelete = selected @@ -115,7 +101,7 @@ function InventoryGroupsList({ i18n }) { }; const handleDelete = async option => { - setIsLoading(true); + setIsDeleteLoading(true); try { /* eslint-disable no-await-in-loop */ @@ -135,30 +121,19 @@ function InventoryGroupsList({ i18n }) { } toggleModal(); - - try { - const { - data: { count, results }, - } = await fetchGroups(inventoryId, search); - setGroups(results); - setGroupCount(count); - } catch (error) { - setContentError(error); - } - - setIsLoading(false); + fetchGroups(); + setSelected([]); + setIsDeleteLoading(false); }; const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - const isAllSelected = - selected.length > 0 && selected.length === groups.length; return ( <> <PaginatedDataList contentError={contentError} - hasContentLoading={isLoading} + hasContentLoading={isLoading || isDeleteLoading} items={groups} itemCount={groupCount} qsConfig={QS_CONFIG} @@ -166,25 +141,25 @@ function InventoryGroupsList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { - name: i18n._(t`Group Type`), + name: i18n._(t`Group type`), key: 'parents__isnull', isBoolean: true, booleanLabels: { - true: i18n._(t`Show Only Root Groups`), - false: i18n._(t`Show All Groups`), + true: i18n._(t`Show only root groups`), + false: i18n._(t`Show all groups`), }, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -207,7 +182,9 @@ function InventoryGroupsList({ i18n }) { {...props} showSelectAll isAllSelected={isAllSelected} - onSelectAll={handleSelectAll} + onSelectAll={isSelected => + setSelected(isSelected ? [...groups] : []) + } qsConfig={QS_CONFIG} additionalControls={[ ...(canAdd diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx index ae7d60b1f7..a6a7c7ef73 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx @@ -77,11 +77,18 @@ describe('<InventoryHostDetail />', () => { describe('User has read-only permissions', () => { beforeAll(() => { - const readOnlyHost = { ...mockHost }; + const readOnlyHost = { + ...mockHost, + summary_fields: { + ...mockHost.summary_fields, + user_capabilities: { + ...mockHost.summary_fields.user_capabilities, + }, + }, + }; readOnlyHost.summary_fields.user_capabilities.edit = false; readOnlyHost.summary_fields.recent_jobs = []; - - wrapper = mountWithContexts(<InventoryHostDetail host={mockHost} />); + wrapper = mountWithContexts(<InventoryHostDetail host={readOnlyHost} />); }); afterAll(() => { diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx index 7d38451bb6..2f8d8fa1ea 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx @@ -31,7 +31,13 @@ function InventoryHostGroupsList({ i18n }) { const { search } = useLocation(); const { - result: { groups, itemCount, actions }, + result: { + groups, + itemCount, + actions, + relatedSearchableKeys, + searchableKeys, + }, error: contentError, isLoading, request: fetchGroups, @@ -53,11 +59,20 @@ function InventoryHostGroupsList({ i18n }) { groups: results, itemCount: count, actions: actionsResponse.data.actions, + 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]), // eslint-disable-line react-hooks/exhaustive-deps { groups: [], itemCount: 0, + actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -134,16 +149,16 @@ function InventoryHostGroupsList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -152,6 +167,8 @@ function InventoryHostGroupsList({ i18n }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderItem={item => ( <InventoryHostGroupItem key={item.id} diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx index dcb817fe59..e5ff1f6dd4 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx @@ -1,7 +1,6 @@ import React, { useState, useCallback, useEffect } from 'react'; import { useLocation, useRouteMatch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; - import { t } from '@lingui/macro'; import { Card, PageSection } from '@patternfly/react-core'; @@ -13,8 +12,8 @@ import ErrorDetail from '../../../components/ErrorDetail'; import PaginatedDataList, { ToolbarDeleteButton, } from '../../../components/PaginatedDataList'; - import { getQSConfig, parseQueryString } from '../../../util/qs'; +import useWsInventories from './useWsInventories'; import AddDropDownButton from '../../../components/AddDropDownButton'; import InventoryListItem from './InventoryListItem'; @@ -30,7 +29,13 @@ function InventoryList({ i18n }) { const [selected, setSelected] = useState([]); const { - result: { inventories, itemCount, actions }, + result: { + results, + itemCount, + actions, + relatedSearchableKeys, + searchableKeys, + }, error: contentError, isLoading, request: fetchInventories, @@ -42,15 +47,23 @@ function InventoryList({ i18n }) { InventoriesAPI.readOptions(), ]); return { - inventories: response.data.results, + results: response.data.results, itemCount: response.data.count, actions: actionsResponse.data.actions, + 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]), { - inventories: [], + results: [], itemCount: 0, actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -58,6 +71,17 @@ function InventoryList({ i18n }) { fetchInventories(); }, [fetchInventories]); + const fetchInventoriesById = useCallback( + async ids => { + const params = parseQueryString(QS_CONFIG, location.search); + params.id__in = ids.join(','); + const { data } = await InventoriesAPI.read(params); + return data.results; + }, + [location.search] // eslint-disable-line react-hooks/exhaustive-deps + ); + const inventories = useWsInventories(results, fetchInventoriesById); + const isAllSelected = selected.length === inventories.length && selected.length > 0; const { @@ -125,16 +149,16 @@ function InventoryList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -143,11 +167,12 @@ function InventoryList({ i18n }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( <DatalistToolbar {...props} showSelectAll - showExpandCollapse isAllSelected={isAllSelected} onSelectAll={handleSelectAll} qsConfig={QS_CONFIG} diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx index 0db85887bb..e9b256e05e 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx @@ -119,6 +119,7 @@ const mockInventories = [ ]; describe('<InventoryList />', () => { + let debug; beforeEach(() => { InventoriesAPI.read.mockResolvedValue({ data: { @@ -135,10 +136,13 @@ describe('<InventoryList />', () => { }, }, }); + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; }); afterEach(() => { jest.clearAllMocks(); + global.console.debug = debug; }); test('should load and render inventories', async () => { diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx index 9d272ace69..9070656bba 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx @@ -10,16 +10,16 @@ import { DataListItemRow, Tooltip, } from '@patternfly/react-core'; - +import { PencilAltIcon } from '@patternfly/react-icons'; import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; import styled from 'styled-components'; -import { PencilAltIcon } from '@patternfly/react-icons'; import { timeOfDay } from '../../../util/dates'; import { InventoriesAPI } from '../../../api'; import { Inventory } from '../../../types'; import DataListCell from '../../../components/DataListCell'; import CopyButton from '../../../components/CopyButton'; +import SyncStatusIndicator from '../../../components/SyncStatusIndicator'; const DataListAction = styled(_DataListAction)` align-items: center; @@ -52,6 +52,14 @@ function InventoryListItem({ }, [inventory.id, inventory.name, fetchInventories]); const labelId = `check-action-${inventory.id}`; + + let syncStatus = 'disabled'; + if (inventory.isSourceSyncRunning) { + syncStatus = 'syncing'; + } else if (inventory.has_inventory_sources) { + syncStatus = + inventory.inventory_sources_with_failures > 0 ? 'error' : 'success'; + } return ( <DataListItem key={inventory.id} @@ -67,7 +75,10 @@ function InventoryListItem({ /> <DataListItemCells dataListCells={[ - <DataListCell key="divider"> + <DataListCell key="sync-status" isIcon> + <SyncStatusIndicator status={syncStatus} /> + </DataListCell>, + <DataListCell key="name"> <Link to={`${detailUrl}`}> <b>{inventory.name}</b> </Link> diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.js b/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.js new file mode 100644 index 0000000000..eea15f9a2c --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.js @@ -0,0 +1,88 @@ +import { useState, useEffect } from 'react'; +import useWebsocket from '../../../util/useWebsocket'; +import useThrottle from '../../../util/useThrottle'; + +export default function useWsProjects( + initialInventories, + fetchInventoriesById +) { + const [inventories, setInventories] = useState(initialInventories); + const [inventoriesToFetch, setInventoriesToFetch] = useState([]); + const throttledInventoriesToFetch = useThrottle(inventoriesToFetch, 5000); + const lastMessage = useWebsocket({ + inventories: ['status_changed'], + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); + + useEffect(() => { + setInventories(initialInventories); + }, [initialInventories]); + + const enqueueId = id => { + if (!inventoriesToFetch.includes(id)) { + setInventoriesToFetch(ids => ids.concat(id)); + } + }; + useEffect( + function fetchUpdatedInventories() { + (async () => { + if (!throttledInventoriesToFetch.length) { + return; + } + setInventoriesToFetch([]); + const newInventories = await fetchInventoriesById( + throttledInventoriesToFetch + ); + const updated = [...inventories]; + newInventories.forEach(inventory => { + const index = inventories.findIndex(i => i.id === inventory.id); + if (index === -1) { + return; + } + updated[index] = inventory; + }); + setInventories(updated); + })(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [throttledInventoriesToFetch, fetchInventoriesById] + ); + + useEffect( + function processWsMessage() { + if ( + !lastMessage?.inventory_id || + lastMessage.type !== 'inventory_update' + ) { + return; + } + const index = inventories.findIndex( + p => p.id === lastMessage.inventory_id + ); + if (index === -1) { + return; + } + + if (!['pending', 'waiting', 'running'].includes(lastMessage.status)) { + enqueueId(lastMessage.inventory_id); + return; + } + + const inventory = inventories[index]; + const updatedInventory = { + ...inventory, + isSourceSyncRunning: true, + }; + setInventories([ + ...inventories.slice(0, index), + updatedInventory, + ...inventories.slice(index + 1), + ]); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps, + [lastMessage] + ); + + return inventories; +} diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.test.jsx new file mode 100644 index 0000000000..196166add6 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.test.jsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import WS from 'jest-websocket-mock'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import useWsInventories from './useWsInventories'; + +/* + Jest mock timers don’t play well with jest-websocket-mock, + so we'll stub out throttling to resolve immediately +*/ +jest.mock('../../../util/useThrottle', () => ({ + __esModule: true, + default: jest.fn(val => val), +})); + +function TestInner() { + return <div />; +} +function Test({ inventories, fetch }) { + const syncedJobs = useWsInventories(inventories, fetch); + return <TestInner inventories={syncedJobs} />; +} + +describe('useWsInventories hook', () => { + let debug; + let wrapper; + beforeEach(() => { + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; + }); + + afterEach(() => { + global.console.debug = debug; + }); + + test('should return inventories list', () => { + const inventories = [{ id: 1 }]; + wrapper = mountWithContexts(<Test inventories={inventories} />); + + expect(wrapper.find('TestInner').prop('inventories')).toEqual(inventories); + WS.clean(); + }); + + test('should establish websocket connection', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const inventories = [{ id: 1 }]; + await act(async () => { + wrapper = await mountWithContexts(<Test inventories={inventories} />); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + inventories: ['status_changed'], + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + WS.clean(); + }); + + test('should update inventory sync status', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const inventories = [{ id: 1 }]; + await act(async () => { + wrapper = await mountWithContexts(<Test inventories={inventories} />); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + inventories: ['status_changed'], + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + act(() => { + mockServer.send( + JSON.stringify({ + inventory_id: 1, + type: 'inventory_update', + status: 'running', + }) + ); + }); + wrapper.update(); + + expect( + wrapper.find('TestInner').prop('inventories')[0].isSourceSyncRunning + ).toEqual(true); + WS.clean(); + }); + + test('should fetch fresh inventory after sync runs', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + const inventories = [{ id: 1 }]; + const fetch = jest.fn(() => []); + await act(async () => { + wrapper = await mountWithContexts( + <Test inventories={inventories} fetch={fetch} /> + ); + }); + + await mockServer.connected; + await act(async () => { + mockServer.send( + JSON.stringify({ + inventory_id: 1, + type: 'inventory_update', + status: 'successful', + }) + ); + }); + + expect(fetch).toHaveBeenCalledWith([1]); + WS.clean(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx index 85958be52f..5b1d8dcaaa 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx @@ -52,7 +52,6 @@ describe('<InventorySourceAdd />', () => { ['openstack', 'OpenStack'], ['rhv', 'Red Hat Virtualization'], ['tower', 'Ansible Tower'], - ['custom', 'Custom Script'], ], }, }, diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx index 919d1ec774..42fad28421 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx @@ -50,7 +50,6 @@ function InventorySourceDetail({ inventorySource, i18n }) { modified_by, organization, source_project, - source_script, user_capabilities, }, } = inventorySource; @@ -220,10 +219,6 @@ function InventorySourceDetail({ inventorySource, i18n }) { label={i18n._(t`Inventory file`)} value={source_path === '' ? i18n._(t`/ (project root)`) : source_path} /> - <Detail - label={i18n._(t`Custom inventory script`)} - value={source_script?.name} - /> <Detail label={i18n._(t`Verbosity`)} value={VERBOSITY[verbosity]} /> <Detail label={i18n._(t`Cache timeout`)} diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx index 2b527425ff..c8905143ee 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx @@ -27,7 +27,6 @@ InventorySourcesAPI.readOptions.mockResolvedValue({ ['openstack', 'OpenStack'], ['rhv', 'Red Hat Virtualization'], ['tower', 'Ansible Tower'], - ['custom', 'Custom Script'], ], }, }, @@ -63,7 +62,6 @@ describe('InventorySourceDetail', () => { assertDetail(wrapper, 'Ansible environment', '/venv/custom'); assertDetail(wrapper, 'Project', 'Mock Project'); assertDetail(wrapper, 'Inventory file', 'foo'); - assertDetail(wrapper, 'Custom inventory script', 'Mock Script'); assertDetail(wrapper, 'Verbosity', '2 (Debug)'); assertDetail(wrapper, 'Cache timeout', '2 seconds'); expect( diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.test.jsx index c37b72c9f6..5198c523d3 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.test.jsx @@ -54,7 +54,6 @@ describe('<InventorySourceAdd />', () => { ['openstack', 'OpenStack'], ['rhv', 'Red Hat Virtualization'], ['tower', 'Ansible Tower'], - ['custom', 'Custom Script'], ], }, }, @@ -118,7 +117,7 @@ describe('<InventorySourceAdd />', () => { ); }); - test('should navigate to inventory sources list when cancel is clicked', async () => { + test('should navigate to inventory source detail when cancel is clicked', async () => { await act(async () => { wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); }); diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx index a11614e2bf..3334600117 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx @@ -19,6 +19,7 @@ import DatalistToolbar from '../../../components/DataListToolbar'; import AlertModal from '../../../components/AlertModal/AlertModal'; import ErrorDetail from '../../../components/ErrorDetail/ErrorDetail'; import InventorySourceListItem from './InventorySourceListItem'; +import useWsInventorySources from './useWsInventorySources'; const QS_CONFIG = getQSConfig('inventory', { not__source: '', @@ -34,7 +35,7 @@ function InventorySourceList({ i18n }) { const { isLoading, error: fetchError, - result: { sources, sourceCount, sourceChoices, sourceChoicesOptions }, + result: { result, sourceCount, sourceChoices, sourceChoicesOptions }, request: fetchSources, } = useRequest( useCallback(async () => { @@ -44,18 +45,21 @@ function InventorySourceList({ i18n }) { InventorySourcesAPI.readOptions(), ]); return { - sources: results[0].data.results, + result: results[0].data.results, sourceCount: results[0].data.count, sourceChoices: results[1].data.actions.GET.source.choices, sourceChoicesOptions: results[1].data.actions, }; }, [id, search]), { - sources: [], + result: [], sourceCount: 0, sourceChoices: [], } ); + + const sources = useWsInventorySources(result); + const canSyncSources = sources.length > 0 && sources.every(source => source.summary_fields.user_capabilities.start); diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx index 9ed97cfb1f..97b2d4c8e3 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx @@ -55,8 +55,11 @@ const sources = { describe('<InventorySourceList />', () => { let wrapper; let history; + let debug; beforeEach(async () => { + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; InventoriesAPI.readSources.mockResolvedValue(sources); InventorySourcesAPI.readOptions.mockResolvedValue({ data: { @@ -98,6 +101,7 @@ describe('<InventorySourceList />', () => { afterEach(() => { wrapper.unmount(); jest.clearAllMocks(); + global.console.debug = debug; }); test('should mount properly', async () => { diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.js b/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.js new file mode 100644 index 0000000000..49a9b4f387 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.js @@ -0,0 +1,48 @@ +import { useState, useEffect } from 'react'; +import useWebsocket from '../../../util/useWebsocket'; + +export default function useWsJobs(initialSources) { + const [sources, setSources] = useState(initialSources); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); + + useEffect(() => { + setSources(initialSources); + }, [initialSources]); + + useEffect( + function parseWsMessage() { + if (!lastMessage?.unified_job_id || !lastMessage?.inventory_source_id) { + return; + } + + const sourceId = lastMessage.inventory_source_id; + const index = sources.findIndex(s => s.id === sourceId); + if (index > -1) { + setSources(updateSource(sources, index, lastMessage)); + } + }, + [lastMessage] // eslint-disable-line react-hooks/exhaustive-deps + ); + + return sources; +} + +function updateSource(sources, index, message) { + const source = { + ...sources[index], + status: message.status, + last_updated: message.finished, + summary_fields: { + ...sources[index].summary_fields, + last_job: { + id: message.unified_job_id, + status: message.status, + finished: message.finished, + }, + }, + }; + return [...sources.slice(0, index), source, ...sources.slice(index + 1)]; +} diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.test.jsx new file mode 100644 index 0000000000..b0e5668624 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.test.jsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import WS from 'jest-websocket-mock'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import useWsInventorySources from './useWsInventorySources'; + +/* + Jest mock timers don’t play well with jest-websocket-mock, + so we'll stub out throttling to resolve immediately +*/ +jest.mock('../../../util/useThrottle', () => ({ + __esModule: true, + default: jest.fn(val => val), +})); + +function TestInner() { + return <div />; +} +function Test({ sources }) { + const syncedSources = useWsInventorySources(sources); + return <TestInner sources={syncedSources} />; +} + +describe('useWsInventorySources hook', () => { + let debug; + let wrapper; + beforeEach(() => { + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; + }); + + afterEach(() => { + global.console.debug = debug; + }); + + test('should return sources list', () => { + const sources = [{ id: 1 }]; + wrapper = mountWithContexts(<Test sources={sources} />); + + expect(wrapper.find('TestInner').prop('sources')).toEqual(sources); + WS.clean(); + }); + + test('should establish websocket connection', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const sources = [{ id: 1 }]; + await act(async () => { + wrapper = await mountWithContexts(<Test sources={sources} />); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + WS.clean(); + }); + + test('should update last job status', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const sources = [ + { + id: 3, + status: 'running', + summary_fields: { + last_job: { + id: 5, + status: 'running', + }, + }, + }, + ]; + await act(async () => { + wrapper = await mountWithContexts(<Test sources={sources} />); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + act(() => { + mockServer.send( + JSON.stringify({ + unified_job_id: 5, + inventory_source_id: 3, + type: 'job', + status: 'successful', + finished: 'the_time', + }) + ); + }); + wrapper.update(); + + const source = wrapper.find('TestInner').prop('sources')[0]; + expect(source).toEqual({ + id: 3, + status: 'successful', + last_updated: 'the_time', + summary_fields: { + last_job: { + id: 5, + status: 'successful', + finished: 'the_time', + }, + }, + }); + WS.clean(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventory.jsx b/awx/ui_next/src/screens/Inventory/SmartInventory.jsx index acb08661b6..18291a2959 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventory.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventory.jsx @@ -47,7 +47,7 @@ function SmartInventory({ i18n, setBreadcrumb }) { useEffect(() => { fetchInventory(); - }, [fetchInventory, location.pathname]); + }, [fetchInventory]); useEffect(() => { if (inventory) { diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx index d29aa3ee5d..3accf3d0c5 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx @@ -1,10 +1,74 @@ -import React, { Component } from 'react'; -import { PageSection } from '@patternfly/react-core'; +import React, { useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Card, PageSection } from '@patternfly/react-core'; +import { CardBody } from '../../../components/Card'; +import SmartInventoryForm from '../shared/SmartInventoryForm'; +import useRequest from '../../../util/useRequest'; +import { InventoriesAPI } from '../../../api'; -class SmartInventoryAdd extends Component { - render() { - return <PageSection>Coming soon :)</PageSection>; - } +function SmartInventoryAdd() { + const history = useHistory(); + + const { + error: submitError, + request: submitRequest, + result: inventoryId, + } = useRequest( + useCallback(async (values, groupsToAssociate) => { + const { + data: { id: invId }, + } = await InventoriesAPI.create(values); + + await Promise.all( + groupsToAssociate.map(({ id }) => + InventoriesAPI.associateInstanceGroup(invId, id) + ) + ); + return invId; + }, []) + ); + + const handleSubmit = async form => { + const { instance_groups, organization, ...remainingForm } = form; + + await submitRequest( + { + organization: organization?.id, + ...remainingForm, + }, + instance_groups + ); + }; + + const handleCancel = () => { + history.push({ + pathname: '/inventories', + search: '', + }); + }; + + useEffect(() => { + if (inventoryId) { + history.push({ + pathname: `/inventories/smart_inventory/${inventoryId}/details`, + search: '', + }); + } + }, [inventoryId, history]); + + return ( + <PageSection> + <Card> + <CardBody> + <SmartInventoryForm + onCancel={handleCancel} + onSubmit={handleSubmit} + submitError={submitError} + /> + </CardBody> + </Card> + </PageSection> + ); } export default SmartInventoryAdd; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.test.jsx new file mode 100644 index 0000000000..b25ba03559 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.test.jsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryAdd from './SmartInventoryAdd'; +import { + InventoriesAPI, + OrganizationsAPI, + InstanceGroupsAPI, +} from '../../../api'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + }), +})); + +jest.mock('../../../api/models/Inventories'); +jest.mock('../../../api/models/Organizations'); +jest.mock('../../../api/models/InstanceGroups'); +OrganizationsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } }); +InstanceGroupsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } }); + +const formData = { + name: 'Mock', + description: 'Foo', + organization: { id: 1 }, + kind: 'smart', + host_filter: 'name__icontains=mock', + variables: '---', + instance_groups: [{ id: 2 }], +}; + +describe('<SmartInventoryAdd />', () => { + describe('when initialized by users with POST capability', () => { + let history; + let wrapper; + + beforeAll(async () => { + InventoriesAPI.create.mockResolvedValueOnce({ data: { id: 1 } }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: true } }, + }); + history = createMemoryHistory({ + initialEntries: [`/inventories/smart_inventory/add`], + }); + await act(async () => { + wrapper = mountWithContexts(<SmartInventoryAdd />, { + context: { router: { history } }, + }); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should enable save button', () => { + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + false + ); + }); + + test('should post to the api when submit is clicked', async () => { + await act(async () => { + wrapper.find('SmartInventoryForm').invoke('onSubmit')(formData); + }); + const { instance_groups, ...formRequest } = formData; + expect(InventoriesAPI.create).toHaveBeenCalledTimes(1); + expect(InventoriesAPI.create).toHaveBeenCalledWith({ + ...formRequest, + organization: formRequest.organization.id, + }); + expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(1); + expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 2); + }); + + test('successful form submission should trigger redirect to details', async () => { + expect(history.location.pathname).toEqual( + '/inventories/smart_inventory/1/details' + ); + }); + + test('should navigate to inventory list when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/inventories'); + }); + + test('unsuccessful form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + InventoriesAPI.create.mockImplementationOnce(() => Promise.reject(error)); + await act(async () => { + wrapper = mountWithContexts(<SmartInventoryAdd />); + }); + expect(wrapper.find('FormSubmitError').length).toBe(0); + await act(async () => { + wrapper.find('SmartInventoryForm').invoke('onSubmit')({}); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + }); + }); + + describe('when initialized by users without POST capability', () => { + let wrapper; + + beforeAll(async () => { + InventoriesAPI.readOptions.mockResolvedValueOnce({ + data: { actions: { POST: false } }, + }); + await act(async () => { + wrapper = mountWithContexts(<SmartInventoryAdd />); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should disable save button', () => { + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + true + ); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.test.jsx index 5207972921..988c3b99a8 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.test.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.test.jsx @@ -58,7 +58,7 @@ describe('<SmartInventoryDetail />', () => { assertDetail('Description', 'smart inv description'); assertDetail('Type', 'Smart inventory'); assertDetail('Organization', 'Default'); - assertDetail('Smart host filter', 'search=local'); + assertDetail('Smart host filter', 'name__icontains=local'); assertDetail('Instance groups', 'mock instance group'); expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength( 1 diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx index 3d179fbc25..b499efd3f7 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx @@ -1,10 +1,120 @@ -import React, { Component } from 'react'; -import { PageSection } from '@patternfly/react-core'; +import React, { useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Inventory } from '../../../types'; +import { getAddedAndRemoved } from '../../../util/lists'; +import useRequest from '../../../util/useRequest'; +import { InventoriesAPI } from '../../../api'; +import { CardBody } from '../../../components/Card'; +import ContentError from '../../../components/ContentError'; +import ContentLoading from '../../../components/ContentLoading'; +import SmartInventoryForm from '../shared/SmartInventoryForm'; -class SmartInventoryEdit extends Component { - render() { - return <PageSection>Coming soon :)</PageSection>; +function SmartInventoryEdit({ inventory }) { + const history = useHistory(); + const detailsUrl = `/inventories/smart_inventory/${inventory.id}/details`; + + const { + error: contentError, + isLoading: hasContentLoading, + request: fetchInstanceGroups, + result: instanceGroups, + } = useRequest( + useCallback(async () => { + const { + data: { results }, + } = await InventoriesAPI.readInstanceGroups(inventory.id); + return results; + }, [inventory.id]), + [] + ); + + useEffect(() => { + fetchInstanceGroups(); + }, [fetchInstanceGroups]); + + const { + error: submitError, + request: submitRequest, + result: submitResult, + } = useRequest( + useCallback( + async (values, groupsToAssociate, groupsToDisassociate) => { + const { data } = await InventoriesAPI.update(inventory.id, values); + await Promise.all( + groupsToAssociate.map(id => + InventoriesAPI.associateInstanceGroup(inventory.id, id) + ) + ); + await Promise.all( + groupsToDisassociate.map(id => + InventoriesAPI.disassociateInstanceGroup(inventory.id, id) + ) + ); + return data; + }, + [inventory.id] + ) + ); + + useEffect(() => { + if (submitResult) { + history.push({ + pathname: detailsUrl, + search: '', + }); + } + }, [submitResult, detailsUrl, history]); + + const handleSubmit = async form => { + const { instance_groups, organization, ...remainingForm } = form; + + const { added, removed } = getAddedAndRemoved( + instanceGroups, + instance_groups + ); + const addedIds = added.map(({ id }) => id); + const removedIds = removed.map(({ id }) => id); + + await submitRequest( + { + organization: organization?.id, + ...remainingForm, + }, + addedIds, + removedIds + ); + }; + + const handleCancel = () => { + history.push({ + pathname: detailsUrl, + search: '', + }); + }; + + if (hasContentLoading) { + return <ContentLoading />; + } + + if (contentError) { + return <ContentError error={contentError} />; } + + return ( + <CardBody> + <SmartInventoryForm + inventory={inventory} + instanceGroups={instanceGroups} + onCancel={handleCancel} + onSubmit={handleSubmit} + submitError={submitError} + /> + </CardBody> + ); } +SmartInventoryEdit.propTypes = { + inventory: Inventory.isRequired, +}; + export default SmartInventoryEdit; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.test.jsx new file mode 100644 index 0000000000..dea1b1e1ba --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.test.jsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryEdit from './SmartInventoryEdit'; +import mockSmartInventory from '../shared/data.smart_inventory.json'; +import { + InventoriesAPI, + OrganizationsAPI, + InstanceGroupsAPI, +} from '../../../api'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 2, + }), +})); +jest.mock('../../../api/models/Inventories'); +jest.mock('../../../api/models/Organizations'); +jest.mock('../../../api/models/InstanceGroups'); +OrganizationsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } }); +InstanceGroupsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } }); + +const mockSmartInv = Object.assign( + {}, + { + ...mockSmartInventory, + organization: { + id: mockSmartInventory.organization, + }, + } +); + +describe('<SmartInventoryEdit />', () => { + let history; + let wrapper; + + beforeAll(async () => { + InventoriesAPI.associateInstanceGroup.mockResolvedValue(); + InventoriesAPI.disassociateInstanceGroup.mockResolvedValue(); + InventoriesAPI.update.mockResolvedValue({ data: mockSmartInv }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: true } }, + }); + InventoriesAPI.readInstanceGroups.mockResolvedValue({ + data: { count: 0, results: [{ id: 10 }, { id: 20 }] }, + }); + history = createMemoryHistory({ + initialEntries: [`/inventories/smart_inventory/${mockSmartInv.id}/edit`], + }); + await act(async () => { + wrapper = mountWithContexts( + <SmartInventoryEdit inventory={{ ...mockSmartInv }} />, + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should fetch related instance groups on initial render', async () => { + expect(InventoriesAPI.readInstanceGroups).toHaveBeenCalledTimes(1); + }); + + test('save button should be enabled for users with POST capability', () => { + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + false + ); + }); + + test('should post to the api when submit is clicked', async () => { + expect(InventoriesAPI.update).toHaveBeenCalledTimes(0); + expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(0); + expect(InventoriesAPI.disassociateInstanceGroup).toHaveBeenCalledTimes(0); + await act(async () => { + wrapper.find('SmartInventoryForm').invoke('onSubmit')({ + ...mockSmartInv, + instance_groups: [{ id: 10 }, { id: 30 }], + }); + }); + expect(InventoriesAPI.update).toHaveBeenCalledTimes(1); + expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(1); + expect(InventoriesAPI.disassociateInstanceGroup).toHaveBeenCalledTimes(1); + }); + + test('successful form submission should trigger redirect to details', async () => { + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(history.location.pathname).toEqual( + '/inventories/smart_inventory/2/details' + ); + }); + + test('should navigate to inventory details when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual( + '/inventories/smart_inventory/2/details' + ); + }); + + test('unsuccessful form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + InventoriesAPI.update.mockImplementationOnce(() => Promise.reject(error)); + await act(async () => { + wrapper = mountWithContexts( + <SmartInventoryEdit inventory={{ ...mockSmartInv }} /> + ); + }); + expect(wrapper.find('FormSubmitError').length).toBe(0); + await act(async () => { + wrapper.find('SmartInventoryForm').invoke('onSubmit')({}); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + }); + + test('should throw content error', async () => { + expect(wrapper.find('ContentError').length).toBe(0); + InventoriesAPI.readInstanceGroups.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + <SmartInventoryEdit inventory={{ ...mockSmartInv }} /> + ); + }); + wrapper.update(); + expect(wrapper.find('ContentError').length).toBe(1); + }); + + test('save button should be disabled for users without POST capability', async () => { + InventoriesAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: false } }, + }); + await act(async () => { + wrapper = mountWithContexts( + <SmartInventoryEdit inventory={{ ...mockSmartInv }} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + true + ); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.jsx new file mode 100644 index 0000000000..aa6290669c --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.jsx @@ -0,0 +1,120 @@ +import React, { useEffect, useCallback } from 'react'; +import { useLocation } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import DataListToolbar from '../../../components/DataListToolbar'; +import PaginatedDataList from '../../../components/PaginatedDataList'; +import SmartInventoryHostListItem from './SmartInventoryHostListItem'; +import useRequest from '../../../util/useRequest'; +import useSelected from '../../../util/useSelected'; +import { getQSConfig, parseQueryString } from '../../../util/qs'; +import { InventoriesAPI } from '../../../api'; +import { Inventory } from '../../../types'; + +const QS_CONFIG = getQSConfig('host', { + page: 1, + page_size: 20, + order_by: 'name', +}); + +function SmartInventoryHostList({ i18n, inventory }) { + const location = useLocation(); + + const { + result: { hosts, count }, + error: contentError, + isLoading, + request: fetchHosts, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const { data } = await InventoriesAPI.readHosts(inventory.id, params); + return { + hosts: data.results, + count: data.count, + }; + }, [location.search, inventory.id]), + { + hosts: [], + count: 0, + } + ); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + hosts + ); + + useEffect(() => { + fetchHosts(); + }, [fetchHosts]); + + return ( + <PaginatedDataList + contentError={contentError} + hasContentLoading={isLoading} + items={hosts} + itemCount={count} + pluralizedItemName={i18n._(t`Hosts`)} + qsConfig={QS_CONFIG} + onRowClick={handleSelect} + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Created by (username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified by (username)`), + key: 'modified_by__username', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + ]} + renderToolbar={props => ( + <DataListToolbar + {...props} + showSelectAll + isAllSelected={isAllSelected} + onSelectAll={isSelected => setSelected(isSelected ? [...hosts] : [])} + qsConfig={QS_CONFIG} + additionalControls={ + inventory?.summary_fields?.user_capabilities?.adhoc + ? [ + <Button + aria-label={i18n._(t`Run commands`)} + isDisabled={selected.length === 0} + > + {i18n._(t`Run commands`)} + </Button>, + ] + : [] + } + /> + )} + renderItem={host => ( + <SmartInventoryHostListItem + key={host.id} + host={host} + detailUrl={`/inventories/smart_inventory/${inventory.id}/hosts/${host.id}/details`} + isSelected={selected.some(row => row.id === host.id)} + onSelect={() => handleSelect(host)} + /> + )} + /> + ); +} + +SmartInventoryHostList.propTypes = { + inventory: Inventory.isRequired, +}; + +export default withI18n()(SmartInventoryHostList); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.jsx new file mode 100644 index 0000000000..ae3f00d66f --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.jsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { InventoriesAPI } from '../../../api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryHostList from './SmartInventoryHostList'; +import mockInventory from '../shared/data.inventory.json'; +import mockHosts from '../shared/data.hosts.json'; + +jest.mock('../../../api'); + +describe('<SmartInventoryHostList />', () => { + describe('User has adhoc permissions', () => { + let wrapper; + const clonedInventory = { + ...mockInventory, + summary_fields: { + ...mockInventory.summary_fields, + user_capabilities: { + ...mockInventory.summary_fields.user_capabilities, + }, + }, + }; + + beforeAll(async () => { + InventoriesAPI.readHosts.mockResolvedValue({ + data: mockHosts, + }); + await act(async () => { + wrapper = mountWithContexts( + <SmartInventoryHostList inventory={clonedInventory} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('initially renders successfully', () => { + expect(wrapper.find('SmartInventoryHostList').length).toBe(1); + }); + + test('should fetch hosts from api and render them in the list', () => { + expect(InventoriesAPI.readHosts).toHaveBeenCalled(); + expect(wrapper.find('SmartInventoryHostListItem').length).toBe(3); + }); + + test('should disable run commands button when no hosts are selected', () => { + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + const runCommandsButton = wrapper.find( + 'button[aria-label="Run commands"]' + ); + expect(runCommandsButton.length).toBe(1); + expect(runCommandsButton.prop('disabled')).toEqual(true); + }); + + test('should enable run commands button when at least one host is selected', () => { + act(() => { + wrapper.find('DataListCheck[id="select-host-2"]').invoke('onChange')( + true + ); + }); + wrapper.update(); + const runCommandsButton = wrapper.find( + 'button[aria-label="Run commands"]' + ); + expect(runCommandsButton.prop('disabled')).toEqual(false); + }); + + test('should select and deselect all items', async () => { + act(() => { + wrapper.find('DataListToolbar').invoke('onSelectAll')(true); + }); + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toEqual(true); + }); + act(() => { + wrapper.find('DataListToolbar').invoke('onSelectAll')(false); + }); + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toEqual(false); + }); + }); + + test('should show content error when api throws an error', async () => { + InventoriesAPI.readHosts.mockImplementation(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + <SmartInventoryHostList inventory={mockInventory} /> + ); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); + }); + + describe('User does not have adhoc permissions', () => { + let wrapper; + const clonedInventory = { + ...mockInventory, + summary_fields: { + user_capabilities: { + adhoc: false, + }, + }, + }; + + test('should hide run commands button', async () => { + InventoriesAPI.readHosts.mockResolvedValue({ + data: { results: [], count: 0 }, + }); + await act(async () => { + wrapper = mountWithContexts( + <SmartInventoryHostList inventory={clonedInventory} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + const runCommandsButton = wrapper.find( + 'button[aria-label="Run commands"]' + ); + expect(runCommandsButton.length).toBe(0); + jest.clearAllMocks(); + wrapper.unmount(); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.jsx new file mode 100644 index 0000000000..72fb90079b --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.jsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { string, bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t, Trans } from '@lingui/macro'; +import 'styled-components/macro'; + +import { + DataListAction, + DataListCheck, + DataListItem, + DataListItemCells, + DataListItemRow, +} from '@patternfly/react-core'; +import DataListCell from '../../../components/DataListCell'; +import HostToggle from '../../../components/HostToggle'; +import Sparkline from '../../../components/Sparkline'; +import { Host } from '../../../types'; + +function SmartInventoryHostListItem({ + i18n, + detailUrl, + host, + isSelected, + onSelect, +}) { + const recentPlaybookJobs = host.summary_fields.recent_jobs.map(job => ({ + ...job, + type: 'job', + })); + + const labelId = `check-action-${host.id}`; + + return ( + <DataListItem key={host.id} aria-labelledby={labelId} id={`${host.id}`}> + <DataListItemRow> + <DataListCheck + id={`select-host-${host.id}`} + checked={isSelected} + onChange={onSelect} + aria-labelledby={labelId} + /> + <DataListItemCells + dataListCells={[ + <DataListCell key="name"> + <Link to={`${detailUrl}`}> + <b>{host.name}</b> + </Link> + </DataListCell>, + <DataListCell key="recentJobs"> + <Sparkline jobs={recentPlaybookJobs} /> + </DataListCell>, + <DataListCell key="inventory"> + <> + <b css="margin-right: 24px">{i18n._(t`Inventory`)}</b> + <Link + to={`/inventories/inventory/${host.summary_fields.inventory.id}/details`} + > + {host.summary_fields.inventory.name} + </Link> + </> + </DataListCell>, + ]} + /> + <DataListAction + aria-label="actions" + aria-labelledby={labelId} + id={labelId} + > + <HostToggle + isDisabled + host={host} + tooltip={ + <Trans> + <b>Smart inventory hosts are read-only.</b> + <br /> + Toggle 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. + </Trans> + } + /> + </DataListAction> + </DataListItemRow> + </DataListItem> + ); +} + +SmartInventoryHostListItem.propTypes = { + detailUrl: string.isRequired, + host: Host.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(SmartInventoryHostListItem); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.test.jsx new file mode 100644 index 0000000000..a2462a831a --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.test.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryHostListItem from './SmartInventoryHostListItem'; + +const mockHost = { + id: 2, + name: 'Host Two', + url: '/api/v2/hosts/2', + inventory: 1, + summary_fields: { + inventory: { + id: 1, + name: 'Inv 1', + }, + user_capabilities: { + edit: true, + }, + recent_jobs: [], + }, +}; + +describe('<SmartInventoryHostListItem />', () => { + let wrapper; + + beforeEach(() => { + wrapper = mountWithContexts( + <SmartInventoryHostListItem + detailUrl="/inventories/smart_inventory/1/hosts/2" + host={mockHost} + isSelected={false} + onSelect={() => {}} + /> + ); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('should render expected row cells', () => { + const cells = wrapper.find('DataListCell'); + expect(cells).toHaveLength(3); + expect(cells.at(0).text()).toEqual('Host Two'); + expect(cells.at(1).find('Sparkline').length).toEqual(1); + expect(cells.at(2).text()).toContain('Inv 1'); + }); + + test('should display disabled host toggle', () => { + expect(wrapper.find('HostToggle').length).toBe(1); + expect(wrapper.find('HostToggle Switch').prop('isDisabled')).toEqual(true); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.jsx index 608e664e95..0aa24cdc59 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.jsx @@ -1,10 +1,18 @@ -import React, { Component } from 'react'; -import { CardBody } from '../../../components/Card'; +import React from 'react'; +import { Route } from 'react-router-dom'; +import SmartInventoryHostList from './SmartInventoryHostList'; +import { Inventory } from '../../../types'; -class SmartInventoryHosts extends Component { - render() { - return <CardBody>Coming soon :)</CardBody>; - } +function SmartInventoryHosts({ inventory }) { + return ( + <Route key="host-list" path="/inventories/smart_inventory/:id/hosts"> + <SmartInventoryHostList inventory={inventory} /> + </Route> + ); } +SmartInventoryHosts.propTypes = { + inventory: Inventory.isRequired, +}; + export default SmartInventoryHosts; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.test.jsx new file mode 100644 index 0000000000..8fed1ef7f7 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.test.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryHosts from './SmartInventoryHosts'; + +jest.mock('../../../api'); + +describe('<SmartInventoryHosts />', () => { + test('should render smart inventory host list', () => { + const history = createMemoryHistory({ + initialEntries: ['/inventories/smart_inventory/1/hosts'], + }); + const match = { + path: '/inventories/smart_inventory/:id/hosts', + url: '/inventories/smart_inventory/1/hosts', + isExact: true, + }; + const wrapper = mountWithContexts( + <SmartInventoryHosts inventory={{ id: 1 }} />, + { + context: { router: { history, route: { match } } }, + } + ); + expect(wrapper.find('SmartInventoryHostList').length).toBe(1); + jest.clearAllMocks(); + wrapper.unmount(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx index 73dec8877f..a694d15243 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx @@ -25,7 +25,6 @@ import { import { AzureSubForm, CloudFormsSubForm, - CustomScriptSubForm, EC2SubForm, GCESubForm, OpenStackSubForm, @@ -143,12 +142,14 @@ const InventorySourceFormFields = ({ sourceOptions, i18n }) => { <FormGroup fieldId="custom-virtualenv" label={i18n._(t`Ansible Environment`)} - > - <FieldTooltip - content={i18n._(t`Select the custom + labelIcon={ + <FieldTooltip + content={i18n._(t`Select the custom Python virtual environment for this inventory source sync to run on.`)} - /> + /> + } + > <AnsibleSelect id="custom-virtualenv" data={[ @@ -171,7 +172,6 @@ const InventorySourceFormFields = ({ sourceOptions, i18n }) => { { azure_rm: <AzureSubForm sourceOptions={sourceOptions} />, cloudforms: <CloudFormsSubForm />, - custom: <CustomScriptSubForm />, ec2: <EC2SubForm sourceOptions={sourceOptions} />, gce: <GCESubForm sourceOptions={sourceOptions} />, openstack: <OpenStackSubForm />, diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx index 2eb183f441..829a1c18a0 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx @@ -36,7 +36,6 @@ describe('<InventorySourceForm />', () => { ['openstack', 'OpenStack'], ['rhv', 'Red Hat Virtualization'], ['tower', 'Ansible Tower'], - ['custom', 'Custom Script'], ], }, }, diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CustomScriptSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CustomScriptSubForm.jsx deleted file mode 100644 index 2ea29e697f..0000000000 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CustomScriptSubForm.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import { useField } from 'formik'; -import { useParams } from 'react-router-dom'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; -import InventoryScriptLookup from '../../../../components/Lookup/InventoryScriptLookup'; -import { OptionsField, SourceVarsField, VerbosityField } from './SharedFields'; - -const CustomScriptSubForm = ({ i18n }) => { - const { id } = useParams(); - const [credentialField, , credentialHelpers] = useField('credential'); - const [scriptField, scriptMeta, scriptHelpers] = useField('source_script'); - - return ( - <> - <CredentialLookup - credentialTypeNamespace="cloud" - label={i18n._(t`Credential`)} - value={credentialField.value} - onChange={value => { - credentialHelpers.setValue(value); - }} - /> - <InventoryScriptLookup - helperTextInvalid={scriptMeta.error} - isValid={!scriptMeta.touched || !scriptMeta.error} - onBlur={() => scriptHelpers.setTouched()} - onChange={value => { - scriptHelpers.setValue(value); - }} - inventoryId={id} - value={scriptField.value} - required - /> - <VerbosityField /> - <OptionsField /> - <SourceVarsField /> - </> - ); -}; - -export default withI18n()(CustomScriptSubForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CustomScriptSubForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CustomScriptSubForm.test.jsx deleted file mode 100644 index d71d866c6d..0000000000 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CustomScriptSubForm.test.jsx +++ /dev/null @@ -1,100 +0,0 @@ -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { Formik } from 'formik'; -import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; -import CustomScriptSubForm from './CustomScriptSubForm'; -import { - CredentialsAPI, - InventoriesAPI, - InventoryScriptsAPI, -} from '../../../../api'; - -jest.mock('../../../../api/models/Credentials'); -jest.mock('../../../../api/models/Inventories'); -jest.mock('../../../../api/models/InventoryScripts'); - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ - id: 789, - }), -})); - -const initialValues = { - credential: null, - custom_virtualenv: '', - group_by: '', - instance_filters: '', - overwrite: false, - overwrite_vars: false, - source_path: '', - source_project: null, - source_regions: '', - source_script: null, - source_vars: '---\n', - update_cache_timeout: 0, - update_on_launch: true, - update_on_project_update: false, - verbosity: 1, -}; - -describe('<CustomScriptSubForm />', () => { - let wrapper; - CredentialsAPI.read.mockResolvedValue({ - data: { count: 0, results: [] }, - }); - InventoriesAPI.readDetail.mockResolvedValue({ - data: { organization: 123 }, - }); - InventoryScriptsAPI.read.mockResolvedValue({ - data: { count: 0, results: [] }, - }); - - beforeAll(async () => { - await act(async () => { - wrapper = mountWithContexts( - <Formik initialValues={initialValues}> - <CustomScriptSubForm /> - </Formik> - ); - }); - }); - - afterAll(() => { - jest.clearAllMocks(); - wrapper.unmount(); - }); - - test('should render subform fields', () => { - expect(wrapper.find('FormGroup[label="Credential"]')).toHaveLength(1); - expect(wrapper.find('FormGroup[label="Inventory script"]')).toHaveLength(1); - expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1); - expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1); - expect( - wrapper.find('FormGroup[label="Cache timeout (seconds)"]') - ).toHaveLength(1); - expect( - wrapper.find('VariablesField[label="Source variables"]') - ).toHaveLength(1); - }); - - test('should make expected api calls', () => { - expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); - expect(CredentialsAPI.read).toHaveBeenCalledWith({ - credential_type__namespace: 'cloud', - order_by: 'name', - page: 1, - page_size: 5, - }); - expect(InventoriesAPI.readDetail).toHaveBeenCalledTimes(1); - expect(InventoriesAPI.readDetail).toHaveBeenCalledWith(789); - expect(InventoryScriptsAPI.read).toHaveBeenCalledTimes(1); - expect(InventoryScriptsAPI.read).toHaveBeenCalledWith({ - organization: 123, - role_level: 'admin_role', - order_by: 'name', - page: 1, - page_size: 5, - }); - }); -}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx index 3689e305a8..b338088a8d 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx @@ -91,12 +91,14 @@ const SCMSubForm = ({ i18n }) => { } isRequired label={i18n._(t`Inventory file`)} - > - <FieldTooltip - content={i18n._(t`Select the inventory file + labelIcon={ + <FieldTooltip + content={i18n._(t`Select the inventory file to be synced by this source. You can select from the dropdown or enter a file within the input.`)} - /> + /> + } + > <AnsibleSelect {...sourcePathField} id="source_path" diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx index b4cca4a073..dcfc4b70fc 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx @@ -49,17 +49,19 @@ export const RegionsField = withI18n()(({ i18n, regionOptions }) => { helperTextInvalid={meta.error} validated="default" label={i18n._(t`Regions`)} + labelIcon={ + <FieldTooltip + content={ + <Trans> + Click on the regions field to see a list of regions for your cloud + provider. You can select multiple regions, or choose + <em> All</em> to include all regions. Only Hosts associated with + the selected regions will be updated. + </Trans> + } + /> + } > - <FieldTooltip - content={ - <Trans> - Click on the regions field to see a list of regions for your cloud - provider. You can select multiple regions, or choose - <em> All</em> to include all regions. Only Hosts associated with the - selected regions will be updated. - </Trans> - } - /> <Select variant={SelectVariant.typeaheadMulti} id="regions" @@ -139,56 +141,58 @@ export const GroupByField = withI18n()( helperTextInvalid={meta.error} validated="default" label={i18n._(t`Only group by`)} + labelIcon={ + <FieldTooltip + content={ + <Trans> + Select which groups to create automatically. AWX will create + group names similar to the following examples based on the + options selected: + <br /> + <br /> + <ul> + <li> + Availability Zone: <strong>zones » us-east-1b</strong> + </li> + <li> + Image ID: <strong>images » ami-b007ab1e</strong> + </li> + <li> + Instance ID: <strong>instances » i-ca11ab1e </strong> + </li> + <li> + Instance Type: <strong>types » type_m1_medium</strong> + </li> + <li> + Key Name: <strong>keys » key_testing</strong> + </li> + <li> + Region: <strong>regions » us-east-1</strong> + </li> + <li> + Security Group:{' '} + <strong> + security_groups » security_group_default + </strong> + </li> + <li> + Tags: <strong>tags » tag_Name_host1</strong> + </li> + <li> + VPC ID: <strong>vpcs » vpc-5ca1ab1e</strong> + </li> + <li> + Tag None: <strong>tags » tag_none</strong> + </li> + </ul> + <br /> + If blank, all groups above are created except{' '} + <em>Instance ID</em>. + </Trans> + } + /> + } > - <FieldTooltip - content={ - <Trans> - Select which groups to create automatically. AWX will create group - names similar to the following examples based on the options - selected: - <br /> - <br /> - <ul> - <li> - Availability Zone: <strong>zones » us-east-1b</strong> - </li> - <li> - Image ID: <strong>images » ami-b007ab1e</strong> - </li> - <li> - Instance ID: <strong>instances » i-ca11ab1e </strong> - </li> - <li> - Instance Type: <strong>types » type_m1_medium</strong> - </li> - <li> - Key Name: <strong>keys » key_testing</strong> - </li> - <li> - Region: <strong>regions » us-east-1</strong> - </li> - <li> - Security Group:{' '} - <strong> - security_groups » security_group_default - </strong> - </li> - <li> - Tags: <strong>tags » tag_Name_host1</strong> - </li> - <li> - VPC ID: <strong>vpcs » vpc-5ca1ab1e</strong> - </li> - <li> - Tag None: <strong>tags » tag_none</strong> - </li> - </ul> - <br /> - If blank, all groups above are created except <em>Instance ID</em> - . - </Trans> - } - /> <Select variant={SelectVariant.typeaheadMulti} id="group-by" @@ -231,11 +235,13 @@ export const VerbosityField = withI18n()(({ i18n }) => { fieldId="verbosity" validated={isValid ? 'default' : 'error'} label={i18n._(t`Verbosity`)} - > - <FieldTooltip - content={i18n._(t`Control the level of output Ansible + labelIcon={ + <FieldTooltip + content={i18n._(t`Control the level of output Ansible will produce for inventory source update jobs.`)} - /> + /> + } + > <AnsibleSelect id="verbosity" data={options} diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js index 936d646673..324d797d56 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js @@ -1,6 +1,5 @@ export { default as AzureSubForm } from './AzureSubForm'; export { default as CloudFormsSubForm } from './CloudFormsSubForm'; -export { default as CustomScriptSubForm } from './CustomScriptSubForm'; export { default as EC2SubForm } from './EC2SubForm'; export { default as GCESubForm } from './GCESubForm'; export { default as OpenStackSubForm } from './OpenStackSubForm'; diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx new file mode 100644 index 0000000000..12cd3ee215 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx @@ -0,0 +1,175 @@ +import React, { useEffect, useCallback } from 'react'; +import { Formik, useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { func, shape, object, arrayOf } from 'prop-types'; +import { Form } from '@patternfly/react-core'; +import { VariablesField } from '../../../components/CodeMirrorInput'; +import ContentError from '../../../components/ContentError'; +import ContentLoading from '../../../components/ContentLoading'; +import FormActionGroup from '../../../components/FormActionGroup'; +import FormField, { FormSubmitError } from '../../../components/FormField'; +import { + FormColumnLayout, + FormFullWidthLayout, +} from '../../../components/FormLayout'; +import HostFilterLookup from '../../../components/Lookup/HostFilterLookup'; +import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup'; +import OrganizationLookup from '../../../components/Lookup/OrganizationLookup'; +import useRequest from '../../../util/useRequest'; +import { required } from '../../../util/validators'; +import { InventoriesAPI } from '../../../api'; + +const SmartInventoryFormFields = withI18n()(({ i18n }) => { + const [organizationField, organizationMeta, organizationHelpers] = useField({ + name: 'organization', + validate: required(i18n._(t`Select a value for this field`), i18n), + }); + const [instanceGroupsField, , instanceGroupsHelpers] = useField({ + name: 'instance_groups', + }); + const [hostFilterField, hostFilterMeta, hostFilterHelpers] = useField({ + name: 'host_filter', + validate: required(null, i18n), + }); + + return ( + <> + <FormField + id="name" + label={i18n._(t`Name`)} + name="name" + type="text" + validate={required(null, i18n)} + isRequired + /> + <FormField + id="description" + label={i18n._(t`Description`)} + name="description" + type="text" + /> + <OrganizationLookup + helperTextInvalid={organizationMeta.error} + isValid={!organizationMeta.touched || !organizationMeta.error} + onBlur={() => organizationHelpers.setTouched()} + onChange={value => { + organizationHelpers.setValue(value); + }} + value={organizationField.value} + required + /> + <HostFilterLookup + value={hostFilterField.value} + organizationId={organizationField.value?.id} + helperTextInvalid={hostFilterMeta.error} + onChange={value => { + hostFilterHelpers.setValue(value); + }} + onBlur={() => hostFilterHelpers.setTouched()} + isValid={!hostFilterMeta.touched || !hostFilterMeta.error} + isDisabled={!organizationField.value} + /> + <InstanceGroupsLookup + value={instanceGroupsField.value} + onChange={value => { + instanceGroupsHelpers.setValue(value); + }} + /> + <FormFullWidthLayout> + <VariablesField + id="variables" + name="variables" + label={i18n._(t`Variables`)} + tooltip={i18n._( + t`Enter inventory variables using either JSON or YAML syntax. + Use the radio button to toggle between the two. Refer to the + Ansible Tower documentation for example syntax.` + )} + /> + </FormFullWidthLayout> + </> + ); +}); + +function SmartInventoryForm({ + inventory, + instanceGroups, + onSubmit, + onCancel, + submitError, +}) { + const initialValues = { + description: inventory.description || '', + host_filter: inventory.host_filter || '', + instance_groups: instanceGroups || [], + kind: 'smart', + name: inventory.name || '', + organization: inventory.summary_fields?.organization || null, + variables: inventory.variables || '---', + }; + + const { + isLoading, + error: optionsError, + request: fetchOptions, + result: options, + } = useRequest( + useCallback(async () => { + const { data } = await InventoriesAPI.readOptions(); + return data; + }, []), + null + ); + + useEffect(() => { + fetchOptions(); + }, [fetchOptions]); + + if (isLoading) { + return <ContentLoading />; + } + + if (optionsError) { + return <ContentError error={optionsError} />; + } + + return ( + <Formik + initialValues={initialValues} + onSubmit={values => { + onSubmit(values); + }} + > + {formik => ( + <Form autoComplete="off" onSubmit={formik.handleSubmit}> + <FormColumnLayout> + <SmartInventoryFormFields /> + {submitError && <FormSubmitError error={submitError} />} + <FormActionGroup + onCancel={onCancel} + onSubmit={formik.handleSubmit} + submitDisabled={!options?.actions?.POST} + /> + </FormColumnLayout> + </Form> + )} + </Formik> + ); +} + +SmartInventoryForm.propTypes = { + instanceGroups: arrayOf(object), + inventory: shape({}), + onCancel: func.isRequired, + onSubmit: func.isRequired, + submitError: shape({}), +}; + +SmartInventoryForm.defaultProps = { + instanceGroups: [], + inventory: {}, + submitError: null, +}; + +export default withI18n()(SmartInventoryForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx new file mode 100644 index 0000000000..34d7fb72c7 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryForm from './SmartInventoryForm'; +import { + InventoriesAPI, + OrganizationsAPI, + InstanceGroupsAPI, +} from '../../../api'; + +jest.mock('../../../api/models/Inventories'); +jest.mock('../../../api/models/Organizations'); +jest.mock('../../../api/models/InstanceGroups'); +OrganizationsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } }); +InstanceGroupsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } }); +InventoriesAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: true } }, +}); + +const mockFormValues = { + kind: 'smart', + name: 'new smart inventory', + description: '', + organization: { id: 1, name: 'mock organization' }, + host_filter: + 'name__icontains=mock and name__icontains=foo and groups__name=mock group', + instance_groups: [{ id: 123 }], + variables: '---', +}; + +describe('<SmartInventoryForm />', () => { + describe('when initialized by users with POST capability', () => { + let wrapper; + const onSubmit = jest.fn(); + + beforeAll(async () => { + await act(async () => { + wrapper = mountWithContexts( + <SmartInventoryForm onCancel={() => {}} onSubmit={onSubmit} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should enable save button', () => { + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + false + ); + }); + + test('should show expected form fields', () => { + expect(wrapper.find('FormGroup[label="Name"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Description"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Organization"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Host filter"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Instance Groups"]')).toHaveLength( + 1 + ); + expect(wrapper.find('VariablesField[label="Variables"]')).toHaveLength(1); + expect(wrapper.find('Button[aria-label="Save"]')).toHaveLength(1); + expect(wrapper.find('Button[aria-label="Cancel"]')).toHaveLength(1); + }); + + test('should enable host filter field when organization field has a value', async () => { + expect(wrapper.find('HostFilterLookup').prop('isDisabled')).toBe(true); + await act(async () => { + wrapper.find('OrganizationLookup').invoke('onBlur')(); + wrapper.find('OrganizationLookup').invoke('onChange')( + mockFormValues.organization + ); + }); + wrapper.update(); + expect(wrapper.find('HostFilterLookup').prop('isDisabled')).toBe(false); + }); + + test('should show error when form is saved without a host filter value', async () => { + expect(wrapper.find('HostFilterLookup #host-filter-helper').length).toBe( + 0 + ); + wrapper.find('input#name').simulate('change', { + target: { value: mockFormValues.name, name: 'name' }, + }); + await act(async () => { + wrapper.find('button[aria-label="Save"]').simulate('click'); + }); + wrapper.update(); + const hostFilterError = wrapper.find( + 'HostFilterLookup #host-filter-helper' + ); + expect(hostFilterError.length).toBe(1); + expect(hostFilterError.text()).toContain('This field must not be blank'); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + test('should display filter chips when host filter has a value', async () => { + await act(async () => { + wrapper.find('HostFilterLookup').invoke('onBlur')(); + wrapper.find('HostFilterLookup').invoke('onChange')( + mockFormValues.host_filter + ); + }); + wrapper.update(); + const nameChipGroup = wrapper.find( + 'HostFilterLookup ChipGroup[categoryName="Name"]' + ); + const groupChipGroup = wrapper.find( + 'HostFilterLookup ChipGroup[categoryName="Group"]' + ); + expect(nameChipGroup.find('Chip').length).toBe(2); + expect(groupChipGroup.find('Chip').length).toBe(1); + }); + + test('should submit expected form values on save', async () => { + await act(async () => { + wrapper.find('InstanceGroupsLookup').invoke('onChange')( + mockFormValues.instance_groups + ); + }); + wrapper.update(); + await act(async () => { + wrapper.find('button[aria-label="Save"]').simulate('click'); + }); + wrapper.update(); + expect(onSubmit).toHaveBeenCalledWith(mockFormValues); + }); + }); + + test('should throw content error when option request fails', async () => { + let wrapper; + InventoriesAPI.readOptions.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + <SmartInventoryForm onCancel={() => {}} onSubmit={() => {}} /> + ); + }); + expect(wrapper.find('ContentError').length).toBe(0); + wrapper.update(); + expect(wrapper.find('ContentError').length).toBe(1); + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('should throw content error when option request fails', async () => { + let wrapper; + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + await act(async () => { + wrapper = mountWithContexts( + <SmartInventoryForm + submitError={error} + onCancel={() => {}} + onSubmit={() => {}} + /> + ); + }); + expect(wrapper.find('FormSubmitError').length).toBe(1); + expect(wrapper.find('SmartInventoryForm').prop('submitError')).toEqual( + error + ); + wrapper.unmount(); + jest.clearAllMocks(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/data.smart_inventory.json b/awx/ui_next/src/screens/Inventory/shared/data.smart_inventory.json index 0ab15565f6..204f616b7e 100644 --- a/awx/ui_next/src/screens/Inventory/shared/data.smart_inventory.json +++ b/awx/ui_next/src/screens/Inventory/shared/data.smart_inventory.json @@ -80,7 +80,7 @@ "description": "smart inv description", "organization": 1, "kind": "smart", - "host_filter": "search=local", + "host_filter": "name__icontains=local", "variables": "", "has_active_failures": false, "total_hosts": 1, diff --git a/awx/ui_next/src/screens/InventoryScript/InventoryScripts.jsx b/awx/ui_next/src/screens/InventoryScript/InventoryScripts.jsx deleted file mode 100644 index d45f660fa8..0000000000 --- a/awx/ui_next/src/screens/InventoryScript/InventoryScripts.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { - PageSection, - PageSectionVariants, - Title, -} from '@patternfly/react-core'; - -class InventoryScripts extends Component { - render() { - const { i18n } = this.props; - const { light } = PageSectionVariants; - - return ( - <Fragment> - <PageSection variant={light} className="pf-m-condensed"> - <Title size="2xl" headingLevel="h2"> - {i18n._(t`Inventory Scripts`)} - </Title> - </PageSection> - <PageSection /> - </Fragment> - ); - } -} - -export default withI18n()(InventoryScripts); diff --git a/awx/ui_next/src/screens/InventoryScript/InventoryScripts.test.jsx b/awx/ui_next/src/screens/InventoryScript/InventoryScripts.test.jsx deleted file mode 100644 index 2c981361bb..0000000000 --- a/awx/ui_next/src/screens/InventoryScript/InventoryScripts.test.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; - -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; - -import InventoryScripts from './InventoryScripts'; - -describe('<InventoryScripts />', () => { - let pageWrapper; - let pageSections; - let title; - - beforeEach(() => { - pageWrapper = mountWithContexts(<InventoryScripts />); - pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); - }); - - afterEach(() => { - pageWrapper.unmount(); - }); - - test('initially renders without crashing', () => { - expect(pageWrapper.length).toBe(1); - expect(pageSections.length).toBe(2); - expect(title.length).toBe(1); - expect(title.props().size).toBe('2xl'); - expect(pageSections.first().props().variant).toBe('light'); - }); -}); diff --git a/awx/ui_next/src/screens/InventoryScript/index.js b/awx/ui_next/src/screens/InventoryScript/index.js deleted file mode 100644 index 9effeb748e..0000000000 --- a/awx/ui_next/src/screens/InventoryScript/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './InventoryScripts'; diff --git a/awx/ui_next/src/screens/Job/JobOutput/shared/HostStatusBar.test.jsx b/awx/ui_next/src/screens/Job/JobOutput/shared/HostStatusBar.test.jsx index 5174501386..7e4237ba60 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/shared/HostStatusBar.test.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/shared/HostStatusBar.test.jsx @@ -45,7 +45,7 @@ describe('<HostStatusBar />', () => { test('empty host counts should display tooltip and one bar segment', () => { wrapper = mountWithContexts(<HostStatusBar />); expect(wrapper.find('BarSegment').length).toBe(1); - expect(wrapper.find('TooltipContent').text()).toEqual( + expect(wrapper.find('Tooltip').prop('content')).toEqual( 'Host status information for this job is unavailable.' ); }); diff --git a/awx/ui_next/src/screens/JobsSetting/JobsSettings.jsx b/awx/ui_next/src/screens/JobsSetting/JobsSettings.jsx deleted file mode 100644 index f5fb77ef50..0000000000 --- a/awx/ui_next/src/screens/JobsSetting/JobsSettings.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { - PageSection, - PageSectionVariants, - Title, -} from '@patternfly/react-core'; - -class JobsSettings extends Component { - render() { - const { i18n } = this.props; - const { light } = PageSectionVariants; - - return ( - <Fragment> - <PageSection variant={light} className="pf-m-condensed"> - <Title size="2xl" headingLevel="h2"> - {i18n._(t`Jobs Settings`)} - </Title> - </PageSection> - <PageSection /> - </Fragment> - ); - } -} - -export default withI18n()(JobsSettings); diff --git a/awx/ui_next/src/screens/JobsSetting/JobsSettings.test.jsx b/awx/ui_next/src/screens/JobsSetting/JobsSettings.test.jsx deleted file mode 100644 index c57567c5c4..0000000000 --- a/awx/ui_next/src/screens/JobsSetting/JobsSettings.test.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; - -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; - -import JobsSettings from './JobsSettings'; - -describe('<JobsSettings />', () => { - let pageWrapper; - let pageSections; - let title; - - beforeEach(() => { - pageWrapper = mountWithContexts(<JobsSettings />); - pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); - }); - - afterEach(() => { - pageWrapper.unmount(); - }); - - test('initially renders without crashing', () => { - expect(pageWrapper.length).toBe(1); - expect(pageSections.length).toBe(2); - expect(title.length).toBe(1); - expect(title.props().size).toBe('2xl'); - expect(pageSections.first().props().variant).toBe('light'); - }); -}); diff --git a/awx/ui_next/src/screens/JobsSetting/index.js b/awx/ui_next/src/screens/JobsSetting/index.js deleted file mode 100644 index 376300927a..0000000000 --- a/awx/ui_next/src/screens/JobsSetting/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './JobsSettings'; diff --git a/awx/ui_next/src/screens/License/License.jsx b/awx/ui_next/src/screens/License/License.jsx deleted file mode 100644 index 1ec59d2930..0000000000 --- a/awx/ui_next/src/screens/License/License.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { - PageSection, - PageSectionVariants, - Title, -} from '@patternfly/react-core'; - -class License extends Component { - render() { - const { i18n } = this.props; - const { light } = PageSectionVariants; - - return ( - <Fragment> - <PageSection variant={light} className="pf-m-condensed"> - <Title size="2xl" headingLevel="h2"> - {i18n._(t`License`)} - </Title> - </PageSection> - <PageSection /> - </Fragment> - ); - } -} - -export default withI18n()(License); diff --git a/awx/ui_next/src/screens/License/License.test.jsx b/awx/ui_next/src/screens/License/License.test.jsx deleted file mode 100644 index 58e3cbfa90..0000000000 --- a/awx/ui_next/src/screens/License/License.test.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; - -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; - -import License from './License'; - -describe('<License />', () => { - let pageWrapper; - let pageSections; - let title; - - beforeEach(() => { - pageWrapper = mountWithContexts(<License />); - pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); - }); - - afterEach(() => { - pageWrapper.unmount(); - }); - - test('initially renders without crashing', () => { - expect(pageWrapper.length).toBe(1); - expect(pageSections.length).toBe(2); - expect(title.length).toBe(1); - expect(title.props().size).toBe('2xl'); - expect(pageSections.first().props().variant).toBe('light'); - }); -}); diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplate.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplate.jsx new file mode 100644 index 0000000000..7b8516f787 --- /dev/null +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplate.jsx @@ -0,0 +1,113 @@ +import React, { useEffect, useCallback } from 'react'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; +import { Card, PageSection } from '@patternfly/react-core'; +import { CaretLeftIcon } from '@patternfly/react-icons'; +import { + Link, + Switch, + Route, + Redirect, + useParams, + useRouteMatch, + useLocation, +} from 'react-router-dom'; +import useRequest from '../../util/useRequest'; +import RoutedTabs from '../../components/RoutedTabs'; +import ContentError from '../../components/ContentError'; +import { NotificationTemplatesAPI } from '../../api'; +import NotificationTemplateDetail from './NotificationTemplateDetail'; +import NotificationTemplateEdit from './NotificationTemplateEdit'; + +function NotificationTemplate({ setBreadcrumb, i18n }) { + const { id: templateId } = useParams(); + const match = useRouteMatch(); + const location = useLocation(); + const { + result: template, + isLoading, + error, + request: fetchTemplate, + } = useRequest( + useCallback(async () => { + const { data } = await NotificationTemplatesAPI.readDetail(templateId); + setBreadcrumb(data); + return data; + }, [templateId, setBreadcrumb]), + null + ); + + useEffect(() => { + fetchTemplate(); + }, [fetchTemplate]); + + if (error) { + return ( + <PageSection> + <Card> + <ContentError error={error}> + {error.response.status === 404 && ( + <span> + {i18n._(t`Notification Template not found.`)}{' '} + <Link to="/notification_templates"> + {i18n._(t`View all Notification Templates.`)} + </Link> + </span> + )} + </ContentError> + </Card> + </PageSection> + ); + } + + const showCardHeader = !isLoading && !location.pathname.endsWith('edit'); + const tabs = [ + { + name: ( + <> + <CaretLeftIcon /> + {i18n._(t`Back to Notifications`)} + </> + ), + link: `/notification_templates`, + id: 99, + }, + { + name: i18n._(t`Details`), + link: `${match.url}/details`, + id: 0, + }, + ]; + return ( + <PageSection> + <Card> + {showCardHeader && <RoutedTabs tabsArray={tabs} />} + <Switch> + <Redirect + from="/notification_templates/:id" + to="/notification_templates/:id/details" + exact + /> + {template && ( + <> + <Route path="/notification_templates/:id/edit"> + <NotificationTemplateEdit + template={template} + isLoading={isLoading} + /> + </Route> + <Route path="/notification_templates/:id/details"> + <NotificationTemplateDetail + template={template} + isLoading={isLoading} + /> + </Route> + </> + )} + </Switch> + </Card> + </PageSection> + ); +} + +export default withI18n()(NotificationTemplate); diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateAdd.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateAdd.jsx new file mode 100644 index 0000000000..bbf39b61a9 --- /dev/null +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateAdd.jsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function NotificationTemplateAdd() { + return <div />; +} diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx new file mode 100644 index 0000000000..951ba5bd8b --- /dev/null +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx @@ -0,0 +1,361 @@ +import React, { useCallback } from 'react'; +import { Link, useHistory } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { Button } from '@patternfly/react-core'; +import { t } from '@lingui/macro'; +import AlertModal from '../../../components/AlertModal'; +import { CardBody, CardActionsRow } from '../../../components/Card'; +import { + Detail, + DetailList, + DeletedDetail, +} from '../../../components/DetailList'; +import ObjectDetail from '../../../components/DetailList/ObjectDetail'; +import DeleteButton from '../../../components/DeleteButton'; +import ErrorDetail from '../../../components/ErrorDetail'; +import { NotificationTemplatesAPI } from '../../../api'; +import useRequest, { useDismissableError } from '../../../util/useRequest'; +import { NOTIFICATION_TYPES } from '../constants'; + +function NotificationTemplateDetail({ i18n, template }) { + const history = useHistory(); + + const { + notification_configuration: configuration, + summary_fields, + } = template; + + const { request: deleteTemplate, isLoading, error: deleteError } = useRequest( + useCallback(async () => { + await NotificationTemplatesAPI.destroy(template.id); + history.push(`/notification_templates`); + }, [template.id, history]) + ); + + const { error, dismissError } = useDismissableError(deleteError); + + return ( + <CardBody> + <DetailList gutter="sm"> + <Detail + label={i18n._(t`Name`)} + value={template.name} + dataCy="nt-detail-name" + /> + <Detail + label={i18n._(t`Description`)} + value={template.description} + dataCy="nt-detail-description" + /> + {summary_fields.organization ? ( + <Detail + label={i18n._(t`Organization`)} + value={ + <Link + to={`/organizations/${summary_fields.organization.id}/details`} + > + {summary_fields.organization.name} + </Link> + } + /> + ) : ( + <DeletedDetail label={i18n._(t`Organization`)} /> + )} + <Detail + label={i18n._(t`Notification Type`)} + value={ + NOTIFICATION_TYPES[template.notification_type] || + template.notification_type + } + dataCy="nt-detail-type" + /> + {template.notification_type === 'email' && ( + <> + <Detail + label={i18n._(t`Username`)} + value={configuration.username} + dataCy="nt-detail-username" + /> + <Detail + label={i18n._(t`Host`)} + value={configuration.host} + dataCy="nt-detail-host" + /> + <Detail + label={i18n._(t`Recipient List`)} + value={configuration.recipients} // array + dataCy="nt-detail-recipients" + /> + <Detail + label={i18n._(t`Sender Email`)} + value={configuration.sender} + dataCy="nt-detail-sender" + /> + <Detail + label={i18n._(t`Port`)} + value={configuration.port} + dataCy="nt-detail-port" + /> + <Detail + label={i18n._(t`Timeout`)} + value={configuration.timeout} + dataCy="nt-detail-timeout" + /> + <Detail + label={i18n._(t`Email Options`)} + value={ + configuration.use_ssl ? i18n._(t`Use SSL`) : i18n._(t`Use TLS`) + } + dataCy="nt-detail-email-options" + /> + </> + )} + {template.notification_type === 'grafana' && ( + <> + <Detail + label={i18n._(t`Grafana URL`)} + value={configuration.grafana_url} + dataCy="nt-detail-grafana-url" + /> + <Detail + label={i18n._(t`ID of the Dashboard`)} + value={configuration.dashboardId} + dataCy="nt-detail-dashboard-id" + /> + <Detail + label={i18n._(t`ID of the Panel`)} + value={configuration.panelId} + dataCy="nt-detail-panel-id" + /> + <Detail + label={i18n._(t`Tags for the Annotation`)} + value={configuration.annotation_tags} // array + dataCy="nt-detail-" + /> + <Detail + label={i18n._(t`Disable SSL Verification`)} + value={ + configuration.grafana_no_verify_ssl + ? i18n._(t`True`) + : i18n._(t`False`) + } + dataCy="nt-detail-disable-ssl" + /> + </> + )} + {template.notification_type === 'irc' && ( + <> + <Detail + label={i18n._(t`IRC Server Port`)} + value={configuration.port} + dataCy="nt-detail-irc-port" + /> + <Detail + label={i18n._(t`IRC Server Address`)} + value={configuration.server} + dataCy="nt-detail-irc-server" + /> + <Detail + label={i18n._(t`IRC Nick`)} + value={configuration.nickname} + dataCy="nt-detail-irc-nickname" + /> + <Detail + label={i18n._(t`Destination Channels or Users`)} + value={configuration.targets} // array + dataCy="nt-detail-channels" + /> + <Detail + label={i18n._(t`SSL Connection`)} + value={configuration.use_ssl ? i18n._(t`True`) : i18n._(t`False`)} + dataCy="nt-detail-irc-ssl" + /> + </> + )} + {template.notification_type === 'mattermost' && ( + <> + <Detail + label={i18n._(t`Target URL`)} + value={configuration.mattermost_url} + dataCy="nt-detail-mattermost-url" + /> + <Detail + label={i18n._(t`Username`)} + value={configuration.mattermost_username} + dataCy="nt-detail-mattermost-username" + /> + <Detail + label={i18n._(t`Channel`)} + value={configuration.mattermost_channel} + dataCy="nt-detail-mattermost_channel" + /> + <Detail + label={i18n._(t`Icon URL`)} + value={configuration.mattermost_icon_url} + dataCy="nt-detail-mattermost-icon-url" + /> + <Detail + label={i18n._(t`Disable SSL Verification`)} + value={ + configuration.mattermost_no_verify_ssl + ? i18n._(t`True`) + : i18n._(t`False`) + } + dataCy="nt-detail-disable-ssl" + /> + </> + )} + {template.notification_type === 'pagerduty' && ( + <> + <Detail + label={i18n._(t`Pagerduty Subdomain`)} + value={configuration.subdomain} + dataCy="nt-detail-pagerduty-subdomain" + /> + <Detail + label={i18n._(t`API Service/Integration Key`)} + value={configuration.service_key} + dataCy="nt-detail-pagerduty-service-key" + /> + <Detail + label={i18n._(t`Client Identifier`)} + value={configuration.client_name} + dataCy="nt-detail-pagerduty-client-name" + /> + </> + )} + {template.notification_type === 'rocketchat' && ( + <> + <Detail + label={i18n._(t`Target URL`)} + value={configuration.rocketchat_url} + dataCy="nt-detail-rocketchat-url" + /> + <Detail + label={i18n._(t`Username`)} + value={configuration.rocketchat_username} + dataCy="nt-detail-pagerduty-rocketchat-username" + /> + <Detail + label={i18n._(t`Icon URL`)} + value={configuration.rocketchat_icon_url} + dataCy="nt-detail-rocketchat-icon-url" + /> + <Detail + label={i18n._(t`Disable SSL Verification`)} + value={ + configuration.rocketchat_no_verify_ssl + ? i18n._(t`True`) + : i18n._(t`False`) + } + dataCy="nt-detail-disable-ssl" + /> + </> + )} + {template.notification_type === 'slack' && ( + <> + <Detail + label={i18n._(t`Destination Channels`)} + value={configuration.channels} // array + dataCy="nt-detail-slack-channels" + /> + <Detail + label={i18n._(t`Notification Color`)} + value={configuration.hex_color} + dataCy="nt-detail-slack-color" + /> + </> + )} + {template.notification_type === 'twilio' && ( + <> + <Detail + label={i18n._(t`Source Phone Number`)} + value={configuration.from_number} + dataCy="nt-detail-twilio-source-phone" + /> + <Detail + label={i18n._(t`Destination SMS Number`)} + value={configuration.to_numbers} // array + dataCy="nt-detail-twilio-destination-numbers" + /> + <Detail + label={i18n._(t`Account SID`)} + value={configuration.account_sid} + dataCy="nt-detail-twilio-account-sid" + /> + </> + )} + {template.notification_type === 'webhook' && ( + <> + <Detail + label={i18n._(t`Username`)} + value={configuration.username} + dataCy="nt-detail-webhook-password" + /> + <Detail + label={i18n._(t`Target URL`)} + value={configuration.url} + dataCy="nt-detail-webhook-url" + /> + <Detail + label={i18n._(t`Disable SSL Verification`)} + value={ + configuration.disable_ssl_verification + ? i18n._(t`True`) + : i18n._(t`False`) + } + dataCy="nt-detail-disable-ssl" + /> + <Detail + label={i18n._(t`HTTP Method`)} + value={configuration.http_method} + dataCy="nt-detail-webhook-http-method" + /> + <ObjectDetail + label={i18n._(t`HTTP Headers`)} + value={configuration.headers} + rows="6" + dataCy="nt-detail-webhook-headers" + /> + </> + )} + </DetailList> + <CardActionsRow> + {summary_fields.user_capabilities && + summary_fields.user_capabilities.edit && ( + <Button + component={Link} + to={`/notification_templates/${template.id}/edit`} + aria-label={i18n._(t`Edit`)} + > + {i18n._(t`Edit`)} + </Button> + )} + {summary_fields.user_capabilities && + summary_fields.user_capabilities.delete && ( + <DeleteButton + name={template.name} + modalTitle={i18n._(t`Delete Notification`)} + onConfirm={deleteTemplate} + isDisabled={isLoading} + > + {i18n._(t`Delete`)} + </DeleteButton> + )} + </CardActionsRow> + {error && ( + <AlertModal + isOpen={error} + variant="error" + title={i18n._(t`Error!`)} + onClose={dismissError} + > + {i18n._(t`Failed to delete notification.`)} + <ErrorDetail error={error} /> + </AlertModal> + )} + </CardBody> + ); +} + +export default withI18n()(NotificationTemplateDetail); diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/index.js b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/index.js new file mode 100644 index 0000000000..118818bb64 --- /dev/null +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/index.js @@ -0,0 +1,3 @@ +import NotificationTemplateDetail from './NotificationTemplateDetail'; + +export default NotificationTemplateDetail; diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateEdit/NotificationTemplateEdit.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateEdit/NotificationTemplateEdit.jsx new file mode 100644 index 0000000000..b089b6b89f --- /dev/null +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateEdit/NotificationTemplateEdit.jsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useHistory } from 'react-router-dom'; +import { CardBody } from '../../../components/Card'; +import { OrganizationsAPI } from '../../../api'; +import { Config } from '../../../contexts/Config'; + +import NotificationTemplateForm from '../shared/NotificationTemplateForm'; + +function NotificationTemplateEdit({ template }) { + const detailsUrl = `/notification_templates/${template.id}/details`; + const history = useHistory(); + const [formError, setFormError] = useState(null); + + const handleSubmit = async ( + values, + groupsToAssociate, + groupsToDisassociate + ) => { + try { + await OrganizationsAPI.update(template.id, values); + await Promise.all( + groupsToAssociate.map(id => + OrganizationsAPI.associateInstanceGroup(template.id, id) + ) + ); + await Promise.all( + groupsToDisassociate.map(id => + OrganizationsAPI.disassociateInstanceGroup(template.id, id) + ) + ); + history.push(detailsUrl); + } catch (error) { + setFormError(error); + } + }; + + const handleCancel = () => { + history.push(detailsUrl); + }; + + return ( + <CardBody> + <Config> + {({ me }) => ( + <NotificationTemplateForm + template={template} + onSubmit={handleSubmit} + onCancel={handleCancel} + me={me || {}} + submitError={formError} + /> + )} + </Config> + </CardBody> + ); +} + +NotificationTemplateEdit.propTypes = { + template: PropTypes.shape().isRequired, +}; + +NotificationTemplateEdit.contextTypes = { + custom_virtualenvs: PropTypes.arrayOf(PropTypes.string), +}; + +export { NotificationTemplateEdit as _NotificationTemplateEdit }; +export default NotificationTemplateEdit; diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateEdit/index.js b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateEdit/index.js new file mode 100644 index 0000000000..be9b40a69c --- /dev/null +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateEdit/index.js @@ -0,0 +1,3 @@ +import NotificationTemplateEdit from './NotificationTemplateEdit'; + +export default NotificationTemplateEdit; diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.jsx new file mode 100644 index 0000000000..3dac16f6a2 --- /dev/null +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.jsx @@ -0,0 +1,170 @@ +import React, { useCallback, useEffect } from 'react'; +import { useLocation, useRouteMatch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Card, PageSection } from '@patternfly/react-core'; +import { NotificationTemplatesAPI } from '../../../api'; +import PaginatedDataList, { + ToolbarAddButton, + ToolbarDeleteButton, +} from '../../../components/PaginatedDataList'; +import AlertModal from '../../../components/AlertModal'; +import ErrorDetail from '../../../components/ErrorDetail'; +import DataListToolbar from '../../../components/DataListToolbar'; +import NotificationTemplateListItem from './NotificationTemplateListItem'; +import useRequest, { useDeleteItems } from '../../../util/useRequest'; +import useSelected from '../../../util/useSelected'; +import { getQSConfig, parseQueryString } from '../../../util/qs'; + +const QS_CONFIG = getQSConfig('notification-templates', { + page: 1, + page_size: 20, + order_by: 'name', +}); + +function NotificationTemplatesList({ i18n }) { + const location = useLocation(); + const match = useRouteMatch(); + + const addUrl = `${match.url}/add`; + + const { + result: { templates, count, actions }, + error: contentError, + isLoading: isTemplatesLoading, + request: fetchTemplates, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const responses = await Promise.all([ + NotificationTemplatesAPI.read(params), + NotificationTemplatesAPI.readOptions(), + ]); + return { + templates: responses[0].data.results, + count: responses[0].data.count, + actions: responses[1].data.actions, + }; + }, [location]), + { + templates: [], + count: 0, + actions: {}, + } + ); + + useEffect(() => { + fetchTemplates(); + }, [fetchTemplates]); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + templates + ); + + const { + isLoading: isDeleteLoading, + deleteItems: deleteTemplates, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all( + selected.map(({ id }) => NotificationTemplatesAPI.destroy(id)) + ); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchTemplates, + } + ); + + const handleDelete = async () => { + await deleteTemplates(); + setSelected([]); + }; + + const canAdd = actions && actions.POST; + + return ( + <> + <PageSection> + <Card> + <PaginatedDataList + contentError={contentError} + hasContentLoading={isTemplatesLoading || isDeleteLoading} + items={templates} + itemCount={count} + pluralizedItemName={i18n._(t`Notification Templates`)} + qsConfig={QS_CONFIG} + onRowClick={handleSelect} + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Type`), + key: 'notification_type', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + { + name: i18n._(t`Type`), + key: 'notification_type', + }, + ]} + renderToolbar={props => ( + <DataListToolbar + {...props} + showSelectAll + isAllSelected={isAllSelected} + onSelectAll={() => setSelected([...templates])} + qsConfig={QS_CONFIG} + additionalControls={[ + ...(canAdd + ? [<ToolbarAddButton key="add" linkTo={addUrl} />] + : []), + <ToolbarDeleteButton + key="delete" + onDelete={handleDelete} + itemsToDelete={selected} + pluralizedItemName="Organizations" + />, + ]} + /> + )} + renderItem={template => ( + <NotificationTemplateListItem + key={template.id} + template={template} + detailUrl={`${match.url}/${template.id}`} + isSelected={selected.some(row => row.id === template.id)} + onSelect={() => handleSelect(template)} + /> + )} + emptyStateControls={ + canAdd ? <ToolbarAddButton key="add" linkTo={addUrl} /> : null + } + /> + </Card> + </PageSection> + <AlertModal + isOpen={deletionError} + variant="error" + title={i18n._(t`Error!`)} + onClose={clearDeletionError} + > + {i18n._(t`Failed to delete one or more organizations.`)} + <ErrorDetail error={deletionError} /> + </AlertModal> + </> + ); +} + +export default withI18n()(NotificationTemplatesList); diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.test.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.test.jsx new file mode 100644 index 0000000000..d39bffe087 --- /dev/null +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.test.jsx @@ -0,0 +1,202 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { OrganizationsAPI } from '../../../api'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import NotificationTemplateList from './NotificationTemplateList'; + +jest.mock('../../../api'); + +const mockTemplates = { + data: { + count: 3, + results: [ + { + name: 'Boston', + id: 1, + url: '/notification_templates/1', + type: 'slack', + summary_fields: { + recent_notifications: [ + { + status: 'success', + }, + ], + user_capabilities: { + delete: true, + edit: true, + }, + }, + }, + { + name: 'Minneapolis', + id: 2, + url: '/notification_templates/2', + summary_fields: { + recent_notifications: [], + user_capabilities: { + delete: true, + edit: true, + }, + }, + }, + { + name: 'Philidelphia', + id: 3, + url: '/notification_templates/3', + summary_fields: { + recent_notifications: [ + { + status: 'failed', + }, + { + status: 'success', + }, + ], + user_capabilities: { + delete: true, + edit: true, + }, + }, + }, + ], + }, +}; + +describe('<NotificationTemplateList />', () => { + let wrapper; + beforeEach(() => { + OrganizationsAPI.read.mockResolvedValue(mockTemplates); + OrganizationsAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should load notifications', async () => { + await act(async () => { + wrapper = mountWithContexts(<NotificationTemplateList />); + }); + wrapper.update(); + expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1); + expect(wrapper.find('NotificationTemplateListItem').length).toBe(3); + }); + + test('should select item', async () => { + const itemCheckboxInput = 'input#select-template-1'; + await act(async () => { + wrapper = mountWithContexts(<NotificationTemplateList />); + }); + wrapper.update(); + expect(wrapper.find(itemCheckboxInput).prop('checked')).toEqual(false); + await act(async () => { + wrapper + .find(itemCheckboxInput) + .closest('DataListCheck') + .props() + .onChange(); + }); + wrapper.update(); + expect(wrapper.find(itemCheckboxInput).prop('checked')).toEqual(true); + }); + + test('should delete notifications', async () => { + await act(async () => { + wrapper = mountWithContexts(<NotificationTemplateList />); + }); + wrapper.update(); + expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1); + await act(async () => { + wrapper + .find('Checkbox#select-all') + .props() + .onChange(true); + }); + wrapper.update(); + await act(async () => { + wrapper.find('button[aria-label="Delete"]').simulate('click'); + wrapper.update(); + }); + const deleteButton = global.document.querySelector( + 'body div[role="dialog"] button[aria-label="confirm delete"]' + ); + expect(deleteButton).not.toEqual(null); + await act(async () => { + deleteButton.click(); + }); + expect(OrganizationsAPI.destroy).toHaveBeenCalledTimes(3); + expect(OrganizationsAPI.read).toHaveBeenCalledTimes(2); + }); + + test('should show error dialog shown for failed deletion', async () => { + const itemCheckboxInput = 'input#select-template-1'; + OrganizationsAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: '/api/v2/organizations/1', + }, + data: 'An error occurred', + }, + }) + ); + await act(async () => { + wrapper = mountWithContexts(<NotificationTemplateList />); + }); + wrapper.update(); + await act(async () => { + wrapper + .find(itemCheckboxInput) + .closest('DataListCheck') + .props() + .onChange(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('button[aria-label="Delete"]').simulate('click'); + wrapper.update(); + }); + const deleteButton = global.document.querySelector( + 'body div[role="dialog"] button[aria-label="confirm delete"]' + ); + expect(deleteButton).not.toEqual(null); + await act(async () => { + deleteButton.click(); + }); + wrapper.update(); + + const modal = wrapper.find('Modal'); + expect(modal.prop('isOpen')).toEqual(true); + expect(modal.prop('title')).toEqual('Error!'); + }); + + test('should show add button', async () => { + await act(async () => { + wrapper = mountWithContexts(<NotificationTemplateList />); + }); + wrapper.update(); + expect(wrapper.find('ToolbarAddButton').length).toBe(1); + }); + + test('should hide add button (rbac)', async () => { + OrganizationsAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + }, + }, + }); + await act(async () => { + wrapper = mountWithContexts(<NotificationTemplateList />); + }); + wrapper.update(); + expect(wrapper.find('ToolbarAddButton').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx new file mode 100644 index 0000000000..0087e7f9a8 --- /dev/null +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx @@ -0,0 +1,122 @@ +import 'styled-components/macro'; +import React, { useState, useEffect, useCallback } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; +import { + Button, + DataListAction as _DataListAction, + DataListCheck, + DataListItem, + DataListItemCells, + DataListItemRow, + Tooltip, +} from '@patternfly/react-core'; +import { PencilAltIcon, BellIcon } from '@patternfly/react-icons'; +import { NotificationTemplatesAPI } from '../../../api'; +import DataListCell from '../../../components/DataListCell'; +import StatusLabel from '../../../components/StatusLabel'; +import useRequest from '../../../util/useRequest'; +import { NOTIFICATION_TYPES } from '../constants'; + +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 16px; + grid-template-columns: 40px 40px; +`; + +function NotificationTemplateListItem({ + template, + detailUrl, + isSelected, + onSelect, + i18n, +}) { + const recentNotifications = template.summary_fields?.recent_notifications; + const latestStatus = recentNotifications + ? recentNotifications[0]?.status + : null; + const [status, setStatus] = useState(latestStatus); + + useEffect(() => { + setStatus(latestStatus); + }, [latestStatus]); + + const { request: sendTestNotification, isLoading, error } = useRequest( + useCallback(() => { + NotificationTemplatesAPI.test(template.id); + setStatus('running'); + }, [template.id]) + ); + + useEffect(() => { + if (error) { + setStatus('error'); + } + }, [error]); + + const labelId = `template-name-${template.id}`; + + return ( + <DataListItem key={template.id} aria-labelledby={labelId} id={template.id}> + <DataListItemRow> + <DataListCheck + id={`select-template-${template.id}`} + checked={isSelected} + onChange={onSelect} + aria-labelledby={labelId} + /> + <DataListItemCells + dataListCells={[ + <DataListCell key="name" id={labelId}> + <Link to={detailUrl}> + <b>{template.name}</b> + </Link> + </DataListCell>, + <DataListCell key="status"> + {status && <StatusLabel status={status} />} + </DataListCell>, + <DataListCell key="type"> + <strong>{i18n._(t`Type:`)}</strong>{' '} + {NOTIFICATION_TYPES[template.notification_type] || + template.notification_type} + </DataListCell>, + ]} + /> + <DataListAction aria-label="actions" aria-labelledby={labelId}> + <Tooltip content={i18n._(t`Test Notification`)} position="top"> + <Button + aria-label={i18n._(t`Test Notification`)} + variant="plain" + onClick={sendTestNotification} + disabled={isLoading} + > + <BellIcon /> + </Button> + </Tooltip> + {template.summary_fields.user_capabilities.edit ? ( + <Tooltip + content={i18n._(t`Edit Notification Template`)} + position="top" + > + <Button + aria-label={i18n._(t`Edit Notification Template`)} + variant="plain" + component={Link} + to={`/notification_templates/${template.id}/edit`} + > + <PencilAltIcon /> + </Button> + </Tooltip> + ) : ( + <div /> + )} + </DataListAction> + </DataListItemRow> + </DataListItem> + ); +} + +export default withI18n()(NotificationTemplateListItem); diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.test.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.test.jsx new file mode 100644 index 0000000000..5a4566779e --- /dev/null +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.test.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { NotificationTemplatesAPI } from '../../../api'; +import NotificationTemplateListItem from './NotificationTemplateListItem'; + +jest.mock('../../../api/models/NotificationTemplates'); + +const template = { + id: 3, + notification_type: 'slack', + name: 'Test Notification', + summary_fields: { + user_capabilities: { + edit: true, + }, + recent_notifications: [ + { + status: 'success', + }, + ], + }, +}; + +describe('<NotificationTemplateListItem />', () => { + test('should render template row', () => { + const wrapper = mountWithContexts( + <NotificationTemplateListItem + template={template} + detailUrl="/notification_templates/3/detail" + /> + ); + + const cells = wrapper.find('DataListCell'); + expect(cells).toHaveLength(3); + expect(cells.at(0).text()).toEqual('Test Notification'); + expect(cells.at(1).text()).toEqual('Success'); + expect(cells.at(2).text()).toEqual('Type: Slack'); + }); + + test('should send test notification', async () => { + NotificationTemplatesAPI.test.mockResolvedValue({}); + + const wrapper = mountWithContexts( + <NotificationTemplateListItem + template={template} + detailUrl="/notification_templates/3/detail" + /> + ); + await act(async () => { + wrapper + .find('Button') + .at(0) + .invoke('onClick')(); + }); + expect(NotificationTemplatesAPI.test).toHaveBeenCalledTimes(1); + expect( + wrapper + .find('DataListCell') + .at(1) + .text() + ).toEqual('Running'); + }); +}); diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/index.js b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/index.js new file mode 100644 index 0000000000..335e76dd6c --- /dev/null +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/index.js @@ -0,0 +1,4 @@ +import NotificationTemplateList from './NotificationTemplateList'; + +export default NotificationTemplateList; +export { default as NotificationTemplateListItem } from './NotificationTemplateListItem'; diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx index 857201bc6b..2ae913202f 100644 --- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx @@ -1,28 +1,51 @@ -import React, { Component, Fragment } from 'react'; +import React, { useState, useCallback } from 'react'; +import { Route, Switch, useRouteMatch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { - PageSection, - PageSectionVariants, - Title, -} from '@patternfly/react-core'; +import NotificationTemplateList from './NotificationTemplateList'; +import NotificationTemplateAdd from './NotificationTemplateAdd'; +import NotificationTemplate from './NotificationTemplate'; +import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; -class NotificationTemplates extends Component { - render() { - const { i18n } = this.props; - const { light } = PageSectionVariants; +function NotificationTemplates({ i18n }) { + const match = useRouteMatch(); + const [breadcrumbConfig, setBreadcrumbConfig] = useState({ + '/notification_templates': i18n._(t`Notification Templates`), + '/notification_templates/add': i18n._(t`Create New Notification Template`), + }); - return ( - <Fragment> - <PageSection variant={light} className="pf-m-condensed"> - <Title size="2xl" headingLevel="h2"> - {i18n._(t`Notification Templates`)} - </Title> - </PageSection> - <PageSection /> - </Fragment> - ); - } + const updateBreadcrumbConfig = useCallback( + notification => { + const { id } = notification; + setBreadcrumbConfig({ + '/notification_templates': i18n._(t`Notification Templates`), + '/notification_templates/add': i18n._( + t`Create New Notification Template` + ), + [`/notification_templates/${id}`]: notification.name, + [`/notification_templates/${id}/edit`]: i18n._(t`Edit Details`), + [`/notification_templates/${id}/details`]: i18n._(t`Details`), + }); + }, + [i18n] + ); + + return ( + <> + <Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> + <Switch> + <Route path={`${match.url}/add`}> + <NotificationTemplateAdd /> + </Route> + <Route path={`${match.url}/:id`}> + <NotificationTemplate setBreadcrumb={updateBreadcrumbConfig} /> + </Route> + <Route path={`${match.url}`}> + <NotificationTemplateList /> + </Route> + </Switch> + </> + ); } export default withI18n()(NotificationTemplates); diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx index 93babc8e06..9333850cf9 100644 --- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx @@ -1,18 +1,14 @@ import React from 'react'; - import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; - import NotificationTemplates from './NotificationTemplates'; describe('<NotificationTemplates />', () => { let pageWrapper; let pageSections; - let title; beforeEach(() => { pageWrapper = mountWithContexts(<NotificationTemplates />); pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); }); afterEach(() => { @@ -22,8 +18,6 @@ describe('<NotificationTemplates />', () => { test('initially renders without crashing', () => { expect(pageWrapper.length).toBe(1); expect(pageSections.length).toBe(2); - expect(title.length).toBe(1); - expect(title.props().size).toBe('2xl'); expect(pageSections.first().props().variant).toBe('light'); }); }); diff --git a/awx/ui_next/src/screens/NotificationTemplate/constants.js b/awx/ui_next/src/screens/NotificationTemplate/constants.js new file mode 100644 index 0000000000..5937e48743 --- /dev/null +++ b/awx/ui_next/src/screens/NotificationTemplate/constants.js @@ -0,0 +1,12 @@ +/* eslint-disable-next-line import/prefer-default-export */ +export const NOTIFICATION_TYPES = { + email: 'Email', + grafana: 'Grafana', + irc: 'IRC', + mattermost: 'Mattermost', + pagerduty: 'Pagerduty', + rocketchat: 'Rocket.Chat', + slack: 'Slack', + twilio: 'Twilio', + webhook: 'Webhook', +}; diff --git a/awx/ui_next/src/screens/NotificationTemplate/shared/NotificationTemplateForm.jsx b/awx/ui_next/src/screens/NotificationTemplate/shared/NotificationTemplateForm.jsx new file mode 100644 index 0000000000..c08caaa3e5 --- /dev/null +++ b/awx/ui_next/src/screens/NotificationTemplate/shared/NotificationTemplateForm.jsx @@ -0,0 +1,3 @@ +export default function NotificationTemplateForm() { + // +} diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx index d2bc4a4bc2..7b3c0eeda2 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx @@ -31,7 +31,13 @@ function OrganizationsList({ i18n }) { const addUrl = `${match.url}/add`; const { - result: { organizations, organizationCount, actions }, + result: { + organizations, + organizationCount, + actions, + relatedSearchableKeys, + searchableKeys, + }, error: contentError, isLoading: isOrgsLoading, request: fetchOrganizations, @@ -46,12 +52,20 @@ function OrganizationsList({ i18n }) { organizations: orgs.data.results, organizationCount: orgs.data.count, actions: orgActions.data.actions, + relatedSearchableKeys: ( + orgActions?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: Object.keys(orgActions.data.actions?.GET || {}).filter( + key => orgActions.data.actions?.GET[key].filterable + ), }; }, [location]), { organizations: [], organizationCount: 0, actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -114,16 +128,16 @@ function OrganizationsList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -132,6 +146,8 @@ function OrganizationsList({ i18n }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( <DataListToolbar {...props} diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx index 51f78c173c..37d01a9e0a 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx @@ -62,12 +62,10 @@ function OrganizationListItem({ /> <DataListItemCells dataListCells={[ - <DataListCell key="divider"> - <span id={labelId}> - <Link to={`${detailUrl}`}> - <b>{organization.name}</b> - </Link> - </span> + <DataListCell key="name" id={labelId}> + <Link to={`${detailUrl}`}> + <b>{organization.name}</b> + </Link> </DataListCell>, <DataListCell key="related-field-counts"> <ListGroup> @@ -85,11 +83,7 @@ function OrganizationListItem({ </DataListCell>, ]} /> - <DataListAction - aria-label="actions" - aria-labelledby={labelId} - id={labelId} - > + <DataListAction aria-label="actions" aria-labelledby={labelId}> {organization.summary_fields.user_capabilities.edit ? ( <Tooltip content={i18n._(t`Edit Organization`)} position="top"> <Button diff --git a/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeamList.jsx b/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeamList.jsx new file mode 100644 index 0000000000..1ab2ecd293 --- /dev/null +++ b/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeamList.jsx @@ -0,0 +1,91 @@ +import React, { useCallback, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useLocation } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { OrganizationsAPI } from '../../../api'; +import PaginatedDataList from '../../../components/PaginatedDataList'; +import { getQSConfig, parseQueryString } from '../../../util/qs'; +import useRequest from '../../../util/useRequest'; +import OrganizationTeamListItem from './OrganizationTeamListItem'; + +const QS_CONFIG = getQSConfig('team', { + page: 1, + page_size: 5, + order_by: 'name', +}); + +function OrganizationTeamList({ id, i18n }) { + const location = useLocation(); + + const { + result: { teams, count }, + error, + isLoading, + request: fetchTeams, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const results = await OrganizationsAPI.readTeams(id, params); + return { + teams: results.data.results, + count: results.data.count, + }; + }, [id, location]), + { + teams: [], + count: 0, + } + ); + + useEffect(() => { + fetchTeams(); + }, [fetchTeams]); + + return ( + <PaginatedDataList + contentError={error} + hasContentLoading={isLoading} + items={teams} + itemCount={count} + pluralizedItemName={i18n._(t`Teams`)} + qsConfig={QS_CONFIG} + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name__icontains', + isDefault: true, + }, + { + name: i18n._(t`Created by (username)`), + key: 'created_by__username__icontains', + }, + { + name: i18n._(t`Modified by (username)`), + key: 'modified_by__username__icontains', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + ]} + renderItem={item => ( + <OrganizationTeamListItem + key={item.id} + value={item.name} + team={item} + detailUrl={`/teams/${item.id}`} + /> + )} + /> + ); +} + +OrganizationTeamList.propTypes = { + id: PropTypes.number.isRequired, +}; + +export { OrganizationTeamList as _OrganizationTeamList }; +export default withI18n()(OrganizationTeamList); diff --git a/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeams.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeamList.test.jsx index c21ec13622..f7c61d8ac4 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeams.test.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeamList.test.jsx @@ -8,7 +8,7 @@ import { } from '../../../../testUtils/enzymeHelpers'; import { sleep } from '../../../../testUtils/testUtils'; -import OrganizationTeams from './OrganizationTeams'; +import OrganizationTeamList from './OrganizationTeamList'; jest.mock('../../../api'); @@ -16,16 +16,41 @@ const listData = { data: { count: 7, results: [ - { id: 1, name: 'one', url: '/org/team/1' }, - { id: 2, name: 'two', url: '/org/team/2' }, - { id: 3, name: 'three', url: '/org/team/3' }, - { id: 4, name: 'four', url: '/org/team/4' }, - { id: 5, name: 'five', url: '/org/team/5' }, + { + id: 1, + name: 'one', + url: '/org/team/1', + summary_fields: { user_capabilities: { edit: true, delete: true } }, + }, + { + id: 2, + name: 'two', + url: '/org/team/2', + summary_fields: { user_capabilities: { edit: true, delete: true } }, + }, + { + id: 3, + name: 'three', + url: '/org/team/3', + summary_fields: { user_capabilities: { edit: true, delete: true } }, + }, + { + id: 4, + name: 'four', + url: '/org/team/4', + summary_fields: { user_capabilities: { edit: true, delete: true } }, + }, + { + id: 5, + name: 'five', + url: '/org/team/5', + summary_fields: { user_capabilities: { edit: true, delete: true } }, + }, ], }, }; -describe('<OrganizationTeams />', () => { +describe('<OrganizationTeamList />', () => { beforeEach(() => { OrganizationsAPI.readTeams.mockResolvedValue(listData); }); @@ -37,7 +62,7 @@ describe('<OrganizationTeams />', () => { test('renders succesfully', async () => { await act(async () => { mountWithContexts( - <OrganizationTeams + <OrganizationTeamList id={1} searchString="" location={{ search: '', pathname: '/organizations/1/teams' }} @@ -48,8 +73,8 @@ describe('<OrganizationTeams />', () => { test('should load teams on mount', async () => { await act(async () => { - mountWithContexts(<OrganizationTeams id={1} searchString="" />).find( - 'OrganizationTeams' + mountWithContexts(<OrganizationTeamList id={1} searchString="" />).find( + 'OrganizationTeamList' ); }); expect(OrganizationsAPI.readTeams).toHaveBeenCalledWith(1, { @@ -62,7 +87,9 @@ describe('<OrganizationTeams />', () => { test('should pass fetched teams to PaginatedDatalist', async () => { let wrapper; await act(async () => { - wrapper = mountWithContexts(<OrganizationTeams id={1} searchString="" />); + wrapper = mountWithContexts( + <OrganizationTeamList id={1} searchString="" /> + ); }); await sleep(0); wrapper.update(); @@ -90,7 +117,7 @@ describe('<OrganizationTeams />', () => { ); let wrapper; await act(async () => { - wrapper = mountWithContexts(<OrganizationTeams id={1} />); + wrapper = mountWithContexts(<OrganizationTeamList id={1} />); }); await waitForElement(wrapper, 'ContentError', el => el.length === 1); }); diff --git a/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeamListItem.jsx b/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeamListItem.jsx new file mode 100644 index 0000000000..b352e3dda9 --- /dev/null +++ b/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeamListItem.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import { + Button, + DataListAction, + DataListItem, + DataListItemRow, + DataListItemCells, + Tooltip, +} from '@patternfly/react-core'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; +import { PencilAltIcon } from '@patternfly/react-icons'; +import DataListCell from '../../../components/DataListCell'; + +function OrganizationTeamListItem({ i18n, team, detailUrl }) { + const labelId = `check-action-${team.id}`; + + return ( + <DataListItem aria-labelledby={labelId} id={`${team.id}`}> + <DataListItemRow> + <DataListItemCells + dataListCells={[ + <DataListCell key="divider"> + <span> + <Link to={`${detailUrl}/details`}> + <b aria-label={i18n._(t`team name`)}>{team.name}</b> + </Link> + </span> + </DataListCell>, + ]} + /> + <DataListAction + aria-label="actions" + aria-labelledby={labelId} + id={labelId} + > + {team.summary_fields.user_capabilities.edit && ( + <Tooltip content={i18n._(t`Edit Team`)} position="top"> + <Button + aria-label={i18n._(t`Edit Team`)} + css="grid-column: 2" + variant="plain" + component={Link} + to={`${detailUrl}/edit`} + > + <PencilAltIcon /> + </Button> + </Tooltip> + )} + </DataListAction> + </DataListItemRow> + </DataListItem> + ); +} + +OrganizationTeamListItem.propTypes = { + team: PropTypes.shape({ id: PropTypes.number, name: PropTypes.string }) + .isRequired, + detailUrl: PropTypes.string.isRequired, +}; + +export default withI18n()(OrganizationTeamListItem); diff --git a/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeams.jsx b/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeams.jsx deleted file mode 100644 index 70965ad1da..0000000000 --- a/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeams.jsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import PropTypes from 'prop-types'; -import { useLocation } from 'react-router-dom'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { OrganizationsAPI } from '../../../api'; -import PaginatedDataList from '../../../components/PaginatedDataList'; -import { getQSConfig, parseQueryString } from '../../../util/qs'; - -const QS_CONFIG = getQSConfig('team', { - page: 1, - page_size: 5, - order_by: 'name', -}); - -function OrganizationTeams({ id, i18n }) { - const location = useLocation(); - const [contentError, setContentError] = useState(null); - const [hasContentLoading, setHasContentLoading] = useState(false); - const [itemCount, setItemCount] = useState(0); - const [teams, setTeams] = useState([]); - - useEffect(() => { - (async () => { - const params = parseQueryString(QS_CONFIG, location.search); - setContentError(null); - setHasContentLoading(true); - try { - const { - data: { count = 0, results = [] }, - } = await OrganizationsAPI.readTeams(id, params); - setItemCount(count); - setTeams( - results.map(team => ({ ...team, url: `/teams/${team.id}/details` })) - ); - } catch (error) { - setContentError(error); - } finally { - setHasContentLoading(false); - } - })(); - }, [id, location]); - - return ( - <PaginatedDataList - contentError={contentError} - hasContentLoading={hasContentLoading} - items={teams} - itemCount={itemCount} - pluralizedItemName={i18n._(t`Teams`)} - qsConfig={QS_CONFIG} - toolbarSearchColumns={[ - { - name: i18n._(t`Name`), - key: 'name', - isDefault: true, - }, - { - name: i18n._(t`Created by (username)`), - key: 'created_by__username', - }, - { - name: i18n._(t`Modified by (username)`), - key: 'modified_by__username', - }, - ]} - toolbarSortColumns={[ - { - name: i18n._(t`Name`), - key: 'name', - }, - ]} - /> - ); -} - -OrganizationTeams.propTypes = { - id: PropTypes.number.isRequired, -}; - -export { OrganizationTeams as _OrganizationTeams }; -export default withI18n()(OrganizationTeams); diff --git a/awx/ui_next/src/screens/Organization/OrganizationTeams/OrgnizationTeamListItem.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationTeams/OrgnizationTeamListItem.test.jsx new file mode 100644 index 0000000000..8758ebf946 --- /dev/null +++ b/awx/ui_next/src/screens/Organization/OrganizationTeams/OrgnizationTeamListItem.test.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import OrganizationTeamListItem from './OrganizationTeamListItem'; + +const team = { + id: 1, + name: 'one', + url: '/org/team/1', + summary_fields: { user_capabilities: { edit: true, delete: true } }, +}; + +describe('<OrganizationTeamListItem />', () => { + let wrapper; + test('should mount properly', async () => { + await act(async () => { + wrapper = mountWithContexts( + <OrganizationTeamListItem team={team} detailUrl="/teams/1" /> + ); + }); + expect(wrapper.find('OrganizationTeamListItem').length).toBe(1); + }); + + test('should render proper data', async () => { + await act(async () => { + wrapper = mountWithContexts( + <OrganizationTeamListItem team={team} detailUrl="/teams/1" /> + ); + }); + expect(wrapper.find(`b[aria-label="team name"]`).text()).toBe('one'); + expect(wrapper.find('PencilAltIcon').length).toBe(1); + }); + + test('should not render edit button', async () => { + team.summary_fields.user_capabilities.edit = false; + await act(async () => { + wrapper = mountWithContexts( + <OrganizationTeamListItem team={team} detailUrl="/teams/1" /> + ); + }); + expect(wrapper.find('PencilAltIcon').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/Organization/OrganizationTeams/index.js b/awx/ui_next/src/screens/Organization/OrganizationTeams/index.js index eb8b71f016..de8b47e407 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationTeams/index.js +++ b/awx/ui_next/src/screens/Organization/OrganizationTeams/index.js @@ -1 +1 @@ -export { default } from './OrganizationTeams'; +export { default } from './OrganizationTeamList'; diff --git a/awx/ui_next/src/screens/Portal/Portal.jsx b/awx/ui_next/src/screens/Portal/Portal.jsx deleted file mode 100644 index 6651fc25d2..0000000000 --- a/awx/ui_next/src/screens/Portal/Portal.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { - PageSection, - PageSectionVariants, - Title, -} from '@patternfly/react-core'; - -class Portal extends Component { - render() { - const { i18n } = this.props; - const { light } = PageSectionVariants; - - return ( - <Fragment> - <PageSection variant={light} className="pf-m-condensed"> - <Title size="2xl" headingLevel="h2"> - {i18n._(t`My View`)} - </Title> - </PageSection> - <PageSection /> - </Fragment> - ); - } -} - -export default withI18n()(Portal); diff --git a/awx/ui_next/src/screens/Portal/Portal.test.jsx b/awx/ui_next/src/screens/Portal/Portal.test.jsx deleted file mode 100644 index cee4a93678..0000000000 --- a/awx/ui_next/src/screens/Portal/Portal.test.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; - -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; - -import Portal from './Portal'; - -describe('<Portal />', () => { - let pageWrapper; - let pageSections; - let title; - - beforeEach(() => { - pageWrapper = mountWithContexts(<Portal />); - pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); - }); - - afterEach(() => { - pageWrapper.unmount(); - }); - - test('initially renders without crashing', () => { - expect(pageWrapper.length).toBe(1); - expect(pageSections.length).toBe(2); - expect(title.length).toBe(1); - expect(title.props().size).toBe('2xl'); - expect(pageSections.first().props().variant).toBe('light'); - }); -}); diff --git a/awx/ui_next/src/screens/Portal/index.js b/awx/ui_next/src/screens/Portal/index.js deleted file mode 100644 index f227015fb9..0000000000 --- a/awx/ui_next/src/screens/Portal/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './Portal'; diff --git a/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesList.jsx b/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesList.jsx index defb79bab7..d804a6ea83 100644 --- a/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesList.jsx +++ b/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesList.jsx @@ -1,14 +1,9 @@ -import React, { useEffect, useState } from 'react'; -import { useParams, useLocation } from 'react-router-dom'; +import React, { useCallback, useEffect } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Card } from '@patternfly/react-core'; - -import { - JobTemplatesAPI, - UnifiedJobTemplatesAPI, - WorkflowJobTemplatesAPI, -} from '../../../api'; +import { JobTemplatesAPI } from '../../../api'; import AlertModal from '../../../components/AlertModal'; import DatalistToolbar from '../../../components/DataListToolbar'; import ErrorDetail from '../../../components/ErrorDetail'; @@ -17,171 +12,108 @@ import PaginatedDataList, { ToolbarDeleteButton, } from '../../../components/PaginatedDataList'; import { getQSConfig, parseQueryString } from '../../../util/qs'; +import useSelected from '../../../util/useSelected'; +import useRequest, { useDeleteItems } from '../../../util/useRequest'; import ProjectTemplatesListItem from './ProjectJobTemplatesListItem'; -// The type value in const QS_CONFIG below does not have a space between job_template and -// workflow_job_template so the params sent to the API match what the api expects. const QS_CONFIG = getQSConfig('template', { page: 1, page_size: 20, order_by: 'name', - type: 'job_template,workflow_job_template', }); function ProjectJobTemplatesList({ i18n }) { const { id: projectId } = useParams(); - const { pathname, search } = useLocation(); - - const [deletionError, setDeletionError] = useState(null); - const [contentError, setContentError] = useState(null); - const [hasContentLoading, setHasContentLoading] = useState(true); - const [jtActions, setJTActions] = useState(null); - const [wfjtActions, setWFJTActions] = useState(null); - const [count, setCount] = useState(0); - const [templates, setTemplates] = useState([]); - const [selected, setSelected] = useState([]); - - useEffect( - () => { - const loadTemplates = async () => { - const params = { - ...parseQueryString(QS_CONFIG, search), - }; - - let jtOptionsPromise; - if (jtActions) { - jtOptionsPromise = Promise.resolve({ - data: { actions: jtActions }, - }); - } else { - jtOptionsPromise = JobTemplatesAPI.readOptions(); - } - - let wfjtOptionsPromise; - if (wfjtActions) { - wfjtOptionsPromise = Promise.resolve({ - data: { actions: wfjtActions }, - }); - } else { - wfjtOptionsPromise = WorkflowJobTemplatesAPI.readOptions(); - } - if (pathname.startsWith('/projects') && projectId) { - params.jobtemplate__project = projectId; - } - - const promises = Promise.all([ - UnifiedJobTemplatesAPI.read(params), - jtOptionsPromise, - wfjtOptionsPromise, - ]); - setDeletionError(null); - - try { - const [ - { - data: { count: itemCount, results }, - }, - { - data: { actions: jobTemplateActions }, - }, - { - data: { actions: workFlowJobTemplateActions }, - }, - ] = await promises; - setJTActions(jobTemplateActions); - setWFJTActions(workFlowJobTemplateActions); - setCount(itemCount); - setTemplates(results); - setHasContentLoading(false); - } catch (err) { - setContentError(err); - } + const location = useLocation(); + + const { + result: { jobTemplates, itemCount, actions }, + error: contentError, + isLoading, + request: fetchTemplates, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + params.project = projectId; + const [response, actionsResponse] = await Promise.all([ + JobTemplatesAPI.read(params), + JobTemplatesAPI.readOptions(), + ]); + return { + jobTemplates: response.data.results, + itemCount: response.data.count, + actions: actionsResponse.data.actions, }; - loadTemplates(); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [pathname, search, count, projectId] + }, [location, projectId]), + { + jobTemplates: [], + itemCount: 0, + actions: {}, + } ); - const handleSelectAll = isSelected => { - const selectedItems = isSelected ? [...templates] : []; - setSelected(selectedItems); - }; + useEffect(() => { + fetchTemplates(); + }, [fetchTemplates]); - const handleSelect = template => { - if (selected.some(s => s.id === template.id)) { - setSelected(selected.filter(s => s.id !== template.id)); - } else { - setSelected(selected.concat(template)); - } - }; + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + jobTemplates + ); - const handleTemplateDelete = async () => { - setHasContentLoading(true); - try { - await Promise.all( - selected.map(({ type, id }) => { - let deletePromise; - if (type === 'job_template') { - deletePromise = JobTemplatesAPI.destroy(id); - } else if (type === 'workflow_job_template') { - deletePromise = WorkflowJobTemplatesAPI.destroy(id); - } - return deletePromise; - }) + const { + isLoading: isDeleteLoading, + deleteItems: deleteTemplates, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all( + selected.map(template => JobTemplatesAPI.destroy(template.id)) ); - setCount(count - selected.length); - } catch (err) { - setDeletionError(err); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchTemplates, } + ); + + const handleTemplateDelete = async () => { + await deleteTemplates(); + setSelected([]); }; const canAddJT = - jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST'); + actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); const addButton = ( <ToolbarAddButton key="add" linkTo="/templates/job_template/add/" /> ); - const isAllSelected = - selected.length === templates.length && selected.length > 0; - return ( <> <Card> <PaginatedDataList contentError={contentError} - hasContentLoading={hasContentLoading} - items={templates} - itemCount={count} - pluralizedItemName={i18n._(t`Templates`)} + hasContentLoading={isDeleteLoading || isLoading} + items={jobTemplates} + itemCount={itemCount} + pluralizedItemName={i18n._(t`Job templates`)} qsConfig={QS_CONFIG} onRowClick={handleSelect} toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { - name: i18n._(t`Type`), - key: 'type', - options: [ - [`job_template`, i18n._(t`Job Template`)], - [`workflow_job_template`, i18n._(t`Workflow Template`)], - ], - }, - { - name: i18n._(t`Playbook name`), - key: 'job_template__playbook', - }, - { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -190,7 +122,7 @@ function ProjectJobTemplatesList({ i18n }) { key: 'job_template__inventory__id', }, { - name: i18n._(t`Last Job Run`), + name: i18n._(t`Last job run`), key: 'last_job_run', }, { @@ -214,9 +146,10 @@ function ProjectJobTemplatesList({ i18n }) { <DatalistToolbar {...props} showSelectAll - showExpandCollapse isAllSelected={isAllSelected} - onSelectAll={handleSelectAll} + onSelectAll={isSelected => + setSelected(isSelected ? [...jobTemplates] : []) + } qsConfig={QS_CONFIG} additionalControls={[ ...(canAddJT ? [addButton] : []), @@ -224,7 +157,7 @@ function ProjectJobTemplatesList({ i18n }) { key="delete" onDelete={handleTemplateDelete} itemsToDelete={selected} - pluralizedItemName="Templates" + pluralizedItemName={i18n._(t`Job templates`)} />, ]} /> @@ -246,9 +179,9 @@ function ProjectJobTemplatesList({ i18n }) { isOpen={deletionError} variant="danger" title={i18n._(t`Error!`)} - onClose={() => setDeletionError(null)} + onClose={clearDeletionError} > - {i18n._(t`Failed to delete one or more templates.`)} + {i18n._(t`Failed to delete one or more job templates.`)} <ErrorDetail error={deletionError} /> </AlertModal> </> diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx index e82a31c4ae..7afbe124b3 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx @@ -13,6 +13,7 @@ import PaginatedDataList, { ToolbarAddButton, ToolbarDeleteButton, } from '../../../components/PaginatedDataList'; +import useWsProjects from './useWsProjects'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import ProjectListItem from './ProjectListItem'; @@ -29,7 +30,13 @@ function ProjectList({ i18n }) { const [selected, setSelected] = useState([]); const { - result: { projects, itemCount, actions }, + result: { + results, + itemCount, + actions, + relatedSearchableKeys, + searchableKeys, + }, error: contentError, isLoading, request: fetchProjects, @@ -41,15 +48,23 @@ function ProjectList({ i18n }) { ProjectsAPI.readOptions(), ]); return { - projects: response.data.results, + results: response.data.results, itemCount: response.data.count, actions: actionsResponse.data.actions, + 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]), { - projects: [], + results: [], itemCount: 0, actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -57,6 +72,8 @@ function ProjectList({ i18n }) { fetchProjects(); }, [fetchProjects]); + const projects = useWsProjects(results); + const isAllSelected = selected.length === projects.length && selected.length > 0; const { @@ -110,12 +127,12 @@ function ProjectList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Type`), - key: 'scm_type', + key: 'or__scm_type', options: [ [``, i18n._(t`Manual`)], [`git`, i18n._(t`Git`)], @@ -126,17 +143,19 @@ function ProjectList({ i18n }) { }, { name: i18n._(t`Source Control URL`), - key: 'scm_url', + key: 'scm_url__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} toolbarSortColumns={[ { name: i18n._(t`Name`), diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx index 945fbfc8e4..b50e569f18 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx @@ -78,6 +78,7 @@ describe('<ProjectList />', () => { GET: {}, POST: {}, }, + related_search_fields: [], }, }); }); diff --git a/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js new file mode 100644 index 0000000000..38303c9ed3 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js @@ -0,0 +1,44 @@ +import { useState, useEffect } from 'react'; +import useWebsocket from '../../../util/useWebsocket'; + +export default function useWsProjects(initialProjects) { + const [projects, setProjects] = useState(initialProjects); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); + + useEffect(() => { + setProjects(initialProjects); + }, [initialProjects]); + + useEffect(() => { + if (!lastMessage?.unified_job_id || lastMessage.type !== 'project_update') { + return; + } + const index = projects.findIndex(p => p.id === lastMessage.project_id); + if (index === -1) { + return; + } + + const project = projects[index]; + const updatedProject = { + ...project, + summary_fields: { + ...project.summary_fields, + last_job: { + id: lastMessage.unified_job_id, + status: lastMessage.status, + finished: lastMessage.finished, + }, + }, + }; + setProjects([ + ...projects.slice(0, index), + updatedProject, + ...projects.slice(index + 1), + ]); + }, [lastMessage]); // eslint-disable-line react-hooks/exhaustive-deps + + return projects; +} diff --git a/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.test.jsx b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.test.jsx new file mode 100644 index 0000000000..e32a31ab70 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.test.jsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import WS from 'jest-websocket-mock'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import useWsProjects from './useWsProjects'; + +function TestInner() { + return <div />; +} +function Test({ projects }) { + const synced = useWsProjects(projects); + return <TestInner projects={synced} />; +} + +describe('useWsProjects', () => { + let debug; + let wrapper; + beforeEach(() => { + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; + }); + + afterEach(() => { + global.console.debug = debug; + }); + + test('should return projects list', async () => { + const projects = [{ id: 1 }]; + await act(async () => { + wrapper = await mountWithContexts(<Test projects={projects} />); + }); + + expect(wrapper.find('TestInner').prop('projects')).toEqual(projects); + WS.clean(); + }); + + test('should establish websocket connection', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const projects = [{ id: 1 }]; + await act(async () => { + wrapper = await mountWithContexts(<Test projects={projects} />); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + WS.clean(); + }); + + test('should update project status', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const projects = [ + { + id: 1, + summary_fields: { + last_job: { + id: 1, + status: 'running', + finished: null, + }, + }, + }, + ]; + await act(async () => { + wrapper = await mountWithContexts(<Test projects={projects} />); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + expect( + wrapper.find('TestInner').prop('projects')[0].summary_fields.last_job + .status + ).toEqual('running'); + await act(async () => { + mockServer.send( + JSON.stringify({ + project_id: 1, + unified_job_id: 12, + type: 'project_update', + status: 'successful', + finished: '2020-07-02T16:28:31.839071Z', + }) + ); + }); + wrapper.update(); + + expect( + wrapper.find('TestInner').prop('projects')[0].summary_fields.last_job + ).toEqual({ + id: 12, + status: 'successful', + finished: '2020-07-02T16:28:31.839071Z', + }); + WS.clean(); + }); +}); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx index 692c66d3d2..56c740d73d 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx @@ -259,11 +259,13 @@ function ProjectFormFields({ <FormGroup fieldId="project-custom-virtualenv" label={i18n._(t`Ansible Environment`)} - > - <FieldTooltip - content={i18n._(t`Select the playbook to be executed by + labelIcon={ + <FieldTooltip + content={i18n._(t`Select the playbook to be executed by this job.`)} - /> + /> + } + > <AnsibleSelect id="project-custom-virtualenv" data={[ diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/ManualSubForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/ManualSubForm.jsx index b6ece76103..e8bb4ef366 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/ManualSubForm.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/ManualSubForm.jsx @@ -83,12 +83,14 @@ const ManualSubForm = ({ isRequired validated={!pathMeta.touched || !pathMeta.error ? 'default' : 'error'} label={i18n._(t`Playbook Directory`)} - > - <FieldTooltip - content={i18n._(t`Select from the list of directories found in + labelIcon={ + <FieldTooltip + content={i18n._(t`Select from the list of directories found in the Project Base Path. Together the base path and the playbook directory provide the full path used to locate playbooks.`)} - /> + /> + } + > <AnsibleSelect {...pathField} id="local_path" diff --git a/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStream.jsx b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStream.jsx new file mode 100644 index 0000000000..bfdfc6f736 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStream.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import ActivityStreamDetail from './ActivityStreamDetail'; +import ActivityStreamEdit from './ActivityStreamEdit'; + +function ActivityStream({ i18n }) { + const baseUrl = '/settings/activity_stream'; + return ( + <PageSection> + <Card> + {i18n._(t`Activity stream settings`)} + <Switch> + <Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> + <Route path={`${baseUrl}/details`}> + <ActivityStreamDetail /> + </Route> + <Route path={`${baseUrl}/edit`}> + <ActivityStreamEdit /> + </Route> + </Switch> + </Card> + </PageSection> + ); +} + +export default withI18n()(ActivityStream); diff --git a/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStream.test.jsx b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStream.test.jsx new file mode 100644 index 0000000000..cb102b3009 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStream.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import ActivityStream from './ActivityStream'; + +describe('<ActivityStream />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<ActivityStream />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('Card').text()).toContain('Activity stream settings'); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/ActivityStreamDetail.jsx b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/ActivityStreamDetail.jsx new file mode 100644 index 0000000000..58872940c8 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/ActivityStreamDetail.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function ActivityStreamDetail({ i18n }) { + return ( + <CardBody> + {i18n._(t`Detail coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Edit`)} + component={Link} + to="/settings/activity_stream/edit" + > + {i18n._(t`Edit`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(ActivityStreamDetail); diff --git a/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/ActivityStreamDetail.test.jsx b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/ActivityStreamDetail.test.jsx new file mode 100644 index 0000000000..fe7949a139 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/ActivityStreamDetail.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import ActivityStreamDetail from './ActivityStreamDetail'; + +describe('<ActivityStreamDetail />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<ActivityStreamDetail />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('ActivityStreamDetail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/index.js b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/index.js new file mode 100644 index 0000000000..442e39b0e7 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/index.js @@ -0,0 +1 @@ +export { default } from './ActivityStreamDetail'; diff --git a/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamEdit/ActivityStreamEdit.jsx b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamEdit/ActivityStreamEdit.jsx new file mode 100644 index 0000000000..6b11e727c0 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamEdit/ActivityStreamEdit.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function ActivityStreamEdit({ i18n }) { + return ( + <CardBody> + {i18n._(t`Edit form coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Cancel`)} + component={Link} + to="/settings/activity_stream/details" + > + {i18n._(t`Cancel`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(ActivityStreamEdit); diff --git a/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamEdit/ActivityStreamEdit.test.jsx b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamEdit/ActivityStreamEdit.test.jsx new file mode 100644 index 0000000000..a9794b3a69 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamEdit/ActivityStreamEdit.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import ActivityStreamEdit from './ActivityStreamEdit'; + +describe('<ActivityStreamEdit />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<ActivityStreamEdit />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('ActivityStreamEdit').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamEdit/index.js b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamEdit/index.js new file mode 100644 index 0000000000..0818b2b1a3 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamEdit/index.js @@ -0,0 +1 @@ +export { default } from './ActivityStreamEdit'; diff --git a/awx/ui_next/src/screens/Setting/ActivityStream/index.js b/awx/ui_next/src/screens/Setting/ActivityStream/index.js new file mode 100644 index 0000000000..5c0c72d9ef --- /dev/null +++ b/awx/ui_next/src/screens/Setting/ActivityStream/index.js @@ -0,0 +1 @@ +export { default } from './ActivityStream'; diff --git a/awx/ui_next/src/screens/Setting/AzureAD/AzureAD.jsx b/awx/ui_next/src/screens/Setting/AzureAD/AzureAD.jsx new file mode 100644 index 0000000000..ab2f23e4a9 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/AzureAD/AzureAD.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import AzureADDetail from './AzureADDetail'; +import AzureADEdit from './AzureADEdit'; + +function AzureAD({ i18n }) { + const baseUrl = '/settings/azure'; + + return ( + <PageSection> + <Card> + {i18n._(t`Azure AD settings`)} + <Switch> + <Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> + <Route path={`${baseUrl}/details`}> + <AzureADDetail /> + </Route> + <Route path={`${baseUrl}/edit`}> + <AzureADEdit /> + </Route> + </Switch> + </Card> + </PageSection> + ); +} + +export default withI18n()(AzureAD); diff --git a/awx/ui_next/src/screens/Setting/AzureAD/AzureAD.test.jsx b/awx/ui_next/src/screens/Setting/AzureAD/AzureAD.test.jsx new file mode 100644 index 0000000000..84d21a712e --- /dev/null +++ b/awx/ui_next/src/screens/Setting/AzureAD/AzureAD.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import AzureAD from './AzureAD'; + +describe('<AzureAD />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<AzureAD />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('Card').text()).toContain('Azure AD settings'); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx b/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx new file mode 100644 index 0000000000..d4d15d4213 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function AzureADDetail({ i18n }) { + return ( + <CardBody> + {i18n._(t`Detail coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Edit`)} + component={Link} + to="/settings/azure/edit" + > + {i18n._(t`Edit`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(AzureADDetail); diff --git a/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.test.jsx b/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.test.jsx new file mode 100644 index 0000000000..192cb20d15 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import AzureADDetail from './AzureADDetail'; + +describe('<AzureADDetail />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<AzureADDetail />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('AzureADDetail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/index.js b/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/index.js new file mode 100644 index 0000000000..baa4ca3b5e --- /dev/null +++ b/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/index.js @@ -0,0 +1 @@ +export { default } from './AzureADDetail'; diff --git a/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.jsx b/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.jsx new file mode 100644 index 0000000000..3aaf801740 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function AzureADEdit({ i18n }) { + return ( + <CardBody> + {i18n._(t`Edit form coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Cancel`)} + component={Link} + to="/settings/azure/details" + > + {i18n._(t`Cancel`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(AzureADEdit); diff --git a/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.test.jsx b/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.test.jsx new file mode 100644 index 0000000000..33ff2f09d8 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import AzureADEdit from './AzureADEdit'; + +describe('<AzureADEdit />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<AzureADEdit />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('AzureADEdit').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/index.js b/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/index.js new file mode 100644 index 0000000000..879ccbb06e --- /dev/null +++ b/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/index.js @@ -0,0 +1 @@ +export { default } from './AzureADEdit'; diff --git a/awx/ui_next/src/screens/Setting/AzureAD/index.js b/awx/ui_next/src/screens/Setting/AzureAD/index.js new file mode 100644 index 0000000000..3eee0cb78d --- /dev/null +++ b/awx/ui_next/src/screens/Setting/AzureAD/index.js @@ -0,0 +1 @@ +export { default } from './AzureAD'; diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx new file mode 100644 index 0000000000..bd2a2fd121 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import GitHubDetail from './GitHubDetail'; +import GitHubEdit from './GitHubEdit'; + +function GitHub({ i18n }) { + const baseUrl = '/settings/github'; + + return ( + <PageSection> + <Card> + {i18n._(t`GitHub settings`)} + <Switch> + <Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> + <Route path={`${baseUrl}/details`}> + <GitHubDetail /> + </Route> + <Route path={`${baseUrl}/edit`}> + <GitHubEdit /> + </Route> + </Switch> + </Card> + </PageSection> + ); +} + +export default withI18n()(GitHub); diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx new file mode 100644 index 0000000000..25ea0d5ca0 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import GitHub from './GitHub'; + +describe('<GitHub />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<GitHub />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('Card').text()).toContain('GitHub settings'); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx new file mode 100644 index 0000000000..de8ad5ec52 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function GitHubDetail({ i18n }) { + return ( + <CardBody> + {i18n._(t`Detail coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Edit`)} + component={Link} + to="/settings/github/edit" + > + {i18n._(t`Edit`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(GitHubDetail); diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.test.jsx new file mode 100644 index 0000000000..d75fd60ea1 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import GitHubDetail from './GitHubDetail'; + +describe('<GitHubDetail />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<GitHubDetail />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('GitHubDetail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/index.js b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/index.js new file mode 100644 index 0000000000..1edff6684e --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/index.js @@ -0,0 +1 @@ +export { default } from './GitHubDetail'; diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx new file mode 100644 index 0000000000..07a6f45015 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function GitHubEdit({ i18n }) { + return ( + <CardBody> + {i18n._(t`Edit form coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Cancel`)} + component={Link} + to="/settings/github/details" + > + {i18n._(t`Cancel`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(GitHubEdit); diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx new file mode 100644 index 0000000000..539932c99a --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import GitHubEdit from './GitHubEdit'; + +describe('<GitHubEdit />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<GitHubEdit />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('GitHubEdit').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/index.js b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/index.js new file mode 100644 index 0000000000..cf1d354bf5 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/index.js @@ -0,0 +1 @@ +export { default } from './GitHubEdit'; diff --git a/awx/ui_next/src/screens/Setting/GitHub/index.js b/awx/ui_next/src/screens/Setting/GitHub/index.js new file mode 100644 index 0000000000..3bebcf36da --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GitHub/index.js @@ -0,0 +1 @@ +export { default } from './GitHub'; diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2.jsx b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2.jsx new file mode 100644 index 0000000000..1a64384cb5 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import GoogleOAuth2Detail from './GoogleOAuth2Detail'; +import GoogleOAuth2Edit from './GoogleOAuth2Edit'; + +function GoogleOAuth2({ i18n }) { + const baseUrl = '/settings/google_oauth2'; + + return ( + <PageSection> + <Card> + {i18n._(t`Google OAuth 2.0 settings`)} + <Switch> + <Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> + <Route path={`${baseUrl}/details`}> + <GoogleOAuth2Detail /> + </Route> + <Route path={`${baseUrl}/edit`}> + <GoogleOAuth2Edit /> + </Route> + </Switch> + </Card> + </PageSection> + ); +} + +export default withI18n()(GoogleOAuth2); diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2.test.jsx b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2.test.jsx new file mode 100644 index 0000000000..582e2680c5 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import GoogleOAuth2 from './GoogleOAuth2'; + +describe('<GoogleOAuth2 />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<GoogleOAuth2 />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('Card').text()).toContain('Google OAuth 2.0 settings'); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx new file mode 100644 index 0000000000..84188ccf3c --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function GoogleOAuth2Detail({ i18n }) { + return ( + <CardBody> + {i18n._(t`Detail coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Edit`)} + component={Link} + to="/settings/google_oauth2/edit" + > + {i18n._(t`Edit`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(GoogleOAuth2Detail); diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.test.jsx b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.test.jsx new file mode 100644 index 0000000000..a5408a8af6 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import GoogleOAuth2Detail from './GoogleOAuth2Detail'; + +describe('<GoogleOAuth2Detail />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<GoogleOAuth2Detail />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('GoogleOAuth2Detail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/index.js b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/index.js new file mode 100644 index 0000000000..f88168ce7c --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/index.js @@ -0,0 +1 @@ +export { default } from './GoogleOAuth2Detail'; diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.jsx b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.jsx new file mode 100644 index 0000000000..50a546334d --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function GoogleOAuth2Edit({ i18n }) { + return ( + <CardBody> + {i18n._(t`Edit form coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Cancel`)} + component={Link} + to="/settings/google_oauth2/details" + > + {i18n._(t`Cancel`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(GoogleOAuth2Edit); diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.test.jsx b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.test.jsx new file mode 100644 index 0000000000..034a0def4e --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import GoogleOAuth2Edit from './GoogleOAuth2Edit'; + +describe('<GoogleOAuth2Edit />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<GoogleOAuth2Edit />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('GoogleOAuth2Edit').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/index.js b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/index.js new file mode 100644 index 0000000000..2f6d4af0f9 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/index.js @@ -0,0 +1 @@ +export { default } from './GoogleOAuth2Edit'; diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/index.js b/awx/ui_next/src/screens/Setting/GoogleOAuth2/index.js new file mode 100644 index 0000000000..96db4517d5 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/index.js @@ -0,0 +1 @@ +export { default } from './GoogleOAuth2'; diff --git a/awx/ui_next/src/screens/Setting/Jobs/Jobs.jsx b/awx/ui_next/src/screens/Setting/Jobs/Jobs.jsx new file mode 100644 index 0000000000..33a52f7771 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Jobs/Jobs.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import JobsDetail from './JobsDetail'; +import JobsEdit from './JobsEdit'; + +function Jobs({ i18n }) { + const baseUrl = '/settings/jobs'; + + return ( + <PageSection> + <Card> + {i18n._(t`Jobs settings`)} + <Switch> + <Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> + <Route path={`${baseUrl}/details`}> + <JobsDetail /> + </Route> + <Route path={`${baseUrl}/edit`}> + <JobsEdit /> + </Route> + </Switch> + </Card> + </PageSection> + ); +} + +export default withI18n()(Jobs); diff --git a/awx/ui_next/src/screens/Setting/Jobs/Jobs.test.jsx b/awx/ui_next/src/screens/Setting/Jobs/Jobs.test.jsx new file mode 100644 index 0000000000..7a2e767743 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Jobs/Jobs.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import Jobs from './Jobs'; + +describe('<Jobs />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<Jobs />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('Card').text()).toContain('Jobs settings'); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx b/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx new file mode 100644 index 0000000000..4ecb0eb5df --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function JobsDetail({ i18n }) { + return ( + <CardBody> + {i18n._(t`Detail coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Edit`)} + component={Link} + to="/settings/jobs/edit" + > + {i18n._(t`Edit`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(JobsDetail); diff --git a/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.test.jsx b/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.test.jsx new file mode 100644 index 0000000000..80ab5f4795 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import JobsDetail from './JobsDetail'; + +describe('<JobsDetail />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<JobsDetail />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('JobsDetail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/index.js b/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/index.js new file mode 100644 index 0000000000..1cea5fcc5a --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/index.js @@ -0,0 +1 @@ +export { default } from './JobsDetail'; diff --git a/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.jsx b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.jsx new file mode 100644 index 0000000000..7ae08c9276 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function JobsEdit({ i18n }) { + return ( + <CardBody> + {i18n._(t`Edit form coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Cancel`)} + component={Link} + to="/settings/jobs/details" + > + {i18n._(t`Cancel`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(JobsEdit); diff --git a/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.test.jsx b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.test.jsx new file mode 100644 index 0000000000..06f4fb2f12 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import JobsEdit from './JobsEdit'; + +describe('<JobsEdit />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<JobsEdit />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('JobsEdit').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/index.js b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/index.js new file mode 100644 index 0000000000..a7399e9e67 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/index.js @@ -0,0 +1 @@ +export { default } from './JobsEdit'; diff --git a/awx/ui_next/src/screens/Setting/Jobs/index.js b/awx/ui_next/src/screens/Setting/Jobs/index.js new file mode 100644 index 0000000000..9fc254c85c --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Jobs/index.js @@ -0,0 +1 @@ +export { default } from './Jobs'; diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAP.jsx b/awx/ui_next/src/screens/Setting/LDAP/LDAP.jsx new file mode 100644 index 0000000000..f5e9d1b454 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/LDAP/LDAP.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import LDAPDetail from './LDAPDetail'; +import LDAPEdit from './LDAPEdit'; + +function LDAP({ i18n }) { + const baseUrl = '/settings/ldap'; + + return ( + <PageSection> + <Card> + {i18n._(t`LDAP settings`)} + <Switch> + <Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> + <Route path={`${baseUrl}/details`}> + <LDAPDetail /> + </Route> + <Route path={`${baseUrl}/edit`}> + <LDAPEdit /> + </Route> + </Switch> + </Card> + </PageSection> + ); +} + +export default withI18n()(LDAP); diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAP.test.jsx b/awx/ui_next/src/screens/Setting/LDAP/LDAP.test.jsx new file mode 100644 index 0000000000..f67a4dc108 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/LDAP/LDAP.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import LDAP from './LDAP'; + +describe('<LDAP />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<LDAP />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('Card').text()).toContain('LDAP settings'); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx b/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx new file mode 100644 index 0000000000..63e5cfb9b1 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function LDAPDetail({ i18n }) { + return ( + <CardBody> + {i18n._(t`Detail coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Edit`)} + component={Link} + to="/settings/ldap/edit" + > + {i18n._(t`Edit`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(LDAPDetail); diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.test.jsx b/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.test.jsx new file mode 100644 index 0000000000..f4440ace38 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import LDAPDetail from './LDAPDetail'; + +describe('<LDAPDetail />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<LDAPDetail />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('LDAPDetail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/index.js b/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/index.js new file mode 100644 index 0000000000..8bcb7a3206 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/index.js @@ -0,0 +1 @@ +export { default } from './LDAPDetail'; diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.jsx b/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.jsx new file mode 100644 index 0000000000..084df200ed --- /dev/null +++ b/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function LDAPEdit({ i18n }) { + return ( + <CardBody> + {i18n._(t`Edit form coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Cancel`)} + component={Link} + to="/settings/ldap/details" + > + {i18n._(t`Cancel`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(LDAPEdit); diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.test.jsx b/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.test.jsx new file mode 100644 index 0000000000..12ac75a6ed --- /dev/null +++ b/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import LDAPEdit from './LDAPEdit'; + +describe('<LDAPEdit />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<LDAPEdit />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('LDAPEdit').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/index.js b/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/index.js new file mode 100644 index 0000000000..347c49008b --- /dev/null +++ b/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/index.js @@ -0,0 +1 @@ +export { default } from './LDAPEdit'; diff --git a/awx/ui_next/src/screens/Setting/LDAP/index.js b/awx/ui_next/src/screens/Setting/LDAP/index.js new file mode 100644 index 0000000000..30ceea6f47 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/LDAP/index.js @@ -0,0 +1 @@ +export { default } from './LDAP'; diff --git a/awx/ui_next/src/screens/Setting/License/License.jsx b/awx/ui_next/src/screens/Setting/License/License.jsx new file mode 100644 index 0000000000..1d92df41dc --- /dev/null +++ b/awx/ui_next/src/screens/Setting/License/License.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import LicenseDetail from './LicenseDetail'; +import LicenseEdit from './LicenseEdit'; + +function License({ i18n }) { + const baseUrl = '/settings/license'; + + return ( + <PageSection> + <Card> + {i18n._(t`License settings`)} + <Switch> + <Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> + <Route path={`${baseUrl}/details`}> + <LicenseDetail /> + </Route> + <Route path={`${baseUrl}/edit`}> + <LicenseEdit /> + </Route> + </Switch> + </Card> + </PageSection> + ); +} + +export default withI18n()(License); diff --git a/awx/ui_next/src/screens/Setting/License/License.test.jsx b/awx/ui_next/src/screens/Setting/License/License.test.jsx new file mode 100644 index 0000000000..17388ebd2d --- /dev/null +++ b/awx/ui_next/src/screens/Setting/License/License.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import License from './License'; + +describe('<License />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<License />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('Card').text()).toContain('License settings'); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx b/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx new file mode 100644 index 0000000000..233fb40895 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function LicenseDetail({ i18n }) { + return ( + <CardBody> + {i18n._(t`Detail coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Edit`)} + component={Link} + to="/settings/license/edit" + > + {i18n._(t`Edit`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(LicenseDetail); diff --git a/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.test.jsx b/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.test.jsx new file mode 100644 index 0000000000..f744cab073 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import LicenseDetail from './LicenseDetail'; + +describe('<LicenseDetail />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<LicenseDetail />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('LicenseDetail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/License/LicenseDetail/index.js b/awx/ui_next/src/screens/Setting/License/LicenseDetail/index.js new file mode 100644 index 0000000000..efe2514fed --- /dev/null +++ b/awx/ui_next/src/screens/Setting/License/LicenseDetail/index.js @@ -0,0 +1 @@ +export { default } from './LicenseDetail'; diff --git a/awx/ui_next/src/screens/Setting/License/LicenseEdit/LicenseEdit.jsx b/awx/ui_next/src/screens/Setting/License/LicenseEdit/LicenseEdit.jsx new file mode 100644 index 0000000000..38e4eca014 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/License/LicenseEdit/LicenseEdit.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function LicenseEdit({ i18n }) { + return ( + <CardBody> + {i18n._(t`Edit form coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Cancel`)} + component={Link} + to="/settings/license/details" + > + {i18n._(t`Cancel`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(LicenseEdit); diff --git a/awx/ui_next/src/screens/Setting/License/LicenseEdit/LicenseEdit.test.jsx b/awx/ui_next/src/screens/Setting/License/LicenseEdit/LicenseEdit.test.jsx new file mode 100644 index 0000000000..f1e6163948 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/License/LicenseEdit/LicenseEdit.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import LicenseEdit from './LicenseEdit'; + +describe('<LicenseEdit />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<LicenseEdit />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('LicenseEdit').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/License/LicenseEdit/index.js b/awx/ui_next/src/screens/Setting/License/LicenseEdit/index.js new file mode 100644 index 0000000000..04c3fcfb24 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/License/LicenseEdit/index.js @@ -0,0 +1 @@ +export { default } from './LicenseEdit'; diff --git a/awx/ui_next/src/screens/License/index.js b/awx/ui_next/src/screens/Setting/License/index.js index 1bf99773e6..1bf99773e6 100644 --- a/awx/ui_next/src/screens/License/index.js +++ b/awx/ui_next/src/screens/Setting/License/index.js diff --git a/awx/ui_next/src/screens/Setting/Logging/Logging.jsx b/awx/ui_next/src/screens/Setting/Logging/Logging.jsx new file mode 100644 index 0000000000..cd8e9aa81d --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Logging/Logging.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import LoggingDetail from './LoggingDetail'; +import LoggingEdit from './LoggingEdit'; + +function Logging({ i18n }) { + const baseUrl = '/settings/logging'; + + return ( + <PageSection> + <Card> + {i18n._(t`Logging settings`)} + <Switch> + <Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> + <Route path={`${baseUrl}/details`}> + <LoggingDetail /> + </Route> + <Route path={`${baseUrl}/edit`}> + <LoggingEdit /> + </Route> + </Switch> + </Card> + </PageSection> + ); +} + +export default withI18n()(Logging); diff --git a/awx/ui_next/src/screens/Setting/Logging/Logging.test.jsx b/awx/ui_next/src/screens/Setting/Logging/Logging.test.jsx new file mode 100644 index 0000000000..2486d42b59 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Logging/Logging.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import Logging from './Logging'; + +describe('<Logging />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<Logging />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('Card').text()).toContain('Logging settings'); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx b/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx new file mode 100644 index 0000000000..8b10e37e82 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function LoggingDetail({ i18n }) { + return ( + <CardBody> + {i18n._(t`Detail coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Edit`)} + component={Link} + to="/settings/logging/edit" + > + {i18n._(t`Edit`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(LoggingDetail); diff --git a/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.test.jsx b/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.test.jsx new file mode 100644 index 0000000000..384d3b148c --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import LoggingDetail from './LoggingDetail'; + +describe('<LoggingDetail />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<LoggingDetail />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('LoggingDetail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/index.js b/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/index.js new file mode 100644 index 0000000000..c250c9e39d --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/index.js @@ -0,0 +1 @@ +export { default } from './LoggingDetail'; diff --git a/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx b/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx new file mode 100644 index 0000000000..334518c4fb --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function LoggingEdit({ i18n }) { + return ( + <CardBody> + {i18n._(t`Edit form coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Cancel`)} + component={Link} + to="/settings/logging/details" + > + {i18n._(t`Cancel`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(LoggingEdit); diff --git a/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.test.jsx b/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.test.jsx new file mode 100644 index 0000000000..ee1abc72f6 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import LoggingEdit from './LoggingEdit'; + +describe('<LoggingEdit />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<LoggingEdit />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('LoggingEdit').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/index.js b/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/index.js new file mode 100644 index 0000000000..6c43fb33e8 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/index.js @@ -0,0 +1 @@ +export { default } from './LoggingEdit'; diff --git a/awx/ui_next/src/screens/Setting/Logging/index.js b/awx/ui_next/src/screens/Setting/Logging/index.js new file mode 100644 index 0000000000..a764bd5334 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Logging/index.js @@ -0,0 +1 @@ +export { default } from './Logging'; diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystem.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystem.jsx new file mode 100644 index 0000000000..9a15087680 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystem.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import MiscSystemDetail from './MiscSystemDetail'; +import MiscSystemEdit from './MiscSystemEdit'; + +function MiscSystem({ i18n }) { + const baseUrl = '/settings/miscellaneous_system'; + + return ( + <PageSection> + <Card> + {i18n._(t`Miscellaneous system settings`)} + <Switch> + <Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> + <Route path={`${baseUrl}/details`}> + <MiscSystemDetail /> + </Route> + <Route path={`${baseUrl}/edit`}> + <MiscSystemEdit /> + </Route> + </Switch> + </Card> + </PageSection> + ); +} + +export default withI18n()(MiscSystem); diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystem.test.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystem.test.jsx new file mode 100644 index 0000000000..4ac180a6ac --- /dev/null +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystem.test.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import MiscSystem from './MiscSystem'; + +describe('<MiscSystem />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<MiscSystem />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('Card').text()).toContain( + 'Miscellaneous system settings' + ); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx new file mode 100644 index 0000000000..9784f2bdc6 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function MiscSystemDetail({ i18n }) { + return ( + <CardBody> + {i18n._(t`Detail coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Edit`)} + component={Link} + to="/settings/miscellaneous_system/edit" + > + {i18n._(t`Edit`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(MiscSystemDetail); diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.test.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.test.jsx new file mode 100644 index 0000000000..c6dba5b869 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import MiscSystemDetail from './MiscSystemDetail'; + +describe('<MiscSystemDetail />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<MiscSystemDetail />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('MiscSystemDetail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/index.js b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/index.js new file mode 100644 index 0000000000..1976c6d590 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/index.js @@ -0,0 +1 @@ +export { default } from './MiscSystemDetail'; diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.jsx new file mode 100644 index 0000000000..e6fa7fdf18 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function MiscSystemEdit({ i18n }) { + return ( + <CardBody> + {i18n._(t`Edit form coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Cancel`)} + component={Link} + to="/settings/miscellaneous_system/details" + > + {i18n._(t`Cancel`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(MiscSystemEdit); diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.test.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.test.jsx new file mode 100644 index 0000000000..9d3441a413 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import MiscSystemEdit from './MiscSystemEdit'; + +describe('<MiscSystemEdit />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<MiscSystemEdit />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('MiscSystemEdit').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/index.js b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/index.js new file mode 100644 index 0000000000..d1f06f7da1 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/index.js @@ -0,0 +1 @@ +export { default } from './MiscSystemEdit'; diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/index.js b/awx/ui_next/src/screens/Setting/MiscSystem/index.js new file mode 100644 index 0000000000..e504018a6e --- /dev/null +++ b/awx/ui_next/src/screens/Setting/MiscSystem/index.js @@ -0,0 +1 @@ +export { default } from './MiscSystem'; diff --git a/awx/ui_next/src/screens/Setting/Radius/Radius.jsx b/awx/ui_next/src/screens/Setting/Radius/Radius.jsx new file mode 100644 index 0000000000..a3b4780c72 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Radius/Radius.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import RadiusDetail from './RadiusDetail'; +import RadiusEdit from './RadiusEdit'; + +function Radius({ i18n }) { + const baseUrl = '/settings/radius'; + + return ( + <PageSection> + <Card> + {i18n._(t`Radius settings`)} + <Switch> + <Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> + <Route path={`${baseUrl}/details`}> + <RadiusDetail /> + </Route> + <Route path={`${baseUrl}/edit`}> + <RadiusEdit /> + </Route> + </Switch> + </Card> + </PageSection> + ); +} + +export default withI18n()(Radius); diff --git a/awx/ui_next/src/screens/Setting/Radius/Radius.test.jsx b/awx/ui_next/src/screens/Setting/Radius/Radius.test.jsx new file mode 100644 index 0000000000..0337cd3593 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Radius/Radius.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import Radius from './Radius'; + +describe('<Radius />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<Radius />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('Card').text()).toContain('Radius settings'); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Radius/RadiusDetail/RadiusDetail.jsx b/awx/ui_next/src/screens/Setting/Radius/RadiusDetail/RadiusDetail.jsx new file mode 100644 index 0000000000..1453f87573 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Radius/RadiusDetail/RadiusDetail.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function RadiusDetail({ i18n }) { + return ( + <CardBody> + {i18n._(t`Detail coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Edit`)} + component={Link} + to="/settings/radius/edit" + > + {i18n._(t`Edit`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(RadiusDetail); diff --git a/awx/ui_next/src/screens/Setting/Radius/RadiusDetail/RadiusDetail.test.jsx b/awx/ui_next/src/screens/Setting/Radius/RadiusDetail/RadiusDetail.test.jsx new file mode 100644 index 0000000000..84d329116e --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Radius/RadiusDetail/RadiusDetail.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import RadiusDetail from './RadiusDetail'; + +describe('<RadiusDetail />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<RadiusDetail />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('RadiusDetail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Radius/RadiusDetail/index.js b/awx/ui_next/src/screens/Setting/Radius/RadiusDetail/index.js new file mode 100644 index 0000000000..cf4fdebfea --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Radius/RadiusDetail/index.js @@ -0,0 +1 @@ +export { default } from './RadiusDetail'; diff --git a/awx/ui_next/src/screens/Setting/Radius/RadiusEdit/RadiusEdit.jsx b/awx/ui_next/src/screens/Setting/Radius/RadiusEdit/RadiusEdit.jsx new file mode 100644 index 0000000000..62448ead15 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Radius/RadiusEdit/RadiusEdit.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function RadiusEdit({ i18n }) { + return ( + <CardBody> + {i18n._(t`Edit form coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Cancel`)} + component={Link} + to="/settings/radius/details" + > + {i18n._(t`Cancel`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(RadiusEdit); diff --git a/awx/ui_next/src/screens/Setting/Radius/RadiusEdit/RadiusEdit.test.jsx b/awx/ui_next/src/screens/Setting/Radius/RadiusEdit/RadiusEdit.test.jsx new file mode 100644 index 0000000000..bfb517dcbb --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Radius/RadiusEdit/RadiusEdit.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import RadiusEdit from './RadiusEdit'; + +describe('<RadiusEdit />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<RadiusEdit />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('RadiusEdit').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Radius/RadiusEdit/index.js b/awx/ui_next/src/screens/Setting/Radius/RadiusEdit/index.js new file mode 100644 index 0000000000..bb00543488 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Radius/RadiusEdit/index.js @@ -0,0 +1 @@ +export { default } from './RadiusEdit'; diff --git a/awx/ui_next/src/screens/Setting/Radius/index.js b/awx/ui_next/src/screens/Setting/Radius/index.js new file mode 100644 index 0000000000..4bf959792b --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Radius/index.js @@ -0,0 +1 @@ +export { default } from './Radius'; diff --git a/awx/ui_next/src/screens/Setting/SAML/SAML.jsx b/awx/ui_next/src/screens/Setting/SAML/SAML.jsx new file mode 100644 index 0000000000..51db443691 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/SAML/SAML.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import SAMLDetail from './SAMLDetail'; +import SAMLEdit from './SAMLEdit'; + +function SAML({ i18n }) { + const baseUrl = '/settings/saml'; + + return ( + <PageSection> + <Card> + {i18n._(t`SAML settings`)} + <Switch> + <Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> + <Route path={`${baseUrl}/details`}> + <SAMLDetail /> + </Route> + <Route path={`${baseUrl}/edit`}> + <SAMLEdit /> + </Route> + </Switch> + </Card> + </PageSection> + ); +} + +export default withI18n()(SAML); diff --git a/awx/ui_next/src/screens/Setting/SAML/SAML.test.jsx b/awx/ui_next/src/screens/Setting/SAML/SAML.test.jsx new file mode 100644 index 0000000000..ed6f945835 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/SAML/SAML.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import SAML from './SAML'; + +describe('<SAML />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<SAML />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('Card').text()).toContain('SAML settings'); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx new file mode 100644 index 0000000000..1cf5606f61 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function SAMLDetail({ i18n }) { + return ( + <CardBody> + {i18n._(t`Detail coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Edit`)} + component={Link} + to="/settings/saml/edit" + > + {i18n._(t`Edit`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(SAMLDetail); diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.test.jsx b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.test.jsx new file mode 100644 index 0000000000..a420e32e9c --- /dev/null +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import SAMLDetail from './SAMLDetail'; + +describe('<SAMLDetail />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<SAMLDetail />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('SAMLDetail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/index.js b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/index.js new file mode 100644 index 0000000000..61df794e27 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/index.js @@ -0,0 +1 @@ +export { default } from './SAMLDetail'; diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.jsx b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.jsx new file mode 100644 index 0000000000..fc9740b16c --- /dev/null +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function SAMLEdit({ i18n }) { + return ( + <CardBody> + {i18n._(t`Edit form coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Cancel`)} + component={Link} + to="/settings/saml/details" + > + {i18n._(t`Cancel`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(SAMLEdit); diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.test.jsx b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.test.jsx new file mode 100644 index 0000000000..d6319d9b2e --- /dev/null +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import SAMLEdit from './SAMLEdit'; + +describe('<SAMLEdit />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<SAMLEdit />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('SAMLEdit').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/index.js b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/index.js new file mode 100644 index 0000000000..c8c80b8cbe --- /dev/null +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/index.js @@ -0,0 +1 @@ +export { default } from './SAMLEdit'; diff --git a/awx/ui_next/src/screens/Setting/SAML/index.js b/awx/ui_next/src/screens/Setting/SAML/index.js new file mode 100644 index 0000000000..e19b42b241 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/SAML/index.js @@ -0,0 +1 @@ +export { default } from './SAML'; diff --git a/awx/ui_next/src/screens/Setting/SettingList.jsx b/awx/ui_next/src/screens/Setting/SettingList.jsx new file mode 100644 index 0000000000..9f0e6ffe64 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/SettingList.jsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Card as _Card, + CardHeader as _CardHeader, + CardTitle, + DataList, + DataListItem, + DataListCell, + DataListItemCells, + DataListItemRow, + PageSection, +} from '@patternfly/react-core'; +import styled from 'styled-components'; +import { BrandName } from '../../variables'; +import { useConfig } from '../../contexts/Config'; +import ContentLoading from '../../components/ContentLoading/ContentLoading'; + +// Setting BrandName to a variable here is necessary to get the jest tests +// passing. Attempting to use BrandName in the template literal results +// in failing tests. +const brandName = BrandName; + +const SplitLayout = styled(PageSection)` + column-count: 1; + column-gap: 24px; + @media (min-width: 576px) { + column-count: 2; + } +`; +const Card = styled(_Card)` + display: inline-block; + margin-bottom: 24px; + width: 100%; +`; +const CardHeader = styled(_CardHeader)` + align-items: flex-start; + display: flex; + flex-flow: column nowrap; + && > * { + padding: 0; + } +`; +const CardDescription = styled.div` + color: var(--pf-global--palette--black-600); + font-size: var(--pf-global--FontSize--xs); +`; + +function SettingList({ i18n }) { + const config = useConfig(); + const settingRoutes = [ + { + header: i18n._(t`Authentication`), + description: i18n._( + t`Enable simplified login for your ${brandName} applications` + ), + id: 'authentication', + routes: [ + { + title: i18n._(t`Azure AD settings`), + path: '/settings/azure', + }, + { + title: i18n._(t`GitHub settings`), + path: '/settings/github', + }, + { + title: i18n._(t`Google OAuth 2 settings`), + path: '/settings/google_oauth2', + }, + { + title: i18n._(t`LDAP settings`), + path: '/settings/ldap', + }, + { + title: i18n._(t`Radius settings`), + path: '/settings/radius', + }, + { + title: i18n._(t`SAML settings`), + path: '/settings/saml', + }, + { + title: i18n._(t`TACACS+ settings`), + path: '/settings/tacacs', + }, + ], + }, + { + header: i18n._(t`Jobs`), + description: i18n._( + t`Update settings pertaining to Jobs within ${brandName}` + ), + id: 'jobs', + routes: [ + { + title: i18n._(t`Jobs settings`), + path: '/settings/jobs', + }, + ], + }, + { + header: i18n._(t`System`), + description: i18n._(t`Define system-level features and functions`), + id: 'system', + routes: [ + { + title: i18n._(t`Miscellaneous system settings`), + path: '/settings/miscellaneous_system', + }, + { + title: i18n._(t`Activity stream settings`), + path: '/settings/activity_stream', + }, + { + title: i18n._(t`Logging settings`), + path: '/settings/logging', + }, + ], + }, + { + header: i18n._(t`User interface`), + description: i18n._( + t`Set preferences for data collection, logos, and logins` + ), + id: 'user_interface', + routes: [ + { + title: i18n._(t`User interface settings`), + path: '/settings/user_interface', + }, + ], + }, + { + header: i18n._(t`License`), + description: i18n._(t`View and edit your license information`), + id: 'license', + routes: [ + { + title: i18n._(t`License settings`), + path: '/settings/license', + }, + ], + }, + ]; + + if (Object.keys(config).length === 0) { + return ( + <PageSection> + <Card> + <ContentLoading /> + </Card> + </PageSection> + ); + } + + return ( + <SplitLayout> + {settingRoutes.map(({ description, header, id, routes }) => { + if (id === 'license' && config?.license_info?.license_type === 'open') { + return null; + } + return ( + <Card isCompact key={header}> + <CardHeader> + <CardTitle>{header}</CardTitle> + <CardDescription>{description}</CardDescription> + </CardHeader> + <DataList aria-label={`${id}-settings`} isCompact> + {routes.map(({ title, path }) => ( + <DataListItem key={title}> + <DataListItemRow> + <DataListItemCells + dataListCells={[ + <DataListCell key={title}> + <Link to={path}>{title}</Link> + </DataListCell>, + ]} + /> + </DataListItemRow> + </DataListItem> + ))} + </DataList> + </Card> + ); + })} + </SplitLayout> + ); +} + +export default withI18n()(SettingList); diff --git a/awx/ui_next/src/screens/Setting/SettingList.test.jsx b/awx/ui_next/src/screens/Setting/SettingList.test.jsx new file mode 100644 index 0000000000..373d6ba5a4 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/SettingList.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import SettingList from './SettingList'; + +describe('<SettingList />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<SettingList />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/Settings.jsx b/awx/ui_next/src/screens/Setting/Settings.jsx new file mode 100644 index 0000000000..4d8d49830d --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Settings.jsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { Link, Route, Switch, Redirect } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import ContentError from '../../components/ContentError'; +import Breadcrumbs from '../../components/Breadcrumbs'; +import ActivityStream from './ActivityStream'; +import AzureAD from './AzureAD'; +import GitHub from './GitHub'; +import GoogleOAuth2 from './GoogleOAuth2'; +import Jobs from './Jobs'; +import LDAP from './LDAP'; +import License from './License'; +import Logging from './Logging'; +import MiscSystem from './MiscSystem'; +import Radius from './Radius'; +import SAML from './SAML'; +import SettingList from './SettingList'; +import TACACS from './TACACS'; +import UI from './UI'; +import { useConfig } from '../../contexts/Config'; + +function Settings({ i18n }) { + const { license_info = {} } = useConfig(); + const breadcrumbConfig = { + '/settings': i18n._(t`Settings`), + '/settings/activity_stream': i18n._(t`Activity stream`), + '/settings/azure': i18n._(t`Azure AD`), + '/settings/github': i18n._(t`GitHub`), + '/settings/google_oauth2': i18n._(t`Google OAuth2`), + '/settings/jobs': i18n._(t`Jobs`), + '/settings/ldap': i18n._(t`LDAP`), + '/settings/license': i18n._(t`License`), + '/settings/logging': i18n._(t`Logging`), + '/settings/miscellaneous_system': i18n._(t`Miscellaneous system`), + '/settings/radius': i18n._(t`Radius`), + '/settings/saml': i18n._(t`SAML`), + '/settings/tacacs': i18n._(t`TACACS+`), + '/settings/user_interface': i18n._(t`User interface`), + }; + + return ( + <> + <Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> + <Switch> + <Route path="/settings/activity_stream"> + <ActivityStream /> + </Route> + <Route path="/settings/azure"> + <AzureAD /> + </Route> + <Route path="/settings/github"> + <GitHub /> + </Route> + <Route path="/settings/google_oauth2"> + <GoogleOAuth2 /> + </Route> + <Route path="/settings/jobs"> + <Jobs /> + </Route> + <Route path="/settings/ldap"> + <LDAP /> + </Route> + <Route path="/settings/license"> + {license_info?.license_type === 'open' ? ( + <License /> + ) : ( + <Redirect to="/settings" /> + )} + </Route> + <Route path="/settings/logging"> + <Logging /> + </Route> + <Route path="/settings/miscellaneous_system"> + <MiscSystem /> + </Route> + <Route path="/settings/radius"> + <Radius /> + </Route> + <Route path="/settings/saml"> + <SAML /> + </Route> + <Route path="/settings/tacacs"> + <TACACS /> + </Route> + <Route path="/settings/user_interface"> + <UI /> + </Route> + <Route path="/settings" exact> + <SettingList /> + </Route> + <Route key="not-found" path="*"> + <PageSection> + <Card> + <ContentError isNotFound> + <Link to="/settings">{i18n._(t`View all settings`)}</Link> + </ContentError> + </Card> + </PageSection> + </Route> + </Switch> + </> + ); +} + +export default withI18n()(Settings); diff --git a/awx/ui_next/src/screens/Setting/Settings.test.jsx b/awx/ui_next/src/screens/Setting/Settings.test.jsx new file mode 100644 index 0000000000..6f00bf2f5e --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Settings.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import Settings from './Settings'; + +describe('<Settings />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<Settings />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/TACACS/TACACS.jsx b/awx/ui_next/src/screens/Setting/TACACS/TACACS.jsx new file mode 100644 index 0000000000..2ba6e62a3d --- /dev/null +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACS.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import TACACSDetail from './TACACSDetail'; +import TACACSEdit from './TACACSEdit'; + +function TACACS({ i18n }) { + const baseUrl = '/settings/tacacs'; + + return ( + <PageSection> + <Card> + {i18n._(t`TACACS+ settings`)} + <Switch> + <Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> + <Route path={`${baseUrl}/details`}> + <TACACSDetail /> + </Route> + <Route path={`${baseUrl}/edit`}> + <TACACSEdit /> + </Route> + </Switch> + </Card> + </PageSection> + ); +} + +export default withI18n()(TACACS); diff --git a/awx/ui_next/src/screens/Setting/TACACS/TACACS.test.jsx b/awx/ui_next/src/screens/Setting/TACACS/TACACS.test.jsx new file mode 100644 index 0000000000..ccf384382d --- /dev/null +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACS.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import TACACS from './TACACS'; + +describe('<TACACS />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<TACACS />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('Card').text()).toContain('TACACS+ settings'); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.jsx b/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.jsx new file mode 100644 index 0000000000..97568bb63d --- /dev/null +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function TACACSDetail({ i18n }) { + return ( + <CardBody> + {i18n._(t`Detail coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Edit`)} + component={Link} + to="/settings/tacacs/edit" + > + {i18n._(t`Edit`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(TACACSDetail); diff --git a/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.test.jsx b/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.test.jsx new file mode 100644 index 0000000000..0f88fa78fa --- /dev/null +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import TACACSDetail from './TACACSDetail'; + +describe('<TACACSDetail />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<TACACSDetail />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('TACACSDetail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/index.js b/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/index.js new file mode 100644 index 0000000000..1720e8b921 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/index.js @@ -0,0 +1 @@ +export { default } from './TACACSDetail'; diff --git a/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.jsx b/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.jsx new file mode 100644 index 0000000000..8ec22acb07 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function TACACSEdit({ i18n }) { + return ( + <CardBody> + {i18n._(t`Edit form coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Cancel`)} + component={Link} + to="/settings/tacacs/details" + > + {i18n._(t`Cancel`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(TACACSEdit); diff --git a/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.test.jsx b/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.test.jsx new file mode 100644 index 0000000000..529090a34f --- /dev/null +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import TACACSEdit from './TACACSEdit'; + +describe('<TACACSEdit />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<TACACSEdit />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('TACACSEdit').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/index.js b/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/index.js new file mode 100644 index 0000000000..2b95f71aa8 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/index.js @@ -0,0 +1 @@ +export { default } from './TACACSEdit'; diff --git a/awx/ui_next/src/screens/Setting/TACACS/index.js b/awx/ui_next/src/screens/Setting/TACACS/index.js new file mode 100644 index 0000000000..d1cb31279e --- /dev/null +++ b/awx/ui_next/src/screens/Setting/TACACS/index.js @@ -0,0 +1 @@ +export { default } from './TACACS'; diff --git a/awx/ui_next/src/screens/Setting/UI/UI.jsx b/awx/ui_next/src/screens/Setting/UI/UI.jsx new file mode 100644 index 0000000000..f7f0136e1e --- /dev/null +++ b/awx/ui_next/src/screens/Setting/UI/UI.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import UIDetail from './UIDetail'; +import UIEdit from './UIEdit'; + +function UI({ i18n }) { + const baseUrl = '/settings/ui'; + + return ( + <PageSection> + <Card> + {i18n._(t`User interface settings`)} + <Switch> + <Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> + <Route path={`${baseUrl}/details`}> + <UIDetail /> + </Route> + <Route path={`${baseUrl}/edit`}> + <UIEdit /> + </Route> + </Switch> + </Card> + </PageSection> + ); +} + +export default withI18n()(UI); diff --git a/awx/ui_next/src/screens/Setting/UI/UI.test.jsx b/awx/ui_next/src/screens/Setting/UI/UI.test.jsx new file mode 100644 index 0000000000..5d62f597e3 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/UI/UI.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import UI from './UI'; + +describe('<UI />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<UI />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('Card').text()).toContain('User interface settings'); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx b/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx new file mode 100644 index 0000000000..8f031eb7b3 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function UIDetail({ i18n }) { + return ( + <CardBody> + {i18n._(t`Detail coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Edit`)} + component={Link} + to="/settings/ui/edit" + > + {i18n._(t`Edit`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(UIDetail); diff --git a/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.test.jsx b/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.test.jsx new file mode 100644 index 0000000000..7cb27b17a8 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import UIDetail from './UIDetail'; + +describe('<UIDetail />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<UIDetail />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('UIDetail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/UI/UIDetail/index.js b/awx/ui_next/src/screens/Setting/UI/UIDetail/index.js new file mode 100644 index 0000000000..791d1d8873 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/UI/UIDetail/index.js @@ -0,0 +1 @@ +export { default } from './UIDetail'; diff --git a/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx new file mode 100644 index 0000000000..c8d0f4df78 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '../../../../components/Card'; + +function UIEdit({ i18n }) { + return ( + <CardBody> + {i18n._(t`Edit form coming soon :)`)} + <CardActionsRow> + <Button + aria-label={i18n._(t`Cancel`)} + component={Link} + to="/settings/ui/details" + > + {i18n._(t`Cancel`)} + </Button> + </CardActionsRow> + </CardBody> + ); +} + +export default withI18n()(UIEdit); diff --git a/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.test.jsx b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.test.jsx new file mode 100644 index 0000000000..c51fb06fa7 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import UIEdit from './UIEdit'; + +describe('<UIEdit />', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts(<UIEdit />); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders without crashing', () => { + expect(wrapper.find('UIEdit').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/UI/UIEdit/index.js b/awx/ui_next/src/screens/Setting/UI/UIEdit/index.js new file mode 100644 index 0000000000..affad29bf8 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/UI/UIEdit/index.js @@ -0,0 +1 @@ +export { default } from './UIEdit'; diff --git a/awx/ui_next/src/screens/Setting/UI/index.js b/awx/ui_next/src/screens/Setting/UI/index.js new file mode 100644 index 0000000000..a33b447adf --- /dev/null +++ b/awx/ui_next/src/screens/Setting/UI/index.js @@ -0,0 +1 @@ +export { default } from './UI'; diff --git a/awx/ui_next/src/screens/Setting/index.js b/awx/ui_next/src/screens/Setting/index.js new file mode 100644 index 0000000000..63a5e968e4 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/index.js @@ -0,0 +1 @@ +export { default } from './Settings'; diff --git a/awx/ui_next/src/screens/SystemSetting/SystemSettings.jsx b/awx/ui_next/src/screens/SystemSetting/SystemSettings.jsx deleted file mode 100644 index 458658e23c..0000000000 --- a/awx/ui_next/src/screens/SystemSetting/SystemSettings.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { - PageSection, - PageSectionVariants, - Title, -} from '@patternfly/react-core'; - -class SystemSettings extends Component { - render() { - const { i18n } = this.props; - const { light } = PageSectionVariants; - - return ( - <Fragment> - <PageSection variant={light} className="pf-m-condensed"> - <Title size="2xl" headingLevel="h2"> - {i18n._(t`System Settings`)} - </Title> - </PageSection> - <PageSection /> - </Fragment> - ); - } -} - -export default withI18n()(SystemSettings); diff --git a/awx/ui_next/src/screens/SystemSetting/SystemSettings.test.jsx b/awx/ui_next/src/screens/SystemSetting/SystemSettings.test.jsx deleted file mode 100644 index 2a909f36c7..0000000000 --- a/awx/ui_next/src/screens/SystemSetting/SystemSettings.test.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; - -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; - -import SystemSettings from './SystemSettings'; - -describe('<SystemSettings />', () => { - let pageWrapper; - let pageSections; - let title; - - beforeEach(() => { - pageWrapper = mountWithContexts(<SystemSettings />); - pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); - }); - - afterEach(() => { - pageWrapper.unmount(); - }); - - test('initially renders without crashing', () => { - expect(pageWrapper.length).toBe(1); - expect(pageSections.length).toBe(2); - expect(title.length).toBe(1); - expect(title.props().size).toBe('2xl'); - expect(pageSections.first().props().variant).toBe('light'); - }); -}); diff --git a/awx/ui_next/src/screens/SystemSetting/index.js b/awx/ui_next/src/screens/SystemSetting/index.js deleted file mode 100644 index 68b119e97b..0000000000 --- a/awx/ui_next/src/screens/SystemSetting/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './SystemSettings'; diff --git a/awx/ui_next/src/screens/Team/Team.jsx b/awx/ui_next/src/screens/Team/Team.jsx index 52203d3748..a846334ccb 100644 --- a/awx/ui_next/src/screens/Team/Team.jsx +++ b/awx/ui_next/src/screens/Team/Team.jsx @@ -11,12 +11,14 @@ import { } from 'react-router-dom'; import { CaretLeftIcon } from '@patternfly/react-icons'; import { Card, PageSection } from '@patternfly/react-core'; +import { Config } from '../../contexts/Config'; import RoutedTabs from '../../components/RoutedTabs'; import ContentError from '../../components/ContentError'; import TeamDetail from './TeamDetail'; import TeamEdit from './TeamEdit'; import { TeamsAPI } from '../../api'; -import TeamAccessList from './TeamAccess'; +import TeamRolesList from './TeamRoles'; +import { ResourceAccessList } from '../../components/ResourceAccessList'; function Team({ i18n, setBreadcrumb }) { const [team, setTeam] = useState(null); @@ -51,8 +53,8 @@ function Team({ i18n, setBreadcrumb }) { id: 99, }, { name: i18n._(t`Details`), link: `/teams/${id}/details`, id: 0 }, - { name: i18n._(t`Users`), link: `/teams/${id}/users`, id: 1 }, - { name: i18n._(t`Access`), link: `/teams/${id}/access`, id: 2 }, + { name: i18n._(t`Access`), link: `/teams/${id}/access`, id: 1 }, + { name: i18n._(t`Roles`), link: `/teams/${id}/roles`, id: 2 }, ]; let showCardHeader = true; @@ -95,13 +97,15 @@ function Team({ i18n, setBreadcrumb }) { </Route> )} {team && ( - <Route path="/teams/:id/users"> - <span>Coming soon :)</span> + <Route path="/teams/:id/access"> + <ResourceAccessList resource={team} apiModel={TeamsAPI} /> </Route> )} {team && ( - <Route path="/teams/:id/access"> - <TeamAccessList /> + <Route path="/teams/:id/roles"> + <Config> + {({ me }) => <>{me && <TeamRolesList me={me} team={team} />}</>} + </Config> </Route> )} <Route key="not-found" path="*"> diff --git a/awx/ui_next/src/screens/Team/TeamAccess/index.js b/awx/ui_next/src/screens/Team/TeamAccess/index.js deleted file mode 100644 index d249ad2afa..0000000000 --- a/awx/ui_next/src/screens/Team/TeamAccess/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './TeamAccessList'; diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx index 5dc6580274..07de516ca4 100644 --- a/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx +++ b/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx @@ -29,7 +29,13 @@ function TeamList({ i18n }) { const [selected, setSelected] = useState([]); const { - result: { teams, itemCount, actions }, + result: { + teams, + itemCount, + actions, + relatedSearchableKeys, + searchableKeys, + }, error: contentError, isLoading, request: fetchTeams, @@ -44,12 +50,20 @@ function TeamList({ i18n }) { teams: response.data.results, itemCount: response.data.count, actions: actionsResponse.data.actions, + 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]), { teams: [], itemCount: 0, actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -109,20 +123,20 @@ function TeamList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Organization Name`), - key: 'organization__name', + key: 'organization__name__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -131,6 +145,8 @@ function TeamList({ i18n }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( <DataListToolbar {...props} diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.jsx b/awx/ui_next/src/screens/Team/TeamRoles/TeamRoleListItem.jsx index e32199d19e..7c32f50d60 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.jsx +++ b/awx/ui_next/src/screens/Team/TeamRoles/TeamRoleListItem.jsx @@ -11,7 +11,7 @@ import { Link } from 'react-router-dom'; import { DetailList, Detail } from '../../../components/DetailList'; import DataListCell from '../../../components/DataListCell'; -function TeamAccessListItem({ role, i18n, detailUrl, onSelect }) { +function TeamRoleListItem({ role, i18n, detailUrl, onSelect }) { const labelId = `teamRole-${role.id}`; return ( <DataListItem key={role.id} aria-labelledby={labelId} id={`${role.id}`}> @@ -60,4 +60,4 @@ function TeamAccessListItem({ role, i18n, detailUrl, onSelect }) { </DataListItem> ); } -export default withI18n()(TeamAccessListItem); +export default withI18n()(TeamRoleListItem); diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.test.jsx b/awx/ui_next/src/screens/Team/TeamRoles/TeamRoleListItem.test.jsx index 094ba21bf8..3e446a34a8 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamRoles/TeamRoleListItem.test.jsx @@ -1,8 +1,8 @@ import React from 'react'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import TeamAccessListItem from './TeamAccessListItem'; +import TeamRoleListItem from './TeamRoleListItem'; -describe('<TeamAccessListItem/>', () => { +describe('<TeamRoleListItem/>', () => { let wrapper; const role = { id: 1, @@ -20,7 +20,7 @@ describe('<TeamAccessListItem/>', () => { test('should mount properly', () => { wrapper = mountWithContexts( - <TeamAccessListItem + <TeamRoleListItem role={role} detailUrl="/templates/job_template/15/details" /> @@ -31,7 +31,7 @@ describe('<TeamAccessListItem/>', () => { test('should render proper list item data', () => { wrapper = mountWithContexts( - <TeamAccessListItem + <TeamRoleListItem role={role} detailUrl="/templates/job_template/15/details" /> @@ -49,7 +49,7 @@ describe('<TeamAccessListItem/>', () => { }); test('should render deletable chip', () => { wrapper = mountWithContexts( - <TeamAccessListItem + <TeamRoleListItem role={role} detailUrl="/templates/job_template/15/details" /> @@ -59,7 +59,7 @@ describe('<TeamAccessListItem/>', () => { test('should render read only chip', () => { role.summary_fields.user_capabilities.unattach = false; wrapper = mountWithContexts( - <TeamAccessListItem + <TeamRoleListItem role={role} detailUrl="/templates/job_template/15/details" /> diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx b/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.jsx index e29b7d5e5f..31db276045 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx +++ b/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.jsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useLocation, useParams } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -12,14 +12,14 @@ import { Title, } from '@patternfly/react-core'; import { CubesIcon } from '@patternfly/react-icons'; -import { TeamsAPI, RolesAPI } from '../../../api'; +import { TeamsAPI, RolesAPI, UsersAPI } from '../../../api'; import useRequest, { useDeleteItems } from '../../../util/useRequest'; import DataListToolbar from '../../../components/DataListToolbar'; import PaginatedDataList from '../../../components/PaginatedDataList'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import ErrorDetail from '../../../components/ErrorDetail'; import AlertModal from '../../../components/AlertModal'; -import TeamAccessListItem from './TeamAccessListItem'; +import TeamRoleListItem from './TeamRoleListItem'; import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd'; const QS_CONFIG = getQSConfig('roles', { @@ -28,17 +28,22 @@ const QS_CONFIG = getQSConfig('roles', { order_by: 'id', }); -function TeamAccessList({ i18n }) { +function TeamRolesList({ i18n, me, team }) { const [isWizardOpen, setIsWizardOpen] = useState(false); const { search } = useLocation(); - const { id } = useParams(); const [roleToDisassociate, setRoleToDisassociate] = useState(null); const { isLoading, request: fetchRoles, contentError, - result: { roleCount, roles, options }, + result: { + roleCount, + roles, + isAdminOfOrg, + relatedSearchableKeys, + searchableKeys, + }, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, search); @@ -46,18 +51,33 @@ function TeamAccessList({ i18n }) { { data: { results, count }, }, - { - data: { actions }, - }, + { count: orgAdminCount }, + actionsResponse, ] = await Promise.all([ - TeamsAPI.readRoles(id, params), - TeamsAPI.readRoleOptions(id), + TeamsAPI.readRoles(team.id, params), + UsersAPI.readAdminOfOrganizations(me.id, { + id: team.organization, + }), + TeamsAPI.readRoleOptions(team.id), ]); - return { roleCount: count, roles: results, options: actions }; - }, [id, search]), + return { + roleCount: count, + roles: results, + isAdminOfOrg: orgAdminCount > 0, + 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), + }; + }, [me.id, team.id, team.organization, search]), { roles: [], roleCount: 0, + isAdminOfOrg: false, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -79,15 +99,13 @@ function TeamAccessList({ i18n }) { setRoleToDisassociate(null); await RolesAPI.disassociateTeamRole( roleToDisassociate.id, - parseInt(id, 10) + parseInt(team.id, 10) ); - }, [roleToDisassociate, id]), + }, [roleToDisassociate, team.id]), { qsConfig: QS_CONFIG, fetchItems: fetchRoles } ); - const canAdd = - options && Object.prototype.hasOwnProperty.call(options, 'POST'); - + const canAdd = team?.summary_fields?.user_capabilities?.edit || isAdminOfOrg; const detailUrl = role => { const { resource_id, resource_type } = role.summary_fields; @@ -128,21 +146,23 @@ function TeamAccessList({ i18n }) { hasContentLoading={isLoading || isDisassociateLoading} items={roles} itemCount={roleCount} - pluralizedItemName={i18n._(t`Teams`)} + pluralizedItemName={i18n._(t`Team Roles`)} qsConfig={QS_CONFIG} toolbarSearchColumns={[ { name: i18n._(t`Role`), - key: 'role_field', + key: 'role_field__icontains', isDefault: true, }, ]} toolbarSortColumns={[ { - name: i18n._(t`Name`), + name: i18n._(t`ID`), key: 'id', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( <DataListToolbar {...props} @@ -157,7 +177,7 @@ function TeamAccessList({ i18n }) { setIsWizardOpen(true); }} > - Add + {i18n._(t`Add`)} </Button>, ] : []), @@ -165,7 +185,7 @@ function TeamAccessList({ i18n }) { /> )} renderItem={role => ( - <TeamAccessListItem + <TeamRoleListItem key={role.id} role={role} detailUrl={detailUrl(role)} @@ -234,4 +254,4 @@ function TeamAccessList({ i18n }) { </> ); } -export default withI18n()(TeamAccessList); +export default withI18n()(TeamRolesList); diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx b/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.test.jsx index 5828d162df..2b6158a174 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.test.jsx @@ -1,21 +1,82 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { TeamsAPI, RolesAPI } from '../../../api'; +import { TeamsAPI, RolesAPI, UsersAPI } from '../../../api'; import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; -import TeamAccessList from './TeamAccessList'; +import TeamRolesList from './TeamRolesList'; jest.mock('../../../api/models/Teams'); jest.mock('../../../api/models/Roles'); +jest.mock('../../../api/models/Users'); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ - id: 18, - }), -})); +const me = { + id: 1, +}; + +const team = { + id: 18, + type: 'team', + url: '/api/v2/teams/1/', + related: { + created_by: '/api/v2/users/1/', + modified_by: '/api/v2/users/1/', + projects: '/api/v2/teams/1/projects/', + users: '/api/v2/teams/1/users/', + credentials: '/api/v2/teams/1/credentials/', + roles: '/api/v2/teams/1/roles/', + object_roles: '/api/v2/teams/1/object_roles/', + activity_stream: '/api/v2/teams/1/activity_stream/', + access_list: '/api/v2/teams/1/access_list/', + organization: '/api/v2/organizations/1/', + }, + summary_fields: { + organization: { + id: 1, + name: 'Default', + description: '', + }, + created_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + modified_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + object_roles: { + admin_role: { + description: 'Can manage all aspects of the team', + name: 'Admin', + id: 33, + }, + member_role: { + description: 'User is a member of the team', + name: 'Member', + id: 34, + }, + read_role: { + description: 'May view settings for the team', + name: 'Read', + id: 35, + }, + }, + user_capabilities: { + edit: false, + delete: false, + }, + }, + created: '2020-07-22T18:21:54.233411Z', + modified: '2020-07-22T18:21:54.233442Z', + name: 'a team', + description: '', + organization: 1, +}; const roles = { data: { @@ -89,32 +150,47 @@ const roles = { count: 5, }, }; -const options = { - data: { actions: { POST: { id: 1, disassociate: true } } }, -}; -describe('<TeamAccessList />', () => { + +describe('<TeamRolesList />', () => { let wrapper; + beforeEach(() => { + UsersAPI.readAdminOfOrganizations.mockResolvedValue({ + count: 1, + results: [ + { + id: 1, + name: 'Foo Org', + }, + ], + }); + + TeamsAPI.readRoleOptions.mockResolvedValue({ + data: { + actions: { GET: {} }, + related_search_fields: [], + }, + }); + }); + afterEach(() => { jest.clearAllMocks(); wrapper.unmount(); }); test('should render properly', async () => { TeamsAPI.readRoles.mockResolvedValue(roles); - TeamsAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(<TeamAccessList />); + wrapper = mountWithContexts(<TeamRolesList me={me} team={team} />); }); - expect(wrapper.find('TeamAccessList').length).toBe(1); + expect(wrapper.find('TeamRolesList').length).toBe(1); }); test('should create proper detailUrl', async () => { TeamsAPI.readRoles.mockResolvedValue(roles); - TeamsAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(<TeamAccessList />); + wrapper = mountWithContexts(<TeamRolesList me={me} team={team} />); }); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); @@ -134,9 +210,10 @@ describe('<TeamAccessList />', () => { '/inventories/smart_inventory/77/details' ); }); - test('should not render add button', async () => { - TeamsAPI.readRoleOptions.mockResolvedValueOnce({ - data: {}, + test('should not render add button when user cannot edit team and is not an admin of the org', async () => { + UsersAPI.readAdminOfOrganizations.mockResolvedValueOnce({ + count: 0, + results: [], }); TeamsAPI.readRoles.mockResolvedValue({ @@ -160,8 +237,9 @@ describe('<TeamAccessList />', () => { count: 1, }, }); + await act(async () => { - wrapper = mountWithContexts(<TeamAccessList />); + wrapper = mountWithContexts(<TeamRolesList me={me} team={team} />); }); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); @@ -172,10 +250,9 @@ describe('<TeamAccessList />', () => { test('should render disassociate modal', async () => { TeamsAPI.readRoles.mockResolvedValue(roles); - TeamsAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(<TeamAccessList />); + wrapper = mountWithContexts(<TeamRolesList me={me} team={team} />); }); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); @@ -225,10 +302,9 @@ describe('<TeamAccessList />', () => { }, }) ); - TeamsAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(<TeamAccessList />); + wrapper = mountWithContexts(<TeamRolesList me={me} team={team} />); }); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); @@ -282,10 +358,9 @@ describe('<TeamAccessList />', () => { count: 1, }, }); - TeamsAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(<TeamAccessList />); + wrapper = mountWithContexts(<TeamRolesList me={me} team={team} />); }); waitForElement( diff --git a/awx/ui_next/src/screens/Team/TeamRoles/index.js b/awx/ui_next/src/screens/Team/TeamRoles/index.js new file mode 100644 index 0000000000..67d96f34fd --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamRoles/index.js @@ -0,0 +1 @@ +export { default } from './TeamRolesList'; diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx index 4bcae68e9d..43897e3803 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx @@ -241,7 +241,9 @@ describe('<JobTemplateAdd />', () => { }); }); await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); - wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); expect(history.location.pathname).toEqual('/templates'); }); }); diff --git a/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx index 5d6b738a41..dd06e0bca9 100644 --- a/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx +++ b/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx @@ -1,11 +1,20 @@ import React, { useState } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { DataList, Button as _Button } from '@patternfly/react-core'; +import { useRouteMatch } from 'react-router-dom'; +import { + DataList, + Button as _Button, + Title, + EmptyState, + EmptyStateIcon, + EmptyStateBody, +} from '@patternfly/react-core'; +import { CubesIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; import ContentLoading from '../../../components/ContentLoading'; -import ContentEmpty from '../../../components/ContentEmpty'; import AlertModal from '../../../components/AlertModal'; +import { ToolbarAddButton } from '../../../components/PaginatedDataList'; import SurveyListItem from './SurveyListItem'; import SurveyToolbar from './SurveyToolbar'; @@ -25,6 +34,8 @@ function SurveyList({ canEdit, i18n, }) { + const match = useRouteMatch(); + const questions = survey?.spec || []; const [selected, setSelected] = useState([]); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -78,13 +89,6 @@ function SurveyList({ let content; if (isLoading) { content = <ContentLoading />; - } else if (!questions || questions?.length <= 0) { - content = ( - <ContentEmpty - title={i18n._(t`No Survey Questions Found`)} - message={i18n._(t`Please add survey questions.`)} - /> - ); } else { content = ( <DataList aria-label={i18n._(t`Survey List`)}> @@ -163,6 +167,20 @@ function SurveyList({ </AlertModal> ); } + if (!questions || questions?.length <= 0) { + return ( + <EmptyState variant="full"> + <EmptyStateIcon icon={CubesIcon} /> + <Title size="lg" headingLevel="h3"> + {i18n._(t`No survey questions found.`)} + </Title> + <EmptyStateBody> + {i18n._(t`Please add survey questions.`)} + </EmptyStateBody> + <ToolbarAddButton isDisabled={!canEdit} linkTo={`${match.url}/add`} /> + </EmptyState> + ); + } return ( <> <SurveyToolbar diff --git a/awx/ui_next/src/screens/Template/Survey/SurveyList.test.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyList.test.jsx index bdc70c4fff..d1ee6a8e17 100644 --- a/awx/ui_next/src/screens/Template/Survey/SurveyList.test.jsx +++ b/awx/ui_next/src/screens/Template/Survey/SurveyList.test.jsx @@ -163,7 +163,7 @@ describe('Survey with no questions', () => { <SurveyList template={mockJobTemplateData} /> ); }); - expect(wrapper.find('ContentEmpty').length).toBe(1); + expect(wrapper.find('EmptyState').length).toBe(1); expect(wrapper.find('SurveyListItem').length).toBe(0); }); }); diff --git a/awx/ui_next/src/screens/Template/Survey/SurveyQuestionForm.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyQuestionForm.jsx index a55e21a4f6..ff57f3bf17 100644 --- a/awx/ui_next/src/screens/Template/Survey/SurveyQuestionForm.jsx +++ b/awx/ui_next/src/screens/Template/Survey/SurveyQuestionForm.jsx @@ -29,16 +29,18 @@ function AnswerTypeField({ i18n }) { return ( <FormGroup label={i18n._(t`Answer Type`)} + labelIcon={ + <FieldTooltip + content={i18n._( + t`Choose an answer type or format you want as the prompt for the user. + Refer to the Ansible Tower Documentation for more additional + information about each option.` + )} + /> + } isRequired fieldId="question-answer-type" > - <FieldTooltip - content={i18n._( - t`Choose an answer type or format you want as the prompt for the user. - Refer to the Ansible Tower Documentation for more additional - information about each option.` - )} - /> <AnsibleSelect id="question-type" {...field} diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx index 5be5cf580f..455bf4ce6c 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx @@ -17,7 +17,7 @@ import PaginatedDataList, { } from '../../../components/PaginatedDataList'; import useRequest, { useDeleteItems } from '../../../util/useRequest'; import { getQSConfig, parseQueryString } from '../../../util/qs'; - +import useWsTemplates from './useWsTemplates'; import AddDropDownButton from '../../../components/AddDropDownButton'; import TemplateListItem from './TemplateListItem'; @@ -36,30 +36,46 @@ function TemplateList({ i18n }) { const [selected, setSelected] = useState([]); const { - result: { templates, count, jtActions, wfjtActions }, + result: { + results, + count, + jtActions, + wfjtActions, + relatedSearchableKeys, + searchableKeys, + }, error: contentError, isLoading, request: fetchTemplates, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const results = await Promise.all([ + const responses = await Promise.all([ UnifiedJobTemplatesAPI.read(params), JobTemplatesAPI.readOptions(), WorkflowJobTemplatesAPI.readOptions(), + UnifiedJobTemplatesAPI.readOptions(), ]); return { - templates: results[0].data.results, - count: results[0].data.count, - jtActions: results[1].data.actions, - wfjtActions: results[2].data.actions, + results: responses[0].data.results, + count: responses[0].data.count, + jtActions: responses[1].data.actions, + wfjtActions: responses[2].data.actions, + relatedSearchableKeys: ( + responses[3]?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: Object.keys( + responses[3].data.actions?.GET || {} + ).filter(key => responses[3].data.actions?.GET[key].filterable), }; }, [location]), { - templates: [], + results: [], count: 0, jtActions: {}, wfjtActions: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -67,6 +83,8 @@ function TemplateList({ i18n }) { fetchTemplates(); }, [fetchTemplates]); + const templates = useWsTemplates(results); + const isAllSelected = selected.length === templates.length && selected.length > 0; const { @@ -116,11 +134,12 @@ function TemplateList({ i18n }) { jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST'); const canAddWFJT = wfjtActions && Object.prototype.hasOwnProperty.call(wfjtActions, 'POST'); + // spreading Set() returns only unique keys const addButtonOptions = []; if (canAddJT) { addButtonOptions.push({ - label: i18n._(t`Template`), + label: i18n._(t`Job Template`), url: `/templates/job_template/add/`, }); } @@ -150,16 +169,16 @@ function TemplateList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Description`), - key: 'description', + key: 'description__icontains', }, { name: i18n._(t`Type`), - key: 'type', + key: 'or__type', options: [ [`job_template`, i18n._(t`Job Template`)], [`workflow_job_template`, i18n._(t`Workflow Template`)], @@ -167,15 +186,15 @@ function TemplateList({ i18n }) { }, { name: i18n._(t`Playbook name`), - key: 'job_template__playbook', + key: 'job_template__playbook__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -204,11 +223,12 @@ function TemplateList({ i18n }) { key: 'type', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( <DatalistToolbar {...props} showSelectAll - showExpandCollapse isAllSelected={isAllSelected} onSelectAll={handleSelectAll} qsConfig={QS_CONFIG} diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx index b04ef9c35b..377e87583e 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx @@ -75,6 +75,7 @@ const mockTemplates = [ ]; describe('<TemplateList />', () => { + let debug; beforeEach(() => { UnifiedJobTemplatesAPI.read.mockResolvedValue({ data: { @@ -88,10 +89,13 @@ describe('<TemplateList />', () => { actions: [], }, }); + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; }); afterEach(() => { jest.clearAllMocks(); + global.console.debug = debug; }); test('initially renders successfully', async () => { diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx index b31b1d0ec7..716c83e608 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx @@ -78,7 +78,7 @@ function TemplateListItem({ /> <DataListItemCells dataListCells={[ - <DataListCell key="divider"> + <DataListCell key="name" id={labelId}> <span> <Link to={`${detailUrl}`}> <b>{template.name}</b> @@ -105,11 +105,7 @@ function TemplateListItem({ </DataListCell>, ]} /> - <DataListAction - aria-label="actions" - aria-labelledby={labelId} - id={labelId} - > + <DataListAction aria-label="actions" aria-labelledby={labelId}> {canLaunch && template.type === 'job_template' && ( <Tooltip content={i18n._(t`Launch Template`)} position="top"> <LaunchButton resource={template}> diff --git a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js new file mode 100644 index 0000000000..fa10424c85 --- /dev/null +++ b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js @@ -0,0 +1,63 @@ +import { useState, useEffect } from 'react'; +import useWebsocket from '../../../util/useWebsocket'; + +export default function useWsTemplates(initialTemplates) { + const [templates, setTemplates] = useState(initialTemplates); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); + + useEffect(() => { + setTemplates(initialTemplates); + }, [initialTemplates]); + + useEffect( + function parseWsMessage() { + if (!lastMessage?.unified_job_id) { + return; + } + const index = templates.findIndex( + t => t.id === lastMessage.unified_job_template_id + ); + if (index === -1) { + return; + } + + const template = templates[index]; + const updated = [...templates]; + updated[index] = updateTemplate(template, lastMessage); + setTemplates(updated); + }, + [lastMessage] // eslint-disable-line react-hooks/exhaustive-deps + ); + + return templates; +} + +function updateTemplate(template, message) { + const recentJobs = [...(template.summary_fields.recent_jobs || [])]; + const job = { + id: message.unified_job_id, + status: message.status, + finished: message.finished || null, + type: message.type, + }; + const index = recentJobs.findIndex(j => j.id === job.id); + if (index > -1) { + recentJobs[index] = { + ...recentJobs[index], + ...job, + }; + } else { + recentJobs.unshift(job); + } + + return { + ...template, + summary_fields: { + ...template.summary_fields, + recent_jobs: recentJobs.slice(0, 10), + }, + }; +} diff --git a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx new file mode 100644 index 0000000000..61bc6e042e --- /dev/null +++ b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import WS from 'jest-websocket-mock'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import useWsTemplates from './useWsTemplates'; + +/* + Jest mock timers don’t play well with jest-websocket-mock, + so we'll stub out throttling to resolve immediately +*/ +jest.mock('../../../util/useThrottle', () => ({ + __esModule: true, + default: jest.fn(val => val), +})); + +function TestInner() { + return <div />; +} +function Test({ templates }) { + const syncedTemplates = useWsTemplates(templates); + return <TestInner templates={syncedTemplates} />; +} + +describe('useWsTemplates hook', () => { + let debug; + let wrapper; + beforeEach(() => { + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; + }); + + afterEach(() => { + global.console.debug = debug; + }); + + test('should return templates list', () => { + const templates = [{ id: 1 }]; + wrapper = mountWithContexts(<Test templates={templates} />); + + expect(wrapper.find('TestInner').prop('templates')).toEqual(templates); + WS.clean(); + }); + + test('should establish websocket connection', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const templates = [{ id: 1 }]; + await act(async () => { + wrapper = await mountWithContexts(<Test templates={templates} />); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + WS.clean(); + }); + + test('should update recent job status', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const templates = [ + { + id: 1, + summary_fields: { + recent_jobs: [ + { + id: 10, + type: 'job', + status: 'running', + }, + { + id: 11, + type: 'job', + status: 'successful', + }, + ], + }, + }, + ]; + await act(async () => { + wrapper = await mountWithContexts(<Test templates={templates} />); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + expect( + wrapper.find('TestInner').prop('templates')[0].summary_fields + .recent_jobs[0].status + ).toEqual('running'); + act(() => { + mockServer.send( + JSON.stringify({ + unified_job_template_id: 1, + unified_job_id: 10, + type: 'job', + status: 'successful', + }) + ); + }); + wrapper.update(); + + expect( + wrapper.find('TestInner').prop('templates')[0].summary_fields + .recent_jobs[0].status + ).toEqual('successful'); + WS.clean(); + }); + + test('should add new job status', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const templates = [ + { + id: 1, + summary_fields: { + recent_jobs: [ + { + id: 10, + type: 'job', + status: 'running', + }, + { + id: 11, + type: 'job', + status: 'successful', + }, + ], + }, + }, + ]; + await act(async () => { + wrapper = await mountWithContexts(<Test templates={templates} />); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + expect( + wrapper.find('TestInner').prop('templates')[0].summary_fields + .recent_jobs[0].status + ).toEqual('running'); + act(() => { + mockServer.send( + JSON.stringify({ + unified_job_template_id: 1, + unified_job_id: 13, + type: 'job', + status: 'running', + }) + ); + }); + wrapper.update(); + + expect( + wrapper.find('TestInner').prop('templates')[0].summary_fields.recent_jobs + ).toHaveLength(3); + expect( + wrapper.find('TestInner').prop('templates')[0].summary_fields + .recent_jobs[0] + ).toEqual({ + id: 13, + status: 'running', + finished: null, + type: 'job', + }); + WS.clean(); + }); +}); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx index bc4d13db48..2ccdbef448 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx @@ -1,10 +1,11 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { func, shape } from 'prop-types'; import { InventorySourcesAPI } from '../../../../../../api'; import { getQSConfig, parseQueryString } from '../../../../../../util/qs'; +import useRequest from '../../../../../../util/useRequest'; import PaginatedDataList from '../../../../../../components/PaginatedDataList'; import DataListToolbar from '../../../../../../components/DataListToolbar'; import CheckboxListItem from '../../../../../../components/CheckboxListItem'; @@ -16,29 +17,31 @@ const QS_CONFIG = getQSConfig('inventory_sources', { }); function InventorySourcesList({ i18n, nodeResource, onUpdateNodeResource }) { - const [count, setCount] = useState(0); - const [error, setError] = useState(null); - const [inventorySources, setInventorySources] = useState([]); - const [isLoading, setIsLoading] = useState(true); const location = useLocation(); - useEffect(() => { - (async () => { - setIsLoading(true); - setInventorySources([]); - setCount(0); + const { + result: { inventorySources, count }, + error, + isLoading, + request: fetchInventorySources, + } = useRequest( + useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - try { - const { data } = await InventorySourcesAPI.read(params); - setInventorySources(data.results); - setCount(data.count); - } catch (err) { - setError(err); - } finally { - setIsLoading(false); - } - })(); - }, [location]); + const results = await InventorySourcesAPI.read(params); + return { + inventorySources: results.data.results, + count: results.data.count, + }; + }, [location]), + { + inventorySources: [], + count: 0, + } + ); + + useEffect(() => { + fetchInventorySources(); + }, [fetchInventorySources]); return ( <PaginatedDataList @@ -65,15 +68,15 @@ function InventorySourcesList({ i18n, nodeResource, onUpdateNodeResource }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Source`), - key: 'source', + key: 'or__source', options: [ - [`file`, i18n._(t`File, Directory or Script`)], - [`scm`, i18n._(t`Sourced from a Project`)], + [`file`, i18n._(t`File, directory or script`)], + [`scm`, i18n._(t`Sourced from a project`)], [`ec2`, i18n._(t`Amazon EC2`)], [`gce`, i18n._(t`Google Compute Engine`)], [`azure_rm`, i18n._(t`Microsoft Azure Resource Manager`)], @@ -82,7 +85,6 @@ function InventorySourcesList({ i18n, nodeResource, onUpdateNodeResource }) { [`openstack`, i18n._(t`OpenStack`)], [`rhv`, i18n._(t`Red Hat Virtualization`)], [`tower`, i18n._(t`Ansible Tower`)], - [`custom`, i18n._(t`Custom Script`)], ], }, ]} diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx index 58e2078d7a..f6790b3332 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx @@ -1,10 +1,11 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { func, shape } from 'prop-types'; import { JobTemplatesAPI } from '../../../../../../api'; import { getQSConfig, parseQueryString } from '../../../../../../util/qs'; +import useRequest from '../../../../../../util/useRequest'; import PaginatedDataList from '../../../../../../components/PaginatedDataList'; import DataListToolbar from '../../../../../../components/DataListToolbar'; import CheckboxListItem from '../../../../../../components/CheckboxListItem'; @@ -16,32 +17,33 @@ const QS_CONFIG = getQSConfig('job_templates', { }); function JobTemplatesList({ i18n, nodeResource, onUpdateNodeResource }) { - const [count, setCount] = useState(0); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [jobTemplates, setJobTemplates] = useState([]); - const location = useLocation(); - useEffect(() => { - (async () => { - setIsLoading(true); - setJobTemplates([]); - setCount(0); + const { + result: { jobTemplates, count }, + error, + isLoading, + request: fetchJobTemplates, + } = useRequest( + useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - try { - const { data } = await JobTemplatesAPI.read(params, { - role_level: 'execute_role', - }); - setJobTemplates(data.results); - setCount(data.count); - } catch (err) { - setError(err); - } finally { - setIsLoading(false); - } - })(); - }, [location]); + const results = await JobTemplatesAPI.read(params, { + role_level: 'execute_role', + }); + return { + jobTemplates: results.data.results, + count: results.data.count, + }; + }, [location]), + { + jobTemplates: [], + count: 0, + } + ); + + useEffect(() => { + fetchJobTemplates(); + }, [fetchJobTemplates]); return ( <PaginatedDataList @@ -68,20 +70,20 @@ function JobTemplatesList({ i18n, nodeResource, onUpdateNodeResource }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Playbook name`), - key: 'playbook', + key: 'playbook__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/ProjectsList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/ProjectsList.jsx index bb8d58006e..e7dfa098c9 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/ProjectsList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/ProjectsList.jsx @@ -1,10 +1,11 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { func, shape } from 'prop-types'; import { ProjectsAPI } from '../../../../../../api'; import { getQSConfig, parseQueryString } from '../../../../../../util/qs'; +import useRequest from '../../../../../../util/useRequest'; import PaginatedDataList from '../../../../../../components/PaginatedDataList'; import DataListToolbar from '../../../../../../components/DataListToolbar'; import CheckboxListItem from '../../../../../../components/CheckboxListItem'; @@ -16,30 +17,31 @@ const QS_CONFIG = getQSConfig('projects', { }); function ProjectsList({ i18n, nodeResource, onUpdateNodeResource }) { - const [count, setCount] = useState(0); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [projects, setProjects] = useState([]); - const location = useLocation(); - useEffect(() => { - (async () => { - setIsLoading(true); - setProjects([]); - setCount(0); + const { + result: { projects, count }, + error, + isLoading, + request: fetchProjects, + } = useRequest( + useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - try { - const { data } = await ProjectsAPI.read(params); - setProjects(data.results); - setCount(data.count); - } catch (err) { - setError(err); - } finally { - setIsLoading(false); - } - })(); - }, [location]); + const results = await ProjectsAPI.read(params); + return { + projects: results.data.results, + count: results.data.count, + }; + }, [location]), + { + projects: [], + count: 0, + } + ); + + useEffect(() => { + fetchProjects(); + }, [fetchProjects]); return ( <PaginatedDataList @@ -66,12 +68,12 @@ function ProjectsList({ i18n, nodeResource, onUpdateNodeResource }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Type`), - key: 'scm_type', + key: 'or__scm_type', options: [ [``, i18n._(t`Manual`)], [`git`, i18n._(t`Git`)], @@ -82,15 +84,15 @@ function ProjectsList({ i18n, nodeResource, onUpdateNodeResource }) { }, { name: i18n._(t`Source Control URL`), - key: 'scm_url', + key: 'scm_url__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, ]} toolbarSortColumns={[ diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx index f614bb4efc..a51f3b364b 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx @@ -1,10 +1,11 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { func, shape } from 'prop-types'; import { WorkflowJobTemplatesAPI } from '../../../../../../api'; import { getQSConfig, parseQueryString } from '../../../../../../util/qs'; +import useRequest from '../../../../../../util/useRequest'; import PaginatedDataList from '../../../../../../components/PaginatedDataList'; import DataListToolbar from '../../../../../../components/DataListToolbar'; import CheckboxListItem from '../../../../../../components/CheckboxListItem'; @@ -20,32 +21,33 @@ function WorkflowJobTemplatesList({ nodeResource, onUpdateNodeResource, }) { - const [count, setCount] = useState(0); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [workflowJobTemplates, setWorkflowJobTemplates] = useState([]); - const location = useLocation(); - useEffect(() => { - (async () => { - setIsLoading(true); - setWorkflowJobTemplates([]); - setCount(0); + const { + result: { workflowJobTemplates, count }, + error, + isLoading, + request: fetchWorkflowJobTemplates, + } = useRequest( + useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - try { - const { data } = await WorkflowJobTemplatesAPI.read(params, { - role_level: 'execute_role', - }); - setWorkflowJobTemplates(data.results); - setCount(data.count); - } catch (err) { - setError(err); - } finally { - setIsLoading(false); - } - })(); - }, [location]); + const results = await WorkflowJobTemplatesAPI.read(params, { + role_level: 'execute_role', + }); + return { + workflowJobTemplates: results.data.results, + count: results.data.count, + }; + }, [location]), + { + workflowJobTemplates: [], + count: 0, + } + ); + + useEffect(() => { + fetchWorkflowJobTemplates(); + }, [fetchWorkflowJobTemplates]); return ( <PaginatedDataList @@ -72,24 +74,24 @@ function WorkflowJobTemplatesList({ toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Organization (Name)`), - key: 'organization__name', + key: 'organization__name__icontains', }, { name: i18n._(t`Inventory (Name)`), - key: 'inventory__name', + key: 'inventory__name__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 4fa38d2fd6..c9c5bdf3db 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -298,10 +298,14 @@ function JobTemplateForm({ } isRequired label={i18n._(t`Playbook`)} + labelIcon={ + <FieldTooltip + content={i18n._( + t`Select the playbook to be executed by this job.` + )} + /> + } > - <FieldTooltip - content={i18n._(t`Select the playbook to be executed by this job.`)} - /> <PlaybookSelect projectId={projectField.value?.id} isValid={!playbookMeta.touched || !playbookMeta.error} @@ -330,12 +334,17 @@ function JobTemplateForm({ onError={setContentError} /> </FieldWithPrompt> - <FormGroup label={i18n._(t`Labels`)} fieldId="template-labels"> - <FieldTooltip - content={i18n._(t`Optional labels that describe this job template, + <FormGroup + label={i18n._(t`Labels`)} + labelIcon={ + <FieldTooltip + content={i18n._(t`Optional labels that describe this job template, such as 'dev' or 'test'. Labels can be used to group and filter job templates and completed jobs.`)} - /> + /> + } + fieldId="template-labels" + > <LabelSelect value={labelsField.value} onChange={labels => labelsHelpers.setValue(labels)} diff --git a/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx b/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx index b6ced55dd6..74b85aa7dd 100644 --- a/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx @@ -126,8 +126,10 @@ function WebhookSubForm({ i18n, templateType }) { fieldId="webhook_service" helperTextInvalid={webhookServiceMeta.error} label={i18n._(t`Webhook Service`)} + labelIcon={ + <FieldTooltip content={i18n._(t`Select a webhook service.`)} /> + } > - <FieldTooltip content={i18n._(t`Select a webhook service.`)} /> <AnsibleSelect {...webhookServiceField} id="webhook_service" @@ -162,13 +164,15 @@ function WebhookSubForm({ i18n, templateType }) { type="text" fieldId="jt-webhookURL" label={i18n._(t`Webhook URL`)} + labelIcon={ + <FieldTooltip + content={i18n._( + t`Webhook services can launch jobs with this workflow job template by making a POST request to this URL.` + )} + /> + } name="webhook_url" > - <FieldTooltip - content={i18n._( - t`Webhook services can launch jobs with this workflow job template by making a POST request to this URL.` - )} - /> <TextInput id="t-webhookURL" aria-label={i18n._(t`Webhook URL`)} @@ -178,13 +182,15 @@ function WebhookSubForm({ i18n, templateType }) { </FormGroup> <FormGroup label={i18n._(t`Webhook Key`)} + labelIcon={ + <FieldTooltip + content={i18n._( + t`Webhook services can use this as a shared secret.` + )} + /> + } fieldId="template-webhook_key" > - <FieldTooltip - content={i18n._( - t`Webhook services can use this as a shared secret.` - )} - /> <InputGroup> <TextInput id="template-webhook_key" diff --git a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx index ece98a5e20..4a135682a1 100644 --- a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx @@ -131,7 +131,9 @@ function WorkflowJobTemplateForm({ <TextInput id="text-wfjt-limit" {...limitField} - isValid={!limitMeta.touched || !limitMeta.error} + validated={ + !limitMeta.touched || !limitMeta.error ? 'default' : 'error' + } onChange={value => { limitHelpers.setValue(value); }} @@ -157,12 +159,17 @@ function WorkflowJobTemplateForm({ </FieldWithPrompt> </FormColumnLayout> <FormFullWidthLayout> - <FormGroup label={i18n._(t`Labels`)} fieldId="template-labels"> - <FieldTooltip - content={i18n._(t`Optional labels that describe this job template, + <FormGroup + label={i18n._(t`Labels`)} + labelIcon={ + <FieldTooltip + content={i18n._(t`Optional labels that describe this job template, such as 'dev' or 'test'. Labels can be used to group and filter job templates and completed jobs.`)} - /> + /> + } + fieldId="template-labels" + > <LabelSelect value={labelsField.value} onChange={labels => labelsHelpers.setValue(labels)} diff --git a/awx/ui_next/src/screens/UISetting/UISettings.jsx b/awx/ui_next/src/screens/UISetting/UISettings.jsx deleted file mode 100644 index 1ecec2af54..0000000000 --- a/awx/ui_next/src/screens/UISetting/UISettings.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { - PageSection, - PageSectionVariants, - Title, -} from '@patternfly/react-core'; - -class UISettings extends Component { - render() { - const { i18n } = this.props; - const { light } = PageSectionVariants; - - return ( - <Fragment> - <PageSection variant={light} className="pf-m-condensed"> - <Title size="2xl" headingLevel="h2"> - {i18n._(t`User Interface Settings`)} - </Title> - </PageSection> - <PageSection /> - </Fragment> - ); - } -} - -export default withI18n()(UISettings); diff --git a/awx/ui_next/src/screens/UISetting/UISettings.test.jsx b/awx/ui_next/src/screens/UISetting/UISettings.test.jsx deleted file mode 100644 index 106b1d4e4e..0000000000 --- a/awx/ui_next/src/screens/UISetting/UISettings.test.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; - -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; - -import UISettings from './UISettings'; - -describe('<UISettings />', () => { - let pageWrapper; - let pageSections; - let title; - - beforeEach(() => { - pageWrapper = mountWithContexts(<UISettings />); - pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); - }); - - afterEach(() => { - pageWrapper.unmount(); - }); - - test('initially renders without crashing', () => { - expect(pageWrapper.length).toBe(1); - expect(pageSections.length).toBe(2); - expect(title.length).toBe(1); - expect(title.props().size).toBe('2xl'); - expect(pageSections.first().props().variant).toBe('light'); - }); -}); diff --git a/awx/ui_next/src/screens/UISetting/index.js b/awx/ui_next/src/screens/UISetting/index.js deleted file mode 100644 index 168e652b48..0000000000 --- a/awx/ui_next/src/screens/UISetting/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './UISettings'; diff --git a/awx/ui_next/src/screens/User/User.jsx b/awx/ui_next/src/screens/User/User.jsx index af60199e69..d071cedd8f 100644 --- a/awx/ui_next/src/screens/User/User.jsx +++ b/awx/ui_next/src/screens/User/User.jsx @@ -20,7 +20,7 @@ import UserDetail from './UserDetail'; import UserEdit from './UserEdit'; import UserOrganizations from './UserOrganizations'; import UserTeams from './UserTeams'; -import UserTokenList from './UserTokenList'; +import UserTokens from './UserTokens'; import UserAccessList from './UserAccess/UserAccessList'; function User({ i18n, setBreadcrumb, me }) { @@ -80,7 +80,9 @@ function User({ i18n, setBreadcrumb, me }) { } let showCardHeader = true; - if (['edit'].some(name => location.pathname.includes(name))) { + if ( + ['edit', 'add', 'tokens'].some(name => location.pathname.includes(name)) + ) { showCardHeader = false; } @@ -127,11 +129,15 @@ function User({ i18n, setBreadcrumb, me }) { </Route> {user && ( <Route path="/users/:id/access"> - <UserAccessList /> + <UserAccessList user={user} /> </Route> )} <Route path="/users/:id/tokens"> - <UserTokenList id={Number(match.params.id)} /> + <UserTokens + user={user} + setBreadcrumb={setBreadcrumb} + id={Number(match.params.id)} + /> </Route> <Route key="not-found" path="*"> <ContentError isNotFound> diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx index 4bc108ff83..5e528913c1 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useParams, useLocation } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { @@ -29,17 +29,22 @@ const QS_CONFIG = getQSConfig('roles', { // TODO Figure out how to best conduct a search of this list. // Since we only have a role ID in the top level of each role object // we can't really search using the normal search parameters. -function UserAccessList({ i18n }) { - const { id } = useParams(); +function UserAccessList({ i18n, user }) { const { search } = useLocation(); const [isWizardOpen, setIsWizardOpen] = useState(false); - const [roleToDisassociate, setRoleToDisassociate] = useState(null); + const { isLoading, request: fetchRoles, error, - result: { roleCount, roles, options }, + result: { + roleCount, + roles, + actions, + relatedSearchableKeys, + searchableKeys, + }, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, search); @@ -47,20 +52,32 @@ function UserAccessList({ i18n }) { { data: { results, count }, }, - { - data: { actions }, - }, + actionsResponse, ] = await Promise.all([ - UsersAPI.readRoles(id, params), - UsersAPI.readRoleOptions(id), + UsersAPI.readRoles(user.id, params), + UsersAPI.readOptions(), ]); - return { roleCount: count, roles: results, options: actions }; - }, [id, search]), + return { + roleCount: count, + roles: results, + actions: actionsResponse.data.actions, + 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), + }; + }, [user.id, search]), { roles: [], roleCount: 0, + actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); + useEffect(() => { fetchRoles(); }, [fetchRoles]); @@ -75,14 +92,15 @@ function UserAccessList({ i18n }) { setRoleToDisassociate(null); await RolesAPI.disassociateUserRole( roleToDisassociate.id, - parseInt(id, 10) + parseInt(user.id, 10) ); - }, [roleToDisassociate, id]), + }, [roleToDisassociate, user.id]), { qsConfig: QS_CONFIG, fetchItems: fetchRoles } ); const canAdd = - options && Object.prototype.hasOwnProperty.call(options, 'POST'); + user?.summary_fields?.user_capabilities?.edit || + (actions && Object.prototype.hasOwnProperty.call(actions, 'POST')); const saveRoles = () => { setIsWizardOpen(false); @@ -132,16 +150,18 @@ function UserAccessList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Role`), - key: 'role_field', + key: 'role_field__icontains', isDefault: true, }, ]} toolbarSortColumns={[ { - name: i18n._(t`Name`), + name: i18n._(t`ID`), key: 'id', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderItem={role => { return ( <UserAccessListItem @@ -170,7 +190,7 @@ function UserAccessList({ i18n }) { setIsWizardOpen(true); }} > - Add + {i18n._(t`Add`)} </Button>, ] : []), @@ -198,7 +218,7 @@ function UserAccessList({ i18n }) { <Button key="disassociate" variant="danger" - aria-label={i18n._(t`confirm disassociate`)} + aria-label={i18n._(t`Confirm disassociate`)} onClick={() => disassociateRole()} > {i18n._(t`Disassociate`)} diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx index 7a539e8c2a..42bf328450 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx @@ -10,12 +10,24 @@ import UserAccessList from './UserAccessList'; jest.mock('../../../api/models/Users'); jest.mock('../../../api/models/Roles'); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ - id: 18, - }), -})); +UsersAPI.readOptions.mockResolvedValue({ + data: { + actions: { GET: {} }, + related_search_fields: [], + }, +}); + +const user = { + id: 18, + username: 'Foo User', + summary_fields: { + user_capabilities: { + edit: true, + delete: true, + }, + }, +}; + const roles = { data: { results: [ @@ -88,21 +100,18 @@ const roles = { count: 5, }, }; -const options = { - data: { actions: { POST: { id: 1, disassociate: true } } }, -}; + describe('<UserAccessList />', () => { let wrapper; afterEach(() => { jest.clearAllMocks(); - // wrapper.unmount(); + wrapper.unmount(); }); test('should render properly', async () => { UsersAPI.readRoles.mockResolvedValue(roles); - UsersAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(<UserAccessList />); + wrapper = mountWithContexts(<UserAccessList user={user} />); }); expect(wrapper.find('UserAccessList').length).toBe(1); @@ -110,13 +119,12 @@ describe('<UserAccessList />', () => { test('should create proper detailUrl', async () => { UsersAPI.readRoles.mockResolvedValue(roles); - UsersAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(<UserAccessList />); + wrapper = mountWithContexts(<UserAccessList user={user} />); }); - waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + wrapper.update(); expect(wrapper.find(`Link#userRole-2`).prop('to')).toBe( '/templates/job_template/15/details' @@ -134,9 +142,14 @@ describe('<UserAccessList />', () => { '/inventories/smart_inventory/77/details' ); }); - test('should not render add button', async () => { + test('should not render add button when user cannot create other users and user cannot edit this user', async () => { UsersAPI.readRoleOptions.mockResolvedValueOnce({ - data: {}, + data: { + actions: { + GET: {}, + }, + related_search_fields: [], + }, }); UsersAPI.readRoles.mockResolvedValue({ @@ -177,21 +190,33 @@ describe('<UserAccessList />', () => { }, }); await act(async () => { - wrapper = mountWithContexts(<UserAccessList />); + wrapper = mountWithContexts( + <UserAccessList + user={{ + ...user, + summary_fields: { + user_capabilities: { + edit: false, + delete: false, + }, + }, + }} + /> + ); }); - waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + wrapper.update(); + expect(wrapper.find('Button[aria-label="Add resource roles"]').length).toBe( 0 ); }); test('should open and close wizard', async () => { UsersAPI.readRoles.mockResolvedValue(roles); - UsersAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(<UserAccessList />); + wrapper = mountWithContexts(<UserAccessList user={user} />); }); - waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + wrapper.update(); await act(async () => wrapper.find('Button[aria-label="Add resource roles"]').prop('onClick')() ); @@ -205,13 +230,12 @@ describe('<UserAccessList />', () => { }); test('should render disassociate modal', async () => { UsersAPI.readRoles.mockResolvedValue(roles); - UsersAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(<UserAccessList />); + wrapper = mountWithContexts(<UserAccessList user={user} />); }); - waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + wrapper.update(); await act(async () => wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({ @@ -234,7 +258,7 @@ describe('<UserAccessList />', () => { ).toBe(1); await act(async () => wrapper - .find('button[aria-label="confirm disassociate"]') + .find('button[aria-label="Confirm disassociate"]') .prop('onClick')() ); expect(RolesAPI.disassociateUserRole).toBeCalledWith(4, 18); @@ -257,13 +281,12 @@ describe('<UserAccessList />', () => { }, }) ); - UsersAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(<UserAccessList />); + wrapper = mountWithContexts(<UserAccessList user={user} />); }); - waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + wrapper.update(); await act(async () => wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({ @@ -286,7 +309,7 @@ describe('<UserAccessList />', () => { ).toBe(1); await act(async () => wrapper - .find('button[aria-label="confirm disassociate"]') + .find('button[aria-label="Confirm disassociate"]') .prop('onClick')() ); wrapper.update(); @@ -313,10 +336,9 @@ describe('<UserAccessList />', () => { count: 1, }, }); - UsersAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(<UserAccessList />); + wrapper = mountWithContexts(<UserAccessList user={user} />); }); waitForElement( diff --git a/awx/ui_next/src/screens/User/UserAccess/index.js b/awx/ui_next/src/screens/User/UserAccess/index.js index 754ba1ae99..5270c97adf 100644 --- a/awx/ui_next/src/screens/User/UserAccess/index.js +++ b/awx/ui_next/src/screens/User/UserAccess/index.js @@ -1,2 +1,2 @@ -export { default as UserAccessListList } from './UserAccessList'; +export { default as UserAccessList } from './UserAccessList'; export { default as UserAccessListItem } from './UserAccessListItem'; diff --git a/awx/ui_next/src/screens/User/UserList/UserList.jsx b/awx/ui_next/src/screens/User/UserList/UserList.jsx index e64ff44280..d34706a5d6 100644 --- a/awx/ui_next/src/screens/User/UserList/UserList.jsx +++ b/awx/ui_next/src/screens/User/UserList/UserList.jsx @@ -1,9 +1,8 @@ -import React, { Component, Fragment } from 'react'; -import { withRouter } from 'react-router-dom'; +import React, { useEffect, useCallback } from 'react'; +import { useLocation, useRouteMatch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Card, PageSection } from '@patternfly/react-core'; - import { UsersAPI } from '../../../api'; import AlertModal from '../../../components/AlertModal'; import DataListToolbar from '../../../components/DataListToolbar'; @@ -12,8 +11,9 @@ import PaginatedDataList, { ToolbarAddButton, ToolbarDeleteButton, } from '../../../components/PaginatedDataList'; +import useRequest, { useDeleteItems } from '../../../util/useRequest'; +import useSelected from '../../../util/useSelected'; import { getQSConfig, parseQueryString } from '../../../util/qs'; - import UserListItem from './UserListItem'; const QS_CONFIG = getQSConfig('user', { @@ -22,222 +22,165 @@ const QS_CONFIG = getQSConfig('user', { order_by: 'username', }); -class UsersList extends Component { - constructor(props) { - super(props); - - this.state = { - hasContentLoading: true, - contentError: null, - deletionError: null, +function UserList({ i18n }) { + const location = useLocation(); + const match = useRouteMatch(); + + const { + result: { users, itemCount, actions }, + error: contentError, + isLoading, + request: fetchUsers, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const [response, actionsResponse] = await Promise.all([ + UsersAPI.read(params), + UsersAPI.readOptions(), + ]); + return { + users: response.data.results, + itemCount: response.data.count, + actions: actionsResponse.data.actions, + }; + }, [location]), + { users: [], - selected: [], itemCount: 0, - actions: null, - }; - - this.handleSelectAll = this.handleSelectAll.bind(this); - this.handleSelect = this.handleSelect.bind(this); - this.handleUserDelete = this.handleUserDelete.bind(this); - this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this); - this.loadUsers = this.loadUsers.bind(this); - } - - componentDidMount() { - this.loadUsers(); - } - - componentDidUpdate(prevProps) { - const { location } = this.props; - if (location !== prevProps.location) { - this.loadUsers(); + actions: {}, } - } - - handleSelectAll(isSelected) { - const { users } = this.state; - - const selected = isSelected ? [...users] : []; - this.setState({ selected }); - } - - handleSelect(row) { - const { selected } = this.state; - - if (selected.some(s => s.id === row.id)) { - this.setState({ selected: selected.filter(s => s.id !== row.id) }); - } else { - this.setState({ selected: selected.concat(row) }); + ); + + useEffect(() => { + fetchUsers(); + }, [fetchUsers]); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + users + ); + + const { + isLoading: isDeleteLoading, + deleteItems: deleteUsers, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all(selected.map(user => UsersAPI.destroy(user.id))); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchUsers, } - } - - handleDeleteErrorClose() { - this.setState({ deletionError: null }); - } - - async handleUserDelete() { - const { selected } = this.state; - - this.setState({ hasContentLoading: true }); - try { - await Promise.all(selected.map(org => UsersAPI.destroy(org.id))); - } catch (err) { - this.setState({ deletionError: err }); - } finally { - await this.loadUsers(); - } - } - - async loadUsers() { - const { location } = this.props; - const { actions: cachedActions } = this.state; - const params = parseQueryString(QS_CONFIG, location.search); - - let optionsPromise; - if (cachedActions) { - optionsPromise = Promise.resolve({ data: { actions: cachedActions } }); - } else { - optionsPromise = UsersAPI.readOptions(); - } - - const promises = Promise.all([UsersAPI.read(params), optionsPromise]); - - this.setState({ contentError: null, hasContentLoading: true }); - try { - const [ - { - data: { count, results }, - }, - { - data: { actions }, - }, - ] = await promises; - this.setState({ - actions, - itemCount: count, - users: results, - selected: [], - }); - } catch (err) { - this.setState({ contentError: err }); - } finally { - this.setState({ hasContentLoading: false }); - } - } - - render() { - const { - actions, - itemCount, - contentError, - hasContentLoading, - deletionError, - selected, - users, - } = this.state; - const { match, i18n } = this.props; - - const canAdd = - actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - const isAllSelected = - selected.length === users.length && selected.length > 0; - - return ( - <Fragment> - <PageSection> - <Card> - <PaginatedDataList - contentError={contentError} - hasContentLoading={hasContentLoading} - items={users} - itemCount={itemCount} - pluralizedItemName={i18n._(t`Users`)} - qsConfig={QS_CONFIG} - onRowClick={this.handleSelect} - toolbarSearchColumns={[ - { - name: i18n._(t`Username`), - key: 'username', - isDefault: true, - }, - { - name: i18n._(t`First Name`), - key: 'first_name', - }, - { - name: i18n._(t`Last Name`), - key: 'last_name', - }, - ]} - toolbarSortColumns={[ - { - name: i18n._(t`Username`), - key: 'username', - }, - { - name: i18n._(t`First Name`), - key: 'first_name', - }, - { - name: i18n._(t`Last Name`), - key: 'last_name', - }, - ]} - renderToolbar={props => ( - <DataListToolbar - {...props} - showSelectAll - isAllSelected={isAllSelected} - onSelectAll={this.handleSelectAll} - qsConfig={QS_CONFIG} - additionalControls={[ - ...(canAdd - ? [ - <ToolbarAddButton - key="add" - linkTo={`${match.url}/add`} - />, - ] - : []), - <ToolbarDeleteButton - key="delete" - onDelete={this.handleUserDelete} - itemsToDelete={selected} - pluralizedItemName="Users" - />, - ]} - /> - )} - renderItem={o => ( - <UserListItem - key={o.id} - user={o} - detailUrl={`${match.url}/${o.id}/details`} - isSelected={selected.some(row => row.id === o.id)} - onSelect={() => this.handleSelect(o)} - /> - )} - emptyStateControls={ - canAdd ? ( - <ToolbarAddButton key="add" linkTo={`${match.url}/add`} /> - ) : null - } - /> - </Card> - </PageSection> + ); + + const handleUserDelete = async () => { + await deleteUsers(); + setSelected([]); + }; + + const hasContentLoading = isDeleteLoading || isLoading; + const canAdd = actions && actions.POST; + + return ( + <> + <PageSection> + <Card> + <PaginatedDataList + contentError={contentError} + hasContentLoading={hasContentLoading} + items={users} + itemCount={itemCount} + pluralizedItemName={i18n._(t`Users`)} + qsConfig={QS_CONFIG} + onRowClick={handleSelect} + toolbarSearchColumns={[ + { + name: i18n._(t`Username`), + key: 'username__icontains', + isDefault: true, + }, + { + name: i18n._(t`First Name`), + key: 'first_name__icontains', + }, + { + name: i18n._(t`Last Name`), + key: 'last_name__icontains', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Username`), + key: 'username', + }, + { + name: i18n._(t`First Name`), + key: 'first_name', + }, + { + name: i18n._(t`Last Name`), + key: 'last_name', + }, + ]} + renderToolbar={props => ( + <DataListToolbar + {...props} + showSelectAll + isAllSelected={isAllSelected} + onSelectAll={isSelected => + setSelected(isSelected ? [...users] : []) + } + qsConfig={QS_CONFIG} + additionalControls={[ + ...(canAdd + ? [ + <ToolbarAddButton + key="add" + linkTo={`${match.url}/add`} + />, + ] + : []), + <ToolbarDeleteButton + key="delete" + onDelete={handleUserDelete} + itemsToDelete={selected} + pluralizedItemName="Users" + />, + ]} + /> + )} + renderItem={o => ( + <UserListItem + key={o.id} + user={o} + detailUrl={`${match.url}/${o.id}/details`} + isSelected={selected.some(row => row.id === o.id)} + onSelect={() => handleSelect(o)} + /> + )} + emptyStateControls={ + canAdd ? ( + <ToolbarAddButton key="add" linkTo={`${match.url}/add`} /> + ) : null + } + /> + </Card> + </PageSection> + {deletionError && ( <AlertModal isOpen={deletionError} variant="error" title={i18n._(t`Error!`)} - onClose={this.handleDeleteErrorClose} + onClose={clearDeletionError} > {i18n._(t`Failed to delete one or more users.`)} <ErrorDetail error={deletionError} /> </AlertModal> - </Fragment> - ); - } + )} + </> + ); } -export { UsersList as _UsersList }; -export default withI18n()(withRouter(UsersList)); +export default withI18n()(UserList); diff --git a/awx/ui_next/src/screens/User/UserList/UserList.test.jsx b/awx/ui_next/src/screens/User/UserList/UserList.test.jsx index 96d763dad5..46cf02c128 100644 --- a/awx/ui_next/src/screens/User/UserList/UserList.test.jsx +++ b/awx/ui_next/src/screens/User/UserList/UserList.test.jsx @@ -1,16 +1,16 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { UsersAPI } from '../../../api'; import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; -import UsersList, { _UsersList } from './UserList'; +import UsersList from './UserList'; jest.mock('../../../api'); let wrapper; -const loadUsers = jest.spyOn(_UsersList.prototype, 'loadUsers'); const mockUsers = [ { id: 1, @@ -84,7 +84,8 @@ const mockUsers = [ }, ]; -beforeAll(() => { +beforeEach(() => { + UsersAPI.destroy = jest.fn(); UsersAPI.read.mockResolvedValue({ data: { count: mockUsers.length, @@ -110,146 +111,96 @@ describe('UsersList with full permissions', () => { }); }); - beforeEach(() => { - wrapper = mountWithContexts(<UsersList />); - }); - - test('initially renders successfully', () => { - mountWithContexts( - <UsersList - match={{ path: '/users', url: '/users' }} - location={{ search: '', pathname: '/users' }} - /> - ); + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts(<UsersList />); + }); + wrapper.update(); }); test('Users are retrieved from the api and the components finishes loading', async () => { await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); - expect(loadUsers).toHaveBeenCalled(); + expect(UsersAPI.read).toHaveBeenCalled(); }); - test('Selects one team when row is checked', async () => { - await waitForElement( - wrapper, - 'UsersList', - el => el.state('hasContentLoading') === false - ); - expect( - wrapper - .find('input[type="checkbox"]') - .findWhere(n => n.prop('checked') === true).length - ).toBe(0); - wrapper - .find('UserListItem') - .at(0) - .find('DataListCheck') - .props() - .onChange(true); - wrapper.update(); - expect( - wrapper - .find('input[type="checkbox"]') - .findWhere(n => n.prop('checked') === true).length - ).toBe(1); + test('should show add button', () => { + expect(wrapper.find('ToolbarAddButton').length).toBe(1); }); - test('Select all checkbox selects and unselects all rows', async () => { - await waitForElement( - wrapper, - 'UsersList', - el => el.state('hasContentLoading') === false - ); + test('should check and uncheck the row item', async () => { expect( - wrapper - .find('input[type="checkbox"]') - .findWhere(n => n.prop('checked') === true).length - ).toBe(0); - wrapper - .find('Checkbox#select-all') - .props() - .onChange(true); + wrapper.find('DataListCheck[id="select-user-1"]').props().checked + ).toBe(false); + await act(async () => { + wrapper.find('DataListCheck[id="select-user-1"]').invoke('onChange')( + true + ); + }); wrapper.update(); expect( - wrapper - .find('input[type="checkbox"]') - .findWhere(n => n.prop('checked') === true).length - ).toBe(3); - wrapper - .find('Checkbox#select-all') - .props() - .onChange(false); + wrapper.find('DataListCheck[id="select-user-1"]').props().checked + ).toBe(true); + await act(async () => { + wrapper.find('DataListCheck[id="select-user-1"]').invoke('onChange')( + false + ); + }); wrapper.update(); expect( - wrapper - .find('input[type="checkbox"]') - .findWhere(n => n.prop('checked') === true).length - ).toBe(0); + wrapper.find('DataListCheck[id="select-user-1"]').props().checked + ).toBe(false); }); - test('delete button is disabled if user does not have delete capabilities on a selected user', async () => { - wrapper.find('UsersList').setState({ - users: mockUsers, - itemCount: 2, - isInitialized: true, - selected: mockUsers.slice(0, 1), + test('should check all row items when select all is checked', async () => { + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); }); - await waitForElement( - wrapper, - 'ToolbarDeleteButton * button', - el => el.getDOMNode().disabled === false - ); - wrapper.find('UsersList').setState({ - selected: mockUsers, + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(true); }); - await waitForElement( - wrapper, - 'ToolbarDeleteButton * button', - el => el.getDOMNode().disabled === true - ); - }); - - test('api is called to delete users for each selected user.', async () => { - UsersAPI.destroy = jest.fn(); - wrapper.find('UsersList').setState({ - users: mockUsers, - itemCount: 2, - isInitialized: true, - isModalOpen: true, - selected: mockUsers, + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(true); + }); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(false); + }); + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); }); - await wrapper.find('ToolbarDeleteButton').prop('onDelete')(); - expect(UsersAPI.destroy).toHaveBeenCalledTimes(2); }); - test('error is shown when user not successfully deleted from api', async () => { - UsersAPI.destroy.mockRejectedValue( - new Error({ - response: { - config: { - method: 'delete', - url: '/api/v2/users/1', - }, - data: 'An error occurred', - }, - }) - ); - wrapper.find('UsersList').setState({ - users: mockUsers, - itemCount: 1, - isInitialized: true, - isModalOpen: true, - selected: mockUsers.slice(0, 1), + test('should call api delete users for each selected user', async () => { + await act(async () => { + wrapper.find('DataListCheck[id="select-user-1"]').invoke('onChange')(); }); - wrapper.find('ToolbarDeleteButton').prop('onDelete')(); - await waitForElement( - wrapper, - 'Modal', - el => el.props().isOpen === true && el.props().title === 'Error!' - ); + wrapper.update(); + await act(async () => { + wrapper.find('ToolbarDeleteButton').invoke('onDelete')(); + }); + wrapper.update(); + expect(UsersAPI.destroy).toHaveBeenCalledTimes(1); }); - test('Add button shown for users with ability to POST', async () => { - await waitForElement(wrapper, 'ToolbarAddButton', el => el.length === 1); + test('should show error modal when user is not successfully deleted from api', async () => { + UsersAPI.destroy.mockImplementationOnce(() => Promise.reject(new Error())); + // expect(wrapper.debug()).toBe(false); + expect(wrapper.find('Modal').length).toBe(0); + await act(async () => { + wrapper.find('DataListCheck[id="select-user-1"]').invoke('onChange')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('ToolbarDeleteButton').invoke('onDelete')(); + }); + wrapper.update(); + expect(wrapper.find('Modal').length).toBe(1); + await act(async () => { + wrapper.find('ModalBoxCloseButton').invoke('onClose')(); + }); + wrapper.update(); + expect(wrapper.find('Modal').length).toBe(0); }); }); @@ -263,9 +214,21 @@ describe('UsersList without full permissions', () => { }, }); - wrapper = mountWithContexts(<UsersList />); - await waitForElement(wrapper, 'ContentLoading', el => el.length === 1); - await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + await act(async () => { + wrapper = mountWithContexts(<UsersList />); + }); + wrapper.update(); expect(wrapper.find('ToolbarAddButton').length).toBe(0); }); }); + +describe('read call unsuccessful', () => { + test('should show content error when read call unsuccessful', async () => { + UsersAPI.read.mockRejectedValue(new Error()); + await act(async () => { + wrapper = mountWithContexts(<UsersList />); + }); + wrapper.update(); + expect(wrapper.find('ContentError').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeamList.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.jsx index d7a902b7e3..868cbb9e55 100644 --- a/awx/ui_next/src/screens/User/UserTeams/UserTeamList.jsx +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.jsx @@ -20,24 +20,38 @@ function UserTeamList({ i18n }) { const { id: userId } = useParams(); const { - result: { teams, count }, + result: { teams, count, relatedSearchableKeys, searchableKeys }, error: contentError, isLoading, request: fetchOrgs, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const { - data: { results, count: teamCount }, - } = await UsersAPI.readTeams(userId, params); + const [ + { + data: { results, count: teamCount }, + }, + actionsResponse, + ] = await Promise.all([ + UsersAPI.readTeams(userId, params), + UsersAPI.readTeamsOptions(userId), + ]); return { teams: results, count: teamCount, + 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), }; }, [userId, location.search]), { teams: [], count: 0, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -66,14 +80,16 @@ function UserTeamList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Organization`), - key: 'organization__name', + key: 'organization__name__icontains', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} /> ); } diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx index caac6b0c5f..b7fd0a9abf 100644 --- a/awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx @@ -58,13 +58,14 @@ describe('<UserTeamList />', () => { data: mockAPIUserTeamList.data, }) ); - UsersAPI.readOptions = jest.fn(() => + UsersAPI.readTeamsOptions = jest.fn(() => Promise.resolve({ data: { actions: { GET: {}, POST: {}, }, + related_search_fields: [], }, }) ); diff --git a/awx/ui_next/src/screens/User/UserToken/UserToken.jsx b/awx/ui_next/src/screens/User/UserToken/UserToken.jsx new file mode 100644 index 0000000000..af6f0f0b16 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserToken/UserToken.jsx @@ -0,0 +1,117 @@ +import React, { useEffect, useCallback } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Link, + Redirect, + Route, + Switch, + useLocation, + useParams, +} from 'react-router-dom'; +import { CaretLeftIcon } from '@patternfly/react-icons'; +import { Card, PageSection } from '@patternfly/react-core'; +import RoutedTabs from '../../../components/RoutedTabs'; +import ContentError from '../../../components/ContentError'; +import { TokensAPI } from '../../../api'; +import useRequest from '../../../util/useRequest'; +import UserTokenDetail from '../UserTokenDetail'; + +function UserToken({ i18n, setBreadcrumb, user }) { + const location = useLocation(); + const { id, tokenId } = useParams(); + const { + isLoading, + error, + request: fetchToken, + result: { token, actions }, + } = useRequest( + useCallback(async () => { + const [response, actionsResponse] = await Promise.all([ + TokensAPI.readDetail(tokenId), + TokensAPI.readOptions(), + ]); + setBreadcrumb(user, response.data); + return { + token: response.data, + actions: actionsResponse.data.actions.POST, + }; + }, [setBreadcrumb, user, tokenId]), + { token: null, actions: null } + ); + useEffect(() => { + fetchToken(); + }, [fetchToken]); + + const tabsArray = [ + { + name: ( + <> + <CaretLeftIcon /> + {i18n._(t`Back to Tokens`)} + </> + ), + link: `/users/${id}/tokens`, + id: 99, + }, + { + name: i18n._(t`Details`), + link: `/users/${id}/tokens/${tokenId}/details`, + id: 0, + }, + ]; + + let showCardHeader = true; + + if (location.pathname.endsWith('edit')) { + showCardHeader = false; + } + + if (!isLoading && error) { + return ( + <PageSection> + <Card> + <ContentError error={error}> + {error.response.status === 404 && ( + <span> + {i18n._(t`Token not found.`)}{' '} + <Link to="/users/:id/tokens"> + {i18n._(t`View all tokens.`)} + </Link> + </span> + )} + </ContentError> + </Card> + </PageSection> + ); + } + + return ( + <> + {showCardHeader && <RoutedTabs tabsArray={tabsArray} />} + <Switch> + <Redirect + from="/users/:id/tokens/:tokenId" + to="/users/:id/tokens/:tokenId/details" + exact + /> + {token && ( + <Route path="/users/:id/tokens/:tokenId/details"> + <UserTokenDetail canEditOrDelete={actions} token={token} /> + </Route> + )} + <Route key="not-found" path="*"> + {!isLoading && ( + <ContentError isNotFound> + {id && ( + <Link to={`/users/${id}/tokens`}>{i18n._(t`View Tokens`)}</Link> + )} + </ContentError> + )} + </Route> + </Switch> + </> + ); +} + +export default withI18n()(UserToken); diff --git a/awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx b/awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx new file mode 100644 index 0000000000..8e71f1b085 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { TokensAPI } from '../../../api'; +import UserToken from './UserToken'; + +jest.mock('../../../api/models/Tokens'); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + tokenId: 2, + }), +})); +describe('<UserToken/>', () => { + let wrapper; + const user = { + id: 1, + type: 'user', + url: '/api/v2/users/1/', + summary_fields: { + user_capabilities: { + edit: true, + delete: false, + }, + }, + created: '2020-06-19T12:55:13.138692Z', + username: 'admin', + first_name: 'Alex', + last_name: 'Corey', + email: 'a@g.com', + }; + test('should call api for token details and actions', async () => { + TokensAPI.readDetail.mockResolvedValue({ + id: 2, + type: 'o_auth2_access_token', + url: '/api/v2/tokens/2/', + + summary_fields: { + user: { + id: 1, + username: 'admin', + first_name: 'Alex', + last_name: 'Corey', + }, + application: { + id: 3, + name: 'hg', + }, + }, + created: '2020-06-23T19:56:38.422053Z', + modified: '2020-06-23T19:56:38.441353Z', + description: 'cdfsg', + scope: 'read', + }); + TokensAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: true } }, + }); + await act(async () => { + wrapper = mountWithContexts( + <UserToken setBreadcrumb={jest.fn()} user={user} /> + ); + }); + expect(wrapper.find('UserToken').length).toBe(1); + }); + test('should call api for token details and actions', async () => { + TokensAPI.readDetail.mockResolvedValue({ + id: 2, + type: 'o_auth2_access_token', + url: '/api/v2/tokens/2/', + + summary_fields: { + user: { + id: 1, + username: 'admin', + first_name: 'Alex', + last_name: 'Corey', + }, + application: { + id: 3, + name: 'hg', + }, + }, + created: '2020-06-23T19:56:38.422053Z', + modified: '2020-06-23T19:56:38.441353Z', + description: 'cdfsg', + scope: 'read', + }); + TokensAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: true } }, + }); + await act(async () => { + wrapper = mountWithContexts( + <UserToken setBreadcrumb={jest.fn()} user={user} /> + ); + }); + expect(TokensAPI.readDetail).toBeCalledWith(2); + expect(TokensAPI.readOptions).toBeCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserToken/index.js b/awx/ui_next/src/screens/User/UserToken/index.js new file mode 100644 index 0000000000..f899410e7d --- /dev/null +++ b/awx/ui_next/src/screens/User/UserToken/index.js @@ -0,0 +1 @@ +export { default } from './UserToken'; diff --git a/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.jsx b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.jsx new file mode 100644 index 0000000000..606171c028 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.jsx @@ -0,0 +1,42 @@ +import React, { useCallback } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; + +import { CardBody } from '../../../components/Card'; +import { TokensAPI, UsersAPI } from '../../../api'; +import useRequest from '../../../util/useRequest'; +import UserTokenFrom from '../shared/UserTokenForm'; + +function UserTokenAdd() { + const history = useHistory(); + const { id: userId } = useParams(); + const { error: submitError, request: handleSubmit } = useRequest( + useCallback( + async formData => { + if (formData.application) { + formData.application = formData.application?.id || null; + await UsersAPI.createToken(userId, formData); + } else { + await TokensAPI.create(formData); + } + + history.push(`/users/${userId}/tokens`); + }, + [history, userId] + ) + ); + + const handleCancel = () => { + history.push(`/users/${userId}/tokens`); + }; + + return ( + <CardBody> + <UserTokenFrom + handleCancel={handleCancel} + handleSubmit={handleSubmit} + submitError={submitError} + /> + </CardBody> + ); +} +export default UserTokenAdd; diff --git a/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.test.jsx b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.test.jsx new file mode 100644 index 0000000000..4323663c45 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.test.jsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import UserTokenAdd from './UserTokenAdd'; +import { UsersAPI, TokensAPI } from '../../../api'; + +jest.mock('../../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + history: () => ({ + location: '/user', + }), + useParams: () => ({ id: 1 }), +})); +let wrapper; + +describe('<UserTokenAdd />', () => { + test('handleSubmit should post to api', async () => { + await act(async () => { + wrapper = mountWithContexts(<UserTokenAdd />); + }); + UsersAPI.createToken.mockResolvedValueOnce({ data: { id: 1 } }); + const tokenData = { + application: 1, + description: 'foo', + scope: 'read', + }; + await act(async () => { + wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData); + }); + expect(UsersAPI.createToken).toHaveBeenCalledWith(1, tokenData); + }); + + test('should navigate to tokens list when cancel is clicked', async () => { + const history = createMemoryHistory({}); + await act(async () => { + wrapper = mountWithContexts(<UserTokenAdd />, { + context: { router: { history } }, + }); + }); + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + }); + expect(history.location.pathname).toEqual('/users/1/tokens'); + }); + + test('successful form submission should trigger redirect', async () => { + const history = createMemoryHistory({}); + const tokenData = { + application: 1, + description: 'foo', + scope: 'read', + }; + UsersAPI.createToken.mockResolvedValueOnce({ + data: { + id: 2, + ...tokenData, + }, + }); + await act(async () => { + wrapper = mountWithContexts(<UserTokenAdd />, { + context: { router: { history } }, + }); + }); + await waitForElement(wrapper, 'button[aria-label="Save"]'); + await act(async () => { + wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData); + }); + expect(history.location.pathname).toEqual('/users/1/tokens'); + }); + + test('should successful submit form with application', async () => { + const history = createMemoryHistory({}); + const tokenData = { + scope: 'read', + }; + TokensAPI.create.mockResolvedValueOnce({ + data: { + id: 2, + ...tokenData, + }, + }); + await act(async () => { + wrapper = mountWithContexts(<UserTokenAdd />, { + context: { router: { history } }, + }); + }); + await waitForElement(wrapper, 'button[aria-label="Save"]'); + await act(async () => { + wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData); + }); + expect(history.location.pathname).toEqual('/users/1/tokens'); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserTokenAdd/index.js b/awx/ui_next/src/screens/User/UserTokenAdd/index.js new file mode 100644 index 0000000000..d8a9b4a1f7 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenAdd/index.js @@ -0,0 +1 @@ +export { default } from './UserTokenAdd'; diff --git a/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx new file mode 100644 index 0000000000..4e6891767d --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx @@ -0,0 +1,89 @@ +import React, { useCallback } from 'react'; +import { Link, useHistory, useParams } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; + +import AlertModal from '../../../components/AlertModal'; +import { CardBody, CardActionsRow } from '../../../components/Card'; +import DeleteButton from '../../../components/DeleteButton'; +import { + DetailList, + Detail, + UserDateDetail, +} from '../../../components/DetailList'; +import ErrorDetail from '../../../components/ErrorDetail'; +import { TokensAPI } from '../../../api'; +import useRequest, { useDismissableError } from '../../../util/useRequest'; +import { toTitleCase } from '../../../util/strings'; + +function UserTokenDetail({ token, canEditOrDelete, i18n }) { + const { scope, description, created, modified, summary_fields } = token; + const history = useHistory(); + const { id, tokenId } = useParams(); + const { request: deleteToken, isLoading, error: deleteError } = useRequest( + useCallback(async () => { + await TokensAPI.destroy(tokenId); + history.push(`/users/${id}/tokens`); + }, [tokenId, id, history]) + ); + const { error, dismissError } = useDismissableError(deleteError); + + return ( + <CardBody> + <DetailList> + <Detail + label={i18n._(t`Application`)} + value={summary_fields?.application?.name} + dataCy="application-token-detail-name" + /> + <Detail label={i18n._(t`Description`)} value={description} /> + <Detail label={i18n._(t`Scope`)} value={toTitleCase(scope)} /> + <UserDateDetail + label={i18n._(t`Created`)} + date={created} + user={summary_fields.user} + /> + <UserDateDetail + label={i18n._(t`Last Modified`)} + date={modified} + user={summary_fields.user} + /> + </DetailList> + <CardActionsRow> + {canEditOrDelete && ( + <> + <Button + aria-label={i18n._(t`Edit`)} + component={Link} + to={`/users/${id}/tokens/${tokenId}/details`} + > + {i18n._(t`Edit`)} + </Button> + <DeleteButton + name={summary_fields?.application?.name} + modalTitle={i18n._(t`Delete User Token`)} + onConfirm={deleteToken} + isDisabled={isLoading} + > + {i18n._(t`Delete`)} + </DeleteButton> + </> + )} + </CardActionsRow> + {error && ( + <AlertModal + isOpen={error} + variant="error" + title={i18n._(t`Error!`)} + onClose={dismissError} + > + {i18n._(t`Failed to user token.`)} + <ErrorDetail error={error} /> + </AlertModal> + )} + </CardBody> + ); +} + +export default withI18n()(UserTokenDetail); diff --git a/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.test.jsx b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.test.jsx new file mode 100644 index 0000000000..a3462f758f --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.test.jsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { TokensAPI } from '../../../api'; +import UserTokenDetail from './UserTokenDetail'; + +jest.mock('../../../api/models/Tokens'); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + tokenId: 2, + }), +})); +describe('<UserTokenDetail/>', () => { + let wrapper; + const token = { + id: 2, + type: 'o_auth2_access_token', + url: '/api/v2/tokens/2/', + + summary_fields: { + user: { + id: 1, + username: 'admin', + first_name: 'Alex', + last_name: 'Corey', + }, + application: { + id: 3, + name: 'hg', + }, + }, + created: '2020-06-23T19:56:38.422053Z', + modified: '2020-06-23T19:56:38.441353Z', + description: 'cdfsg', + scope: 'read', + }; + test('should call api for token details and actions', async () => { + await act(async () => { + wrapper = mountWithContexts( + <UserTokenDetail canEditOrDelete token={token} /> + ); + }); + expect(wrapper.find('UserTokenDetail').length).toBe(1); + }); + test('should call api for token details and actions', async () => { + await act(async () => { + wrapper = mountWithContexts( + <UserTokenDetail canEditOrDelete token={token} /> + ); + }); + + expect(wrapper.find('Detail[label="Application"]').prop('value')).toBe( + 'hg' + ); + expect(wrapper.find('Detail[label="Description"]').prop('value')).toBe( + 'cdfsg' + ); + expect(wrapper.find('Detail[label="Scope"]').prop('value')).toBe('Read'); + expect(wrapper.find('UserDateDetail[label="Created"]').prop('date')).toBe( + '2020-06-23T19:56:38.422053Z' + ); + expect( + wrapper.find('UserDateDetail[label="Last Modified"]').prop('date') + ).toBe('2020-06-23T19:56:38.441353Z'); + expect(wrapper.find('Button[aria-label="Edit"]').length).toBe(1); + expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(1); + }); + test('should not render edit or delete buttons', async () => { + await act(async () => { + wrapper = mountWithContexts( + <UserTokenDetail canEditOrDelete={false} token={token} /> + ); + }); + expect(wrapper.find('Button[aria-label="Edit"]').length).toBe(0); + expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(0); + }); + test('should delete token properly', async () => { + await act(async () => { + wrapper = mountWithContexts( + <UserTokenDetail canEditOrDelete token={token} /> + ); + }); + await act(async () => + wrapper.find('Button[aria-label="Delete"]').prop('onClick')() + ); + wrapper.update(); + await act(async () => wrapper.find('DeleteButton').prop('onConfirm')()); + expect(TokensAPI.destroy).toBeCalledWith(2); + }); + test('should throw deletion error', async () => { + TokensAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: '/api/v2/tokens', + }, + data: 'An error occurred', + status: 400, + }, + }) + ); + await act(async () => { + wrapper = mountWithContexts( + <UserTokenDetail canEditOrDelete token={token} /> + ); + }); + await act(async () => + wrapper.find('Button[aria-label="Delete"]').prop('onClick')() + ); + wrapper.update(); + await act(async () => wrapper.find('DeleteButton').prop('onConfirm')()); + expect(TokensAPI.destroy).toBeCalledWith(2); + wrapper.update(); + expect(wrapper.find('ErrorDetail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserTokenDetail/index.js b/awx/ui_next/src/screens/User/UserTokenDetail/index.js new file mode 100644 index 0000000000..a6a9011996 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenDetail/index.js @@ -0,0 +1 @@ +export { default } from './UserTokenDetail'; diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx index ac94f980af..4427b4c886 100644 --- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx @@ -5,11 +5,14 @@ import { t } from '@lingui/macro'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import PaginatedDataList, { ToolbarAddButton, + ToolbarDeleteButton, } from '../../../components/PaginatedDataList'; import useSelected from '../../../util/useSelected'; -import useRequest from '../../../util/useRequest'; -import { UsersAPI } from '../../../api'; +import useRequest, { useDeleteItems } from '../../../util/useRequest'; +import { UsersAPI, TokensAPI } from '../../../api'; import DataListToolbar from '../../../components/DataListToolbar'; +import AlertModal from '../../../components/AlertModal'; +import ErrorDetail from '../../../components/ErrorDetail'; import UserTokensListItem from './UserTokenListItem'; const QS_CONFIG = getQSConfig('user', { @@ -25,13 +28,19 @@ function UserTokenList({ i18n }) { error, isLoading, request: fetchTokens, - result: { tokens, itemCount }, + result: { tokens, itemCount, relatedSearchableKeys, searchableKeys }, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const { - data: { results, count }, - } = await UsersAPI.readTokens(id, params); + const [ + { + data: { results, count }, + }, + actionsResponse, + ] = await Promise.all([ + UsersAPI.readTokens(id, params), + UsersAPI.readTokenOptions(id), + ]); const modifiedResults = results.map(result => { result.summary_fields = { user: result.summary_fields.user, @@ -41,9 +50,18 @@ function UserTokenList({ i18n }) { result.name = result.summary_fields.application?.name; return result; }); - return { tokens: modifiedResults, itemCount: count }; + return { + tokens: modifiedResults, + 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), + }; }, [id, location.search]), - { tokens: [], itemCount: 0 } + { tokens: [], itemCount: 0, relatedSearchableKeys: [], searchableKeys: [] } ); useEffect(() => { @@ -54,84 +72,130 @@ function UserTokenList({ i18n }) { tokens ); + const { + isLoading: isDeleteLoading, + deleteItems: deleteTokens, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all( + selected.map(({ id: tokenId }) => TokensAPI.destroy(tokenId)) + ); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchTokens, + } + ); + const handleDelete = async () => { + await deleteTokens(); + setSelected([]); + }; + const canAdd = true; + return ( - <PaginatedDataList - contentError={error} - hasContentLoading={isLoading} - items={tokens} - itemCount={itemCount} - pluralizedItemName={i18n._(t`Tokens`)} - qsConfig={QS_CONFIG} - onRowClick={handleSelect} - toolbarSearchColumns={[ - { - name: i18n._(t`Name`), - key: 'application__name', - isDefault: true, - }, - { - name: i18n._(t`Description`), - key: 'description', - }, - ]} - toolbarSortColumns={[ - { - name: i18n._(t`Name`), - key: 'application__name', - }, - { - name: i18n._(t`Scope`), - key: 'scope', - }, - { - name: i18n._(t`Expires`), - key: 'expires', - }, - { - name: i18n._(t`Created`), - key: 'created', - }, - { - name: i18n._(t`Modified`), - key: 'modified', - }, - ]} - renderToolbar={props => ( - <DataListToolbar - {...props} - showSelectAll - isAllSelected={isAllSelected} - qsConfig={QS_CONFIG} - onSelectAll={isSelected => setSelected(isSelected ? [...tokens] : [])} - additionalControls={[ - ...(canAdd - ? [ - <ToolbarAddButton - key="add" - linkTo={`${location.pathname}/add`} - />, - ] - : []), - ]} - /> - )} - renderItem={token => ( - <UserTokensListItem - key={token.id} - token={token} - onSelect={() => { - handleSelect(token); - }} - isSelected={selected.some(row => row.id === token.id)} - /> + <> + <PaginatedDataList + contentError={error} + hasContentLoading={isLoading || isDeleteLoading} + items={tokens} + itemCount={itemCount} + pluralizedItemName={i18n._(t`Tokens`)} + qsConfig={QS_CONFIG} + onRowClick={handleSelect} + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'application__name__icontains', + isDefault: true, + }, + { + name: i18n._(t`Description`), + key: 'description__icontains', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'application__name', + }, + { + name: i18n._(t`Scope`), + key: 'scope', + }, + { + name: i18n._(t`Expires`), + key: 'expires', + }, + { + name: i18n._(t`Created`), + key: 'created', + }, + { + name: i18n._(t`Modified`), + key: 'modified', + }, + ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} + renderToolbar={props => ( + <DataListToolbar + {...props} + showSelectAll + isAllSelected={isAllSelected} + qsConfig={QS_CONFIG} + onSelectAll={isSelected => + setSelected(isSelected ? [...tokens] : []) + } + additionalControls={[ + ...(canAdd + ? [ + <ToolbarAddButton + key="add" + linkTo={`${location.pathname}/add`} + />, + ] + : []), + <ToolbarDeleteButton + key="delete" + onDelete={handleDelete} + itemsToDelete={selected} + pluralizedItemName={i18n._(t`User tokens`)} + />, + ]} + /> + )} + renderItem={token => ( + <UserTokensListItem + key={token.id} + token={token} + onSelect={() => { + handleSelect(token); + }} + isSelected={selected.some(row => row.id === token.id)} + /> + )} + emptyStateControls={ + canAdd ? ( + <ToolbarAddButton key="add" linkTo={`${location.pathname}/add`} /> + ) : null + } + /> + {deletionError && ( + <AlertModal + isOpen={deletionError} + variant="danger" + title={i18n._(t`Error!`)} + onClose={clearDeletionError} + > + {i18n._(t`Failed to delete one or more user tokens.`)} + <ErrorDetail error={deletionError} /> + </AlertModal> )} - emptyStateControls={ - canAdd ? ( - <ToolbarAddButton key="add" linkTo={`${location.pathname}/add`} /> - ) : null - } - /> + </> ); } diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.test.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.test.jsx index 83549798bb..2b05d2ef35 100644 --- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.test.jsx +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.test.jsx @@ -4,10 +4,11 @@ import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; -import { UsersAPI } from '../../../api'; +import { UsersAPI, TokensAPI } from '../../../api'; import UserTokenList from './UserTokenList'; jest.mock('../../../api/models/Users'); +jest.mock('../../../api/models/Tokens'); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -122,8 +123,15 @@ const tokens = { describe('<UserTokenList />', () => { let wrapper; - test('should mount properly, and fetch tokens', async () => { + + beforeEach(() => { UsersAPI.readTokens.mockResolvedValue(tokens); + UsersAPI.readTokenOptions.mockResolvedValue({ + data: { related_search_fields: [] }, + }); + }); + + test('should mount properly, and fetch tokens', async () => { await act(async () => { wrapper = mountWithContexts(<UserTokenList />); }); @@ -136,7 +144,6 @@ describe('<UserTokenList />', () => { }); test('edit button should be disabled', async () => { - UsersAPI.readTokens.mockResolvedValue(tokens); await act(async () => { wrapper = mountWithContexts(<UserTokenList />); }); @@ -162,4 +169,96 @@ describe('<UserTokenList />', () => { wrapper.find('DataListCheck[id="select-token-3"]').props().checked ).toBe(true); }); + test('delete button should be disabled', async () => { + UsersAPI.readTokens.mockResolvedValue(tokens); + await act(async () => { + wrapper = mountWithContexts(<UserTokenList />); + }); + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + true + ); + }); + test('should select and then delete item properly', async () => { + UsersAPI.readTokens.mockResolvedValue(tokens); + await act(async () => { + wrapper = mountWithContexts(<UserTokenList />); + }); + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + true + ); + await act(async () => { + wrapper + .find('DataListCheck[aria-labelledby="check-action-3"]') + .prop('onChange')(tokens.data.results[0]); + }); + wrapper.update(); + expect( + wrapper + .find('DataListCheck[aria-labelledby="check-action-3"]') + .prop('checked') + ).toBe(true); + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + false + ); + await act(async () => + wrapper.find('Button[aria-label="Delete"]').prop('onClick')() + ); + wrapper.update(); + await act(async () => expect(wrapper.find('AlertModal').length).toBe(1)); + await act(async () => + wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')() + ); + wrapper.update(); + expect(TokensAPI.destroy).toHaveBeenCalledWith(3); + }); + test('should select and then delete item properly', async () => { + UsersAPI.readTokens.mockResolvedValue(tokens); + TokensAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: '/api/v2/tokens', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + await act(async () => { + wrapper = mountWithContexts(<UserTokenList />); + }); + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + true + ); + await act(async () => { + wrapper + .find('DataListCheck[aria-labelledby="check-action-3"]') + .prop('onChange')(tokens.data.results[0]); + }); + wrapper.update(); + expect( + wrapper + .find('DataListCheck[aria-labelledby="check-action-3"]') + .prop('checked') + ).toBe(true); + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + false + ); + await act(async () => + wrapper.find('Button[aria-label="Delete"]').prop('onClick')() + ); + wrapper.update(); + await act(async () => expect(wrapper.find('AlertModal').length).toBe(1)); + await act(async () => + wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')() + ); + wrapper.update(); + expect(TokensAPI.destroy).toHaveBeenCalledWith(3); + wrapper.update(); + expect(wrapper.find('ErrorDetail').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx index cb5e4057c5..52eb44a7f1 100644 --- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Link, useParams } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { @@ -10,7 +11,7 @@ import { import styled from 'styled-components'; import { toTitleCase } from '../../../util/strings'; -import { formatDateStringUTC } from '../../../util/dates'; +import { formatDateString } from '../../../util/dates'; import DataListCell from '../../../components/DataListCell'; const Label = styled.b` @@ -22,6 +23,7 @@ const NameLabel = styled.b` `; function UserTokenListItem({ i18n, token, isSelected, onSelect }) { + const { id } = useParams(); const labelId = `check-action-${token.id}`; return ( <DataListItem key={token.id} aria-labelledby={labelId} id={`${token.id}`}> @@ -40,11 +42,15 @@ function UserTokenListItem({ i18n, token, isSelected, onSelect }) { > {token.summary_fields?.application?.name ? ( <span> - <NameLabel>{i18n._(t`Application:`)}</NameLabel> - {token.summary_fields.application.name} + <NameLabel>{i18n._(t`Application`)}</NameLabel> + <Link to={`/users/${id}/tokens/${token.id}/details`}> + {token.summary_fields.application.name} + </Link> </span> ) : ( - i18n._(t`Personal access token`) + <Link to={`/users/${id}/tokens/${token.id}/details`}> + {i18n._(t`Personal access token`)} + </Link> )} </DataListCell>, <DataListCell aria-label={i18n._(t`scope`)} key={token.scope}> @@ -53,7 +59,7 @@ function UserTokenListItem({ i18n, token, isSelected, onSelect }) { </DataListCell>, <DataListCell aria-label={i18n._(t`expiration`)} key="expiration"> <Label>{i18n._(t`Expires`)}</Label> - {formatDateStringUTC(token.expires)} + {formatDateString(token.expires)} </DataListCell>, ]} /> diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx index fe009e4b8a..a91e2d1632 100644 --- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx @@ -53,7 +53,7 @@ describe('<UserTokenListItem />', () => { expect(wrapper.find('DataListCheck').prop('checked')).toBe(false); expect( wrapper.find('PFDataListCell[aria-label="application name"]').text() - ).toBe('Application:app'); + ).toBe('Applicationapp'); expect(wrapper.find('PFDataListCell[aria-label="scope"]').text()).toBe( 'ScopeRead' ); diff --git a/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx new file mode 100644 index 0000000000..c73519d7f9 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { Switch, Route, useParams } from 'react-router-dom'; +import UserTokenAdd from '../UserTokenAdd'; +import UserTokenList from '../UserTokenList'; +import UserToken from '../UserToken'; + +function UserTokens({ setBreadcrumb, user }) { + const { id } = useParams(); + return ( + <Switch> + <Route key="add" path="/users/:id/tokens/add"> + <UserTokenAdd id={Number(id)} /> + </Route> + <Route key="token" path="/users/:id/tokens/:tokenId"> + <UserToken user={user} setBreadcrumb={setBreadcrumb} id={Number(id)} /> + </Route> + <Route key="list" path="/users/:id/tokens"> + <UserTokenList id={Number(id)} /> + </Route> + </Switch> + ); +} + +export default withI18n()(UserTokens); diff --git a/awx/ui_next/src/screens/User/UserTokens/index.js b/awx/ui_next/src/screens/User/UserTokens/index.js new file mode 100644 index 0000000000..8ea0743daa --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokens/index.js @@ -0,0 +1 @@ +export { default } from './UserTokens'; diff --git a/awx/ui_next/src/screens/User/Users.jsx b/awx/ui_next/src/screens/User/Users.jsx index 575b997f48..e9fe2d4ef2 100644 --- a/awx/ui_next/src/screens/User/Users.jsx +++ b/awx/ui_next/src/screens/User/Users.jsx @@ -18,7 +18,7 @@ function Users({ i18n }) { const match = useRouteMatch(); const addUserBreadcrumb = useCallback( - user => { + (user, token) => { if (!user) { return; } @@ -33,6 +33,11 @@ function Users({ i18n }) { [`/users/${user.id}/teams`]: i18n._(t`Teams`), [`/users/${user.id}/organizations`]: i18n._(t`Organizations`), [`/users/${user.id}/tokens`]: i18n._(t`Tokens`), + [`/users/${user.id}/tokens/add`]: i18n._(t`Create user token`), + [`/users/${user.id}/tokens/${token && token.id}`]: `Application Name`, + [`/users/${user.id}/tokens/${token && token.id}/details`]: i18n._( + t`Details` + ), }); }, [i18n] diff --git a/awx/ui_next/src/screens/User/shared/UserTokenForm.jsx b/awx/ui_next/src/screens/User/shared/UserTokenForm.jsx new file mode 100644 index 0000000000..bfc5f0f08f --- /dev/null +++ b/awx/ui_next/src/screens/User/shared/UserTokenForm.jsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Formik, useField } from 'formik'; +import { Form, FormGroup } from '@patternfly/react-core'; +import AnsibleSelect from '../../../components/AnsibleSelect'; +import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup'; +import FormField, { + FormSubmitError, + FieldTooltip, +} from '../../../components/FormField'; +import ApplicationLookup from '../../../components/Lookup/ApplicationLookup'; +import { required } from '../../../util/validators'; + +import { FormColumnLayout } from '../../../components/FormLayout'; + +function UserTokenFormFields({ i18n }) { + const [applicationField, applicationMeta, applicationHelpers] = useField( + 'application' + ); + + const [scopeField, scopeMeta, scopeHelpers] = useField({ + name: 'scope', + validate: required(i18n._(t`Please enter a value.`), i18n), + }); + + return ( + <> + <FormGroup + fieldId="application-lookup" + name="application" + validated={ + !applicationMeta.touched || !applicationMeta.error + ? 'default' + : 'error' + } + helperTextInvalid={applicationMeta.error} + > + <ApplicationLookup + value={applicationField.value} + onChange={value => { + applicationHelpers.setValue(value); + }} + label={ + <span> + {i18n._(t`Application`)} + <FieldTooltip + content={i18n._( + t`Select the application that this token will belong to.` + )} + /> + </span> + } + touched={applicationMeta.touched} + /> + </FormGroup> + <FormField + id="token-description" + name="description" + type="text" + label={i18n._(t`Description`)} + /> + + <FormGroup + name="scope" + fieldId="token-scope" + helperTextInvalid={scopeMeta.error} + isRequired + validated={!scopeMeta.touched || !scopeMeta.error ? 'default' : 'error'} + label={i18n._(t`Scope`)} + labelIcon={ + <FieldTooltip + content={i18n._(t`Specify a scope for the token's access`)} + /> + } + > + <AnsibleSelect + {...scopeField} + id="token-scope" + data={[ + { key: 'default', label: '', value: '' }, + { key: 'read', value: 'read', label: i18n._(t`Read`) }, + { key: 'write', value: 'write', label: i18n._(t`Write`) }, + ]} + onChange={(event, value) => { + scopeHelpers.setValue(value); + }} + /> + </FormGroup> + </> + ); +} + +function UserTokenForm({ + handleCancel, + handleSubmit, + submitError, + i18n, + token = {}, +}) { + return ( + <Formik + initialValues={{ + description: token.description || '', + application: token.application || null, + scope: token.scope || '', + }} + onSubmit={handleSubmit} + > + {formik => ( + <Form autoComplete="off" onSubmit={formik.handleSubmit}> + <FormColumnLayout> + <UserTokenFormFields i18n={i18n} /> + {submitError && <FormSubmitError error={submitError} />} + <FormActionGroup + onCancel={handleCancel} + onSubmit={() => { + formik.handleSubmit(); + }} + /> + </FormColumnLayout> + </Form> + )} + </Formik> + ); +} +export default withI18n()(UserTokenForm); diff --git a/awx/ui_next/src/screens/User/shared/UserTokenForm.test.jsx b/awx/ui_next/src/screens/User/shared/UserTokenForm.test.jsx new file mode 100644 index 0000000000..ddfcbd6cb4 --- /dev/null +++ b/awx/ui_next/src/screens/User/shared/UserTokenForm.test.jsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import UserTokenForm from './UserTokenForm'; +import { sleep } from '../../../../testUtils/testUtils'; +import { ApplicationsAPI } from '../../../api'; + +jest.mock('../../../api'); +const applications = { + data: { + count: 2, + results: [ + { + id: 1, + name: 'app', + description: '', + }, + { + id: 4, + name: 'application that should not crach', + description: '', + }, + ], + }, +}; +describe('<UserTokenForm />', () => { + let wrapper; + beforeEach(() => {}); + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('initially renders successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + <UserTokenForm handleSubmit={jest.fn()} handleCancel={jest.fn()} /> + ); + }); + + expect(wrapper.find('UserTokenForm').length).toBe(1); + }); + + test('add form displays all form fields', async () => { + await act(async () => { + wrapper = mountWithContexts( + <UserTokenForm handleSubmit={jest.fn()} handleCancel={jest.fn()} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('FormGroup[name="application"]').length).toBe(1); + expect(wrapper.find('FormField[name="description"]').length).toBe(1); + expect(wrapper.find('FormGroup[name="scope"]').length).toBe(1); + }); + + test('inputs should update form value on change', async () => { + await act(async () => { + wrapper = mountWithContexts( + <UserTokenForm handleSubmit={jest.fn()} handleCancel={jest.fn()} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + wrapper.update(); + await act(async () => { + wrapper.find('ApplicationLookup').invoke('onChange')({ + id: 1, + name: 'application', + }); + wrapper.find('input[name="description"]').simulate('change', { + target: { value: 'new Bar', name: 'description' }, + }); + wrapper.find('AnsibleSelect[name="scope"]').prop('onChange')({}, 'read'); + }); + wrapper.update(); + expect(wrapper.find('ApplicationLookup').prop('value')).toEqual({ + id: 1, + name: 'application', + }); + expect(wrapper.find('input[name="description"]').prop('value')).toBe( + 'new Bar' + ); + expect(wrapper.find('AnsibleSelect#token-scope').prop('value')).toBe( + 'read' + ); + }); + + test('should call handleSubmit when Submit button is clicked', async () => { + ApplicationsAPI.read.mockResolvedValue(applications); + const handleSubmit = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + <UserTokenForm handleSubmit={handleSubmit} handleCancel={jest.fn()} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + + await act(async () => { + wrapper.find('AnsibleSelect[name="scope"]').prop('onChange')({}, 'read'); + }); + wrapper.update(); + await act(async () => { + wrapper.find('button[aria-label="Save"]').prop('onClick')(); + }); + await sleep(1); + + expect(handleSubmit).toBeCalled(); + }); + + test('should call handleCancel when Cancel button is clicked', async () => { + const handleCancel = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + <UserTokenForm handleSubmit={jest.fn()} handleCancel={handleCancel} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(handleCancel).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + expect(handleCancel).toBeCalled(); + }); + test('should throw error on submit without scope value', async () => { + ApplicationsAPI.read.mockResolvedValue(applications); + const handleSubmit = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + <UserTokenForm handleSubmit={handleSubmit} handleCancel={jest.fn()} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + + await act(async () => { + wrapper.find('button[aria-label="Save"]').prop('onClick')(); + }); + await sleep(1); + wrapper.update(); + expect( + wrapper.find('FormGroup[name="scope"]').prop('helperTextInvalid') + ).toBe('Please enter a value.'); + expect(handleSubmit).not.toBeCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/User/shared/index.js b/awx/ui_next/src/screens/User/shared/index.js index ee4362b5c2..4a93f427bd 100644 --- a/awx/ui_next/src/screens/User/shared/index.js +++ b/awx/ui_next/src/screens/User/shared/index.js @@ -1,2 +1,3 @@ /* eslint-disable-next-line import/prefer-default-export */ export { default as UserForm } from './UserForm'; +export { default as UserTokenForm } from './UserTokenForm'; diff --git a/awx/ui_next/src/setupTests.js b/awx/ui_next/src/setupTests.js index 7122bdd255..7d59ff1a4c 100644 --- a/awx/ui_next/src/setupTests.js +++ b/awx/ui_next/src/setupTests.js @@ -12,3 +12,10 @@ require('@nteract/mockument'); // eslint-disable-next-line import/prefer-default-export export const asyncFlush = () => new Promise(resolve => setImmediate(resolve)); + +// this ensures that debug messages don't get logged out to the console +// while tests are running i.e. websocket connect/disconnect +global.console = { + ...console, + debug: jest.fn(), +}; diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index 4ba0808d7d..7a66ae3c68 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -234,6 +234,13 @@ export const Team = shape({ organization: number, }); +export const Token = shape({ + id: number.isRequired, + expires: string.isRequired, + summary_fields: shape({}), + scope: string.isRequired, +}); + export const User = shape({ id: number.isRequired, type: oneOf(['user']), diff --git a/awx/ui_next/src/components/JobList/useThrottle.js b/awx/ui_next/src/util/useThrottle.js index cfdedfecfc..cfdedfecfc 100644 --- a/awx/ui_next/src/components/JobList/useThrottle.js +++ b/awx/ui_next/src/util/useThrottle.js diff --git a/awx/ui_next/src/util/useWebsocket.js b/awx/ui_next/src/util/useWebsocket.js new file mode 100644 index 0000000000..c04d086f35 --- /dev/null +++ b/awx/ui_next/src/util/useWebsocket.js @@ -0,0 +1,49 @@ +import { useState, useEffect, useRef } from 'react'; + +export default function useWebsocket(subscribeGroups) { + const [lastMessage, setLastMessage] = useState(null); + const ws = useRef(null); + + useEffect(function setupSocket() { + ws.current = new WebSocket(`wss://${window.location.host}/websocket/`); + + const connect = () => { + const xrftoken = `; ${document.cookie}` + .split('; csrftoken=') + .pop() + .split(';') + .shift(); + ws.current.send( + JSON.stringify({ + xrftoken, + groups: subscribeGroups, + }) + ); + }; + ws.current.onopen = connect; + + ws.current.onmessage = e => { + setLastMessage(JSON.parse(e.data)); + }; + + ws.current.onclose = e => { + // eslint-disable-next-line no-console + console.debug('Socket closed. Reconnecting...', e); + setTimeout(() => { + connect(); + }, 1000); + }; + + ws.current.onerror = err => { + // eslint-disable-next-line no-console + console.debug('Socket error: ', err, 'Disconnecting...'); + ws.current.close(); + }; + + return () => { + ws.current.close(); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + return lastMessage; +} diff --git a/awx/ui_next/src/util/yaml.js b/awx/ui_next/src/util/yaml.js index d9b5d8ed1b..ec11b09613 100644 --- a/awx/ui_next/src/util/yaml.js +++ b/awx/ui_next/src/util/yaml.js @@ -38,13 +38,12 @@ export function isJson(jsonString) { export function parseVariableField(variableField) { if (variableField === '---' || variableField === '{}') { - variableField = {}; - } else { - if (!isJson(variableField)) { - variableField = yamlToJson(variableField); - } - variableField = JSON.parse(variableField); + return {}; } + if (!isJson(variableField)) { + variableField = yamlToJson(variableField); + } + variableField = JSON.parse(variableField); return variableField; } diff --git a/awx_collection/plugins/doc_fragments/auth_plugin.py b/awx_collection/plugins/doc_fragments/auth_plugin.py new file mode 100644 index 0000000000..527054ed27 --- /dev/null +++ b/awx_collection/plugins/doc_fragments/auth_plugin.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Ansible by Red Hat, Inc +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + # Ansible Tower documentation fragment + DOCUMENTATION = r''' +options: + host: + description: The network address of your Ansible Tower host. + env: + - name: TOWER_HOST + username: + description: The user that you plan to use to access inventories on Ansible Tower. + env: + - name: TOWER_USERNAME + password: + description: The password for your Ansible Tower user. + env: + - name: TOWER_PASSWORD + oauth_token: + description: + - The Tower OAuth token to use. + env: + - name: TOWER_OAUTH_TOKEN + verify_ssl: + description: + - Specify whether Ansible should verify the SSL certificate of Ansible Tower host. + - Defaults to True, but this is handled by the shared module_utils code + type: bool + env: + - name: TOWER_VERIFY_SSL + aliases: [ validate_certs ] + +notes: +- If no I(config_file) is provided we will attempt to use the tower-cli library + defaults to find your Tower host information. +- I(config_file) should contain Tower configuration in the following format + host=hostname + username=username + password=password +''' diff --git a/awx_collection/plugins/inventory/tower.py b/awx_collection/plugins/inventory/tower.py index c906795a8e..872e2a3328 100644 --- a/awx_collection/plugins/inventory/tower.py +++ b/awx_collection/plugins/inventory/tower.py @@ -6,59 +6,35 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type DOCUMENTATION = ''' - name: tower - plugin_type: inventory - author: - - Matthew Jones (@matburt) - - Yunfan Zhang (@YunfanZhang42) - short_description: Ansible dynamic inventory plugin for Ansible Tower. - description: - - Reads inventories from Ansible Tower. - - Supports reading configuration from both YAML config file and environment variables. - - If reading from the YAML file, the file name must end with tower.(yml|yaml) or tower_inventory.(yml|yaml), - the path in the command would be /path/to/tower_inventory.(yml|yaml). If some arguments in the config file - are missing, this plugin will try to fill in missing arguments by reading from environment variables. - - If reading configurations from environment variables, the path in the command must be @tower_inventory. - options: - host: - description: The network address of your Ansible Tower host. - env: - - name: TOWER_HOST - username: - description: The user that you plan to use to access inventories on Ansible Tower. - env: - - name: TOWER_USERNAME - password: - description: The password for your Ansible Tower user. - env: - - name: TOWER_PASSWORD - oauth_token: - description: - - The Tower OAuth token to use. - env: - - name: TOWER_OAUTH_TOKEN - inventory_id: - description: - - The ID of the Ansible Tower inventory that you wish to import. - - This is allowed to be either the inventory primary key or its named URL slug. - - Primary key values will be accepted as strings or integers, and URL slugs must be strings. - - Named URL slugs follow the syntax of "inventory_name++organization_name". - type: raw - env: - - name: TOWER_INVENTORY - required: True - verify_ssl: - description: - - Specify whether Ansible should verify the SSL certificate of Ansible Tower host. - - Defaults to True, but this is handled by the shared module_utils code - type: bool - env: - - name: TOWER_VERIFY_SSL - aliases: [ validate_certs ] - include_metadata: - description: Make extra requests to provide all group vars with metadata about the source Ansible Tower host. - type: bool - default: False +name: tower +plugin_type: inventory +author: + - Matthew Jones (@matburt) + - Yunfan Zhang (@YunfanZhang42) +short_description: Ansible dynamic inventory plugin for Ansible Tower. +description: + - Reads inventories from Ansible Tower. + - Supports reading configuration from both YAML config file and environment variables. + - If reading from the YAML file, the file name must end with tower.(yml|yaml) or tower_inventory.(yml|yaml), + the path in the command would be /path/to/tower_inventory.(yml|yaml). If some arguments in the config file + are missing, this plugin will try to fill in missing arguments by reading from environment variables. + - If reading configurations from environment variables, the path in the command must be @tower_inventory. +extends_documentation_fragment: awx.awx.auth_plugin +options: + inventory_id: + description: + - The ID of the Ansible Tower inventory that you wish to import. + - This is allowed to be either the inventory primary key or its named URL slug. + - Primary key values will be accepted as strings or integers, and URL slugs must be strings. + - Named URL slugs follow the syntax of "inventory_name++organization_name". + type: raw + env: + - name: TOWER_INVENTORY + required: True + include_metadata: + description: Make extra requests to provide all group vars with metadata about the source Ansible Tower host. + type: bool + default: False ''' EXAMPLES = ''' diff --git a/awx_collection/plugins/lookup/tower_api.py b/awx_collection/plugins/lookup/tower_api.py new file mode 100644 index 0000000000..9829507125 --- /dev/null +++ b/awx_collection/plugins/lookup/tower_api.py @@ -0,0 +1,196 @@ +# (c) 2020 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = """ +lookup: tower_api +author: John Westcott IV (@john-westcott-iv) +short_description: Search the API for objects +requirements: + - None +description: + - Returns GET requests from the Ansible Tower API. See + U(https://docs.ansible.com/ansible-tower/latest/html/towerapi/index.html) for API usage. + - For use that is cross-compatible between the awx.awx and ansible.tower collection + see the tower_meta module +extends_documentation_fragment: awx.awx.auth_plugin +options: + _terms: + description: + - The endpoint to query, i.e. teams, users, tokens, job_templates, etc. + required: True + query_params: + description: + - The query parameters to search for in the form of key/value pairs. + type: dict + required: False + aliases: [query, data, filter, params] + expect_objects: + description: + - Error if the response does not contain either a detail view or a list view. + type: boolean + default: False + aliases: [expect_object] + expect_one: + description: + - Error if the response contains more than one object. + type: boolean + default: False + return_objects: + description: + - If a list view is returned, promote the list of results to the top-level of list returned. + - Allows using this lookup plugin to loop over objects without additional work. + type: boolean + default: True + return_all: + description: + - If the response is paginated, return all pages. + type: boolean + default: False + return_ids: + description: + - If response contains objects, promote the id key to the top-level entries in the list. + - Allows looking up a related object and passing it as a parameter to another module. + - This will convert the return to a string or list of strings depending on the number of selected items. + type: boolean + aliases: [return_id] + default: False + max_objects: + description: + - if C(return_all) is true, this is the maximum of number of objects to return from the list. + - If a list view returns more an max_objects an exception will be raised + type: integer + default: 1000 + +notes: + - If the query is not filtered properly this can cause a performance impact. +""" + +EXAMPLES = """ +- name: Load the UI settings + set_fact: + tower_settings: "{{ lookup('awx.awx.tower_api', 'settings/ui') }}" + +- name: Report the usernames of all users with admin privs + debug: + msg: "Admin users: {{ query('awx.awx.tower_api', 'users', query_params={ 'is_superuser': true }) | map(attribute='username') | join(', ') }}" + +- name: debug all organizations in a loop # use query to return a list + debug: + msg: "Organization description={{ item['description'] }} id={{ item['id'] }}" + loop: "{{ query('awx.awx.tower_api', 'organizations') }}" + loop_control: + label: "{{ item['name'] }}" + +- name: Make sure user 'john' is an org admin of the default org if the user exists + tower_role: + organization: Default + role: admin + user: john + when: "lookup('awx.awx.tower_api', 'users', query_params={ 'username': 'john' }) | length == 1" + +- name: Create an inventory group with all 'foo' hosts + tower_group: + name: "Foo Group" + inventory: "Demo Inventory" + hosts: >- + {{ query( + 'awx.awx.tower_api', + 'hosts', + query_params={ 'name__startswith' : 'foo', }, + ) | map(attribute='name') | list }} + register: group_creation +""" + +RETURN = """ +_raw: + description: + - Response from the API + type: dict + returned: on successful request +""" + +from ansible.plugins.lookup import LookupBase +from ansible.errors import AnsibleError +from ansible.module_utils._text import to_native +from ansible.utils.display import Display +from ..module_utils.tower_api import TowerModule + + +class LookupModule(LookupBase): + display = Display() + + def handle_error(self, **kwargs): + raise AnsibleError(to_native(kwargs.get('msg'))) + + def warn_callback(self, warning): + self.display.warning(warning) + + def run(self, terms, variables=None, **kwargs): + if len(terms) != 1: + raise AnsibleError('You must pass exactly one endpoint to query') + + # Defer processing of params to logic shared with the modules + module_params = {} + for plugin_param, module_param in TowerModule.short_params.items(): + opt_val = self.get_option(plugin_param) + if opt_val is not None: + module_params[module_param] = opt_val + + # Create our module + module = TowerModule( + argument_spec={}, direct_params=module_params, + error_callback=self.handle_error, warn_callback=self.warn_callback + ) + + self.set_options(direct=kwargs) + + response = module.get_endpoint(terms[0], data=self.get_option('query_params', {})) + + if 'status_code' not in response: + raise AnsibleError("Unclear response from API: {0}".format(response)) + + if response['status_code'] != 200: + raise AnsibleError("Failed to query the API: {0}".format(response['json'].get('detail', response['json']))) + + return_data = response['json'] + + if self.get_option('expect_objects') or self.get_option('expect_one'): + if ('id' not in return_data) and ('results' not in return_data): + raise AnsibleError( + 'Did not obtain a list or detail view at {0}, and ' + 'expect_objects or expect_one is set to True'.format(terms[0]) + ) + + if self.get_option('expect_one'): + if 'results' in return_data and len(return_data['results']) != 1: + raise AnsibleError( + 'Expected one object from endpoint {0}, ' + 'but obtained {1} from API'.format(terms[0], len(return_data['results'])) + ) + + if self.get_option('return_all') and 'results' in return_data: + if return_data['count'] > self.get_option('max_objects'): + raise AnsibleError( + 'List view at {0} returned {1} objects, which is more than the maximum allowed ' + 'by max_objects, {2}'.format(terms[0], return_data['count'], self.get_option('max_objects')) + ) + + next_page = return_data['next'] + while next_page is not None: + next_response = module.get_endpoint(next_page) + return_data['results'] += next_response['json']['results'] + next_page = next_response['json']['next'] + return_data['next'] = None + + if self.get_option('return_ids'): + if 'results' in return_data: + return_data['results'] = [str(item['id']) for item in return_data['results']] + elif 'id' in return_data: + return_data = str(return_data['id']) + + if self.get_option('return_objects') and 'results' in return_data: + return return_data['results'] + else: + return [return_data] diff --git a/awx_collection/plugins/lookup/tower_schedule_rrule.py b/awx_collection/plugins/lookup/tower_schedule_rrule.py index 0af71b0000..918b9fa1d0 100644 --- a/awx_collection/plugins/lookup/tower_schedule_rrule.py +++ b/awx_collection/plugins/lookup/tower_schedule_rrule.py @@ -11,15 +11,15 @@ DOCUMENTATION = """ - pytz - python.dateutil >= 2.7.0 description: - - Returns a string based on criteria which represent an rule + - Returns a string based on criteria which represents an rrule options: _terms: description: - The frequency of the schedule - none - Run this schedule once - - minute - Run this schedule ever x minutes + - minute - Run this schedule every x minutes - hour - Run this schedule every x hours - - day - Run this schedule ever x days + - day - Run this schedule every x days - week - Run this schedule weekly - month - Run this schedule monthly required: True @@ -39,36 +39,36 @@ DOCUMENTATION = """ type: str every: description: - - The repition in months, weeks, days hours or minutes + - The repetition in months, weeks, days hours or minutes - Used for all types except none type: int end_on: description: - How to end this schedule - - If this is not defined this schedule will never end - - If this is a positive integer this schedule will end after this number of occurances - - If this is a date in the format YYYY-MM-DD [HH:MM:SS] this schedule end after this date + - If this is not defined, this schedule will never end + - If this is a positive integer, this schedule will end after this number of occurences + - If this is a date in the format YYYY-MM-DD [HH:MM:SS], this schedule ends after this date - Used for all types except none type: str on_days: description: - The days to run this schedule on - - A comma seperated list which can contain values sunday, monday, tuesday, wednesday, thursday, friday + - A comma-separated list which can contain values sunday, monday, tuesday, wednesday, thursday, friday - Used for week type schedules month_day_number: description: - The day of the month this schedule will run on (0-31) - Used for month type schedules - - Can not be used with on_the parameter + - Cannot be used with on_the parameter type: int on_the: description: - A description on when this schedule will run - - Two strings seperated by space + - Two strings separated by a space - First string is one of first, second, third, fourth, last - Second string is one of sunday, monday, tuesday, wednesday, thursday, friday - Used for month type schedules - - Can not be used with month_day_number parameters + - Cannot be used with month_day_number parameters """ EXAMPLES = """ @@ -189,7 +189,7 @@ class LookupModule(LookupBase): except Exception: raise AnsibleError('Parameter end_on must either be an integer or in the format YYYY-MM-DD [HH:MM:SS]') - # A week based frequency can also take the on_days parameter + # A week-based frequency can also take the on_days parameter if frequency == 'week' and 'on_days' in kwargs: days = [] for day in kwargs['on_days'].split(','): @@ -200,10 +200,10 @@ class LookupModule(LookupBase): rrule_kwargs['byweekday'] = days - # A month based frequency can also deal with month_day_number and on_the options + # A month-based frequency can also deal with month_day_number and on_the options if frequency == 'month': if 'month_day_number' in kwargs and 'on_the' in kwargs: - raise AnsibleError('Month based frquencies can have month_day_number or on_the but not both') + raise AnsibleError('Month based frequencies can have month_day_number or on_the but not both') if 'month_day_number' in kwargs: try: @@ -219,7 +219,7 @@ class LookupModule(LookupBase): try: (occurance, weekday) = kwargs['on_the'].split(' ') except Exception: - raise AnsibleError('on_the parameter must be two space seperated words') + raise AnsibleError('on_the parameter must be two words separated by a space') if weekday not in LookupModule.weekdays: raise AnsibleError('Weekday portion of on_the parameter is not valid') @@ -231,7 +231,7 @@ class LookupModule(LookupBase): my_rule = rrule.rrule(**rrule_kwargs) - # All frequencies can use a timezone but rrule can't support the format that tower uses. + # All frequencies can use a timezone but rrule can't support the format that Tower uses. # So we will do a string manip here if we need to timezone = 'America/New_York' if 'timezone' in kwargs: @@ -239,9 +239,9 @@ class LookupModule(LookupBase): raise AnsibleError('Timezone parameter is not valid') timezone = kwargs['timezone'] - # rrule puts a \n in the rule instad of a space and can't hand timezones + # rrule puts a \n in the rule instad of a space and can't handle timezones return_rrule = str(my_rule).replace('\n', ' ').replace('DTSTART:', 'DTSTART;TZID={0}:'.format(timezone)) - # Tower requires an interval. rrule will not add interval if its set to 1 + # Tower requires an interval. rrule will not add interval if it's set to 1 if kwargs.get('every', 1) == 1: return_rrule = "{0};INTERVAL=1".format(return_rrule) diff --git a/awx_collection/plugins/modules/tower_credential.py b/awx_collection/plugins/modules/tower_credential.py index 2ca50dea4d..97a801aa8f 100644 --- a/awx_collection/plugins/modules/tower_credential.py +++ b/awx_collection/plugins/modules/tower_credential.py @@ -384,19 +384,25 @@ def main(): team_id = module.resolve_name_to_id('teams', team) # Create credential input from legacy inputs + has_inputs = False credential_inputs = {} for legacy_input in OLD_INPUT_NAMES: if module.params.get(legacy_input) is not None: + has_inputs = True credential_inputs[legacy_input] = module.params.get(legacy_input) + if inputs: + has_inputs = True credential_inputs.update(inputs) # Create the data that gets sent for create and update credential_fields = { 'name': new_name if new_name else name, 'credential_type': cred_type_id, - 'inputs': credential_inputs, } + if has_inputs: + credential_fields['inputs'] = credential_inputs + if description: credential_fields['description'] = description if organization: diff --git a/awx_collection/plugins/modules/tower_inventory_source.py b/awx_collection/plugins/modules/tower_inventory_source.py index b094bb6b55..afa6c229e2 100644 --- a/awx_collection/plugins/modules/tower_inventory_source.py +++ b/awx_collection/plugins/modules/tower_inventory_source.py @@ -77,7 +77,6 @@ options: description: - Delete child groups and hosts not found in source. type: bool - default: 'no' overwrite_vars: description: - Override vars in child groups and hosts with those from external source. @@ -86,7 +85,6 @@ options: description: - Local absolute file path containing a custom Python virtualenv to use. type: str - default: '' timeout: description: The amount of time (in seconds) to run before the task is canceled. type: int @@ -98,7 +96,6 @@ options: description: - Refresh inventory data from its source each time a job is run. type: bool - default: 'no' update_cache_timeout: description: - Time in seconds to consider an inventory sync to be current. @@ -173,7 +170,7 @@ def main(): group_by=dict(), overwrite=dict(type='bool'), overwrite_vars=dict(type='bool'), - custom_virtualenv=dict(default=''), + custom_virtualenv=dict(), timeout=dict(type='int'), verbosity=dict(type='int', choices=[0, 1, 2]), update_on_launch=dict(type='bool'), @@ -257,7 +254,7 @@ def main(): # Layer in all remaining optional information for field_name in OPTIONAL_VARS: field_val = module.params.get(field_name) - if field_val: + if field_val is not None: inventory_source_fields[field_name] = field_val # Attempt to JSON encode source vars diff --git a/awx_collection/plugins/modules/tower_meta.py b/awx_collection/plugins/modules/tower_meta.py new file mode 100644 index 0000000000..6d5c801ade --- /dev/null +++ b/awx_collection/plugins/modules/tower_meta.py @@ -0,0 +1,84 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# (c) 2020, Ansible by Red Hat, Inc +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +--- +module: tower_meta +author: "Alan Rominger (@alancoding)" +short_description: Returns metadata about the collection this module lives in. +description: + - Allows a user to find out what collection this module exists in. + - This takes common module parameters, but does nothing with them. +options: {} +extends_documentation_fragment: awx.awx.auth +''' + + +RETURN = ''' +prefix: + description: Collection namespace and name in the namespace.name format + returned: success + sample: awx.awx + type: str +name: + description: Collection name + returned: success + sample: awx + type: str +namespace: + description: Collection namespace + returned: success + sample: awx + type: str +version: + description: Version of the collection + returned: success + sample: 0.0.1-devel + type: str +''' + + +EXAMPLES = ''' +- tower_meta: + register: result + +- name: Show details about the collection + debug: var=result + +- name: Load the UI setting without hard-coding the collection name + debug: + msg: "{{ lookup(result.prefix + '.tower_api', 'settings/ui') }}" +''' + + +from ..module_utils.tower_api import TowerModule + + +def main(): + module = TowerModule(argument_spec={}) + namespace = { + 'awx': 'awx', + 'tower': 'ansible' + }.get(module._COLLECTION_TYPE, 'unknown') + namespace_name = '{0}.{1}'.format(namespace, module._COLLECTION_TYPE) + module.exit_json( + prefix=namespace_name, + name=module._COLLECTION_TYPE, + namespace=namespace, + version=module._COLLECTION_VERSION + ) + + +if __name__ == '__main__': + main() diff --git a/awx_collection/requirements.txt b/awx_collection/requirements.txt new file mode 100644 index 0000000000..37d1ffa1c6 --- /dev/null +++ b/awx_collection/requirements.txt @@ -0,0 +1,3 @@ +pytz # for tower_schedule_rrule lookup plugin +python-dateutil>=2.7.0 # tower_schedule_rrule +awxkit # For import and export modules
\ No newline at end of file diff --git a/awx_collection/test/awx/test_inventory_source.py b/awx_collection/test/awx/test_inventory_source.py index bc4a7bfe1e..ab0296689b 100644 --- a/awx_collection/test/awx/test_inventory_source.py +++ b/awx_collection/test/awx/test_inventory_source.py @@ -151,6 +151,31 @@ def test_custom_venv_no_op(run_module, admin_user, base_inventory, mocker, proje assert inv_src.description == 'this is the changed description' +@pytest.mark.django_db +def test_falsy_value(run_module, admin_user, base_inventory): + result = run_module('tower_inventory_source', dict( + name='falsy-test', + inventory=base_inventory.name, + source='ec2', + update_on_launch=True + ), admin_user) + assert not result.get('failed', False), result.get('msg', result) + assert result.get('changed', None), result + + inv_src = InventorySource.objects.get(name='falsy-test') + assert inv_src.update_on_launch is True + + result = run_module('tower_inventory_source', dict( + name='falsy-test', + inventory=base_inventory.name, + # source='ec2', + update_on_launch=False + ), admin_user) + + inv_src.refresh_from_db() + assert inv_src.update_on_launch is False + + # Tests related to source-specific parameters # # We want to let the API return issues with "this doesn't support that", etc. diff --git a/awx_collection/test/awx/test_schedule.py b/awx_collection/test/awx/test_schedule.py index 3db6249b33..7a58892dcf 100644 --- a/awx_collection/test/awx/test_schedule.py +++ b/awx_collection/test/awx/test_schedule.py @@ -84,7 +84,7 @@ def test_empty_schedule_rrule(collection_import, freq): 'Parameter on_days must only contain values monday, tuesday, wednesday, thursday, friday, saturday, sunday'), # Test combo of both month_day_number and on_the ('month', dict(start_date='2020-4-16 03:45:07', on_the='something', month_day_number='else'), - "Month based frquencies can have month_day_number or on_the but not both"), + "Month based frequencies can have month_day_number or on_the but not both"), # Test month_day_number as not an integer ('month', dict(start_date='2020-4-16 03:45:07', month_day_number='junk'), "month_day_number must be between 1 and 31"), # Test month_day_number < 1 @@ -92,7 +92,7 @@ def test_empty_schedule_rrule(collection_import, freq): # Test month_day_number > 31 ('month', dict(start_date='2020-4-16 03:45:07', month_day_number='32'), "month_day_number must be between 1 and 31"), # Test on_the as junk - ('month', dict(start_date='2020-4-16 03:45:07', on_the='junk'), "on_the parameter must be two space seperated words"), + ('month', dict(start_date='2020-4-16 03:45:07', on_the='junk'), "on_the parameter must be two words separated by a space"), # Test on_the with invalid occurance ('month', dict(start_date='2020-4-16 03:45:07', on_the='junk wednesday'), "The first string of the on_the parameter is not valid"), # Test on_the with invalid weekday diff --git a/awx_collection/tests/integration/targets/demo_data/tasks/main.yml b/awx_collection/tests/integration/targets/demo_data/tasks/main.yml index cce9cc1f51..800afda594 100644 --- a/awx_collection/tests/integration/targets/demo_data/tasks/main.yml +++ b/awx_collection/tests/integration/targets/demo_data/tasks/main.yml @@ -16,6 +16,15 @@ name: "Demo Inventory" organization: Default +- name: Create a Host + tower_host: + name: "localhost" + inventory: "Demo Inventory" + state: present + variables: + ansible_connection: local + register: result + - name: Assure that demo job template exists tower_job_template: name: "Demo Job Template" diff --git a/awx_collection/tests/integration/targets/tower_credential/tasks/main.yml b/awx_collection/tests/integration/targets/tower_credential/tasks/main.yml index 13cf0c45c2..7c0f4b080d 100644 --- a/awx_collection/tests/integration/targets/tower_credential/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_credential/tasks/main.yml @@ -191,6 +191,19 @@ that: - result is changed +- name: Check for inputs idempotency (when "inputs" is blank) + tower_credential: + name: "{{ ssh_cred_name2 }}" + organization: Default + state: present + credential_type: Machine + description: An example SSH credential + register: result + +- assert: + that: + - result is not changed + - name: Create a valid SSH credential from lookup source (old school) tower_credential: name: "{{ ssh_cred_name3 }}" diff --git a/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml b/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml index a3e1338e02..b04fa62ff8 100644 --- a/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml @@ -19,6 +19,8 @@ job_type: run project: "{{ proj_name }}" inventory: "Demo Inventory" + extra_vars: + sleep_interval: 300 - name: Check deprecation warnings tower_job_wait: diff --git a/awx_collection/tests/integration/targets/tower_lookup_api_plugin/tasks/main.yml b/awx_collection/tests/integration/targets/tower_lookup_api_plugin/tasks/main.yml new file mode 100644 index 0000000000..9f29b0d9ab --- /dev/null +++ b/awx_collection/tests/integration/targets/tower_lookup_api_plugin/tasks/main.yml @@ -0,0 +1,238 @@ +--- +- name: Generate a random string for test + set_fact: + test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + when: test_id is not defined + +- name: Generate usernames + set_fact: + usernames: + - "AWX-Collection-tests-tower_api_lookup-user1-{{ test_id }}" + - "AWX-Collection-tests-tower_api_lookup-user2-{{ test_id }}" + - "AWX-Collection-tests-tower_api_lookup-user3-{{ test_id }}" + hosts: + - "AWX-Collection-tests-tower_api_lookup-host1-{{ test_id }}" + - "AWX-Collection-tests-tower_api_lookup-host2-{{ test_id }}" + group_name: "AWX-Collection-tests-tower_api_lookup-group1-{{ test_id }}" + +- name: Get our collection package + tower_meta: + register: tower_meta + +- name: Generate the name of our plugin + set_fact: + plugin_name: "{{ tower_meta.prefix }}.tower_api" + +- name: Create all of our users + tower_user: + username: "{{ item }}" + is_superuser: true + password: "{{ test_id }}" + loop: "{{ usernames }}" + register: user_creation_results + +- block: + - name: Create our hosts + tower_host: + name: "{{ item }}" + inventory: "Demo Inventory" + loop: "{{ hosts }}" + + - name: Test too many params (failure from validation of terms) + set_fact: + junk: "{{ query(plugin_name, 'users', 'teams', query_params={}, ) }}" + ignore_errors: true + register: result + + - assert: + that: + - result is failed + - "'You must pass exactly one endpoint to query' in result.msg" + + - name: Try to load invalid endpoint + set_fact: + junk: "{{ query(plugin_name, 'john', query_params={}, ) }}" + ignore_errors: true + register: result + + - assert: + that: + - result is failed + - "'The requested object could not be found at' in result.msg" + + - name: Load user of a specific name without promoting objects + set_fact: + users_list: "{{ lookup(plugin_name, 'users', query_params={ 'username' : user_creation_results['results'][0]['item'] }, return_objects=False) }}" + + - assert: + that: + - users_list['results'] | length() == 1 + - users_list['count'] == 1 + - users_list['results'][0]['id'] == user_creation_results['results'][0]['id'] + + - name: Load user of a specific name with promoting objects + set_fact: + user_objects: "{{ query(plugin_name, 'users', query_params={ 'username' : user_creation_results['results'][0]['item'] }, return_objects=True ) }}" + + - assert: + that: + - user_objects | length() == 1 + - users_list['results'][0]['id'] == user_objects[0]['id'] + + - name: Loop over one user with the loop syntax + assert: + that: + - item['id'] == user_creation_results['results'][0]['id'] + loop: "{{ query(plugin_name, 'users', query_params={ 'username' : user_creation_results['results'][0]['item'] } ) }}" + loop_control: + label: "{{ item.id }}" + + - name: Get a page of users as just ids + set_fact: + users: "{{ query(plugin_name, 'users', query_params={ 'username__endswith': test_id, 'page_size': 2 }, return_ids=True ) }}" + + - name: Assert that user list has 2 ids only and that they are strings, not ints + assert: + that: + - users | length() == 2 + - user_creation_results['results'][0]['id'] not in users + - user_creation_results['results'][0]['id'] | string in users + + - name: Get all users of a system through next attribute + set_fact: + users: "{{ query(plugin_name, 'users', query_params={ 'username__endswith': test_id, 'page_size': 1 }, return_all=true ) }}" + + - assert: + that: + - users | length() >= 3 + + - name: Get all of the users created with a max_objects of 1 + set_fact: + users: "{{ lookup(plugin_name, 'users', query_params={ 'username__endswith': test_id, 'page_size': 1 }, return_all=true, max_objects=1 ) }}" + ignore_errors: true + register: max_user_errors + + - assert: + that: + - max_user_errors is failed + - "'List view at users returned 3 objects, which is more than the maximum allowed by max_objects' in max_user_errors.msg" + + - name: Get the ID of the first user created and verify that it is correct + assert: + that: "{{ query(plugin_name, 'users', query_params={ 'username' : user_creation_results['results'][0]['item'] }, return_ids=True)[0] }} == {{ user_creation_results['results'][0]['id'] }}" + + - name: Try to get an ID of someone who does not exist + set_fact: + failed_user_id: "{{ query(plugin_name, 'users', query_params={ 'username': 'john jacob jingleheimer schmidt' }, expect_one=True) }}" + register: result + ignore_errors: true + + - assert: + that: + - result is failed + - "'Expected one object from endpoint users' in result['msg']" + + - name: Lookup too many users + set_fact: + too_many_user_ids: " {{ query(plugin_name, 'users', query_params={ 'username__endswith': test_id }, expect_one=True) }}" + register: results + ignore_errors: true + + - assert: + that: + - results is failed + - "'Expected one object from endpoint users, but obtained 3' in results['msg']" + + - name: Get the ping page + set_fact: + ping_data: "{{ lookup(plugin_name, 'ping' ) }}" + register: results + + - assert: + that: + - results is succeeded + - "'active_node' in ping_data" + + - name: "Make sure that expect_objects fails on an API page" + set_fact: + my_var: "{{ lookup(plugin_name, 'settings/ui', expect_objects=True) }}" + ignore_errors: true + register: results + + - assert: + that: + - results is failed + - "'Did not obtain a list or detail view at settings/ui, and expect_objects or expect_one is set to True' in results.msg" + + # DOCS Example Tests + - name: Load the UI settings + set_fact: + tower_settings: "{{ lookup('awx.awx.tower_api', 'settings/ui') }}" + + - assert: + that: + - "'CUSTOM_LOGO' in tower_settings" + + - name: Display the usernames of all admin users + debug: + msg: "Admin users: {{ query('awx.awx.tower_api', 'users', query_params={ 'is_superuser': true }) | map(attribute='username') | join(', ') }}" + register: results + + - assert: + that: + - "'admin' in results.msg" + + - name: debug all organizations in a loop # use query to return a list + debug: + msg: "Organization description={{ item['description'] }} id={{ item['id'] }}" + loop: "{{ query('awx.awx.tower_api', 'organizations') }}" + loop_control: + label: "{{ item['name'] }}" + + - name: Make sure user 'john' is an org admin of the default org if the user exists + tower_role: + organization: Default + role: admin + user: "{{ usernames[0] }}" + state: absent + register: tower_role_revoke + when: "query('awx.awx.tower_api', 'users', query_params={ 'username': 'DNE_TESTING' }) | length == 1" + + - assert: + that: + - tower_role_revoke is skipped + + - name: Create an inventory group with all 'foo' hosts + tower_group: + name: "{{ group_name }}" + inventory: "Demo Inventory" + hosts: >- + {{ query( + 'awx.awx.tower_api', + 'hosts', + query_params={ 'name__endswith' : test_id, }, + ) | map(attribute='name') | list }} + register: group_creation + + - assert: + that: group_creation is changed + + always: + - name: Cleanup group + tower_group: + name: "{{ group_name }}" + inventory: "Demo Inventory" + state: absent + + - name: Cleanup hosts + tower_host: + name: "{{ item }}" + inventory: "Demo Inventory" + state: absent + loop: "{{ hosts }}" + + - name: Cleanup users + tower_user: + username: "{{ item }}" + state: absent + loop: "{{ usernames }}" diff --git a/awx_collection/tests/integration/targets/tower_schedule_rrule/tasks/main.yml b/awx_collection/tests/integration/targets/tower_schedule_rrule/tasks/main.yml index a2468a697f..837821bac2 100644 --- a/awx_collection/tests/integration/targets/tower_schedule_rrule/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_schedule_rrule/tasks/main.yml @@ -1,7 +1,15 @@ --- +- name: Get our collection package + tower_meta: + register: tower_meta + +- name: Generate the name of our plugin + set_fact: + plugin_name: "{{ tower_meta.prefix }}.tower_schedule_rrule" + - name: Test too many params (failure from validation of terms) debug: - msg: "{{ query('awx.awx.tower_schedule_rrule', 'none', 'weekly', start_date='2020-4-16 03:45:07') }}" + msg: "{{ query(plugin_name, 'none', 'weekly', start_date='2020-4-16 03:45:07') }}" ignore_errors: true register: result @@ -12,7 +20,7 @@ - name: Test invalid frequency (failure from validation of term) debug: - msg: "{{ query('awx.awx.tower_schedule_rrule', 'john', start_date='2020-4-16 03:45:07') }}" + msg: "{{ query(plugin_name, 'john', start_date='2020-4-16 03:45:07') }}" ignore_errors: true register: result @@ -23,7 +31,7 @@ - name: Test an invalid start date (generic failure case from get_rrule) debug: - msg: "{{ query('awx.awx.tower_schedule_rrule', 'none', start_date='invalid') }}" + msg: "{{ query(plugin_name, 'none', start_date='invalid') }}" ignore_errors: true register: result @@ -34,7 +42,7 @@ - name: Test end_on as count (generic success case) debug: - msg: "{{ query('awx.awx.tower_schedule_rrule', 'minute', start_date='2020-4-16 03:45:07', end_on='2') }}" + msg: "{{ query(plugin_name, 'minute', start_date='2020-4-16 03:45:07', end_on='2') }}" register: result - assert: diff --git a/awx_collection/tools/roles/template_galaxy/tasks/main.yml b/awx_collection/tools/roles/template_galaxy/tasks/main.yml index d2f7b2929d..96eb26413c 100644 --- a/awx_collection/tools/roles/template_galaxy/tasks/main.yml +++ b/awx_collection/tools/roles/template_galaxy/tasks/main.yml @@ -2,7 +2,7 @@ - name: Set the collection version in the tower_api.py file replace: path: "{{ collection_path }}/plugins/module_utils/tower_api.py" - regexp: '^ _COLLECTION_VERSION = "devel"' + regexp: '^ _COLLECTION_VERSION = "0.0.1-devel"' replace: ' _COLLECTION_VERSION = "{{ collection_version }}"' when: - "awx_template_version | default(True)" @@ -11,7 +11,7 @@ replace: path: "{{ collection_path }}/plugins/module_utils/tower_api.py" regexp: '^ _COLLECTION_TYPE = "awx"' - replace: ' _COLLECTION_TYPE = "{{ collection_namespace }}"' + replace: ' _COLLECTION_TYPE = "{{ collection_package }}"' - name: Do file content replacements for non-default namespace or package name block: @@ -19,9 +19,12 @@ - name: Change module doc_fragments to support desired namespace and package names replace: path: "{{ item }}" - regexp: '^extends_documentation_fragment: awx.awx.auth' - replace: 'extends_documentation_fragment: {{ collection_namespace }}.{{ collection_package }}.auth' - with_fileglob: "{{ collection_path }}/plugins/modules/tower_*.py" + regexp: '^extends_documentation_fragment: awx.awx.auth([a-zA-Z0-9_]*)$' + replace: 'extends_documentation_fragment: {{ collection_namespace }}.{{ collection_package }}.auth\1' + with_fileglob: + - "{{ collection_path }}/plugins/inventory/*.py" + - "{{ collection_path }}/plugins/lookup/*.py" + - "{{ collection_path }}/plugins/modules/tower_*.py" loop_control: label: "{{ item | basename }}" diff --git a/awxkit/VERSION b/awxkit/VERSION index 02161ca86e..4b964e9654 100644 --- a/awxkit/VERSION +++ b/awxkit/VERSION @@ -1 +1 @@ -13.0.0 +14.0.0 diff --git a/awxkit/awxkit/api/pages/api.py b/awxkit/awxkit/api/pages/api.py index 066153bfd3..664c0c18cf 100644 --- a/awxkit/awxkit/api/pages/api.py +++ b/awxkit/awxkit/api/pages/api.py @@ -268,9 +268,9 @@ class ApiV2(base.Base): def _assign_related(self): for _page, name, related_set in self._related: endpoint = _page.related[name] - if isinstance(related_set, dict): # Relateds that are just json blobs, e.g. survey_spec + if isinstance(related_set, dict): # Related that are just json blobs, e.g. survey_spec endpoint.post(related_set) - return + continue if 'natural_key' not in related_set[0]: # It is an attach set # Try to impedance match diff --git a/awxkit/awxkit/api/pages/labels.py b/awxkit/awxkit/api/pages/labels.py index a76b6920a5..34022f66d2 100644 --- a/awxkit/awxkit/api/pages/labels.py +++ b/awxkit/awxkit/api/pages/labels.py @@ -65,4 +65,5 @@ class Labels(page.PageList, Label): page.register_page([resources.labels, resources.job_labels, - resources.job_template_labels], Labels) + resources.job_template_labels, + resources.workflow_job_template_labels], Labels) diff --git a/awxkit/awxkit/cli/client.py b/awxkit/awxkit/cli/client.py index 8013523921..3feded89dc 100755 --- a/awxkit/awxkit/cli/client.py +++ b/awxkit/awxkit/cli/client.py @@ -70,9 +70,10 @@ class CLI(object): subparsers = {} original_action = None - def __init__(self, stdout=sys.stdout, stderr=sys.stderr): + def __init__(self, stdout=sys.stdout, stderr=sys.stderr, stdin=sys.stdin): self.stdout = stdout self.stderr = stderr + self.stdin = stdin def get_config(self, key): """Helper method for looking up the value of a --conf.xyz flag""" diff --git a/awxkit/awxkit/cli/format.py b/awxkit/awxkit/cli/format.py index 34b1cd5b29..d35c61efbb 100644 --- a/awxkit/awxkit/cli/format.py +++ b/awxkit/awxkit/cli/format.py @@ -40,7 +40,7 @@ def add_authentication_arguments(parser, env): def add_output_formatting_arguments(parser, env): - formatting = parser.add_argument_group('output formatting') + formatting = parser.add_argument_group('input/output formatting') formatting.add_argument( '-f', @@ -49,7 +49,7 @@ def add_output_formatting_arguments(parser, env): choices=FORMATTERS.keys(), default=env.get('TOWER_FORMAT', 'json'), help=( - 'specify an output format' + 'specify a format for the input and output' ), ) formatting.add_argument( @@ -130,7 +130,6 @@ def format_yaml(output, fmt): return yaml.safe_dump( output, default_flow_style=False, - encoding='utf-8', allow_unicode=True ) diff --git a/awxkit/awxkit/cli/resource.py b/awxkit/awxkit/cli/resource.py index f22795fab2..8e30accad2 100644 --- a/awxkit/awxkit/cli/resource.py +++ b/awxkit/awxkit/cli/resource.py @@ -1,8 +1,9 @@ +import yaml import json import os -import sys from awxkit import api, config +from awxkit.exceptions import ImportExportError from awxkit.utils import to_str from awxkit.api.pages import Page from awxkit.api.pages.api import EXPORTABLE_RESOURCES @@ -135,7 +136,13 @@ class Import(CustomCommand): parser.print_help() raise SystemExit() - data = json.load(sys.stdin) + fmt = client.get_config('format') + if fmt == 'json': + data = json.load(client.stdin) + elif fmt == 'yaml': + data = yaml.safe_load(client.stdin) + else: + raise ImportExportError("Unsupported format for Import: " + fmt) client.authenticate() client.v2.import_assets(data) diff --git a/awxkit/test/cli/test_format.py b/awxkit/test/cli/test_format.py index 7166fb841c..5ab6e55d6c 100644 --- a/awxkit/test/cli/test_format.py +++ b/awxkit/test/cli/test_format.py @@ -1,10 +1,13 @@ +import io import json import yaml from awxkit.api.pages import Page from awxkit.api.pages.users import Users, User +from awxkit.cli import CLI from awxkit.cli.format import format_response +from awxkit.cli.resource import Import def test_json_empty_list(): @@ -44,3 +47,26 @@ def test_yaml_list(): page = Users.from_json(users) formatted = format_response(page, fmt='yaml') assert yaml.safe_load(formatted) == users + + +def test_yaml_import(): + class MockedV2: + def import_assets(self, data): + self._parsed_data = data + + def _dummy_authenticate(): + pass + + yaml_fd = io.StringIO( + """ + workflow_job_templates: + - name: Workflow1 + """ + ) + cli = CLI(stdin=yaml_fd) + cli.parse_args(['--conf.format', 'yaml']) + cli.v2 = MockedV2() + cli.authenticate = _dummy_authenticate + + Import().handle(cli, None) + assert cli.v2._parsed_data['workflow_job_templates'][0]['name'] diff --git a/docs/collections.md b/docs/collections.md index 5cbf3eab7a..68c11950f8 100644 --- a/docs/collections.md +++ b/docs/collections.md @@ -4,15 +4,18 @@ AWX supports the use of Ansible Collections. This section will give ways to use ### Project Collections Requirements -If you specify a Collections requirements file in SCM at `collections/requirements.yml`, -then AWX will install Collections from that file in the implicit project sync -before a job run. The invocation looks like: +If you specify a collections requirements file in SCM at `collections/requirements.yml`, +then AWX will install collections from that file to a special cache folder in project updates. +Before a job runs, the roles and/or collections will be copied from the special +cache folder to the job temporary folder. + +The invocation looks like: ``` -ansible-galaxy collection install -r requirements.yml -p <job tmp location>/requirements_collections +ansible-galaxy collection install -r requirements.yml -p <project cache location>/requirements_collections ``` -Example of the resultant `tmp` directory where job is running: +Example of the resultant job `tmp` directory where job is running: ``` ├── project @@ -20,7 +23,7 @@ Example of the resultant `tmp` directory where job is running: │ └── debug.yml ├── requirements_collections │ └── ansible_collections -│ └── username +│ └── collection_namespace │ └── collection_name │ ├── FILES.json │ ├── MANIFEST.json @@ -53,6 +56,33 @@ Example of the resultant `tmp` directory where job is running: ``` +### Cache Folder Mechanics + +Every time a project is updated as a "check" job +(via `/api/v2/projects/N/update/` or by a schedule, workflow, etc.), +the roles and collections are downloaded and saved to the project's content cache. +In other words, the cache is invalidated every time a project is updated. +That means that the `ansible-galaxy` commands are ran to download content +even if the project revision does not change in the course of the update. + +Project updates all initially target a staging directory at a path like: + +``` +/var/lib/awx/projects/.__awx_cache/_42__project_name/stage +``` + +After the update finishes, the task logic will decide what id to associate +with the content downloaded. +Then the folder will be renamed from "stage" to the cache id. +For instance, if the cache id is determined to be 63: + +``` +/var/lib/awx/projects/.__awx_cache/_42__project_name/63 +``` + +The cache may be updated by project syncs (the "run" type) which happen before +job runs. It will populate the cache id set by the last "check" type update. + ### Galaxy Server Selection Ansible core default settings will download collections from the public diff --git a/docs/licenses/ruamel.ordereddict.txt b/docs/licenses/ruamel.ordereddict.txt new file mode 100644 index 0000000000..0c12e55403 --- /dev/null +++ b/docs/licenses/ruamel.ordereddict.txt @@ -0,0 +1,23 @@ + + The MIT License (MIT) + + Copyright (c) 2007-2017 Anthon van der Neut/Ruamel BVBA + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + diff --git a/docs/websockets.md b/docs/websockets.md index 25b781f637..12b81248b4 100644 --- a/docs/websockets.md +++ b/docs/websockets.md @@ -1,18 +1,40 @@ # Channels Overview -Our channels/websocket implementation handles the communication between Tower API and updates in Tower UI. +Our channels/websocket implementation handles the communication between AWX API and updates in AWX UI. ## Architecture -Tower enlists the help of the `django-channels` library to create our communications layer. `django-channels` provides us with per-client messaging integration in our application by implementing the Asynchronous Server Gateway Interface (ASGI). +AWX enlists the help of the `django-channels` library to create our communications layer. `django-channels` provides us with per-client messaging integration in our application by implementing the Asynchronous Server Gateway Interface (ASGI). To communicate between our different services we use websockets. Every AWX node is fully connected via a special websocket endpoint that forwards any local websocket data to all other nodes. Local websockets are backed by Redis, the channels2 default service. -Inside Tower we use the `emit_channel_notification` function which places messages onto the queue. The messages are given an explicit event group and event type which we later use in our wire protocol to control message delivery to the client. +Inside AWX we use the `emit_channel_notification` function which places messages onto the queue. The messages are given an explicit event group and event type which we later use in our wire protocol to control message delivery to the client. + +### Broadcast Backplane + +Previously, AWX leveraged RabbitMQ to deliver Ansible events that emanated from one AWX node to all other AWX nodes so that any client listening and subscribed to the Websockets could get events from any running playbook. We are since moved off of RabbitMQ and onto a per-node local Redis instance. To maintain the requirement that any Websocket connection can receive events from any playbook running on any AWX node we still need to deliver every event to every AWX node. AWX does this via a fully connected Websocket backplane. + +#### Broadcast Backplane Token + +AWX node(s) connect to every other node via the Websocket backplane. The backplane websockets initiate from the `wsbroadcast` process and connect to other nodes via the same nginx process that serves webpage websocket connections and marshalls incoming web/API requests. If you have configured AWX to run with an ssl terminated connection in front of nginx then you likely will have nginx configured to handle http traffic and thus the websocket connection will flow unencrypted over http. If you have nginx configured with ssl enabled, then the websocket traffic will flow encrypted. + +Authentication is accomplished via a shared secret that is generated and set at playbook install time. The shared secret is used to derive a payload that is exchanged via the http(s) header `secret`. The shared secret payload consists of a a `secret`, containing the shared secret, and a `nonce` which is used to mitigate replay attack windows. + +Note that the nonce timestamp is considered valid if it is within `300` second threshold. This is to allow for machine clock skews. +``` +{ + "secret": settings.BROADCAST_WEBSOCKET_SECRET, + "nonce": time.now() +} +``` + +The payload is encrypted using `HMAC-SHA256` with `settings.BROADCAST_WEBSOCKET_SECRET` as the key. The final payload that is sent, including the http header, is of the form: `secret: nonce_plaintext:HMAC_SHA256({"secret": settings.BROADCAST_WEBSOCKET_SECRET, "nonce": nonce_plaintext})`. + +Upon receiving the payload, AWX decrypts the `secret` header using the known shared secret and ensures the `secret` value of the decrypted payload matches the known shared secret, `settings.BROADCAST_WEBSOCKET_SECRET`. If it does not match, the connection is closed. If it does match, the `nonce` is compared to the current time. If the nonce is off by more than `300` seconds, the connection is closed. If both tests pass, the connection is accepted. ## Protocol -You can connect to the Tower channels implementation using any standard websocket library by pointing it to `/websocket`. You must +You can connect to the AWX channels implementation using any standard websocket library by pointing it to `/websocket`. You must provide a valid Auth Token in the request URL. Once you've connected, you are not subscribed to any event groups. You subscribe by sending a `json` request that looks like the following: @@ -35,7 +57,7 @@ These map to the event group and event type that the user is interested in. Send This section will specifically discuss deployment in the context of websockets and the path those requests take through the system. -**Note:** The deployment of Tower changes slightly with the introduction of `django-channels` and websockets. There are some minor differences between production and development deployments that will be pointed out in this document, but the actual services that run the code and handle the requests are identical between the two environments. +**Note:** The deployment of AWX changes slightly with the introduction of `django-channels` and websockets. There are some minor differences between production and development deployments that will be pointed out in this document, but the actual services that run the code and handle the requests are identical between the two environments. ### Services | Name | Details | diff --git a/installer/inventory b/installer/inventory index de001730eb..daa6ba6b7d 100644 --- a/installer/inventory +++ b/installer/inventory @@ -20,11 +20,13 @@ dockerhub_base=ansible # Kubernetes Install # kubernetes_context=test-cluster # kubernetes_namespace=awx +# kubernetes_web_svc_type=NodePort # Optional Kubernetes Variables # pg_image_registry=docker.io # pg_serviceaccount=awx # pg_volume_capacity=5 # pg_persistence_storageClass=StorageClassName +# pg_persistence_existingclaim=postgres_pvc # pg_cpu_limit=1000 # pg_mem_limit=2 @@ -167,3 +169,9 @@ secret_key=awxsecret # Be aware that journald may rate limit your log messages if you choose it. # See: https://docs.docker.com/config/containers/logging/configure/ # docker_logger=journald +# + +# Add extra hosts to docker compose file. This might be necessary to +# sneak in servernames. For exmaple for DMZ self-signed CA certificates. +# Equivialent to using the --add-host parameter with "docker run". +#docker_compose_extra_hosts="otherserver.local:192.168.0.1,ldap-server.local:192.168.0.2" diff --git a/installer/roles/image_build/tasks/main.yml b/installer/roles/image_build/tasks/main.yml index e0b8bd3fe1..9679161a86 100644 --- a/installer/roles/image_build/tasks/main.yml +++ b/installer/roles/image_build/tasks/main.yml @@ -110,14 +110,14 @@ copy: src: launch_awx.sh dest: "{{ docker_base_path }}/launch_awx.sh" - mode: '0700' + mode: '0755' delegate_to: localhost - name: Stage launch_awx_task template: src: launch_awx_task.sh.j2 dest: "{{ docker_base_path }}/launch_awx_task.sh" - mode: '0700' + mode: '0755' delegate_to: localhost - name: Stage rsyslog.conf diff --git a/installer/roles/image_push/tasks/main.yml b/installer/roles/image_push/tasks/main.yml index 61db5ce803..e005af1096 100644 --- a/installer/roles/image_push/tasks/main.yml +++ b/installer/roles/image_push/tasks/main.yml @@ -3,7 +3,7 @@ docker_login: registry: "{{ docker_registry }}" username: "{{ docker_registry_username }}" - password: "{{ docker_registry_password | quote }}" + password: "{{ docker_registry_password }}" reauthorize: true when: docker_registry is defined and docker_registry_password is defined delegate_to: localhost diff --git a/installer/roles/kubernetes/defaults/main.yml b/installer/roles/kubernetes/defaults/main.yml index b810b68103..d8d2c862a9 100644 --- a/installer/roles/kubernetes/defaults/main.yml +++ b/installer/roles/kubernetes/defaults/main.yml @@ -10,6 +10,7 @@ kubernetes_base_path: "{{ local_base_config_path|default('/tmp') }}/{{ kubernete kubernetes_awx_version: "{{ dockerhub_version }}" kubernetes_awx_image: "ansible/awx" +kubernetes_web_svc_type: "NodePort" awx_psp_create: false awx_psp_name: 'awx' diff --git a/installer/roles/kubernetes/templates/deployment.yml.j2 b/installer/roles/kubernetes/templates/deployment.yml.j2 index 36fedcdb45..682c61322b 100644 --- a/installer/roles/kubernetes/templates/deployment.yml.j2 +++ b/installer/roles/kubernetes/templates/deployment.yml.j2 @@ -487,10 +487,13 @@ metadata: labels: name: {{ kubernetes_deployment_name }}-web-svc spec: - type: "NodePort" + type: {{ kubernetes_web_svc_type }} ports: - name: http port: 80 +{% if kubernetes_web_svc_type == "ClusterIP" %} + nodePort: null +{% endif %} targetPort: 8052 selector: name: {{ kubernetes_deployment_name }}-web-deploy diff --git a/installer/roles/kubernetes/templates/postgresql-values.yml.j2 b/installer/roles/kubernetes/templates/postgresql-values.yml.j2 index 83a0a1d578..658b898505 100644 --- a/installer/roles/kubernetes/templates/postgresql-values.yml.j2 +++ b/installer/roles/kubernetes/templates/postgresql-values.yml.j2 @@ -6,6 +6,9 @@ persistence: {% if pg_persistence_storageClass is defined %} storageClass: {{ pg_persistence_storageClass }} {% endif %} +{% if pg_persistence_existingclaim is defined %} + existingClaim: {{ pg_persistence_existingclaim }} +{% endif %} {% if pg_cpu_limit is defined or pg_mem_limit is defined %} resources: limits: diff --git a/installer/roles/local_docker/templates/docker-compose.yml.j2 b/installer/roles/local_docker/templates/docker-compose.yml.j2 index aab318ec77..29677669b1 100644 --- a/installer/roles/local_docker/templates/docker-compose.yml.j2 +++ b/installer/roles/local_docker/templates/docker-compose.yml.j2 @@ -64,6 +64,13 @@ services: {% elif awx_alternate_dns_servers is defined %} dns: "{{ awx_alternate_dns_servers }}" {% endif %} + {% if (docker_compose_extra_hosts is defined) and (':' in docker_compose_extra_hosts) %} + {% set docker_compose_extra_hosts_list = docker_compose_extra_hosts.split(',') %} + extra_hosts: + {% for docker_compose_extra_host in docker_compose_extra_hosts_list %} + - "{{ docker_compose_extra_host }}" + {% endfor %} + {% endif %} environment: http_proxy: {{ http_proxy | default('') }} https_proxy: {{ https_proxy | default('') }} @@ -124,6 +131,13 @@ services: {% elif awx_alternate_dns_servers is defined %} dns: "{{ awx_alternate_dns_servers }}" {% endif %} + {% if (docker_compose_extra_hosts is defined) and (':' in docker_compose_extra_hosts) %} + {% set docker_compose_extra_hosts_list = docker_compose_extra_hosts.split(',') %} + extra_hosts: + {% for docker_compose_extra_host in docker_compose_extra_hosts_list %} + - "{{ docker_compose_extra_host }}" + {% endfor %} + {% endif %} environment: http_proxy: {{ http_proxy | default('') }} https_proxy: {{ https_proxy | default('') }} @@ -153,12 +167,11 @@ services: container_name: awx_postgres restart: unless-stopped volumes: - - {{ postgres_data_dir }}/10/data/:/var/lib/postgresql/data/pgdata:Z + - {{ postgres_data_dir }}/10/data/:/var/lib/postgresql/data:Z environment: POSTGRES_USER: {{ pg_username }} POSTGRES_PASSWORD: {{ pg_password }} POSTGRES_DB: {{ pg_database }} - PGDATA: /var/lib/postgresql/data/pgdata http_proxy: {{ http_proxy | default('') }} https_proxy: {{ https_proxy | default('') }} no_proxy: {{ no_proxy | default('') }} diff --git a/requirements/README.md b/requirements/README.md index af672ae20e..412ac93d8d 100644 --- a/requirements/README.md +++ b/requirements/README.md @@ -8,6 +8,8 @@ Commands should be run from inside the `./requirements` directory of the awx rep Make sure you have `patch, awk, python3, python2, python3-venv, python2-virtualenv, pip2, pip3` installed. The development container image should have all these. +Even in the dev container, you may still have to dnf install `libpq-devel libcurl-devel`. + ### Upgrading or Adding Select Libraries If you need to add or upgrade one targeted library, then modify `requirements.in`, diff --git a/requirements/requirements.in b/requirements/requirements.in index b03e163e3c..f8126fb081 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -23,7 +23,7 @@ django-split-settings django-taggit djangorestframework djangorestframework-yaml -GitPython +GitPython>=3.1.1 # minimum to fix https://github.com/ansible/awx/issues/6119 irc jinja2 jsonschema @@ -36,6 +36,7 @@ pygerduty pyparsing python-radius python3-saml +python-ldap>=3.3.1 # https://github.com/python-ldap/python-ldap/issues/270 pyyaml>=5.3.1 # minimum version to pull in new pyyaml for CVE-2017-18342 schedule==0.6.0 social-auth-core==3.3.1 # see UPGRADE BLOCKERs diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 31a736e083..8408960f28 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -32,8 +32,8 @@ django-oauth-toolkit==1.1.3 # via -r /awx_devel/requirements/requirements.in django-pglocks==1.0.4 # via -r /awx_devel/requirements/requirements.in django-polymorphic==2.1.2 # via -r /awx_devel/requirements/requirements.in django-qsstats-magic==1.1.0 # via -r /awx_devel/requirements/requirements.in -django-redis==4.5.0 django-radius==1.3.3 # via -r /awx_devel/requirements/requirements.in +django-redis==4.5.0 # via -r /awx_devel/requirements/requirements.in django-solo==1.1.3 # via -r /awx_devel/requirements/requirements.in django-split-settings==1.0.0 # via -r /awx_devel/requirements/requirements.in django-taggit==1.2.0 # via -r /awx_devel/requirements/requirements.in @@ -43,7 +43,7 @@ djangorestframework==3.11.0 # via -r /awx_devel/requirements/requirements.in docutils==0.16 # via python-daemon future==0.16.0 # via django-radius gitdb==4.0.2 # via gitpython -gitpython==3.1.0 # via -r /awx_devel/requirements/requirements.in +gitpython==3.1.7 # via -r /awx_devel/requirements/requirements.in google-auth==1.11.3 # via kubernetes hiredis==1.0.1 # via aioredis hyperlink==19.0.0 # via twisted @@ -93,14 +93,14 @@ pyrad==2.3 # via django-radius pyrsistent==0.15.7 # via jsonschema python-daemon==2.2.4 # via ansible-runner python-dateutil==2.8.1 # via adal, kubernetes -python-ldap==3.2.0 # via django-auth-ldap +python-ldap==3.3.1 # via -r /awx_devel/requirements/requirements.in, django-auth-ldap python-radius==1.0 # via -r /awx_devel/requirements/requirements.in python-string-utils==1.0.0 # via openshift python3-openid==3.1.0 # via social-auth-core python3-saml==1.9.0 # via -r /awx_devel/requirements/requirements.in pytz==2019.3 # via django, irc, tempora, twilio pyyaml==5.3.1 # via -r /awx_devel/requirements/requirements.in, ansible-runner, djangorestframework-yaml, kubernetes -redis==3.4.1 # via -r /awx_devel/requirements/requirements.in +redis==3.4.1 # via -r /awx_devel/requirements/requirements.in, django-redis requests-oauthlib==1.3.0 # via kubernetes, msrest, social-auth-core requests==2.23.0 # via -r /awx_devel/requirements/requirements.in, adal, azure-keyvault, django-oauth-toolkit, kubernetes, msrest, requests-oauthlib, slackclient, social-auth-core, twilio rsa==4.0 # via google-auth diff --git a/requirements/requirements_ansible.in b/requirements/requirements_ansible.in index 6cc129180c..4eaf5f7c1d 100644 --- a/requirements/requirements_ansible.in +++ b/requirements/requirements_ansible.in @@ -62,5 +62,7 @@ requests requests-credssp==1.0.2 # For windows authentication awx/issues/1144 # OpenStack openstacksdk==0.37.0 +# Openshift/k8s +openshift>=0.11.0 # minimum version to pull in new pyyaml for CVE-2017-18342 pip==19.3.1 # see upgrade blockers -setuptools==41.6.0 # see upgrade blockers
\ No newline at end of file +setuptools==41.6.0 # see upgrade blockers diff --git a/requirements/requirements_ansible.txt b/requirements/requirements_ansible.txt index b7d0d1d810..55db209e4e 100644 --- a/requirements/requirements_ansible.txt +++ b/requirements/requirements_ansible.txt @@ -43,7 +43,7 @@ boto3==1.9.223 # via -r /awx_devel/requirements/requirements_ansible. boto==2.47.0 # via -r /awx_devel/requirements/requirements_ansible.in botocore==1.12.253 # via boto3, s3transfer cachetools==3.1.1 # via google-auth -certifi==2019.11.28 # via msrest, requests +certifi==2019.11.28 # via kubernetes, msrest, requests cffi==1.13.2 # via bcrypt, cryptography, pynacl chardet==3.0.4 # via requests colorama==0.4.3 # via azure-cli-core, knack @@ -53,18 +53,19 @@ docutils==0.15.2 # via botocore dogpile.cache==0.9.0 # via openstacksdk enum34==1.1.6; python_version < "3" # via cryptography, knack, msrest, ovirt-engine-sdk-python futures==3.3.0; python_version < "3" # via openstacksdk, s3transfer -google-auth==1.6.2 # via -r /awx_devel/requirements/requirements_ansible.in +google-auth==1.6.2 # via -r /awx_devel/requirements/requirements_ansible.in, kubernetes humanfriendly==4.18 # via azure-cli-core idna==2.8 # via requests -ipaddress==1.0.23; python_version < "3" # via cryptography, openstacksdk +ipaddress==1.0.23; python_version < "3" # via cryptography, kubernetes, openstacksdk iso8601==0.1.12 # via keystoneauth1, openstacksdk isodate==0.6.0 # via msrest -jinja2==2.10.1 # via -r /awx_devel/requirements/requirements_ansible.in +jinja2==2.10.1 # via -r /awx_devel/requirements/requirements_ansible.in, openshift jmespath==0.9.4 # via azure-cli-core, boto3, botocore, knack, openstacksdk jsonpatch==1.24 # via openstacksdk jsonpointer==2.0 # via jsonpatch keystoneauth1==3.18.0 # via openstacksdk knack==0.3.3 # via azure-cli-core +kubernetes==11.0.0 # via openshift lxml==4.4.2 # via ncclient markupsafe==1.1.1 # via jinja2 monotonic==1.5; python_version < "3" # via humanfriendly @@ -76,6 +77,7 @@ netaddr==0.7.19 # via -r /awx_devel/requirements/requirements_ansible. netifaces==0.10.9 # via openstacksdk ntlm-auth==1.4.0 # via requests-credssp, requests-ntlm oauthlib==3.1.0 # via requests-oauthlib +openshift==0.11.2 # via -r /awx_devel/requirements/requirements_ansible.in openstacksdk==0.37.0 # via -r /awx_devel/requirements/requirements_ansible.in os-service-types==1.7.0 # via keystoneauth1, openstacksdk ovirt-engine-sdk-python==4.3.0 # via -r /awx_devel/requirements/requirements_ansible.in @@ -93,27 +95,32 @@ pykerberos==1.2.1 # via requests-kerberos pynacl==1.3.0 # via paramiko pyopenssl==19.1.0 # via azure-cli-core, requests-credssp pyparsing==2.4.5 # via packaging -python-dateutil==2.8.1 # via adal, azure-storage, botocore +python-dateutil==2.8.1 # via adal, azure-storage, botocore, kubernetes +python-string-utils==0.6.0; python_version < "3" # via openshift pyvmomi==6.7.3 # via -r /awx_devel/requirements/requirements_ansible.in pywinrm[kerberos]==0.3.0 # via -r /awx_devel/requirements/requirements_ansible.in -pyyaml==5.2 # via azure-cli-core, knack, openstacksdk +pyyaml==5.2 # via azure-cli-core, knack, kubernetes, openstacksdk requests-credssp==1.0.2 # via -r /awx_devel/requirements/requirements_ansible.in requests-kerberos==0.12.0 # via pywinrm requests-ntlm==1.1.0 # via pywinrm -requests-oauthlib==1.3.0 # via msrest -requests==2.22.0 # via -r /awx_devel/requirements/requirements_ansible.in, adal, apache-libcloud, azure-cli-core, azure-keyvault, azure-storage, keystoneauth1, msrest, pyvmomi, pywinrm, requests-credssp, requests-kerberos, requests-ntlm, requests-oauthlib +requests-oauthlib==1.3.0 # via kubernetes, msrest +requests==2.22.0 # via -r /awx_devel/requirements/requirements_ansible.in, adal, apache-libcloud, azure-cli-core, azure-keyvault, azure-storage, keystoneauth1, kubernetes, msrest, pyvmomi, pywinrm, requests-credssp, requests-kerberos, requests-ntlm, requests-oauthlib requestsexceptions==1.4.0 # via openstacksdk rsa==4.0 # via google-auth +ruamel.ordereddict==0.4.14; python_version < "3" # via ruamel.yaml +ruamel.yaml.clib==0.2.0 # via ruamel.yaml +ruamel.yaml==0.16.10 # via openshift s3transfer==0.2.1 # via boto3 selectors2==2.0.1 # via ncclient -six==1.13.0 # via azure-cli-core, bcrypt, cryptography, google-auth, isodate, keystoneauth1, knack, munch, ncclient, openstacksdk, ovirt-engine-sdk-python, packaging, pynacl, pyopenssl, python-dateutil, pyvmomi, pywinrm, requests-credssp, stevedore +six==1.13.0 # via azure-cli-core, bcrypt, cryptography, google-auth, isodate, keystoneauth1, knack, kubernetes, munch, ncclient, openshift, openstacksdk, ovirt-engine-sdk-python, packaging, pynacl, pyopenssl, python-dateutil, pyvmomi, pywinrm, requests-credssp, stevedore, websocket-client stevedore==1.31.0 # via keystoneauth1 tabulate==0.8.2 # via azure-cli-core, knack typing==3.7.4.1; python_version < "3" # via msrest -urllib3==1.25.7 # via botocore, requests +urllib3==1.25.7 # via botocore, kubernetes, requests +websocket-client==0.57.0 # via kubernetes wheel==0.33.6 # via azure-cli-core (overriden, see upgrade blockers) xmltodict==0.12.0 # via pywinrm # The following packages are considered to be unsafe in a requirements file: pip==19.3.1 # via -r /awx_devel/requirements/requirements_ansible.in, azure-cli-core -setuptools==41.6.0 # via -r /awx_devel/requirements/requirements_ansible.in, ncclient +setuptools==41.6.0 # via -r /awx_devel/requirements/requirements_ansible.in, kubernetes, ncclient diff --git a/requirements/updater.sh b/requirements/updater.sh index e5eebbc066..7915ef286c 100755 --- a/requirements/updater.sh +++ b/requirements/updater.sh @@ -88,4 +88,4 @@ main() { } # set EVAL=1 in case you want to source this script -test "${EVAL:-0}" = "1" || main "${1:-}" +test "${EVAL:-0}" -eq "1" || main "${1:-}" @@ -19,12 +19,3 @@ exclude=.tox,venv,awx/lib/site-packages,awx/plugins/inventory/ec2.py,awx/plugins max-line-length=160 ignore=E201,E203,E221,E225,E231,E241,E251,E261,E265,E303,W291,W391,W293,E731,W504 exclude=.tox,venv,awx/lib/site-packages,awx/plugins/inventory,awx/ui,awx/api/urls.py,awx/main/migrations,awx/main/tests/data,node_modules/,awx/projects/,tools/docker,awx/settings/local_*.py,installer/openshift/settings.py,build/,installer/,awxkit/test,awx_collection/ - -[testenv:linters] -deps = - make - flake8 - yamllint -commands = - make flake8 - yamllint -s . diff --git a/tools/docker-isolated/Dockerfile b/tools/docker-isolated/Dockerfile index 16638f2b6f..3080117e5b 100644 --- a/tools/docker-isolated/Dockerfile +++ b/tools/docker-isolated/Dockerfile @@ -1,9 +1,9 @@ ARG TAG=latest FROM ansible/awx_devel:${TAG} -RUN yum install -y gcc python36-devel +RUN dnf install -y gcc python36-devel openssh-server RUN python3 -m ensurepip && pip3 install "virtualenv < 20" ansible-runner -RUN yum remove -y gcc python36-devel && rm -rf /var/cache/yum +RUN dnf remove -y gcc python36-devel && rm -rf /var/cache/dnf RUN rm -f /etc/ssh/ssh_host_ecdsa_key /etc/ssh/ssh_host_rsa_key RUN ssh-keygen -q -N "" -t dsa -f /etc/ssh/ssh_host_ecdsa_key diff --git a/tools/scripts/firehose.py b/tools/scripts/firehose.py index 44afd52cfb..f7c7e48551 100755 --- a/tools/scripts/firehose.py +++ b/tools/scripts/firehose.py @@ -26,9 +26,11 @@ import argparse import datetime +import itertools import json import multiprocessing import pkg_resources +import random import subprocess import sys from io import StringIO @@ -58,40 +60,52 @@ u = str(uuid4()) STATUS_OPTIONS = ('successful', 'failed', 'error', 'canceled') +EVENT_OPTIONS = ('runner_on_ok', 'runner_on_failed', 'runner_on_changed', 'runner_on_skipped', 'runner_on_unreachable') + +MODULE_OPTIONS = ('yup', 'stonchronize', 'templotz', 'deboog') + class YieldedRows(StringIO): def __init__(self, job_id, rows, created_stamp, modified_stamp, *args, **kwargs): self.rows = rows - self.row = "\t".join([ - f"{created_stamp}", - f"{modified_stamp}", - "playbook_on_start", - "{}", - 'false', - 'false', - "localhost", - "Example Play", - "Hello World", - "", - "0", - "1", - job_id, - u, - "", - "1", - "hello_world.yml", - "0", - "X", - "1", - ]) + '\n' + self.rowlist = [] + for (event, module) in itertools.product(EVENT_OPTIONS, MODULE_OPTIONS): + event_data_json = { + "task_action": module, + "name": "Do a {} thing".format(module), + "task": "Do a {} thing".format(module) + } + row = "\t".join([ + f"{created_stamp}", + f"{modified_stamp}", + event, + json.dumps(event_data_json), + str(event in ('runner_on_failed', 'runner_on_unreachable')), + str(event == 'runner_on_changed'), + "localhost", + "Example Play", + "Hello World", + "", + "0", + "1", + job_id, + u, + "", + "1", + "hello_world.yml", + "0", + "X", + "1", + ]) + '\n' + self.rowlist.append(row) def read(self, x): if self.rows <= 0: self.close() return '' - self.rows -= 10000 - return self.row * 10000 + self.rows -= 1000 + return self.rowlist[random.randrange(len(self.rowlist))] * 1000 def firehose(job, count, created_stamp, modified_stamp): diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000000..30a245409f --- /dev/null +++ b/tox.ini @@ -0,0 +1,9 @@ +[testenv:linters] +deps = + make + flake8 + yamllint +allowlist_externals = make +commands = + make flake8 + yamllint -s . |