diff --git a/.gitignore b/.gitignore index 54c57c36fe..b0d0e0bcfa 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,6 @@ use_dev_supervisor.txt /tools/docker-compose/overrides/ /awx/ui_next/.ui-built /Dockerfile +/_build/ +/_build_kube_dev/ +/Dockerfile.kube-dev diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f4300311..6237a9f54a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ 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/`. +# 17.0.1 (January 26, 2021) +- Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152 +- Fixed a bug in the UI which caused toggle settings to not be changed when clicked: https://github.com/ansible/awx/pull/9093 + +# 17.0.0 (January 22, 2021) +- AWX now requires PostgreSQL 12 by default: https://github.com/ansible/awx/pull/8943 + **Note:** users who encounter permissions errors at upgrade time should `chown -R ~/.awx/pgdocker` to ensure it's owned by the user running the install playbook +- Added support for region name for OpenStack inventory: https://github.com/ansible/awx/issues/5080 +- Added the ability to chain undefined attributes in custom notification templates: https://github.com/ansible/awx/issues/8677 +- Dramatically simplified the `image_build` role: https://github.com/ansible/awx/pull/8980 +- Fixed a bug which can cause schema migrations to fail at install time: https://github.com/ansible/awx/issues/9077 +- Fixed a bug which caused the `is_superuser` user property to be out of date in certain circumstances: https://github.com/ansible/awx/pull/8833 +- Fixed a bug which sometimes results in race conditions on setting access: https://github.com/ansible/awx/pull/8580 +- Fixed a bug which sometimes causes an unexpected delay in stdout for some playbooks: https://github.com/ansible/awx/issues/9085 +- (UI) Added support for credential password prompting on job launch: https://github.com/ansible/awx/pull/9028 +- (UI) Added the ability to configure LDAP settings in the UI: https://github.com/ansible/awx/issues/8291 +- (UI) Added a sync button to the Project detail view: https://github.com/ansible/awx/issues/8847 +- (UI) Added a form for configuring Google Outh 2.0 settings: https://github.com/ansible/awx/pull/8762 +- (UI) Added searchable keys and related keys to the Credentials list: https://github.com/ansible/awx/issues/8603 +- (UI) Added support for advanced search and copying to Notification Templates: https://github.com/ansible/awx/issues/7879 +- (UI) Added support for prompting on workflow nodes: https://github.com/ansible/awx/issues/5913 +- (UI) Added support for session timeouts: https://github.com/ansible/awx/pull/8250 +- (UI) Fixed a bug that broke websocket streaming for the insecure ws:// protocol: https://github.com/ansible/awx/pull/8877 +- (UI) Fixed a bug in the user interface when a translation for the browser's preferred locale isn't available: https://github.com/ansible/awx/issues/8884 +- (UI) Fixed bug where navigating from one survey question form directly to another wasn't reloading the form: https://github.com/ansible/awx/issues/7522 +- (UI) Fixed a bug which can cause an uncaught error while launching a Job Template: https://github.com/ansible/awx/issues/8936 +- Updated autobahn to address CVE-2020-35678 + ## 16.0.0 (December 10, 2020) - AWX now ships with a reimagined user interface. **Please read this before upgrading:** https://groups.google.com/g/awx-project/c/KuT5Ao92HWo - Removed support for syncing inventory from Red Hat CloudForms - https://github.com/ansible/awx/commit/0b701b3b2 diff --git a/INSTALL.md b/INSTALL.md index 1ed8484e29..f7ab93a6e3 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -497,7 +497,7 @@ Before starting the install process, review the [inventory](./installer/inventor *docker_compose_dir* -> When using docker-compose, the `docker-compose.yml` file will be created there (default `/tmp/awxcompose`). +> When using docker-compose, the `docker-compose.yml` file will be created there (default `~/.awx/awxcompose`). *custom_venv_dir* diff --git a/Makefile b/Makefile index 9c7ccd028b..9cf77e597e 100644 --- a/Makefile +++ b/Makefile @@ -267,11 +267,27 @@ collectstatic: fi; \ mkdir -p awx/public/static && $(PYTHON) manage.py collectstatic --clear --noinput > /dev/null 2>&1 +UWSGI_DEV_RELOAD_COMMAND ?= supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver + uwsgi: collectstatic @if [ "$(VENV_BASE)" ]; then \ . $(VENV_BASE)/awx/bin/activate; \ fi; \ - uwsgi -b 32768 --socket 127.0.0.1:8050 --module=awx.wsgi:application --home=/var/lib/awx/venv/awx --chdir=/awx_devel/ --vacuum --processes=5 --harakiri=120 --master --no-orphans --py-autoreload 1 --max-requests=1000 --stats /tmp/stats.socket --lazy-apps --logformat "%(addr) %(method) %(uri) - %(proto) %(status)" --hook-accepting1="exec:supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver" + uwsgi -b 32768 \ + --socket 127.0.0.1:8050 \ + --module=awx.wsgi:application \ + --home=/var/lib/awx/venv/awx \ + --chdir=/awx_devel/ \ + --vacuum \ + --processes=5 \ + --harakiri=120 --master \ + --no-orphans \ + --py-autoreload 1 \ + --max-requests=1000 \ + --stats /tmp/stats.socket \ + --lazy-apps \ + --logformat "%(addr) %(method) %(uri) - %(proto) %(status)" \ + --hook-accepting1="exec: $(UWSGI_DEV_RELOAD_COMMAND)" daphne: @if [ "$(VENV_BASE)" ]; then \ @@ -579,15 +595,18 @@ docker-compose-clean: awx/projects # Base development image build docker-compose-build: - ansible localhost -m template -a "src=installer/roles/image_build/templates/Dockerfile.j2 dest=tools/docker-compose/Dockerfile" -e build_dev=True - docker build -t ansible/awx_devel -f tools/docker-compose/Dockerfile \ - --cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) . + ansible-playbook installer/dockerfile.yml -e build_dev=True + docker build -t ansible/awx_devel \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + --cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) . docker tag ansible/awx_devel $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) #docker push $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) # For use when developing on "isolated" AWX deployments docker-compose-isolated-build: docker-compose-build - docker build -t ansible/awx_isolated -f tools/docker-isolated/Dockerfile . + docker build -t ansible/awx_isolated \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + -f tools/docker-isolated/Dockerfile . docker tag ansible/awx_isolated $(DEV_DOCKER_TAG_BASE)/awx_isolated:$(COMPOSE_TAG) #docker push $(DEV_DOCKER_TAG_BASE)/awx_isolated:$(COMPOSE_TAG) @@ -624,5 +643,16 @@ psql-container: VERSION: @echo "awx: $(VERSION)" -Dockerfile: installer/roles/image_build/templates/Dockerfile.j2 - ansible localhost -m template -a "src=installer/roles/image_build/templates/Dockerfile.j2 dest=Dockerfile" +Dockerfile: installer/roles/dockerfile/templates/Dockerfile.j2 + ansible-playbook installer/dockerfile.yml + +Dockerfile.kube-dev: installer/roles/dockerfile/templates/Dockerfile.j2 + ansible-playbook installer/dockerfile.yml \ + -e dockerfile_name=Dockerfile.kube-dev \ + -e kube_dev=True \ + -e template_dest=_build_kube_dev + +awx-kube-dev-build: Dockerfile.kube-dev + docker build -f Dockerfile.kube-dev \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + -t $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) . diff --git a/README.md b/README.md index e24e851ce1..145a690900 100644 --- a/README.md +++ b/README.md @@ -14,20 +14,20 @@ Contributing ------------ - Refer to the [Contributing guide](./CONTRIBUTING.md) to get started developing, testing, and building AWX. -- All code submissions are done through pull requests against the `devel` branch. -- All contributors must use git commit --signoff for any commit to be merged, and agree that usage of --signoff constitutes agreement with the terms of [DCO 1.1](./DCO_1_1.md) -- Take care to make sure no merge commits are in the submission, and use `git rebase` vs `git merge` for this reason. -- If submitting a large code change, it's a good idea to join the `#ansible-awx` channel on irc.freenode.net, and talk about what you would like to do or add first. This not only helps everyone know what's going on, it also helps save time and effort, if the community decides some changes are needed. +- All code submissions are made through pull requests against the `devel` branch. +- All contributors must use git commit --signoff for any commit to be merged and agree that usage of --signoff constitutes agreement with the terms of [DCO 1.1](./DCO_1_1.md) +- Take care to make sure no merge commits are in the submission, and use `git rebase` vs. `git merge` for this reason. +- If submitting a large code change, it's a good idea to join the `#ansible-awx` channel on irc.freenode.net and talk about what you would like to do or add first. This not only helps everyone know what's going on, but it also helps save time and effort if the community decides some changes are needed. Reporting Issues ---------------- -If you're experiencing a problem that you feel is a bug in AWX, or have ideas for how to improve AWX, we encourage you to open an issue, and share your feedback. But before opening a new issue, we ask that you please take a look at our [Issues guide](./ISSUES.md). +If you're experiencing a problem that you feel is a bug in AWX or have ideas for improving AWX, we encourage you to open an issue and share your feedback. But before opening a new issue, we ask that you please take a look at our [Issues guide](./ISSUES.md). Code of Conduct --------------- -We ask all of our community members and contributors to adhere to the [Ansible code of conduct](http://docs.ansible.com/ansible/latest/community/code_of_conduct.html). If you have questions, or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com) +We ask all of our community members and contributors to adhere to the [Ansible code of conduct](http://docs.ansible.com/ansible/latest/community/code_of_conduct.html). If you have questions or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com) Get Involved ------------ @@ -41,4 +41,3 @@ License ------- [Apache v2](./LICENSE.md) - diff --git a/VERSION b/VERSION index 946789e619..3e17df0287 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -16.0.0 +17.0.1 diff --git a/awx/api/views/organization.py b/awx/api/views/organization.py index 06172af79f..d03dfcc86f 100644 --- a/awx/api/views/organization.py +++ b/awx/api/views/organization.py @@ -13,6 +13,7 @@ from django.utils.translation import ugettext_lazy as _ from awx.main.models import ( ActivityStream, Inventory, + Host, Project, JobTemplate, WorkflowJobTemplate, @@ -98,6 +99,7 @@ class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPI organization__id=org_id).count() org_counts['job_templates'] = JobTemplate.accessible_objects(**access_kwargs).filter( organization__id=org_id).count() + org_counts['hosts'] = Host.objects.org_active_count(org_id) full_context['related_field_counts'] = {} full_context['related_field_counts'][org_id] = org_counts diff --git a/awx/main/dispatch/worker/callback.py b/awx/main/dispatch/worker/callback.py index fd96a4f04e..e61a516094 100644 --- a/awx/main/dispatch/worker/callback.py +++ b/awx/main/dispatch/worker/callback.py @@ -38,6 +38,7 @@ class CallbackBrokerWorker(BaseWorker): MAX_RETRIES = 2 last_stats = time.time() + last_flush = time.time() total = 0 last_event = '' prof = None @@ -52,7 +53,7 @@ class CallbackBrokerWorker(BaseWorker): def read(self, queue): try: - res = self.redis.blpop(settings.CALLBACK_QUEUE, timeout=settings.JOB_EVENT_BUFFER_SECONDS) + res = self.redis.blpop(settings.CALLBACK_QUEUE, timeout=1) if res is None: return {'event': 'FLUSH'} self.total += 1 @@ -102,6 +103,7 @@ class CallbackBrokerWorker(BaseWorker): now = tz_now() if ( force or + (time.time() - self.last_flush) > settings.JOB_EVENT_BUFFER_SECONDS or any([len(events) >= 1000 for events in self.buff.values()]) ): for cls, events in self.buff.items(): @@ -124,6 +126,7 @@ class CallbackBrokerWorker(BaseWorker): for e in events: emit_event_detail(e) self.buff = {} + self.last_flush = time.time() def perform_work(self, body): try: diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 509378fcf5..87fa5d791f 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -81,10 +81,17 @@ User.add_to_class('accessible_objects', user_accessible_objects) def enforce_bigint_pk_migration(): + # + # NOTE: this function is not actually in use anymore, + # but has been intentionally kept for historical purposes, + # and to serve as an illustration if we ever need to perform + # bulk modification/migration of event data in the future. + # # see: https://github.com/ansible/awx/issues/6010 # look at all the event tables and verify that they have been fully migrated # from the *old* int primary key table to the replacement bigint table # if not, attempt to migrate them in the background + # for tblname in ( 'main_jobevent', 'main_inventoryupdateevent', 'main_projectupdateevent', 'main_adhoccommandevent', diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index 33562e7fca..74a9896385 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -357,7 +357,7 @@ class JobNotificationMixin(object): 'url': 'https://towerhost/#/jobs/playbook/1010', 'approval_status': 'approved', 'approval_node_name': 'Approve Me', - 'workflow_url': 'https://towerhost/#/workflows/1010', + 'workflow_url': 'https://towerhost/#/jobs/workflow/1010', 'job_metadata': """{'url': 'https://towerhost/$/jobs/playbook/13', 'traceback': '', 'status': 'running', diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index dd8bc3e894..d9ac8afcf9 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -620,7 +620,7 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio return reverse('api:workflow_job_detail', kwargs={'pk': self.pk}, request=request) def get_ui_url(self): - return urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.pk)) + return urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.pk)) def notification_data(self): result = super(WorkflowJob, self).notification_data() @@ -752,7 +752,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin): return None def get_ui_url(self): - return urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id)) + return urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.workflow_job.id)) def _get_parent_field_name(self): return 'workflow_approval_template' @@ -840,7 +840,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin): return (msg, body) def context(self, approval_status): - workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id)) + workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.workflow_job.id)) return {'approval_status': approval_status, 'approval_node_name': self.workflow_approval_template.name, 'workflow_url': workflow_url, diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 1fb7d62cef..e6cc61b7dd 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -60,7 +60,7 @@ from awx.main.models import ( Inventory, InventorySource, SmartInventoryMembership, Job, AdHocCommand, ProjectUpdate, InventoryUpdate, SystemJob, JobEvent, ProjectUpdateEvent, InventoryUpdateEvent, AdHocCommandEvent, SystemJobEvent, - build_safe_env, enforce_bigint_pk_migration + build_safe_env ) from awx.main.constants import ACTIVE_STATES from awx.main.exceptions import AwxTaskError, PostRunError @@ -138,12 +138,6 @@ def dispatch_startup(): if Instance.objects.me().is_controller(): awx_isolated_heartbeat() - # at process startup, detect the need to migrate old event records from int - # to bigint; at *some point* in the future, once certain versions of AWX - # and Tower fall out of use/support, we can probably just _assume_ that - # everybody has moved to bigint, and remove this code entirely - enforce_bigint_pk_migration() - # Update Tower's rsyslog.conf file based on loggins settings in the db reconfigure_rsyslog() @@ -738,6 +732,12 @@ def update_host_smart_inventory_memberships(): @task(queue=get_local_queuename) def migrate_legacy_event_data(tblname): + # + # NOTE: this function is not actually in use anymore, + # but has been intentionally kept for historical purposes, + # and to serve as an illustration if we ever need to perform + # bulk modification/migration of event data in the future. + # if 'event' not in tblname: return with advisory_lock(f'bigint_migration_{tblname}', wait=False) as acquired: diff --git a/awx/main/tests/functional/api/test_organization_counts.py b/awx/main/tests/functional/api/test_organization_counts.py index f5221ef5f1..d45b1fa083 100644 --- a/awx/main/tests/functional/api/test_organization_counts.py +++ b/awx/main/tests/functional/api/test_organization_counts.py @@ -2,7 +2,7 @@ import pytest from awx.api.versioning import reverse -from awx.main.models import Project +from awx.main.models import Project, Host @pytest.fixture @@ -81,6 +81,8 @@ def test_org_counts_detail_admin(resourced_organization, user, get): assert response.status_code == 200 counts = response.data['summary_fields']['related_field_counts'] + assert counts['hosts'] == 0 + counts.pop('hosts') assert counts == COUNTS_PRIMES @@ -93,6 +95,8 @@ def test_org_counts_detail_member(resourced_organization, user, get): assert response.status_code == 200 counts = response.data['summary_fields']['related_field_counts'] + assert counts['hosts'] == 0 + counts.pop('hosts') assert counts == { 'users': COUNTS_PRIMES['users'], # Policy is that members can see other users and admins 'admins': COUNTS_PRIMES['admins'], @@ -111,6 +115,7 @@ def test_org_counts_list_admin(resourced_organization, user, get): assert response.status_code == 200 counts = response.data['results'][0]['summary_fields']['related_field_counts'] + assert 'hosts' not in counts # doesn't show in list view assert counts == COUNTS_PRIMES @@ -123,6 +128,7 @@ def test_org_counts_list_member(resourced_organization, user, get): assert response.status_code == 200 counts = response.data['results'][0]['summary_fields']['related_field_counts'] + assert 'hosts' not in counts # doesn't show in list view assert counts == { 'users': COUNTS_PRIMES['users'], # Policy is that members can see other users and admins @@ -145,6 +151,7 @@ def test_new_org_zero_counts(user, post): new_org_list = post_response.render().data counts_dict = new_org_list['summary_fields']['related_field_counts'] + assert 'hosts' not in counts_dict # doesn't show in list view assert counts_dict == COUNTS_ZEROS @@ -167,6 +174,19 @@ def test_two_organizations(resourced_organization, organizations, user, get): assert counts[org_id_zero] == COUNTS_ZEROS +@pytest.mark.django_db +def test_hosts_counted(resourced_organization, user, get): + admin_user = user('admin', True) + assert Host.objects.org_active_count(resourced_organization.id) == 0 + resourced_organization.inventories.first().hosts.create(name='Some Host') + assert Host.objects.org_active_count(resourced_organization.id) == 1 + response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user) + assert response.status_code == 200 + + counts = response.data['summary_fields']['related_field_counts'] + assert counts['hosts'] == Host.objects.org_active_count(resourced_organization.id) == 1 + + @pytest.mark.django_db def test_scan_JT_counted(resourced_organization, user, get): admin_user = user('admin', True) @@ -180,7 +200,10 @@ def test_scan_JT_counted(resourced_organization, user, get): # Test detail view detail_response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user) assert detail_response.status_code == 200 - assert detail_response.data['summary_fields']['related_field_counts'] == counts_dict + counts = detail_response.data['summary_fields']['related_field_counts'] + assert 'hosts' in counts + counts.pop('hosts') + assert counts == counts_dict @pytest.mark.django_db @@ -205,4 +228,7 @@ def test_JT_not_double_counted(resourced_organization, user, get): # Test detail view detail_response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user) assert detail_response.status_code == 200 - assert detail_response.data['summary_fields']['related_field_counts'] == counts_dict + counts = detail_response.data['summary_fields']['related_field_counts'] + assert 'hosts' in counts + counts.pop('hosts') + assert counts == counts_dict diff --git a/awx/main/tests/unit/api/test_logger.py b/awx/main/tests/unit/api/test_logger.py index 95ee8f9a98..dc4c32f229 100644 --- a/awx/main/tests/unit/api/test_logger.py +++ b/awx/main/tests/unit/api/test_logger.py @@ -35,16 +35,17 @@ data_loggly = { # Test reconfigure logging settings function # name this whatever you want @pytest.mark.parametrize( - 'enabled, log_type, host, port, protocol, expected_config', [ + 'enabled, log_type, host, port, protocol, errorfile, expected_config', [ ( True, 'loggly', 'http://logs-01.loggly.com/inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/', None, 'https', + '/var/log/tower/rsyslog.err', '\n'.join([ 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', - 'action(type="omhttp" server="logs-01.loggly.com" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/")', # noqa + 'action(type="omhttp" server="logs-01.loggly.com" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/")', # noqa ]) ), ( @@ -53,6 +54,7 @@ data_loggly = { 'localhost', 9000, 'udp', + '', # empty errorfile '\n'.join([ 'template(name="awx" type="string" string="%rawmsg-after-pri%")', 'action(type="omfwd" target="localhost" port="9000" protocol="udp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa @@ -64,6 +66,7 @@ data_loggly = { 'localhost', 9000, 'tcp', + '/var/log/tower/rsyslog.err', '\n'.join([ 'template(name="awx" type="string" string="%rawmsg-after-pri%")', 'action(type="omfwd" target="localhost" port="9000" protocol="tcp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa @@ -75,9 +78,10 @@ data_loggly = { 'https://yoursplunk/services/collector/event', None, None, + '/var/log/tower/rsyslog.err', '\n'.join([ 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', - 'action(type="omhttp" server="yoursplunk" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa + 'action(type="omhttp" server="yoursplunk" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa ]) ), ( @@ -86,9 +90,10 @@ data_loggly = { 'http://yoursplunk/services/collector/event', None, None, + '/var/log/tower/rsyslog.err', '\n'.join([ 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', - 'action(type="omhttp" server="yoursplunk" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa + 'action(type="omhttp" server="yoursplunk" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa ]) ), ( @@ -97,9 +102,10 @@ data_loggly = { 'https://yoursplunk:8088/services/collector/event', None, None, + '/var/log/tower/rsyslog.err', '\n'.join([ 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', - 'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa + 'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa ]) ), ( @@ -108,9 +114,10 @@ data_loggly = { 'https://yoursplunk/services/collector/event', 8088, None, + '/var/log/tower/rsyslog.err', '\n'.join([ 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', - 'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa + 'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa ]) ), ( @@ -119,9 +126,10 @@ data_loggly = { 'yoursplunk.org/services/collector/event', 8088, 'https', + '/var/log/tower/rsyslog.err', '\n'.join([ 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', - 'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa + 'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa ]) ), ( @@ -130,9 +138,10 @@ data_loggly = { 'http://yoursplunk.org/services/collector/event', 8088, None, + '/var/log/tower/rsyslog.err', '\n'.join([ 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', - 'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa + 'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa ]) ), ( @@ -141,14 +150,15 @@ data_loggly = { 'https://endpoint5.collection.us2.sumologic.com/receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==', # noqa None, 'https', + '/var/log/tower/rsyslog.err', '\n'.join([ 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', - 'action(type="omhttp" server="endpoint5.collection.us2.sumologic.com" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==")', # noqa + 'action(type="omhttp" server="endpoint5.collection.us2.sumologic.com" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==")', # noqa ]) ), ] ) -def test_rsyslog_conf_template(enabled, log_type, host, port, protocol, expected_config): +def test_rsyslog_conf_template(enabled, log_type, host, port, protocol, errorfile, expected_config): mock_settings, _ = _mock_logging_defaults() @@ -159,6 +169,7 @@ def test_rsyslog_conf_template(enabled, log_type, host, port, protocol, expected setattr(mock_settings, 'LOG_AGGREGATOR_ENABLED', enabled) setattr(mock_settings, 'LOG_AGGREGATOR_TYPE', log_type) setattr(mock_settings, 'LOG_AGGREGATOR_HOST', host) + setattr(mock_settings, 'LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE', errorfile) if port: setattr(mock_settings, 'LOG_AGGREGATOR_PORT', port) if protocol: diff --git a/awx/main/utils/external_logging.py b/awx/main/utils/external_logging.py index f551dad2c2..403105edf4 100644 --- a/awx/main/utils/external_logging.py +++ b/awx/main/utils/external_logging.py @@ -18,6 +18,7 @@ def construct_rsyslog_conf_template(settings=settings): timeout = getattr(settings, 'LOG_AGGREGATOR_TCP_TIMEOUT', 5) max_disk_space = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_GB', 1) spool_directory = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_PATH', '/var/lib/awx').rstrip('/') + error_log_file = getattr(settings, 'LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE', '') if not os.access(spool_directory, os.W_OK): spool_directory = '/var/lib/awx' @@ -74,9 +75,10 @@ def construct_rsyslog_conf_template(settings=settings): f'skipverifyhost="{skip_verify}"', 'action.resumeRetryCount="-1"', 'template="awx"', - 'errorfile="/var/log/tower/rsyslog.err"', f'action.resumeInterval="{timeout}"' ] + if error_log_file: + params.append(f'errorfile="{error_log_file}"') if parsed.path: path = urlparse.quote(parsed.path[1:], safe='/=') if parsed.query: diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 05c8a42f20..da79b0fc4c 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -196,9 +196,9 @@ LOCAL_STDOUT_EXPIRE_TIME = 2592000 # events into the database JOB_EVENT_WORKERS = 4 -# The number of seconds (must be an integer) to buffer callback receiver bulk +# The number of seconds to buffer callback receiver bulk # writes in memory before flushing via JobEvent.objects.bulk_create() -JOB_EVENT_BUFFER_SECONDS = 1 +JOB_EVENT_BUFFER_SECONDS = .1 # The interval at which callback receiver statistics should be # recorded @@ -770,6 +770,7 @@ LOG_AGGREGATOR_LEVEL = 'INFO' LOG_AGGREGATOR_MAX_DISK_USAGE_GB = 1 LOG_AGGREGATOR_MAX_DISK_USAGE_PATH = '/var/lib/awx' LOG_AGGREGATOR_RSYSLOGD_DEBUG = False +LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE = '/var/log/tower/rsyslog.err' # The number of retry attempts for websocket session establishment # If you're encountering issues establishing websockets in clustered Tower, @@ -853,38 +854,30 @@ LOGGING = { }, 'tower_warnings': { # don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL - 'class': 'logging.handlers.RotatingFileHandler', + 'class': 'logging.handlers.WatchedFileHandler', 'filters': ['require_debug_false', 'dynamic_level_filter'], 'filename': os.path.join(LOG_ROOT, 'tower.log'), - 'maxBytes': 1024 * 1024 * 5, # 5 MB - 'backupCount': 5, 'formatter':'simple', }, 'callback_receiver': { # don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL - 'class': 'logging.handlers.RotatingFileHandler', + 'class': 'logging.handlers.WatchedFileHandler', 'filters': ['require_debug_false', 'dynamic_level_filter'], 'filename': os.path.join(LOG_ROOT, 'callback_receiver.log'), - 'maxBytes': 1024 * 1024 * 5, # 5 MB - 'backupCount': 5, 'formatter':'simple', }, 'dispatcher': { # don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL - 'class': 'logging.handlers.RotatingFileHandler', + 'class': 'logging.handlers.WatchedFileHandler', 'filters': ['require_debug_false', 'dynamic_level_filter'], 'filename': os.path.join(LOG_ROOT, 'dispatcher.log'), - 'maxBytes': 1024 * 1024 * 5, # 5 MB - 'backupCount': 5, 'formatter':'dispatcher', }, 'wsbroadcast': { # don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL - 'class': 'logging.handlers.RotatingFileHandler', + 'class': 'logging.handlers.WatchedFileHandler', 'filters': ['require_debug_false', 'dynamic_level_filter'], 'filename': os.path.join(LOG_ROOT, 'wsbroadcast.log'), - 'maxBytes': 1024 * 1024 * 5, # 5 MB - 'backupCount': 5, 'formatter':'simple', }, 'celery.beat': { @@ -898,46 +891,36 @@ LOGGING = { }, 'task_system': { # don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL - 'class': 'logging.handlers.RotatingFileHandler', + 'class': 'logging.handlers.WatchedFileHandler', 'filters': ['require_debug_false', 'dynamic_level_filter'], 'filename': os.path.join(LOG_ROOT, 'task_system.log'), - 'maxBytes': 1024 * 1024 * 5, # 5 MB - 'backupCount': 5, 'formatter':'simple', }, 'management_playbooks': { 'level': 'DEBUG', - 'class':'logging.handlers.RotatingFileHandler', + 'class':'logging.handlers.WatchedFileHandler', 'filters': ['require_debug_false'], 'filename': os.path.join(LOG_ROOT, 'management_playbooks.log'), - 'maxBytes': 1024 * 1024 * 5, # 5 MB - 'backupCount': 5, 'formatter':'simple', }, 'system_tracking_migrations': { 'level': 'WARNING', - 'class':'logging.handlers.RotatingFileHandler', + 'class':'logging.handlers.WatchedFileHandler', 'filters': ['require_debug_false'], 'filename': os.path.join(LOG_ROOT, 'tower_system_tracking_migrations.log'), - 'maxBytes': 1024 * 1024 * 5, # 5 MB - 'backupCount': 5, 'formatter':'simple', }, 'rbac_migrations': { 'level': 'WARNING', - 'class':'logging.handlers.RotatingFileHandler', + 'class':'logging.handlers.WatchedFileHandler', 'filters': ['require_debug_false'], 'filename': os.path.join(LOG_ROOT, 'tower_rbac_migrations.log'), - 'maxBytes': 1024 * 1024 * 5, # 5 MB - 'backupCount': 5, 'formatter':'simple', }, 'isolated_manager': { 'level': 'WARNING', - 'class':'logging.handlers.RotatingFileHandler', + 'class':'logging.handlers.WatchedFileHandler', 'filename': os.path.join(LOG_ROOT, 'isolated_manager.log'), - 'maxBytes': 1024 * 1024 * 5, # 5 MB - 'backupCount': 5, 'formatter':'simple', }, }, diff --git a/awx/settings/development.py b/awx/settings/development.py index 9846705fa5..6181d16ec6 100644 --- a/awx/settings/development.py +++ b/awx/settings/development.py @@ -158,7 +158,10 @@ AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx") # default settings for development. If not present, we can still run using # only the defaults. try: - include(optional('local_*.py'), scope=locals()) + if os.getenv('AWX_KUBE_DEVEL', False): + include(optional('minikube.py'), scope=locals()) + else: + include(optional('local_*.py'), scope=locals()) except ImportError: traceback.print_exc() sys.exit(1) diff --git a/awx/settings/minikube.py b/awx/settings/minikube.py new file mode 100644 index 0000000000..0ac81875bc --- /dev/null +++ b/awx/settings/minikube.py @@ -0,0 +1,4 @@ +BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖' +BROADCAST_WEBSOCKET_PORT = 8013 +BROADCAST_WEBSOCKET_VERIFY_CERT = False +BROADCAST_WEBSOCKET_PROTOCOL = 'http' diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index f5f0c05330..cddf01e259 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -1,3 +1,4 @@ +import ActivityStream from './models/ActivityStream'; import AdHocCommands from './models/AdHocCommands'; import Applications from './models/Applications'; import Auth from './models/Auth'; @@ -39,6 +40,7 @@ import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes'; import WorkflowJobTemplates from './models/WorkflowJobTemplates'; import WorkflowJobs from './models/WorkflowJobs'; +const ActivityStreamAPI = new ActivityStream(); const AdHocCommandsAPI = new AdHocCommands(); const ApplicationsAPI = new Applications(); const AuthAPI = new Auth(); @@ -81,6 +83,7 @@ const WorkflowJobTemplatesAPI = new WorkflowJobTemplates(); const WorkflowJobsAPI = new WorkflowJobs(); export { + ActivityStreamAPI, AdHocCommandsAPI, ApplicationsAPI, AuthAPI, diff --git a/awx/ui_next/src/api/models/ActivityStream.js b/awx/ui_next/src/api/models/ActivityStream.js new file mode 100644 index 0000000000..99b65bc634 --- /dev/null +++ b/awx/ui_next/src/api/models/ActivityStream.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class ActivityStream extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/activity_stream/'; + } +} + +export default ActivityStream; diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 2f12953afa..e339142b52 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.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'; @@ -17,95 +17,57 @@ const readTeams = async queryParams => TeamsAPI.read(queryParams); const readTeamsOptions = async () => TeamsAPI.readOptions(); -class AddResourceRole extends React.Component { - constructor(props) { - super(props); - - this.state = { - selectedResource: null, - selectedResourceRows: [], - selectedRoleRows: [], - currentStepId: 1, - maxEnabledStep: 1, - }; - - this.handleResourceCheckboxClick = this.handleResourceCheckboxClick.bind( - this - ); - this.handleResourceSelect = this.handleResourceSelect.bind(this); - this.handleRoleCheckboxClick = this.handleRoleCheckboxClick.bind(this); - this.handleWizardNext = this.handleWizardNext.bind(this); - this.handleWizardSave = this.handleWizardSave.bind(this); - this.handleWizardGoToStep = this.handleWizardGoToStep.bind(this); - } - - handleResourceCheckboxClick(user) { - const { selectedResourceRows, currentStepId } = this.state; +function AddResourceRole({ onSave, onClose, roles, i18n, resource }) { + const [selectedResource, setSelectedResource] = useState(null); + const [selectedResourceRows, setSelectedResourceRows] = useState([]); + const [selectedRoleRows, setSelectedRoleRows] = useState([]); + const [currentStepId, setCurrentStepId] = useState(1); + const [maxEnabledStep, setMaxEnabledStep] = useState(1); + const handleResourceCheckboxClick = user => { const selectedIndex = selectedResourceRows.findIndex( selectedRow => selectedRow.id === user.id ); - if (selectedIndex > -1) { selectedResourceRows.splice(selectedIndex, 1); - const stateToUpdate = { selectedResourceRows }; if (selectedResourceRows.length === 0) { - stateToUpdate.maxEnabledStep = currentStepId; + setMaxEnabledStep(currentStepId); } - this.setState(stateToUpdate); + setSelectedRoleRows(selectedResourceRows); } else { - this.setState(prevState => ({ - selectedResourceRows: [...prevState.selectedResourceRows, user], - })); + setSelectedResourceRows([...selectedResourceRows, user]); } - } - - handleRoleCheckboxClick(role) { - const { selectedRoleRows } = this.state; + }; + const handleRoleCheckboxClick = role => { const selectedIndex = selectedRoleRows.findIndex( selectedRow => selectedRow.id === role.id ); if (selectedIndex > -1) { selectedRoleRows.splice(selectedIndex, 1); - this.setState({ selectedRoleRows }); + setSelectedRoleRows(selectedRoleRows); } else { - this.setState(prevState => ({ - selectedRoleRows: [...prevState.selectedRoleRows, role], - })); + setSelectedRoleRows([...selectedRoleRows, role]); } - } + }; - handleResourceSelect(resourceType) { - this.setState({ - selectedResource: resourceType, - selectedResourceRows: [], - selectedRoleRows: [], - }); - } + const handleResourceSelect = resourceType => { + setSelectedResource(resourceType); + setSelectedResourceRows([]); + setSelectedRoleRows([]); + }; - handleWizardNext(step) { - this.setState({ - currentStepId: step.id, - maxEnabledStep: step.id, - }); - } + const handleWizardNext = step => { + setCurrentStepId(step.id); + setMaxEnabledStep(step.id); + }; - handleWizardGoToStep(step) { - this.setState({ - currentStepId: step.id, - }); - } - - async handleWizardSave() { - const { onSave } = this.props; - const { - selectedResourceRows, - selectedRoleRows, - selectedResource, - } = this.state; + const handleWizardGoToStep = step => { + setCurrentStepId(step.id); + }; + const handleWizardSave = async () => { try { const roleRequests = []; @@ -134,201 +96,186 @@ class AddResourceRole extends React.Component { } catch (err) { // TODO: handle this error } + }; + + // Object roles can be user only, so we remove them when + // showing role choices for team access + const selectableRoles = { ...roles }; + if (selectedResource === 'teams') { + Object.keys(roles).forEach(key => { + if (selectableRoles[key].user_only) { + delete selectableRoles[key]; + } + }); } - render() { - const { - selectedResource, - selectedResourceRows, - selectedRoleRows, - currentStepId, - maxEnabledStep, - } = this.state; - const { onClose, roles, i18n, resource } = this.props; + const userSearchColumns = [ + { + 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', + }, + ]; + const userSortColumns = [ + { + name: i18n._(t`Username`), + key: 'username', + }, + { + name: i18n._(t`First Name`), + key: 'first_name', + }, + { + name: i18n._(t`Last Name`), + key: 'last_name', + }, + ]; + const teamSearchColumns = [ + { + 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', + }, + ]; - // Object roles can be user only, so we remove them when - // showing role choices for team access - const selectableRoles = { ...roles }; - if (selectedResource === 'teams') { - Object.keys(roles).forEach(key => { - if (selectableRoles[key].user_only) { - delete selectableRoles[key]; - } - }); - } + const teamSortColumns = [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ]; - const userSearchColumns = [ - { - 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', - }, - ]; + let wizardTitle = ''; - const userSortColumns = [ - { - name: i18n._(t`Username`), - key: 'username', - }, - { - name: i18n._(t`First Name`), - key: 'first_name', - }, - { - name: i18n._(t`Last Name`), - key: 'last_name', - }, - ]; + switch (selectedResource) { + case 'users': + wizardTitle = i18n._(t`Add User Roles`); + break; + case 'teams': + wizardTitle = i18n._(t`Add Team Roles`); + break; + default: + wizardTitle = i18n._(t`Add Roles`); + } - const teamSearchColumns = [ - { - 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', - }, - ]; - - const teamSortColumns = [ - { - name: i18n._(t`Name`), - key: 'name', - }, - ]; - - let wizardTitle = ''; - - switch (selectedResource) { - case 'users': - wizardTitle = i18n._(t`Add User Roles`); - break; - case 'teams': - wizardTitle = i18n._(t`Add Team Roles`); - break; - default: - wizardTitle = i18n._(t`Add Roles`); - } - - const steps = [ - { - id: 1, - name: i18n._(t`Select a Resource Type`), - component: ( -
-
- {i18n._( - t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.` - )} -
- - this.handleResourceSelect('users')} - /> - {resource?.type === 'credential' && - !resource?.organization ? null : ( - this.handleResourceSelect('teams')} - /> + const steps = [ + { + id: 1, + name: i18n._(t`Select a Resource Type`), + component: ( +
+
+ {i18n._( + t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.` )}
- ), - enableNext: selectedResource !== null, - }, - { - id: 2, - name: i18n._(t`Select Items from List`), - component: ( - - {selectedResource === 'users' && ( - - )} - {selectedResource === 'teams' && ( - - )} - - ), - enableNext: selectedResourceRows.length > 0, - canJumpTo: maxEnabledStep >= 2, - }, - { - id: 3, - name: i18n._(t`Select Roles to Apply`), - component: ( - handleResourceSelect('users')} /> - ), - nextButtonText: i18n._(t`Save`), - enableNext: selectedRoleRows.length > 0, - canJumpTo: maxEnabledStep >= 3, - }, - ]; + {resource?.type === 'credential' && !resource?.organization ? null : ( + handleResourceSelect('teams')} + /> + )} +
+ ), + enableNext: selectedResource !== null, + }, + { + id: 2, + name: i18n._(t`Select Items from List`), + component: ( + + {selectedResource === 'users' && ( + + )} + {selectedResource === 'teams' && ( + + )} + + ), + enableNext: selectedResourceRows.length > 0, + canJumpTo: maxEnabledStep >= 2, + }, + { + id: 3, + name: i18n._(t`Select Roles to Apply`), + component: ( + + ), + nextButtonText: i18n._(t`Save`), + enableNext: selectedRoleRows.length > 0, + canJumpTo: maxEnabledStep >= 3, + }, + ]; - const currentStep = steps.find(step => step.id === currentStepId); + const currentStep = steps.find(step => step.id === currentStepId); - // TODO: somehow internationalize steps and currentStep.nextButtonText - return ( - - ); - } + // TODO: somehow internationalize steps and currentStep.nextButtonText + return ( + handleWizardGoToStep(step)} + steps={steps} + title={wizardTitle} + nextButtonText={currentStep.nextButtonText || undefined} + backButtonText={i18n._(t`Back`)} + cancelButtonText={i18n._(t`Cancel`)} + /> + ); } AddResourceRole.propTypes = { diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx index a681999391..264f7cdb28 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx @@ -1,22 +1,46 @@ /* eslint-disable react/jsx-pascal-case */ import React from 'react'; import { shallow } from 'enzyme'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; + +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; import AddResourceRole, { _AddResourceRole } from './AddResourceRole'; import { TeamsAPI, UsersAPI } from '../../api'; -jest.mock('../../api'); +jest.mock('../../api/models/Teams'); +jest.mock('../../api/models/Users'); + +// TODO: Once error handling is functional in +// this component write tests for it describe('<_AddResourceRole />', () => { UsersAPI.read.mockResolvedValue({ data: { count: 2, results: [ - { id: 1, username: 'foo' }, - { id: 2, username: 'bar' }, + { id: 1, username: 'foo', url: '' }, + { id: 2, username: 'bar', url: '' }, ], }, }); + UsersAPI.readOptions.mockResolvedValue({ + data: { related: {}, actions: { GET: {} } }, + }); + TeamsAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { id: 1, name: 'Team foo', url: '' }, + { id: 2, name: 'Team bar', url: '' }, + ], + }, + }); + TeamsAPI.readOptions.mockResolvedValue({ + data: { related: {}, actions: { GET: {} } }, + }); const roles = { admin_role: { description: 'Can manage all aspects of the organization', @@ -39,191 +63,165 @@ describe('<_AddResourceRole />', () => { /> ); }); - test('handleRoleCheckboxClick properly updates state', () => { - const wrapper = shallow( - <_AddResourceRole - onClose={() => {}} - onSave={() => {}} - roles={roles} - i18n={{ _: val => val.toString() }} - /> - ); - wrapper.setState({ - selectedRoleRows: [ - { - description: 'Can manage all aspects of the organization', - name: 'Admin', - id: 1, - }, - ], + test('should save properly', async () => { + let wrapper; + act(() => { + wrapper = mountWithContexts( + {}} onSave={() => {}} roles={roles} />, + { context: { network: { handleHttpError: () => {} } } } + ); }); - wrapper.instance().handleRoleCheckboxClick({ - description: 'Can manage all aspects of the organization', - name: 'Admin', - id: 1, - }); - expect(wrapper.state('selectedRoleRows')).toEqual([]); - wrapper.instance().handleRoleCheckboxClick({ - description: 'Can manage all aspects of the organization', - name: 'Admin', - id: 1, - }); - expect(wrapper.state('selectedRoleRows')).toEqual([ - { - description: 'Can manage all aspects of the organization', - name: 'Admin', - id: 1, - }, - ]); - }); - test('handleResourceCheckboxClick properly updates state', () => { - const wrapper = shallow( - <_AddResourceRole - onClose={() => {}} - onSave={() => {}} - roles={roles} - i18n={{ _: val => val.toString() }} - /> - ); - wrapper.setState({ - selectedResourceRows: [ - { - id: 1, - username: 'foobar', - }, - ], - }); - wrapper.instance().handleResourceCheckboxClick({ - id: 1, - username: 'foobar', - }); - expect(wrapper.state('selectedResourceRows')).toEqual([]); - wrapper.instance().handleResourceCheckboxClick({ - id: 1, - username: 'foobar', - }); - expect(wrapper.state('selectedResourceRows')).toEqual([ - { - id: 1, - username: 'foobar', - }, - ]); - }); - test('clicking user/team cards updates state', () => { - const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect'); - const wrapper = mountWithContexts( - {}} onSave={() => {}} roles={roles} />, - { context: { network: { handleHttpError: () => {} } } } - ).find('AddResourceRole'); + wrapper.update(); + + // Step 1 const selectableCardWrapper = wrapper.find('SelectableCard'); expect(selectableCardWrapper.length).toBe(2); - selectableCardWrapper.first().simulate('click'); - expect(spy).toHaveBeenCalledWith('users'); - expect(wrapper.state('selectedResource')).toBe('users'); - selectableCardWrapper.at(1).simulate('click'); - expect(spy).toHaveBeenCalledWith('teams'); - expect(wrapper.state('selectedResource')).toBe('teams'); - }); - test('handleResourceSelect clears out selected lists and sets selectedResource', () => { - const wrapper = shallow( - <_AddResourceRole - onClose={() => {}} - onSave={() => {}} - roles={roles} - i18n={{ _: val => val.toString() }} - /> + act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')()); + wrapper.update(); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() ); - wrapper.setState({ - selectedResource: 'teams', - selectedResourceRows: [ - { - id: 1, - username: 'foobar', - }, - ], - selectedRoleRows: [ - { - description: 'Can manage all aspects of the organization', - id: 1, - name: 'Admin', - }, - ], - }); - wrapper.instance().handleResourceSelect('users'); - expect(wrapper.state()).toEqual({ - selectedResource: 'users', - selectedResourceRows: [], - selectedRoleRows: [], - currentStepId: 1, - maxEnabledStep: 1, - }); - wrapper.instance().handleResourceSelect('teams'); - expect(wrapper.state()).toEqual({ - selectedResource: 'teams', - selectedResourceRows: [], - selectedRoleRows: [], - currentStepId: 1, - maxEnabledStep: 1, - }); + wrapper.update(); + + // Step 2 + await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + act(() => + wrapper.find('DataListCheck[name="foo"]').invoke('onChange')(true) + ); + wrapper.update(); + expect(wrapper.find('DataListCheck[name="foo"]').prop('checked')).toBe( + true + ); + act(() => wrapper.find('Button[type="submit"]').prop('onClick')()); + wrapper.update(); + + // Step 3 + act(() => + wrapper.find('Checkbox[aria-label="Admin"]').invoke('onChange')(true) + ); + wrapper.update(); + expect(wrapper.find('Checkbox[aria-label="Admin"]').prop('isChecked')).toBe( + true + ); + + // Save + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + expect(UsersAPI.associateRole).toBeCalledWith(1, 1); }); - test('handleWizardSave makes correct api calls, calls onSave when done', async () => { - const handleSave = jest.fn(); - const wrapper = mountWithContexts( - {}} onSave={handleSave} roles={roles} />, - { context: { network: { handleHttpError: () => {} } } } - ).find('AddResourceRole'); - wrapper.setState({ - selectedResource: 'users', - selectedResourceRows: [ - { - id: 1, - username: 'foobar', - }, - ], - selectedRoleRows: [ - { - description: 'Can manage all aspects of the organization', - id: 1, - name: 'Admin', - }, - { - description: 'May run any executable resources in the organization', - id: 2, - name: 'Execute', - }, - ], + + test('should successfuly click user/team cards', async () => { + let wrapper; + act(() => { + wrapper = mountWithContexts( + {}} onSave={() => {}} roles={roles} />, + { context: { network: { handleHttpError: () => {} } } } + ); }); - await wrapper.instance().handleWizardSave(); - expect(UsersAPI.associateRole).toHaveBeenCalledTimes(2); - expect(handleSave).toHaveBeenCalled(); - wrapper.setState({ - selectedResource: 'teams', - selectedResourceRows: [ - { - id: 1, - name: 'foobar', - }, - ], - selectedRoleRows: [ - { - description: 'Can manage all aspects of the organization', - id: 1, - name: 'Admin', - }, - { - description: 'May run any executable resources in the organization', - id: 2, - name: 'Execute', - }, - ], + wrapper.update(); + + const selectableCardWrapper = wrapper.find('SelectableCard'); + expect(selectableCardWrapper.length).toBe(2); + act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')()); + wrapper.update(); + + await waitForElement( + wrapper, + 'SelectableCard[label="Users"]', + el => el.prop('isSelected') === true + ); + act(() => wrapper.find('SelectableCard[label="Teams"]').prop('onClick')()); + wrapper.update(); + + await waitForElement( + wrapper, + 'SelectableCard[label="Teams"]', + el => el.prop('isSelected') === true + ); + }); + + test('should reset values with resource type changes', async () => { + let wrapper; + act(() => { + wrapper = mountWithContexts( + {}} onSave={() => {}} roles={roles} />, + { context: { network: { handleHttpError: () => {} } } } + ); }); - await wrapper.instance().handleWizardSave(); - expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2); - expect(handleSave).toHaveBeenCalled(); + wrapper.update(); + + // Step 1 + const selectableCardWrapper = wrapper.find('SelectableCard'); + expect(selectableCardWrapper.length).toBe(2); + act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')()); + wrapper.update(); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + wrapper.update(); + + // Step 2 + await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + act(() => + wrapper.find('DataListCheck[name="foo"]').invoke('onChange')(true) + ); + wrapper.update(); + expect(wrapper.find('DataListCheck[name="foo"]').prop('checked')).toBe( + true + ); + act(() => wrapper.find('Button[type="submit"]').prop('onClick')()); + wrapper.update(); + + // Step 3 + act(() => + wrapper.find('Checkbox[aria-label="Admin"]').invoke('onChange')(true) + ); + wrapper.update(); + expect(wrapper.find('Checkbox[aria-label="Admin"]').prop('isChecked')).toBe( + true + ); + + // Go back to step 1 + act(() => { + wrapper + .find('WizardNavItem[content="Select a Resource Type"]') + .find('button') + .prop('onClick')({ id: 1 }); + }); + wrapper.update(); + expect( + wrapper + .find('WizardNavItem[content="Select a Resource Type"]') + .prop('isCurrent') + ).toBe(true); + + // Go back to step 1 and this time select teams. Doing so should clear following steps + act(() => wrapper.find('SelectableCard[label="Teams"]').prop('onClick')()); + wrapper.update(); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + wrapper.update(); + + // Make sure no teams have been selected + await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + wrapper + .find('DataListCheck') + .map(item => expect(item.prop('checked')).toBe(false)); + act(() => wrapper.find('Button[type="submit"]').prop('onClick')()); + wrapper.update(); + + // Make sure that no roles have been selected + wrapper + .find('Checkbox') + .map(card => expect(card.prop('isChecked')).toBe(false)); + + // Make sure the save button is disabled + expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); }); test('should not display team as a choice in case credential does not have organization', () => { - const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect'); const wrapper = mountWithContexts( {}} @@ -232,11 +230,13 @@ describe('<_AddResourceRole />', () => { resource={{ type: 'credential', organization: null }} />, { context: { network: { handleHttpError: () => {} } } } - ).find('AddResourceRole'); - const selectableCardWrapper = wrapper.find('SelectableCard'); - expect(selectableCardWrapper.length).toBe(1); - selectableCardWrapper.first().simulate('click'); - expect(spy).toHaveBeenCalledWith('users'); - expect(wrapper.state('selectedResource')).toBe('users'); + ); + + expect(wrapper.find('SelectableCard').length).toBe(1); + wrapper.find('SelectableCard[label="Users"]').simulate('click'); + wrapper.update(); + expect( + wrapper.find('SelectableCard[label="Users"]').prop('isSelected') + ).toBe(true); }); }); diff --git a/awx/ui_next/src/components/AddRole/SelectRoleStep.jsx b/awx/ui_next/src/components/AddRole/SelectRoleStep.jsx index 826c2d52aa..32f0e6a96c 100644 --- a/awx/ui_next/src/components/AddRole/SelectRoleStep.jsx +++ b/awx/ui_next/src/components/AddRole/SelectRoleStep.jsx @@ -7,59 +7,55 @@ import { t } from '@lingui/macro'; import CheckboxCard from './CheckboxCard'; import SelectedList from '../SelectedList'; -class RolesStep extends React.Component { - render() { - const { - onRolesClick, - roles, - selectedListKey, - selectedListLabel, - selectedResourceRows, - selectedRoleRows, - i18n, - } = this.props; - - return ( - -
- {i18n._( - t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.` - )} -
-
- {selectedResourceRows.length > 0 && ( - - )} -
-
- {Object.keys(roles).map(role => ( - item.id === roles[role].id - )} - key={roles[role].id} - name={roles[role].name} - onSelect={() => onRolesClick(roles[role])} - /> - ))} -
-
- ); - } +function RolesStep({ + onRolesClick, + roles, + selectedListKey, + selectedListLabel, + selectedResourceRows, + selectedRoleRows, + i18n, +}) { + return ( + +
+ {i18n._( + t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.` + )} +
+
+ {selectedResourceRows.length > 0 && ( + + )} +
+
+ {Object.keys(roles).map(role => ( + item.id === roles[role].id + )} + key={roles[role].id} + name={roles[role].name} + onSelect={() => onRolesClick(roles[role])} + /> + ))} +
+
+ ); } RolesStep.propTypes = { diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx index 4f49268c0a..62b8983eb4 100644 --- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx +++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx @@ -12,52 +12,44 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { FormSelect, FormSelectOption } from '@patternfly/react-core'; -class AnsibleSelect extends React.Component { - constructor(props) { - super(props); - this.onSelectChange = this.onSelectChange.bind(this); - } - - onSelectChange(val, event) { - const { onChange, name } = this.props; +function AnsibleSelect({ + id, + data, + i18n, + isValid, + onBlur, + value, + className, + isDisabled, + onChange, + name, +}) { + const onSelectChange = (val, event) => { event.target.name = name; onChange(event, val); - } + }; - render() { - const { - id, - data, - i18n, - isValid, - onBlur, - value, - className, - isDisabled, - } = this.props; - - return ( - - {data.map(option => ( - - ))} - - ); - } + return ( + + {data.map(option => ( + + ))} + + ); } const Option = shape({ diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx index ced058754a..bb671c8823 100644 --- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx +++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; -import AnsibleSelect, { _AnsibleSelect } from './AnsibleSelect'; +import AnsibleSelect from './AnsibleSelect'; const mockData = [ { @@ -16,6 +16,7 @@ const mockData = [ ]; describe('', () => { + const onChange = jest.fn(); test('initially renders succesfully', async () => { mountWithContexts( ', () => { }); test('calls "onSelectChange" on dropdown select change', () => { - const spy = jest.spyOn(_AnsibleSelect.prototype, 'onSelectChange'); const wrapper = mountWithContexts( {}} + onChange={onChange} data={mockData} /> ); - expect(spy).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); wrapper.find('select').simulate('change'); - expect(spy).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalled(); }); test('Returns correct select options', () => { diff --git a/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx b/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx index 4d31af328e..1b8bb0c7e4 100644 --- a/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx +++ b/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx @@ -34,7 +34,6 @@ function PageHeaderToolbar({ const handleUserSelect = () => { setIsUserOpen(!isUserOpen); }; - return ( @@ -90,8 +89,11 @@ function PageHeaderToolbar({ dropdownItems={[ {i18n._(t`User Details`)} diff --git a/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.test.jsx b/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.test.jsx index 7c151d3dc5..4dc093c6c2 100644 --- a/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.test.jsx +++ b/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.test.jsx @@ -25,6 +25,7 @@ describe('PageHeaderToolbar', () => { ); expect(wrapper.find('DropdownItem')).toHaveLength(0); @@ -37,6 +38,10 @@ describe('PageHeaderToolbar', () => { expect(wrapper.find('DropdownItem')).toHaveLength(0); wrapper.find(pageUserDropdownSelector).simulate('click'); + wrapper.update(); + expect( + wrapper.find('DropdownItem[aria-label="User details"]').prop('href') + ).toBe('/#/users/1/details'); expect(wrapper.find('DropdownItem')).toHaveLength(2); const logout = wrapper.find('DropdownItem li button'); diff --git a/awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.jsx b/awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.jsx deleted file mode 100644 index 93a9b3d7f4..0000000000 --- a/awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { - PageSection as PFPageSection, - PageSectionVariants, - Breadcrumb, - BreadcrumbItem, - BreadcrumbHeading, -} from '@patternfly/react-core'; -import { Link, Route, useRouteMatch } from 'react-router-dom'; - -import styled from 'styled-components'; - -const PageSection = styled(PFPageSection)` - padding-top: 10px; - padding-bottom: 10px; -`; - -const Breadcrumbs = ({ breadcrumbConfig }) => { - const { light } = PageSectionVariants; - - return ( - - - - - - - - ); -}; - -const Crumb = ({ breadcrumbConfig, showDivider }) => { - const match = useRouteMatch(); - const crumb = breadcrumbConfig[match.url]; - - let crumbElement = ( - - {crumb} - - ); - - if (match.isExact) { - crumbElement = ( - - {crumb} - - ); - } - - if (!crumb) { - crumbElement = null; - } - - return ( - - {crumbElement} - - - - - ); -}; - -Breadcrumbs.propTypes = { - breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired, -}; - -Crumb.propTypes = { - breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired, -}; - -export default Breadcrumbs; diff --git a/awx/ui_next/src/components/Breadcrumbs/index.js b/awx/ui_next/src/components/Breadcrumbs/index.js deleted file mode 100644 index 3ff68ca589..0000000000 --- a/awx/ui_next/src/components/Breadcrumbs/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './Breadcrumbs'; diff --git a/awx/ui_next/src/components/DetailList/LaunchedByDetail.jsx b/awx/ui_next/src/components/DetailList/LaunchedByDetail.jsx new file mode 100644 index 0000000000..6c451a3e51 --- /dev/null +++ b/awx/ui_next/src/components/DetailList/LaunchedByDetail.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { t } from '@lingui/macro'; +import Detail from './Detail'; + +const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => { + const { + created_by: createdBy, + job_template: jobTemplate, + schedule, + } = summary_fields; + const { schedule: relatedSchedule } = related; + + if (!createdBy && !schedule) { + return {}; + } + + let link; + let value; + + if (createdBy) { + link = `/users/${createdBy.id}`; + value = createdBy.username; + } else if (relatedSchedule && jobTemplate) { + link = `/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`; + value = schedule.name; + } else { + link = null; + value = schedule.name; + } + + return { link, value }; +}; + +export default function LaunchedByDetail({ job, i18n }) { + const { value: launchedByValue, link: launchedByLink } = + getLaunchedByDetails(job) || {}; + + return ( + {launchedByValue} + ) : ( + launchedByValue + ) + } + /> + ); +} diff --git a/awx/ui_next/src/components/DetailList/index.js b/awx/ui_next/src/components/DetailList/index.js index d5e2ccd8a4..a393fe72a0 100644 --- a/awx/ui_next/src/components/DetailList/index.js +++ b/awx/ui_next/src/components/DetailList/index.js @@ -4,6 +4,7 @@ export { default as DeletedDetail } from './DeletedDetail'; export { default as UserDateDetail } from './UserDateDetail'; export { default as DetailBadge } from './DetailBadge'; export { default as ArrayDetail } from './ArrayDetail'; +export { default as LaunchedByDetail } from './LaunchedByDetail'; /* NOTE: CodeDetail cannot be imported here, as it causes circular dependencies in testing environment. Import it directly from diff --git a/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx b/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx index 7ffce947d8..00a33b68c2 100644 --- a/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx +++ b/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx @@ -31,35 +31,31 @@ const ToolbarItem = styled(PFToolbarItem)` // TODO: Recommend renaming this component to avoid confusion // with ExpandingContainer -class ExpandCollapse extends React.Component { - render() { - const { isCompact, onCompact, onExpand, i18n } = this.props; - - return ( - - - - - - - - - ); - } +function ExpandCollapse({ isCompact, onCompact, onExpand, i18n }) { + return ( + + + + + + + + + ); } ExpandCollapse.propTypes = { diff --git a/awx/ui_next/src/components/FormField/PasswordInput.jsx b/awx/ui_next/src/components/FormField/PasswordInput.jsx index 8fa0977f48..1fd6979206 100644 --- a/awx/ui_next/src/components/FormField/PasswordInput.jsx +++ b/awx/ui_next/src/components/FormField/PasswordInput.jsx @@ -12,7 +12,15 @@ import { import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons'; function PasswordInput(props) { - const { id, name, validate, isRequired, isDisabled, i18n } = props; + const { + autocomplete, + id, + name, + validate, + isRequired, + isDisabled, + i18n, + } = props; const [inputType, setInputType] = useState('password'); const [field, meta] = useField({ name, validate }); @@ -38,6 +46,7 @@ function PasswordInput(props) { {}, isRequired: false, isDisabled: false, diff --git a/awx/ui_next/src/components/JobList/JobList.jsx b/awx/ui_next/src/components/JobList/JobList.jsx index ead8916fe8..f21e8bb15b 100644 --- a/awx/ui_next/src/components/JobList/JobList.jsx +++ b/awx/ui_next/src/components/JobList/JobList.jsx @@ -7,7 +7,8 @@ import { Card } from '@patternfly/react-core'; import AlertModal from '../AlertModal'; import DatalistToolbar from '../DataListToolbar'; import ErrorDetail from '../ErrorDetail'; -import PaginatedDataList, { ToolbarDeleteButton } from '../PaginatedDataList'; +import { ToolbarDeleteButton } from '../PaginatedDataList'; +import PaginatedTable, { HeaderRow, HeaderCell } from '../PaginatedTable'; import useRequest, { useDeleteItems, useDismissableError, @@ -27,7 +28,7 @@ import { } from '../../api'; function JobList({ i18n, defaultParams, showTypeColumn = false }) { - const QS_CONFIG = getQSConfig( + const qsConfig = getQSConfig( 'job', { page: 1, @@ -49,7 +50,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { } = useRequest( useCallback( async () => { - const params = parseQueryString(QS_CONFIG, location.search); + const params = parseQueryString(qsConfig, location.search); const [response, actionsResponse] = await Promise.all([ UnifiedJobsAPI.read({ ...params }), UnifiedJobsAPI.readOptions(), @@ -81,7 +82,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { // TODO: update QS_CONFIG to be safe for deps array const fetchJobsById = useCallback( async ids => { - const params = parseQueryString(QS_CONFIG, location.search); + const params = parseQueryString(qsConfig, location.search); params.id__in = ids.join(','); const { data } = await UnifiedJobsAPI.read(params); return data.results; @@ -89,7 +90,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { [location.search] // eslint-disable-line react-hooks/exhaustive-deps ); - const jobs = useWsJobs(results, fetchJobsById, QS_CONFIG); + const jobs = useWsJobs(results, fetchJobsById, qsConfig); const isAllSelected = selected.length === jobs.length && selected.length > 0; @@ -145,7 +146,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { ); }, [selected]), { - qsConfig: QS_CONFIG, + qsConfig, allItemsSelected: isAllSelected, fetchItems: fetchJobs, } @@ -176,14 +177,13 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { return ( <> - + {i18n._(t`Name`)} + {i18n._(t`Status`)} + {showTypeColumn && {i18n._(t`Type`)}} + {i18n._(t`Start Time`)} + + {i18n._(t`Finish Time`)} + + + } toolbarSearchableKeys={searchableKeys} toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( @@ -267,7 +252,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { showSelectAll isAllSelected={isAllSelected} onSelectAll={handleSelectAll} - qsConfig={QS_CONFIG} + qsConfig={qsConfig} additionalControls={[ )} - renderItem={job => ( + renderRow={(job, index) => ( handleSelect(job)} isSelected={selected.some(row => row.id === job.id)} + rowIndex={index} /> )} /> diff --git a/awx/ui_next/src/components/JobList/JobListItem.jsx b/awx/ui_next/src/components/JobList/JobListItem.jsx index be3739ce6e..6e2bff2bf4 100644 --- a/awx/ui_next/src/components/JobList/JobListItem.jsx +++ b/awx/ui_next/src/components/JobList/JobListItem.jsx @@ -1,39 +1,29 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Link } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { - Button, - DataListAction as _DataListAction, - DataListCheck, - DataListItem, - DataListItemRow, - DataListItemCells, - Tooltip, -} from '@patternfly/react-core'; +import { Button, Chip } from '@patternfly/react-core'; +import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table'; import { RocketIcon } from '@patternfly/react-icons'; -import styled from 'styled-components'; -import DataListCell from '../DataListCell'; +import { ActionsTd, ActionItem } from '../PaginatedTable'; import LaunchButton from '../LaunchButton'; -import StatusIcon from '../StatusIcon'; +import StatusLabel from '../StatusLabel'; +import { DetailList, Detail, LaunchedByDetail } from '../DetailList'; +import ChipGroup from '../ChipGroup'; +import CredentialChip from '../CredentialChip'; import { formatDateString } from '../../util/dates'; import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; -const DataListAction = styled(_DataListAction)` - align-items: center; - display: grid; - grid-gap: 16px; - grid-template-columns: 40px; -`; - function JobListItem({ i18n, job, + rowIndex, isSelected, onSelect, showTypeColumn = false, }) { const labelId = `check-action-${job.id}`; + const [isExpanded, setIsExpanded] = useState(false); const jobTypes = { project_update: i18n._(t`Source Control Update`), @@ -44,67 +34,123 @@ function JobListItem({ workflow_job: i18n._(t`Workflow Job`), }; + const { credentials, inventory, labels } = job.summary_fields; + return ( - - - + + setIsExpanded(!isExpanded), + }} /> - - {job.status && } - , - - - - - {job.id} — {job.name} - - - - , - ...(showTypeColumn - ? [ - - {jobTypes[job.type]} - , - ] - : []), - - {job.finished ? formatDateString(job.finished) : ''} - , - ]} + - - {job.type !== 'system_job' && - job.summary_fields?.user_capabilities?.start ? ( - - - {({ handleRelaunch }) => ( - - )} - - - ) : ( - '' - )} - - - + + + + + {job.id} — {job.name} + + + + + + {job.status && } + + {showTypeColumn && ( + {jobTypes[job.type]} + )} + + {formatDateString(job.started)} + + + {job.finished ? formatDateString(job.finished) : ''} + + + + + {({ handleRelaunch }) => ( + + )} + + + + + + + + + + + {credentials && credentials.length > 0 && ( + + {credentials.map(c => ( + + ))} + + } + /> + )} + {labels && labels.count > 0 && ( + + {labels.results.map(l => ( + + {l.name} + + ))} + + } + /> + )} + {inventory && ( + + {inventory.name} + + } + /> + )} + + + + + ); } diff --git a/awx/ui_next/src/components/JobList/JobListItem.test.jsx b/awx/ui_next/src/components/JobList/JobListItem.test.jsx index fc453c3be4..6b9a9c3ae4 100644 --- a/awx/ui_next/src/components/JobList/JobListItem.test.jsx +++ b/awx/ui_next/src/components/JobList/JobListItem.test.jsx @@ -32,7 +32,11 @@ describe('', () => { initialEntries: ['/jobs'], }); wrapper = mountWithContexts( - {}} />, + + + {}} /> + +
, { context: { router: { history } } } ); }); @@ -51,32 +55,40 @@ describe('', () => { test('launch button hidden from users without launch capabilities', () => { wrapper = mountWithContexts( - {}} - isSelected={false} - /> + + + {}} + isSelected={false} + /> + +
); expect(wrapper.find('LaunchButton').length).toBe(0); }); test('should hide type column when showTypeColumn is false', () => { - expect(wrapper.find('DataListCell[aria-label="type"]').length).toBe(0); + expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(0); }); test('should show type column when showTypeColumn is true', () => { wrapper = mountWithContexts( - {}} - /> + + + {}} + /> + +
); - expect(wrapper.find('DataListCell[aria-label="type"]').length).toBe(1); + expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(1); }); }); diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx index d38cc7f2c2..a832a929f8 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx @@ -25,6 +25,8 @@ function canLaunchWithoutPrompt(launchData) { !launchData.ask_limit_on_launch && !launchData.ask_scm_branch_on_launch && !launchData.survey_enabled && + (!launchData.passwords_needed_to_start || + launchData.passwords_needed_to_start.length === 0) && (!launchData.variables_needed_to_start || launchData.variables_needed_to_start.length === 0) ); @@ -100,17 +102,20 @@ class LaunchButton extends React.Component { async launchWithParams(params) { try { const { history, resource } = this.props; - const jobPromise = - resource.type === 'workflow_job_template' - ? WorkflowJobTemplatesAPI.launch(resource.id, params || {}) - : JobTemplatesAPI.launch(resource.id, params || {}); + let jobPromise; + + if (resource.type === 'job_template') { + jobPromise = JobTemplatesAPI.launch(resource.id, params || {}); + } else if (resource.type === 'workflow_job_template') { + jobPromise = WorkflowJobTemplatesAPI.launch(resource.id, params || {}); + } else if (resource.type === 'job') { + jobPromise = JobsAPI.relaunch(resource.id, params || {}); + } else if (resource.type === 'workflow_job') { + jobPromise = WorkflowJobsAPI.relaunch(resource.id, params || {}); + } const { data: job } = await jobPromise; - history.push( - `/${ - resource.type === 'workflow_job_template' ? 'jobs/workflow' : 'jobs' - }/${job.id}/output` - ); + history.push(`/jobs/${job.id}/output`); } catch (launchError) { this.setState({ launchError }); } @@ -127,20 +132,15 @@ class LaunchButton extends React.Component { readRelaunch = InventorySourcesAPI.readLaunchUpdate( resource.inventory_source ); - relaunch = InventorySourcesAPI.launchUpdate(resource.inventory_source); } else if (resource.type === 'project_update') { // We'll need to handle the scenario where the project no longer exists readRelaunch = ProjectsAPI.readLaunchUpdate(resource.project); - relaunch = ProjectsAPI.launchUpdate(resource.project); } else if (resource.type === 'workflow_job') { readRelaunch = WorkflowJobsAPI.readRelaunch(resource.id); - relaunch = WorkflowJobsAPI.relaunch(resource.id); } else if (resource.type === 'ad_hoc_command') { readRelaunch = AdHocCommandsAPI.readRelaunch(resource.id); - relaunch = AdHocCommandsAPI.relaunch(resource.id); } else if (resource.type === 'job') { readRelaunch = JobsAPI.readRelaunch(resource.id); - relaunch = JobsAPI.relaunch(resource.id); } try { @@ -149,11 +149,22 @@ class LaunchButton extends React.Component { !relaunchConfig.passwords_needed_to_start || relaunchConfig.passwords_needed_to_start.length === 0 ) { + if (resource.type === 'inventory_update') { + relaunch = InventorySourcesAPI.launchUpdate( + resource.inventory_source + ); + } else if (resource.type === 'project_update') { + relaunch = ProjectsAPI.launchUpdate(resource.project); + } else if (resource.type === 'workflow_job') { + relaunch = WorkflowJobsAPI.relaunch(resource.id); + } else if (resource.type === 'ad_hoc_command') { + relaunch = AdHocCommandsAPI.relaunch(resource.id); + } else if (resource.type === 'job') { + relaunch = JobsAPI.relaunch(resource.id); + } const { data: job } = await relaunch; history.push(`/jobs/${job.id}/output`); } else { - // TODO: restructure (async?) to send launch command after prompts - // TODO: does relaunch need different prompt treatment than launch? this.setState({ showLaunchPrompt: true, launchConfig: relaunchConfig, diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx index 10fbbd1bf4..c84c7fde4d 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx @@ -4,10 +4,16 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { sleep } from '../../../testUtils/testUtils'; import LaunchButton from './LaunchButton'; -import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api'; +import { + InventorySourcesAPI, + JobsAPI, + JobTemplatesAPI, + ProjectsAPI, + WorkflowJobsAPI, + WorkflowJobTemplatesAPI, +} from '../../api'; -jest.mock('../../api/models/WorkflowJobTemplates'); -jest.mock('../../api/models/JobTemplates'); +jest.mock('../../api'); describe('LaunchButton', () => { JobTemplatesAPI.readLaunch.mockResolvedValue({ @@ -22,10 +28,14 @@ describe('LaunchButton', () => { }, }); - const children = ({ handleLaunch }) => ( + const launchButton = ({ handleLaunch }) => ( + + {searchColumns.map(({ name, key }) => ( + + {chips[key]?.chips?.map(chip => ( + + {chip.node} + + ))} + + ))} + + + ); + return ( } > - - - - {searchColumns.map(({ name, key }) => ( - - {chips[key]?.chips?.map(chip => ( - - {chip.node} - - ))} - - ))} - - + {renderLookup()} + + ) : ( + renderLookup() + )} { - const encodedParams = encodeNonDefaultQueryString(qsConfig, params); + const nonNamespacedParams = parseQueryString({}, history.location.search); + const encodedParams = encodeNonDefaultQueryString( + qsConfig, + params, + nonNamespacedParams + ); history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname); }; diff --git a/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx b/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx index 259cc39bac..a70ca52232 100644 --- a/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx +++ b/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx @@ -12,7 +12,7 @@ const Th = styled(PFTh)` --pf-c-table--cell--Overflow: initial; `; -export default function HeaderRow({ qsConfig, children }) { +export default function HeaderRow({ qsConfig, isExpandable, children }) { const location = useLocation(); const history = useHistory(); @@ -23,7 +23,12 @@ export default function HeaderRow({ qsConfig, children }) { order_by: order === 'asc' ? key : `-${key}`, page: null, }); - const encodedParams = encodeNonDefaultQueryString(qsConfig, newParams); + const nonNamespacedParams = parseQueryString({}, history.location.search); + const encodedParams = encodeNonDefaultQueryString( + qsConfig, + newParams, + nonNamespacedParams + ); history.push( encodedParams ? `${location.pathname}?${encodedParams}` @@ -36,25 +41,38 @@ export default function HeaderRow({ qsConfig, children }) { index: sortKey || qsConfig.defaultParams?.order_by, direction: params.order_by?.startsWith('-') ? 'desc' : 'asc', }; + const idPrefix = `${qsConfig.namespace}-table-sort`; // empty first Th aligns with checkboxes in table rows return ( + {isExpandable && } - {React.Children.map(children, child => - React.cloneElement(child, { - onSort, - sortBy, - columnIndex: child.props.sortKey, - }) + {React.Children.map( + children, + child => + child && + React.cloneElement(child, { + onSort, + sortBy, + columnIndex: child.props.sortKey, + idPrefix, + }) )} ); } -export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) { +export function HeaderCell({ + sortKey, + onSort, + sortBy, + columnIndex, + idPrefix, + children, +}) { const sort = sortKey ? { onSort: (event, key, order) => onSort(sortKey, order), @@ -62,5 +80,9 @@ export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) { columnIndex, } : null; - return {children}; + return ( + + {children} + + ); } diff --git a/awx/ui_next/src/components/PaginatedTable/HeaderRow.test.jsx b/awx/ui_next/src/components/PaginatedTable/HeaderRow.test.jsx index ec1124c4b7..fa53baac60 100644 --- a/awx/ui_next/src/components/PaginatedTable/HeaderRow.test.jsx +++ b/awx/ui_next/src/components/PaginatedTable/HeaderRow.test.jsx @@ -62,4 +62,20 @@ describe('', () => { const cell = wrapper.find('Th').at(2); expect(cell.prop('sort')).toEqual(null); }); + + test('should handle null children gracefully', async () => { + const nope = false; + const wrapper = mountWithContexts( + + + One + {nope && Hidden} + Two + +
+ ); + + const cells = wrapper.find('Th'); + expect(cells).toHaveLength(3); + }); }); diff --git a/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx b/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx index 9892df34fe..42bf01a638 100644 --- a/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx +++ b/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx @@ -40,8 +40,13 @@ function PaginatedTable({ const history = useHistory(); const pushHistoryState = params => { - const { pathname } = history.location; - const encodedParams = encodeNonDefaultQueryString(qsConfig, params); + const { pathname, search } = history.location; + const nonNamespacedParams = parseQueryString({}, search); + const encodedParams = encodeNonDefaultQueryString( + qsConfig, + params, + nonNamespacedParams + ); history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname); }; diff --git a/awx/ui_next/src/components/ResourceAccessList/DeleteRoleConfirmationModal.jsx b/awx/ui_next/src/components/ResourceAccessList/DeleteRoleConfirmationModal.jsx index 8ac2f79bc4..407ebdb499 100644 --- a/awx/ui_next/src/components/ResourceAccessList/DeleteRoleConfirmationModal.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/DeleteRoleConfirmationModal.jsx @@ -7,69 +7,71 @@ import { t } from '@lingui/macro'; import AlertModal from '../AlertModal'; import { Role } from '../../types'; -class DeleteRoleConfirmationModal extends React.Component { - static propTypes = { - role: Role.isRequired, - username: string, - onCancel: func.isRequired, - onConfirm: func.isRequired, - }; - - static defaultProps = { - username: '', - }; - - isTeamRole() { - const { role } = this.props; +function DeleteRoleConfirmationModal({ + role, + username, + onCancel, + onConfirm, + i18n, +}) { + const isTeamRole = () => { return typeof role.team_id !== 'undefined'; - } + }; - render() { - const { role, username, onCancel, onConfirm, i18n } = this.props; - const title = i18n._( - t`Remove ${this.isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access` - ); - return ( - - {i18n._(t`Delete`)} - , - , - ]} - > - {this.isTeamRole() ? ( - - {i18n._( - t`Are you sure you want to remove ${role.name} access from ${role.team_name}? Doing so affects all members of the team.` - )} -
-
- {i18n._( - t`If you only want to remove access for this particular user, please remove them from the team.` - )} -
- ) : ( - - {i18n._( - t`Are you sure you want to remove ${role.name} access from ${username}?` - )} - - )} -
- ); - } + const title = i18n._( + t`Remove ${isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access` + ); + return ( + + {i18n._(t`Delete`)} + , + , + ]} + > + {isTeamRole() ? ( + + {i18n._( + t`Are you sure you want to remove ${role.name} access from ${role.team_name}? Doing so affects all members of the team.` + )} +
+
+ {i18n._( + t`If you only want to remove access for this particular user, please remove them from the team.` + )} +
+ ) : ( + + {i18n._( + t`Are you sure you want to remove ${role.name} access from ${username}?` + )} + + )} +
+ ); } +DeleteRoleConfirmationModal.propTypes = { + role: Role.isRequired, + username: string, + onCancel: func.isRequired, + onConfirm: func.isRequired, +}; + +DeleteRoleConfirmationModal.defaultProps = { + username: '', +}; + export default withI18n()(DeleteRoleConfirmationModal); diff --git a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx index b5b1765d45..0f5f7c1c64 100644 --- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx @@ -144,6 +144,7 @@ function ResourceAccessList({ i18n, apiModel, resource }) { setDeletionRole(role); setShowDeleteModal(true); }} + i18n={i18n} /> )} /> diff --git a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx index 3fe656a3fb..d641e67e01 100644 --- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx @@ -24,19 +24,13 @@ const DataListItemCells = styled(PFDataListItemCells)` align-items: start; `; -class ResourceAccessListItem extends React.Component { - static propTypes = { +function ResourceAccessListItem({ accessRecord, onRoleDelete, i18n }) { + ResourceAccessListItem.propTypes = { accessRecord: AccessRecord.isRequired, onRoleDelete: func.isRequired, }; - constructor(props) { - super(props); - this.renderChip = this.renderChip.bind(this); - } - - getRoleLists() { - const { accessRecord } = this.props; + const getRoleLists = () => { const teamRoles = []; const userRoles = []; @@ -52,10 +46,9 @@ class ResourceAccessListItem extends React.Component { accessRecord.summary_fields.direct_access.map(sort); accessRecord.summary_fields.indirect_access.map(sort); return [teamRoles, userRoles]; - } + }; - renderChip(role) { - const { accessRecord, onRoleDelete } = this.props; + const renderChip = role => { return ( ); - } + }; - render() { - const { accessRecord, i18n } = this.props; - const [teamRoles, userRoles] = this.getRoleLists(); + const [teamRoles, userRoles] = getRoleLists(); - return ( - - - - {accessRecord.username && ( - - {accessRecord.id ? ( - - - {accessRecord.username} - - - ) : ( - + return ( + + + + {accessRecord.username && ( + + {accessRecord.id ? ( + + {accessRecord.username} - - )} - - )} - {accessRecord.first_name || accessRecord.last_name ? ( - - - - ) : null} - , - + + + ) : ( + + {accessRecord.username} + + )} + + )} + {accessRecord.first_name || accessRecord.last_name ? ( - {userRoles.length > 0 && ( - - {userRoles.map(this.renderChip)} - - } - /> - )} - {teamRoles.length > 0 && ( - - {teamRoles.map(this.renderChip)} - - } - /> - )} + - , - ]} - /> - - - ); - } + ) : null} + , + + + {userRoles.length > 0 && ( + + {userRoles.map(renderChip)} + + } + /> + )} + {teamRoles.length > 0 && ( + + {teamRoles.map(renderChip)} + + } + /> + )} + + , + ]} + /> + + + ); } export default withI18n()(ResourceAccessListItem); diff --git a/awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx new file mode 100644 index 0000000000..d856a430ee --- /dev/null +++ b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx @@ -0,0 +1,135 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Button, + PageSection, + PageSectionVariants, + Breadcrumb, + BreadcrumbItem, + Title, + Tooltip, +} from '@patternfly/react-core'; +import { HistoryIcon } from '@patternfly/react-icons'; +import { Link, Route, useRouteMatch } from 'react-router-dom'; + +const ScreenHeader = ({ breadcrumbConfig, i18n, streamType }) => { + const { light } = PageSectionVariants; + const oneCrumbMatch = useRouteMatch({ + path: Object.keys(breadcrumbConfig)[0], + strict: true, + }); + const isOnlyOneCrumb = oneCrumbMatch && oneCrumbMatch.isExact; + + return ( + +
+
+ {!isOnlyOneCrumb && ( + + + + + + )} +
+ + + +
+
+ {streamType !== 'none' && ( +
+ + + +
+ )} +
+
+ ); +}; + +const ActualTitle = ({ breadcrumbConfig }) => { + const match = useRouteMatch(); + const title = breadcrumbConfig[match.url]; + let titleElement; + + if (match.isExact) { + titleElement = ( + + {title} + + ); + } + + if (!title) { + titleElement = null; + } + + return ( + + {titleElement} + + + + + ); +}; + +const Crumb = ({ breadcrumbConfig, showDivider }) => { + const match = useRouteMatch(); + const crumb = breadcrumbConfig[match.url]; + + let crumbElement = ( + + {crumb} + + ); + + if (match.isExact) { + crumbElement = null; + } + + if (!crumb) { + crumbElement = null; + } + return ( + + {crumbElement} + + + + + ); +}; + +ScreenHeader.propTypes = { + breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired, +}; + +Crumb.propTypes = { + breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired, +}; + +export default withI18n()(ScreenHeader); diff --git a/awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.test.jsx b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.test.jsx similarity index 70% rename from awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.test.jsx rename to awx/ui_next/src/components/ScreenHeader/ScreenHeader.test.jsx index 83e5e8c3fe..64f3a92c53 100644 --- a/awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.test.jsx +++ b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.test.jsx @@ -1,9 +1,15 @@ import React from 'react'; -import { mount } from 'enzyme'; import { MemoryRouter } from 'react-router-dom'; -import Breadcrumbs from './Breadcrumbs'; -describe('', () => { +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; + +import ScreenHeader from './ScreenHeader'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + +describe('', () => { let breadcrumbWrapper; let breadcrumb; let breadcrumbItem; @@ -17,15 +23,15 @@ describe('', () => { }; const findChildren = () => { - breadcrumb = breadcrumbWrapper.find('Breadcrumb'); + breadcrumb = breadcrumbWrapper.find('ScreenHeader'); breadcrumbItem = breadcrumbWrapper.find('BreadcrumbItem'); - breadcrumbHeading = breadcrumbWrapper.find('BreadcrumbHeading'); + breadcrumbHeading = breadcrumbWrapper.find('Title'); }; test('initially renders succesfully', () => { - breadcrumbWrapper = mount( + breadcrumbWrapper = mountWithContexts( - + ); @@ -51,9 +57,9 @@ describe('', () => { ]; routes.forEach(([location, crumbLength]) => { - breadcrumbWrapper = mount( + breadcrumbWrapper = mountWithContexts( - + ); diff --git a/awx/ui_next/src/components/ScreenHeader/index.js b/awx/ui_next/src/components/ScreenHeader/index.js new file mode 100644 index 0000000000..7f5ab32733 --- /dev/null +++ b/awx/ui_next/src/components/ScreenHeader/index.js @@ -0,0 +1 @@ +export { default } from './ScreenHeader'; diff --git a/awx/ui_next/src/components/Sort/Sort.jsx b/awx/ui_next/src/components/Sort/Sort.jsx index c0a513cd48..ee8599b531 100644 --- a/awx/ui_next/src/components/Sort/Sort.jsx +++ b/awx/ui_next/src/components/Sort/Sort.jsx @@ -1,7 +1,7 @@ -import React, { Fragment } from 'react'; +import React, { Fragment, useState } from 'react'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; -import { withRouter } from 'react-router-dom'; +import { useLocation, withRouter } from 'react-router-dom'; import { t } from '@lingui/macro'; import { Button, @@ -31,140 +31,110 @@ const NoOptionDropdown = styled.div` border-bottom-color: var(--pf-global--BorderColor--200); `; -class Sort extends React.Component { - constructor(props) { - super(props); +function Sort({ columns, qsConfig, onSort, i18n }) { + const location = useLocation(); + const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false); - let sortKey; - let sortOrder; - let isNumeric; + let sortKey; + let sortOrder; + let isNumeric; - const { qsConfig, location } = this.props; - const queryParams = parseQueryString(qsConfig, location.search); - if (queryParams.order_by && queryParams.order_by.startsWith('-')) { - sortKey = queryParams.order_by.substr(1); - sortOrder = 'descending'; - } else if (queryParams.order_by) { - sortKey = queryParams.order_by; - sortOrder = 'ascending'; - } - - if (qsConfig.integerFields.find(field => field === sortKey)) { - isNumeric = true; - } else { - isNumeric = false; - } - - this.state = { - isSortDropdownOpen: false, - sortKey, - sortOrder, - isNumeric, - }; - - this.handleDropdownToggle = this.handleDropdownToggle.bind(this); - this.handleDropdownSelect = this.handleDropdownSelect.bind(this); - this.handleSort = this.handleSort.bind(this); + const queryParams = parseQueryString(qsConfig, location.search); + if (queryParams.order_by && queryParams.order_by.startsWith('-')) { + sortKey = queryParams.order_by.substr(1); + sortOrder = 'descending'; + } else if (queryParams.order_by) { + sortKey = queryParams.order_by; + sortOrder = 'ascending'; } - handleDropdownToggle(isSortDropdownOpen) { - this.setState({ isSortDropdownOpen }); + if (qsConfig.integerFields.find(field => field === sortKey)) { + isNumeric = true; + } else { + isNumeric = false; } - handleDropdownSelect({ target }) { - const { columns, onSort, qsConfig } = this.props; - const { sortOrder } = this.state; + const handleDropdownToggle = isOpen => { + setIsSortDropdownOpen(isOpen); + }; + + const handleDropdownSelect = ({ target }) => { const { innerText } = target; - const [{ key: sortKey }] = columns.filter(({ name }) => name === innerText); - - let isNumeric; - - if (qsConfig.integerFields.find(field => field === sortKey)) { + const [{ key }] = columns.filter(({ name }) => name === innerText); + sortKey = key; + if (qsConfig.integerFields.find(field => field === key)) { isNumeric = true; } else { isNumeric = false; } - this.setState({ isSortDropdownOpen: false, sortKey, isNumeric }); + setIsSortDropdownOpen(false); onSort(sortKey, sortOrder); - } + }; - handleSort() { - const { onSort } = this.props; - const { sortKey, sortOrder } = this.state; - const newSortOrder = sortOrder === 'ascending' ? 'descending' : 'ascending'; - this.setState({ sortOrder: newSortOrder }); - onSort(sortKey, newSortOrder); - } + const handleSort = () => { + onSort(sortKey, sortOrder === 'ascending' ? 'descending' : 'ascending'); + }; - render() { - const { up } = DropdownPosition; - const { columns, i18n } = this.props; - const { isSortDropdownOpen, sortKey, sortOrder, isNumeric } = this.state; + const { up } = DropdownPosition; - const defaultSortedColumn = columns.find(({ 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 ' - ); - } - - const sortedColumnName = defaultSortedColumn?.name; - - const sortDropdownItems = columns - .filter(({ key }) => key !== sortKey) - .map(({ key, name }) => ( - - {name} - - )); - - let SortIcon; - if (isNumeric) { - SortIcon = - sortOrder === 'ascending' - ? SortNumericDownIcon - : SortNumericDownAltIcon; - } else { - SortIcon = - sortOrder === 'ascending' ? SortAlphaDownIcon : SortAlphaDownAltIcon; - } - - return ( - - {sortedColumnName && ( - - {(sortDropdownItems.length > 0 && ( - - {sortedColumnName} - - } - dropdownItems={sortDropdownItems} - /> - )) || {sortedColumnName}} - - - )} - + if (!defaultSortedColumn) { + throw new Error( + 'sortKey must match one of the column keys, check the sortColumns prop passed to ' ); } + + const sortedColumnName = defaultSortedColumn?.name; + + const sortDropdownItems = columns + .filter(({ key }) => key !== sortKey) + .map(({ key, name }) => ( + + {name} + + )); + + let SortIcon; + if (isNumeric) { + SortIcon = + sortOrder === 'ascending' ? SortNumericDownIcon : SortNumericDownAltIcon; + } else { + SortIcon = + sortOrder === 'ascending' ? SortAlphaDownIcon : SortAlphaDownAltIcon; + } + return ( + + {sortedColumnName && ( + + {(sortDropdownItems.length > 0 && ( + + {sortedColumnName} + + } + dropdownItems={sortDropdownItems} + /> + )) || {sortedColumnName}} + + + + )} + + ); } Sort.propTypes = { diff --git a/awx/ui_next/src/components/Sort/Sort.test.jsx b/awx/ui_next/src/components/Sort/Sort.test.jsx index 1764cf0298..c6cf89ad0d 100644 --- a/awx/ui_next/src/components/Sort/Sort.test.jsx +++ b/awx/ui_next/src/components/Sort/Sort.test.jsx @@ -1,5 +1,10 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; + import Sort from './Sort'; describe('', () => { @@ -105,7 +110,7 @@ describe('', () => { expect(onSort).toHaveBeenCalledWith('foo', 'ascending'); }); - test('Changing dropdown correctly passes back new sort key', () => { + test('Changing dropdown correctly passes back new sort key', async () => { const qsConfig = { namespace: 'item', defaultParams: { page: 1, page_size: 5, order_by: 'foo' }, @@ -131,44 +136,18 @@ describe('', () => { const wrapper = mountWithContexts( - ).find('Sort'); - - wrapper.instance().handleDropdownSelect({ target: { innerText: 'Bar' } }); + ); + act(() => wrapper.find('Dropdown').invoke('onToggle')(true)); + wrapper.update(); + await waitForElement(wrapper, 'Dropdown', el => el.prop('isOpen') === true); + wrapper + .find('li') + .at(0) + .prop('onClick')({ target: { innerText: 'Bar' } }); + wrapper.update(); expect(onSort).toBeCalledWith('bar', 'ascending'); }); - test('Opening dropdown correctly updates state', () => { - const qsConfig = { - namespace: 'item', - defaultParams: { page: 1, page_size: 5, order_by: 'foo' }, - integerFields: ['page', 'page_size'], - }; - - const columns = [ - { - name: 'Foo', - key: 'foo', - }, - { - name: 'Bar', - key: 'bar', - }, - { - name: 'Bakery', - key: 'bakery', - }, - ]; - - const onSort = jest.fn(); - - const wrapper = mountWithContexts( - - ).find('Sort'); - expect(wrapper.state('isSortDropdownOpen')).toEqual(false); - wrapper.instance().handleDropdownToggle(true); - expect(wrapper.state('isSortDropdownOpen')).toEqual(true); - }); - test('It displays correct sort icon', () => { const forwardNumericIconSelector = 'SortNumericDownIcon'; const reverseNumericIconSelector = 'SortNumericDownAltIcon'; diff --git a/awx/ui_next/src/routeConfig.js b/awx/ui_next/src/routeConfig.js index 0b9e0591fa..a343a7d1e0 100644 --- a/awx/ui_next/src/routeConfig.js +++ b/awx/ui_next/src/routeConfig.js @@ -1,5 +1,6 @@ import { t } from '@lingui/macro'; +import ActivityStream from './screens/ActivityStream'; import Applications from './screens/Application'; import Credentials from './screens/Credential'; import CredentialTypes from './screens/CredentialType'; @@ -44,6 +45,11 @@ function getRouteConfig(i18n) { path: '/schedules', screen: Schedules, }, + { + title: i18n._(t`Activity Stream`), + path: '/activity_stream', + screen: ActivityStream, + }, { title: i18n._(t`Workflow Approvals`), path: '/workflow_approvals', diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx new file mode 100644 index 0000000000..84dd9a6066 --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx @@ -0,0 +1,269 @@ +import React, { Fragment, useState, useEffect, useCallback } from 'react'; +import { useLocation, useHistory } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Card, + PageSection, + PageSectionVariants, + SelectGroup, + Select, + SelectVariant, + SelectOption, + Title, +} from '@patternfly/react-core'; + +import DatalistToolbar from '../../components/DataListToolbar'; +import PaginatedTable, { + HeaderRow, + HeaderCell, +} from '../../components/PaginatedTable'; +import useRequest from '../../util/useRequest'; +import { + getQSConfig, + parseQueryString, + replaceParams, + encodeNonDefaultQueryString, +} from '../../util/qs'; +import { ActivityStreamAPI } from '../../api'; + +import ActivityStreamListItem from './ActivityStreamListItem'; + +function ActivityStream({ i18n }) { + const { light } = PageSectionVariants; + + const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false); + const location = useLocation(); + const history = useHistory(); + const urlParams = new URLSearchParams(location.search); + + const activityStreamType = urlParams.get('type') || 'all'; + + let typeParams = {}; + + if (activityStreamType !== 'all') { + typeParams = { + or__object1__in: activityStreamType, + or__object2__in: activityStreamType, + }; + } + + const QS_CONFIG = getQSConfig( + 'activity_stream', + { + page: 1, + page_size: 20, + order_by: '-timestamp', + }, + ['id', 'page', 'page_size'] + ); + + const { + result: { results, count, relatedSearchableKeys, searchableKeys }, + error: contentError, + isLoading, + request: fetchActivityStream, + } = useRequest( + useCallback( + async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const [response, actionsResponse] = await Promise.all([ + ActivityStreamAPI.read({ ...params, ...typeParams }), + ActivityStreamAPI.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, + relatedSearchableKeys: [], + searchableKeys: [], + } + ); + useEffect(() => { + fetchActivityStream(); + }, [fetchActivityStream]); + + const pushHistoryState = urlParamsToAdd => { + let searchParams = parseQueryString(QS_CONFIG, location.search); + searchParams = replaceParams(searchParams, { page: 1 }); + const encodedParams = encodeNonDefaultQueryString(QS_CONFIG, searchParams, { + type: urlParamsToAdd.get('type'), + }); + history.push( + encodedParams + ? `${location.pathname}?${encodedParams}` + : location.pathname + ); + }; + + return ( + + + + {i18n._(t`Activity Stream`)} + + + + + + + + {i18n._(t`Time`)} + + {i18n._(t`Initiated by`)} + + {i18n._(t`Event`)} + {i18n._(t`Actions`)} +
+ } + renderToolbar={props => ( + + )} + renderRow={streamItem => ( + + )} + /> +
+ + + ); +} + +export default withI18n()(ActivityStream); diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStream.test.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStream.test.jsx new file mode 100644 index 0000000000..b5afeef3d6 --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStream.test.jsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; + +import ActivityStream from './ActivityStream'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + +describe('', () => { + let pageWrapper; + + beforeEach(() => { + pageWrapper = mountWithContexts(); + }); + + afterEach(() => { + pageWrapper.unmount(); + }); + + test('initially renders without crashing', () => { + expect(pageWrapper.length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx new file mode 100644 index 0000000000..517491797a --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx @@ -0,0 +1,584 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; + +const buildAnchor = (obj, resource, activity) => { + let url; + let name; + // try/except pattern asserts that: + // if we encounter a case where a UI url can't or + // shouldn't be generated, just supply the name of the resource + try { + // catch-all case to avoid generating urls if a resource has been deleted + // if a resource still exists, it'll be serialized in the activity's summary_fields + if (!activity.summary_fields[resource]) { + throw new Error('The referenced resource no longer exists'); + } + switch (resource) { + case 'custom_inventory_script': + url = `/inventory_scripts/${obj.id}/`; + break; + case 'group': + if ( + activity.operation === 'create' || + activity.operation === 'delete' + ) { + // the API formats the changes.inventory field as str 'myInventoryName-PrimaryKey' + const [inventory_id] = activity.changes.inventory + .split('-') + .slice(-1); + url = `/inventories/inventory/${inventory_id}/groups/${activity.changes.id}/details/`; + } else { + url = `/inventories/inventory/${ + activity.summary_fields.inventory[0].id + }/groups/${activity.changes.id || + activity.changes.object1_pk}/details/`; + } + break; + case 'host': + url = `/hosts/${obj.id}/`; + break; + case 'job': + url = `/jobs/${obj.id}/`; + break; + case 'inventory': + url = + obj?.kind === 'smart' + ? `/inventories/smart_inventory/${obj.id}/` + : `/inventories/inventory/${obj.id}/`; + break; + case 'schedule': + // schedule urls depend on the resource they're associated with + if (activity.summary_fields.job_template) { + const jt_id = activity.summary_fields.job_template[0].id; + url = `/templates/job_template/${jt_id}/schedules/${obj.id}/`; + } else if (activity.summary_fields.workflow_job_template) { + const wfjt_id = activity.summary_fields.workflow_job_template[0].id; + url = `/templates/workflow_job_template/${wfjt_id}/schedules/${obj.id}/`; + } else if (activity.summary_fields.project) { + url = `/projects/${activity.summary_fields.project[0].id}/schedules/${obj.id}/`; + } else if (activity.summary_fields.system_job_template) { + url = null; + } else { + // urls for inventory sync schedules currently depend on having + // an inventory id and group id + throw new Error( + 'activity.summary_fields to build this url not implemented yet' + ); + } + break; + case 'setting': + url = `/settings/`; + break; + case 'notification_template': + url = `/notification_templates/${obj.id}/`; + break; + case 'role': + throw new Error( + 'role object management is not consolidated to a single UI view' + ); + case 'job_template': + url = `/templates/job_template/${obj.id}/`; + break; + case 'workflow_job_template': + url = `/templates/workflow_job_template/${obj.id}/`; + break; + case 'workflow_job_template_node': { + const { + id: wfjt_id, + name: wfjt_name, + } = activity.summary_fields.workflow_job_template[0]; + url = `/templates/workflow_job_template/${wfjt_id}/`; + name = wfjt_name; + break; + } + case 'workflow_job': + url = `/jobs/workflow/${obj.id}/`; + break; + case 'label': + url = null; + break; + case 'inventory_source': { + const inventoryId = (obj.inventory || '').split('-').reverse()[0]; + url = `/inventories/inventory/${inventoryId}/sources/${obj.id}/details/`; + break; + } + case 'o_auth2_application': + url = `/applications/${obj.id}/`; + break; + case 'workflow_approval': + url = `/jobs/workflow/${activity.summary_fields.workflow_job[0].id}/output/`; + name = `${activity.summary_fields.workflow_job[0].name} | ${activity.summary_fields.workflow_approval[0].name}`; + break; + case 'workflow_approval_template': + url = `/templates/workflow_job_template/${activity.summary_fields.workflow_job_template[0].id}/visualizer/`; + name = `${activity.summary_fields.workflow_job_template[0].name} | ${activity.summary_fields.workflow_approval_template[0].name}`; + break; + default: + url = `/${resource}s/${obj.id}/`; + } + + name = name || obj.name || obj.username; + + if (url) { + return {name}; + } + + return {name}; + } catch (err) { + return {obj.name || obj.username || ''}; + } +}; + +const getPastTense = item => { + return /e$/.test(item) ? `${item}d` : `${item}ed`; +}; + +const isGroupRelationship = item => { + return ( + item.object1 === 'group' && + item.object2 === 'group' && + item.summary_fields.group.length > 1 + ); +}; + +const buildLabeledLink = (label, link) => { + return ( + + {label} {link} + + ); +}; + +function ActivityStreamDescription({ i18n, activity }) { + const labeledLinks = []; + // Activity stream objects will outlive the resources they reference + // in that case, summary_fields will not be available - show generic error text instead + try { + switch (activity.object_association) { + // explicit role dis+associations + case 'role': { + let { object1, object2 } = activity; + + // if object1 winds up being the role's resource, we need to swap the objects + // in order to make the sentence make sense. + if (activity.object_type === object1) { + object1 = activity.object2; + object2 = activity.object1; + } + + // object1 field is resource targeted by the dis+association + // object2 field is the resource the role is inherited from + // summary_field.role[0] contains ref info about the role + switch (activity.operation) { + // expected outcome: "disassociated role_name from " + case 'disassociate': + if (isGroupRelationship(activity)) { + labeledLinks.push( + buildLabeledLink( + getPastTense(activity.operation), + buildAnchor( + activity.summary_fields.group[1], + object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `${activity.summary_fields.role[0].role_field} from`, + buildAnchor( + activity.summary_fields.group[0], + object1, + activity + ) + ) + ); + } else { + labeledLinks.push( + buildLabeledLink( + getPastTense(activity.operation), + buildAnchor( + activity.summary_fields[object2][0], + object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `${activity.summary_fields.role[0].role_field} from`, + buildAnchor( + activity.summary_fields[object1][0], + object1, + activity + ) + ) + ); + } + break; + // expected outcome: "associated role_name to " + case 'associate': + if (isGroupRelationship(activity)) { + labeledLinks.push( + buildLabeledLink( + getPastTense(activity.operation), + buildAnchor( + activity.summary_fields.group[1], + object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `${activity.summary_fields.role[0].role_field} to`, + buildAnchor( + activity.summary_fields.group[0], + object1, + activity + ) + ) + ); + } else { + labeledLinks.push( + buildLabeledLink( + getPastTense(activity.operation), + buildAnchor( + activity.summary_fields[object2][0], + object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `${activity.summary_fields.role[0].role_field} to`, + buildAnchor( + activity.summary_fields[object1][0], + object1, + activity + ) + ) + ); + } + break; + default: + break; + } + break; + // inherited role dis+associations (logic identical to case 'role') + } + case 'parents': + // object1 field is resource targeted by the dis+association + // object2 field is the resource the role is inherited from + // summary_field.role[0] contains ref info about the role + switch (activity.operation) { + // expected outcome: "disassociated role_name from " + case 'disassociate': + if (isGroupRelationship(activity)) { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object2}`, + buildAnchor( + activity.summary_fields.group[1], + activity.object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `from ${activity.object1}`, + buildAnchor( + activity.summary_fields.group[0], + activity.object1, + activity + ) + ) + ); + } else { + labeledLinks.push( + buildLabeledLink( + getPastTense(activity.operation), + buildAnchor( + activity.summary_fields[activity.object2][0], + activity.object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `${activity.summary_fields.role[0].role_field} from`, + buildAnchor( + activity.summary_fields[activity.object1][0], + activity.object1, + activity + ) + ) + ); + } + break; + // expected outcome: "associated role_name to " + case 'associate': + if (isGroupRelationship(activity)) { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object1}`, + buildAnchor( + activity.summary_fields.group[0], + activity.object1, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `to ${activity.object2}`, + buildAnchor( + activity.summary_fields.group[1], + activity.object2, + activity + ) + ) + ); + } else { + labeledLinks.push( + buildLabeledLink( + getPastTense(activity.operation), + buildAnchor( + activity.summary_fields[activity.object2][0], + activity.object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `${activity.summary_fields.role[0].role_field} to`, + buildAnchor( + activity.summary_fields[activity.object1][0], + activity.object1, + activity + ) + ) + ); + } + break; + default: + break; + } + break; + // CRUD operations / resource on resource dis+associations + default: + switch (activity.operation) { + // expected outcome: "disassociated from " + case 'disassociate': + if (isGroupRelationship(activity)) { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object2}`, + buildAnchor( + activity.summary_fields.group[1], + activity.object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `from ${activity.object1}`, + buildAnchor( + activity.summary_fields.group[0], + activity.object1, + activity + ) + ) + ); + } else if ( + activity.object1 === 'workflow_job_template_node' && + activity.object2 === 'workflow_job_template_node' + ) { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} two nodes on workflow`, + buildAnchor( + activity.summary_fields[activity.object1[0]], + activity.object1, + activity + ) + ) + ); + } else { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object2}`, + buildAnchor( + activity.summary_fields[activity.object2][0], + activity.object2, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `from ${activity.object1}`, + buildAnchor( + activity.summary_fields[activity.object1][0], + activity.object1, + activity + ) + ) + ); + } + break; + // expected outcome "associated to " + case 'associate': + // groups are the only resource that can be associated/disassociated into each other + if (isGroupRelationship(activity)) { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object1}`, + buildAnchor( + activity.summary_fields.group[0], + activity.object1, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `to ${activity.object2}`, + buildAnchor( + activity.summary_fields.group[1], + activity.object2, + activity + ) + ) + ); + } else if ( + activity.object1 === 'workflow_job_template_node' && + activity.object2 === 'workflow_job_template_node' + ) { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} two nodes on workflow`, + buildAnchor( + activity.summary_fields[activity.object1[0]], + activity.object1, + activity + ) + ) + ); + } else { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object1}`, + buildAnchor( + activity.summary_fields[activity.object1][0], + activity.object1, + activity + ) + ) + ); + labeledLinks.push( + buildLabeledLink( + `to ${activity.object2}`, + buildAnchor( + activity.summary_fields[activity.object2][0], + activity.object2, + activity + ) + ) + ); + } + break; + case 'delete': + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object1}`, + buildAnchor(activity.changes, activity.object1, activity) + ) + ); + break; + // expected outcome: "operation " + case 'update': + if ( + activity.object1 === 'workflow_approval' && + activity?.changes?.status?.length === 2 + ) { + let operationText = ''; + if (activity.changes.status[1] === 'successful') { + operationText = i18n._(t`approved`); + } else if (activity.changes.status[1] === 'failed') { + if ( + activity.changes.timed_out && + activity.changes.timed_out[1] === true + ) { + operationText = i18n._(t`timed out`); + } else { + operationText = i18n._(t`denied`); + } + } else { + operationText = i18n._(t`updated`); + } + labeledLinks.push( + buildLabeledLink( + `${operationText} ${activity.object1}`, + buildAnchor( + activity.summary_fields[activity.object1][0], + activity.object1, + activity + ) + ) + ); + } else { + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object1}`, + buildAnchor( + activity.summary_fields[activity.object1][0], + activity.object1, + activity + ) + ) + ); + } + break; + case 'create': + labeledLinks.push( + buildLabeledLink( + `${getPastTense(activity.operation)} ${activity.object1}`, + buildAnchor(activity.changes, activity.object1, activity) + ) + ); + break; + default: + break; + } + break; + } + } catch (err) { + return {i18n._(t`Event summary not available`)}; + } + + return ( + + {labeledLinks.reduce( + (acc, x) => + acc === null ? ( + x + ) : ( + <> + {acc} {x} + + ), + null + )} + + ); +} + +export default withI18n()(ActivityStreamDescription); diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.test.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.test.jsx new file mode 100644 index 0000000000..9f3a2982d0 --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.test.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import ActivityStreamDescription from './ActivityStreamDescription'; + +describe('ActivityStreamDescription', () => { + test('initially renders succesfully', () => { + const description = mountWithContexts( + + ); + expect(description.find('span').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.jsx new file mode 100644 index 0000000000..22559831b2 --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.jsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button, Modal } from '@patternfly/react-core'; +import { SearchPlusIcon } from '@patternfly/react-icons'; + +import { formatDateString } from '../../util/dates'; + +import { DetailList, Detail } from '../../components/DetailList'; +import { VariablesDetail } from '../../components/CodeMirrorInput'; + +function ActivityStreamDetailButton({ i18n, streamItem, user, description }) { + const [isOpen, setIsOpen] = useState(false); + + const setting = streamItem?.summary_fields?.setting; + const changeRows = Math.max( + Object.keys(streamItem?.changes || []).length + 2, + 6 + ); + + return ( + <> + + setIsOpen(false)} + > + + + + + + + {streamItem?.changes && ( + + )} + + + + ); +} + +export default withI18n()(ActivityStreamDetailButton); diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.test.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.test.jsx new file mode 100644 index 0000000000..40dc104117 --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.test.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import ActivityStreamDetailButton from './ActivityStreamDetailButton'; + +jest.mock('../../api/models/ActivityStream'); + +describe('', () => { + test('initially renders succesfully', () => { + mountWithContexts( + Bob} + description={foo} + /> + ); + }); +}); diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.jsx new file mode 100644 index 0000000000..c5463565bb --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.jsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { shape } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { Tr, Td } from '@patternfly/react-table'; +import { t } from '@lingui/macro'; +import { Link } from 'react-router-dom'; + +import { formatDateString } from '../../util/dates'; +import { ActionsTd, ActionItem } from '../../components/PaginatedTable'; + +import ActivityStreamDetailButton from './ActivityStreamDetailButton'; +import ActivityStreamDescription from './ActivityStreamDescription'; + +function ActivityStreamListItem({ streamItem, i18n }) { + ActivityStreamListItem.propTypes = { + streamItem: shape({}).isRequired, + }; + + const buildUser = item => { + let link; + if (item?.summary_fields?.actor?.id) { + link = ( + + {item.summary_fields.actor.username} + + ); + } else if (item?.summary_fields?.actor) { + link = i18n._(t`${item.summary_fields.actor.username} (deleted)`); + } else { + link = i18n._(t`system`); + } + return link; + }; + + const labelId = `check-action-${streamItem.id}`; + const user = buildUser(streamItem); + const description = ; + + return ( + + + + {streamItem.timestamp ? formatDateString(streamItem.timestamp) : ''} + + {user} + + {description} + + + + + + + + ); +} +export default withI18n()(ActivityStreamListItem); diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.test.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.test.jsx new file mode 100644 index 0000000000..c0f03d23ee --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.test.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import ActivityStreamListItem from './ActivityStreamListItem'; + +jest.mock('../../api/models/ActivityStream'); + +describe('', () => { + test('initially renders succesfully', () => { + mountWithContexts( + + + {}} + /> + +
+ ); + }); +}); diff --git a/awx/ui_next/src/screens/ActivityStream/index.js b/awx/ui_next/src/screens/ActivityStream/index.js new file mode 100644 index 0000000000..5c0c72d9ef --- /dev/null +++ b/awx/ui_next/src/screens/ActivityStream/index.js @@ -0,0 +1 @@ +export { default } from './ActivityStream'; diff --git a/awx/ui_next/src/screens/Application/Applications.jsx b/awx/ui_next/src/screens/Application/Applications.jsx index 85995c8512..ae6fa94af1 100644 --- a/awx/ui_next/src/screens/Application/Applications.jsx +++ b/awx/ui_next/src/screens/Application/Applications.jsx @@ -12,7 +12,7 @@ import { import ApplicationsList from './ApplicationsList'; import ApplicationAdd from './ApplicationAdd'; import Application from './Application'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; import { Detail, DetailList } from '../../components/DetailList'; const ApplicationAlert = styled(Alert)` @@ -45,7 +45,10 @@ function Applications({ i18n }) { return ( <> - + ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let wrapper; diff --git a/awx/ui_next/src/screens/Credential/Credentials.jsx b/awx/ui_next/src/screens/Credential/Credentials.jsx index beca652fd5..d883aa0dca 100644 --- a/awx/ui_next/src/screens/Credential/Credentials.jsx +++ b/awx/ui_next/src/screens/Credential/Credentials.jsx @@ -3,7 +3,7 @@ import { Route, Switch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Config } from '../../contexts/Config'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; import Credential from './Credential'; import CredentialAdd from './CredentialAdd'; import { CredentialList } from './CredentialList'; @@ -34,7 +34,10 @@ function Credentials({ i18n }) { return ( <> - + {({ me }) => } diff --git a/awx/ui_next/src/screens/Credential/Credentials.test.jsx b/awx/ui_next/src/screens/Credential/Credentials.test.jsx index f63c9d9b65..e5cb618fb2 100644 --- a/awx/ui_next/src/screens/Credential/Credentials.test.jsx +++ b/awx/ui_next/src/screens/Credential/Credentials.test.jsx @@ -3,6 +3,10 @@ import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Credentials from './Credentials'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let wrapper; @@ -30,8 +34,8 @@ describe('', () => { }, }); - expect(wrapper.find('Crumb').length).toBe(1); - expect(wrapper.find('BreadcrumbHeading').text()).toBe('Credentials'); + expect(wrapper.find('Crumb').length).toBe(0); + expect(wrapper.find('Title').text()).toBe('Credentials'); }); test('should display create new credential breadcrumb heading', () => { @@ -51,8 +55,6 @@ describe('', () => { }); expect(wrapper.find('Crumb').length).toBe(2); - expect(wrapper.find('BreadcrumbHeading').text()).toBe( - 'Create New Credential' - ); + expect(wrapper.find('Title').text()).toBe('Create New Credential'); }); }); diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx index 7cdbbccb00..4eb47c1892 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx @@ -6,7 +6,7 @@ import { Route, Switch } from 'react-router-dom'; import CredentialTypeAdd from './CredentialTypeAdd'; import CredentialTypeList from './CredentialTypeList'; import CredentialType from './CredentialType'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; function CredentialTypes({ i18n }) { const [breadcrumbConfig, setBreadcrumbConfig] = useState({ @@ -33,7 +33,10 @@ function CredentialTypes({ i18n }) { ); return ( <> - + diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypes.test.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypes.test.jsx index 468a55ac7a..bd8eff8e21 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypes.test.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypes.test.jsx @@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import CredentialTypes from './CredentialTypes'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let pageWrapper; let pageSections; diff --git a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx index f60e049631..714e6cf152 100644 --- a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx +++ b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx @@ -18,7 +18,7 @@ import { import useRequest from '../../util/useRequest'; import { DashboardAPI } from '../../api'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; import JobList from '../../components/JobList'; import ContentLoading from '../../components/ContentLoading'; import LineChart from './shared/LineChart'; @@ -117,7 +117,10 @@ function Dashboard({ i18n }) { } return ( - + ({ + ...jest.requireActual('react-router-dom'), +})); describe('', () => { let pageWrapper; diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.jsx index 1f5e0833bf..0fabce1b60 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { useLocation, useRouteMatch } from 'react-router-dom'; +import { useHistory, useLocation, useRouteMatch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Card, PageSection } from '@patternfly/react-core'; @@ -13,9 +13,14 @@ import PaginatedDataList, { ToolbarDeleteButton, } from '../../../components/PaginatedDataList'; import useRequest, { useDeleteItems } from '../../../util/useRequest'; -import { getQSConfig, parseQueryString } from '../../../util/qs'; +import { + encodeQueryString, + getQSConfig, + parseQueryString, +} from '../../../util/qs'; import HostListItem from './HostListItem'; +import SmartInventoryButton from './SmartInventoryButton'; const QS_CONFIG = getQSConfig('host', { page: 1, @@ -24,9 +29,21 @@ const QS_CONFIG = getQSConfig('host', { }); function HostList({ i18n }) { + const history = useHistory(); const location = useLocation(); const match = useRouteMatch(); const [selected, setSelected] = useState([]); + const parsedQueryStrings = parseQueryString(QS_CONFIG, location.search); + const nonDefaultSearchParams = {}; + + Object.keys(parsedQueryStrings).forEach(key => { + if (!QS_CONFIG.defaultParams[key]) { + nonDefaultSearchParams[key] = parsedQueryStrings[key]; + } + }); + + const hasNonDefaultSearchParams = + Object.keys(nonDefaultSearchParams).length > 0; const { result: { hosts, count, actions, relatedSearchableKeys, searchableKeys }, @@ -99,6 +116,14 @@ function HostList({ i18n }) { } }; + const handleSmartInventoryClick = () => { + history.push( + `/inventories/smart_inventory/add?host_filter=${encodeURIComponent( + encodeQueryString(nonDefaultSearchParams) + )}` + ); + }; + const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); @@ -157,6 +182,14 @@ function HostList({ i18n }) { itemsToDelete={selected} pluralizedItemName={i18n._(t`Hosts`)} />, + ...(canAdd + ? [ + handleSmartInventoryClick()} + />, + ] + : []), ]} /> )} diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx index b65c98b67b..5b502979e1 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; import { HostsAPI } from '../../../api'; import { mountWithContexts, @@ -257,7 +258,7 @@ describe('', () => { expect(modal.prop('title')).toEqual('Error!'); }); - test('should show Add button according to permissions', async () => { + test('should show Add and Smart Inventory buttons according to permissions', async () => { let wrapper; await act(async () => { wrapper = mountWithContexts(); @@ -265,9 +266,10 @@ describe('', () => { await waitForLoaded(wrapper); expect(wrapper.find('ToolbarAddButton').length).toBe(1); + expect(wrapper.find('Button[aria-label="Smart Inventory"]').length).toBe(1); }); - test('should hide Add button according to permissions', async () => { + test('should hide Add and Smart Inventory buttons according to permissions', async () => { HostsAPI.readOptions.mockResolvedValue({ data: { actions: { @@ -282,5 +284,44 @@ describe('', () => { await waitForLoaded(wrapper); expect(wrapper.find('ToolbarAddButton').length).toBe(0); + expect(wrapper.find('Button[aria-label="Smart Inventory"]').length).toBe(0); + }); + + test('Smart Inventory button should be disabled when no search params are present', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + expect( + wrapper.find('Button[aria-label="Smart Inventory"]').props().isDisabled + ).toBe(true); + }); + + test('Clicking Smart Inventory button should navigate to smart inventory form with correct query param', async () => { + let wrapper; + const history = createMemoryHistory({ + initialEntries: ['/hosts?host.name__icontains=foo'], + }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + + await waitForLoaded(wrapper); + expect( + wrapper.find('Button[aria-label="Smart Inventory"]').props().isDisabled + ).toBe(false); + await act(async () => { + wrapper.find('Button[aria-label="Smart Inventory"]').simulate('click'); + }); + wrapper.update(); + expect(history.location.pathname).toEqual( + '/inventories/smart_inventory/add' + ); + expect(history.location.search).toEqual( + '?host_filter=name__icontains%3Dfoo' + ); }); }); diff --git a/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.jsx b/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.jsx new file mode 100644 index 0000000000..9e90bbf531 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { func } from 'prop-types'; +import { Button, DropdownItem, Tooltip } from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useKebabifiedMenu } from '../../../contexts/Kebabified'; + +function SmartInventoryButton({ onClick, i18n, isDisabled }) { + const { isKebabified } = useKebabifiedMenu(); + + if (isKebabified) { + return ( + + {i18n._(t`Smart Inventory`)} + + ); + } + + return ( + +
+ +
+
+ ); +} +SmartInventoryButton.propTypes = { + onClick: func.isRequired, +}; + +export default withI18n()(SmartInventoryButton); diff --git a/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.test.jsx b/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.test.jsx new file mode 100644 index 0000000000..b8f47725f5 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryButton from './SmartInventoryButton'; + +describe('', () => { + test('should render button', () => { + const onClick = jest.fn(); + const wrapper = mountWithContexts( + + ); + const button = wrapper.find('button'); + expect(button).toHaveLength(1); + button.simulate('click'); + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/Host/Hosts.jsx b/awx/ui_next/src/screens/Host/Hosts.jsx index f66a28aa94..4c128bc202 100644 --- a/awx/ui_next/src/screens/Host/Hosts.jsx +++ b/awx/ui_next/src/screens/Host/Hosts.jsx @@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Config } from '../../contexts/Config'; -import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; import HostList from './HostList'; import HostAdd from './HostAdd'; @@ -37,7 +37,7 @@ function Hosts({ i18n }) { return ( <> - + diff --git a/awx/ui_next/src/screens/Host/Hosts.test.jsx b/awx/ui_next/src/screens/Host/Hosts.test.jsx index ba199f842f..1c0b9821c0 100644 --- a/awx/ui_next/src/screens/Host/Hosts.test.jsx +++ b/awx/ui_next/src/screens/Host/Hosts.test.jsx @@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Hosts from './Hosts'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { test('initially renders succesfully', () => { mountWithContexts(); @@ -27,7 +31,7 @@ describe('', () => { }, }, }); - expect(wrapper.find('BreadcrumbHeading').length).toBe(1); + expect(wrapper.find('Title').length).toBe(1); wrapper.unmount(); }); diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx index 4fbdd5d9b2..a133bd91f2 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx @@ -9,7 +9,7 @@ import InstanceGroup from './InstanceGroup'; import ContainerGroupAdd from './ContainerGroupAdd'; import ContainerGroup from './ContainerGroup'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; function InstanceGroups({ i18n }) { const [breadcrumbConfig, setBreadcrumbConfig] = useState({ @@ -54,7 +54,10 @@ function InstanceGroups({ i18n }) { ); return ( <> - + diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx index 321b6ca71b..db32e7e4eb 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx @@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import InstanceGroups from './InstanceGroups'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let pageWrapper; let pageSections; diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index cf286c05eb..17ee02b3be 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -1,10 +1,10 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useRef } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Route, Switch } from 'react-router-dom'; import { Config } from '../../contexts/Config'; -import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; import { InventoryList } from './InventoryList'; import Inventory from './Inventory'; import SmartInventory from './SmartInventory'; @@ -12,14 +12,34 @@ import InventoryAdd from './InventoryAdd'; import SmartInventoryAdd from './SmartInventoryAdd'; function Inventories({ i18n }) { - const [breadcrumbConfig, setBreadcrumbConfig] = useState({ + const initScreenHeader = useRef({ '/inventories': i18n._(t`Inventories`), '/inventories/inventory/add': i18n._(t`Create new inventory`), '/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`), }); - const buildBreadcrumbConfig = useCallback( - (inventory, nested, schedule) => { + const [breadcrumbConfig, setScreenHeader] = useState( + initScreenHeader.current + ); + + const [inventory, setInventory] = useState(); + const [nestedObject, setNestedGroup] = useState(); + const [schedule, setSchedule] = useState(); + + const setBreadcrumbConfig = useCallback( + (passedInventory, passedNestedObject, passedSchedule) => { + if (passedInventory && passedInventory.name !== inventory?.name) { + setInventory(passedInventory); + } + if ( + passedNestedObject && + passedNestedObject.name !== nestedObject?.name + ) { + setNestedGroup(passedNestedObject); + } + if (passedSchedule && passedSchedule.name !== schedule?.name) { + setSchedule(passedSchedule); + } if (!inventory) { return; } @@ -32,13 +52,8 @@ function Inventories({ i18n }) { const inventoryGroupsPath = `${inventoryPath}/groups`; const inventorySourcesPath = `${inventoryPath}/sources`; - setBreadcrumbConfig({ - '/inventories': i18n._(t`Inventories`), - '/inventories/inventory/add': i18n._(t`Create new inventory`), - '/inventories/smart_inventory/add': i18n._( - t`Create new smart inventory` - ), - + setScreenHeader({ + ...initScreenHeader.current, [inventoryPath]: `${inventory.name}`, [`${inventoryPath}/access`]: i18n._(t`Access`), [`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`), @@ -47,55 +62,74 @@ function Inventories({ i18n }) { [inventoryHostsPath]: i18n._(t`Hosts`), [`${inventoryHostsPath}/add`]: i18n._(t`Create new host`), - [`${inventoryHostsPath}/${nested?.id}`]: `${nested?.name}`, - [`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), - [`${inventoryHostsPath}/${nested?.id}/details`]: i18n._( + [`${inventoryHostsPath}/${nestedObject?.id}`]: `${nestedObject?.name}`, + [`${inventoryHostsPath}/${nestedObject?.id}/edit`]: i18n._( + t`Edit details` + ), + [`${inventoryHostsPath}/${nestedObject?.id}/details`]: i18n._( t`Host details` ), - [`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._( + [`${inventoryHostsPath}/${nestedObject?.id}/completed_jobs`]: i18n._( t`Completed jobs` ), - [`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`), - [`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`), + [`${inventoryHostsPath}/${nestedObject?.id}/facts`]: i18n._(t`Facts`), + [`${inventoryHostsPath}/${nestedObject?.id}/groups`]: i18n._(t`Groups`), [inventoryGroupsPath]: i18n._(t`Groups`), [`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`), - [`${inventoryGroupsPath}/${nested?.id}`]: `${nested?.name}`, - [`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), - [`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._( + [`${inventoryGroupsPath}/${nestedObject?.id}`]: `${nestedObject?.name}`, + [`${inventoryGroupsPath}/${nestedObject?.id}/edit`]: i18n._( + t`Edit details` + ), + [`${inventoryGroupsPath}/${nestedObject?.id}/details`]: i18n._( t`Group details` ), - [`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`), - [`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._( + [`${inventoryGroupsPath}/${nestedObject?.id}/nested_hosts`]: i18n._( + t`Hosts` + ), + [`${inventoryGroupsPath}/${nestedObject?.id}/nested_hosts/add`]: i18n._( t`Create new host` ), - [`${inventoryGroupsPath}/${nested?.id}/nested_groups`]: i18n._( - t`Groups` + [`${inventoryGroupsPath}/${nestedObject?.id}/nested_groups`]: i18n._( + t`Related Groups` ), - [`${inventoryGroupsPath}/${nested?.id}/nested_groups/add`]: i18n._( + [`${inventoryGroupsPath}/${nestedObject?.id}/nested_groups/add`]: i18n._( t`Create new group` ), [`${inventorySourcesPath}`]: i18n._(t`Sources`), [`${inventorySourcesPath}/add`]: i18n._(t`Create new source`), - [`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`, - [`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`), - [`${inventorySourcesPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), - [`${inventorySourcesPath}/${nested?.id}/schedules`]: i18n._( + [`${inventorySourcesPath}/${nestedObject?.id}`]: `${nestedObject?.name}`, + [`${inventorySourcesPath}/${nestedObject?.id}/details`]: i18n._( + t`Details` + ), + [`${inventorySourcesPath}/${nestedObject?.id}/edit`]: i18n._( + t`Edit details` + ), + [`${inventorySourcesPath}/${nestedObject?.id}/schedules`]: i18n._( t`Schedules` ), - [`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`, - [`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}/details`]: i18n._( + [`${inventorySourcesPath}/${nestedObject?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`, + [`${inventorySourcesPath}/${nestedObject?.id}/schedules/add`]: i18n._( + t`Create New Schedule` + ), + [`${inventorySourcesPath}/${nestedObject?.id}/schedules/${schedule?.id}/details`]: i18n._( t`Schedule details` ), + [`${inventorySourcesPath}/${nestedObject?.id}/notifications`]: i18n._( + t`Notifcations` + ), }); }, - [i18n] + [i18n, inventory, nestedObject, schedule] ); return ( <> - + @@ -106,12 +140,12 @@ function Inventories({ i18n }) { {({ me }) => ( - + )} - + diff --git a/awx/ui_next/src/screens/Inventory/Inventories.test.jsx b/awx/ui_next/src/screens/Inventory/Inventories.test.jsx index ca452f877a..35e1ee5da0 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.test.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.test.jsx @@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Inventories from './Inventories'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let pageWrapper; diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx index 9b82b25a8b..a851fbfdc4 100644 --- a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useCallback } from 'react'; import { Formik, useField, useFormikContext } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { useLocation } from 'react-router-dom'; import { func, shape, arrayOf } from 'prop-types'; import { Form } from '@patternfly/react-core'; import { InstanceGroup } from '../../../types'; @@ -14,6 +15,10 @@ import { FormColumnLayout, FormFullWidthLayout, } from '../../../components/FormLayout'; +import { + toHostFilter, + toSearchParams, +} from '../../../components/Lookup/shared/HostFilterUtils'; import HostFilterLookup from '../../../components/Lookup/HostFilterLookup'; import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup'; import OrganizationLookup from '../../../components/Lookup/OrganizationLookup'; @@ -109,9 +114,17 @@ function SmartInventoryForm({ onCancel, submitError, }) { + const { search } = useLocation(); + const queryParams = new URLSearchParams(search); + const hostFilterFromParams = queryParams.get('host_filter'); + const initialValues = { description: inventory.description || '', - host_filter: inventory.host_filter || '', + host_filter: + inventory.host_filter || + (hostFilterFromParams + ? toHostFilter(toSearchParams(hostFilterFromParams)) + : ''), instance_groups: instanceGroups || [], kind: 'smart', name: inventory.name || '', diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx index 1f3c1127cc..382a4cea80 100644 --- a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; import { mountWithContexts, waitForElement, @@ -135,6 +136,29 @@ describe('', () => { }); }); + test('should pre-fill the host filter when query param present and not editing', async () => { + let wrapper; + const history = createMemoryHistory({ + initialEntries: [ + '/inventories/smart_inventory/add?host_filter=name__icontains%3Dfoo', + ], + }); + await act(async () => { + wrapper = mountWithContexts( + {}} onSubmit={() => {}} />, + { + context: { router: { history } }, + } + ); + }); + wrapper.update(); + const nameChipGroup = wrapper.find( + 'HostFilterLookup ChipGroup[categoryName="Name"]' + ); + expect(nameChipGroup.find('Chip').length).toBe(1); + wrapper.unmount(); + }); + test('should throw content error when option request fails', async () => { let wrapper; InventoriesAPI.readOptions.mockImplementationOnce(() => diff --git a/awx/ui_next/src/screens/Job/Job.test.jsx b/awx/ui_next/src/screens/Job/Job.test.jsx index 7b902984f0..afb1f6e148 100644 --- a/awx/ui_next/src/screens/Job/Job.test.jsx +++ b/awx/ui_next/src/screens/Job/Job.test.jsx @@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Job from './Jobs'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { test('initially renders succesfully', () => { mountWithContexts(); diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx index 9255b27af1..c24a92dc24 100644 --- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx +++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx @@ -11,6 +11,7 @@ import { DetailList, Detail, UserDateDetail, + LaunchedByDetail, } from '../../../components/DetailList'; import { CardBody, CardActionsRow } from '../../../components/Card'; import ChipGroup from '../../../components/ChipGroup'; @@ -53,35 +54,6 @@ const VERBOSITY = { 4: '4 (Connection Debug)', }; -const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => { - const { - created_by: createdBy, - job_template: jobTemplate, - schedule, - } = summary_fields; - const { schedule: relatedSchedule } = related; - - if (!createdBy && !schedule) { - return null; - } - - let link; - let value; - - if (createdBy) { - link = `/users/${createdBy.id}`; - value = createdBy.username; - } else if (relatedSchedule && jobTemplate) { - link = `/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`; - value = schedule.name; - } else { - link = null; - value = schedule.name; - } - - return { link, value }; -}; - function JobDetail({ job, i18n }) { const { created_by, @@ -93,6 +65,7 @@ function JobDetail({ job, i18n }) { workflow_job_template: workflowJobTemplate, labels, project, + source_workflow_job, } = job.summary_fields; const [errorMsg, setErrorMsg] = useState(); const history = useHistory(); @@ -106,9 +79,6 @@ function JobDetail({ job, i18n }) { workflow_job: i18n._(t`Workflow Job`), }; - const { value: launchedByValue, link: launchedByLink } = - getLaunchedByDetails(job) || {}; - const deleteJob = async () => { try { switch (job.type) { @@ -195,17 +165,18 @@ function JobDetail({ job, i18n }) { } /> )} + {source_workflow_job && ( + + {source_workflow_job.id} - {source_workflow_job.name} + + } + /> + )} - {launchedByValue} - ) : ( - launchedByValue - ) - } - /> + {inventory && ( ', () => { kubernetes: false, credential_type_id: 1, }, + source_workflow_job: { + id: 1234, + name: 'Test Source Workflow', + }, }, }} /> @@ -45,6 +49,7 @@ describe('', () => { assertDetail('Started', '8/8/2019, 7:24:18 PM'); assertDetail('Finished', '8/8/2019, 7:24:50 PM'); assertDetail('Job Template', mockJobData.summary_fields.job_template.name); + assertDetail('Source Workflow Job', `1234 - Test Source Workflow`); assertDetail('Job Type', 'Playbook Run'); assertDetail('Launched By', mockJobData.summary_fields.created_by.username); assertDetail('Inventory', mockJobData.summary_fields.inventory.name); diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx index 2bc991b969..88e32be49f 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx @@ -1,6 +1,6 @@ import React, { Component, Fragment } from 'react'; import { withRouter } from 'react-router-dom'; -import { withI18n } from '@lingui/react'; +import { I18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; import { @@ -518,7 +518,7 @@ class JobOutput extends Component { } render() { - const { job, i18n } = this.props; + const { job } = this.props; const { contentError, @@ -596,15 +596,21 @@ class JobOutput extends Component { {deletionError && ( - this.setState({ deletionError: null })} - title={i18n._(t`Job Delete Error`)} - label={i18n._(t`Job Delete Error`)} - > - - + <> + + {({ i18n }) => ( + this.setState({ deletionError: null })} + title={i18n._(t`Job Delete Error`)} + label={i18n._(t`Job Delete Error`)} + > + + + )} + + )}
); @@ -612,4 +618,4 @@ class JobOutput extends Component { } export { JobOutput as _JobOutput }; -export default withI18n()(withRouter(JobOutput)); +export default withRouter(JobOutput); diff --git a/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx b/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx index 599fc402a9..dbf2256fdc 100644 --- a/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx +++ b/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx @@ -6,6 +6,7 @@ import { t } from '@lingui/macro'; import useRequest from '../../util/useRequest'; import { UnifiedJobsAPI } from '../../api'; import ContentError from '../../components/ContentError'; +import ContentLoading from '../../components/ContentLoading'; import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; const NOT_FOUND = 'not found'; @@ -46,8 +47,13 @@ function JobTypeRedirect({ id, path, view, i18n }) { ); } if (isLoading || !job?.id) { - // TODO show loading state - return
Loading...
; + return ( + + + + + + ); } const type = JOB_TYPE_URL_SEGMENTS[job.type]; return ; diff --git a/awx/ui_next/src/screens/Job/Jobs.jsx b/awx/ui_next/src/screens/Job/Jobs.jsx index 83c8d0d11f..318729407a 100644 --- a/awx/ui_next/src/screens/Job/Jobs.jsx +++ b/awx/ui_next/src/screens/Job/Jobs.jsx @@ -3,7 +3,7 @@ import { Route, Switch, useParams, useRouteMatch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { PageSection } from '@patternfly/react-core'; -import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; import Job from './Job'; import JobTypeRedirect from './JobTypeRedirect'; import JobList from '../../components/JobList'; @@ -40,7 +40,7 @@ function Jobs({ i18n }) { return ( <> - + diff --git a/awx/ui_next/src/screens/Job/Jobs.test.jsx b/awx/ui_next/src/screens/Job/Jobs.test.jsx index 5782335404..e866a6d20d 100644 --- a/awx/ui_next/src/screens/Job/Jobs.test.jsx +++ b/awx/ui_next/src/screens/Job/Jobs.test.jsx @@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Jobs from './Jobs'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { test('initially renders succesfully', () => { mountWithContexts(); @@ -27,7 +31,7 @@ describe('', () => { }, }, }); - expect(wrapper.find('BreadcrumbHeading').length).toBe(1); + expect(wrapper.find('Title').length).toBe(1); wrapper.unmount(); }); }); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx index 774eb3e235..94f5a077c5 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx @@ -2,12 +2,13 @@ import React, { Fragment } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; function ManagementJobs({ i18n }) { return ( - diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx index a667a47690..df422fe8ec 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx @@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import ManagementJobs from './ManagementJobs'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let pageWrapper; @@ -17,6 +21,6 @@ describe('', () => { test('initially renders without crashing', () => { expect(pageWrapper.length).toBe(1); - expect(pageWrapper.find('Breadcrumbs').length).toBe(1); + expect(pageWrapper.find('ScreenHeader').length).toBe(1); }); }); diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx index 2ae913202f..9d41166d1d 100644 --- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx @@ -5,7 +5,7 @@ import { t } from '@lingui/macro'; import NotificationTemplateList from './NotificationTemplateList'; import NotificationTemplateAdd from './NotificationTemplateAdd'; import NotificationTemplate from './NotificationTemplate'; -import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; function NotificationTemplates({ i18n }) { const match = useRouteMatch(); @@ -32,7 +32,10 @@ function NotificationTemplates({ i18n }) { return ( <> - + diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx index 9333850cf9..f8b02d4735 100644 --- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx @@ -2,6 +2,10 @@ import React from 'react'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import NotificationTemplates from './NotificationTemplates'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let pageWrapper; let pageSections; diff --git a/awx/ui_next/src/screens/Organization/Organizations.jsx b/awx/ui_next/src/screens/Organization/Organizations.jsx index 5942d75147..6c7b17dc69 100644 --- a/awx/ui_next/src/screens/Organization/Organizations.jsx +++ b/awx/ui_next/src/screens/Organization/Organizations.jsx @@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Config } from '../../contexts/Config'; -import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; import OrganizationsList from './OrganizationList/OrganizationList'; import OrganizationAdd from './OrganizationAdd/OrganizationAdd'; @@ -42,7 +42,10 @@ function Organizations({ i18n }) { return ( - + diff --git a/awx/ui_next/src/screens/Organization/Organizations.test.jsx b/awx/ui_next/src/screens/Organization/Organizations.test.jsx index 4f510463a4..819b86d88d 100644 --- a/awx/ui_next/src/screens/Organization/Organizations.test.jsx +++ b/awx/ui_next/src/screens/Organization/Organizations.test.jsx @@ -5,6 +5,9 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Organizations from './Organizations'; jest.mock('../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); describe('', () => { test('initially renders succesfully', async () => { diff --git a/awx/ui_next/src/screens/Project/Projects.jsx b/awx/ui_next/src/screens/Project/Projects.jsx index a45dd1890b..8063d4c1e6 100644 --- a/awx/ui_next/src/screens/Project/Projects.jsx +++ b/awx/ui_next/src/screens/Project/Projects.jsx @@ -3,7 +3,7 @@ import { Route, withRouter, Switch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader/ScreenHeader'; import ProjectsList from './ProjectList/ProjectList'; import ProjectAdd from './ProjectAdd/ProjectAdd'; @@ -45,7 +45,7 @@ function Projects({ i18n }) { return ( <> - + diff --git a/awx/ui_next/src/screens/Project/Projects.test.jsx b/awx/ui_next/src/screens/Project/Projects.test.jsx index b46f37ae23..4d522a3d14 100644 --- a/awx/ui_next/src/screens/Project/Projects.test.jsx +++ b/awx/ui_next/src/screens/Project/Projects.test.jsx @@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Projects from './Projects'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { test('initially renders succesfully', () => { mountWithContexts(); @@ -27,7 +31,7 @@ describe('', () => { }, }, }); - expect(wrapper.find('BreadcrumbHeading').length).toBe(1); + expect(wrapper.find('Title').length).toBe(1); wrapper.unmount(); }); }); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx index c5b454246f..8c52218c1e 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx @@ -310,7 +310,17 @@ function ProjectForm({ i18n, project, submitError, ...props }) { const { summary_fields = {} } = project; const [contentError, setContentError] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [scmSubFormState, setScmSubFormState] = useState(null); + const [scmSubFormState, setScmSubFormState] = useState({ + scm_url: '', + scm_branch: '', + scm_refspec: '', + credential: '', + scm_clean: false, + scm_delete_on_update: false, + scm_update_on_launch: false, + allow_override: false, + scm_update_cache_timeout: 0, + }); const [scmTypeOptions, setScmTypeOptions] = useState(null); const [credentials, setCredentials] = useState({ scm: { typeId: null, value: null }, diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx index 864142b046..5a4cc23fdc 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx @@ -6,8 +6,8 @@ import { SyncIcon } from '@patternfly/react-icons'; import { number } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; - import useRequest, { useDismissableError } from '../../../util/useRequest'; + import AlertModal from '../../../components/AlertModal'; import ErrorDetail from '../../../components/ErrorDetail'; import { ProjectsAPI } from '../../../api'; diff --git a/awx/ui_next/src/screens/Schedule/AllSchedules.jsx b/awx/ui_next/src/screens/Schedule/AllSchedules.jsx index a55e9df3bb..4e778e9623 100644 --- a/awx/ui_next/src/screens/Schedule/AllSchedules.jsx +++ b/awx/ui_next/src/screens/Schedule/AllSchedules.jsx @@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { PageSection, Card } from '@patternfly/react-core'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; import { ScheduleList } from '../../components/Schedule'; import { SchedulesAPI } from '../../api'; @@ -19,7 +19,8 @@ function AllSchedules({ i18n }) { return ( <> - ({ + ...jest.requireActual('react-router-dom'), +})); + describe('', () => { let wrapper; @@ -30,7 +34,6 @@ describe('', () => { }, }); - expect(wrapper.find('Crumb').length).toBe(1); - expect(wrapper.find('BreadcrumbHeading').text()).toBe('Schedules'); + expect(wrapper.find('Title').text()).toBe('Schedules'); }); }); diff --git a/awx/ui_next/src/screens/Setting/RADIUS/RADIUS.test.jsx b/awx/ui_next/src/screens/Setting/RADIUS/RADIUS.test.jsx index a275e74d95..cf3e15723c 100644 --- a/awx/ui_next/src/screens/Setting/RADIUS/RADIUS.test.jsx +++ b/awx/ui_next/src/screens/Setting/RADIUS/RADIUS.test.jsx @@ -2,12 +2,18 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import RADIUS from './RADIUS'; +import { SettingsProvider } from '../../../contexts/Settings'; import { SettingsAPI } from '../../../api'; +import mockAllOptions from '../shared/data.allSettingOptions.json'; +import RADIUS from './RADIUS'; jest.mock('../../../api/models/Settings'); SettingsAPI.readCategory.mockResolvedValue({ - data: {}, + data: { + RADIUS_SERVER: 'radius.example.org', + RADIUS_PORT: 1812, + RADIUS_SECRET: '$encrypted$', + }, }); describe('', () => { @@ -23,9 +29,14 @@ describe('', () => { initialEntries: ['/settings/radius/details'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('RADIUSDetail').length).toBe(1); }); @@ -35,9 +46,14 @@ describe('', () => { initialEntries: ['/settings/radius/edit'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('RADIUSEdit').length).toBe(1); }); diff --git a/awx/ui_next/src/screens/Setting/RADIUS/RADIUSEdit/RADIUSEdit.jsx b/awx/ui_next/src/screens/Setting/RADIUS/RADIUSEdit/RADIUSEdit.jsx index 624a624f06..a29c259f34 100644 --- a/awx/ui_next/src/screens/Setting/RADIUS/RADIUSEdit/RADIUSEdit.jsx +++ b/awx/ui_next/src/screens/Setting/RADIUS/RADIUSEdit/RADIUSEdit.jsx @@ -1,25 +1,113 @@ -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'; +import React, { useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Formik } from 'formik'; +import { Form } from '@patternfly/react-core'; +import { CardBody } from '../../../../components/Card'; +import ContentError from '../../../../components/ContentError'; +import ContentLoading from '../../../../components/ContentLoading'; +import { FormSubmitError } from '../../../../components/FormField'; +import { FormColumnLayout } from '../../../../components/FormLayout'; +import { useSettings } from '../../../../contexts/Settings'; +import { RevertAllAlert, RevertFormActionGroup } from '../../shared'; +import { EncryptedField, InputField } from '../../shared/SharedFields'; +import useModal from '../../../../util/useModal'; +import useRequest from '../../../../util/useRequest'; +import { SettingsAPI } from '../../../../api'; + +function RADIUSEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { isLoading, error, request: fetchRadius, result: radius } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('radius'); + const mergedData = {}; + Object.keys(data).forEach(key => { + mergedData[key] = options[key]; + mergedData[key].value = data[key]; + }); + return mergedData; + }, [options]), + null + ); + + useEffect(() => { + fetchRadius(); + }, [fetchRadius]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async values => { + await SettingsAPI.updateAll(values); + history.push('/settings/radius/details'); + }, + [history] + ), + null + ); + + const handleSubmit = async form => { + await submitForm(form); + }; + + const handleRevertAll = async () => { + const defaultValues = Object.assign( + ...Object.entries(radius).map(([key, value]) => ({ + [key]: value.default, + })) + ); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/radius/details'); + }; + + const initialValues = fields => + Object.keys(fields).reduce((acc, key) => { + acc[key] = fields[key].value ?? ''; + return acc; + }, {}); -function RADIUSEdit({ i18n }) { return ( - {i18n._(t`Edit form coming soon :)`)} - - - + {isLoading && } + {!isLoading && error && } + {!isLoading && radius && ( + + {formik => ( +
+ + + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} +
+ )}
); } -export default withI18n()(RADIUSEdit); +export default 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 index 934aeb3825..94ea2f9824 100644 --- a/awx/ui_next/src/screens/Setting/RADIUS/RADIUSEdit/RADIUSEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/RADIUS/RADIUSEdit/RADIUSEdit.test.jsx @@ -1,16 +1,149 @@ import React from 'react'; -import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../../testUtils/enzymeHelpers'; +import mockAllOptions from '../../shared/data.allSettingOptions.json'; +import { SettingsProvider } from '../../../../contexts/Settings'; +import { SettingsAPI } from '../../../../api'; import RADIUSEdit from './RADIUSEdit'; +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: { + RADIUS_SERVER: 'radius.mock.org', + RADIUS_PORT: 1812, + RADIUS_SECRET: '$encrypted$', + }, +}); + describe('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/radius/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + test('initially renders without crashing', () => { expect(wrapper.find('RADIUSEdit').length).toBe(1); }); + + test('should display expected form fields', async () => { + expect(wrapper.find('FormGroup[label="RADIUS Server"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="RADIUS Port"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="RADIUS Secret"]').length).toBe(1); + }); + + test('should successfully send default values to api on form revert all', async () => { + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + expect(wrapper.find('RevertAllAlert')).toHaveLength(0); + await act(async () => { + wrapper + .find('button[aria-label="Revert all to default"]') + .invoke('onClick')(); + }); + wrapper.update(); + expect(wrapper.find('RevertAllAlert')).toHaveLength(1); + await act(async () => { + wrapper + .find('RevertAllAlert button[aria-label="Confirm revert all"]') + .invoke('onClick')(); + }); + wrapper.update(); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + RADIUS_SERVER: '', + RADIUS_PORT: 1812, + RADIUS_SECRET: '', + }); + }); + + test('should successfully send request to api on form submission', async () => { + act(() => { + wrapper.find('input#RADIUS_SERVER').simulate('change', { + target: { value: 'radius.new_mock.org', name: 'RADIUS_SERVER' }, + }); + wrapper + .find('FormGroup[fieldId="RADIUS_SECRET"] button[aria-label="Revert"]') + .invoke('onClick')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + RADIUS_SERVER: 'radius.new_mock.org', + RADIUS_PORT: 1812, + RADIUS_SECRET: '', + }); + }); + + test('should navigate to radius detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual('/settings/radius/details'); + }); + + test('should navigate to radius detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/radius/details'); + }); + + test('should display error message on unsuccessful submission', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error)); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + }); + + test('should display ContentError on throw', async () => { + SettingsAPI.readCategory.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/screens/Setting/SAML/SAML.test.jsx b/awx/ui_next/src/screens/Setting/SAML/SAML.test.jsx index 0c662fd927..70d7ae5bb2 100644 --- a/awx/ui_next/src/screens/Setting/SAML/SAML.test.jsx +++ b/awx/ui_next/src/screens/Setting/SAML/SAML.test.jsx @@ -3,11 +3,31 @@ import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { SettingsAPI } from '../../../api'; +import { SettingsProvider } from '../../../contexts/Settings'; +import mockAllOptions from '../shared/data.allSettingOptions.json'; import SAML from './SAML'; jest.mock('../../../api/models/Settings'); SettingsAPI.readCategory.mockResolvedValue({ - data: {}, + data: { + SOCIAL_AUTH_SAML_CALLBACK_URL: 'https://towerhost/sso/complete/saml/', + SOCIAL_AUTH_SAML_METADATA_URL: 'https://towerhost/sso/metadata/saml/', + SOCIAL_AUTH_SAML_SP_ENTITY_ID: '', + SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: '', + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '', + SOCIAL_AUTH_SAML_ORG_INFO: {}, + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {}, + SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {}, + SOCIAL_AUTH_SAML_ENABLED_IDPS: {}, + SOCIAL_AUTH_SAML_SECURITY_CONFIG: {}, + SOCIAL_AUTH_SAML_SP_EXTRA: {}, + SOCIAL_AUTH_SAML_EXTRA_DATA: [], + SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {}, + SOCIAL_AUTH_SAML_TEAM_MAP: {}, + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {}, + SOCIAL_AUTH_SAML_TEAM_ATTR: {}, + SAML_AUTO_CREATE_OBJECTS: false, + }, }); describe('', () => { @@ -23,9 +43,14 @@ describe('', () => { initialEntries: ['/settings/saml/details'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('SAMLDetail').length).toBe(1); }); @@ -35,9 +60,14 @@ describe('', () => { initialEntries: ['/settings/saml/edit'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('SAMLEdit').length).toBe(1); }); diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx index 5d2e54497e..f8fb104ffb 100644 --- a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx @@ -18,6 +18,7 @@ import { SettingDetail } from '../../shared'; function SAMLDetail({ i18n }) { const { me } = useConfig(); const { GET: options } = useSettings(); + options.SOCIAL_AUTH_SAML_SP_PUBLIC_CERT.type = 'certificate'; const { isLoading, error, request, result: saml } = useRequest( useCallback(async () => { 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 index 1afaee4e24..1c3015c9ca 100644 --- a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.test.jsx +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.test.jsx @@ -32,6 +32,7 @@ SettingsAPI.readCategory.mockResolvedValue({ SOCIAL_AUTH_SAML_TEAM_MAP: {}, SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {}, SOCIAL_AUTH_SAML_TEAM_ATTR: {}, + SAML_AUTO_CREATE_OBJECTS: false, }, }); @@ -59,6 +60,11 @@ describe('', () => { }); test('should render expected details', () => { + assertDetail( + wrapper, + 'Automatically Create Organizations and Teams on SAML Login', + 'Off' + ); assertDetail( wrapper, 'SAML Assertion Consumer Service (ACS) URL', @@ -70,7 +76,7 @@ describe('', () => { 'https://towerhost/sso/metadata/saml/' ); assertDetail(wrapper, 'SAML Service Provider Entity ID', 'mock_id'); - assertDetail( + assertVariableDetail( wrapper, 'SAML Service Provider Public Certificate', 'mock_cert' diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.jsx b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.jsx index fc9740b16c..93010d1ee5 100644 --- a/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.jsx +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.jsx @@ -1,25 +1,208 @@ -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'; +import React, { useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Formik } from 'formik'; +import { Form } from '@patternfly/react-core'; +import { CardBody } from '../../../../components/Card'; +import ContentError from '../../../../components/ContentError'; +import ContentLoading from '../../../../components/ContentLoading'; +import { FormSubmitError } from '../../../../components/FormField'; +import { FormColumnLayout } from '../../../../components/FormLayout'; +import { useSettings } from '../../../../contexts/Settings'; +import { RevertAllAlert, RevertFormActionGroup } from '../../shared'; +import { + BooleanField, + FileUploadField, + InputField, + ObjectField, +} from '../../shared/SharedFields'; +import { formatJson } from '../../shared/settingUtils'; +import useModal from '../../../../util/useModal'; +import useRequest from '../../../../util/useRequest'; +import { SettingsAPI } from '../../../../api'; + +function SAMLEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { isLoading, error, request: fetchSAML, result: saml } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('saml'); + const mergedData = {}; + Object.keys(data).forEach(key => { + if (!options[key]) { + return; + } + mergedData[key] = options[key]; + mergedData[key].value = data[key]; + }); + return mergedData; + }, [options]), + null + ); + + useEffect(() => { + fetchSAML(); + }, [fetchSAML]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async values => { + await SettingsAPI.updateAll(values); + history.push('/settings/saml/details'); + }, + [history] + ), + null + ); + + const handleSubmit = async form => { + await submitForm({ + ...form, + SOCIAL_AUTH_SAML_ORG_INFO: formatJson(form.SOCIAL_AUTH_SAML_ORG_INFO), + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: formatJson( + form.SOCIAL_AUTH_SAML_TECHNICAL_CONTACT + ), + SOCIAL_AUTH_SAML_SUPPORT_CONTACT: formatJson( + form.SOCIAL_AUTH_SAML_SUPPORT_CONTACT + ), + SOCIAL_AUTH_SAML_ENABLED_IDPS: formatJson( + form.SOCIAL_AUTH_SAML_ENABLED_IDPS + ), + SOCIAL_AUTH_SAML_ORGANIZATION_MAP: formatJson( + form.SOCIAL_AUTH_SAML_ORGANIZATION_MAP + ), + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: formatJson( + form.SOCIAL_AUTH_SAML_ORGANIZATION_ATTR + ), + SOCIAL_AUTH_SAML_TEAM_MAP: formatJson(form.SOCIAL_AUTH_SAML_TEAM_MAP), + SOCIAL_AUTH_SAML_TEAM_ATTR: formatJson(form.SOCIAL_AUTH_SAML_TEAM_ATTR), + SOCIAL_AUTH_SAML_SECURITY_CONFIG: formatJson( + form.SOCIAL_AUTH_SAML_SECURITY_CONFIG + ), + SOCIAL_AUTH_SAML_SP_EXTRA: formatJson(form.SOCIAL_AUTH_SAML_SP_EXTRA), + SOCIAL_AUTH_SAML_EXTRA_DATA: formatJson(form.SOCIAL_AUTH_SAML_EXTRA_DATA), + }); + }; + + const handleRevertAll = async () => { + const defaultValues = Object.assign( + ...Object.entries(saml).map(([key, value]) => ({ + [key]: value.default, + })) + ); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/saml/details'); + }; + + const initialValues = fields => + Object.keys(fields).reduce((acc, key) => { + if (fields[key].type === 'list' || fields[key].type === 'nested object') { + const emptyDefault = fields[key].type === 'list' ? '[]' : '{}'; + acc[key] = fields[key].value + ? JSON.stringify(fields[key].value, null, 2) + : emptyDefault; + } else { + acc[key] = fields[key].value ?? ''; + } + return acc; + }, {}); -function SAMLEdit({ i18n }) { return ( - {i18n._(t`Edit form coming soon :)`)} - - - + {isLoading && } + {!isLoading && error && } + {!isLoading && saml && ( + + {formik => ( +
+ + + + + + + + + + + + + + + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} +
+ )}
); } -export default withI18n()(SAMLEdit); +export default 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 index d6319d9b2e..858bf814f7 100644 --- a/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.test.jsx @@ -1,16 +1,251 @@ import React from 'react'; -import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../../testUtils/enzymeHelpers'; +import mockAllOptions from '../../shared/data.allSettingOptions.json'; +import { SettingsProvider } from '../../../../contexts/Settings'; +import { SettingsAPI } from '../../../../api'; import SAMLEdit from './SAMLEdit'; +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: { + SAML_AUTO_CREATE_OBJECTS: true, + SOCIAL_AUTH_SAML_CALLBACK_URL: 'https://towerhost/sso/complete/saml/', + SOCIAL_AUTH_SAML_METADATA_URL: 'https://towerhost/sso/metadata/saml/', + SOCIAL_AUTH_SAML_SP_ENTITY_ID: 'mock_id', + SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: 'mock_cert', + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '$encrypted$', + SOCIAL_AUTH_SAML_ORG_INFO: {}, + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: { + givenName: 'Mock User', + emailAddress: 'mockuser@example.com', + }, + SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {}, + SOCIAL_AUTH_SAML_ENABLED_IDPS: {}, + SOCIAL_AUTH_SAML_SP_EXTRA: {}, + SOCIAL_AUTH_SAML_EXTRA_DATA: [], + SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {}, + SOCIAL_AUTH_SAML_TEAM_MAP: {}, + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {}, + SOCIAL_AUTH_SAML_TEAM_ATTR: {}, + SOCIAL_AUTH_SAML_SECURITY_CONFIG: { + requestedAuthnContext: false, + }, + }, +}); + describe('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/saml/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + test('initially renders without crashing', () => { expect(wrapper.find('SAMLEdit').length).toBe(1); }); + + test('should display expected form fields', async () => { + expect( + wrapper.find('FormGroup[label="SAML Service Provider Entity ID"]').length + ).toBe(1); + expect( + wrapper.find( + 'FormGroup[label="Automatically Create Organizations and Teams on SAML Login"]' + ).length + ).toBe(1); + expect( + wrapper.find( + 'FormGroup[label="SAML Service Provider Public Certificate"]' + ).length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Service Provider Private Key"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Service Provider Organization Info"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Service Provider Technical Contact"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Service Provider Support Contact"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Enabled Identity Providers"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Organization Map"]').length + ).toBe(1); + expect(wrapper.find('FormGroup[label="SAML Team Map"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Organization Attribute Mapping"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Team Attribute Mapping"]').length + ).toBe(1); + expect(wrapper.find('FormGroup[label="SAML Security Config"]').length).toBe( + 1 + ); + expect( + wrapper.find( + 'FormGroup[label="SAML Service Provider extra configuration data"]' + ).length + ).toBe(1); + expect( + wrapper.find( + 'FormGroup[label="SAML IDP to extra_data attribute mapping"]' + ).length + ).toBe(1); + }); + + test('should successfully send default values to api on form revert all', async () => { + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + expect(wrapper.find('RevertAllAlert')).toHaveLength(0); + await act(async () => { + wrapper + .find('button[aria-label="Revert all to default"]') + .invoke('onClick')(); + }); + wrapper.update(); + expect(wrapper.find('RevertAllAlert')).toHaveLength(1); + await act(async () => { + wrapper + .find('RevertAllAlert button[aria-label="Confirm revert all"]') + .invoke('onClick')(); + }); + wrapper.update(); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + SAML_AUTO_CREATE_OBJECTS: true, + SOCIAL_AUTH_SAML_ENABLED_IDPS: {}, + SOCIAL_AUTH_SAML_EXTRA_DATA: null, + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {}, + SOCIAL_AUTH_SAML_ORGANIZATION_MAP: null, + SOCIAL_AUTH_SAML_ORG_INFO: {}, + SOCIAL_AUTH_SAML_SP_ENTITY_ID: '', + SOCIAL_AUTH_SAML_SP_EXTRA: null, + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '', + SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: '', + SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {}, + SOCIAL_AUTH_SAML_TEAM_ATTR: {}, + SOCIAL_AUTH_SAML_TEAM_MAP: null, + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {}, + SOCIAL_AUTH_SAML_SECURITY_CONFIG: { + requestedAuthnContext: false, + }, + }); + }); + + test('should successfully send request to api on form submission', async () => { + act(() => { + wrapper.find('input#SOCIAL_AUTH_SAML_SP_ENTITY_ID').simulate('change', { + target: { value: 'new_id', name: 'SOCIAL_AUTH_SAML_SP_ENTITY_ID' }, + }); + wrapper + .find( + 'FormGroup[fieldId="SOCIAL_AUTH_SAML_TECHNICAL_CONTACT"] button[aria-label="Revert"]' + ) + .invoke('onClick')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + SAML_AUTO_CREATE_OBJECTS: true, + SOCIAL_AUTH_SAML_ENABLED_IDPS: {}, + SOCIAL_AUTH_SAML_EXTRA_DATA: [], + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {}, + SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {}, + SOCIAL_AUTH_SAML_ORG_INFO: {}, + SOCIAL_AUTH_SAML_SP_ENTITY_ID: 'new_id', + SOCIAL_AUTH_SAML_SP_EXTRA: {}, + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '$encrypted$', + SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: 'mock_cert', + SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {}, + SOCIAL_AUTH_SAML_TEAM_ATTR: {}, + SOCIAL_AUTH_SAML_TEAM_MAP: {}, + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {}, + SOCIAL_AUTH_SAML_SECURITY_CONFIG: { + requestedAuthnContext: false, + }, + }); + }); + + test('should navigate to saml detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual('/settings/saml/details'); + }); + + test('should navigate to saml detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/saml/details'); + }); + + test('should display error message on unsuccessful submission', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error)); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + }); + + test('should display ContentError on throw', async () => { + SettingsAPI.readCategory.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/screens/Setting/Settings.jsx b/awx/ui_next/src/screens/Setting/Settings.jsx index ae3356f950..a535384afa 100644 --- a/awx/ui_next/src/screens/Setting/Settings.jsx +++ b/awx/ui_next/src/screens/Setting/Settings.jsx @@ -5,7 +5,7 @@ import { t } from '@lingui/macro'; import { PageSection, Card } from '@patternfly/react-core'; import ContentError from '../../components/ContentError'; import ContentLoading from '../../components/ContentLoading'; -import Breadcrumbs from '../../components/Breadcrumbs'; +import ScreenHeader from '../../components/ScreenHeader'; import ActivityStream from './ActivityStream'; import AzureAD from './AzureAD'; import GitHub from './GitHub'; @@ -129,7 +129,7 @@ function Settings({ i18n }) { return ( - + diff --git a/awx/ui_next/src/screens/Setting/Settings.test.jsx b/awx/ui_next/src/screens/Setting/Settings.test.jsx index 24a82986f8..3c63750e71 100644 --- a/awx/ui_next/src/screens/Setting/Settings.test.jsx +++ b/awx/ui_next/src/screens/Setting/Settings.test.jsx @@ -13,6 +13,9 @@ jest.mock('../../api/models/Settings'); SettingsAPI.readAllOptions.mockResolvedValue({ data: mockAllOptions, }); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); describe('', () => { let wrapper; diff --git a/awx/ui_next/src/screens/Setting/TACACS/TACACS.test.jsx b/awx/ui_next/src/screens/Setting/TACACS/TACACS.test.jsx index ec3c69aed0..ad8fcdbe61 100644 --- a/awx/ui_next/src/screens/Setting/TACACS/TACACS.test.jsx +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACS.test.jsx @@ -2,12 +2,20 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { SettingsProvider } from '../../../contexts/Settings'; import { SettingsAPI } from '../../../api'; +import mockAllOptions from '../shared/data.allSettingOptions.json'; import TACACS from './TACACS'; jest.mock('../../../api/models/Settings'); SettingsAPI.readCategory.mockResolvedValue({ - data: {}, + data: { + TACACSPLUS_HOST: 'mockhost', + TACACSPLUS_PORT: 49, + TACACSPLUS_SECRET: '$encrypted$', + TACACSPLUS_SESSION_TIMEOUT: 5, + TACACSPLUS_AUTH_PROTOCOL: 'ascii', + }, }); describe('', () => { @@ -23,9 +31,14 @@ describe('', () => { initialEntries: ['/settings/tacacs/details'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('TACACSDetail').length).toBe(1); }); @@ -35,9 +48,14 @@ describe('', () => { initialEntries: ['/settings/tacacs/edit'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('TACACSEdit').length).toBe(1); }); diff --git a/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.jsx b/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.jsx index 8ec22acb07..a45e59d069 100644 --- a/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.jsx +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.jsx @@ -1,25 +1,130 @@ -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'; +import React, { useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Formik } from 'formik'; +import { Form } from '@patternfly/react-core'; +import { CardBody } from '../../../../components/Card'; +import ContentError from '../../../../components/ContentError'; +import ContentLoading from '../../../../components/ContentLoading'; +import { FormSubmitError } from '../../../../components/FormField'; +import { FormColumnLayout } from '../../../../components/FormLayout'; +import { useSettings } from '../../../../contexts/Settings'; +import { RevertAllAlert, RevertFormActionGroup } from '../../shared'; +import { + ChoiceField, + EncryptedField, + InputField, +} from '../../shared/SharedFields'; +import useModal from '../../../../util/useModal'; +import useRequest from '../../../../util/useRequest'; +import { SettingsAPI } from '../../../../api'; + +function TACACSEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { isLoading, error, request: fetchTACACS, result: tacacs } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('tacacsplus'); + const mergedData = {}; + Object.keys(data).forEach(key => { + mergedData[key] = options[key]; + mergedData[key].value = data[key]; + }); + return mergedData; + }, [options]), + null + ); + + useEffect(() => { + fetchTACACS(); + }, [fetchTACACS]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async values => { + await SettingsAPI.updateAll(values); + history.push('/settings/tacacs/details'); + }, + [history] + ), + null + ); + + const handleSubmit = async form => { + await submitForm(form); + }; + + const handleRevertAll = async () => { + const defaultValues = Object.assign( + ...Object.entries(tacacs).map(([key, value]) => ({ + [key]: value.default, + })) + ); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/tacacs/details'); + }; + + const initialValues = fields => + Object.keys(fields).reduce((acc, key) => { + acc[key] = fields[key].value ?? ''; + return acc; + }, {}); -function TACACSEdit({ i18n }) { return ( - {i18n._(t`Edit form coming soon :)`)} - - - + {isLoading && } + {!isLoading && error && } + {!isLoading && tacacs && ( + + {formik => ( +
+ + + + + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} +
+ )}
); } -export default withI18n()(TACACSEdit); +export default 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 index 529090a34f..a62cdbc289 100644 --- a/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.test.jsx @@ -1,16 +1,166 @@ import React from 'react'; -import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../../testUtils/enzymeHelpers'; +import mockAllOptions from '../../shared/data.allSettingOptions.json'; +import { SettingsProvider } from '../../../../contexts/Settings'; +import { SettingsAPI } from '../../../../api'; import TACACSEdit from './TACACSEdit'; +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: { + TACACSPLUS_HOST: 'mockhost', + TACACSPLUS_PORT: 49, + TACACSPLUS_SECRET: '$encrypted$', + TACACSPLUS_SESSION_TIMEOUT: 123, + TACACSPLUS_AUTH_PROTOCOL: 'ascii', + }, +}); + describe('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/tacacs/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + test('initially renders without crashing', () => { expect(wrapper.find('TACACSEdit').length).toBe(1); }); + + test('should display expected form fields', async () => { + expect(wrapper.find('FormGroup[label="TACACS+ Server"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="TACACS+ Port"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="TACACS+ Secret"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="TACACS+ Auth Session Timeout"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="TACACS+ Authentication Protocol"]').length + ).toBe(1); + }); + + test('should successfully send default values to api on form revert all', async () => { + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + expect(wrapper.find('RevertAllAlert')).toHaveLength(0); + await act(async () => { + wrapper + .find('button[aria-label="Revert all to default"]') + .invoke('onClick')(); + }); + wrapper.update(); + expect(wrapper.find('RevertAllAlert')).toHaveLength(1); + await act(async () => { + wrapper + .find('RevertAllAlert button[aria-label="Confirm revert all"]') + .invoke('onClick')(); + }); + wrapper.update(); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + TACACSPLUS_HOST: '', + TACACSPLUS_PORT: 49, + TACACSPLUS_SECRET: '', + TACACSPLUS_SESSION_TIMEOUT: 5, + TACACSPLUS_AUTH_PROTOCOL: 'ascii', + }); + }); + + test('should successfully send request to api on form submission', async () => { + act(() => { + wrapper.find('input#TACACSPLUS_HOST').simulate('change', { + target: { value: 'new_host', name: 'TACACSPLUS_HOST' }, + }); + wrapper.find('input#TACACSPLUS_PORT').simulate('change', { + target: { value: 999, name: 'TACACSPLUS_PORT' }, + }); + wrapper + .find( + 'FormGroup[fieldId="TACACSPLUS_SECRET"] button[aria-label="Revert"]' + ) + .invoke('onClick')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + TACACSPLUS_HOST: 'new_host', + TACACSPLUS_PORT: 999, + TACACSPLUS_SECRET: '', + TACACSPLUS_SESSION_TIMEOUT: 123, + TACACSPLUS_AUTH_PROTOCOL: 'ascii', + }); + }); + + test('should navigate to tacacs detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual('/settings/tacacs/details'); + }); + + test('should navigate to tacacs detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/tacacs/details'); + }); + + test('should display error message on unsuccessful submission', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error)); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + }); + + test('should display ContentError on throw', async () => { + SettingsAPI.readCategory.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/screens/Setting/UI/UI.test.jsx b/awx/ui_next/src/screens/Setting/UI/UI.test.jsx index ac7a31d608..fc5aafadcd 100644 --- a/awx/ui_next/src/screens/Setting/UI/UI.test.jsx +++ b/awx/ui_next/src/screens/Setting/UI/UI.test.jsx @@ -6,11 +6,17 @@ import { waitForElement, } from '../../../../testUtils/enzymeHelpers'; import { SettingsAPI } from '../../../api'; +import { SettingsProvider } from '../../../contexts/Settings'; +import mockAllOptions from '../shared/data.allSettingOptions.json'; import UI from './UI'; jest.mock('../../../api/models/Settings'); SettingsAPI.readCategory.mockResolvedValue({ - data: {}, + data: { + CUSTOM_LOGIN_INFO: '', + CUSTOM_LOGO: '', + PENDO_TRACKING_STATE: 'off', + }, }); describe('', () => { @@ -26,9 +32,14 @@ describe('', () => { initialEntries: ['/settings/ui/details'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); expect(wrapper.find('UIDetail').length).toBe(1); @@ -39,9 +50,14 @@ describe('', () => { initialEntries: ['/settings/ui/edit'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); expect(wrapper.find('UIEdit').length).toBe(1); diff --git a/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx index c8d0f4df78..937c0c3e66 100644 --- a/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx +++ b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx @@ -1,25 +1,128 @@ -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'; +import React, { useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Formik } from 'formik'; +import { Form } from '@patternfly/react-core'; +import { CardBody } from '../../../../components/Card'; +import ContentError from '../../../../components/ContentError'; +import ContentLoading from '../../../../components/ContentLoading'; +import { FormSubmitError } from '../../../../components/FormField'; +import { FormColumnLayout } from '../../../../components/FormLayout'; +import { useSettings } from '../../../../contexts/Settings'; +import { RevertAllAlert, RevertFormActionGroup } from '../../shared'; +import { + ChoiceField, + FileUploadField, + TextAreaField, +} from '../../shared/SharedFields'; +import useModal from '../../../../util/useModal'; +import useRequest from '../../../../util/useRequest'; +import { SettingsAPI } from '../../../../api'; + +function UIEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { isLoading, error, request: fetchUI, result: uiData } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('ui'); + const mergedData = {}; + Object.keys(data).forEach(key => { + if (!options[key]) { + return; + } + mergedData[key] = options[key]; + mergedData[key].value = data[key]; + }); + return mergedData; + }, [options]), + null + ); + + useEffect(() => { + fetchUI(); + }, [fetchUI]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async values => { + await SettingsAPI.updateAll(values); + history.push('/settings/ui/details'); + }, + [history] + ), + null + ); + + const handleSubmit = async form => { + await submitForm(form); + }; + + const handleRevertAll = async () => { + const defaultValues = Object.assign( + ...Object.entries(uiData).map(([key, value]) => ({ + [key]: value.default, + })) + ); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/ui/details'); + }; -function UIEdit({ i18n }) { return ( - {i18n._(t`Edit form coming soon :)`)} - - - + {formik => ( +
+ + {uiData?.PENDO_TRACKING_STATE?.value !== 'off' && ( + + )} + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} + + )}
); } -export default withI18n()(UIEdit); +export default 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 index c51fb06fa7..adb43a788c 100644 --- a/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.test.jsx @@ -1,16 +1,151 @@ import React from 'react'; -import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../../testUtils/enzymeHelpers'; +import mockAllOptions from '../../shared/data.allSettingOptions.json'; +import { SettingsProvider } from '../../../../contexts/Settings'; +import { SettingsAPI } from '../../../../api'; import UIEdit from './UIEdit'; +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: { + CUSTOM_LOGIN_INFO: 'mock info', + CUSTOM_LOGO: 'data:mock/jpeg;', + PENDO_TRACKING_STATE: 'detailed', + }, +}); + describe('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/ui/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + test('initially renders without crashing', () => { expect(wrapper.find('UIEdit').length).toBe(1); }); + + test('should display expected form fields', async () => { + expect(wrapper.find('FormGroup[label="Custom Login Info"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Custom Logo"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="User Analytics Tracking State"]').length + ).toBe(1); + }); + + test('should successfully send default values to api on form revert all', async () => { + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + expect(wrapper.find('RevertAllAlert')).toHaveLength(0); + await act(async () => { + wrapper + .find('button[aria-label="Revert all to default"]') + .invoke('onClick')(); + }); + wrapper.update(); + expect(wrapper.find('RevertAllAlert')).toHaveLength(1); + await act(async () => { + wrapper + .find('RevertAllAlert button[aria-label="Confirm revert all"]') + .invoke('onClick')(); + }); + wrapper.update(); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + CUSTOM_LOGIN_INFO: '', + CUSTOM_LOGO: '', + PENDO_TRACKING_STATE: 'off', + }); + }); + + test('should successfully send request to api on form submission', async () => { + act(() => { + wrapper.find('textarea#CUSTOM_LOGIN_INFO').simulate('change', { + target: { value: 'new login info', name: 'CUSTOM_LOGIN_INFO' }, + }); + wrapper + .find('FormGroup[fieldId="CUSTOM_LOGO"] button[aria-label="Revert"]') + .invoke('onClick')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + CUSTOM_LOGIN_INFO: 'new login info', + CUSTOM_LOGO: '', + PENDO_TRACKING_STATE: 'detailed', + }); + }); + + test('should navigate to ui detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual('/settings/ui/details'); + }); + + test('should navigate to ui detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/ui/details'); + }); + + test('should display error message on unsuccessful submission', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error)); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + }); + + test('should display ContentError on throw', async () => { + SettingsAPI.readCategory.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/screens/Setting/shared/RevertButton.jsx b/awx/ui_next/src/screens/Setting/shared/RevertButton.jsx index f0fe7e8b72..a997388395 100644 --- a/awx/ui_next/src/screens/Setting/shared/RevertButton.jsx +++ b/awx/ui_next/src/screens/Setting/shared/RevertButton.jsx @@ -13,7 +13,13 @@ const ButtonWrapper = styled.div` } `; -function RevertButton({ i18n, id, defaultValue, isDisabled = false }) { +function RevertButton({ + i18n, + id, + defaultValue, + isDisabled = false, + onRevertCallback = () => null, +}) { const [field, meta, helpers] = useField(id); const initialValue = meta.initialValue ?? ''; const currentValue = field.value; @@ -30,6 +36,7 @@ function RevertButton({ i18n, id, defaultValue, isDisabled = false }) { function handleConfirm() { helpers.setValue(isRevertable ? defaultValue : initialValue); + onRevertCallback(); } const revertTooltipContent = isRevertable diff --git a/awx/ui_next/src/screens/Setting/shared/SettingDetail.jsx b/awx/ui_next/src/screens/Setting/shared/SettingDetail.jsx index ddcb5fb44d..d58c89e721 100644 --- a/awx/ui_next/src/screens/Setting/shared/SettingDetail.jsx +++ b/awx/ui_next/src/screens/Setting/shared/SettingDetail.jsx @@ -34,6 +34,18 @@ export default withI18n()( /> ); break; + case 'certificate': + detail = ( + + ); + break; case 'image': detail = ( ( } @@ -220,6 +225,44 @@ InputField.propTypes = { isRequired: bool, }; +const TextAreaField = withI18n()( + ({ i18n, name, config, isRequired = false }) => { + const validate = isRequired ? required(null, i18n) : null; + const [field, meta] = useField({ name, validate }); + const isValid = !(meta.touched && meta.error); + + return config ? ( + +