mirror of
https://github.com/ansible/awx.git
synced 2026-01-21 06:28:01 -03:30
Merge branch 'devel' into host-detail-labels
This commit is contained in:
commit
e733506477
24
Makefile
24
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 \
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = {}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
42
awx/ui_next/package-lock.json
generated
42
awx/ui_next/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
12
awx/ui_next/src/api/mixins/Schedules.mixin.js
Normal file
12
awx/ui_next/src/api/mixins/Schedules.mixin.js
Normal file
@ -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;
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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/`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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/`);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 }) {
|
||||
<label htmlFor={id} className="pf-c-form__label">
|
||||
<span className="pf-c-form__label-text">{label}</span>
|
||||
</label>
|
||||
{tooltip && <FieldTooltip content={tooltip} />}
|
||||
</SplitItem>
|
||||
<SplitItem>
|
||||
<MultiButtonToggle
|
||||
|
||||
@ -69,6 +69,22 @@ describe('VariablesField', () => {
|
||||
expect(field.prop('hasErrors')).toEqual(true);
|
||||
expect(wrapper.find('.pf-m-error')).toHaveLength(1);
|
||||
});
|
||||
it('should render tooltip', () => {
|
||||
const value = '---\n';
|
||||
const wrapper = mount(
|
||||
<Formik initialValues={{ variables: value }}>
|
||||
{() => (
|
||||
<VariablesField
|
||||
id="the-field"
|
||||
name="variables"
|
||||
label="Variables"
|
||||
tooltip="This is a tooltip"
|
||||
/>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
expect(wrapper.find('Tooltip').length).toBe(1);
|
||||
});
|
||||
|
||||
it('should submit value through Formik', async () => {
|
||||
const value = '---\nfoo: bar\n';
|
||||
|
||||
135
awx/ui_next/src/components/HostForm/HostForm.jsx
Normal file
135
awx/ui_next/src/components/HostForm/HostForm.jsx
Normal file
@ -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 (
|
||||
<FormGroup
|
||||
label={i18n._(t`Inventory`)}
|
||||
isRequired
|
||||
fieldId="inventory-lookup"
|
||||
isValid={!inventoryMeta.touched || !inventoryMeta.error}
|
||||
helperTextInvalid={inventoryMeta.error}
|
||||
>
|
||||
<FieldTooltip
|
||||
content={i18n._(t`Select the inventory that this host will belong to.`)}
|
||||
/>
|
||||
<InventoryLookup
|
||||
value={inventory}
|
||||
onBlur={() => 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}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
});
|
||||
|
||||
const HostForm = ({
|
||||
handleCancel,
|
||||
handleSubmit,
|
||||
host,
|
||||
isInventoryVisible,
|
||||
i18n,
|
||||
submitError,
|
||||
}) => {
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: host.name,
|
||||
description: host.description,
|
||||
inventory: host.inventory || '',
|
||||
variables: host.variables,
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{formik => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormColumnLayout>
|
||||
<FormField
|
||||
id="host-name"
|
||||
name="name"
|
||||
type="text"
|
||||
label={i18n._(t`Name`)}
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
/>
|
||||
<FormField
|
||||
id="host-description"
|
||||
name="description"
|
||||
type="text"
|
||||
label={i18n._(t`Description`)}
|
||||
/>
|
||||
{isInventoryVisible && <InventoryLookupField host={host} />}
|
||||
<FormFullWidthLayout>
|
||||
<VariablesField
|
||||
id="host-variables"
|
||||
name="variables"
|
||||
label={i18n._(t`Variables`)}
|
||||
/>
|
||||
</FormFullWidthLayout>
|
||||
{submitError && <FormSubmitError error={submitError} />}
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
</FormColumnLayout>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
@ -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('<HostForm />', () => {
|
||||
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(
|
||||
<HostForm
|
||||
host={mockData}
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
me={meConfig.me}
|
||||
handleSubmit={handleSubmit}
|
||||
handleCancel={handleCancel}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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('<HostForm />', () => {
|
||||
});
|
||||
|
||||
test('calls handleSubmit when form submitted', async () => {
|
||||
const handleSubmit = jest.fn();
|
||||
const wrapper = mountWithContexts(
|
||||
<HostForm
|
||||
host={mockData}
|
||||
handleSubmit={handleSubmit}
|
||||
handleCancel={jest.fn()}
|
||||
me={meConfig.me}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<HostForm
|
||||
host={mockData}
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={handleCancel}
|
||||
me={meConfig.me}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<HostForm
|
||||
host={mockData}
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
isInventoryVisible={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('InventoryLookupField').length).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -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({
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
{job.type !== 'system_job' &&
|
||||
job.summary_fields?.user_capabilities?.start && (
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
<Tooltip content={i18n._(t`Relaunch Job`)} position="top">
|
||||
<LaunchButton resource={job}>
|
||||
{({ handleRelaunch }) => (
|
||||
<Button variant="plain" onClick={handleRelaunch}>
|
||||
<RocketIcon />
|
||||
</Button>
|
||||
)}
|
||||
</LaunchButton>
|
||||
</Tooltip>
|
||||
</DataListAction>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
{job.type !== 'system_job' &&
|
||||
job.summary_fields?.user_capabilities?.start ? (
|
||||
<Tooltip content={i18n._(t`Relaunch Job`)} position="top">
|
||||
<LaunchButton resource={job}>
|
||||
{({ handleRelaunch }) => (
|
||||
<Button variant="plain" onClick={handleRelaunch}>
|
||||
<RocketIcon />
|
||||
</Button>
|
||||
)}
|
||||
</LaunchButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
);
|
||||
|
||||
@ -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 && <FieldTooltip content={tooltip} />}
|
||||
<Lookup
|
||||
id="credential"
|
||||
header={label}
|
||||
@ -97,6 +100,7 @@ function CredentialLookup({
|
||||
},
|
||||
]}
|
||||
readOnly={!canDelete}
|
||||
name="credential"
|
||||
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||
/>
|
||||
|
||||
@ -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 (
|
||||
<Lookup
|
||||
@ -91,6 +106,16 @@ function MultiCredentialsLookup(props) {
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => {
|
||||
return (
|
||||
<Fragment>
|
||||
{isVault && (
|
||||
<Alert
|
||||
variant="info"
|
||||
isInline
|
||||
css="margin-bottom: 20px;"
|
||||
title={i18n._(
|
||||
t`You cannot select multiple vault credentials with the same vault ID. Doing so will automatically deselect the other with the same vault ID.`
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{credentialTypes && credentialTypes.length > 0 && (
|
||||
<ToolbarItem css=" display: flex; align-items: center;">
|
||||
<div css="flex: 0 0 25%; margin-right: 32px">
|
||||
@ -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({
|
||||
|
||||
@ -12,7 +12,8 @@ describe('<MultiCredentialsLookup />', () => {
|
||||
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('<MultiCredentialsLookup />', () => {
|
||||
);
|
||||
});
|
||||
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('<MultiCredentialsLookup />', () => {
|
||||
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('<MultiCredentialsLookup />', () => {
|
||||
act(() => {
|
||||
optionsList.invoke('selectItem')({
|
||||
id: 5,
|
||||
kind: 'Machine',
|
||||
kind: 'vault',
|
||||
name: 'Cred 5',
|
||||
url: 'www.google.com',
|
||||
});
|
||||
@ -205,9 +208,115 @@ describe('<MultiCredentialsLookup />', () => {
|
||||
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(
|
||||
<MultiCredentialsLookup
|
||||
value={credentials}
|
||||
tooltip="This is credentials look up"
|
||||
onChange={onChange}
|
||||
onError={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
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(
|
||||
<MultiCredentialsLookup
|
||||
value={credentials}
|
||||
tooltip="This is credentials look up"
|
||||
onChange={onChange}
|
||||
onError={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
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' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -21,6 +21,7 @@ function MultiButtonToggle({ buttons, value, onChange }) {
|
||||
{buttons &&
|
||||
buttons.map(([buttonValue, buttonLabel]) => (
|
||||
<SmallButton
|
||||
aria-label={buttonLabel}
|
||||
key={buttonLabel}
|
||||
onClick={() => setValue(buttonValue)}
|
||||
variant={buttonValue === value ? 'primary' : 'secondary'}
|
||||
|
||||
140
awx/ui_next/src/components/Schedule/Schedule.jsx
Normal file
140
awx/ui_next/src/components/Schedule/Schedule.jsx
Normal file
@ -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: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{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 <ContentLoading />;
|
||||
}
|
||||
|
||||
if (
|
||||
schedule.summary_fields.unified_job_template.id !==
|
||||
parseInt(unifiedJobTemplate.id, 10)
|
||||
) {
|
||||
return (
|
||||
<ContentError>
|
||||
{schedule && (
|
||||
<Link to={`${pathRoot}schedules`}>{i18n._(t`View Schedules`)}</Link>
|
||||
)}
|
||||
</ContentError>
|
||||
);
|
||||
}
|
||||
|
||||
if (contentError) {
|
||||
return <ContentError error={contentError} />;
|
||||
}
|
||||
|
||||
let cardHeader = null;
|
||||
if (
|
||||
location.pathname.includes('schedules/') &&
|
||||
!location.pathname.endsWith('edit')
|
||||
) {
|
||||
cardHeader = (
|
||||
<TabbedCardHeader>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<CardActions>
|
||||
<CardCloseButton linkTo={`${pathRoot}schedules`} />
|
||||
</CardActions>
|
||||
</TabbedCardHeader>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{cardHeader}
|
||||
<Switch>
|
||||
<Redirect
|
||||
from={`${pathRoot}schedules/:scheduleId`}
|
||||
to={`${pathRoot}schedules/:scheduleId/details`}
|
||||
exact
|
||||
/>
|
||||
{schedule && [
|
||||
<Route
|
||||
key="details"
|
||||
path={`${pathRoot}schedules/:scheduleId/details`}
|
||||
render={() => {
|
||||
return <ScheduleDetail schedule={schedule} />;
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
<Route
|
||||
key="not-found"
|
||||
path="*"
|
||||
render={() => {
|
||||
return (
|
||||
<ContentError>
|
||||
{unifiedJobTemplate && (
|
||||
<Link to={`${pathRoot}details`}>
|
||||
{i18n._(t`View Details`)}
|
||||
</Link>
|
||||
)}
|
||||
</ContentError>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { Schedule as _Schedule };
|
||||
export default withI18n()(Schedule);
|
||||
110
awx/ui_next/src/components/Schedule/Schedule.test.jsx
Normal file
110
awx/ui_next/src/components/Schedule/Schedule.test.jsx
Normal file
@ -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('<Schedule />', () => {
|
||||
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(
|
||||
<Route
|
||||
path="/templates/job_template/:id/schedules"
|
||||
component={() => (
|
||||
<Schedule
|
||||
setBreadcrumb={() => {}}
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -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 <ContentLoading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ContentError error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
<ScheduleToggle schedule={schedule} css="padding-bottom: 40px" />
|
||||
<DetailList gutter="sm">
|
||||
<Detail label={i18n._(t`Name`)} value={name} />
|
||||
<Detail label={i18n._(t`Description`)} value={description} />
|
||||
<Detail
|
||||
label={i18n._(t`First Run`)}
|
||||
value={formatDateString(dtstart)}
|
||||
/>
|
||||
<Detail
|
||||
label={i18n._(t`Next Run`)}
|
||||
value={formatDateString(next_run)}
|
||||
/>
|
||||
<Detail label={i18n._(t`Last Run`)} value={formatDateString(dtend)} />
|
||||
<Detail label={i18n._(t`Local Time Zone`)} value={timezone} />
|
||||
<Detail label={i18n._(t`Repeat Frequency`)} value={repeatFrequency} />
|
||||
<ScheduleOccurrences preview={preview} />
|
||||
<UserDateDetail
|
||||
label={i18n._(t`Created`)}
|
||||
date={created}
|
||||
user={summary_fields.created_by}
|
||||
/>
|
||||
<UserDateDetail
|
||||
label={i18n._(t`Last Modified`)}
|
||||
date={modified}
|
||||
user={summary_fields.modified_by}
|
||||
/>
|
||||
{showPromptedFields && (
|
||||
<>
|
||||
<PromptTitle size="md" css="grid-column: 1 / -1;">
|
||||
{i18n._(t`Prompted Fields`)}
|
||||
</PromptTitle>
|
||||
<Detail label={i18n._(t`Job Type`)} value={job_type} />
|
||||
{inventory && summary_fields.inventory && (
|
||||
<Detail
|
||||
label={i18n._(t`Inventory`)}
|
||||
value={
|
||||
<Link
|
||||
to={`/inventories/${
|
||||
summary_fields.inventory.kind === 'smart'
|
||||
? 'smart_inventory'
|
||||
: 'inventory'
|
||||
}/${summary_fields.inventory.id}/details`}
|
||||
>
|
||||
{summary_fields.inventory.name}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Detail label={i18n._(t`SCM Branch`)} value={scm_branch} />
|
||||
<Detail label={i18n._(t`Limit`)} value={limit} />
|
||||
{typeof diff_mode === 'boolean' && (
|
||||
<Detail
|
||||
label={i18n._(t`Show Changes`)}
|
||||
value={diff_mode ? 'On' : 'Off'}
|
||||
/>
|
||||
)}
|
||||
{credentials && credentials.length > 0 && (
|
||||
<Detail
|
||||
fullWidth
|
||||
label={i18n._(t`Credentials`)}
|
||||
value={
|
||||
<ChipGroup numChips={5}>
|
||||
{credentials.map(c => (
|
||||
<CredentialChip key={c.id} credential={c} isReadOnly />
|
||||
))}
|
||||
</ChipGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{job_tags && job_tags.length > 0 && (
|
||||
<Detail
|
||||
fullWidth
|
||||
label={i18n._(t`Job Tags`)}
|
||||
value={
|
||||
<ChipGroup numChips={5}>
|
||||
{job_tags.split(',').map(jobTag => (
|
||||
<Chip key={jobTag} isReadOnly>
|
||||
{jobTag}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{skip_tags && skip_tags.length > 0 && (
|
||||
<Detail
|
||||
fullWidth
|
||||
label={i18n._(t`Skip Tags`)}
|
||||
value={
|
||||
<ChipGroup numChips={5}>
|
||||
{skip_tags.split(',').map(skipTag => (
|
||||
<Chip key={skipTag} isReadOnly>
|
||||
{skipTag}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DetailList>
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
ScheduleDetail.propTypes = {
|
||||
schedule: Schedule.isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(ScheduleDetail);
|
||||
@ -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('<ScheduleDetail />', () => {
|
||||
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(
|
||||
<Route
|
||||
path="/templates/job_template/:id/schedules/:scheduleId"
|
||||
component={() => <ScheduleDetail schedule={schedule} />}
|
||||
/>,
|
||||
{
|
||||
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(
|
||||
<Route
|
||||
path="/templates/job_template/:id/schedules/:scheduleId"
|
||||
component={() => <ScheduleDetail schedule={scheduleWithPrompts} />}
|
||||
/>,
|
||||
{
|
||||
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(
|
||||
<Route
|
||||
path="/templates/job_template/:id/schedules/:scheduleId"
|
||||
component={() => <ScheduleDetail schedule={schedule} />}
|
||||
/>,
|
||||
{
|
||||
context: {
|
||||
router: {
|
||||
history,
|
||||
route: {
|
||||
location: history.location,
|
||||
match: { params: { id: 1 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './ScheduleDetail';
|
||||
@ -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 (
|
||||
<>
|
||||
<PaginatedDataList
|
||||
@ -130,6 +149,14 @@ function ScheduleList({ i18n, loadSchedules }) {
|
||||
onSelectAll={handleSelectAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
...(canAdd
|
||||
? [
|
||||
<ToolbarAddButton
|
||||
key="add"
|
||||
linkTo={`${location.pathname}/add`}
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
<ToolbarDeleteButton
|
||||
key="delete"
|
||||
onDelete={handleDelete}
|
||||
@ -155,4 +182,13 @@ function ScheduleList({ i18n, loadSchedules }) {
|
||||
);
|
||||
}
|
||||
|
||||
ScheduleList.propTypes = {
|
||||
hideAddButton: bool,
|
||||
loadSchedules: func.isRequired,
|
||||
loadScheduleOptions: func.isRequired,
|
||||
};
|
||||
ScheduleList.defaultProps = {
|
||||
hideAddButton: false,
|
||||
};
|
||||
|
||||
export default withI18n()(ScheduleList);
|
||||
@ -3,7 +3,7 @@ import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import { SchedulesAPI } from '@api';
|
||||
import ScheduleList from './ScheduleList';
|
||||
import mockSchedules from './data.schedules.json';
|
||||
import mockSchedules from '../data.schedules.json';
|
||||
|
||||
jest.mock('@api/models/Schedules');
|
||||
|
||||
@ -11,6 +11,18 @@ SchedulesAPI.destroy = jest.fn();
|
||||
SchedulesAPI.update.mockResolvedValue({
|
||||
data: mockSchedules.results[0],
|
||||
});
|
||||
SchedulesAPI.read.mockResolvedValue({ data: mockSchedules });
|
||||
SchedulesAPI.readOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const loadSchedules = params => 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(
|
||||
<ScheduleList loadSchedules={loadSchedules} />
|
||||
<ScheduleList
|
||||
loadSchedules={loadSchedules}
|
||||
loadScheduleOptions={loadScheduleOptions}
|
||||
/>
|
||||
);
|
||||
});
|
||||
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(
|
||||
<ScheduleList
|
||||
loadSchedules={loadSchedules}
|
||||
loadScheduleOptions={loadScheduleOptions}
|
||||
hideAddButton
|
||||
/>
|
||||
);
|
||||
});
|
||||
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(<ScheduleList />);
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleList
|
||||
loadSchedules={loadSchedules}
|
||||
loadScheduleOptions={loadScheduleOptions}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
@ -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"
|
||||
>
|
||||
<ScheduleToggle schedule={schedule} />
|
||||
{schedule.summary_fields.user_capabilities.edit && (
|
||||
{schedule.summary_fields.user_capabilities.edit ? (
|
||||
<Tooltip content={i18n._(t`Edit Schedule`)} position="top">
|
||||
<Button
|
||||
css="grid-column: 2"
|
||||
@ -115,6 +115,8 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) {
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</DataListAction>,
|
||||
]}
|
||||
@ -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 (
|
||||
<>
|
||||
<DetailName
|
||||
component={TextListItemVariants.dt}
|
||||
fullWidth
|
||||
css="grid-column: 1 / -1"
|
||||
>
|
||||
<Split gutter="sm">
|
||||
<SplitItem>
|
||||
<OccurrencesLabel>
|
||||
<span>{i18n._(t`Occurrences`)}</span>
|
||||
<span>{i18n._(t`(Limited to first 10)`)}</span>
|
||||
</OccurrencesLabel>
|
||||
</SplitItem>
|
||||
<SplitItem>
|
||||
<MultiButtonToggle
|
||||
buttons={[['local', 'Local'], ['utc', 'UTC']]}
|
||||
value={mode}
|
||||
onChange={newMode => setMode(newMode)}
|
||||
/>
|
||||
</SplitItem>
|
||||
</Split>
|
||||
</DetailName>
|
||||
<DetailValue
|
||||
component={TextListItemVariants.dd}
|
||||
fullWidth
|
||||
css="grid-column: 1 / -1; margin-top: -10px"
|
||||
>
|
||||
{preview[mode].map(dateStr => (
|
||||
<div key={dateStr}>
|
||||
{mode === 'local'
|
||||
? formatDateString(dateStr)
|
||||
: formatDateStringUTC(dateStr)}
|
||||
</div>
|
||||
))}
|
||||
</DetailValue>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ScheduleOccurrences.propTypes = {
|
||||
preview: shape(),
|
||||
};
|
||||
|
||||
ScheduleOccurrences.defaultProps = {
|
||||
preview: { local: [], utc: [] },
|
||||
};
|
||||
|
||||
export default withI18n()(ScheduleOccurrences);
|
||||
@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import ScheduleOccurrences from './ScheduleOccurrences';
|
||||
|
||||
describe('<ScheduleOccurrences>', () => {
|
||||
let wrapper;
|
||||
describe('At least two dates passed in', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleOccurrences
|
||||
preview={{
|
||||
local: ['2020-03-16T00:00:00-04:00', '2020-03-30T00:00:00-04:00'],
|
||||
utc: ['2020-03-16T04:00:00Z', '2020-03-30T04:00:00Z'],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
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(
|
||||
<ScheduleOccurrences
|
||||
preview={{
|
||||
local: ['2020-03-16T00:00:00-04:00'],
|
||||
utc: ['2020-03-16T04:00:00Z'],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('ScheduleOccurrences').children().length).toBe(0);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './ScheduleOccurrences';
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './ScheduleToggle';
|
||||
43
awx/ui_next/src/components/Schedule/Schedules.jsx
Normal file
43
awx/ui_next/src/components/Schedule/Schedules.jsx
Normal file
@ -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 (
|
||||
<Switch>
|
||||
<Route
|
||||
key="details"
|
||||
path={`${match.path}/:scheduleId`}
|
||||
render={() => (
|
||||
<Schedule
|
||||
unifiedJobTemplate={unifiedJobTemplate}
|
||||
setBreadcrumb={setBreadcrumb}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
key="list"
|
||||
path={`${match.path}`}
|
||||
render={() => {
|
||||
return (
|
||||
<ScheduleList
|
||||
loadSchedules={loadSchedules}
|
||||
loadScheduleOptions={loadScheduleOptions}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
export { Schedules as _Schedules };
|
||||
export default withI18n()(Schedules);
|
||||
33
awx/ui_next/src/components/Schedule/Schedules.test.jsx
Normal file
33
awx/ui_next/src/components/Schedule/Schedules.test.jsx
Normal file
@ -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('<Schedules />', () => {
|
||||
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(
|
||||
<Schedules
|
||||
setBreadcrumb={() => {}}
|
||||
jobTemplate={jobTemplate}
|
||||
loadSchedules={() => {}}
|
||||
loadScheduleOptions={() => {}}
|
||||
/>,
|
||||
|
||||
{
|
||||
context: {
|
||||
router: { history, route: { location: history.location } },
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
expect(wrapper.length).toBe(1);
|
||||
});
|
||||
});
|
||||
6
awx/ui_next/src/components/Schedule/index.js
Normal file
6
awx/ui_next/src/components/Schedule/index.js
Normal file
@ -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';
|
||||
@ -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 ? (
|
||||
<Tooltip content={i18n._(t`Edit Credential`)} position="top">
|
||||
<Button
|
||||
variant="plain"
|
||||
@ -67,6 +74,8 @@ function CredentialListItem({
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
|
||||
@ -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: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{i18n._(t`Back to Hosts`)}
|
||||
</>
|
||||
),
|
||||
link: hostListUrl,
|
||||
id: 99,
|
||||
});
|
||||
}
|
||||
|
||||
let cardHeader = (
|
||||
<TabbedCardHeader>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<CardActions>
|
||||
<CardCloseButton linkTo={hostListUrl} />
|
||||
<CardCloseButton linkTo="/hosts" />
|
||||
</CardActions>
|
||||
</TabbedCardHeader>
|
||||
);
|
||||
@ -124,7 +96,7 @@ function Host({ inventory, i18n, setBreadcrumb }) {
|
||||
{contentError.response && contentError.response.status === 404 && (
|
||||
<span>
|
||||
{i18n._(`Host not found.`)}{' '}
|
||||
<Link to={hostListUrl}>{i18n._(`View all Hosts.`)}</Link>
|
||||
<Link to="/hosts">{i18n._(`View all Hosts.`)}</Link>
|
||||
</span>
|
||||
)}
|
||||
</ContentError>
|
||||
@ -132,72 +104,35 @@ function Host({ inventory, i18n, setBreadcrumb }) {
|
||||
);
|
||||
}
|
||||
|
||||
const redirect = hostsMatch ? (
|
||||
<Redirect from="/hosts/:id" to="/hosts/:id/details" exact />
|
||||
) : (
|
||||
<Redirect
|
||||
from="/inventories/inventory/:id/hosts/:hostId"
|
||||
to="/inventories/inventory/:id/hosts/:hostId/details"
|
||||
exact
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{cardHeader}
|
||||
<Switch>
|
||||
{redirect}
|
||||
{host && (
|
||||
<Route
|
||||
path={[
|
||||
'/hosts/:id/details',
|
||||
'/inventories/inventory/:id/hosts/:hostId/details',
|
||||
]}
|
||||
>
|
||||
<HostDetail
|
||||
host={host}
|
||||
onUpdateHost={newHost => setHost(newHost)}
|
||||
/>
|
||||
</Route>
|
||||
)}
|
||||
{host && (
|
||||
<Route
|
||||
path={[
|
||||
'/hosts/:id/edit',
|
||||
'/inventories/inventory/:id/hosts/:hostId/edit',
|
||||
]}
|
||||
render={() => <HostEdit host={host} />}
|
||||
/>
|
||||
)}
|
||||
{host && (
|
||||
<Route
|
||||
path="/hosts/:id/facts"
|
||||
render={() => <HostFacts host={host} />}
|
||||
/>
|
||||
)}
|
||||
{host && (
|
||||
<Route
|
||||
path="/hosts/:id/groups"
|
||||
render={() => <HostGroups host={host} />}
|
||||
/>
|
||||
)}
|
||||
{host?.id && (
|
||||
<Route
|
||||
path={[
|
||||
'/hosts/:id/completed_jobs',
|
||||
'/inventories/inventory/:id/hosts/:hostId/completed_jobs',
|
||||
]}
|
||||
>
|
||||
<Redirect from="/hosts/:id" to="/hosts/:id/details" exact />
|
||||
{host && [
|
||||
<Route path="/hosts/:id/details" key="details">
|
||||
<HostDetail host={host} />
|
||||
</Route>,
|
||||
<Route path="/hosts/:id/edit" key="edit">
|
||||
<HostEdit host={host} />
|
||||
</Route>,
|
||||
<Route path="/hosts/:id/facts" key="facts">
|
||||
<HostFacts host={host} />
|
||||
</Route>,
|
||||
<Route path="/hosts/:id/groups" key="groups">
|
||||
<HostGroups host={host} />
|
||||
</Route>,
|
||||
<Route path="/hosts/:id/completed_jobs" key="completed-jobs">
|
||||
<JobList defaultParams={{ job__hosts: host.id }} />
|
||||
</Route>
|
||||
)}
|
||||
</Route>,
|
||||
]}
|
||||
<Route
|
||||
key="not-found"
|
||||
path="*"
|
||||
render={() =>
|
||||
!hasContentLoading && (
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${baseUrl}/details`}>
|
||||
<Link to={`${match.url}/details`}>
|
||||
{i18n._(`View Host Details`)}
|
||||
</Link>
|
||||
</ContentError>
|
||||
|
||||
@ -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('<Host />', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
|
||||
HostsAPI.readDetail.mockResolvedValue({
|
||||
data: { ...mockDetails },
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<Host setBreadcrumb={() => {}} />);
|
||||
});
|
||||
});
|
||||
|
||||
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(<Host setBreadcrumb={() => {}} />, {
|
||||
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(<Host setBreadcrumb={() => {}} />, {
|
||||
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 () => {
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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('<HostAdd />', () => {
|
||||
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(<HostAdd />, {
|
||||
context: { router: { history } },
|
||||
@ -29,13 +34,12 @@ describe('<HostAdd />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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('<HostAdd />', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 }) {
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
|
||||
const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' }));
|
||||
return (
|
||||
<CardBody>
|
||||
<HostToggle
|
||||
host={host}
|
||||
onToggle={enabled =>
|
||||
onUpdateHost({
|
||||
...host,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
css="padding-bottom: 40px"
|
||||
/>
|
||||
<HostToggle host={host} css="padding-bottom: 40px" />
|
||||
<DetailList gutter="sm">
|
||||
<Detail label={i18n._(t`Name`)} value={name} dataCy="host-name" />
|
||||
<Detail
|
||||
value={<Sparkline jobs={recentPlaybookJobs} />}
|
||||
label={i18n._(t`Activity`)}
|
||||
value={<Sparkline jobs={recentPlaybookJobs} />}
|
||||
/>
|
||||
<Detail label={i18n._(t`Description`)} value={description} />
|
||||
{inventory && (
|
||||
<Detail
|
||||
label={i18n._(t`Inventory`)}
|
||||
dataCy="host-inventory"
|
||||
value={
|
||||
<Link
|
||||
to={`/inventories/${
|
||||
kind === 'smart' ? 'smart_inventory' : 'inventory'
|
||||
}/${inventoryId}/details`}
|
||||
>
|
||||
{inventory.name}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Detail
|
||||
label={i18n._(t`Inventory`)}
|
||||
dataCy="host-inventory"
|
||||
value={
|
||||
<Link to={`/inventories/inventory/${inventory.id}/details`}>
|
||||
{inventory.name}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<UserDateDetail
|
||||
date={created}
|
||||
label={i18n._(t`Created`)}
|
||||
@ -107,39 +88,46 @@ function HostDetail({ host, i18n, onUpdateHost }) {
|
||||
dataCy="host-created-by"
|
||||
/>
|
||||
<UserDateDetail
|
||||
date={modified}
|
||||
label={i18n._(t`Last Modified`)}
|
||||
user={modified_by}
|
||||
date={modified}
|
||||
dataCy="host-last-modified-by"
|
||||
/>
|
||||
<VariablesDetail
|
||||
value={host.variables}
|
||||
rows={4}
|
||||
label={i18n._(t`Variables`)}
|
||||
rows={4}
|
||||
value={variables}
|
||||
/>
|
||||
</DetailList>
|
||||
<CardActionsRow>
|
||||
{user_capabilities && user_capabilities.edit && (
|
||||
{user_capabilities?.edit && (
|
||||
<Button
|
||||
aria-label={i18n._(t`edit`)}
|
||||
component={Link}
|
||||
to={
|
||||
pathname.startsWith('/inventories')
|
||||
? `/inventories/inventory/${inventoryId}/hosts/${inventoryHostId}/edit`
|
||||
: `/hosts/${id}/edit`
|
||||
}
|
||||
to={`/hosts/${id}/edit`}
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
)}
|
||||
{user_capabilities && user_capabilities.delete && (
|
||||
{user_capabilities?.delete && (
|
||||
<DeleteButton
|
||||
onConfirm={() => handleHostDelete()}
|
||||
modalTitle={i18n._(t`Delete Host`)}
|
||||
name={host.name}
|
||||
name={name}
|
||||
/>
|
||||
)}
|
||||
</CardActionsRow>
|
||||
{deletionError && (
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={() => setDeletionError(null)}
|
||||
>
|
||||
{i18n._(t`Failed to delete host.`)}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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('<HostDetail />', () => {
|
||||
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(<HostDetail host={mockHost} />);
|
||||
describe('User has edit permissions', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = mountWithContexts(<HostDetail host={mockHost} />);
|
||||
});
|
||||
|
||||
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(<HostDetail host={mockHost} />);
|
||||
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(<HostDetail host={mockHost} />);
|
||||
const editButton = wrapper.find('Button[aria-label="edit"]');
|
||||
expect(editButton.text()).toEqual('Edit');
|
||||
expect(editButton.prop('to')).toBe('/hosts/1/edit');
|
||||
});
|
||||
wrapper = mountWithContexts(<HostDetail host={mockHost} />);
|
||||
});
|
||||
|
||||
test('should hide edit button for users without edit permission', async () => {
|
||||
const readOnlyHost = { ...mockHost };
|
||||
readOnlyHost.summary_fields.user_capabilities.edit = false;
|
||||
const wrapper = mountWithContexts(<HostDetail host={readOnlyHost} />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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('<HostEdit />', () => {
|
||||
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(<HostEdit host={mockData} />);
|
||||
|
||||
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(<HostEdit host={mockHost} />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should navigate to host detail when cancel is clicked', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/hosts/1/edit'],
|
||||
});
|
||||
const wrapper = mountWithContexts(<HostEdit host={mockData} />, {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}
|
||||
>
|
||||
<HostToggle host={host} />
|
||||
{host.summary_fields.user_capabilities.edit && (
|
||||
{host.summary_fields.user_capabilities.edit ? (
|
||||
<Tooltip content={i18n._(t`Edit Host`)} position="top">
|
||||
<Button
|
||||
variant="plain"
|
||||
@ -87,6 +87,8 @@ function HostListItem({ i18n, host, isSelected, onSelect, detailUrl }) {
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
|
||||
@ -30,4 +30,23 @@ describe('<Hosts />', () => {
|
||||
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(<Hosts />, {
|
||||
context: { router: { history, route: { match } } },
|
||||
});
|
||||
|
||||
expect(wrapper.find('Host').length).toBe(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<FormField
|
||||
id="host-name"
|
||||
name="name"
|
||||
type="text"
|
||||
label={i18n._(t`Name`)}
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
/>
|
||||
<FormField
|
||||
id="host-description"
|
||||
name="description"
|
||||
type="text"
|
||||
label={i18n._(t`Description`)}
|
||||
/>
|
||||
{hostAddMatch && (
|
||||
<FormGroup
|
||||
label={i18n._(t`Inventory`)}
|
||||
isRequired
|
||||
fieldId="inventory-lookup"
|
||||
isValid={!inventoryMeta.touched || !inventoryMeta.error}
|
||||
helperTextInvalid={inventoryMeta.error}
|
||||
>
|
||||
<FieldTooltip
|
||||
content={i18n._(
|
||||
t`Select the inventory that this host will belong to.`
|
||||
)}
|
||||
/>
|
||||
<InventoryLookup
|
||||
value={inventory}
|
||||
onBlur={() => 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}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
<FormFullWidthLayout>
|
||||
<VariablesField
|
||||
id="host-variables"
|
||||
name="variables"
|
||||
label={i18n._(t`Variables`)}
|
||||
/>
|
||||
</FormFullWidthLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HostForm({ handleSubmit, host, submitError, handleCancel, ...rest }) {
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: host.name,
|
||||
description: host.description,
|
||||
inventory: host.inventory || '',
|
||||
variables: host.variables,
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{formik => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormColumnLayout>
|
||||
<HostFormFields host={host} {...rest} />
|
||||
<FormSubmitError error={submitError} />
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
</FormColumnLayout>
|
||||
</Form>
|
||||
)}
|
||||
</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);
|
||||
@ -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 });
|
||||
};
|
||||
|
||||
@ -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 }) {
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
<Route
|
||||
key="hosts"
|
||||
path="/inventories/inventory/:id/groups/:groupId/nested_hosts"
|
||||
>
|
||||
<InventoryGroupHosts />
|
||||
</Route>
|
||||
<Route
|
||||
key="not-found"
|
||||
path="*"
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
import React, { useState } from 'react';
|
||||
import { func } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownPosition,
|
||||
DropdownToggle,
|
||||
} from '@patternfly/react-core';
|
||||
|
||||
function AddHostDropdown({ i18n, onAddNew, onAddExisting }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const dropdownItems = [
|
||||
<DropdownItem
|
||||
key="add-new"
|
||||
aria-label="add new host"
|
||||
component="button"
|
||||
onClick={onAddNew}
|
||||
>
|
||||
{i18n._(t`Add New Host`)}
|
||||
</DropdownItem>,
|
||||
<DropdownItem
|
||||
key="add-existing"
|
||||
aria-label="add existing host"
|
||||
component="button"
|
||||
onClick={onAddExisting}
|
||||
>
|
||||
{i18n._(t`Add Existing Host`)}
|
||||
</DropdownItem>,
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
isOpen={isOpen}
|
||||
position={DropdownPosition.right}
|
||||
toggle={
|
||||
<DropdownToggle
|
||||
id="add-host-dropdown"
|
||||
aria-label="add host"
|
||||
isPrimary
|
||||
onToggle={() => setIsOpen(prevState => !prevState)}
|
||||
>
|
||||
{i18n._(t`Add`)}
|
||||
</DropdownToggle>
|
||||
}
|
||||
dropdownItems={dropdownItems}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
AddHostDropdown.propTypes = {
|
||||
onAddNew: func.isRequired,
|
||||
onAddExisting: func.isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(AddHostDropdown);
|
||||
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import AddHostDropdown from './AddHostDropdown';
|
||||
|
||||
describe('<AddHostDropdown />', () => {
|
||||
let wrapper;
|
||||
let dropdownToggle;
|
||||
const onAddNew = jest.fn();
|
||||
const onAddExisting = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<AddHostDropdown onAddNew={onAddNew} onAddExisting={onAddExisting} />
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -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 (
|
||||
<>
|
||||
<PaginatedDataList
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading}
|
||||
items={hosts}
|
||||
itemCount={hostCount}
|
||||
pluralizedItemName={i18n._(t`Hosts`)}
|
||||
qsConfig={QS_CONFIG}
|
||||
onRowClick={handleSelect}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
renderToolbar={props => (
|
||||
<DataListToolbar
|
||||
{...props}
|
||||
showSelectAll
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={handleSelectAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
...(canAdd
|
||||
? [
|
||||
<AddHostDropdown
|
||||
onAddExisting={() => setIsModalOpen(true)}
|
||||
onAddNew={() => history.push(addFormUrl)}
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
// TODO HOST DISASSOCIATE BUTTON
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderItem={o => (
|
||||
<InventoryGroupHostListItem
|
||||
key={o.id}
|
||||
host={o}
|
||||
detailUrl={`/inventories/inventory/${inventoryId}/hosts/${o.id}/details`}
|
||||
editUrl={`/inventories/inventory/${inventoryId}/hosts/${o.id}/edit`}
|
||||
isSelected={selected.some(row => row.id === o.id)}
|
||||
onSelect={() => handleSelect(o)}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={
|
||||
canAdd && (
|
||||
<AddHostDropdown
|
||||
onAddExisting={() => setIsModalOpen(true)}
|
||||
onAddNew={() => history.push(addFormUrl)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* DISASSOCIATE HOST MODAL PLACEHOLDER */}
|
||||
|
||||
{isModalOpen && (
|
||||
<AlertModal
|
||||
isOpen={isModalOpen}
|
||||
variant="info"
|
||||
title={i18n._(t`Select Hosts`)}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
>
|
||||
{/* ADD/ASSOCIATE HOST MODAL PLACEHOLDER */}
|
||||
{i18n._(t`Host Select Modal`)}
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(InventoryGroupHostList);
|
||||
@ -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('<InventoryGroupHostList />', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(async () => {
|
||||
GroupsAPI.readAllHosts.mockResolvedValue({
|
||||
data: { ...mockHosts },
|
||||
});
|
||||
InventoriesAPI.readHostsOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventoryGroupHostList />);
|
||||
});
|
||||
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(<InventoryGroupHostList />);
|
||||
});
|
||||
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(<InventoryGroupHostList />, {
|
||||
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(<InventoryGroupHostList />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||
});
|
||||
});
|
||||
@ -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 (
|
||||
<DataListItem key={host.id} aria-labelledby={labelId} id={`${host.id}`}>
|
||||
<DataListItemRow>
|
||||
<DataListCheck
|
||||
id={`select-host-${host.id}`}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="name">
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{host.name}</b>
|
||||
</Link>
|
||||
</DataListCell>,
|
||||
<DataListCell key="recentJobs">
|
||||
<Sparkline jobs={recentPlaybookJobs} />
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
<HostToggle css="grid-column: 1" host={host} />
|
||||
{host.summary_fields.user_capabilities?.edit && (
|
||||
<Tooltip content={i18n._(t`Edit Host`)} position="top">
|
||||
<Button
|
||||
css="grid-column: 2"
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`${editUrl}`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
);
|
||||
}
|
||||
|
||||
InventoryGroupHostListItem.propTypes = {
|
||||
detailUrl: string.isRequired,
|
||||
editUrl: string.isRequired,
|
||||
host: Host.isRequired,
|
||||
isSelected: bool.isRequired,
|
||||
onSelect: func.isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(InventoryGroupHostListItem);
|
||||
@ -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('<InventoryGroupHostListItem />', () => {
|
||||
let wrapper;
|
||||
const mockHost = mockHosts.results[0];
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<InventoryGroupHostListItem
|
||||
detailUrl="/host/1"
|
||||
editUrl="/host/1"
|
||||
host={mockHost}
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<InventoryGroupHostListItem
|
||||
detailUrl="/host/1"
|
||||
editUrl="/host/1"
|
||||
host={mockHost}
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Switch, Route } from 'react-router-dom';
|
||||
import InventoryGroupHostList from './InventoryGroupHostList';
|
||||
|
||||
function InventoryGroupHosts() {
|
||||
return (
|
||||
<Switch>
|
||||
{/* Route to InventoryGroupHostAddForm */}
|
||||
<Route path="/inventories/inventory/:id/groups/:groupId/nested_hosts">
|
||||
<InventoryGroupHostList />
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
export default InventoryGroupHosts;
|
||||
@ -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('<InventoryGroupHosts />', () => {
|
||||
let wrapper;
|
||||
|
||||
test('initially renders successfully', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/inventory/1/groups/1/nested_hosts'],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventoryGroupHosts />, {
|
||||
context: {
|
||||
router: { history, route: { location: history.location } },
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(wrapper.length).toBe(1);
|
||||
expect(wrapper.find('InventoryGroupHostList').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryGroupHosts';
|
||||
@ -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: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{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 = (
|
||||
<TabbedCardHeader>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<CardActions>
|
||||
<CardCloseButton linkTo={hostListUrl} />
|
||||
</CardActions>
|
||||
</TabbedCardHeader>
|
||||
);
|
||||
|
||||
if (location.pathname.endsWith('edit')) {
|
||||
cardHeader = null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
if (!isLoading && contentError) {
|
||||
return (
|
||||
<Card>
|
||||
<ContentError error={contentError}>
|
||||
{contentError.response && contentError.response.status === 404 && (
|
||||
<span>
|
||||
{i18n._(`Host not found.`)}{' '}
|
||||
<Link to={hostListUrl}>
|
||||
{i18n._(`View all Inventory Hosts.`)}
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</ContentError>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{cardHeader}
|
||||
<Switch>
|
||||
<Redirect
|
||||
from="/inventories/inventory/:id/hosts/:hostId"
|
||||
to="/inventories/inventory/:id/hosts/:hostId/details"
|
||||
exact
|
||||
/>
|
||||
{host &&
|
||||
inventory && [
|
||||
<Route
|
||||
key="details"
|
||||
path="/inventories/inventory/:id/hosts/:hostId/details"
|
||||
>
|
||||
<InventoryHostDetail host={host} />
|
||||
</Route>,
|
||||
<Route
|
||||
key="edit"
|
||||
path="/inventories/inventory/:id/hosts/:hostId/edit"
|
||||
>
|
||||
<InventoryHostEdit host={host} inventory={inventory} />
|
||||
</Route>,
|
||||
<Route
|
||||
key="completed-jobs"
|
||||
path="/inventories/inventory/:id/hosts/:hostId/completed_jobs"
|
||||
>
|
||||
<JobList defaultParams={{ job__hosts: host.id }} />
|
||||
</Route>,
|
||||
]}
|
||||
<Route
|
||||
key="not-found"
|
||||
path="*"
|
||||
render={() =>
|
||||
!isLoading && (
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${match.url}/details`}>
|
||||
{i18n._(`View Inventory Host Details`)}
|
||||
</Link>
|
||||
</ContentError>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(InventoryHost);
|
||||
@ -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('<InventoryHost />', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InventoryHost inventory={mockInventory} setBreadcrumb={() => {}} />
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
<InventoryHost inventory={mockInventory} setBreadcrumb={() => {}} />
|
||||
);
|
||||
});
|
||||
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(
|
||||
<InventoryHost inventory={mockInventory} setBreadcrumb={() => {}} />,
|
||||
{ context: { router: { history } } }
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/screens/Inventory/InventoryHost/index.js
Normal file
1
awx/ui_next/src/screens/Inventory/InventoryHost/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './InventoryHost';
|
||||
@ -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 (
|
||||
<CardBody>
|
||||
<HostForm
|
||||
handleSubmit={handleSubmit}
|
||||
handleCancel={handleCancel}
|
||||
isInventoryVisible={false}
|
||||
submitError={formError}
|
||||
/>
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default InventoryHostAdd;
|
||||
@ -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('<InventoryHostAdd />', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
|
||||
beforeAll(async () => {
|
||||
history = createMemoryHistory();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventoryHostAdd inventory={{ id: 3 }} />, {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryHostAdd';
|
||||
@ -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 (
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={() => setDeletionError(false)}
|
||||
>
|
||||
{i18n._(t`Failed to delete ${name}.`)}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
|
||||
const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' }));
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
<HostToggle host={host} css="padding-bottom: 40px" />
|
||||
<DetailList gutter="sm">
|
||||
<Detail label={i18n._(t`Name`)} value={name} />
|
||||
<Detail
|
||||
label={i18n._(t`Activity`)}
|
||||
value={<Sparkline jobs={recentPlaybookJobs} />}
|
||||
/>
|
||||
<Detail label={i18n._(t`Description`)} value={description} />
|
||||
<UserDateDetail
|
||||
date={created}
|
||||
label={i18n._(t`Created`)}
|
||||
user={created_by}
|
||||
/>
|
||||
<UserDateDetail
|
||||
date={modified}
|
||||
label={i18n._(t`Last Modified`)}
|
||||
user={modified_by}
|
||||
/>
|
||||
<VariablesDetail
|
||||
label={i18n._(t`Variables`)}
|
||||
rows={4}
|
||||
value={variables}
|
||||
/>
|
||||
</DetailList>
|
||||
<CardActionsRow>
|
||||
{user_capabilities?.edit && (
|
||||
<Button
|
||||
aria-label={i18n._(t`edit`)}
|
||||
component={Link}
|
||||
to={`/inventories/inventory/${inventory.id}/hosts/${id}/edit`}
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
)}
|
||||
{user_capabilities?.delete && (
|
||||
<DeleteButton
|
||||
name={name}
|
||||
modalTitle={i18n._(t`Delete Host`)}
|
||||
onConfirm={() => handleHostDelete()}
|
||||
/>
|
||||
)}
|
||||
</CardActionsRow>
|
||||
{deletionError && (
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={() => setDeletionError(null)}
|
||||
>
|
||||
{i18n._(t`Failed to delete host.`)}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
InventoryHostDetail.propTypes = {
|
||||
host: Host.isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(InventoryHostDetail);
|
||||
@ -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('<InventoryHostDetail />', () => {
|
||||
let wrapper;
|
||||
|
||||
describe('User has edit permissions', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = mountWithContexts(<InventoryHostDetail host={mockHost} />);
|
||||
});
|
||||
|
||||
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(<InventoryHostDetail host={mockHost} />);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('should hide edit button for users without edit permission', async () => {
|
||||
expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryHostDetail';
|
||||
@ -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 (
|
||||
<CardBody>
|
||||
<HostForm
|
||||
host={host}
|
||||
handleSubmit={handleSubmit}
|
||||
handleCancel={handleCancel}
|
||||
isInventoryVisible={false}
|
||||
submitError={formError}
|
||||
/>
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
InventoryHostEdit.propTypes = {
|
||||
host: PropTypes.shape().isRequired,
|
||||
};
|
||||
|
||||
export default InventoryHostEdit;
|
||||
@ -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('<InventoryHostEdit />', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
|
||||
const updatedHostData = {
|
||||
name: 'new name',
|
||||
description: 'new description',
|
||||
variables: '---\nfoo: bar',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
history = createMemoryHistory();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InventoryHostEdit host={mockHost} inventory={{ id: 123 }} />,
|
||||
{
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryHostEdit';
|
||||
@ -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 (
|
||||
<Switch>
|
||||
<Route key="host-add" path="/inventories/inventory/:id/hosts/add">
|
||||
<HostAdd />
|
||||
<InventoryHostAdd inventory={inventory} />
|
||||
</Route>
|
||||
<Route key="host" path="/inventories/inventory/:id/hosts/:hostId">
|
||||
<InventoryHost setBreadcrumb={setBreadcrumb} inventory={inventory} />
|
||||
</Route>
|
||||
<Route key="host-list" path="/inventories/inventory/:id/hosts">
|
||||
<InventoryHostList />
|
||||
</Route>
|
||||
<Route
|
||||
key="host"
|
||||
path="/inventories/inventory/:id/hosts/:hostId"
|
||||
render={() => (
|
||||
<Host setBreadcrumb={setBreadcrumb} inventory={inventory} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
key="host-list"
|
||||
path="/inventories/inventory/:id/hosts/"
|
||||
render={() => <InventoryHostList setBreadcrumb={setBreadcrumb} />}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import InventoryHosts from './InventoryHosts';
|
||||
|
||||
describe('<InventoryHosts />', () => {
|
||||
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(<InventoryHosts />, {
|
||||
context: { router: { history, route: { match } } },
|
||||
});
|
||||
|
||||
expect(wrapper.find('InventoryHostList').length).toBe(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@ -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 ? (
|
||||
<Tooltip content={i18n._(t`Edit Inventory`)} position="top">
|
||||
<Button
|
||||
variant="plain"
|
||||
@ -72,6 +80,8 @@ class InventoryListItem extends React.Component {
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
|
||||
86
awx/ui_next/src/screens/Inventory/shared/data.host.json
Normal file
86
awx/ui_next/src/screens/Inventory/shared/data.host.json
Normal file
@ -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
|
||||
}
|
||||
393
awx/ui_next/src/screens/Inventory/shared/data.hosts.json
Normal file
393
awx/ui_next/src/screens/Inventory/shared/data.hosts.json
Normal file
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -164,7 +164,15 @@ function JobDetail({ job, i18n }) {
|
||||
<Detail
|
||||
label={i18n._(t`Inventory`)}
|
||||
value={
|
||||
<Link to={`/inventory/${inventory.id}`}>{inventory.name}</Link>
|
||||
<Link
|
||||
to={
|
||||
inventory.kind === 'smart'
|
||||
? `/inventories/smart_inventory/${inventory.id}`
|
||||
: `/inventories/inventory/${inventory.id}`
|
||||
}
|
||||
>
|
||||
{inventory.name}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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 ? (
|
||||
<Tooltip content={i18n._(t`Edit Organization`)} position="top">
|
||||
<Button
|
||||
variant="plain"
|
||||
@ -92,6 +99,8 @@ function OrganizationListItem({
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
|
||||
@ -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 {
|
||||
<Route
|
||||
path="/projects/:id/schedules"
|
||||
render={() => (
|
||||
<ScheduleList loadSchedules={this.loadSchedules} />
|
||||
<Schedules
|
||||
setBreadcrumb={setBreadcrumb}
|
||||
unifiedJobTemplate={project}
|
||||
loadSchedules={this.loadSchedules}
|
||||
loadScheduleOptions={this.loadScheduleOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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 ? (
|
||||
<Tooltip content={i18n._(t`Sync Project`)} position="top">
|
||||
<ProjectSyncButton projectId={project.id}>
|
||||
{handleSync => (
|
||||
@ -140,8 +140,10 @@ class ProjectListItem extends React.Component {
|
||||
)}
|
||||
</ProjectSyncButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
{project.summary_fields.user_capabilities.edit && (
|
||||
{project.summary_fields.user_capabilities.edit ? (
|
||||
<Tooltip content={i18n._(t`Edit Project`)} position="top">
|
||||
<Button
|
||||
css="grid-column: 2"
|
||||
@ -152,6 +154,8 @@ class ProjectListItem extends React.Component {
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
|
||||
@ -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 }) {
|
||||
<Route path="/schedules">
|
||||
<PageSection>
|
||||
<Card>
|
||||
<ScheduleList loadSchedules={loadSchedules} />
|
||||
<ScheduleList
|
||||
loadSchedules={loadSchedules}
|
||||
loadScheduleOptions={loadScheduleOptions}
|
||||
hideAddButton
|
||||
/>
|
||||
</Card>
|
||||
</PageSection>
|
||||
</Route>
|
||||
@ -33,4 +41,4 @@ function Schedules({ i18n }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(Schedules);
|
||||
export default withI18n()(AllSchedules);
|
||||
@ -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('<Schedules />', () => {
|
||||
describe('<AllSchedules />', () => {
|
||||
let wrapper;
|
||||
|
||||
afterEach(() => {
|
||||
@ -11,7 +11,7 @@ describe('<Schedules />', () => {
|
||||
});
|
||||
|
||||
test('initially renders succesfully', () => {
|
||||
wrapper = mountWithContexts(<Schedules />);
|
||||
wrapper = mountWithContexts(<AllSchedules />);
|
||||
});
|
||||
|
||||
test('should display schedule list breadcrumb heading', () => {
|
||||
@ -19,7 +19,7 @@ describe('<Schedules />', () => {
|
||||
initialEntries: ['/schedules'],
|
||||
});
|
||||
|
||||
wrapper = mountWithContexts(<Schedules />, {
|
||||
wrapper = mountWithContexts(<AllSchedules />, {
|
||||
context: {
|
||||
router: {
|
||||
history,
|
||||
@ -1 +1 @@
|
||||
export { default } from './Schedules';
|
||||
export { default } from './AllSchedules';
|
||||
|
||||
@ -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 ? (
|
||||
<Tooltip content={i18n._(t`Edit Team`)} position="top">
|
||||
<Button
|
||||
variant="plain"
|
||||
@ -74,6 +82,8 @@ class TeamListItem extends React.Component {
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user