Merge branch 'devel' into host-detail-labels

This commit is contained in:
Daniel Sami 2020-03-06 15:50:58 -05:00 committed by GitHub
commit e733506477
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
127 changed files with 5657 additions and 958 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +1 @@
export { default } from './Schedules';
export { default } from './AllSchedules';

View File

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