diff --git a/Makefile b/Makefile index 069a6ff748..4a4f8bb18e 100644 --- a/Makefile +++ b/Makefile @@ -402,6 +402,7 @@ prepare_collection_venv: COLLECTION_TEST_DIRS ?= awx_collection/test/awx COLLECTION_PACKAGE ?= awx COLLECTION_NAMESPACE ?= awx +COLLECTION_INSTALL = ~/.ansible/collections/ansible_collections/$(COLLECTION_NAMESPACE)/$(COLLECTION_PACKAGE) test_collection: @if [ "$(VENV_BASE)" ]; then \ @@ -414,27 +415,26 @@ flake8_collection: test_collection_all: prepare_collection_venv test_collection flake8_collection -test_collection_sanity: - rm -rf sanity - mkdir -p sanity/ansible_collections/$(COLLECTION_NAMESPACE) - cp -Ra awx_collection sanity/ansible_collections/$(COLLECTION_NAMESPACE)/awx # symlinks do not work - cd sanity/ansible_collections/$(COLLECTION_NAMESPACE)/awx && git init && git add . # requires both this file structure and a git repo, so there you go - cd sanity/ansible_collections/$(COLLECTION_NAMESPACE)/awx && ansible-test sanity - # WARNING: symlinking a collection is fundamentally unstable # this is for rapid development iteration with playbooks, do not use with other test targets symlink_collection: - rm -rf ~/.ansible/collections/ansible_collections/$(COLLECTION_NAMESPACE) - mkdir -p ~/.ansible/collections/ansible_collections/$(COLLECTION_NAMESPACE) - ln -s $(shell pwd)/awx_collection ~/.ansible/collections/ansible_collections/$(COLLECTION_NAMESPACE)/awx + rm -rf $(COLLECTION_INSTALL) + mkdir -p ~/.ansible/collections/ansible_collections/$(COLLECTION_NAMESPACE) # in case it does not exist + ln -s $(shell pwd)/awx_collection $(COLLECTION_INSTALL) build_collection: ansible-playbook -i localhost, awx_collection/template_galaxy.yml -e collection_package=$(COLLECTION_PACKAGE) -e collection_namespace=$(COLLECTION_NAMESPACE) -e collection_version=$(VERSION) ansible-galaxy collection build awx_collection --force --output-path=awx_collection install_collection: build_collection - rm -rf ~/.ansible/collections/ansible_collections/$(COLLECTION_NAMESPACE)/awx - ansible-galaxy collection install awx_collection/$(COLLECTION_NAMESPACE)-awx-$(VERSION).tar.gz + rm -rf $(COLLECTION_INSTALL) + ansible-galaxy collection install awx_collection/$(COLLECTION_NAMESPACE)-$(COLLECTION_PACKAGE)-$(VERSION).tar.gz + +test_collection_sanity: install_collection + cd $(COLLECTION_INSTALL) && ansible-test sanity + +test_collection_integration: install_collection + cd $(COLLECTION_INSTALL) && ansible-test integration test_unit: @if [ "$(VENV_BASE)" ]; then \ diff --git a/awx/api/metadata.py b/awx/api/metadata.py index 144aea3012..dba633a13c 100644 --- a/awx/api/metadata.py +++ b/awx/api/metadata.py @@ -101,8 +101,10 @@ class Metadata(metadata.SimpleMetadata): (choice_value, choice_name) for choice_value, choice_name in field.choices.items() ] if not any(choice in ('', None) for choice, _ in choices): - if field.allow_blank or (field.allow_null and not isinstance(field, ChoiceNullField)): + if field.allow_blank: choices = [("", "---------")] + choices + if field.allow_null and not isinstance(field, ChoiceNullField): + choices = [(None, "---------")] + choices field_info['choices'] = choices # Indicate if a field is write-only. diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 93124ea19e..f4a61f0c43 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4054,6 +4054,13 @@ class JobLaunchSerializer(BaseSerializer): **attrs) self._ignored_fields = rejected + # Basic validation - cannot run a playbook without a playbook + if not template.project: + errors['project'] = _("A project is required to run a job.") + elif template.project.status in ('error', 'failed'): + errors['playbook'] = _("Missing a revision to run due to failed project update.") + + # cannot run a playbook without an inventory if template.inventory and template.inventory.pending_deletion is True: errors['inventory'] = _("The inventory associated with this Job Template is being deleted.") elif 'inventory' in accepted and accepted['inventory'].pending_deletion: diff --git a/awx/main/access.py b/awx/main/access.py index e0274e1fdf..95ec0f20a8 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2429,6 +2429,9 @@ class ScheduleAccess(BaseAccess): def can_add(self, data): if not JobLaunchConfigAccess(self.user).can_add(data): return False + if not data: + return Role.objects.filter(role_field__in=['update_role', 'execute_role'], ancestors__in=self.user.roles.all()).exists() + return self.check_related('unified_job_template', UnifiedJobTemplate, data, role_field='execute_role', mandatory=True) @check_superuser diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index eb351dcd53..77b3871626 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -829,8 +829,10 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana continue host.ansible_facts = ansible_facts host.ansible_facts_modified = now() - ansible_local_system_id = ansible_facts.get('ansible_local', {}).get('insights', {}).get('system_id', None) - ansible_facts_system_id = ansible_facts.get('insights', {}).get('system_id', None) + ansible_local = ansible_facts.get('ansible_local', {}).get('insights', {}) + ansible_facts = ansible_facts.get('insights', {}) + ansible_local_system_id = ansible_local.get('system_id', None) if isinstance(ansible_local, dict) else None + ansible_facts_system_id = ansible_facts.get('system_id', None) if isinstance(ansible_facts, dict) else None if ansible_local_system_id: print("Setting local {}".format(ansible_local_system_id)) logger.debug("Insights system_id {} found for host <{}, {}> in" diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index 71a68b2a9f..f731a03ce0 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -270,21 +270,19 @@ class JobNotificationMixin(object): 'elapsed', 'job_explanation', 'execution_node', 'controller_node', 'allow_simultaneous', 'scm_revision', 'diff_mode', 'job_slice_number', 'job_slice_count', 'custom_virtualenv', 'approval_status', 'approval_node_name', 'workflow_url', - {'host_status_counts': ['skipped', 'ok', 'changed', 'failures', 'dark']}, - {'playbook_counts': ['play_count', 'task_count']}, + {'host_status_counts': ['skipped', 'ok', 'changed', 'failed', 'failures', 'dark' + 'processed', 'rescued', 'ignored']}, {'summary_fields': [{'inventory': ['id', 'name', 'description', 'has_active_failures', 'total_hosts', 'hosts_with_active_failures', 'total_groups', 'has_inventory_sources', 'total_inventory_sources', 'inventory_sources_with_failures', 'organization_id', 'kind']}, {'project': ['id', 'name', 'description', 'status', 'scm_type']}, - {'project_update': ['id', 'name', 'description', 'status', 'failed']}, {'job_template': ['id', 'name', 'description']}, {'unified_job_template': ['id', 'name', 'description', 'unified_job_type']}, {'instance_group': ['name', 'id']}, {'created_by': ['id', 'username', 'first_name', 'last_name']}, - {'labels': ['count', 'results']}, - {'source_workflow_job': ['description', 'elapsed', 'failed', 'id', 'name', 'status']}]}] + {'labels': ['count', 'results']}]}] @classmethod def context_stub(cls): @@ -303,7 +301,7 @@ class JobNotificationMixin(object): 'finished': False, 'force_handlers': False, 'forks': 0, - 'host_status_counts': {'skipped': 1, 'ok': 5, 'changed': 3, 'failures': 0, 'dark': 0}, + 'host_status_counts': {'skipped': 1, 'ok': 5, 'changed': 3, 'failures': 0, 'dark': 0, 'failed': False, 'processed': 0, 'rescued': 0}, 'id': 42, 'job_explanation': 'Sample job explanation', 'job_slice_count': 1, @@ -314,7 +312,6 @@ class JobNotificationMixin(object): 'limit': 'bar_limit', 'modified': datetime.datetime(2018, 12, 13, 6, 4, 0, 0, tzinfo=datetime.timezone.utc), 'name': 'Stub JobTemplate', - 'playbook_counts': {'play_count': 5, 'task_count': 10}, 'playbook': 'ping.yml', 'scm_revision': '', 'skip_tags': '', @@ -347,18 +344,10 @@ class JobNotificationMixin(object): 'name': 'Stub project', 'scm_type': 'git', 'status': 'successful'}, - 'project_update': {'id': 5, 'name': 'Stub Project Update', 'description': 'Project Update', - 'status': 'running', 'failed': False}, 'unified_job_template': {'description': 'Sample unified job template description', 'id': 39, 'name': 'Stub Job Template', - 'unified_job_type': 'job'}, - 'source_workflow_job': {'description': 'Sample workflow job description', - 'elapsed': 0.000, - 'failed': False, - 'id': 88, - 'name': 'Stub WorkflowJobTemplate', - 'status': 'running'}}, + 'unified_job_type': 'job'}}, 'timeout': 0, 'type': 'job', 'url': '/api/v2/jobs/13/', @@ -392,10 +381,20 @@ class JobNotificationMixin(object): The context will contain whitelisted content retrieved from a serialized job object (see JobNotificationMixin.JOB_FIELDS_WHITELIST), the job's friendly name, and a url to the job run.""" - context = {'job': {}, - 'job_friendly_name': self.get_notification_friendly_name(), - 'url': self.get_ui_url(), - 'job_metadata': json.dumps(self.notification_data(), indent=4)} + job_context = {'host_status_counts': {}} + summary = None + if hasattr(self, 'job_host_summaries'): + summary = self.job_host_summaries.first() + if summary: + from awx.api.serializers import JobHostSummarySerializer + summary_data = JobHostSummarySerializer(summary).to_representation(summary) + job_context['host_status_counts'] = summary_data + context = { + 'job': job_context, + 'job_friendly_name': self.get_notification_friendly_name(), + 'url': self.get_ui_url(), + 'job_metadata': json.dumps(self.notification_data(), indent=4) + } def build_context(node, fields, whitelisted_fields): for safe_field in whitelisted_fields: diff --git a/awx/main/tests/functional/api/test_schedules.py b/awx/main/tests/functional/api/test_schedules.py index bdb3534fb3..bae83ef93e 100644 --- a/awx/main/tests/functional/api/test_schedules.py +++ b/awx/main/tests/functional/api/test_schedules.py @@ -365,3 +365,43 @@ def test_zoneinfo(get, admin_user): url = reverse('api:schedule_zoneinfo') r = get(url, admin_user, expect=200) assert {'name': 'America/New_York'} in r.data + + +@pytest.mark.django_db +def test_normal_user_can_create_ujt_schedule(options, post, project, inventory, alice): + jt1 = JobTemplate.objects.create( + name='test-jt', + project=project, + playbook='helloworld.yml', + inventory=inventory + ) + jt1.save() + url = reverse('api:schedule_list') + + # can't create a schedule on JT1 because we don't have execute rights + params = { + 'name': 'My Example Schedule', + 'rrule': RRULE_EXAMPLE, + 'unified_job_template': jt1.id, + } + assert 'POST' not in options(url, user=alice).data['actions'].keys() + post(url, params, alice, expect=403) + + # now we can, because we're allowed to execute JT1 + jt1.execute_role.members.add(alice) + assert 'POST' in options(url, user=alice).data['actions'].keys() + post(url, params, alice, expect=201) + + # can't create a schedule on JT2 because we don't have execute rights + jt2 = JobTemplate.objects.create( + name='test-jt-2', + project=project, + playbook='helloworld.yml', + inventory=inventory + ) + jt2.save() + post(url, { + 'name': 'My Example Schedule', + 'rrule': RRULE_EXAMPLE, + 'unified_job_template': jt2.id, + }, alice, expect=403) diff --git a/awx/main/tests/functional/models/test_notifications.py b/awx/main/tests/functional/models/test_notifications.py index 02a2b28e83..57fd4cca91 100644 --- a/awx/main/tests/functional/models/test_notifications.py +++ b/awx/main/tests/functional/models/test_notifications.py @@ -23,8 +23,11 @@ class TestJobNotificationMixin(object): 'finished': bool, 'force_handlers': bool, 'forks': int, - 'host_status_counts': {'skipped': int, 'ok': int, 'changed': int, - 'failures': int, 'dark': int}, + 'host_status_counts': { + 'skipped': int, 'ok': int, 'changed': int, + 'failures': int, 'dark': int, 'processed': int, + 'rescued': int, 'failed': bool + }, 'id': int, 'job_explanation': str, 'job_slice_count': int, @@ -36,7 +39,6 @@ class TestJobNotificationMixin(object): 'modified': datetime.datetime, 'name': str, 'playbook': str, - 'playbook_counts': {'play_count': int, 'task_count': int}, 'scm_revision': str, 'skip_tags': str, 'start_at_task': str, @@ -68,17 +70,10 @@ class TestJobNotificationMixin(object): 'name': str, 'scm_type': str, 'status': str}, - 'project_update': {'id': int, 'name': str, 'description': str, 'status': str, 'failed': bool}, 'unified_job_template': {'description': str, 'id': int, 'name': str, - 'unified_job_type': str}, - 'source_workflow_job': {'description': str, - 'elapsed': float, - 'failed': bool, - 'id': int, - 'name': str, - 'status': str}}, + 'unified_job_type': str}}, 'timeout': int, 'type': str, diff --git a/awx/main/tests/unit/models/test_jobs.py b/awx/main/tests/unit/models/test_jobs.py index b8964a94f8..f28691f500 100644 --- a/awx/main/tests/unit/models/test_jobs.py +++ b/awx/main/tests/unit/models/test_jobs.py @@ -89,6 +89,27 @@ def test_finish_job_fact_cache_with_existing_data(job, hosts, inventory, mocker, hosts[1].save.assert_called_once_with() +def test_finish_job_fact_cache_with_malformed_fact(job, hosts, inventory, mocker, tmpdir): + fact_cache = os.path.join(tmpdir, 'facts') + modified_times = {} + job.start_job_fact_cache(fact_cache, modified_times, 0) + + for h in hosts: + h.save = mocker.Mock() + + for h in hosts: + filepath = os.path.join(fact_cache, h.name) + with open(filepath, 'w') as f: + json.dump({'ansible_local': {'insights': 'this is an unexpected error from ansible'}}, f) + new_modification_time = time.time() + 3600 + os.utime(filepath, (new_modification_time, new_modification_time)) + + job.finish_job_fact_cache(fact_cache, modified_times) + + for h in hosts: + assert h.insights_system_id is None + + def test_finish_job_fact_cache_with_bad_data(job, hosts, inventory, mocker, tmpdir): fact_cache = os.path.join(tmpdir, 'facts') modified_times = {} diff --git a/awx/main/utils/ansible.py b/awx/main/utils/ansible.py index fa15c47ab1..18011504b9 100644 --- a/awx/main/utils/ansible.py +++ b/awx/main/utils/ansible.py @@ -106,7 +106,7 @@ def could_be_inventory(project_path, dir_path, filename): def read_ansible_config(project_path, variables_of_interest): fnames = ['/etc/ansible/ansible.cfg'] if project_path: - fnames.insert(0, os.path.join(project_path, 'ansible.cfg')) + fnames.append(os.path.join(project_path, 'ansible.cfg')) values = {} try: parser = ConfigParser() diff --git a/awx/settings/production.py b/awx/settings/production.py index b5d4ad4437..cda057c087 100644 --- a/awx/settings/production.py +++ b/awx/settings/production.py @@ -44,7 +44,7 @@ JOBOUTPUT_ROOT = '/var/lib/awx/job_status/' SCHEDULE_METADATA_LOCATION = '/var/lib/awx/.tower_cycle' # Ansible base virtualenv paths and enablement -BASE_VENV_PATH = "/var/lib/awx/venv" +BASE_VENV_PATH = os.path.realpath("/var/lib/awx/venv") ANSIBLE_VENV_PATH = os.path.join(BASE_VENV_PATH, "ansible") # Tower base virtualenv paths and enablement diff --git a/awx/ui_next/package-lock.json b/awx/ui_next/package-lock.json index 75edf45828..75b693e4d8 100644 --- a/awx/ui_next/package-lock.json +++ b/awx/ui_next/package-lock.json @@ -13202,6 +13202,12 @@ "yallist": "^2.1.2" } }, + "luxon": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.22.0.tgz", + "integrity": "sha512-3sLvlfbFo+AxVEY3IqxymbumtnlgBwjDExxK60W3d+trrUzErNAz/PfvPT+mva+vEUrdIodeCOs7fB6zHtRSrw==", + "optional": true + }, "make-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", @@ -15370,9 +15376,9 @@ } }, "react": { - "version": "16.10.2", - "resolved": "https://registry.npmjs.org/react/-/react-16.10.2.tgz", - "integrity": "sha512-MFVIq0DpIhrHFyqLU0S3+4dIcBhhOvBE8bJ/5kHPVOVaGdo0KuiQzpcjCPsf585WvhypqtrMILyoE2th6dT+Lw==", + "version": "16.13.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.13.0.tgz", + "integrity": "sha512-TSavZz2iSLkq5/oiE7gnFzmURKZMltmi193rm5HEoUDAXpzT9Kzw6oNZnGoai/4+fUnm7FqS5dwgUL34TujcWQ==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -15385,20 +15391,20 @@ "integrity": "sha512-D7y9qZ05FbUh9blqECaJMdDwKluQiO3A9xB+fssd5jKM7YAXucRuEOlX32mJQumUvHUkHRHqXIPBjm6g0FW0Ag==" }, "react-dom": { - "version": "16.10.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.10.2.tgz", - "integrity": "sha512-kWGDcH3ItJK4+6Pl9DZB16BXYAZyrYQItU4OMy0jAkv5aNqc+mAKb4TpFtAteI6TJZu+9ZlNhaeNQSVQDHJzkw==", + "version": "16.13.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.0.tgz", + "integrity": "sha512-y09d2c4cG220DzdlFkPTnVvGTszVvNpC73v+AaLGLHbkpy3SSgvYq8x0rNwPJ/Rk/CicTNgk0hbHNw1gMEZAXg==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "scheduler": "^0.16.2" + "scheduler": "^0.19.0" }, "dependencies": { "scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-BqYVWqwz6s1wZMhjFvLfVR5WXP7ZY32M/wYPo04CcuPM7XZEbV2TBNW7Z0UkguPTl0dWMA59VbNXxK6q+pHItg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.0.tgz", + "integrity": "sha512-xowbVaTPe9r7y7RUejcK73/j8tt2jfiyTednOvHbA8JoClvMYCp+r8QegLwK/n8zWQAtZb1fFnER4XLBZXrCxA==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -16217,6 +16223,22 @@ "inherits": "^2.0.1" } }, + "rrule": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.6.4.tgz", + "integrity": "sha512-sLdnh4lmjUqq8liFiOUXD5kWp/FcnbDLPwq5YAc/RrN6120XOPb86Ae5zxF7ttBVq8O3LxjjORMEit1baluahA==", + "requires": { + "luxon": "^1.21.3", + "tslib": "^1.10.0" + }, + "dependencies": { + "tslib": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz", + "integrity": "sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==" + } + } + }, "rst-selector-parser": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz", diff --git a/awx/ui_next/package.json b/awx/ui_next/package.json index e934301d13..2d290c7c15 100644 --- a/awx/ui_next/package.json +++ b/awx/ui_next/package.json @@ -72,11 +72,12 @@ "html-entities": "^1.2.1", "js-yaml": "^3.13.1", "prop-types": "^15.6.2", - "react": "^16.10.2", + "react": "^16.13.0", "react-codemirror2": "^6.0.0", - "react-dom": "^16.10.2", + "react-dom": "^16.13.0", "react-router-dom": "^5.1.2", "react-virtualized": "^9.21.1", + "rrule": "^2.6.4", "styled-components": "^4.2.0" } } diff --git a/awx/ui_next/src/api/mixins/InstanceGroups.mixin.js b/awx/ui_next/src/api/mixins/InstanceGroups.mixin.js index e3b2be1cb5..e6745ac522 100644 --- a/awx/ui_next/src/api/mixins/InstanceGroups.mixin.js +++ b/awx/ui_next/src/api/mixins/InstanceGroups.mixin.js @@ -1,10 +1,9 @@ const InstanceGroupsMixin = parent => class extends parent { readInstanceGroups(resourceId, params) { - return this.http.get( - `${this.baseUrl}${resourceId}/instance_groups/`, - params - ); + return this.http.get(`${this.baseUrl}${resourceId}/instance_groups/`, { + params, + }); } associateInstanceGroup(resourceId, instanceGroupId) { diff --git a/awx/ui_next/src/api/mixins/Schedules.mixin.js b/awx/ui_next/src/api/mixins/Schedules.mixin.js new file mode 100644 index 0000000000..4ea44f418e --- /dev/null +++ b/awx/ui_next/src/api/mixins/Schedules.mixin.js @@ -0,0 +1,12 @@ +const SchedulesMixin = parent => + class extends parent { + readSchedules(id, params) { + return this.http.get(`${this.baseUrl}${id}/schedules/`, { params }); + } + + readScheduleOptions(id) { + return this.http.options(`${this.baseUrl}${id}/schedules/`); + } + }; + +export default SchedulesMixin; diff --git a/awx/ui_next/src/api/models/Groups.js b/awx/ui_next/src/api/models/Groups.js index 019ba0ea94..4c19a44572 100644 --- a/awx/ui_next/src/api/models/Groups.js +++ b/awx/ui_next/src/api/models/Groups.js @@ -4,6 +4,12 @@ class Groups extends Base { constructor(http) { super(http); this.baseUrl = '/api/v2/groups/'; + + this.readAllHosts = this.readAllHosts.bind(this); + } + + readAllHosts(id, params) { + return this.http.get(`${this.baseUrl}${id}/all_hosts/`, { params }); } } diff --git a/awx/ui_next/src/api/models/Inventories.js b/awx/ui_next/src/api/models/Inventories.js index 08640173d4..858e7a390a 100644 --- a/awx/ui_next/src/api/models/Inventories.js +++ b/awx/ui_next/src/api/models/Inventories.js @@ -35,6 +35,10 @@ class Inventories extends InstanceGroupsMixin(Base) { return this.http.options(`${this.baseUrl}${id}/groups/`); } + readHostsOptions(id) { + return this.http.options(`${this.baseUrl}${id}/hosts/`); + } + promoteGroup(inventoryId, groupId) { return this.http.post(`${this.baseUrl}${inventoryId}/groups/`, { id: groupId, diff --git a/awx/ui_next/src/api/models/JobTemplates.js b/awx/ui_next/src/api/models/JobTemplates.js index ef615a65f6..98174bc1e7 100644 --- a/awx/ui_next/src/api/models/JobTemplates.js +++ b/awx/ui_next/src/api/models/JobTemplates.js @@ -1,8 +1,11 @@ import Base from '../Base'; import NotificationsMixin from '../mixins/Notifications.mixin'; import InstanceGroupsMixin from '../mixins/InstanceGroups.mixin'; +import SchedulesMixin from '../mixins/Schedules.mixin'; -class JobTemplates extends InstanceGroupsMixin(NotificationsMixin(Base)) { +class JobTemplates extends SchedulesMixin( + InstanceGroupsMixin(NotificationsMixin(Base)) +) { constructor(http) { super(http); this.baseUrl = '/api/v2/job_templates/'; @@ -63,7 +66,13 @@ class JobTemplates extends InstanceGroupsMixin(NotificationsMixin(Base)) { } readScheduleList(id, params) { - return this.http.get(`${this.baseUrl}${id}/schedules/`, { params }); + return this.http.get(`${this.baseUrl}${id}/schedules/`, { + params, + }); + } + + readSurvey(id) { + return this.http.get(`${this.baseUrl}${id}/survey_spec/`); } } diff --git a/awx/ui_next/src/api/models/Projects.js b/awx/ui_next/src/api/models/Projects.js index 3a4049f9f8..3761c61961 100644 --- a/awx/ui_next/src/api/models/Projects.js +++ b/awx/ui_next/src/api/models/Projects.js @@ -1,8 +1,11 @@ import Base from '../Base'; import NotificationsMixin from '../mixins/Notifications.mixin'; import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin'; +import SchedulesMixin from '../mixins/Schedules.mixin'; -class Projects extends LaunchUpdateMixin(NotificationsMixin(Base)) { +class Projects extends SchedulesMixin( + LaunchUpdateMixin(NotificationsMixin(Base)) +) { constructor(http) { super(http); this.baseUrl = '/api/v2/projects/'; @@ -21,10 +24,6 @@ class Projects extends LaunchUpdateMixin(NotificationsMixin(Base)) { return this.http.get(`${this.baseUrl}${id}/playbooks/`); } - readScheduleList(id, params) { - return this.http.get(`${this.baseUrl}${id}/schedules/`, { params }); - } - readSync(id) { return this.http.get(`${this.baseUrl}${id}/update/`); } diff --git a/awx/ui_next/src/api/models/Schedules.js b/awx/ui_next/src/api/models/Schedules.js index e5581d2875..01bff4891e 100644 --- a/awx/ui_next/src/api/models/Schedules.js +++ b/awx/ui_next/src/api/models/Schedules.js @@ -5,6 +5,14 @@ class Schedules extends Base { super(http); this.baseUrl = '/api/v2/schedules/'; } + + createPreview(data) { + return this.http.post(`${this.baseUrl}preview/`, data); + } + + readCredentials(resourceId, params) { + return this.http.get(`${this.baseUrl}${resourceId}/credentials/`, params); + } } export default Schedules; diff --git a/awx/ui_next/src/api/models/WorkflowJobTemplates.js b/awx/ui_next/src/api/models/WorkflowJobTemplates.js index 691c444379..0725a1a3e4 100644 --- a/awx/ui_next/src/api/models/WorkflowJobTemplates.js +++ b/awx/ui_next/src/api/models/WorkflowJobTemplates.js @@ -1,11 +1,38 @@ import Base from '../Base'; +import SchedulesMixin from '../mixins/Schedules.mixin'; -class WorkflowJobTemplates extends Base { +class WorkflowJobTemplates extends SchedulesMixin(Base) { constructor(http) { super(http); this.baseUrl = '/api/v2/workflow_job_templates/'; } + readWebhookKey(id) { + return this.http.get(`${this.baseUrl}${id}/webhook_key/`); + } + + updateWebhookKey(id) { + return this.http.post(`${this.baseUrl}${id}/webhook_key/`); + } + + associateLabel(id, label, orgId) { + return this.http.post(`${this.baseUrl}${id}/labels/`, { + name: label.name, + organization: orgId, + }); + } + + createNode(id, data) { + return this.http.post(`${this.baseUrl}${id}/workflow_nodes/`, data); + } + + disassociateLabel(id, label) { + return this.http.post(`${this.baseUrl}${id}/labels/`, { + id: label.id, + disassociate: true, + }); + } + launch(id, data) { return this.http.post(`${this.baseUrl}${id}/launch/`, data); } @@ -19,18 +46,6 @@ class WorkflowJobTemplates extends Base { params, }); } - - readWebhookKey(id) { - return this.http.get(`${this.baseUrl}${id}/webhook_key/`); - } - - createNode(id, data) { - return this.http.post(`${this.baseUrl}${id}/workflow_nodes/`, data); - } - - readScheduleList(id, params) { - return this.http.get(`${this.baseUrl}${id}/schedules/`, { params }); - } } export default WorkflowJobTemplates; diff --git a/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx b/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx index d27b85d328..c8049328a8 100644 --- a/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx +++ b/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx @@ -5,7 +5,7 @@ import { t } from '@lingui/macro'; import { useField } from 'formik'; import styled from 'styled-components'; import { Split, SplitItem } from '@patternfly/react-core'; -import { CheckboxField } from '@components/FormField'; +import { CheckboxField, FieldTooltip } from '@components/FormField'; import MultiButtonToggle from '@components/MultiButtonToggle'; import { yamlToJson, jsonToYaml, isJson } from '@util/yaml'; import CodeMirrorInput from './CodeMirrorInput'; @@ -20,7 +20,15 @@ const StyledCheckboxField = styled(CheckboxField)` --pf-c-check__label--FontSize: var(--pf-c-form__label--FontSize); `; -function VariablesField({ i18n, id, name, label, readOnly, promptId }) { +function VariablesField({ + i18n, + id, + name, + label, + readOnly, + promptId, + tooltip, +}) { const [field, meta, helpers] = useField(name); const [mode, setMode] = useState(isJson(field.value) ? JSON_MODE : YAML_MODE); @@ -32,6 +40,7 @@ function VariablesField({ i18n, id, name, label, readOnly, promptId }) { + {tooltip && } { expect(field.prop('hasErrors')).toEqual(true); expect(wrapper.find('.pf-m-error')).toHaveLength(1); }); + it('should render tooltip', () => { + const value = '---\n'; + const wrapper = mount( + + {() => ( + + )} + + ); + expect(wrapper.find('Tooltip').length).toBe(1); + }); it('should submit value through Formik', async () => { const value = '---\nfoo: bar\n'; diff --git a/awx/ui_next/src/components/HostForm/HostForm.jsx b/awx/ui_next/src/components/HostForm/HostForm.jsx new file mode 100644 index 0000000000..5e662ea313 --- /dev/null +++ b/awx/ui_next/src/components/HostForm/HostForm.jsx @@ -0,0 +1,135 @@ +import React, { useState } from 'react'; +import { bool, func, shape } from 'prop-types'; +import { Formik, useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; + +import { Form, FormGroup } from '@patternfly/react-core'; +import FormField, { + FormSubmitError, + FieldTooltip, +} from '@components/FormField'; +import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; +import { VariablesField } from '@components/CodeMirrorInput'; +import { InventoryLookup } from '@components/Lookup'; +import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout'; +import { required } from '@util/validators'; + +const InventoryLookupField = withI18n()(({ i18n, host }) => { + const [inventory, setInventory] = useState( + host ? host.summary_fields.inventory : '' + ); + + const [, inventoryMeta, inventoryHelpers] = useField({ + name: 'inventory', + validate: required(i18n._(t`Select a value for this field`), i18n), + }); + + return ( + + + inventoryHelpers.setTouched()} + tooltip={i18n._(t`Select the inventory that this host will belong to.`)} + isValid={!inventoryMeta.touched || !inventoryMeta.error} + helperTextInvalid={inventoryMeta.error} + onChange={value => { + inventoryHelpers.setValue(value.id); + setInventory(value); + }} + required + touched={inventoryMeta.touched} + error={inventoryMeta.error} + /> + + ); +}); + +const HostForm = ({ + handleCancel, + handleSubmit, + host, + isInventoryVisible, + i18n, + submitError, +}) => { + return ( + + {formik => ( +
+ + + + {isInventoryVisible && } + + + + {submitError && } + + +
+ )} +
+ ); +}; + +HostForm.propTypes = { + handleCancel: func.isRequired, + handleSubmit: func.isRequired, + host: shape({}), + isInventoryVisible: bool, + submitError: shape({}), +}; + +HostForm.defaultProps = { + host: { + name: '', + description: '', + inventory: undefined, + variables: '---\n', + summary_fields: { + inventory: null, + }, + }, + isInventoryVisible: true, + submitError: null, +}; + +export { HostForm as _HostForm }; +export default withI18n()(HostForm); diff --git a/awx/ui_next/src/screens/Host/shared/HostForm.test.jsx b/awx/ui_next/src/components/HostForm/HostForm.test.jsx similarity index 63% rename from awx/ui_next/src/screens/Host/shared/HostForm.test.jsx rename to awx/ui_next/src/components/HostForm/HostForm.test.jsx index 8664b64eb0..c39867641e 100644 --- a/awx/ui_next/src/screens/Host/shared/HostForm.test.jsx +++ b/awx/ui_next/src/components/HostForm/HostForm.test.jsx @@ -6,43 +6,43 @@ import HostForm from './HostForm'; jest.mock('@api'); +const mockData = { + id: 1, + name: 'Foo', + description: 'Bar', + variables: '---', + inventory: 1, + summary_fields: { + inventory: { + id: 1, + name: 'Test Inv', + }, + }, +}; + describe('', () => { - const meConfig = { - me: { - is_superuser: false, - }, - }; - const mockData = { - id: 1, - name: 'Foo', - description: 'Bar', - variables: '---', - inventory: 1, - summary_fields: { - inventory: { - id: 1, - name: 'Test Inv', - }, - }, - }; + let wrapper; + const handleSubmit = jest.fn(); + const handleCancel = jest.fn(); - afterEach(() => { - jest.clearAllMocks(); - }); - - test('changing inputs should update form values', async () => { - let wrapper; + beforeEach(async () => { await act(async () => { wrapper = mountWithContexts( ); }); + }); + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('changing inputs should update form values', async () => { await act(async () => { wrapper.find('input#host-name').simulate('change', { target: { value: 'new foo', name: 'name' }, @@ -59,35 +59,30 @@ describe('', () => { }); test('calls handleSubmit when form submitted', async () => { - const handleSubmit = jest.fn(); - const wrapper = mountWithContexts( - - ); expect(handleSubmit).not.toHaveBeenCalled(); await act(async () => { wrapper.find('button[aria-label="Save"]').simulate('click'); }); - expect(handleSubmit).toHaveBeenCalled(); + expect(handleSubmit).toHaveBeenCalledTimes(1); }); test('calls "handleCancel" when Cancel button is clicked', () => { - const handleCancel = jest.fn(); - - const wrapper = mountWithContexts( - - ); expect(handleCancel).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); - expect(handleCancel).toBeCalled(); + expect(handleCancel).toHaveBeenCalledTimes(1); + }); + + test('should hide inventory lookup field', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(wrapper.find('InventoryLookupField').length).toBe(0); }); }); diff --git a/awx/ui_next/src/screens/Host/shared/index.js b/awx/ui_next/src/components/HostForm/index.js similarity index 100% rename from awx/ui_next/src/screens/Host/shared/index.js rename to awx/ui_next/src/components/HostForm/index.js diff --git a/awx/ui_next/src/components/JobList/JobListItem.jsx b/awx/ui_next/src/components/JobList/JobListItem.jsx index a577c605b8..fc280498cf 100644 --- a/awx/ui_next/src/components/JobList/JobListItem.jsx +++ b/awx/ui_next/src/components/JobList/JobListItem.jsx @@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Button, - DataListAction, + DataListAction as _DataListAction, DataListCell, DataListCheck, DataListItem, @@ -18,6 +18,14 @@ import StatusIcon from '@components/StatusIcon'; import { toTitleCase } from '@util/strings'; import { formatDateString } from '@util/dates'; import { JOB_TYPE_URL_SEGMENTS } from '@constants'; +import styled from 'styled-components'; + +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 16px; + grid-template-columns: 40px; +`; function JobListItem({ i18n, @@ -63,24 +71,26 @@ function JobListItem({ , ]} /> - {job.type !== 'system_job' && - job.summary_fields?.user_capabilities?.start && ( - - - - {({ handleRelaunch }) => ( - - )} - - - + + {job.type !== 'system_job' && + job.summary_fields?.user_capabilities?.start ? ( + + + {({ handleRelaunch }) => ( + + )} + + + ) : ( + '' )} + ); diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index bd3dbe3a5c..37f8a2e3bb 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -6,6 +6,7 @@ import { t } from '@lingui/macro'; import { CredentialsAPI } from '@api'; import { Credential } from '@types'; import { getQSConfig, parseQueryString, mergeParams } from '@util/qs'; +import { FieldTooltip } from '@components/FormField'; import { FormGroup } from '@patternfly/react-core'; import Lookup from '@components/Lookup'; import OptionsList from './shared/OptionsList'; @@ -28,6 +29,7 @@ function CredentialLookup({ value, history, i18n, + tooltip, }) { const [credentials, setCredentials] = useState([]); const [count, setCount] = useState(0); @@ -60,6 +62,7 @@ function CredentialLookup({ label={label} helperTextInvalid={helperTextInvalid} > + {tooltip && } dispatch({ type: 'SELECT_ITEM', item })} deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} /> diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 9a24316c74..1bc80341b9 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -3,7 +3,7 @@ import { withRouter } from 'react-router-dom'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { ToolbarItem } from '@patternfly/react-core'; +import { ToolbarItem, Alert } from '@patternfly/react-core'; import { CredentialsAPI, CredentialTypesAPI } from '@api'; import AnsibleSelect from '@components/AnsibleSelect'; import CredentialChip from '@components/CredentialChip'; @@ -18,9 +18,24 @@ const QS_CONFIG = getQSConfig('credentials', { }); async function loadCredentialTypes() { - const { data } = await CredentialTypesAPI.read(); - const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault']; - return data.results.filter(type => acceptableTypes.includes(type.kind)); + const pageSize = 200; + const acceptableKinds = ['machine', 'cloud', 'net', 'ssh', 'vault']; + // The number of credential types a user can have is unlimited. In practice, it is unlikely for + // users to have more than a page at the maximum request size. + const { + data: { next, results }, + } = await CredentialTypesAPI.read({ page_size: pageSize }); + let nextResults = []; + if (next) { + const { data } = await CredentialTypesAPI.read({ + page_size: pageSize, + page: 2, + }); + nextResults = data.results; + } + return results + .concat(nextResults) + .filter(type => acceptableKinds.includes(type.kind)); } async function loadCredentials(params, selectedCredentialTypeId) { @@ -77,7 +92,7 @@ function MultiCredentialsLookup(props) { /> ); - const isMultiple = selectedType && selectedType.kind === 'vault'; + const isVault = selectedType?.kind === 'vault'; return ( { return ( + {isVault && ( + + )} {credentialTypes && credentialTypes.length > 0 && (
@@ -140,17 +165,18 @@ function MultiCredentialsLookup(props) { key: 'name', }, ]} - multiple={isMultiple} + multiple={isVault} header={i18n._(t`Credentials`)} name="credentials" qsConfig={QS_CONFIG} readOnly={!canDelete} selectItem={item => { - if (isMultiple) { - return dispatch({ type: 'SELECT_ITEM', item }); - } - const selectedItems = state.selectedItems.filter( - i => i.kind !== item.kind + const hasSameVaultID = val => + val?.inputs?.vault_id !== undefined && + val?.inputs?.vault_id === item?.inputs?.vault_id; + const hasSameKind = val => val.kind === item.kind; + const selectedItems = state.selectedItems.filter(i => + isVault ? !hasSameVaultID(i) : !hasSameKind(i) ); selectedItems.push(item); return dispatch({ diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx index fa73edad3a..fa8eb5a15f 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx @@ -12,7 +12,8 @@ describe('', () => { const credentials = [ { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' }, - { name: 'Gatsby', id: 21, kind: 'vault' }, + { name: 'Gatsby', id: 21, kind: 'vault', inputs: { vault_id: '1' } }, + { name: 'Gatsby 2', id: 23, kind: 'vault' }, { name: 'Gatsby', id: 8, kind: 'Machine' }, ]; @@ -80,14 +81,15 @@ describe('', () => { ); }); const chip = wrapper.find('CredentialChip'); - expect(chip).toHaveLength(4); + expect(chip).toHaveLength(5); const button = chip.at(1).find('ChipButton'); await act(async () => { button.invoke('onClick')(); }); expect(onChange).toBeCalledWith([ { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, - { id: 21, kind: 'vault', name: 'Gatsby' }, + { id: 21, inputs: { vault_id: '1' }, kind: 'vault', name: 'Gatsby' }, + { id: 23, kind: 'vault', name: 'Gatsby 2' }, { id: 8, kind: 'Machine', name: 'Gatsby' }, ]); }); @@ -161,12 +163,13 @@ describe('', () => { expect(onChange).toBeCalledWith([ { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' }, - { id: 21, kind: 'vault', name: 'Gatsby' }, + { id: 21, inputs: { vault_id: '1' }, kind: 'vault', name: 'Gatsby' }, + { id: 23, kind: 'vault', name: 'Gatsby 2' }, { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, ]); }); - test('should allow multiple vault credentials', async () => { + test('should allow multiple vault credentials with no vault id', async () => { const onChange = jest.fn(); await act(async () => { wrapper = mountWithContexts( @@ -193,7 +196,7 @@ describe('', () => { act(() => { optionsList.invoke('selectItem')({ id: 5, - kind: 'Machine', + kind: 'vault', name: 'Cred 5', url: 'www.google.com', }); @@ -205,9 +208,115 @@ describe('', () => { expect(onChange).toBeCalledWith([ { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' }, - { id: 21, kind: 'vault', name: 'Gatsby' }, + { id: 21, kind: 'vault', name: 'Gatsby', inputs: { vault_id: '1' } }, + { id: 23, kind: 'vault', name: 'Gatsby 2' }, { id: 8, kind: 'Machine', name: 'Gatsby' }, - { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, + { id: 5, kind: 'vault', name: 'Cred 5', url: 'www.google.com' }, + ]); + }); + + test('should allow multiple vault credentials with different vault ids', async () => { + const onChange = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + const searchButton = await waitForElement(wrapper, 'SearchButton'); + await act(async () => { + searchButton.invoke('onClick')(); + }); + wrapper.update(); + const typeSelect = wrapper.find('AnsibleSelect'); + act(() => { + typeSelect.invoke('onChange')({}, 500); + }); + wrapper.update(); + const optionsList = wrapper.find('OptionsList'); + expect(optionsList.prop('multiple')).toEqual(true); + act(() => { + optionsList.invoke('selectItem')({ + id: 5, + kind: 'vault', + name: 'Cred 5', + url: 'www.google.com', + inputs: { vault_id: '2' }, + }); + }); + wrapper.update(); + act(() => { + wrapper.find('Button[variant="primary"]').invoke('onClick')(); + }); + expect(onChange).toBeCalledWith([ + { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, + { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' }, + { id: 21, kind: 'vault', name: 'Gatsby', inputs: { vault_id: '1' } }, + { id: 23, kind: 'vault', name: 'Gatsby 2' }, + { id: 8, kind: 'Machine', name: 'Gatsby' }, + { + id: 5, + kind: 'vault', + name: 'Cred 5', + url: 'www.google.com', + inputs: { vault_id: '2' }, + }, + ]); + }); + + test('should not select multiple vault credentials with same vault id', async () => { + const onChange = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + const searchButton = await waitForElement(wrapper, 'SearchButton'); + await act(async () => { + searchButton.invoke('onClick')(); + }); + wrapper.update(); + const typeSelect = wrapper.find('AnsibleSelect'); + act(() => { + typeSelect.invoke('onChange')({}, 500); + }); + wrapper.update(); + const optionsList = wrapper.find('OptionsList'); + expect(optionsList.prop('multiple')).toEqual(true); + act(() => { + optionsList.invoke('selectItem')({ + id: 24, + kind: 'vault', + name: 'Cred 5', + url: 'www.google.com', + inputs: { vault_id: '1' }, + }); + }); + wrapper.update(); + act(() => { + wrapper.find('Button[variant="primary"]').invoke('onClick')(); + }); + expect(onChange).toBeCalledWith([ + { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, + { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' }, + { id: 23, kind: 'vault', name: 'Gatsby 2' }, + { id: 8, kind: 'Machine', name: 'Gatsby' }, + { + id: 24, + kind: 'vault', + name: 'Cred 5', + url: 'www.google.com', + inputs: { vault_id: '1' }, + }, ]); }); }); diff --git a/awx/ui_next/src/components/MultiButtonToggle/MultiButtonToggle.jsx b/awx/ui_next/src/components/MultiButtonToggle/MultiButtonToggle.jsx index 9c72cfbab3..b76c4c6a61 100644 --- a/awx/ui_next/src/components/MultiButtonToggle/MultiButtonToggle.jsx +++ b/awx/ui_next/src/components/MultiButtonToggle/MultiButtonToggle.jsx @@ -21,6 +21,7 @@ function MultiButtonToggle({ buttons, value, onChange }) { {buttons && buttons.map(([buttonValue, buttonLabel]) => ( setValue(buttonValue)} variant={buttonValue === value ? 'primary' : 'secondary'} diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx new file mode 100644 index 0000000000..852f811b40 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/Schedule.jsx @@ -0,0 +1,140 @@ +import React, { useEffect, useState } from 'react'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; + +import { + Switch, + Route, + Link, + Redirect, + useLocation, + useParams, +} from 'react-router-dom'; +import { CardActions } from '@patternfly/react-core'; +import { CaretLeftIcon } from '@patternfly/react-icons'; +import CardCloseButton from '@components/CardCloseButton'; +import RoutedTabs from '@components/RoutedTabs'; +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import { TabbedCardHeader } from '@components/Card'; +import { ScheduleDetail } from '@components/Schedule'; +import { SchedulesAPI } from '@api'; + +function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) { + const [schedule, setSchedule] = useState(null); + const [contentLoading, setContentLoading] = useState(true); + const [contentError, setContentError] = useState(null); + const { scheduleId } = useParams(); + const location = useLocation(); + const { pathname } = location; + const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); + + useEffect(() => { + const loadData = async () => { + try { + const { data } = await SchedulesAPI.readDetail(scheduleId); + setSchedule(data); + setBreadcrumb(unifiedJobTemplate, data); + } catch (err) { + setContentError(err); + } finally { + setContentLoading(false); + } + }; + + loadData(); + }, [location.pathname, scheduleId, unifiedJobTemplate, setBreadcrumb]); + + const tabsArray = [ + { + name: ( + <> + + {i18n._(t`Back to Schedules`)} + + ), + link: `${pathRoot}schedules`, + id: 99, + }, + { + name: i18n._(t`Details`), + link: `${pathRoot}schedules/${schedule && schedule.id}/details`, + id: 0, + }, + ]; + + if (contentLoading) { + return ; + } + + if ( + schedule.summary_fields.unified_job_template.id !== + parseInt(unifiedJobTemplate.id, 10) + ) { + return ( + + {schedule && ( + {i18n._(t`View Schedules`)} + )} + + ); + } + + if (contentError) { + return ; + } + + let cardHeader = null; + if ( + location.pathname.includes('schedules/') && + !location.pathname.endsWith('edit') + ) { + cardHeader = ( + + + + + + + ); + } + return ( + <> + {cardHeader} + + + {schedule && [ + { + return ; + }} + />, + ]} + { + return ( + + {unifiedJobTemplate && ( + + {i18n._(t`View Details`)} + + )} + + ); + }} + /> + + + ); +} + +export { Schedule as _Schedule }; +export default withI18n()(Schedule); diff --git a/awx/ui_next/src/components/Schedule/Schedule.test.jsx b/awx/ui_next/src/components/Schedule/Schedule.test.jsx new file mode 100644 index 0000000000..3ed3c5b025 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/Schedule.test.jsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { SchedulesAPI } from '@api'; +import { Route } from 'react-router-dom'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import Schedule from './Schedule'; + +jest.mock('@api/models/Schedules'); + +SchedulesAPI.readDetail.mockResolvedValue({ + data: { + url: '/api/v2/schedules/1', + rrule: + 'DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1', + id: 1, + summary_fields: { + unified_job_template: { + id: 1, + name: 'Mock JT', + description: '', + unified_job_type: 'job', + }, + user_capabilities: { + edit: true, + delete: true, + }, + created_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + modified_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + }, + created: '2020-03-03T20:38:54.210306Z', + modified: '2020-03-03T20:38:54.210336Z', + name: 'Mock JT Schedule', + next_run: '2020-02-20T05:00:00Z', + }, +}); + +SchedulesAPI.createPreview.mockResolvedValue({ + data: { + local: [], + utc: [], + }, +}); + +SchedulesAPI.readCredentials.mockResolvedValue({ + data: { + count: 0, + results: [], + }, +}); + +describe('', () => { + let wrapper; + let history; + const unifiedJobTemplate = { id: 1, name: 'Mock JT' }; + beforeAll(async () => { + history = createMemoryHistory({ + initialEntries: ['/templates/job_template/1/schedules/1/details'], + }); + await act(async () => { + wrapper = mountWithContexts( + ( + {}} + unifiedJobTemplate={unifiedJobTemplate} + /> + )} + />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { id: 1 }, + }, + }, + }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + afterAll(() => { + wrapper.unmount(); + }); + test('renders successfully', async () => { + expect(wrapper.length).toBe(1); + }); + test('expect all tabs to exist, including Back to Schedules', async () => { + expect( + wrapper.find('button[link="/templates/job_template/1/schedules"]').length + ).toBe(1); + expect(wrapper.find('button[aria-label="Details"]').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx new file mode 100644 index 0000000000..c6ae0b97c4 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx @@ -0,0 +1,202 @@ +import React, { useCallback, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { rrulestr } from 'rrule'; +import styled from 'styled-components'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Schedule } from '@types'; +import { Chip, ChipGroup, Title } from '@patternfly/react-core'; +import { CardBody } from '@components/Card'; +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import CredentialChip from '@components/CredentialChip'; +import { DetailList, Detail, UserDateDetail } from '@components/DetailList'; +import { ScheduleOccurrences, ScheduleToggle } from '@components/Schedule'; +import { formatDateString } from '@util/dates'; +import useRequest from '@util/useRequest'; +import { SchedulesAPI } from '@api'; + +const PromptTitle = styled(Title)` + --pf-c-title--m-md--FontWeight: 700; +`; + +function ScheduleDetail({ schedule, i18n }) { + const { + id, + created, + description, + diff_mode, + dtend, + dtstart, + job_tags, + job_type, + inventory, + limit, + modified, + name, + next_run, + rrule, + scm_branch, + skip_tags, + summary_fields, + timezone, + } = schedule; + + const { + result: [credentials, preview], + isLoading, + error, + request: fetchCredentialsAndPreview, + } = useRequest( + useCallback(async () => { + const [{ data }, { data: schedulePreview }] = await Promise.all([ + SchedulesAPI.readCredentials(id), + SchedulesAPI.createPreview({ + rrule, + }), + ]); + return [data.results, schedulePreview]; + }, [id, rrule]), + [] + ); + + useEffect(() => { + fetchCredentialsAndPreview(); + }, [fetchCredentialsAndPreview]); + + const rule = rrulestr(rrule); + const repeatFrequency = + rule.options.freq === 3 && dtstart === dtend + ? i18n._(t`None (Run Once)`) + : rule.toText().replace(/^\w/, c => c.toUpperCase()); + const showPromptedFields = + (credentials && credentials.length > 0) || + job_type || + (inventory && summary_fields.inventory) || + scm_branch || + limit || + typeof diff_mode === 'boolean' || + (job_tags && job_tags.length > 0) || + (skip_tags && skip_tags.length > 0); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + + + + + + + + + + + + + {showPromptedFields && ( + <> + + {i18n._(t`Prompted Fields`)} + + + {inventory && summary_fields.inventory && ( + + {summary_fields.inventory.name} + + } + /> + )} + + + {typeof diff_mode === 'boolean' && ( + + )} + {credentials && credentials.length > 0 && ( + + {credentials.map(c => ( + + ))} + + } + /> + )} + {job_tags && job_tags.length > 0 && ( + + {job_tags.split(',').map(jobTag => ( + + {jobTag} + + ))} + + } + /> + )} + {skip_tags && skip_tags.length > 0 && ( + + {skip_tags.split(',').map(skipTag => ( + + {skipTag} + + ))} + + } + /> + )} + + )} + + + ); +} + +ScheduleDetail.propTypes = { + schedule: Schedule.isRequired, +}; + +export default withI18n()(ScheduleDetail); diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.test.jsx new file mode 100644 index 0000000000..d67ff21fc2 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.test.jsx @@ -0,0 +1,261 @@ +import React from 'react'; +import { SchedulesAPI } from '@api'; +import { Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import ScheduleDetail from './ScheduleDetail'; + +jest.mock('@api/models/Schedules'); + +const schedule = { + url: '/api/v2/schedules/1', + rrule: + 'DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1', + id: 1, + summary_fields: { + unified_job_template: { + id: 1, + name: 'Mock JT', + description: '', + unified_job_type: 'job', + }, + user_capabilities: { + edit: true, + delete: true, + }, + created_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + modified_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + inventory: { + id: 1, + name: 'Test Inventory', + }, + }, + created: '2020-03-03T20:38:54.210306Z', + modified: '2020-03-03T20:38:54.210336Z', + name: 'Mock JT Schedule', + enabled: false, + description: 'A good schedule', + timezone: 'America/New_York', + dtstart: '2020-03-16T04:00:00Z', + dtend: '2020-07-06T04:00:00Z', + next_run: '2020-03-16T04:00:00Z', +}; + +SchedulesAPI.createPreview.mockResolvedValue({ + data: { + local: [], + utc: [], + }, +}); + +describe('', () => { + let wrapper; + const history = createMemoryHistory({ + initialEntries: ['/templates/job_template/1/schedules/1/details'], + }); + afterEach(() => { + wrapper.unmount(); + }); + test('details should render with the proper values without prompts', async () => { + SchedulesAPI.readCredentials.mockResolvedValueOnce({ + data: { + count: 0, + results: [], + }, + }); + await act(async () => { + wrapper = mountWithContexts( + } + />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { params: { id: 1 } }, + }, + }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect( + wrapper + .find('Detail[label="Name"]') + .find('dd') + .text() + ).toBe('Mock JT Schedule'); + expect( + wrapper + .find('Detail[label="Description"]') + .find('dd') + .text() + ).toBe('A good schedule'); + expect(wrapper.find('Detail[label="First Run"]').length).toBe(1); + expect(wrapper.find('Detail[label="Next Run"]').length).toBe(1); + expect(wrapper.find('Detail[label="Last Run"]').length).toBe(1); + expect( + wrapper + .find('Detail[label="Local Time Zone"]') + .find('dd') + .text() + ).toBe('America/New_York'); + expect(wrapper.find('Detail[label="Repeat Frequency"]').length).toBe(1); + expect(wrapper.find('Detail[label="Created"]').length).toBe(1); + expect(wrapper.find('Detail[label="Last Modified"]').length).toBe(1); + expect(wrapper.find('Title[children="Prompted Fields"]').length).toBe(0); + expect(wrapper.find('Detail[label="Job Type"]').length).toBe(0); + expect(wrapper.find('Detail[label="Inventory"]').length).toBe(0); + expect(wrapper.find('Detail[label="SCM Branch"]').length).toBe(0); + expect(wrapper.find('Detail[label="Limit"]').length).toBe(0); + expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(0); + expect(wrapper.find('Detail[label="Credentials"]').length).toBe(0); + expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0); + expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(0); + }); + test('details should render with the proper values with prompts', async () => { + SchedulesAPI.readCredentials.mockResolvedValueOnce({ + data: { + count: 2, + results: [ + { + id: 1, + name: 'Cred 1', + }, + { + id: 2, + name: 'Cred 2', + }, + ], + }, + }); + const scheduleWithPrompts = { + ...schedule, + job_type: 'run', + inventory: 1, + job_tags: 'tag1', + skip_tags: 'tag2', + scm_branch: 'foo/branch', + limit: 'localhost', + diff_mode: true, + verbosity: 1, + }; + await act(async () => { + wrapper = mountWithContexts( + } + />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { params: { id: 1 } }, + }, + }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect( + wrapper + .find('Detail[label="Name"]') + .find('dd') + .text() + ).toBe('Mock JT Schedule'); + expect( + wrapper + .find('Detail[label="Description"]') + .find('dd') + .text() + ).toBe('A good schedule'); + expect(wrapper.find('Detail[label="First Run"]').length).toBe(1); + expect(wrapper.find('Detail[label="Next Run"]').length).toBe(1); + expect(wrapper.find('Detail[label="Last Run"]').length).toBe(1); + expect( + wrapper + .find('Detail[label="Local Time Zone"]') + .find('dd') + .text() + ).toBe('America/New_York'); + expect(wrapper.find('Detail[label="Repeat Frequency"]').length).toBe(1); + expect(wrapper.find('Detail[label="Created"]').length).toBe(1); + expect(wrapper.find('Detail[label="Last Modified"]').length).toBe(1); + expect(wrapper.find('Title[children="Prompted Fields"]').length).toBe(1); + expect( + wrapper + .find('Detail[label="Job Type"]') + .find('dd') + .text() + ).toBe('run'); + expect(wrapper.find('Detail[label="Inventory"]').length).toBe(1); + expect( + wrapper + .find('Detail[label="SCM Branch"]') + .find('dd') + .text() + ).toBe('foo/branch'); + expect( + wrapper + .find('Detail[label="Limit"]') + .find('dd') + .text() + ).toBe('localhost'); + expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(1); + expect(wrapper.find('Detail[label="Credentials"]').length).toBe(1); + expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(1); + expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(1); + }); + test('error shown when error encountered fetching credentials', async () => { + SchedulesAPI.readCredentials.mockRejectedValueOnce( + new Error({ + response: { + config: { + method: 'get', + url: '/api/v2/job_templates/1/schedules/1/credentials', + }, + data: 'An error occurred', + status: 500, + }, + }) + ); + await act(async () => { + wrapper = mountWithContexts( + } + />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { params: { id: 1 } }, + }, + }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); +}); diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/index.js b/awx/ui_next/src/components/Schedule/ScheduleDetail/index.js new file mode 100644 index 0000000000..dc7a5b7477 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/index.js @@ -0,0 +1 @@ +export { default } from './ScheduleDetail'; diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleList.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx similarity index 78% rename from awx/ui_next/src/components/ScheduleList/ScheduleList.jsx rename to awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx index e24e46e0b6..999f28a6b9 100644 --- a/awx/ui_next/src/components/ScheduleList/ScheduleList.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; +import { bool, func } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { SchedulesAPI } from '@api'; @@ -7,6 +8,7 @@ import AlertModal from '@components/AlertModal'; import ErrorDetail from '@components/ErrorDetail'; import DataListToolbar from '@components/DataListToolbar'; import PaginatedDataList, { + ToolbarAddButton, ToolbarDeleteButton, } from '@components/PaginatedDataList'; import useRequest, { useDeleteItems } from '@util/useRequest'; @@ -19,28 +21,40 @@ const QS_CONFIG = getQSConfig('schedule', { order_by: 'unified_job_template__polymorphic_ctype__model', }); -function ScheduleList({ i18n, loadSchedules }) { +function ScheduleList({ + i18n, + loadSchedules, + loadScheduleOptions, + hideAddButton, +}) { const [selected, setSelected] = useState([]); const location = useLocation(); const { - result: { schedules, itemCount }, + result: { schedules, itemCount, actions }, error: contentError, isLoading, request: fetchSchedules, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const response = loadSchedules(params); - const { - data: { count, results }, - } = await response; - return { itemCount: count, schedules: results }; - }, [location, loadSchedules]), + const [ + { + data: { count, results }, + }, + scheduleActions, + ] = await Promise.all([loadSchedules(params), loadScheduleOptions()]); + return { + schedules: results, + itemCount: count, + actions: scheduleActions.data.actions, + }; + }, [location, loadSchedules, loadScheduleOptions]), { schedules: [], itemCount: 0, + actions: {}, } ); @@ -84,6 +98,11 @@ function ScheduleList({ i18n, loadSchedules }) { setSelected([]); }; + const canAdd = + actions && + Object.prototype.hasOwnProperty.call(actions, 'POST') && + !hideAddButton; + return ( <> , + ] + : []), SchedulesAPI.read(params); +const loadScheduleOptions = () => SchedulesAPI.readOptions(); describe('ScheduleList', () => { let wrapper; @@ -21,11 +33,12 @@ describe('ScheduleList', () => { describe('read call successful', () => { beforeAll(async () => { - SchedulesAPI.read.mockResolvedValue({ data: mockSchedules }); - const loadSchedules = params => SchedulesAPI.read(params); await act(async () => { wrapper = mountWithContexts( - + ); }); wrapper.update(); @@ -40,6 +53,10 @@ describe('ScheduleList', () => { expect(wrapper.find('ScheduleListItem').length).toBe(5); }); + test('should show add button', () => { + expect(wrapper.find('ToolbarAddButton').length).toBe(1); + }); + test('should check and uncheck the row item', async () => { expect( wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked @@ -153,11 +170,32 @@ describe('ScheduleList', () => { }); }); + describe('hidden add button', () => { + test('should hide add button when flag is passed', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expect(wrapper.find('ToolbarAddButton').length).toBe(0); + }); + }); + describe('read call unsuccessful', () => { test('should show content error when read call unsuccessful', async () => { SchedulesAPI.read.mockRejectedValue(new Error()); await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts( + + ); }); wrapper.update(); expect(wrapper.find('ContentError').length).toBe(1); diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleListItem.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx similarity index 95% rename from awx/ui_next/src/components/ScheduleList/ScheduleListItem.jsx rename to awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx index 980cecfac5..ff2b3d05b5 100644 --- a/awx/ui_next/src/components/ScheduleList/ScheduleListItem.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx @@ -15,16 +15,16 @@ import { } from '@patternfly/react-core'; import { PencilAltIcon } from '@patternfly/react-icons'; import { DetailList, Detail } from '@components/DetailList'; +import { ScheduleToggle } from '@components/Schedule'; import styled from 'styled-components'; import { Schedule } from '@types'; import { formatDateString } from '@util/dates'; -import ScheduleToggle from './ScheduleToggle'; const DataListAction = styled(_DataListAction)` align-items: center; display: grid; grid-gap: 16px; - grid-template-columns: auto 40px; + grid-template-columns: 92px 40px; `; function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) { @@ -104,7 +104,7 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) { key="actions" > - {schedule.summary_fields.user_capabilities.edit && ( + {schedule.summary_fields.user_capabilities.edit ? ( + ) : ( + '' )} , ]} diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleListItem.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.test.jsx similarity index 100% rename from awx/ui_next/src/components/ScheduleList/ScheduleListItem.test.jsx rename to awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.test.jsx diff --git a/awx/ui_next/src/components/ScheduleList/index.js b/awx/ui_next/src/components/Schedule/ScheduleList/index.js similarity index 100% rename from awx/ui_next/src/components/ScheduleList/index.js rename to awx/ui_next/src/components/Schedule/ScheduleList/index.js diff --git a/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.jsx b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.jsx new file mode 100644 index 0000000000..13e7835eec --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.jsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import { shape } from 'prop-types'; +import styled from 'styled-components'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { formatDateString, formatDateStringUTC } from '@util/dates'; +import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core'; +import { DetailName, DetailValue } from '@components/DetailList'; +import MultiButtonToggle from '@components/MultiButtonToggle'; + +const OccurrencesLabel = styled.div` + display: inline-block; + font-size: var(--pf-c-form__label--FontSize); + font-weight: var(--pf-c-form__label--FontWeight); + line-height: var(--pf-c-form__label--LineHeight); + color: var(--pf-c-form__label--Color); + + span:first-of-type { + font-weight: var(--pf-global--FontWeight--bold); + margin-right: 10px; + } +`; + +function ScheduleOccurrences({ preview = { local: [], utc: [] }, i18n }) { + const [mode, setMode] = useState('local'); + + if (preview.local.length < 2) { + return null; + } + + return ( + <> + + + + + {i18n._(t`Occurrences`)} + {i18n._(t`(Limited to first 10)`)} + + + + setMode(newMode)} + /> + + + + + {preview[mode].map(dateStr => ( +
+ {mode === 'local' + ? formatDateString(dateStr) + : formatDateStringUTC(dateStr)} +
+ ))} +
+ + ); +} + +ScheduleOccurrences.propTypes = { + preview: shape(), +}; + +ScheduleOccurrences.defaultProps = { + preview: { local: [], utc: [] }, +}; + +export default withI18n()(ScheduleOccurrences); diff --git a/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.test.jsx new file mode 100644 index 0000000000..46cc795995 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.test.jsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import ScheduleOccurrences from './ScheduleOccurrences'; + +describe('', () => { + let wrapper; + describe('At least two dates passed in', () => { + beforeAll(() => { + wrapper = mountWithContexts( + + ); + }); + afterAll(() => { + wrapper.unmount(); + }); + test('Local option initially set', async () => { + expect(wrapper.find('MultiButtonToggle').props().value).toBe('local'); + }); + test('It renders the correct number of dates', async () => { + expect(wrapper.find('dd').children().length).toBe(2); + }); + test('Clicking UTC button toggles the dates to utc', async () => { + wrapper.find('button[aria-label="UTC"]').simulate('click'); + expect(wrapper.find('MultiButtonToggle').props().value).toBe('utc'); + expect(wrapper.find('dd').children().length).toBe(2); + expect( + wrapper + .find('dd') + .children() + .at(0) + .text() + ).toBe('3/16/2020, 4:00:00 AM'); + expect( + wrapper + .find('dd') + .children() + .at(1) + .text() + ).toBe('3/30/2020, 4:00:00 AM'); + }); + }); + describe('Only one date passed in', () => { + test('Component should not render chldren', async () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find('ScheduleOccurrences').children().length).toBe(0); + wrapper.unmount(); + }); + }); +}); diff --git a/awx/ui_next/src/components/Schedule/ScheduleOccurrences/index.js b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/index.js new file mode 100644 index 0000000000..2b21bebcce --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/index.js @@ -0,0 +1 @@ +export { default } from './ScheduleOccurrences'; diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleToggle.jsx b/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx similarity index 100% rename from awx/ui_next/src/components/ScheduleList/ScheduleToggle.jsx rename to awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleToggle.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.test.jsx similarity index 100% rename from awx/ui_next/src/components/ScheduleList/ScheduleToggle.test.jsx rename to awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.test.jsx diff --git a/awx/ui_next/src/components/Schedule/ScheduleToggle/index.js b/awx/ui_next/src/components/Schedule/ScheduleToggle/index.js new file mode 100644 index 0000000000..65573a4fde --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleToggle/index.js @@ -0,0 +1 @@ +export { default } from './ScheduleToggle'; diff --git a/awx/ui_next/src/components/Schedule/Schedules.jsx b/awx/ui_next/src/components/Schedule/Schedules.jsx new file mode 100644 index 0000000000..4866aba404 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/Schedules.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { Switch, Route, useRouteMatch } from 'react-router-dom'; +import { Schedule, ScheduleList } from '@components/Schedule'; + +function Schedules({ + setBreadcrumb, + unifiedJobTemplate, + loadSchedules, + loadScheduleOptions, +}) { + const match = useRouteMatch(); + + return ( + + ( + + )} + /> + { + return ( + + ); + }} + /> + + ); +} + +export { Schedules as _Schedules }; +export default withI18n()(Schedules); diff --git a/awx/ui_next/src/components/Schedule/Schedules.test.jsx b/awx/ui_next/src/components/Schedule/Schedules.test.jsx new file mode 100644 index 0000000000..4589403c01 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/Schedules.test.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import Schedules from './Schedules'; + +describe('', () => { + test('initially renders successfully', async () => { + let wrapper; + const history = createMemoryHistory({ + initialEntries: ['/templates/job_template/1/schedules'], + }); + const jobTemplate = { id: 1, name: 'Mock JT' }; + + await act(async () => { + wrapper = mountWithContexts( + {}} + jobTemplate={jobTemplate} + loadSchedules={() => {}} + loadScheduleOptions={() => {}} + />, + + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + expect(wrapper.length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/components/ScheduleList/data.schedules.json b/awx/ui_next/src/components/Schedule/data.schedules.json similarity index 100% rename from awx/ui_next/src/components/ScheduleList/data.schedules.json rename to awx/ui_next/src/components/Schedule/data.schedules.json diff --git a/awx/ui_next/src/components/Schedule/index.js b/awx/ui_next/src/components/Schedule/index.js new file mode 100644 index 0000000000..f734f868d2 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/index.js @@ -0,0 +1,6 @@ +export { default as Schedule } from './Schedule'; +export { default as Schedules } from './Schedules'; +export { default as ScheduleList } from './ScheduleList'; +export { default as ScheduleOccurrences } from './ScheduleOccurrences'; +export { default as ScheduleToggle } from './ScheduleToggle'; +export { default as ScheduleDetail } from './ScheduleDetail'; diff --git a/awx/ui_next/src/screens/Credential/CredentialList/CredentialListItem.jsx b/awx/ui_next/src/screens/Credential/CredentialList/CredentialListItem.jsx index 9b519bfcb1..a2d9feaae5 100644 --- a/awx/ui_next/src/screens/Credential/CredentialList/CredentialListItem.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialList/CredentialListItem.jsx @@ -5,7 +5,7 @@ import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; import { Button, - DataListAction, + DataListAction as _DataListAction, DataListCell, DataListCheck, DataListItem, @@ -14,8 +14,15 @@ import { Tooltip, } from '@patternfly/react-core'; import { PencilAltIcon } from '@patternfly/react-icons'; - import { Credential } from '@types'; +import styled from 'styled-components'; + +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 16px; + grid-template-columns: 40px; +`; function CredentialListItem({ credential, @@ -57,7 +64,7 @@ function CredentialListItem({ aria-labelledby={labelId} id={labelId} > - {canEdit && ( + {canEdit ? ( + ) : ( + '' )} diff --git a/awx/ui_next/src/screens/Host/Host.jsx b/awx/ui_next/src/screens/Host/Host.jsx index 37096fc024..2a8f286bd2 100644 --- a/awx/ui_next/src/screens/Host/Host.jsx +++ b/awx/ui_next/src/screens/Host/Host.jsx @@ -10,7 +10,6 @@ import { useLocation, } from 'react-router-dom'; import { Card, CardActions } from '@patternfly/react-core'; -import { CaretLeftIcon } from '@patternfly/react-icons'; import { TabbedCardHeader } from '@components/Card'; import CardCloseButton from '@components/CardCloseButton'; @@ -24,20 +23,13 @@ import HostEdit from './HostEdit'; import HostGroups from './HostGroups'; import { HostsAPI } from '@api'; -function Host({ inventory, i18n, setBreadcrumb }) { +function Host({ i18n, setBreadcrumb }) { const [host, setHost] = useState(null); const [contentError, setContentError] = useState(null); const [hasContentLoading, setHasContentLoading] = useState(true); const location = useLocation(); - const hostsMatch = useRouteMatch('/hosts/:id'); - const inventoriesMatch = useRouteMatch( - '/inventories/inventory/:id/hosts/:hostId' - ); - const baseUrl = hostsMatch ? hostsMatch.url : inventoriesMatch.url; - const hostListUrl = hostsMatch - ? '/hosts' - : `/inventories/inventory/${inventoriesMatch.params.id}/hosts`; + const match = useRouteMatch('/hosts/:id'); useEffect(() => { (async () => { @@ -45,17 +37,10 @@ function Host({ inventory, i18n, setBreadcrumb }) { setHasContentLoading(true); try { - const hostId = hostsMatch - ? hostsMatch.params.id - : inventoriesMatch.params.hostId; - const { data } = await HostsAPI.readDetail(hostId); - setHost(data); + const { data } = await HostsAPI.readDetail(match.params.id); - if (hostsMatch) { - setBreadcrumb(data); - } else if (inventoriesMatch) { - setBreadcrumb(inventory, data); - } + setHost(data); + setBreadcrumb(data); } catch (error) { setContentError(error); } finally { @@ -67,44 +52,31 @@ function Host({ inventory, i18n, setBreadcrumb }) { const tabsArray = [ { name: i18n._(t`Details`), - link: `${baseUrl}/details`, + link: `${match.url}/details`, id: 0, }, { name: i18n._(t`Facts`), - link: `${baseUrl}/facts`, + link: `${match.url}/facts`, id: 1, }, { name: i18n._(t`Groups`), - link: `${baseUrl}/groups`, + link: `${match.url}/groups`, id: 2, }, { name: i18n._(t`Completed Jobs`), - link: `${baseUrl}/completed_jobs`, + link: `${match.url}/completed_jobs`, id: 3, }, ]; - if (inventoriesMatch) { - tabsArray.unshift({ - name: ( - <> - - {i18n._(t`Back to Hosts`)} - - ), - link: hostListUrl, - id: 99, - }); - } - let cardHeader = ( - + ); @@ -124,7 +96,7 @@ function Host({ inventory, i18n, setBreadcrumb }) { {contentError.response && contentError.response.status === 404 && ( {i18n._(`Host not found.`)}{' '} - {i18n._(`View all Hosts.`)} + {i18n._(`View all Hosts.`)} )}
@@ -132,72 +104,35 @@ function Host({ inventory, i18n, setBreadcrumb }) { ); } - const redirect = hostsMatch ? ( - - ) : ( - - ); - return ( {cardHeader} - {redirect} - {host && ( - - setHost(newHost)} - /> - - )} - {host && ( - } - /> - )} - {host && ( - } - /> - )} - {host && ( - } - /> - )} - {host?.id && ( - + + {host && [ + + + , + + + , + + + , + + + , + - - )} + , + ]} !hasContentLoading && ( - + {i18n._(`View Host Details`)} diff --git a/awx/ui_next/src/screens/Host/Host.test.jsx b/awx/ui_next/src/screens/Host/Host.test.jsx index 8d5b83d2ef..64c188fc06 100644 --- a/awx/ui_next/src/screens/Host/Host.test.jsx +++ b/awx/ui_next/src/screens/Host/Host.test.jsx @@ -3,53 +3,41 @@ import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { HostsAPI } from '@api'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; -import mockDetails from './data.host.json'; +import mockHost from './data.host.json'; import Host from './Host'; jest.mock('@api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useRouteMatch: () => ({ + url: '/hosts/1', + params: { id: 1 }, + }), +})); + +HostsAPI.readDetail.mockResolvedValue({ + data: { ...mockHost }, +}); describe('', () => { let wrapper; let history; - HostsAPI.readDetail.mockResolvedValue({ - data: { ...mockDetails }, + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( {}} />); + }); }); afterEach(() => { wrapper.unmount(); }); - test('initially renders succesfully', async () => { - history = createMemoryHistory({ - initialEntries: ['/hosts/1/edit'], + test('should render expected tabs', async () => { + const expectedTabs = ['Details', 'Facts', 'Groups', 'Completed Jobs']; + wrapper.find('RoutedTabs li').forEach((tab, index) => { + expect(tab.text()).toEqual(expectedTabs[index]); }); - - await act(async () => { - wrapper = mountWithContexts( {}} />, { - context: { router: { history } }, - }); - }); - await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); - expect(wrapper.find('Host').length).toBe(1); - }); - - test('should render "Back to Hosts" tab when navigating from inventories', async () => { - history = createMemoryHistory({ - initialEntries: ['/inventories/inventory/1/hosts/1'], - }); - await act(async () => { - wrapper = mountWithContexts( {}} />, { - context: { router: { history } }, - }); - }); - await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); - expect( - wrapper - .find('RoutedTabs li') - .first() - .text() - ).toBe('Back to Hosts'); }); test('should show content error when api throws error on initial render', async () => { diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx index 422cabb721..122ce7dd27 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx @@ -1,34 +1,24 @@ import React, { useState } from 'react'; -import { useHistory, useRouteMatch } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { CardBody } from '@components/Card'; +import HostForm from '@components/HostForm'; import { HostsAPI } from '@api'; -import HostForm from '../shared'; function HostAdd() { const [formError, setFormError] = useState(null); const history = useHistory(); - const hostsMatch = useRouteMatch('/hosts'); - const inventoriesMatch = useRouteMatch('/inventories/inventory/:id/hosts'); - const url = hostsMatch ? hostsMatch.url : inventoriesMatch.url; const handleSubmit = async formData => { - const values = { - ...formData, - inventory: inventoriesMatch - ? inventoriesMatch.params.id - : formData.inventory, - }; - try { - const { data: response } = await HostsAPI.create(values); - history.push(`${url}/${response.id}/details`); + const { data: response } = await HostsAPI.create(formData); + history.push(`/hosts/${response.id}/details`); } catch (error) { setFormError(error); } }; const handleCancel = () => { - history.push(`${url}`); + history.push(`/hosts`); }; return ( diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx index ebb302fc42..1096b83ca6 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx @@ -1,27 +1,32 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; -import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; import HostAdd from './HostAdd'; import { HostsAPI } from '@api'; jest.mock('@api'); +const hostData = { + name: 'new name', + description: 'new description', + inventory: 1, + variables: '---\nfoo: bar', +}; + +HostsAPI.create.mockResolvedValue({ + data: { + ...hostData, + id: 5, + }, +}); + describe('', () => { let wrapper; let history; - const hostData = { - name: 'new name', - description: 'new description', - inventory: 1, - variables: '---\nfoo: bar', - }; - beforeEach(async () => { - history = createMemoryHistory({ - initialEntries: ['/hosts/1/add'], - }); + history = createMemoryHistory(); await act(async () => { wrapper = mountWithContexts(, { context: { router: { history } }, @@ -29,13 +34,12 @@ describe('', () => { }); }); + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + test('handleSubmit should post to api', async () => { - HostsAPI.create.mockResolvedValueOnce({ - data: { - ...hostData, - id: 5, - }, - }); await act(async () => { wrapper.find('HostForm').prop('handleSubmit')(hostData); }); @@ -43,21 +47,31 @@ describe('', () => { }); test('should navigate to hosts list when cancel is clicked', async () => { - wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + }); expect(history.location.pathname).toEqual('/hosts'); }); test('successful form submission should trigger redirect', async () => { - HostsAPI.create.mockResolvedValueOnce({ - data: { - ...hostData, - id: 5, - }, - }); - await waitForElement(wrapper, 'button[aria-label="Save"]'); await act(async () => { wrapper.find('HostForm').invoke('handleSubmit')(hostData); }); + expect(wrapper.find('FormSubmitError').length).toBe(0); expect(history.location.pathname).toEqual('/hosts/5/details'); }); + + test('failed form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + HostsAPI.create.mockImplementationOnce(() => Promise.reject(error)); + await act(async () => { + wrapper.find('HostForm').invoke('handleSubmit')(hostData); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx index 630e18d19e..41a2dec2a8 100644 --- a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx +++ b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Link, useHistory, useParams, useLocation } from 'react-router-dom'; +import { Link, useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Host } from '@types'; @@ -14,42 +14,36 @@ import DeleteButton from '@components/DeleteButton'; import { HostsAPI } from '@api'; import HostToggle from '@components/HostToggle'; -function HostDetail({ host, i18n, onUpdateHost }) { +function HostDetail({ i18n, host }) { const { created, description, id, modified, name, + variables, summary_fields: { inventory, recent_jobs, - kind, created_by, modified_by, user_capabilities, }, } = host; - const history = useHistory(); - const { pathname } = useLocation(); - const { id: inventoryId, hostId: inventoryHostId } = useParams(); const [isLoading, setIsloading] = useState(false); const [deletionError, setDeletionError] = useState(false); - - const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' })); + const history = useHistory(); const handleHostDelete = async () => { setIsloading(true); try { await HostsAPI.destroy(id); - setIsloading(false); - const url = pathname.startsWith('/inventories') - ? `/inventories/inventory/${inventoryId}/hosts/` - : `/hosts`; - history.push(url); + history.push('/hosts'); } catch (err) { setDeletionError(err); + } finally { + setIsloading(false); } }; @@ -66,40 +60,27 @@ function HostDetail({ host, i18n, onUpdateHost }) { ); } + + const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' })); return ( - - onUpdateHost({ - ...host, - enabled, - }) - } - css="padding-bottom: 40px" - /> + } label={i18n._(t`Activity`)} + value={} /> - {inventory && ( - - {inventory.name} - - } - /> - )} + + {inventory.name} + + } + /> - {user_capabilities && user_capabilities.edit && ( + {user_capabilities?.edit && ( )} - {user_capabilities && user_capabilities.delete && ( + {user_capabilities?.delete && ( handleHostDelete()} modalTitle={i18n._(t`Delete Host`)} - name={host.name} + name={name} /> )} + {deletionError && ( + setDeletionError(null)} + > + {i18n._(t`Failed to delete host.`)} + + + )} ); } diff --git a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx index 0f1ccc037a..d809bf1732 100644 --- a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx +++ b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx @@ -1,66 +1,88 @@ import React from 'react'; - +import { act } from 'react-dom/test-utils'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; - import HostDetail from './HostDetail'; +import { HostsAPI } from '@api'; + +import mockHost from '../data.host.json'; jest.mock('@api'); describe('', () => { - const mockHost = { - id: 1, - name: 'Foo', - description: 'Bar', - inventory: 1, - created: '2015-07-07T17:21:26.429745Z', - modified: '2019-08-11T19:47:37.980466Z', - variables: '---', - summary_fields: { - inventory: { - id: 1, - name: 'test inventory', - }, - user_capabilities: { - edit: true, - }, - recent_jobs: [], - }, - }; + let wrapper; - test('initially renders succesfully', () => { - mountWithContexts(); + describe('User has edit permissions', () => { + beforeAll(() => { + wrapper = mountWithContexts(); + }); + + afterAll(() => { + wrapper.unmount(); + }); + + test('should render Details', async () => { + function assertDetail(label, value) { + expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); + expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); + } + + assertDetail('Name', 'localhost'); + assertDetail('Description', 'a good description'); + assertDetail('Inventory', 'Mikes Inventory'); + assertDetail('Created', '10/28/2019, 9:26:54 PM'); + assertDetail('Last Modified', '10/29/2019, 8:18:41 PM'); + }); + + test('should show edit button for users with edit permission', () => { + const editButton = wrapper.find('Button[aria-label="edit"]'); + expect(editButton.text()).toEqual('Edit'); + expect(editButton.prop('to')).toBe('/hosts/2/edit'); + }); + + test('expected api call is made for delete', async () => { + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); + expect(HostsAPI.destroy).toHaveBeenCalledTimes(1); + }); + + test('Error dialog shown for failed deletion', async () => { + HostsAPI.destroy.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 1 + ); + await act(async () => { + wrapper.find('Modal[title="Error!"]').invoke('onClose')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 0 + ); + }); }); - test('should render Details', async () => { - const wrapper = mountWithContexts(); - const testParams = [ - { label: 'Name', value: 'Foo' }, - { label: 'Description', value: 'Bar' }, - { label: 'Inventory', value: 'test inventory' }, - { label: 'Created', value: '7/7/2015, 5:21:26 PM' }, - { label: 'Last Modified', value: '8/11/2019, 7:47:37 PM' }, - ]; - // eslint-disable-next-line no-restricted-syntax - for (const { label, value } of testParams) { - // eslint-disable-next-line no-await-in-loop - const detail = await waitForElement(wrapper, `Detail[label="${label}"]`); - expect(detail.find('dt').text()).toBe(label); - expect(detail.find('dd').text()).toBe(value); - } - }); + describe('User has read-only permissions', () => { + beforeAll(() => { + const readOnlyHost = { ...mockHost }; + readOnlyHost.summary_fields.user_capabilities.edit = false; - test('should show edit button for users with edit permission', async () => { - const wrapper = mountWithContexts(); - const editButton = wrapper.find('Button[aria-label="edit"]'); - expect(editButton.text()).toEqual('Edit'); - expect(editButton.prop('to')).toBe('/hosts/1/edit'); - }); + wrapper = mountWithContexts(); + }); - test('should hide edit button for users without edit permission', async () => { - const readOnlyHost = { ...mockHost }; - readOnlyHost.summary_fields.user_capabilities.edit = false; - const wrapper = mountWithContexts(); - await waitForElement(wrapper, 'HostDetail'); - expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0); + afterAll(() => { + wrapper.unmount(); + }); + + test('should hide edit button for users without edit permission', async () => { + expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0); + }); }); }); diff --git a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx index d2ef0252e9..bddb692b14 100644 --- a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx +++ b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx @@ -1,30 +1,14 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { useHistory, useRouteMatch } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { CardBody } from '@components/Card'; +import HostForm from '@components/HostForm'; import { HostsAPI } from '@api'; -import HostForm from '../shared'; function HostEdit({ host }) { const [formError, setFormError] = useState(null); - const hostsMatch = useRouteMatch('/hosts/:id/edit'); - const inventoriesMatch = useRouteMatch( - '/inventories/inventory/:id/hosts/:hostId/edit' - ); + const detailsUrl = `/hosts/${host.id}/details`; const history = useHistory(); - let detailsUrl; - - if (hostsMatch) { - detailsUrl = `/hosts/${hostsMatch.params.id}/details`; - } - - if (inventoriesMatch) { - const kind = - host.summary_fields.inventory.kind === 'smart' - ? 'smart_inventory' - : 'inventory'; - detailsUrl = `/inventories/${kind}/${inventoriesMatch.params.id}/hosts/${inventoriesMatch.params.hostId}/details`; - } const handleSubmit = async values => { try { diff --git a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx index 637dd64273..038ee90bbf 100644 --- a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx +++ b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx @@ -1,49 +1,70 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { HostsAPI } from '@api'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import mockHost from '../data.host.json'; import HostEdit from './HostEdit'; jest.mock('@api'); describe('', () => { - const mockData = { - id: 1, - name: 'Foo', - description: 'Bar', - inventory: 1, - variables: '---', - summary_fields: { - inventory: { - id: 1, - name: 'test inventory', - }, - }, + let wrapper; + let history; + + const updatedHostData = { + name: 'new name', + description: 'new description', + variables: '---\nfoo: bar', }; - test('handleSubmit should call api update', () => { - const wrapper = mountWithContexts(); - - const updatedHostData = { - name: 'new name', - description: 'new description', - variables: '---\nfoo: bar', - }; - wrapper.find('HostForm').prop('handleSubmit')(updatedHostData); - - expect(HostsAPI.update).toHaveBeenCalledWith(1, updatedHostData); + beforeAll(async () => { + history = createMemoryHistory(); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); }); - test('should navigate to host detail when cancel is clicked', () => { - const history = createMemoryHistory({ - initialEntries: ['/hosts/1/edit'], - }); - const wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); - wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + test('handleSubmit should call api update', async () => { + await act(async () => { + wrapper.find('HostForm').prop('handleSubmit')(updatedHostData); + }); + expect(HostsAPI.update).toHaveBeenCalledWith(2, updatedHostData); + }); - expect(history.location.pathname).toEqual('/hosts/1/details'); + test('should navigate to host detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + }); + expect(history.location.pathname).toEqual('/hosts/2/details'); + }); + + test('should navigate to host detail after successful submission', async () => { + await act(async () => { + wrapper.find('HostForm').invoke('handleSubmit')(updatedHostData); + }); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(history.location.pathname).toEqual('/hosts/2/details'); + }); + + test('failed form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + HostsAPI.update.mockImplementationOnce(() => Promise.reject(error)); + await act(async () => { + wrapper.find('HostForm').invoke('handleSubmit')(mockHost); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); }); }); diff --git a/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx b/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx index fec58640a6..3bc9c9f949 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx @@ -24,7 +24,7 @@ const DataListAction = styled(_DataListAction)` align-items: center; display: grid; grid-gap: 24px; - grid-template-columns: min-content 40px; + grid-template-columns: 92px 40px; `; function HostListItem({ i18n, host, isSelected, onSelect, detailUrl }) { @@ -77,7 +77,7 @@ function HostListItem({ i18n, host, isSelected, onSelect, detailUrl }) { id={labelId} > - {host.summary_fields.user_capabilities.edit && ( + {host.summary_fields.user_capabilities.edit ? ( + ) : ( + '' )} diff --git a/awx/ui_next/src/screens/Host/Hosts.test.jsx b/awx/ui_next/src/screens/Host/Hosts.test.jsx index 0581b3371e..7db85fbb84 100644 --- a/awx/ui_next/src/screens/Host/Hosts.test.jsx +++ b/awx/ui_next/src/screens/Host/Hosts.test.jsx @@ -30,4 +30,23 @@ describe('', () => { expect(wrapper.find('BreadcrumbHeading').length).toBe(1); wrapper.unmount(); }); + + test('should render Host component', () => { + const history = createMemoryHistory({ + initialEntries: ['/hosts/1'], + }); + + const match = { + path: '/hosts/:id', + url: '/hosts/1', + isExact: true, + }; + + const wrapper = mountWithContexts(, { + context: { router: { history, route: { match } } }, + }); + + expect(wrapper.find('Host').length).toBe(1); + wrapper.unmount(); + }); }); diff --git a/awx/ui_next/src/screens/Host/data.host.json b/awx/ui_next/src/screens/Host/data.host.json index d2ef565610..aacc08f787 100644 --- a/awx/ui_next/src/screens/Host/data.host.json +++ b/awx/ui_next/src/screens/Host/data.host.json @@ -51,18 +51,6 @@ "id": 1, "failed": false }, - "created_by": { - "id": 1, - "username": "admin", - "first_name": "", - "last_name": "" - }, - "modified_by": { - "id": 1, - "username": "admin", - "first_name": "", - "last_name": "" - }, "user_capabilities": { "edit": true, "delete": true diff --git a/awx/ui_next/src/screens/Host/shared/HostForm.jsx b/awx/ui_next/src/screens/Host/shared/HostForm.jsx deleted file mode 100644 index 7df23e70c8..0000000000 --- a/awx/ui_next/src/screens/Host/shared/HostForm.jsx +++ /dev/null @@ -1,140 +0,0 @@ -import React, { useState } from 'react'; -import { func, shape } from 'prop-types'; - -import { useRouteMatch } from 'react-router-dom'; -import { Formik, useField } from 'formik'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; - -import { Form, FormGroup } from '@patternfly/react-core'; - -import FormField, { - FormSubmitError, - FieldTooltip, -} from '@components/FormField'; -import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; -import { VariablesField } from '@components/CodeMirrorInput'; -import { required } from '@util/validators'; -import { InventoryLookup } from '@components/Lookup'; -import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout'; - -function HostFormFields({ host, i18n }) { - const [inventory, setInventory] = useState( - host ? host.summary_fields.inventory : '' - ); - - const hostAddMatch = useRouteMatch('/hosts/add'); - const inventoryFieldArr = useField({ - name: 'inventory', - validate: required(i18n._(t`Select a value for this field`), i18n), - }); - const inventoryMeta = inventoryFieldArr[1]; - const inventoryHelpers = inventoryFieldArr[2]; - - return ( - <> - - - {hostAddMatch && ( - - - inventoryHelpers.setTouched()} - tooltip={i18n._( - t`Select the inventory that this host will belong to.` - )} - isValid={!inventoryMeta.touched || !inventoryMeta.error} - helperTextInvalid={inventoryMeta.error} - onChange={value => { - inventoryHelpers.setValue(value.id); - setInventory(value); - }} - required - touched={inventoryMeta.touched} - error={inventoryMeta.error} - /> - - )} - - - - - ); -} - -function HostForm({ handleSubmit, host, submitError, handleCancel, ...rest }) { - return ( - - {formik => ( -
- - - - - -
- )} -
- ); -} - -HostForm.propTypes = { - handleSubmit: func.isRequired, - handleCancel: func.isRequired, - host: shape({}), - submitError: shape({}), -}; - -HostForm.defaultProps = { - host: { - name: '', - description: '', - inventory: undefined, - variables: '---\n', - summary_fields: { - inventory: null, - }, - }, - submitError: null, -}; - -export { HostForm as _HostForm }; -export default withI18n()(HostForm); diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index d7361690f6..d6bfa12f74 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -32,8 +32,10 @@ class Inventories extends Component { if (!inventory) { return; } + const inventoryKind = inventory.kind === 'smart' ? 'smart_inventory' : 'inventory'; + const breadcrumbConfig = { '/inventories': i18n._(t`Inventories`), '/inventories/inventory/add': i18n._(t`Create New Inventory`), @@ -65,9 +67,7 @@ class Inventories extends Component { t`Create New Host` ), [`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource && - nestedResource.id}`]: i18n._( - t`${nestedResource && nestedResource.name}` - ), + nestedResource.id}`]: `${nestedResource && nestedResource.name}`, [`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource && nestedResource.id}/edit`]: i18n._(t`Edit Details`), [`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource && @@ -83,6 +83,10 @@ class Inventories extends Component { nestedResource.id}/edit`]: i18n._(t`Edit Details`), [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource && nestedResource.id}/details`]: i18n._(t`Group Details`), + [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource && + nestedResource.id}`]: `${nestedResource && nestedResource.name}`, + [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource && + nestedResource.id}/nested_hosts`]: i18n._(t`Hosts`), }; this.setState({ breadcrumbConfig }); }; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx index ef5b0a7995..b1d3734a5f 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx @@ -19,6 +19,8 @@ import ContentLoading from '@components/ContentLoading'; import { TabbedCardHeader } from '@components/Card'; import InventoryGroupEdit from '../InventoryGroupEdit/InventoryGroupEdit'; import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail'; +import InventoryGroupHosts from '../InventoryGroupHosts'; + import { GroupsAPI } from '@api'; function InventoryGroup({ i18n, setBreadcrumb, inventory }) { @@ -142,6 +144,12 @@ function InventoryGroup({ i18n, setBreadcrumb, inventory }) { }} />, ]} + + + + {i18n._(t`Add New Host`)} + , + + {i18n._(t`Add Existing Host`)} + , + ]; + + return ( + setIsOpen(prevState => !prevState)} + > + {i18n._(t`Add`)} + + } + dropdownItems={dropdownItems} + /> + ); +} + +AddHostDropdown.propTypes = { + onAddNew: func.isRequired, + onAddExisting: func.isRequired, +}; + +export default withI18n()(AddHostDropdown); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.test.jsx new file mode 100644 index 0000000000..c72e505ea2 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.test.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import AddHostDropdown from './AddHostDropdown'; + +describe('', () => { + let wrapper; + let dropdownToggle; + const onAddNew = jest.fn(); + const onAddExisting = jest.fn(); + + beforeEach(() => { + wrapper = mountWithContexts( + + ); + dropdownToggle = wrapper.find('DropdownToggle button'); + }); + + test('should initially render a closed dropdown', () => { + expect(wrapper.find('DropdownItem').length).toBe(0); + }); + + test('should render two dropdown items', () => { + dropdownToggle.simulate('click'); + expect(wrapper.find('DropdownItem').length).toBe(2); + }); + + test('should close when button re-clicked', () => { + dropdownToggle.simulate('click'); + expect(wrapper.find('DropdownItem').length).toBe(2); + dropdownToggle.simulate('click'); + expect(wrapper.find('DropdownItem').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx new file mode 100644 index 0000000000..237c9e5c93 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx @@ -0,0 +1,162 @@ +import React, { useEffect, useCallback, useState } from 'react'; +import { useHistory, useLocation, useParams } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import { GroupsAPI, InventoriesAPI } from '@api'; + +import AlertModal from '@components/AlertModal'; +import DataListToolbar from '@components/DataListToolbar'; +import PaginatedDataList from '@components/PaginatedDataList'; +import useRequest from '@util/useRequest'; +import InventoryGroupHostListItem from './InventoryGroupHostListItem'; +import AddHostDropdown from './AddHostDropdown'; + +const QS_CONFIG = getQSConfig('host', { + page: 1, + page_size: 20, + order_by: 'name', +}); + +function InventoryGroupHostList({ i18n }) { + const [selected, setSelected] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const { id: inventoryId, groupId } = useParams(); + const location = useLocation(); + const history = useHistory(); + + const { + result: { hosts, hostCount, actions }, + error: contentError, + isLoading, + request: fetchHosts, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const [response, actionsResponse] = await Promise.all([ + GroupsAPI.readAllHosts(groupId, params), + InventoriesAPI.readHostsOptions(inventoryId), + ]); + + return { + hosts: response.data.results, + hostCount: response.data.count, + actions: actionsResponse.data.actions, + }; + }, [groupId, inventoryId, location.search]), + { + hosts: [], + hostCount: 0, + } + ); + + useEffect(() => { + fetchHosts(); + }, [fetchHosts]); + + const handleSelectAll = isSelected => { + setSelected(isSelected ? [...hosts] : []); + }; + + const handleSelect = row => { + if (selected.some(s => s.id === row.id)) { + setSelected(selected.filter(s => s.id !== row.id)); + } else { + setSelected(selected.concat(row)); + } + }; + + const isAllSelected = selected.length > 0 && selected.length === hosts.length; + const canAdd = + actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + const addFormUrl = `/inventories/inventory/${inventoryId}/groups/${groupId}/nested_hosts/add`; + + return ( + <> + ( + setIsModalOpen(true)} + onAddNew={() => history.push(addFormUrl)} + />, + ] + : []), + // TODO HOST DISASSOCIATE BUTTON + ]} + /> + )} + renderItem={o => ( + row.id === o.id)} + onSelect={() => handleSelect(o)} + /> + )} + emptyStateControls={ + canAdd && ( + setIsModalOpen(true)} + onAddNew={() => history.push(addFormUrl)} + /> + ) + } + /> + + {/* DISASSOCIATE HOST MODAL PLACEHOLDER */} + + {isModalOpen && ( + setIsModalOpen(false)} + > + {/* ADD/ASSOCIATE HOST MODAL PLACEHOLDER */} + {i18n._(t`Host Select Modal`)} + + )} + + ); +} + +export default withI18n()(InventoryGroupHostList); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx new file mode 100644 index 0000000000..8345964e40 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx @@ -0,0 +1,164 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { GroupsAPI, InventoriesAPI } from '@api'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import InventoryGroupHostList from './InventoryGroupHostList'; +import mockHosts from '../shared/data.hosts.json'; + +jest.mock('@api/models/Groups'); +jest.mock('@api/models/Inventories'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + }), +})); + +describe('', () => { + let wrapper; + + beforeEach(async () => { + GroupsAPI.readAllHosts.mockResolvedValue({ + data: { ...mockHosts }, + }); + InventoriesAPI.readHostsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('initially renders successfully ', () => { + expect(wrapper.find('InventoryGroupHostList').length).toBe(1); + }); + + test('should fetch inventory group hosts from api and render them in the list', () => { + expect(GroupsAPI.readAllHosts).toHaveBeenCalled(); + expect(InventoriesAPI.readHostsOptions).toHaveBeenCalled(); + expect(wrapper.find('InventoryGroupHostListItem').length).toBe(3); + }); + + test('should check and uncheck the row item', async () => { + expect( + wrapper.find('DataListCheck[id="select-host-2"]').props().checked + ).toBe(false); + await act(async () => { + wrapper.find('DataListCheck[id="select-host-2"]').invoke('onChange')(); + }); + wrapper.update(); + expect( + wrapper.find('DataListCheck[id="select-host-2"]').props().checked + ).toBe(true); + await act(async () => { + wrapper.find('DataListCheck[id="select-host-2"]').invoke('onChange')(); + }); + wrapper.update(); + expect( + wrapper.find('DataListCheck[id="select-host-2"]').props().checked + ).toBe(false); + }); + + test('should check all row items when select all is checked', async () => { + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(true); + }); + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(true); + }); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(false); + }); + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + }); + + test('should show add dropdown button according to permissions', async () => { + expect(wrapper.find('AddHostDropdown').length).toBe(1); + InventoriesAPI.readHostsOptions.mockResolvedValueOnce({ + data: { + actions: { + GET: {}, + }, + }, + }); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('AddHostDropdown').length).toBe(0); + }); + + test('should show associate host modal when adding an existing host', () => { + const dropdownToggle = wrapper.find( + 'DropdownToggle button[aria-label="add host"]' + ); + dropdownToggle.simulate('click'); + wrapper + .find('DropdownItem[aria-label="add existing host"]') + .simulate('click'); + expect(wrapper.find('AlertModal').length).toBe(1); + wrapper.find('ModalBoxCloseButton').simulate('click'); + expect(wrapper.find('AlertModal').length).toBe(0); + }); + + test('should navigate to host add form when adding a new host', async () => { + GroupsAPI.readAllHosts.mockResolvedValue({ + data: { ...mockHosts }, + }); + InventoriesAPI.readHostsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + const history = createMemoryHistory(); + await act(async () => { + wrapper = mountWithContexts(, { + context: { + router: { history }, + }, + }); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + const dropdownToggle = wrapper.find( + 'DropdownToggle button[aria-label="add host"]' + ); + dropdownToggle.simulate('click'); + wrapper.find('DropdownItem[aria-label="add new host"]').simulate('click'); + expect(history.location.pathname).toEqual( + '/inventories/inventory/1/groups/2/nested_hosts/add' + ); + }); + + test('should show content error when api throws error on initial render', async () => { + InventoriesAPI.readHostsOptions.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.jsx new file mode 100644 index 0000000000..a234b9597f --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.jsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { string, bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; + +import { + Button, + DataListAction as _DataListAction, + DataListCell, + DataListCheck, + DataListItem, + DataListItemCells, + DataListItemRow, + Tooltip, +} from '@patternfly/react-core'; +import { PencilAltIcon } from '@patternfly/react-icons'; +import HostToggle from '@components/HostToggle'; +import Sparkline from '@components/Sparkline'; +import { Host } from '@types'; +import styled from 'styled-components'; + +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 24px; + grid-template-columns: min-content 40px; +`; + +function InventoryGroupHostListItem({ + i18n, + detailUrl, + editUrl, + host, + isSelected, + onSelect, +}) { + const recentPlaybookJobs = host.summary_fields.recent_jobs.map(job => ({ + ...job, + type: 'job', + })); + + const labelId = `check-action-${host.id}`; + + return ( + + + + + + {host.name} + + , + + + , + ]} + /> + + + {host.summary_fields.user_capabilities?.edit && ( + + + + )} + + + + ); +} + +InventoryGroupHostListItem.propTypes = { + detailUrl: string.isRequired, + editUrl: string.isRequired, + host: Host.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(InventoryGroupHostListItem); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.test.jsx new file mode 100644 index 0000000000..830bd5540e --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.test.jsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventoryGroupHostListItem from './InventoryGroupHostListItem'; +import mockHosts from '../shared/data.hosts.json'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + const mockHost = mockHosts.results[0]; + + beforeEach(() => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('should display expected row item content', () => { + expect( + wrapper + .find('DataListCell') + .first() + .text() + ).toBe('.host-000001.group-00000.dummy'); + expect(wrapper.find('Sparkline').length).toBe(1); + expect(wrapper.find('HostToggle').length).toBe(1); + }); + + test('edit button shown to users with edit capabilities', () => { + expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + }); + + test('edit button hidden from users without edit capabilities', () => { + const copyMockHost = Object.assign({}, mockHost); + copyMockHost.summary_fields.user_capabilities.edit = false; + wrapper = mountWithContexts( + {}} + /> + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.jsx new file mode 100644 index 0000000000..dc3da57781 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Switch, Route } from 'react-router-dom'; +import InventoryGroupHostList from './InventoryGroupHostList'; + +function InventoryGroupHosts() { + return ( + + {/* Route to InventoryGroupHostAddForm */} + + + + + ); +} + +export default InventoryGroupHosts; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.test.jsx new file mode 100644 index 0000000000..0a4cecc19d --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.test.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import InventoryGroupHosts from './InventoryGroupHosts'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + + test('initially renders successfully', async () => { + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/groups/1/nested_hosts'], + }); + + await act(async () => { + wrapper = mountWithContexts(, { + context: { + router: { history, route: { location: history.location } }, + }, + }); + }); + expect(wrapper.length).toBe(1); + expect(wrapper.find('InventoryGroupHostList').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/index.js new file mode 100644 index 0000000000..58e24ac90e --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroupHosts'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.jsx b/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.jsx new file mode 100644 index 0000000000..89121e8cd5 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.jsx @@ -0,0 +1,174 @@ +import React, { useEffect, useCallback } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Switch, + Route, + Redirect, + Link, + useRouteMatch, + useLocation, +} from 'react-router-dom'; +import useRequest from '@util/useRequest'; + +import { HostsAPI } from '@api'; +import { Card, CardActions } from '@patternfly/react-core'; +import { CaretLeftIcon } from '@patternfly/react-icons'; +import { TabbedCardHeader } from '@components/Card'; +import CardCloseButton from '@components/CardCloseButton'; +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import RoutedTabs from '@components/RoutedTabs'; +import JobList from '@components/JobList'; +import InventoryHostDetail from '../InventoryHostDetail'; +import InventoryHostEdit from '../InventoryHostEdit'; + +function InventoryHost({ i18n, setBreadcrumb, inventory }) { + const location = useLocation(); + const match = useRouteMatch('/inventories/inventory/:id/hosts/:hostId'); + const hostListUrl = `/inventories/inventory/${inventory.id}/hosts`; + + const { + result: { host }, + error: contentError, + isLoading, + request: fetchHost, + } = useRequest( + useCallback(async () => { + const { data } = await HostsAPI.readDetail(match.params.hostId); + + return { + host: data, + }; + }, [match.params.hostId]), // eslint-disable-line react-hooks/exhaustive-deps + { + host: null, + } + ); + + useEffect(() => { + fetchHost(); + }, [fetchHost]); + + useEffect(() => { + if (inventory && host) { + setBreadcrumb(inventory, host); + } + }, [inventory, host, setBreadcrumb]); + + const tabsArray = [ + { + name: ( + <> + + {i18n._(t`Back to Hosts`)} + + ), + link: `${hostListUrl}`, + id: 0, + }, + { + name: i18n._(t`Details`), + link: `${match.url}/details`, + id: 1, + }, + { + name: i18n._(t`Facts`), + link: `${match.url}/facts`, + id: 2, + }, + { + name: i18n._(t`Groups`), + link: `${match.url}/groups`, + id: 3, + }, + { + name: i18n._(t`Completed Jobs`), + link: `${match.url}/completed_jobs`, + id: 4, + }, + ]; + + let cardHeader = ( + + + + + + + ); + + if (location.pathname.endsWith('edit')) { + cardHeader = null; + } + + if (isLoading) { + return ; + } + + if (!isLoading && contentError) { + return ( + + + {contentError.response && contentError.response.status === 404 && ( + + {i18n._(`Host not found.`)}{' '} + + {i18n._(`View all Inventory Hosts.`)} + + + )} + + + ); + } + + return ( + <> + {cardHeader} + + + {host && + inventory && [ + + + , + + + , + + + , + ]} + + !isLoading && ( + + + {i18n._(`View Inventory Host Details`)} + + + ) + } + /> + + + ); +} + +export default withI18n()(InventoryHost); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.test.jsx new file mode 100644 index 0000000000..bea26df827 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.test.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { HostsAPI } from '@api'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import mockHost from '../shared/data.host.json'; +import InventoryHost from './InventoryHost'; + +jest.mock('@api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useRouteMatch: () => ({ + url: '/inventories/inventory/1/hosts/1', + params: { id: 1, hostId: 1 }, + }), +})); + +HostsAPI.readDetail.mockResolvedValue({ + data: { ...mockHost }, +}); + +const mockInventory = { + id: 1, + name: 'Mock Inventory', +}; + +describe('', () => { + let wrapper; + let history; + + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} /> + ); + }); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('should render expected tabs', async () => { + const expectedTabs = [ + 'Back to Hosts', + 'Details', + 'Facts', + 'Groups', + 'Completed Jobs', + ]; + wrapper.find('RoutedTabs li').forEach((tab, index) => { + expect(tab.text()).toEqual(expectedTabs[index]); + }); + }); + + test('should show content error when api throws error on initial render', async () => { + HostsAPI.readDetail.mockRejectedValueOnce(new Error()); + await act(async () => { + wrapper = mountWithContexts( + {}} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); + + test('should show content error when user attempts to navigate to erroneous route', async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/hosts/1/foobar'], + }); + await act(async () => { + wrapper = mountWithContexts( + {}} />, + { context: { router: { history } } } + ); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHost/index.js b/awx/ui_next/src/screens/Inventory/InventoryHost/index.js new file mode 100644 index 0000000000..5419035b15 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHost/index.js @@ -0,0 +1 @@ +export { default } from './InventoryHost'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx new file mode 100644 index 0000000000..9afb38c90b --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx @@ -0,0 +1,42 @@ +import React, { useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { CardBody } from '@components/Card'; +import HostForm from '@components/HostForm'; + +import { HostsAPI } from '@api'; + +function InventoryHostAdd({ inventory }) { + const [formError, setFormError] = useState(null); + const hostsUrl = `/inventories/inventory/${inventory.id}/hosts`; + const history = useHistory(); + + const handleSubmit = async formData => { + try { + const values = { + ...formData, + inventory: inventory.id, + }; + const { data: response } = await HostsAPI.create(values); + history.push(`${hostsUrl}/${response.id}/details`); + } catch (error) { + setFormError(error); + } + }; + + const handleCancel = () => { + history.push(hostsUrl); + }; + + return ( + + + + ); +} + +export default InventoryHostAdd; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx new file mode 100644 index 0000000000..d17216ac10 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventoryHostAdd from './InventoryHostAdd'; +import mockHost from '../shared/data.host.json'; +import { HostsAPI } from '@api'; + +jest.mock('@api'); + +HostsAPI.create.mockResolvedValue({ + data: { + ...mockHost, + }, +}); + +describe('', () => { + let wrapper; + let history; + + beforeAll(async () => { + history = createMemoryHistory(); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('handleSubmit should post to api', async () => { + await act(async () => { + wrapper.find('HostForm').prop('handleSubmit')(mockHost); + }); + expect(HostsAPI.create).toHaveBeenCalledWith(mockHost); + }); + + test('should navigate to hosts list when cancel is clicked', () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + expect(history.location.pathname).toEqual('/inventories/inventory/3/hosts'); + }); + + test('successful form submission should trigger redirect', async () => { + await act(async () => { + wrapper.find('HostForm').invoke('handleSubmit')(mockHost); + }); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(history.location.pathname).toEqual( + '/inventories/inventory/3/hosts/2/details' + ); + }); + + test('failed form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + HostsAPI.create.mockImplementationOnce(() => Promise.reject(error)); + await act(async () => { + wrapper.find('HostForm').invoke('handleSubmit')(mockHost); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/index.js b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/index.js new file mode 100644 index 0000000000..56bb7e05ad --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/index.js @@ -0,0 +1 @@ +export { default } from './InventoryHostAdd'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.jsx new file mode 100644 index 0000000000..cfa36e4e83 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.jsx @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; +import { Link, useHistory } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Host } from '@types'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '@components/Card'; +import AlertModal from '@components/AlertModal'; +import ErrorDetail from '@components/ErrorDetail'; +import { DetailList, Detail, UserDateDetail } from '@components/DetailList'; +import { VariablesDetail } from '@components/CodeMirrorInput'; +import Sparkline from '@components/Sparkline'; +import DeleteButton from '@components/DeleteButton'; +import { HostsAPI } from '@api'; +import HostToggle from '@components/HostToggle'; + +function InventoryHostDetail({ i18n, host }) { + const { + created, + description, + id, + modified, + name, + variables, + summary_fields: { + inventory, + recent_jobs, + created_by, + modified_by, + user_capabilities, + }, + } = host; + + const [isLoading, setIsloading] = useState(false); + const [deletionError, setDeletionError] = useState(false); + const history = useHistory(); + + const handleHostDelete = async () => { + setIsloading(true); + try { + await HostsAPI.destroy(id); + history.push(`/inventories/inventory/${inventory.id}/hosts`); + } catch (err) { + setDeletionError(err); + } finally { + setIsloading(false); + } + }; + + if (!isLoading && deletionError) { + return ( + setDeletionError(false)} + > + {i18n._(t`Failed to delete ${name}.`)} + + + ); + } + + const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' })); + + return ( + + + + + } + /> + + + + + + + {user_capabilities?.edit && ( + + )} + {user_capabilities?.delete && ( + handleHostDelete()} + /> + )} + + {deletionError && ( + setDeletionError(null)} + > + {i18n._(t`Failed to delete host.`)} + + + )} + + ); +} + +InventoryHostDetail.propTypes = { + host: Host.isRequired, +}; + +export default withI18n()(InventoryHostDetail); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx new file mode 100644 index 0000000000..66735e19cb --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import InventoryHostDetail from './InventoryHostDetail'; +import { HostsAPI } from '@api'; +import mockHost from '../shared/data.host.json'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + + describe('User has edit permissions', () => { + beforeAll(() => { + wrapper = mountWithContexts(); + }); + + afterAll(() => { + wrapper.unmount(); + }); + + test('should render Details', async () => { + function assertDetail(label, value) { + expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); + expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); + } + + assertDetail('Name', 'localhost'); + assertDetail('Description', 'localhost description'); + assertDetail('Created', '10/28/2019, 9:26:54 PM'); + assertDetail('Last Modified', '10/29/2019, 8:18:41 PM'); + }); + + test('should show edit button for users with edit permission', () => { + const editButton = wrapper.find('Button[aria-label="edit"]'); + expect(editButton.text()).toEqual('Edit'); + expect(editButton.prop('to')).toBe( + '/inventories/inventory/3/hosts/2/edit' + ); + }); + + test('expected api call is made for delete', async () => { + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); + expect(HostsAPI.destroy).toHaveBeenCalledTimes(1); + }); + + test('Error dialog shown for failed deletion', async () => { + HostsAPI.destroy.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 1 + ); + await act(async () => { + wrapper.find('Modal[title="Error!"]').invoke('onClose')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 0 + ); + }); + }); + + describe('User has read-only permissions', () => { + beforeAll(() => { + const readOnlyHost = { ...mockHost }; + readOnlyHost.summary_fields.user_capabilities.edit = false; + + wrapper = mountWithContexts(); + }); + + afterAll(() => { + wrapper.unmount(); + }); + + test('should hide edit button for users without edit permission', async () => { + expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostDetail/index.js b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/index.js new file mode 100644 index 0000000000..df9deaf20d --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/index.js @@ -0,0 +1 @@ +export { default } from './InventoryHostDetail'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.jsx new file mode 100644 index 0000000000..c7f0845bd4 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.jsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useHistory } from 'react-router-dom'; +import { CardBody } from '@components/Card'; +import HostForm from '@components/HostForm'; + +import { HostsAPI } from '@api'; + +function InventoryHostEdit({ host, inventory }) { + const [formError, setFormError] = useState(null); + const detailsUrl = `/inventories/inventory/${inventory.id}/hosts/${host.id}/details`; + const history = useHistory(); + + const handleSubmit = async values => { + try { + await HostsAPI.update(host.id, values); + history.push(detailsUrl); + } catch (error) { + setFormError(error); + } + }; + + const handleCancel = () => { + history.push(detailsUrl); + }; + + return ( + + + + ); +} + +InventoryHostEdit.propTypes = { + host: PropTypes.shape().isRequired, +}; + +export default InventoryHostEdit; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.test.jsx new file mode 100644 index 0000000000..f6a6ccb849 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.test.jsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { HostsAPI } from '@api'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventoryHostEdit from './InventoryHostEdit'; +import mockHost from '../shared/data.host.json'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + let history; + + const updatedHostData = { + name: 'new name', + description: 'new description', + variables: '---\nfoo: bar', + }; + + beforeAll(async () => { + history = createMemoryHistory(); + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('handleSubmit should call api update', async () => { + await act(async () => { + wrapper.find('HostForm').prop('handleSubmit')(updatedHostData); + }); + expect(HostsAPI.update).toHaveBeenCalledWith(2, updatedHostData); + }); + + test('should navigate to inventory host detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + }); + expect(history.location.pathname).toEqual( + '/inventories/inventory/123/hosts/2/details' + ); + }); + + test('should navigate to inventory host detail after successful submission', async () => { + await act(async () => { + wrapper.find('HostForm').invoke('handleSubmit')(updatedHostData); + }); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(history.location.pathname).toEqual( + '/inventories/inventory/123/hosts/2/details' + ); + }); + + test('failed form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + HostsAPI.update.mockImplementationOnce(() => Promise.reject(error)); + await act(async () => { + wrapper.find('HostForm').invoke('handleSubmit')(mockHost); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostEdit/index.js b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/index.js new file mode 100644 index 0000000000..428da2e09c --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/index.js @@ -0,0 +1 @@ +export { default } from './InventoryHostEdit'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx index 3257024cef..a15fbebf6d 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx @@ -1,28 +1,22 @@ import React from 'react'; import { Switch, Route } from 'react-router-dom'; -import Host from '../../Host/Host'; +import InventoryHost from '../InventoryHost'; +import InventoryHostAdd from '../InventoryHostAdd'; import InventoryHostList from './InventoryHostList'; -import HostAdd from '../../Host/HostAdd'; function InventoryHosts({ setBreadcrumb, inventory }) { return ( - + + + + + + + - ( - - )} - /> - } - /> ); } diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.test.jsx new file mode 100644 index 0000000000..140a6e57ea --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.test.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventoryHosts from './InventoryHosts'; + +describe('', () => { + test('should render inventory host list', () => { + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/hosts'], + }); + + const match = { + path: '/inventories/inventory/:id/hosts', + url: '/inventories/inventory/1/hosts', + isExact: true, + }; + + const wrapper = mountWithContexts(, { + context: { router: { history, route: { match } } }, + }); + + expect(wrapper.find('InventoryHostList').length).toBe(1); + wrapper.unmount(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx index ea5e445994..3edd54e169 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx @@ -3,7 +3,7 @@ import { string, bool, func } from 'prop-types'; import { withI18n } from '@lingui/react'; import { Button, - DataListAction, + DataListAction as _DataListAction, DataListCell, DataListCheck, DataListItem, @@ -13,10 +13,18 @@ import { } from '@patternfly/react-core'; import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; +import styled from 'styled-components'; import { PencilAltIcon } from '@patternfly/react-icons'; import { Inventory } from '@types'; +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 16px; + grid-template-columns: 40px; +`; + class InventoryListItem extends React.Component { static propTypes = { inventory: Inventory.isRequired, @@ -60,7 +68,7 @@ class InventoryListItem extends React.Component { aria-labelledby={labelId} id={labelId} > - {inventory.summary_fields.user_capabilities.edit && ( + {inventory.summary_fields.user_capabilities.edit ? ( + ) : ( + '' )} diff --git a/awx/ui_next/src/screens/Inventory/shared/data.host.json b/awx/ui_next/src/screens/Inventory/shared/data.host.json new file mode 100644 index 0000000000..a4975ad01b --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/data.host.json @@ -0,0 +1,86 @@ +{ + "id": 2, + "type": "host", + "url": "/api/v2/hosts/2/", + "related": { + "created_by": "/api/v2/users/1/", + "modified_by": "/api/v2/users/1/", + "variable_data": "/api/v2/hosts/2/variable_data/", + "groups": "/api/v2/hosts/2/groups/", + "all_groups": "/api/v2/hosts/2/all_groups/", + "job_events": "/api/v2/hosts/2/job_events/", + "job_host_summaries": "/api/v2/hosts/2/job_host_summaries/", + "activity_stream": "/api/v2/hosts/2/activity_stream/", + "inventory_sources": "/api/v2/hosts/2/inventory_sources/", + "smart_inventories": "/api/v2/hosts/2/smart_inventories/", + "ad_hoc_commands": "/api/v2/hosts/2/ad_hoc_commands/", + "ad_hoc_command_events": "/api/v2/hosts/2/ad_hoc_command_events/", + "insights": "/api/v2/hosts/2/insights/", + "ansible_facts": "/api/v2/hosts/2/ansible_facts/", + "inventory": "/api/v2/inventories/3/", + "last_job": "/api/v2/jobs/3/", + "last_job_host_summary": "/api/v2/job_host_summaries/1/" + }, + "summary_fields": { + "inventory": { + "id": 3, + "name": "Mikes Inventory", + "description": "", + "has_active_failures": false, + "total_hosts": 3, + "hosts_with_active_failures": 0, + "total_groups": 0, + "groups_with_active_failures": 0, + "has_inventory_sources": true, + "total_inventory_sources": 1, + "inventory_sources_with_failures": 0, + "organization_id": 3, + "kind": "" + }, + "last_job": { + "id": 3, + "name": "Ping", + "description": "", + "finished": "2019-10-28T21:29:08.880572Z", + "status": "successful", + "failed": false, + "job_template_id": 9, + "job_template_name": "Ping" + }, + "last_job_host_summary": { + "id": 1, + "failed": false + }, + "user_capabilities": { + "edit": true, + "delete": true + }, + "groups": { + "count": 0, + "results": [] + }, + "recent_jobs": [ + { + "id": 3, + "name": "Ping", + "status": "successful", + "finished": "2019-10-28T21:29:08.880572Z", + "type": "job" + } + ] + }, + "created": "2019-10-28T21:26:54.508081Z", + "modified": "2019-10-29T20:18:41.915796Z", + "name": "localhost", + "description": "localhost description", + "inventory": 3, + "enabled": true, + "instance_id": "", + "variables": "---\nansible_connection: local", + "has_active_failures": false, + "has_inventory_sources": false, + "last_job": 3, + "last_job_host_summary": 1, + "insights_system_id": null, + "ansible_facts_modified": null +} \ No newline at end of file diff --git a/awx/ui_next/src/screens/Inventory/shared/data.hosts.json b/awx/ui_next/src/screens/Inventory/shared/data.hosts.json new file mode 100644 index 0000000000..07c6ef7d9f --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/data.hosts.json @@ -0,0 +1,393 @@ + +{ + "count": 3, + "results": [ + { + "id": 2, + "type": "host", + "url": "/api/v2/hosts/2/", + "related": { + "created_by": "/api/v2/users/10/", + "modified_by": "/api/v2/users/19/", + "variable_data": "/api/v2/hosts/2/variable_data/", + "groups": "/api/v2/hosts/2/groups/", + "all_groups": "/api/v2/hosts/2/all_groups/", + "job_events": "/api/v2/hosts/2/job_events/", + "job_host_summaries": "/api/v2/hosts/2/job_host_summaries/", + "activity_stream": "/api/v2/hosts/2/activity_stream/", + "inventory_sources": "/api/v2/hosts/2/inventory_sources/", + "smart_inventories": "/api/v2/hosts/2/smart_inventories/", + "ad_hoc_commands": "/api/v2/hosts/2/ad_hoc_commands/", + "ad_hoc_command_events": "/api/v2/hosts/2/ad_hoc_command_events/", + "insights": "/api/v2/hosts/2/insights/", + "ansible_facts": "/api/v2/hosts/2/ansible_facts/", + "inventory": "/api/v2/inventories/2/", + "last_job": "/api/v2/jobs/236/", + "last_job_host_summary": "/api/v2/job_host_summaries/2202/" + }, + "summary_fields": { + "inventory": { + "id": 2, + "name": " Inventory 1 Org 0", + "description": "", + "has_active_failures": false, + "total_hosts": 33, + "hosts_with_active_failures": 0, + "total_groups": 4, + "has_inventory_sources": false, + "total_inventory_sources": 0, + "inventory_sources_with_failures": 0, + "organization_id": 2, + "kind": "" + }, + "last_job": { + "id": 236, + "name": " Job Template 1 Project 0", + "description": "", + "finished": "2020-02-26T03:15:21.471439Z", + "status": "successful", + "failed": false, + "job_template_id": 18, + "job_template_name": " Job Template 1 Project 0" + }, + "last_job_host_summary": { + "id": 2202, + "failed": false + }, + "created_by": { + "id": 10, + "username": "user-3", + "first_name": "", + "last_name": "" + }, + "modified_by": { + "id": 19, + "username": "all", + "first_name": "", + "last_name": "" + }, + "user_capabilities": { + "edit": true, + "delete": true + }, + "groups": { + "count": 2, + "results": [ + { + "id": 1, + "name": " Group 1 Inventory 0" + }, + { + "id": 2, + "name": " Group 2 Inventory 0" + } + ] + }, + "recent_jobs": [ + { + "id": 236, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-26T03:15:21.471439Z" + }, + { + "id": 232, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T21:20:33.593789Z" + }, + { + "id": 229, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T16:19:46.364134Z" + }, + { + "id": 228, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T16:18:54.138363Z" + }, + { + "id": 225, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T15:55:32.247652Z" + } + ] + }, + "created": "2020-02-24T15:10:58.922179Z", + "modified": "2020-02-26T21:52:43.428530Z", + "name": ".host-000001.group-00000.dummy", + "description": "", + "inventory": 2, + "enabled": false, + "instance_id": "", + "variables": "", + "has_active_failures": false, + "has_inventory_sources": false, + "last_job": 236, + "last_job_host_summary": 2202, + "insights_system_id": null, + "ansible_facts_modified": null + }, + { + "id": 3, + "type": "host", + "url": "/api/v2/hosts/3/", + "related": { + "created_by": "/api/v2/users/11/", + "modified_by": "/api/v2/users/1/", + "variable_data": "/api/v2/hosts/3/variable_data/", + "groups": "/api/v2/hosts/3/groups/", + "all_groups": "/api/v2/hosts/3/all_groups/", + "job_events": "/api/v2/hosts/3/job_events/", + "job_host_summaries": "/api/v2/hosts/3/job_host_summaries/", + "activity_stream": "/api/v2/hosts/3/activity_stream/", + "inventory_sources": "/api/v2/hosts/3/inventory_sources/", + "smart_inventories": "/api/v2/hosts/3/smart_inventories/", + "ad_hoc_commands": "/api/v2/hosts/3/ad_hoc_commands/", + "ad_hoc_command_events": "/api/v2/hosts/3/ad_hoc_command_events/", + "insights": "/api/v2/hosts/3/insights/", + "ansible_facts": "/api/v2/hosts/3/ansible_facts/", + "inventory": "/api/v2/inventories/2/", + "last_job": "/api/v2/jobs/236/", + "last_job_host_summary": "/api/v2/job_host_summaries/2195/" + }, + "summary_fields": { + "inventory": { + "id": 2, + "name": " Inventory 1 Org 0", + "description": "", + "has_active_failures": false, + "total_hosts": 33, + "hosts_with_active_failures": 0, + "total_groups": 4, + "has_inventory_sources": false, + "total_inventory_sources": 0, + "inventory_sources_with_failures": 0, + "organization_id": 2, + "kind": "" + }, + "last_job": { + "id": 236, + "name": " Job Template 1 Project 0", + "description": "", + "finished": "2020-02-26T03:15:21.471439Z", + "status": "successful", + "failed": false, + "job_template_id": 18, + "job_template_name": " Job Template 1 Project 0" + }, + "last_job_host_summary": { + "id": 2195, + "failed": false + }, + "created_by": { + "id": 11, + "username": "user-4", + "first_name": "", + "last_name": "" + }, + "modified_by": { + "id": 1, + "username": "admin", + "first_name": "", + "last_name": "" + }, + "user_capabilities": { + "edit": true, + "delete": true + }, + "groups": { + "count": 2, + "results": [ + { + "id": 1, + "name": " Group 1 Inventory 0" + }, + { + "id": 2, + "name": " Group 2 Inventory 0" + } + ] + }, + "recent_jobs": [ + { + "id": 236, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-26T03:15:21.471439Z" + }, + { + "id": 232, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T21:20:33.593789Z" + }, + { + "id": 229, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T16:19:46.364134Z" + }, + { + "id": 228, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T16:18:54.138363Z" + }, + { + "id": 225, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T15:55:32.247652Z" + } + ] + }, + "created": "2020-02-24T15:10:58.945113Z", + "modified": "2020-02-27T03:43:43.635871Z", + "name": ".host-000002.group-00000.dummy", + "description": "", + "inventory": 2, + "enabled": false, + "instance_id": "", + "variables": "", + "has_active_failures": false, + "has_inventory_sources": false, + "last_job": 236, + "last_job_host_summary": 2195, + "insights_system_id": null, + "ansible_facts_modified": null + }, + { + "id": 4, + "type": "host", + "url": "/api/v2/hosts/4/", + "related": { + "created_by": "/api/v2/users/12/", + "modified_by": "/api/v2/users/1/", + "variable_data": "/api/v2/hosts/4/variable_data/", + "groups": "/api/v2/hosts/4/groups/", + "all_groups": "/api/v2/hosts/4/all_groups/", + "job_events": "/api/v2/hosts/4/job_events/", + "job_host_summaries": "/api/v2/hosts/4/job_host_summaries/", + "activity_stream": "/api/v2/hosts/4/activity_stream/", + "inventory_sources": "/api/v2/hosts/4/inventory_sources/", + "smart_inventories": "/api/v2/hosts/4/smart_inventories/", + "ad_hoc_commands": "/api/v2/hosts/4/ad_hoc_commands/", + "ad_hoc_command_events": "/api/v2/hosts/4/ad_hoc_command_events/", + "insights": "/api/v2/hosts/4/insights/", + "ansible_facts": "/api/v2/hosts/4/ansible_facts/", + "inventory": "/api/v2/inventories/2/", + "last_job": "/api/v2/jobs/236/", + "last_job_host_summary": "/api/v2/job_host_summaries/2192/" + }, + "summary_fields": { + "inventory": { + "id": 2, + "name": " Inventory 1 Org 0", + "description": "", + "has_active_failures": false, + "total_hosts": 33, + "hosts_with_active_failures": 0, + "total_groups": 4, + "has_inventory_sources": false, + "total_inventory_sources": 0, + "inventory_sources_with_failures": 0, + "organization_id": 2, + "kind": "" + }, + "last_job": { + "id": 236, + "name": " Job Template 1 Project 0", + "description": "", + "finished": "2020-02-26T03:15:21.471439Z", + "status": "successful", + "failed": false, + "job_template_id": 18, + "job_template_name": " Job Template 1 Project 0" + }, + "last_job_host_summary": { + "id": 2192, + "failed": false + }, + "created_by": { + "id": 12, + "username": "user-5", + "first_name": "", + "last_name": "" + }, + "modified_by": { + "id": 1, + "username": "admin", + "first_name": "", + "last_name": "" + }, + "user_capabilities": { + "edit": true, + "delete": true + }, + "groups": { + "count": 2, + "results": [ + { + "id": 1, + "name": " Group 1 Inventory 0" + }, + { + "id": 2, + "name": " Group 2 Inventory 0" + } + ] + }, + "recent_jobs": [ + { + "id": 236, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-26T03:15:21.471439Z" + }, + { + "id": 232, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T21:20:33.593789Z" + }, + { + "id": 229, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T16:19:46.364134Z" + }, + { + "id": 228, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T16:18:54.138363Z" + }, + { + "id": 225, + "name": " Job Template 1 Project 0", + "status": "successful", + "finished": "2020-02-25T15:55:32.247652Z" + } + ] + }, + "created": "2020-02-24T15:10:58.962312Z", + "modified": "2020-02-27T03:43:45.528882Z", + "name": ".host-000003.group-00000.dummy", + "description": "", + "inventory": 2, + "enabled": false, + "instance_id": "", + "variables": "", + "has_active_failures": false, + "has_inventory_sources": false, + "last_job": 236, + "last_job_host_summary": 2192, + "insights_system_id": null, + "ansible_facts_modified": null + } + ] +} diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx index 3634c73b89..01f09e6c11 100644 --- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx +++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx @@ -164,7 +164,15 @@ function JobDetail({ job, i18n }) { {inventory.name} + + {inventory.name} + } /> )} diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx index f2b1a4f8ee..35145e3998 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx @@ -5,7 +5,7 @@ import { t } from '@lingui/macro'; import { Badge as PFBadge, Button, - DataListAction, + DataListAction as _DataListAction, DataListCell, DataListCheck, DataListItem, @@ -31,6 +31,13 @@ const ListGroup = styled.span` } `; +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 16px; + grid-template-columns: 40px; +`; + function OrganizationListItem({ organization, isSelected, @@ -82,7 +89,7 @@ function OrganizationListItem({ aria-labelledby={labelId} id={labelId} > - {organization.summary_fields.user_capabilities.edit && ( + {organization.summary_fields.user_capabilities.edit ? ( + ) : ( + '' )} diff --git a/awx/ui_next/src/screens/Project/Project.jsx b/awx/ui_next/src/screens/Project/Project.jsx index 9e401fde76..0016acdc39 100644 --- a/awx/ui_next/src/screens/Project/Project.jsx +++ b/awx/ui_next/src/screens/Project/Project.jsx @@ -9,7 +9,7 @@ import RoutedTabs from '@components/RoutedTabs'; import ContentError from '@components/ContentError'; import NotificationList from '@components/NotificationList'; import { ResourceAccessList } from '@components/ResourceAccessList'; -import ScheduleList from '@components/ScheduleList'; +import { Schedules } from '@components/Schedule'; import ProjectDetail from './ProjectDetail'; import ProjectEdit from './ProjectEdit'; import ProjectJobTemplatesList from './ProjectJobTemplatesList'; @@ -31,6 +31,7 @@ class Project extends Component { this.loadProject = this.loadProject.bind(this); this.loadProjectAndRoles = this.loadProjectAndRoles.bind(this); this.loadSchedules = this.loadSchedules.bind(this); + this.loadScheduleOptions = this.loadScheduleOptions.bind(this); } async componentDidMount() { @@ -104,13 +105,18 @@ class Project extends Component { } } + loadScheduleOptions() { + const { project } = this.state; + return ProjectsAPI.readScheduleOptions(project.id); + } + loadSchedules(params) { const { project } = this.state; - return ProjectsAPI.readScheduleList(project.id, params); + return ProjectsAPI.readSchedules(project.id, params); } render() { - const { location, match, me, i18n } = this.props; + const { location, match, me, i18n, setBreadcrumb } = this.props; const { project, @@ -169,7 +175,10 @@ class Project extends Component { cardHeader = null; } - if (location.pathname.endsWith('edit')) { + if ( + location.pathname.endsWith('edit') || + location.pathname.includes('schedules/') + ) { cardHeader = null; } @@ -241,7 +250,12 @@ class Project extends Component { ( - + )} /> )} diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx index b540be821a..92e337e892 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx @@ -126,7 +126,7 @@ class ProjectListItem extends React.Component { aria-labelledby={labelId} id={labelId} > - {project.summary_fields.user_capabilities.start && ( + {project.summary_fields.user_capabilities.start ? ( {handleSync => ( @@ -140,8 +140,10 @@ class ProjectListItem extends React.Component { )} + ) : ( + '' )} - {project.summary_fields.user_capabilities.edit && ( + {project.summary_fields.user_capabilities.edit ? ( + ) : ( + '' )} diff --git a/awx/ui_next/src/screens/Schedule/Schedules.jsx b/awx/ui_next/src/screens/Schedule/AllSchedules.jsx similarity index 64% rename from awx/ui_next/src/screens/Schedule/Schedules.jsx rename to awx/ui_next/src/screens/Schedule/AllSchedules.jsx index 0f7c9b0ee0..1f7ffa3188 100644 --- a/awx/ui_next/src/screens/Schedule/Schedules.jsx +++ b/awx/ui_next/src/screens/Schedule/AllSchedules.jsx @@ -4,11 +4,15 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import Breadcrumbs from '@components/Breadcrumbs'; -import ScheduleList from '@components/ScheduleList'; +import { ScheduleList } from '@components/Schedule'; import { SchedulesAPI } from '@api'; import { PageSection, Card } from '@patternfly/react-core'; -function Schedules({ i18n }) { +function AllSchedules({ i18n }) { + const loadScheduleOptions = () => { + return SchedulesAPI.readOptions(); + }; + const loadSchedules = params => { return SchedulesAPI.read(params); }; @@ -24,7 +28,11 @@ function Schedules({ i18n }) { - + @@ -33,4 +41,4 @@ function Schedules({ i18n }) { ); } -export default withI18n()(Schedules); +export default withI18n()(AllSchedules); diff --git a/awx/ui_next/src/screens/Schedule/Schedules.test.jsx b/awx/ui_next/src/screens/Schedule/AllSchedules.test.jsx similarity index 79% rename from awx/ui_next/src/screens/Schedule/Schedules.test.jsx rename to awx/ui_next/src/screens/Schedule/AllSchedules.test.jsx index ebdc67a046..50e4b76f66 100644 --- a/awx/ui_next/src/screens/Schedule/Schedules.test.jsx +++ b/awx/ui_next/src/screens/Schedule/AllSchedules.test.jsx @@ -1,9 +1,9 @@ import React from 'react'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { createMemoryHistory } from 'history'; -import Schedules from './Schedules'; +import AllSchedules from './AllSchedules'; -describe('', () => { +describe('', () => { let wrapper; afterEach(() => { @@ -11,7 +11,7 @@ describe('', () => { }); test('initially renders succesfully', () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts(); }); test('should display schedule list breadcrumb heading', () => { @@ -19,7 +19,7 @@ describe('', () => { initialEntries: ['/schedules'], }); - wrapper = mountWithContexts(, { + wrapper = mountWithContexts(, { context: { router: { history, diff --git a/awx/ui_next/src/screens/Schedule/index.js b/awx/ui_next/src/screens/Schedule/index.js index 64f2dedc84..3e38a47a40 100644 --- a/awx/ui_next/src/screens/Schedule/index.js +++ b/awx/ui_next/src/screens/Schedule/index.js @@ -1 +1 @@ -export { default } from './Schedules'; +export { default } from './AllSchedules'; diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx index 0b1e66e9d3..b915117d2e 100644 --- a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx +++ b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx @@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Button, - DataListAction, + DataListAction as _DataListAction, DataListCell, DataListCheck, DataListItem, @@ -12,11 +12,19 @@ import { DataListItemRow, Tooltip, } from '@patternfly/react-core'; +import styled from 'styled-components'; import { Link } from 'react-router-dom'; import { PencilAltIcon } from '@patternfly/react-icons'; import { Team } from '@types'; +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 16px; + grid-template-columns: 40px; +`; + class TeamListItem extends React.Component { static propTypes = { team: Team.isRequired, @@ -64,7 +72,7 @@ class TeamListItem extends React.Component { aria-labelledby={labelId} id={labelId} > - {team.summary_fields.user_capabilities.edit && ( + {team.summary_fields.user_capabilities.edit ? ( + ) : ( + '' )} diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx index 69a55550f1..5863abe8ea 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useHistory } from 'react-router-dom'; -import { Card } from '@patternfly/react-core'; +import { Card, PageSection } from '@patternfly/react-core'; import { CardBody } from '@components/Card'; import JobTemplateForm from '../shared/JobTemplateForm'; import { JobTemplatesAPI } from '@api'; @@ -61,15 +61,17 @@ function JobTemplateAdd() { } return ( - - - - - + + + + + + + ); } diff --git a/awx/ui_next/src/screens/Template/Template.jsx b/awx/ui_next/src/screens/Template/Template.jsx index 84839c1a3f..7cdd8ce032 100644 --- a/awx/ui_next/src/screens/Template/Template.jsx +++ b/awx/ui_next/src/screens/Template/Template.jsx @@ -10,11 +10,12 @@ import ContentError from '@components/ContentError'; import JobList from '@components/JobList'; import NotificationList from '@components/NotificationList'; import RoutedTabs from '@components/RoutedTabs'; -import ScheduleList from '@components/ScheduleList'; +import { Schedules } from '@components/Schedule'; import { ResourceAccessList } from '@components/ResourceAccessList'; import JobTemplateDetail from './JobTemplateDetail'; import JobTemplateEdit from './JobTemplateEdit'; import { JobTemplatesAPI, OrganizationsAPI } from '@api'; +import SurveyList from './shared/SurveyList'; class Template extends Component { constructor(props) { @@ -29,6 +30,7 @@ class Template extends Component { this.loadTemplate = this.loadTemplate.bind(this); this.loadTemplateAndRoles = this.loadTemplateAndRoles.bind(this); this.loadSchedules = this.loadSchedules.bind(this); + this.loadScheduleOptions = this.loadScheduleOptions.bind(this); } async componentDidMount() { @@ -83,13 +85,18 @@ class Template extends Component { } } + loadScheduleOptions() { + const { template } = this.state; + return JobTemplatesAPI.readScheduleOptions(template.id); + } + loadSchedules(params) { const { template } = this.state; - return JobTemplatesAPI.readScheduleList(template.id, params); + return JobTemplatesAPI.readSchedules(template.id, params); } render() { - const { i18n, location, match, me } = this.props; + const { i18n, location, match, me, setBreadcrumb } = this.props; const { contentError, hasContentLoading, @@ -111,17 +118,6 @@ class Template extends Component { }); } - tabsArray.push( - { - name: i18n._(t`Completed Jobs`), - link: `${match.url}/completed_jobs`, - }, - { - name: i18n._(t`Survey`), - link: '/home', - } - ); - if (template) { tabsArray.push({ name: i18n._(t`Schedules`), @@ -129,6 +125,21 @@ class Template extends Component { }); } + tabsArray.push( + { + name: i18n._(t`Schedules`), + link: `${match.url}/schedules`, + }, + { + name: i18n._(t`Completed Jobs`), + link: `${match.url}/completed_jobs`, + }, + { + name: i18n._(t`Survey`), + link: `${match.url}/survey`, + } + ); + tabsArray.forEach((tab, n) => { tab.id = n; }); @@ -142,7 +153,10 @@ class Template extends Component { ); - if (location.pathname.endsWith('edit')) { + if ( + location.pathname.endsWith('edit') || + location.pathname.includes('schedules/') + ) { cardHeader = null; } @@ -164,87 +178,103 @@ class Template extends Component { } return ( - - {cardHeader} - - - {template && ( - ( - - )} + + + {cardHeader} + + - )} - {template && ( + {template && ( + ( + + )} + /> + )} + {template && ( + } + /> + )} + {template && ( + ( + + )} + /> + )} + {template && ( + ( + + )} + /> + )} + {canSeeNotificationsTab && ( + ( + + )} + /> + )} + {template?.id && ( + + + + )} + {template && ( + } + /> + )} } + key="not-found" + path="*" + render={() => + !hasContentLoading && ( + + {match.params.id && ( + + {i18n._(`View Template Details`)} + + )} + + ) + } /> - )} - {template && ( - ( - - )} - /> - )} - {canSeeNotificationsTab && ( - ( - - )} - /> - )} - {template?.id && ( - - - - )} - {template && ( - } - /> - )} - - !hasContentLoading && ( - - {match.params.id && ( - - {i18n._(`View Template Details`)} - - )} - - ) - } - /> - - + + + ); } } diff --git a/awx/ui_next/src/screens/Template/Template.test.jsx b/awx/ui_next/src/screens/Template/Template.test.jsx index c4f0a36af5..2f3c56cc58 100644 --- a/awx/ui_next/src/screens/Template/Template.test.jsx +++ b/awx/ui_next/src/screens/Template/Template.test.jsx @@ -58,10 +58,11 @@ describe('