Merge pull request #35 from ansible/devel

Rebase
This commit is contained in:
Sean Sullivan
2021-01-29 09:27:42 -06:00
committed by GitHub
177 changed files with 6500 additions and 1812 deletions

3
.gitignore vendored
View File

@@ -149,3 +149,6 @@ use_dev_supervisor.txt
/tools/docker-compose/overrides/ /tools/docker-compose/overrides/
/awx/ui_next/.ui-built /awx/ui_next/.ui-built
/Dockerfile /Dockerfile
/_build/
/_build_kube_dev/
/Dockerfile.kube-dev

View File

@@ -2,6 +2,34 @@
This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<version>`. This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<version>`.
# 17.0.1 (January 26, 2021)
- Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152
- Fixed a bug in the UI which caused toggle settings to not be changed when clicked: https://github.com/ansible/awx/pull/9093
# 17.0.0 (January 22, 2021)
- AWX now requires PostgreSQL 12 by default: https://github.com/ansible/awx/pull/8943
**Note:** users who encounter permissions errors at upgrade time should `chown -R ~/.awx/pgdocker` to ensure it's owned by the user running the install playbook
- Added support for region name for OpenStack inventory: https://github.com/ansible/awx/issues/5080
- Added the ability to chain undefined attributes in custom notification templates: https://github.com/ansible/awx/issues/8677
- Dramatically simplified the `image_build` role: https://github.com/ansible/awx/pull/8980
- Fixed a bug which can cause schema migrations to fail at install time: https://github.com/ansible/awx/issues/9077
- Fixed a bug which caused the `is_superuser` user property to be out of date in certain circumstances: https://github.com/ansible/awx/pull/8833
- Fixed a bug which sometimes results in race conditions on setting access: https://github.com/ansible/awx/pull/8580
- Fixed a bug which sometimes causes an unexpected delay in stdout for some playbooks: https://github.com/ansible/awx/issues/9085
- (UI) Added support for credential password prompting on job launch: https://github.com/ansible/awx/pull/9028
- (UI) Added the ability to configure LDAP settings in the UI: https://github.com/ansible/awx/issues/8291
- (UI) Added a sync button to the Project detail view: https://github.com/ansible/awx/issues/8847
- (UI) Added a form for configuring Google Outh 2.0 settings: https://github.com/ansible/awx/pull/8762
- (UI) Added searchable keys and related keys to the Credentials list: https://github.com/ansible/awx/issues/8603
- (UI) Added support for advanced search and copying to Notification Templates: https://github.com/ansible/awx/issues/7879
- (UI) Added support for prompting on workflow nodes: https://github.com/ansible/awx/issues/5913
- (UI) Added support for session timeouts: https://github.com/ansible/awx/pull/8250
- (UI) Fixed a bug that broke websocket streaming for the insecure ws:// protocol: https://github.com/ansible/awx/pull/8877
- (UI) Fixed a bug in the user interface when a translation for the browser's preferred locale isn't available: https://github.com/ansible/awx/issues/8884
- (UI) Fixed bug where navigating from one survey question form directly to another wasn't reloading the form: https://github.com/ansible/awx/issues/7522
- (UI) Fixed a bug which can cause an uncaught error while launching a Job Template: https://github.com/ansible/awx/issues/8936
- Updated autobahn to address CVE-2020-35678
## 16.0.0 (December 10, 2020) ## 16.0.0 (December 10, 2020)
- AWX now ships with a reimagined user interface. **Please read this before upgrading:** https://groups.google.com/g/awx-project/c/KuT5Ao92HWo - AWX now ships with a reimagined user interface. **Please read this before upgrading:** https://groups.google.com/g/awx-project/c/KuT5Ao92HWo
- Removed support for syncing inventory from Red Hat CloudForms - https://github.com/ansible/awx/commit/0b701b3b2 - Removed support for syncing inventory from Red Hat CloudForms - https://github.com/ansible/awx/commit/0b701b3b2

View File

@@ -497,7 +497,7 @@ Before starting the install process, review the [inventory](./installer/inventor
*docker_compose_dir* *docker_compose_dir*
> When using docker-compose, the `docker-compose.yml` file will be created there (default `/tmp/awxcompose`). > When using docker-compose, the `docker-compose.yml` file will be created there (default `~/.awx/awxcompose`).
*custom_venv_dir* *custom_venv_dir*

View File

@@ -267,11 +267,27 @@ collectstatic:
fi; \ fi; \
mkdir -p awx/public/static && $(PYTHON) manage.py collectstatic --clear --noinput > /dev/null 2>&1 mkdir -p awx/public/static && $(PYTHON) manage.py collectstatic --clear --noinput > /dev/null 2>&1
UWSGI_DEV_RELOAD_COMMAND ?= supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver
uwsgi: collectstatic uwsgi: collectstatic
@if [ "$(VENV_BASE)" ]; then \ @if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/awx/bin/activate; \ . $(VENV_BASE)/awx/bin/activate; \
fi; \ fi; \
uwsgi -b 32768 --socket 127.0.0.1:8050 --module=awx.wsgi:application --home=/var/lib/awx/venv/awx --chdir=/awx_devel/ --vacuum --processes=5 --harakiri=120 --master --no-orphans --py-autoreload 1 --max-requests=1000 --stats /tmp/stats.socket --lazy-apps --logformat "%(addr) %(method) %(uri) - %(proto) %(status)" --hook-accepting1="exec:supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver" uwsgi -b 32768 \
--socket 127.0.0.1:8050 \
--module=awx.wsgi:application \
--home=/var/lib/awx/venv/awx \
--chdir=/awx_devel/ \
--vacuum \
--processes=5 \
--harakiri=120 --master \
--no-orphans \
--py-autoreload 1 \
--max-requests=1000 \
--stats /tmp/stats.socket \
--lazy-apps \
--logformat "%(addr) %(method) %(uri) - %(proto) %(status)" \
--hook-accepting1="exec: $(UWSGI_DEV_RELOAD_COMMAND)"
daphne: daphne:
@if [ "$(VENV_BASE)" ]; then \ @if [ "$(VENV_BASE)" ]; then \
@@ -579,15 +595,18 @@ docker-compose-clean: awx/projects
# Base development image build # Base development image build
docker-compose-build: docker-compose-build:
ansible localhost -m template -a "src=installer/roles/image_build/templates/Dockerfile.j2 dest=tools/docker-compose/Dockerfile" -e build_dev=True ansible-playbook installer/dockerfile.yml -e build_dev=True
docker build -t ansible/awx_devel -f tools/docker-compose/Dockerfile \ docker build -t ansible/awx_devel \
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) . --build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) .
docker tag ansible/awx_devel $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) docker tag ansible/awx_devel $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
#docker push $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) #docker push $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
# For use when developing on "isolated" AWX deployments # For use when developing on "isolated" AWX deployments
docker-compose-isolated-build: docker-compose-build docker-compose-isolated-build: docker-compose-build
docker build -t ansible/awx_isolated -f tools/docker-isolated/Dockerfile . docker build -t ansible/awx_isolated \
--build-arg BUILDKIT_INLINE_CACHE=1 \
-f tools/docker-isolated/Dockerfile .
docker tag ansible/awx_isolated $(DEV_DOCKER_TAG_BASE)/awx_isolated:$(COMPOSE_TAG) docker tag ansible/awx_isolated $(DEV_DOCKER_TAG_BASE)/awx_isolated:$(COMPOSE_TAG)
#docker push $(DEV_DOCKER_TAG_BASE)/awx_isolated:$(COMPOSE_TAG) #docker push $(DEV_DOCKER_TAG_BASE)/awx_isolated:$(COMPOSE_TAG)
@@ -624,5 +643,16 @@ psql-container:
VERSION: VERSION:
@echo "awx: $(VERSION)" @echo "awx: $(VERSION)"
Dockerfile: installer/roles/image_build/templates/Dockerfile.j2 Dockerfile: installer/roles/dockerfile/templates/Dockerfile.j2
ansible localhost -m template -a "src=installer/roles/image_build/templates/Dockerfile.j2 dest=Dockerfile" ansible-playbook installer/dockerfile.yml
Dockerfile.kube-dev: installer/roles/dockerfile/templates/Dockerfile.j2
ansible-playbook installer/dockerfile.yml \
-e dockerfile_name=Dockerfile.kube-dev \
-e kube_dev=True \
-e template_dest=_build_kube_dev
awx-kube-dev-build: Dockerfile.kube-dev
docker build -f Dockerfile.kube-dev \
--build-arg BUILDKIT_INLINE_CACHE=1 \
-t $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) .

View File

@@ -14,20 +14,20 @@ Contributing
------------ ------------
- Refer to the [Contributing guide](./CONTRIBUTING.md) to get started developing, testing, and building AWX. - Refer to the [Contributing guide](./CONTRIBUTING.md) to get started developing, testing, and building AWX.
- All code submissions are done through pull requests against the `devel` branch. - All code submissions are made through pull requests against the `devel` branch.
- All contributors must use git commit --signoff for any commit to be merged, and agree that usage of --signoff constitutes agreement with the terms of [DCO 1.1](./DCO_1_1.md) - All contributors must use git commit --signoff for any commit to be merged and agree that usage of --signoff constitutes agreement with the terms of [DCO 1.1](./DCO_1_1.md)
- Take care to make sure no merge commits are in the submission, and use `git rebase` vs `git merge` for this reason. - Take care to make sure no merge commits are in the submission, and use `git rebase` vs. `git merge` for this reason.
- If submitting a large code change, it's a good idea to join the `#ansible-awx` channel on irc.freenode.net, and talk about what you would like to do or add first. This not only helps everyone know what's going on, it also helps save time and effort, if the community decides some changes are needed. - If submitting a large code change, it's a good idea to join the `#ansible-awx` channel on irc.freenode.net and talk about what you would like to do or add first. This not only helps everyone know what's going on, but it also helps save time and effort if the community decides some changes are needed.
Reporting Issues Reporting Issues
---------------- ----------------
If you're experiencing a problem that you feel is a bug in AWX, or have ideas for how to improve AWX, we encourage you to open an issue, and share your feedback. But before opening a new issue, we ask that you please take a look at our [Issues guide](./ISSUES.md). If you're experiencing a problem that you feel is a bug in AWX or have ideas for improving AWX, we encourage you to open an issue and share your feedback. But before opening a new issue, we ask that you please take a look at our [Issues guide](./ISSUES.md).
Code of Conduct Code of Conduct
--------------- ---------------
We ask all of our community members and contributors to adhere to the [Ansible code of conduct](http://docs.ansible.com/ansible/latest/community/code_of_conduct.html). If you have questions, or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com) We ask all of our community members and contributors to adhere to the [Ansible code of conduct](http://docs.ansible.com/ansible/latest/community/code_of_conduct.html). If you have questions or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com)
Get Involved Get Involved
------------ ------------
@@ -41,4 +41,3 @@ License
------- -------
[Apache v2](./LICENSE.md) [Apache v2](./LICENSE.md)

View File

@@ -1 +1 @@
16.0.0 17.0.1

View File

@@ -13,6 +13,7 @@ from django.utils.translation import ugettext_lazy as _
from awx.main.models import ( from awx.main.models import (
ActivityStream, ActivityStream,
Inventory, Inventory,
Host,
Project, Project,
JobTemplate, JobTemplate,
WorkflowJobTemplate, WorkflowJobTemplate,
@@ -98,6 +99,7 @@ class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPI
organization__id=org_id).count() organization__id=org_id).count()
org_counts['job_templates'] = JobTemplate.accessible_objects(**access_kwargs).filter( org_counts['job_templates'] = JobTemplate.accessible_objects(**access_kwargs).filter(
organization__id=org_id).count() organization__id=org_id).count()
org_counts['hosts'] = Host.objects.org_active_count(org_id)
full_context['related_field_counts'] = {} full_context['related_field_counts'] = {}
full_context['related_field_counts'][org_id] = org_counts full_context['related_field_counts'][org_id] = org_counts

View File

@@ -38,6 +38,7 @@ class CallbackBrokerWorker(BaseWorker):
MAX_RETRIES = 2 MAX_RETRIES = 2
last_stats = time.time() last_stats = time.time()
last_flush = time.time()
total = 0 total = 0
last_event = '' last_event = ''
prof = None prof = None
@@ -52,7 +53,7 @@ class CallbackBrokerWorker(BaseWorker):
def read(self, queue): def read(self, queue):
try: try:
res = self.redis.blpop(settings.CALLBACK_QUEUE, timeout=settings.JOB_EVENT_BUFFER_SECONDS) res = self.redis.blpop(settings.CALLBACK_QUEUE, timeout=1)
if res is None: if res is None:
return {'event': 'FLUSH'} return {'event': 'FLUSH'}
self.total += 1 self.total += 1
@@ -102,6 +103,7 @@ class CallbackBrokerWorker(BaseWorker):
now = tz_now() now = tz_now()
if ( if (
force or force or
(time.time() - self.last_flush) > settings.JOB_EVENT_BUFFER_SECONDS or
any([len(events) >= 1000 for events in self.buff.values()]) any([len(events) >= 1000 for events in self.buff.values()])
): ):
for cls, events in self.buff.items(): for cls, events in self.buff.items():
@@ -124,6 +126,7 @@ class CallbackBrokerWorker(BaseWorker):
for e in events: for e in events:
emit_event_detail(e) emit_event_detail(e)
self.buff = {} self.buff = {}
self.last_flush = time.time()
def perform_work(self, body): def perform_work(self, body):
try: try:

View File

@@ -81,10 +81,17 @@ User.add_to_class('accessible_objects', user_accessible_objects)
def enforce_bigint_pk_migration(): def enforce_bigint_pk_migration():
#
# NOTE: this function is not actually in use anymore,
# but has been intentionally kept for historical purposes,
# and to serve as an illustration if we ever need to perform
# bulk modification/migration of event data in the future.
#
# see: https://github.com/ansible/awx/issues/6010 # see: https://github.com/ansible/awx/issues/6010
# look at all the event tables and verify that they have been fully migrated # look at all the event tables and verify that they have been fully migrated
# from the *old* int primary key table to the replacement bigint table # from the *old* int primary key table to the replacement bigint table
# if not, attempt to migrate them in the background # if not, attempt to migrate them in the background
#
for tblname in ( for tblname in (
'main_jobevent', 'main_inventoryupdateevent', 'main_jobevent', 'main_inventoryupdateevent',
'main_projectupdateevent', 'main_adhoccommandevent', 'main_projectupdateevent', 'main_adhoccommandevent',

View File

@@ -357,7 +357,7 @@ class JobNotificationMixin(object):
'url': 'https://towerhost/#/jobs/playbook/1010', 'url': 'https://towerhost/#/jobs/playbook/1010',
'approval_status': 'approved', 'approval_status': 'approved',
'approval_node_name': 'Approve Me', 'approval_node_name': 'Approve Me',
'workflow_url': 'https://towerhost/#/workflows/1010', 'workflow_url': 'https://towerhost/#/jobs/workflow/1010',
'job_metadata': """{'url': 'https://towerhost/$/jobs/playbook/13', 'job_metadata': """{'url': 'https://towerhost/$/jobs/playbook/13',
'traceback': '', 'traceback': '',
'status': 'running', 'status': 'running',

View File

@@ -620,7 +620,7 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
return reverse('api:workflow_job_detail', kwargs={'pk': self.pk}, request=request) return reverse('api:workflow_job_detail', kwargs={'pk': self.pk}, request=request)
def get_ui_url(self): def get_ui_url(self):
return urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.pk)) return urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.pk))
def notification_data(self): def notification_data(self):
result = super(WorkflowJob, self).notification_data() result = super(WorkflowJob, self).notification_data()
@@ -752,7 +752,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
return None return None
def get_ui_url(self): def get_ui_url(self):
return urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id)) return urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.workflow_job.id))
def _get_parent_field_name(self): def _get_parent_field_name(self):
return 'workflow_approval_template' return 'workflow_approval_template'
@@ -840,7 +840,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
return (msg, body) return (msg, body)
def context(self, approval_status): def context(self, approval_status):
workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id)) workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.workflow_job.id))
return {'approval_status': approval_status, return {'approval_status': approval_status,
'approval_node_name': self.workflow_approval_template.name, 'approval_node_name': self.workflow_approval_template.name,
'workflow_url': workflow_url, 'workflow_url': workflow_url,

View File

@@ -60,7 +60,7 @@ from awx.main.models import (
Inventory, InventorySource, SmartInventoryMembership, Inventory, InventorySource, SmartInventoryMembership,
Job, AdHocCommand, ProjectUpdate, InventoryUpdate, SystemJob, Job, AdHocCommand, ProjectUpdate, InventoryUpdate, SystemJob,
JobEvent, ProjectUpdateEvent, InventoryUpdateEvent, AdHocCommandEvent, SystemJobEvent, JobEvent, ProjectUpdateEvent, InventoryUpdateEvent, AdHocCommandEvent, SystemJobEvent,
build_safe_env, enforce_bigint_pk_migration build_safe_env
) )
from awx.main.constants import ACTIVE_STATES from awx.main.constants import ACTIVE_STATES
from awx.main.exceptions import AwxTaskError, PostRunError from awx.main.exceptions import AwxTaskError, PostRunError
@@ -138,12 +138,6 @@ def dispatch_startup():
if Instance.objects.me().is_controller(): if Instance.objects.me().is_controller():
awx_isolated_heartbeat() awx_isolated_heartbeat()
# at process startup, detect the need to migrate old event records from int
# to bigint; at *some point* in the future, once certain versions of AWX
# and Tower fall out of use/support, we can probably just _assume_ that
# everybody has moved to bigint, and remove this code entirely
enforce_bigint_pk_migration()
# Update Tower's rsyslog.conf file based on loggins settings in the db # Update Tower's rsyslog.conf file based on loggins settings in the db
reconfigure_rsyslog() reconfigure_rsyslog()
@@ -738,6 +732,12 @@ def update_host_smart_inventory_memberships():
@task(queue=get_local_queuename) @task(queue=get_local_queuename)
def migrate_legacy_event_data(tblname): def migrate_legacy_event_data(tblname):
#
# NOTE: this function is not actually in use anymore,
# but has been intentionally kept for historical purposes,
# and to serve as an illustration if we ever need to perform
# bulk modification/migration of event data in the future.
#
if 'event' not in tblname: if 'event' not in tblname:
return return
with advisory_lock(f'bigint_migration_{tblname}', wait=False) as acquired: with advisory_lock(f'bigint_migration_{tblname}', wait=False) as acquired:

View File

@@ -2,7 +2,7 @@ import pytest
from awx.api.versioning import reverse from awx.api.versioning import reverse
from awx.main.models import Project from awx.main.models import Project, Host
@pytest.fixture @pytest.fixture
@@ -81,6 +81,8 @@ def test_org_counts_detail_admin(resourced_organization, user, get):
assert response.status_code == 200 assert response.status_code == 200
counts = response.data['summary_fields']['related_field_counts'] counts = response.data['summary_fields']['related_field_counts']
assert counts['hosts'] == 0
counts.pop('hosts')
assert counts == COUNTS_PRIMES assert counts == COUNTS_PRIMES
@@ -93,6 +95,8 @@ def test_org_counts_detail_member(resourced_organization, user, get):
assert response.status_code == 200 assert response.status_code == 200
counts = response.data['summary_fields']['related_field_counts'] counts = response.data['summary_fields']['related_field_counts']
assert counts['hosts'] == 0
counts.pop('hosts')
assert counts == { assert counts == {
'users': COUNTS_PRIMES['users'], # Policy is that members can see other users and admins 'users': COUNTS_PRIMES['users'], # Policy is that members can see other users and admins
'admins': COUNTS_PRIMES['admins'], 'admins': COUNTS_PRIMES['admins'],
@@ -111,6 +115,7 @@ def test_org_counts_list_admin(resourced_organization, user, get):
assert response.status_code == 200 assert response.status_code == 200
counts = response.data['results'][0]['summary_fields']['related_field_counts'] counts = response.data['results'][0]['summary_fields']['related_field_counts']
assert 'hosts' not in counts # doesn't show in list view
assert counts == COUNTS_PRIMES assert counts == COUNTS_PRIMES
@@ -123,6 +128,7 @@ def test_org_counts_list_member(resourced_organization, user, get):
assert response.status_code == 200 assert response.status_code == 200
counts = response.data['results'][0]['summary_fields']['related_field_counts'] counts = response.data['results'][0]['summary_fields']['related_field_counts']
assert 'hosts' not in counts # doesn't show in list view
assert counts == { assert counts == {
'users': COUNTS_PRIMES['users'], # Policy is that members can see other users and admins 'users': COUNTS_PRIMES['users'], # Policy is that members can see other users and admins
@@ -145,6 +151,7 @@ def test_new_org_zero_counts(user, post):
new_org_list = post_response.render().data new_org_list = post_response.render().data
counts_dict = new_org_list['summary_fields']['related_field_counts'] counts_dict = new_org_list['summary_fields']['related_field_counts']
assert 'hosts' not in counts_dict # doesn't show in list view
assert counts_dict == COUNTS_ZEROS assert counts_dict == COUNTS_ZEROS
@@ -167,6 +174,19 @@ def test_two_organizations(resourced_organization, organizations, user, get):
assert counts[org_id_zero] == COUNTS_ZEROS assert counts[org_id_zero] == COUNTS_ZEROS
@pytest.mark.django_db
def test_hosts_counted(resourced_organization, user, get):
admin_user = user('admin', True)
assert Host.objects.org_active_count(resourced_organization.id) == 0
resourced_organization.inventories.first().hosts.create(name='Some Host')
assert Host.objects.org_active_count(resourced_organization.id) == 1
response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user)
assert response.status_code == 200
counts = response.data['summary_fields']['related_field_counts']
assert counts['hosts'] == Host.objects.org_active_count(resourced_organization.id) == 1
@pytest.mark.django_db @pytest.mark.django_db
def test_scan_JT_counted(resourced_organization, user, get): def test_scan_JT_counted(resourced_organization, user, get):
admin_user = user('admin', True) admin_user = user('admin', True)
@@ -180,7 +200,10 @@ def test_scan_JT_counted(resourced_organization, user, get):
# Test detail view # Test detail view
detail_response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user) detail_response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user)
assert detail_response.status_code == 200 assert detail_response.status_code == 200
assert detail_response.data['summary_fields']['related_field_counts'] == counts_dict counts = detail_response.data['summary_fields']['related_field_counts']
assert 'hosts' in counts
counts.pop('hosts')
assert counts == counts_dict
@pytest.mark.django_db @pytest.mark.django_db
@@ -205,4 +228,7 @@ def test_JT_not_double_counted(resourced_organization, user, get):
# Test detail view # Test detail view
detail_response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user) detail_response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user)
assert detail_response.status_code == 200 assert detail_response.status_code == 200
assert detail_response.data['summary_fields']['related_field_counts'] == counts_dict counts = detail_response.data['summary_fields']['related_field_counts']
assert 'hosts' in counts
counts.pop('hosts')
assert counts == counts_dict

View File

@@ -35,16 +35,17 @@ data_loggly = {
# Test reconfigure logging settings function # Test reconfigure logging settings function
# name this whatever you want # name this whatever you want
@pytest.mark.parametrize( @pytest.mark.parametrize(
'enabled, log_type, host, port, protocol, expected_config', [ 'enabled, log_type, host, port, protocol, errorfile, expected_config', [
( (
True, True,
'loggly', 'loggly',
'http://logs-01.loggly.com/inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/', 'http://logs-01.loggly.com/inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/',
None, None,
'https', 'https',
'/var/log/tower/rsyslog.err',
'\n'.join([ '\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="logs-01.loggly.com" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/")', # noqa 'action(type="omhttp" server="logs-01.loggly.com" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/")', # noqa
]) ])
), ),
( (
@@ -53,6 +54,7 @@ data_loggly = {
'localhost', 'localhost',
9000, 9000,
'udp', 'udp',
'', # empty errorfile
'\n'.join([ '\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")', 'template(name="awx" type="string" string="%rawmsg-after-pri%")',
'action(type="omfwd" target="localhost" port="9000" protocol="udp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa 'action(type="omfwd" target="localhost" port="9000" protocol="udp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa
@@ -64,6 +66,7 @@ data_loggly = {
'localhost', 'localhost',
9000, 9000,
'tcp', 'tcp',
'/var/log/tower/rsyslog.err',
'\n'.join([ '\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")', 'template(name="awx" type="string" string="%rawmsg-after-pri%")',
'action(type="omfwd" target="localhost" port="9000" protocol="tcp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa 'action(type="omfwd" target="localhost" port="9000" protocol="tcp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa
@@ -75,9 +78,10 @@ data_loggly = {
'https://yoursplunk/services/collector/event', 'https://yoursplunk/services/collector/event',
None, None,
None, None,
'/var/log/tower/rsyslog.err',
'\n'.join([ '\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="yoursplunk" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa 'action(type="omhttp" server="yoursplunk" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
]) ])
), ),
( (
@@ -86,9 +90,10 @@ data_loggly = {
'http://yoursplunk/services/collector/event', 'http://yoursplunk/services/collector/event',
None, None,
None, None,
'/var/log/tower/rsyslog.err',
'\n'.join([ '\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="yoursplunk" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa 'action(type="omhttp" server="yoursplunk" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
]) ])
), ),
( (
@@ -97,9 +102,10 @@ data_loggly = {
'https://yoursplunk:8088/services/collector/event', 'https://yoursplunk:8088/services/collector/event',
None, None,
None, None,
'/var/log/tower/rsyslog.err',
'\n'.join([ '\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa 'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
]) ])
), ),
( (
@@ -108,9 +114,10 @@ data_loggly = {
'https://yoursplunk/services/collector/event', 'https://yoursplunk/services/collector/event',
8088, 8088,
None, None,
'/var/log/tower/rsyslog.err',
'\n'.join([ '\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa 'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
]) ])
), ),
( (
@@ -119,9 +126,10 @@ data_loggly = {
'yoursplunk.org/services/collector/event', 'yoursplunk.org/services/collector/event',
8088, 8088,
'https', 'https',
'/var/log/tower/rsyslog.err',
'\n'.join([ '\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa 'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
]) ])
), ),
( (
@@ -130,9 +138,10 @@ data_loggly = {
'http://yoursplunk.org/services/collector/event', 'http://yoursplunk.org/services/collector/event',
8088, 8088,
None, None,
'/var/log/tower/rsyslog.err',
'\n'.join([ '\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa 'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
]) ])
), ),
( (
@@ -141,14 +150,15 @@ data_loggly = {
'https://endpoint5.collection.us2.sumologic.com/receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==', # noqa 'https://endpoint5.collection.us2.sumologic.com/receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==', # noqa
None, None,
'https', 'https',
'/var/log/tower/rsyslog.err',
'\n'.join([ '\n'.join([
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")', 'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
'action(type="omhttp" server="endpoint5.collection.us2.sumologic.com" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==")', # noqa 'action(type="omhttp" server="endpoint5.collection.us2.sumologic.com" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==")', # noqa
]) ])
), ),
] ]
) )
def test_rsyslog_conf_template(enabled, log_type, host, port, protocol, expected_config): def test_rsyslog_conf_template(enabled, log_type, host, port, protocol, errorfile, expected_config):
mock_settings, _ = _mock_logging_defaults() mock_settings, _ = _mock_logging_defaults()
@@ -159,6 +169,7 @@ def test_rsyslog_conf_template(enabled, log_type, host, port, protocol, expected
setattr(mock_settings, 'LOG_AGGREGATOR_ENABLED', enabled) setattr(mock_settings, 'LOG_AGGREGATOR_ENABLED', enabled)
setattr(mock_settings, 'LOG_AGGREGATOR_TYPE', log_type) setattr(mock_settings, 'LOG_AGGREGATOR_TYPE', log_type)
setattr(mock_settings, 'LOG_AGGREGATOR_HOST', host) setattr(mock_settings, 'LOG_AGGREGATOR_HOST', host)
setattr(mock_settings, 'LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE', errorfile)
if port: if port:
setattr(mock_settings, 'LOG_AGGREGATOR_PORT', port) setattr(mock_settings, 'LOG_AGGREGATOR_PORT', port)
if protocol: if protocol:

View File

@@ -18,6 +18,7 @@ def construct_rsyslog_conf_template(settings=settings):
timeout = getattr(settings, 'LOG_AGGREGATOR_TCP_TIMEOUT', 5) timeout = getattr(settings, 'LOG_AGGREGATOR_TCP_TIMEOUT', 5)
max_disk_space = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_GB', 1) max_disk_space = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_GB', 1)
spool_directory = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_PATH', '/var/lib/awx').rstrip('/') spool_directory = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_PATH', '/var/lib/awx').rstrip('/')
error_log_file = getattr(settings, 'LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE', '')
if not os.access(spool_directory, os.W_OK): if not os.access(spool_directory, os.W_OK):
spool_directory = '/var/lib/awx' spool_directory = '/var/lib/awx'
@@ -74,9 +75,10 @@ def construct_rsyslog_conf_template(settings=settings):
f'skipverifyhost="{skip_verify}"', f'skipverifyhost="{skip_verify}"',
'action.resumeRetryCount="-1"', 'action.resumeRetryCount="-1"',
'template="awx"', 'template="awx"',
'errorfile="/var/log/tower/rsyslog.err"',
f'action.resumeInterval="{timeout}"' f'action.resumeInterval="{timeout}"'
] ]
if error_log_file:
params.append(f'errorfile="{error_log_file}"')
if parsed.path: if parsed.path:
path = urlparse.quote(parsed.path[1:], safe='/=') path = urlparse.quote(parsed.path[1:], safe='/=')
if parsed.query: if parsed.query:

View File

@@ -196,9 +196,9 @@ LOCAL_STDOUT_EXPIRE_TIME = 2592000
# events into the database # events into the database
JOB_EVENT_WORKERS = 4 JOB_EVENT_WORKERS = 4
# The number of seconds (must be an integer) to buffer callback receiver bulk # The number of seconds to buffer callback receiver bulk
# writes in memory before flushing via JobEvent.objects.bulk_create() # writes in memory before flushing via JobEvent.objects.bulk_create()
JOB_EVENT_BUFFER_SECONDS = 1 JOB_EVENT_BUFFER_SECONDS = .1
# The interval at which callback receiver statistics should be # The interval at which callback receiver statistics should be
# recorded # recorded
@@ -770,6 +770,7 @@ LOG_AGGREGATOR_LEVEL = 'INFO'
LOG_AGGREGATOR_MAX_DISK_USAGE_GB = 1 LOG_AGGREGATOR_MAX_DISK_USAGE_GB = 1
LOG_AGGREGATOR_MAX_DISK_USAGE_PATH = '/var/lib/awx' LOG_AGGREGATOR_MAX_DISK_USAGE_PATH = '/var/lib/awx'
LOG_AGGREGATOR_RSYSLOGD_DEBUG = False LOG_AGGREGATOR_RSYSLOGD_DEBUG = False
LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE = '/var/log/tower/rsyslog.err'
# The number of retry attempts for websocket session establishment # The number of retry attempts for websocket session establishment
# If you're encountering issues establishing websockets in clustered Tower, # If you're encountering issues establishing websockets in clustered Tower,
@@ -853,38 +854,30 @@ LOGGING = {
}, },
'tower_warnings': { 'tower_warnings': {
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL # don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false', 'dynamic_level_filter'], 'filters': ['require_debug_false', 'dynamic_level_filter'],
'filename': os.path.join(LOG_ROOT, 'tower.log'), 'filename': os.path.join(LOG_ROOT, 'tower.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple', 'formatter':'simple',
}, },
'callback_receiver': { 'callback_receiver': {
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL # don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false', 'dynamic_level_filter'], 'filters': ['require_debug_false', 'dynamic_level_filter'],
'filename': os.path.join(LOG_ROOT, 'callback_receiver.log'), 'filename': os.path.join(LOG_ROOT, 'callback_receiver.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple', 'formatter':'simple',
}, },
'dispatcher': { 'dispatcher': {
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL # don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false', 'dynamic_level_filter'], 'filters': ['require_debug_false', 'dynamic_level_filter'],
'filename': os.path.join(LOG_ROOT, 'dispatcher.log'), 'filename': os.path.join(LOG_ROOT, 'dispatcher.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'dispatcher', 'formatter':'dispatcher',
}, },
'wsbroadcast': { 'wsbroadcast': {
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL # don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false', 'dynamic_level_filter'], 'filters': ['require_debug_false', 'dynamic_level_filter'],
'filename': os.path.join(LOG_ROOT, 'wsbroadcast.log'), 'filename': os.path.join(LOG_ROOT, 'wsbroadcast.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple', 'formatter':'simple',
}, },
'celery.beat': { 'celery.beat': {
@@ -898,46 +891,36 @@ LOGGING = {
}, },
'task_system': { 'task_system': {
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL # don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false', 'dynamic_level_filter'], 'filters': ['require_debug_false', 'dynamic_level_filter'],
'filename': os.path.join(LOG_ROOT, 'task_system.log'), 'filename': os.path.join(LOG_ROOT, 'task_system.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple', 'formatter':'simple',
}, },
'management_playbooks': { 'management_playbooks': {
'level': 'DEBUG', 'level': 'DEBUG',
'class':'logging.handlers.RotatingFileHandler', 'class':'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false'], 'filters': ['require_debug_false'],
'filename': os.path.join(LOG_ROOT, 'management_playbooks.log'), 'filename': os.path.join(LOG_ROOT, 'management_playbooks.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple', 'formatter':'simple',
}, },
'system_tracking_migrations': { 'system_tracking_migrations': {
'level': 'WARNING', 'level': 'WARNING',
'class':'logging.handlers.RotatingFileHandler', 'class':'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false'], 'filters': ['require_debug_false'],
'filename': os.path.join(LOG_ROOT, 'tower_system_tracking_migrations.log'), 'filename': os.path.join(LOG_ROOT, 'tower_system_tracking_migrations.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple', 'formatter':'simple',
}, },
'rbac_migrations': { 'rbac_migrations': {
'level': 'WARNING', 'level': 'WARNING',
'class':'logging.handlers.RotatingFileHandler', 'class':'logging.handlers.WatchedFileHandler',
'filters': ['require_debug_false'], 'filters': ['require_debug_false'],
'filename': os.path.join(LOG_ROOT, 'tower_rbac_migrations.log'), 'filename': os.path.join(LOG_ROOT, 'tower_rbac_migrations.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple', 'formatter':'simple',
}, },
'isolated_manager': { 'isolated_manager': {
'level': 'WARNING', 'level': 'WARNING',
'class':'logging.handlers.RotatingFileHandler', 'class':'logging.handlers.WatchedFileHandler',
'filename': os.path.join(LOG_ROOT, 'isolated_manager.log'), 'filename': os.path.join(LOG_ROOT, 'isolated_manager.log'),
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
'formatter':'simple', 'formatter':'simple',
}, },
}, },

View File

@@ -158,7 +158,10 @@ AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx")
# default settings for development. If not present, we can still run using # default settings for development. If not present, we can still run using
# only the defaults. # only the defaults.
try: try:
include(optional('local_*.py'), scope=locals()) if os.getenv('AWX_KUBE_DEVEL', False):
include(optional('minikube.py'), scope=locals())
else:
include(optional('local_*.py'), scope=locals())
except ImportError: except ImportError:
traceback.print_exc() traceback.print_exc()
sys.exit(1) sys.exit(1)

4
awx/settings/minikube.py Normal file
View File

@@ -0,0 +1,4 @@
BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖'
BROADCAST_WEBSOCKET_PORT = 8013
BROADCAST_WEBSOCKET_VERIFY_CERT = False
BROADCAST_WEBSOCKET_PROTOCOL = 'http'

View File

@@ -1,3 +1,4 @@
import ActivityStream from './models/ActivityStream';
import AdHocCommands from './models/AdHocCommands'; import AdHocCommands from './models/AdHocCommands';
import Applications from './models/Applications'; import Applications from './models/Applications';
import Auth from './models/Auth'; import Auth from './models/Auth';
@@ -39,6 +40,7 @@ import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
import WorkflowJobTemplates from './models/WorkflowJobTemplates'; import WorkflowJobTemplates from './models/WorkflowJobTemplates';
import WorkflowJobs from './models/WorkflowJobs'; import WorkflowJobs from './models/WorkflowJobs';
const ActivityStreamAPI = new ActivityStream();
const AdHocCommandsAPI = new AdHocCommands(); const AdHocCommandsAPI = new AdHocCommands();
const ApplicationsAPI = new Applications(); const ApplicationsAPI = new Applications();
const AuthAPI = new Auth(); const AuthAPI = new Auth();
@@ -81,6 +83,7 @@ const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
const WorkflowJobsAPI = new WorkflowJobs(); const WorkflowJobsAPI = new WorkflowJobs();
export { export {
ActivityStreamAPI,
AdHocCommandsAPI, AdHocCommandsAPI,
ApplicationsAPI, ApplicationsAPI,
AuthAPI, AuthAPI,

View File

@@ -0,0 +1,10 @@
import Base from '../Base';
class ActivityStream extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/activity_stream/';
}
}
export default ActivityStream;

View File

@@ -1,4 +1,4 @@
import React, { Fragment } from 'react'; import React, { Fragment, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -17,95 +17,57 @@ const readTeams = async queryParams => TeamsAPI.read(queryParams);
const readTeamsOptions = async () => TeamsAPI.readOptions(); const readTeamsOptions = async () => TeamsAPI.readOptions();
class AddResourceRole extends React.Component { function AddResourceRole({ onSave, onClose, roles, i18n, resource }) {
constructor(props) { const [selectedResource, setSelectedResource] = useState(null);
super(props); const [selectedResourceRows, setSelectedResourceRows] = useState([]);
const [selectedRoleRows, setSelectedRoleRows] = useState([]);
this.state = { const [currentStepId, setCurrentStepId] = useState(1);
selectedResource: null, const [maxEnabledStep, setMaxEnabledStep] = useState(1);
selectedResourceRows: [],
selectedRoleRows: [],
currentStepId: 1,
maxEnabledStep: 1,
};
this.handleResourceCheckboxClick = this.handleResourceCheckboxClick.bind(
this
);
this.handleResourceSelect = this.handleResourceSelect.bind(this);
this.handleRoleCheckboxClick = this.handleRoleCheckboxClick.bind(this);
this.handleWizardNext = this.handleWizardNext.bind(this);
this.handleWizardSave = this.handleWizardSave.bind(this);
this.handleWizardGoToStep = this.handleWizardGoToStep.bind(this);
}
handleResourceCheckboxClick(user) {
const { selectedResourceRows, currentStepId } = this.state;
const handleResourceCheckboxClick = user => {
const selectedIndex = selectedResourceRows.findIndex( const selectedIndex = selectedResourceRows.findIndex(
selectedRow => selectedRow.id === user.id selectedRow => selectedRow.id === user.id
); );
if (selectedIndex > -1) { if (selectedIndex > -1) {
selectedResourceRows.splice(selectedIndex, 1); selectedResourceRows.splice(selectedIndex, 1);
const stateToUpdate = { selectedResourceRows };
if (selectedResourceRows.length === 0) { if (selectedResourceRows.length === 0) {
stateToUpdate.maxEnabledStep = currentStepId; setMaxEnabledStep(currentStepId);
} }
this.setState(stateToUpdate); setSelectedRoleRows(selectedResourceRows);
} else { } else {
this.setState(prevState => ({ setSelectedResourceRows([...selectedResourceRows, user]);
selectedResourceRows: [...prevState.selectedResourceRows, user],
}));
} }
} };
handleRoleCheckboxClick(role) {
const { selectedRoleRows } = this.state;
const handleRoleCheckboxClick = role => {
const selectedIndex = selectedRoleRows.findIndex( const selectedIndex = selectedRoleRows.findIndex(
selectedRow => selectedRow.id === role.id selectedRow => selectedRow.id === role.id
); );
if (selectedIndex > -1) { if (selectedIndex > -1) {
selectedRoleRows.splice(selectedIndex, 1); selectedRoleRows.splice(selectedIndex, 1);
this.setState({ selectedRoleRows }); setSelectedRoleRows(selectedRoleRows);
} else { } else {
this.setState(prevState => ({ setSelectedRoleRows([...selectedRoleRows, role]);
selectedRoleRows: [...prevState.selectedRoleRows, role],
}));
} }
} };
handleResourceSelect(resourceType) { const handleResourceSelect = resourceType => {
this.setState({ setSelectedResource(resourceType);
selectedResource: resourceType, setSelectedResourceRows([]);
selectedResourceRows: [], setSelectedRoleRows([]);
selectedRoleRows: [], };
});
}
handleWizardNext(step) { const handleWizardNext = step => {
this.setState({ setCurrentStepId(step.id);
currentStepId: step.id, setMaxEnabledStep(step.id);
maxEnabledStep: step.id, };
});
}
handleWizardGoToStep(step) { const handleWizardGoToStep = step => {
this.setState({ setCurrentStepId(step.id);
currentStepId: step.id, };
});
}
async handleWizardSave() {
const { onSave } = this.props;
const {
selectedResourceRows,
selectedRoleRows,
selectedResource,
} = this.state;
const handleWizardSave = async () => {
try { try {
const roleRequests = []; const roleRequests = [];
@@ -134,201 +96,186 @@ class AddResourceRole extends React.Component {
} catch (err) { } catch (err) {
// TODO: handle this error // TODO: handle this error
} }
};
// Object roles can be user only, so we remove them when
// showing role choices for team access
const selectableRoles = { ...roles };
if (selectedResource === 'teams') {
Object.keys(roles).forEach(key => {
if (selectableRoles[key].user_only) {
delete selectableRoles[key];
}
});
} }
render() { const userSearchColumns = [
const { {
selectedResource, name: i18n._(t`Username`),
selectedResourceRows, key: 'username__icontains',
selectedRoleRows, isDefault: true,
currentStepId, },
maxEnabledStep, {
} = this.state; name: i18n._(t`First Name`),
const { onClose, roles, i18n, resource } = this.props; key: 'first_name__icontains',
},
{
name: i18n._(t`Last Name`),
key: 'last_name__icontains',
},
];
const userSortColumns = [
{
name: i18n._(t`Username`),
key: 'username',
},
{
name: i18n._(t`First Name`),
key: 'first_name',
},
{
name: i18n._(t`Last Name`),
key: 'last_name',
},
];
const teamSearchColumns = [
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
];
// Object roles can be user only, so we remove them when const teamSortColumns = [
// showing role choices for team access {
const selectableRoles = { ...roles }; name: i18n._(t`Name`),
if (selectedResource === 'teams') { key: 'name',
Object.keys(roles).forEach(key => { },
if (selectableRoles[key].user_only) { ];
delete selectableRoles[key];
}
});
}
const userSearchColumns = [ let wizardTitle = '';
{
name: i18n._(t`Username`),
key: 'username__icontains',
isDefault: true,
},
{
name: i18n._(t`First Name`),
key: 'first_name__icontains',
},
{
name: i18n._(t`Last Name`),
key: 'last_name__icontains',
},
];
const userSortColumns = [ switch (selectedResource) {
{ case 'users':
name: i18n._(t`Username`), wizardTitle = i18n._(t`Add User Roles`);
key: 'username', break;
}, case 'teams':
{ wizardTitle = i18n._(t`Add Team Roles`);
name: i18n._(t`First Name`), break;
key: 'first_name', default:
}, wizardTitle = i18n._(t`Add Roles`);
{ }
name: i18n._(t`Last Name`),
key: 'last_name',
},
];
const teamSearchColumns = [ const steps = [
{ {
name: i18n._(t`Name`), id: 1,
key: 'name', name: i18n._(t`Select a Resource Type`),
isDefault: true, component: (
}, <div style={{ display: 'flex', flexWrap: 'wrap' }}>
{ <div style={{ width: '100%', marginBottom: '10px' }}>
name: i18n._(t`Created By (Username)`), {i18n._(
key: 'created_by__username', t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.`
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
];
const teamSortColumns = [
{
name: i18n._(t`Name`),
key: 'name',
},
];
let wizardTitle = '';
switch (selectedResource) {
case 'users':
wizardTitle = i18n._(t`Add User Roles`);
break;
case 'teams':
wizardTitle = i18n._(t`Add Team Roles`);
break;
default:
wizardTitle = i18n._(t`Add Roles`);
}
const steps = [
{
id: 1,
name: i18n._(t`Select a Resource Type`),
component: (
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
<div style={{ width: '100%', marginBottom: '10px' }}>
{i18n._(
t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.`
)}
</div>
<SelectableCard
isSelected={selectedResource === 'users'}
label={i18n._(t`Users`)}
dataCy="add-role-users"
ariaLabel={i18n._(t`Users`)}
onClick={() => this.handleResourceSelect('users')}
/>
{resource?.type === 'credential' &&
!resource?.organization ? null : (
<SelectableCard
isSelected={selectedResource === 'teams'}
label={i18n._(t`Teams`)}
dataCy="add-role-teams"
ariaLabel={i18n._(t`Teams`)}
onClick={() => this.handleResourceSelect('teams')}
/>
)} )}
</div> </div>
), <SelectableCard
enableNext: selectedResource !== null, isSelected={selectedResource === 'users'}
}, label={i18n._(t`Users`)}
{ ariaLabel={i18n._(t`Users`)}
id: 2, dataCy="add-role-users"
name: i18n._(t`Select Items from List`), onClick={() => handleResourceSelect('users')}
component: (
<Fragment>
{selectedResource === 'users' && (
<SelectResourceStep
searchColumns={userSearchColumns}
sortColumns={userSortColumns}
displayKey="username"
onRowClick={this.handleResourceCheckboxClick}
fetchItems={readUsers}
fetchOptions={readUsersOptions}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
sortedColumnKey="username"
/>
)}
{selectedResource === 'teams' && (
<SelectResourceStep
searchColumns={teamSearchColumns}
sortColumns={teamSortColumns}
onRowClick={this.handleResourceCheckboxClick}
fetchItems={readTeams}
fetchOptions={readTeamsOptions}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
/>
)}
</Fragment>
),
enableNext: selectedResourceRows.length > 0,
canJumpTo: maxEnabledStep >= 2,
},
{
id: 3,
name: i18n._(t`Select Roles to Apply`),
component: (
<SelectRoleStep
onRolesClick={this.handleRoleCheckboxClick}
roles={selectableRoles}
selectedListKey={selectedResource === 'users' ? 'username' : 'name'}
selectedListLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
selectedRoleRows={selectedRoleRows}
/> />
), {resource?.type === 'credential' && !resource?.organization ? null : (
nextButtonText: i18n._(t`Save`), <SelectableCard
enableNext: selectedRoleRows.length > 0, isSelected={selectedResource === 'teams'}
canJumpTo: maxEnabledStep >= 3, label={i18n._(t`Teams`)}
}, ariaLabel={i18n._(t`Teams`)}
]; dataCy="add-role-teams"
onClick={() => handleResourceSelect('teams')}
/>
)}
</div>
),
enableNext: selectedResource !== null,
},
{
id: 2,
name: i18n._(t`Select Items from List`),
component: (
<Fragment>
{selectedResource === 'users' && (
<SelectResourceStep
searchColumns={userSearchColumns}
sortColumns={userSortColumns}
displayKey="username"
onRowClick={handleResourceCheckboxClick}
fetchItems={readUsers}
fetchOptions={readUsersOptions}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
sortedColumnKey="username"
/>
)}
{selectedResource === 'teams' && (
<SelectResourceStep
searchColumns={teamSearchColumns}
sortColumns={teamSortColumns}
onRowClick={handleResourceCheckboxClick}
fetchItems={readTeams}
fetchOptions={readTeamsOptions}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
/>
)}
</Fragment>
),
enableNext: selectedResourceRows.length > 0,
canJumpTo: maxEnabledStep >= 2,
},
{
id: 3,
name: i18n._(t`Select Roles to Apply`),
component: (
<SelectRoleStep
onRolesClick={handleRoleCheckboxClick}
roles={selectableRoles}
selectedListKey={selectedResource === 'users' ? 'username' : 'name'}
selectedListLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
selectedRoleRows={selectedRoleRows}
/>
),
nextButtonText: i18n._(t`Save`),
enableNext: selectedRoleRows.length > 0,
canJumpTo: maxEnabledStep >= 3,
},
];
const currentStep = steps.find(step => step.id === currentStepId); const currentStep = steps.find(step => step.id === currentStepId);
// TODO: somehow internationalize steps and currentStep.nextButtonText // TODO: somehow internationalize steps and currentStep.nextButtonText
return ( return (
<Wizard <Wizard
style={{ overflow: 'scroll' }} style={{ overflow: 'scroll' }}
isOpen isOpen
onNext={this.handleWizardNext} onNext={handleWizardNext}
onClose={onClose} onClose={onClose}
onSave={this.handleWizardSave} onSave={handleWizardSave}
onGoToStep={this.handleWizardGoToStep} onGoToStep={step => handleWizardGoToStep(step)}
steps={steps} steps={steps}
title={wizardTitle} title={wizardTitle}
nextButtonText={currentStep.nextButtonText || undefined} nextButtonText={currentStep.nextButtonText || undefined}
backButtonText={i18n._(t`Back`)} backButtonText={i18n._(t`Back`)}
cancelButtonText={i18n._(t`Cancel`)} cancelButtonText={i18n._(t`Cancel`)}
/> />
); );
}
} }
AddResourceRole.propTypes = { AddResourceRole.propTypes = {

View File

@@ -1,22 +1,46 @@
/* eslint-disable react/jsx-pascal-case */ /* eslint-disable react/jsx-pascal-case */
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import AddResourceRole, { _AddResourceRole } from './AddResourceRole'; import AddResourceRole, { _AddResourceRole } from './AddResourceRole';
import { TeamsAPI, UsersAPI } from '../../api'; import { TeamsAPI, UsersAPI } from '../../api';
jest.mock('../../api'); jest.mock('../../api/models/Teams');
jest.mock('../../api/models/Users');
// TODO: Once error handling is functional in
// this component write tests for it
describe('<_AddResourceRole />', () => { describe('<_AddResourceRole />', () => {
UsersAPI.read.mockResolvedValue({ UsersAPI.read.mockResolvedValue({
data: { data: {
count: 2, count: 2,
results: [ results: [
{ id: 1, username: 'foo' }, { id: 1, username: 'foo', url: '' },
{ id: 2, username: 'bar' }, { id: 2, username: 'bar', url: '' },
], ],
}, },
}); });
UsersAPI.readOptions.mockResolvedValue({
data: { related: {}, actions: { GET: {} } },
});
TeamsAPI.read.mockResolvedValue({
data: {
count: 2,
results: [
{ id: 1, name: 'Team foo', url: '' },
{ id: 2, name: 'Team bar', url: '' },
],
},
});
TeamsAPI.readOptions.mockResolvedValue({
data: { related: {}, actions: { GET: {} } },
});
const roles = { const roles = {
admin_role: { admin_role: {
description: 'Can manage all aspects of the organization', description: 'Can manage all aspects of the organization',
@@ -39,191 +63,165 @@ describe('<_AddResourceRole />', () => {
/> />
); );
}); });
test('handleRoleCheckboxClick properly updates state', () => { test('should save properly', async () => {
const wrapper = shallow( let wrapper;
<_AddResourceRole act(() => {
onClose={() => {}} wrapper = mountWithContexts(
onSave={() => {}} <AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
roles={roles} { context: { network: { handleHttpError: () => {} } } }
i18n={{ _: val => val.toString() }} );
/>
);
wrapper.setState({
selectedRoleRows: [
{
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 1,
},
],
}); });
wrapper.instance().handleRoleCheckboxClick({ wrapper.update();
description: 'Can manage all aspects of the organization',
name: 'Admin', // Step 1
id: 1,
});
expect(wrapper.state('selectedRoleRows')).toEqual([]);
wrapper.instance().handleRoleCheckboxClick({
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 1,
});
expect(wrapper.state('selectedRoleRows')).toEqual([
{
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 1,
},
]);
});
test('handleResourceCheckboxClick properly updates state', () => {
const wrapper = shallow(
<_AddResourceRole
onClose={() => {}}
onSave={() => {}}
roles={roles}
i18n={{ _: val => val.toString() }}
/>
);
wrapper.setState({
selectedResourceRows: [
{
id: 1,
username: 'foobar',
},
],
});
wrapper.instance().handleResourceCheckboxClick({
id: 1,
username: 'foobar',
});
expect(wrapper.state('selectedResourceRows')).toEqual([]);
wrapper.instance().handleResourceCheckboxClick({
id: 1,
username: 'foobar',
});
expect(wrapper.state('selectedResourceRows')).toEqual([
{
id: 1,
username: 'foobar',
},
]);
});
test('clicking user/team cards updates state', () => {
const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect');
const wrapper = mountWithContexts(
<AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
{ context: { network: { handleHttpError: () => {} } } }
).find('AddResourceRole');
const selectableCardWrapper = wrapper.find('SelectableCard'); const selectableCardWrapper = wrapper.find('SelectableCard');
expect(selectableCardWrapper.length).toBe(2); expect(selectableCardWrapper.length).toBe(2);
selectableCardWrapper.first().simulate('click'); act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')());
expect(spy).toHaveBeenCalledWith('users'); wrapper.update();
expect(wrapper.state('selectedResource')).toBe('users'); await act(async () =>
selectableCardWrapper.at(1).simulate('click'); wrapper.find('Button[type="submit"]').prop('onClick')()
expect(spy).toHaveBeenCalledWith('teams');
expect(wrapper.state('selectedResource')).toBe('teams');
});
test('handleResourceSelect clears out selected lists and sets selectedResource', () => {
const wrapper = shallow(
<_AddResourceRole
onClose={() => {}}
onSave={() => {}}
roles={roles}
i18n={{ _: val => val.toString() }}
/>
); );
wrapper.setState({ wrapper.update();
selectedResource: 'teams',
selectedResourceRows: [ // Step 2
{ await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
id: 1, act(() =>
username: 'foobar', wrapper.find('DataListCheck[name="foo"]').invoke('onChange')(true)
}, );
], wrapper.update();
selectedRoleRows: [ expect(wrapper.find('DataListCheck[name="foo"]').prop('checked')).toBe(
{ true
description: 'Can manage all aspects of the organization', );
id: 1, act(() => wrapper.find('Button[type="submit"]').prop('onClick')());
name: 'Admin', wrapper.update();
},
], // Step 3
}); act(() =>
wrapper.instance().handleResourceSelect('users'); wrapper.find('Checkbox[aria-label="Admin"]').invoke('onChange')(true)
expect(wrapper.state()).toEqual({ );
selectedResource: 'users', wrapper.update();
selectedResourceRows: [], expect(wrapper.find('Checkbox[aria-label="Admin"]').prop('isChecked')).toBe(
selectedRoleRows: [], true
currentStepId: 1, );
maxEnabledStep: 1,
}); // Save
wrapper.instance().handleResourceSelect('teams'); await act(async () =>
expect(wrapper.state()).toEqual({ wrapper.find('Button[type="submit"]').prop('onClick')()
selectedResource: 'teams', );
selectedResourceRows: [], expect(UsersAPI.associateRole).toBeCalledWith(1, 1);
selectedRoleRows: [],
currentStepId: 1,
maxEnabledStep: 1,
});
}); });
test('handleWizardSave makes correct api calls, calls onSave when done', async () => {
const handleSave = jest.fn(); test('should successfuly click user/team cards', async () => {
const wrapper = mountWithContexts( let wrapper;
<AddResourceRole onClose={() => {}} onSave={handleSave} roles={roles} />, act(() => {
{ context: { network: { handleHttpError: () => {} } } } wrapper = mountWithContexts(
).find('AddResourceRole'); <AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
wrapper.setState({ { context: { network: { handleHttpError: () => {} } } }
selectedResource: 'users', );
selectedResourceRows: [
{
id: 1,
username: 'foobar',
},
],
selectedRoleRows: [
{
description: 'Can manage all aspects of the organization',
id: 1,
name: 'Admin',
},
{
description: 'May run any executable resources in the organization',
id: 2,
name: 'Execute',
},
],
}); });
await wrapper.instance().handleWizardSave(); wrapper.update();
expect(UsersAPI.associateRole).toHaveBeenCalledTimes(2);
expect(handleSave).toHaveBeenCalled(); const selectableCardWrapper = wrapper.find('SelectableCard');
wrapper.setState({ expect(selectableCardWrapper.length).toBe(2);
selectedResource: 'teams', act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')());
selectedResourceRows: [ wrapper.update();
{
id: 1, await waitForElement(
name: 'foobar', wrapper,
}, 'SelectableCard[label="Users"]',
], el => el.prop('isSelected') === true
selectedRoleRows: [ );
{ act(() => wrapper.find('SelectableCard[label="Teams"]').prop('onClick')());
description: 'Can manage all aspects of the organization', wrapper.update();
id: 1,
name: 'Admin', await waitForElement(
}, wrapper,
{ 'SelectableCard[label="Teams"]',
description: 'May run any executable resources in the organization', el => el.prop('isSelected') === true
id: 2, );
name: 'Execute', });
},
], test('should reset values with resource type changes', async () => {
let wrapper;
act(() => {
wrapper = mountWithContexts(
<AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
{ context: { network: { handleHttpError: () => {} } } }
);
}); });
await wrapper.instance().handleWizardSave(); wrapper.update();
expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2);
expect(handleSave).toHaveBeenCalled(); // Step 1
const selectableCardWrapper = wrapper.find('SelectableCard');
expect(selectableCardWrapper.length).toBe(2);
act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')());
wrapper.update();
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.update();
// Step 2
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
act(() =>
wrapper.find('DataListCheck[name="foo"]').invoke('onChange')(true)
);
wrapper.update();
expect(wrapper.find('DataListCheck[name="foo"]').prop('checked')).toBe(
true
);
act(() => wrapper.find('Button[type="submit"]').prop('onClick')());
wrapper.update();
// Step 3
act(() =>
wrapper.find('Checkbox[aria-label="Admin"]').invoke('onChange')(true)
);
wrapper.update();
expect(wrapper.find('Checkbox[aria-label="Admin"]').prop('isChecked')).toBe(
true
);
// Go back to step 1
act(() => {
wrapper
.find('WizardNavItem[content="Select a Resource Type"]')
.find('button')
.prop('onClick')({ id: 1 });
});
wrapper.update();
expect(
wrapper
.find('WizardNavItem[content="Select a Resource Type"]')
.prop('isCurrent')
).toBe(true);
// Go back to step 1 and this time select teams. Doing so should clear following steps
act(() => wrapper.find('SelectableCard[label="Teams"]').prop('onClick')());
wrapper.update();
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.update();
// Make sure no teams have been selected
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
wrapper
.find('DataListCheck')
.map(item => expect(item.prop('checked')).toBe(false));
act(() => wrapper.find('Button[type="submit"]').prop('onClick')());
wrapper.update();
// Make sure that no roles have been selected
wrapper
.find('Checkbox')
.map(card => expect(card.prop('isChecked')).toBe(false));
// Make sure the save button is disabled
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
}); });
test('should not display team as a choice in case credential does not have organization', () => { test('should not display team as a choice in case credential does not have organization', () => {
const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect');
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<AddResourceRole <AddResourceRole
onClose={() => {}} onClose={() => {}}
@@ -232,11 +230,13 @@ describe('<_AddResourceRole />', () => {
resource={{ type: 'credential', organization: null }} resource={{ type: 'credential', organization: null }}
/>, />,
{ context: { network: { handleHttpError: () => {} } } } { context: { network: { handleHttpError: () => {} } } }
).find('AddResourceRole'); );
const selectableCardWrapper = wrapper.find('SelectableCard');
expect(selectableCardWrapper.length).toBe(1); expect(wrapper.find('SelectableCard').length).toBe(1);
selectableCardWrapper.first().simulate('click'); wrapper.find('SelectableCard[label="Users"]').simulate('click');
expect(spy).toHaveBeenCalledWith('users'); wrapper.update();
expect(wrapper.state('selectedResource')).toBe('users'); expect(
wrapper.find('SelectableCard[label="Users"]').prop('isSelected')
).toBe(true);
}); });
}); });

View File

@@ -7,59 +7,55 @@ import { t } from '@lingui/macro';
import CheckboxCard from './CheckboxCard'; import CheckboxCard from './CheckboxCard';
import SelectedList from '../SelectedList'; import SelectedList from '../SelectedList';
class RolesStep extends React.Component { function RolesStep({
render() { onRolesClick,
const { roles,
onRolesClick, selectedListKey,
roles, selectedListLabel,
selectedListKey, selectedResourceRows,
selectedListLabel, selectedRoleRows,
selectedResourceRows, i18n,
selectedRoleRows, }) {
i18n, return (
} = this.props; <Fragment>
<div>
return ( {i18n._(
<Fragment> t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.`
<div> )}
{i18n._( </div>
t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.` <div>
)} {selectedResourceRows.length > 0 && (
</div> <SelectedList
<div> displayKey={selectedListKey}
{selectedResourceRows.length > 0 && ( isReadOnly
<SelectedList label={selectedListLabel || i18n._(t`Selected`)}
displayKey={selectedListKey} selected={selectedResourceRows}
isReadOnly />
label={selectedListLabel || i18n._(t`Selected`)} )}
selected={selectedResourceRows} </div>
/> <div
)} style={{
</div> display: 'grid',
<div gridTemplateColumns: '1fr 1fr',
style={{ gap: '20px 20px',
display: 'grid', marginTop: '20px',
gridTemplateColumns: '1fr 1fr', }}
gap: '20px 20px', >
marginTop: '20px', {Object.keys(roles).map(role => (
}} <CheckboxCard
> description={roles[role].description}
{Object.keys(roles).map(role => ( itemId={roles[role].id}
<CheckboxCard isSelected={selectedRoleRows.some(
description={roles[role].description} item => item.id === roles[role].id
itemId={roles[role].id} )}
isSelected={selectedRoleRows.some( key={roles[role].id}
item => item.id === roles[role].id name={roles[role].name}
)} onSelect={() => onRolesClick(roles[role])}
key={roles[role].id} />
name={roles[role].name} ))}
onSelect={() => onRolesClick(roles[role])} </div>
/> </Fragment>
))} );
</div>
</Fragment>
);
}
} }
RolesStep.propTypes = { RolesStep.propTypes = {

View File

@@ -12,52 +12,44 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormSelect, FormSelectOption } from '@patternfly/react-core'; import { FormSelect, FormSelectOption } from '@patternfly/react-core';
class AnsibleSelect extends React.Component { function AnsibleSelect({
constructor(props) { id,
super(props); data,
this.onSelectChange = this.onSelectChange.bind(this); i18n,
} isValid,
onBlur,
onSelectChange(val, event) { value,
const { onChange, name } = this.props; className,
isDisabled,
onChange,
name,
}) {
const onSelectChange = (val, event) => {
event.target.name = name; event.target.name = name;
onChange(event, val); onChange(event, val);
} };
render() { return (
const { <FormSelect
id, id={id}
data, value={value}
i18n, onChange={onSelectChange}
isValid, onBlur={onBlur}
onBlur, aria-label={i18n._(t`Select Input`)}
value, validated={isValid ? 'default' : 'error'}
className, className={className}
isDisabled, isDisabled={isDisabled}
} = this.props; >
{data.map(option => (
return ( <FormSelectOption
<FormSelect key={option.key}
id={id} value={option.value}
value={value} label={option.label}
onChange={this.onSelectChange} isDisabled={option.isDisabled}
onBlur={onBlur} />
aria-label={i18n._(t`Select Input`)} ))}
validated={isValid ? 'default' : 'error'} </FormSelect>
className={className} );
isDisabled={isDisabled}
>
{data.map(option => (
<FormSelectOption
key={option.key}
value={option.value}
label={option.label}
isDisabled={option.isDisabled}
/>
))}
</FormSelect>
);
}
} }
const Option = shape({ const Option = shape({

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import AnsibleSelect, { _AnsibleSelect } from './AnsibleSelect'; import AnsibleSelect from './AnsibleSelect';
const mockData = [ const mockData = [
{ {
@@ -16,6 +16,7 @@ const mockData = [
]; ];
describe('<AnsibleSelect />', () => { describe('<AnsibleSelect />', () => {
const onChange = jest.fn();
test('initially renders succesfully', async () => { test('initially renders succesfully', async () => {
mountWithContexts( mountWithContexts(
<AnsibleSelect <AnsibleSelect
@@ -29,19 +30,18 @@ describe('<AnsibleSelect />', () => {
}); });
test('calls "onSelectChange" on dropdown select change', () => { test('calls "onSelectChange" on dropdown select change', () => {
const spy = jest.spyOn(_AnsibleSelect.prototype, 'onSelectChange');
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<AnsibleSelect <AnsibleSelect
id="bar" id="bar"
value="foo" value="foo"
name="bar" name="bar"
onChange={() => {}} onChange={onChange}
data={mockData} data={mockData}
/> />
); );
expect(spy).not.toHaveBeenCalled(); expect(onChange).not.toHaveBeenCalled();
wrapper.find('select').simulate('change'); wrapper.find('select').simulate('change');
expect(spy).toHaveBeenCalled(); expect(onChange).toHaveBeenCalled();
}); });
test('Returns correct select options', () => { test('Returns correct select options', () => {

View File

@@ -34,7 +34,6 @@ function PageHeaderToolbar({
const handleUserSelect = () => { const handleUserSelect = () => {
setIsUserOpen(!isUserOpen); setIsUserOpen(!isUserOpen);
}; };
return ( return (
<PageHeaderTools> <PageHeaderTools>
<PageHeaderToolsGroup> <PageHeaderToolsGroup>
@@ -90,8 +89,11 @@ function PageHeaderToolbar({
dropdownItems={[ dropdownItems={[
<DropdownItem <DropdownItem
key="user" key="user"
aria-label={i18n._(t`User details`)}
href={ href={
loggedInUser ? `/users/${loggedInUser.id}/details` : '/home' loggedInUser
? `/#/users/${loggedInUser.id}/details`
: '/#/home'
} }
> >
{i18n._(t`User Details`)} {i18n._(t`User Details`)}

View File

@@ -25,6 +25,7 @@ describe('PageHeaderToolbar', () => {
<PageHeaderToolbar <PageHeaderToolbar
onAboutClick={onAboutClick} onAboutClick={onAboutClick}
onLogoutClick={onLogoutClick} onLogoutClick={onLogoutClick}
loggedInUser={{ id: 1 }}
/> />
); );
expect(wrapper.find('DropdownItem')).toHaveLength(0); expect(wrapper.find('DropdownItem')).toHaveLength(0);
@@ -37,6 +38,10 @@ describe('PageHeaderToolbar', () => {
expect(wrapper.find('DropdownItem')).toHaveLength(0); expect(wrapper.find('DropdownItem')).toHaveLength(0);
wrapper.find(pageUserDropdownSelector).simulate('click'); wrapper.find(pageUserDropdownSelector).simulate('click');
wrapper.update();
expect(
wrapper.find('DropdownItem[aria-label="User details"]').prop('href')
).toBe('/#/users/1/details');
expect(wrapper.find('DropdownItem')).toHaveLength(2); expect(wrapper.find('DropdownItem')).toHaveLength(2);
const logout = wrapper.find('DropdownItem li button'); const logout = wrapper.find('DropdownItem li button');

View File

@@ -1,73 +0,0 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import {
PageSection as PFPageSection,
PageSectionVariants,
Breadcrumb,
BreadcrumbItem,
BreadcrumbHeading,
} from '@patternfly/react-core';
import { Link, Route, useRouteMatch } from 'react-router-dom';
import styled from 'styled-components';
const PageSection = styled(PFPageSection)`
padding-top: 10px;
padding-bottom: 10px;
`;
const Breadcrumbs = ({ breadcrumbConfig }) => {
const { light } = PageSectionVariants;
return (
<PageSection variant={light}>
<Breadcrumb>
<Route path="/:path">
<Crumb breadcrumbConfig={breadcrumbConfig} />
</Route>
</Breadcrumb>
</PageSection>
);
};
const Crumb = ({ breadcrumbConfig, showDivider }) => {
const match = useRouteMatch();
const crumb = breadcrumbConfig[match.url];
let crumbElement = (
<BreadcrumbItem key={match.url} showDivider={showDivider}>
<Link to={match.url}>{crumb}</Link>
</BreadcrumbItem>
);
if (match.isExact) {
crumbElement = (
<BreadcrumbHeading key="breadcrumb-heading" showDivider={showDivider}>
{crumb}
</BreadcrumbHeading>
);
}
if (!crumb) {
crumbElement = null;
}
return (
<Fragment>
{crumbElement}
<Route path={`${match.url}/:path`}>
<Crumb breadcrumbConfig={breadcrumbConfig} showDivider />
</Route>
</Fragment>
);
};
Breadcrumbs.propTypes = {
breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired,
};
Crumb.propTypes = {
breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired,
};
export default Breadcrumbs;

View File

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

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { t } from '@lingui/macro';
import Detail from './Detail';
const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => {
const {
created_by: createdBy,
job_template: jobTemplate,
schedule,
} = summary_fields;
const { schedule: relatedSchedule } = related;
if (!createdBy && !schedule) {
return {};
}
let link;
let value;
if (createdBy) {
link = `/users/${createdBy.id}`;
value = createdBy.username;
} else if (relatedSchedule && jobTemplate) {
link = `/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`;
value = schedule.name;
} else {
link = null;
value = schedule.name;
}
return { link, value };
};
export default function LaunchedByDetail({ job, i18n }) {
const { value: launchedByValue, link: launchedByLink } =
getLaunchedByDetails(job) || {};
return (
<Detail
label={i18n._(t`Launched By`)}
value={
launchedByLink ? (
<Link to={`${launchedByLink}`}>{launchedByValue}</Link>
) : (
launchedByValue
)
}
/>
);
}

View File

@@ -4,6 +4,7 @@ export { default as DeletedDetail } from './DeletedDetail';
export { default as UserDateDetail } from './UserDateDetail'; export { default as UserDateDetail } from './UserDateDetail';
export { default as DetailBadge } from './DetailBadge'; export { default as DetailBadge } from './DetailBadge';
export { default as ArrayDetail } from './ArrayDetail'; export { default as ArrayDetail } from './ArrayDetail';
export { default as LaunchedByDetail } from './LaunchedByDetail';
/* /*
NOTE: CodeDetail cannot be imported here, as it causes circular NOTE: CodeDetail cannot be imported here, as it causes circular
dependencies in testing environment. Import it directly from dependencies in testing environment. Import it directly from

View File

@@ -31,35 +31,31 @@ const ToolbarItem = styled(PFToolbarItem)`
// TODO: Recommend renaming this component to avoid confusion // TODO: Recommend renaming this component to avoid confusion
// with ExpandingContainer // with ExpandingContainer
class ExpandCollapse extends React.Component { function ExpandCollapse({ isCompact, onCompact, onExpand, i18n }) {
render() { return (
const { isCompact, onCompact, onExpand, i18n } = this.props; <Fragment>
<ToolbarItem>
return ( <Button
<Fragment> variant="plain"
<ToolbarItem> aria-label={i18n._(t`Collapse`)}
<Button onClick={onCompact}
variant="plain" isActive={isCompact}
aria-label={i18n._(t`Collapse`)} >
onClick={onCompact} <BarsIcon />
isActive={isCompact} </Button>
> </ToolbarItem>
<BarsIcon /> <ToolbarItem>
</Button> <Button
</ToolbarItem> variant="plain"
<ToolbarItem> aria-label={i18n._(t`Expand`)}
<Button onClick={onExpand}
variant="plain" isActive={!isCompact}
aria-label={i18n._(t`Expand`)} >
onClick={onExpand} <EqualsIcon />
isActive={!isCompact} </Button>
> </ToolbarItem>
<EqualsIcon /> </Fragment>
</Button> );
</ToolbarItem>
</Fragment>
);
}
} }
ExpandCollapse.propTypes = { ExpandCollapse.propTypes = {

View File

@@ -12,7 +12,15 @@ import {
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons'; import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
function PasswordInput(props) { function PasswordInput(props) {
const { id, name, validate, isRequired, isDisabled, i18n } = props; const {
autocomplete,
id,
name,
validate,
isRequired,
isDisabled,
i18n,
} = props;
const [inputType, setInputType] = useState('password'); const [inputType, setInputType] = useState('password');
const [field, meta] = useField({ name, validate }); const [field, meta] = useField({ name, validate });
@@ -38,6 +46,7 @@ function PasswordInput(props) {
</Button> </Button>
</Tooltip> </Tooltip>
<TextInput <TextInput
autoComplete={autocomplete}
id={id} id={id}
placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined} placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined}
{...field} {...field}
@@ -55,6 +64,7 @@ function PasswordInput(props) {
} }
PasswordInput.propTypes = { PasswordInput.propTypes = {
autocomplete: PropTypes.string,
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
validate: PropTypes.func, validate: PropTypes.func,
@@ -63,6 +73,7 @@ PasswordInput.propTypes = {
}; };
PasswordInput.defaultProps = { PasswordInput.defaultProps = {
autocomplete: 'new-password',
validate: () => {}, validate: () => {},
isRequired: false, isRequired: false,
isDisabled: false, isDisabled: false,

View File

@@ -7,7 +7,8 @@ import { Card } from '@patternfly/react-core';
import AlertModal from '../AlertModal'; import AlertModal from '../AlertModal';
import DatalistToolbar from '../DataListToolbar'; import DatalistToolbar from '../DataListToolbar';
import ErrorDetail from '../ErrorDetail'; import ErrorDetail from '../ErrorDetail';
import PaginatedDataList, { ToolbarDeleteButton } from '../PaginatedDataList'; import { ToolbarDeleteButton } from '../PaginatedDataList';
import PaginatedTable, { HeaderRow, HeaderCell } from '../PaginatedTable';
import useRequest, { import useRequest, {
useDeleteItems, useDeleteItems,
useDismissableError, useDismissableError,
@@ -27,7 +28,7 @@ import {
} from '../../api'; } from '../../api';
function JobList({ i18n, defaultParams, showTypeColumn = false }) { function JobList({ i18n, defaultParams, showTypeColumn = false }) {
const QS_CONFIG = getQSConfig( const qsConfig = getQSConfig(
'job', 'job',
{ {
page: 1, page: 1,
@@ -49,7 +50,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
} = useRequest( } = useRequest(
useCallback( useCallback(
async () => { async () => {
const params = parseQueryString(QS_CONFIG, location.search); const params = parseQueryString(qsConfig, location.search);
const [response, actionsResponse] = await Promise.all([ const [response, actionsResponse] = await Promise.all([
UnifiedJobsAPI.read({ ...params }), UnifiedJobsAPI.read({ ...params }),
UnifiedJobsAPI.readOptions(), UnifiedJobsAPI.readOptions(),
@@ -81,7 +82,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
// TODO: update QS_CONFIG to be safe for deps array // TODO: update QS_CONFIG to be safe for deps array
const fetchJobsById = useCallback( const fetchJobsById = useCallback(
async ids => { async ids => {
const params = parseQueryString(QS_CONFIG, location.search); const params = parseQueryString(qsConfig, location.search);
params.id__in = ids.join(','); params.id__in = ids.join(',');
const { data } = await UnifiedJobsAPI.read(params); const { data } = await UnifiedJobsAPI.read(params);
return data.results; return data.results;
@@ -89,7 +90,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
[location.search] // eslint-disable-line react-hooks/exhaustive-deps [location.search] // eslint-disable-line react-hooks/exhaustive-deps
); );
const jobs = useWsJobs(results, fetchJobsById, QS_CONFIG); const jobs = useWsJobs(results, fetchJobsById, qsConfig);
const isAllSelected = selected.length === jobs.length && selected.length > 0; const isAllSelected = selected.length === jobs.length && selected.length > 0;
@@ -145,7 +146,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
); );
}, [selected]), }, [selected]),
{ {
qsConfig: QS_CONFIG, qsConfig,
allItemsSelected: isAllSelected, allItemsSelected: isAllSelected,
fetchItems: fetchJobs, fetchItems: fetchJobs,
} }
@@ -176,14 +177,13 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
return ( return (
<> <>
<Card> <Card>
<PaginatedDataList <PaginatedTable
contentError={contentError} contentError={contentError}
hasContentLoading={isLoading || isDeleteLoading || isCancelLoading} hasContentLoading={isLoading || isDeleteLoading || isCancelLoading}
items={jobs} items={jobs}
itemCount={count} itemCount={count}
pluralizedItemName={i18n._(t`Jobs`)} pluralizedItemName={i18n._(t`Jobs`)}
qsConfig={QS_CONFIG} qsConfig={qsConfig}
onRowClick={handleSelect}
toolbarSearchColumns={[ toolbarSearchColumns={[
{ {
name: i18n._(t`Name`), name: i18n._(t`Name`),
@@ -233,32 +233,17 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
key: 'job__limit', key: 'job__limit',
}, },
]} ]}
toolbarSortColumns={[ headerRow={
{ <HeaderRow qsConfig={qsConfig} isExpandable>
name: i18n._(t`Finish Time`), <HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
key: 'finished', <HeaderCell sortKey="status">{i18n._(t`Status`)}</HeaderCell>
}, {showTypeColumn && <HeaderCell>{i18n._(t`Type`)}</HeaderCell>}
{ <HeaderCell sortKey="started">{i18n._(t`Start Time`)}</HeaderCell>
name: i18n._(t`ID`), <HeaderCell sortKey="finished">
key: 'id', {i18n._(t`Finish Time`)}
}, </HeaderCell>
{ </HeaderRow>
name: i18n._(t`Launched By`), }
key: 'created_by__id',
},
{
name: i18n._(t`Name`),
key: 'name',
},
{
name: i18n._(t`Project`),
key: 'unified_job_template__project__id',
},
{
name: i18n._(t`Start Time`),
key: 'started',
},
]}
toolbarSearchableKeys={searchableKeys} toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys} toolbarRelatedSearchableKeys={relatedSearchableKeys}
renderToolbar={props => ( renderToolbar={props => (
@@ -267,7 +252,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
showSelectAll showSelectAll
isAllSelected={isAllSelected} isAllSelected={isAllSelected}
onSelectAll={handleSelectAll} onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG} qsConfig={qsConfig}
additionalControls={[ additionalControls={[
<ToolbarDeleteButton <ToolbarDeleteButton
key="delete" key="delete"
@@ -283,13 +268,14 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
]} ]}
/> />
)} )}
renderItem={job => ( renderRow={(job, index) => (
<JobListItem <JobListItem
key={job.id} key={job.id}
job={job} job={job}
showTypeColumn={showTypeColumn} showTypeColumn={showTypeColumn}
onSelect={() => handleSelect(job)} onSelect={() => handleSelect(job)}
isSelected={selected.some(row => row.id === job.id)} isSelected={selected.some(row => row.id === job.id)}
rowIndex={index}
/> />
)} )}
/> />

View File

@@ -1,39 +1,29 @@
import React from 'react'; import React, { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { import { Button, Chip } from '@patternfly/react-core';
Button, import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
DataListAction as _DataListAction,
DataListCheck,
DataListItem,
DataListItemRow,
DataListItemCells,
Tooltip,
} from '@patternfly/react-core';
import { RocketIcon } from '@patternfly/react-icons'; import { RocketIcon } from '@patternfly/react-icons';
import styled from 'styled-components'; import { ActionsTd, ActionItem } from '../PaginatedTable';
import DataListCell from '../DataListCell';
import LaunchButton from '../LaunchButton'; import LaunchButton from '../LaunchButton';
import StatusIcon from '../StatusIcon'; import StatusLabel from '../StatusLabel';
import { DetailList, Detail, LaunchedByDetail } from '../DetailList';
import ChipGroup from '../ChipGroup';
import CredentialChip from '../CredentialChip';
import { formatDateString } from '../../util/dates'; import { formatDateString } from '../../util/dates';
import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
const DataListAction = styled(_DataListAction)`
align-items: center;
display: grid;
grid-gap: 16px;
grid-template-columns: 40px;
`;
function JobListItem({ function JobListItem({
i18n, i18n,
job, job,
rowIndex,
isSelected, isSelected,
onSelect, onSelect,
showTypeColumn = false, showTypeColumn = false,
}) { }) {
const labelId = `check-action-${job.id}`; const labelId = `check-action-${job.id}`;
const [isExpanded, setIsExpanded] = useState(false);
const jobTypes = { const jobTypes = {
project_update: i18n._(t`Source Control Update`), project_update: i18n._(t`Source Control Update`),
@@ -44,67 +34,123 @@ function JobListItem({
workflow_job: i18n._(t`Workflow Job`), workflow_job: i18n._(t`Workflow Job`),
}; };
const { credentials, inventory, labels } = job.summary_fields;
return ( return (
<DataListItem aria-labelledby={labelId} id={`${job.id}`}> <>
<DataListItemRow> <Tr id={`job-row-${job.id}`}>
<DataListCheck <Td
id={`select-job-${job.id}`} expand={{
checked={isSelected} rowIndex: job.id,
onChange={onSelect} isExpanded,
aria-labelledby={labelId} onToggle: () => setIsExpanded(!isExpanded),
}}
/> />
<DataListItemCells <Td
dataListCells={[ select={{
<DataListCell key="status" isFilled={false}> rowIndex,
{job.status && <StatusIcon status={job.status} />} isSelected,
</DataListCell>, onSelect,
<DataListCell key="name"> }}
<span> dataLabel={i18n._(t`Select`)}
<Link to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}>
<b>
{job.id} &mdash; {job.name}
</b>
</Link>
</span>
</DataListCell>,
...(showTypeColumn
? [
<DataListCell key="type" aria-label="type">
{jobTypes[job.type]}
</DataListCell>,
]
: []),
<DataListCell key="finished">
{job.finished ? formatDateString(job.finished) : ''}
</DataListCell>,
]}
/> />
<DataListAction <Td id={labelId} dataLabel={i18n._(t`Name`)}>
aria-label="actions" <span>
aria-labelledby={labelId} <Link to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}>
id={labelId} <b>
> {job.id} &mdash; {job.name}
{job.type !== 'system_job' && </b>
job.summary_fields?.user_capabilities?.start ? ( </Link>
<Tooltip content={i18n._(t`Relaunch Job`)} position="top"> </span>
<LaunchButton resource={job}> </Td>
{({ handleRelaunch }) => ( <Td dataLabel={i18n._(t`Status`)}>
<Button {job.status && <StatusLabel status={job.status} />}
variant="plain" </Td>
onClick={handleRelaunch} {showTypeColumn && (
aria-label={i18n._(t`Relaunch`)} <Td dataLabel={i18n._(t`Type`)}>{jobTypes[job.type]}</Td>
> )}
<RocketIcon /> <Td dataLabel={i18n._(t`Start Time`)}>
</Button> {formatDateString(job.started)}
)} </Td>
</LaunchButton> <Td dataLabel={i18n._(t`Finish Time`)}>
</Tooltip> {job.finished ? formatDateString(job.finished) : ''}
) : ( </Td>
'' <ActionsTd dataLabel={i18n._(t`Actions`)}>
)} <ActionItem
</DataListAction> visible={
</DataListItemRow> job.type !== 'system_job' &&
</DataListItem> job.summary_fields?.user_capabilities?.start
}
tooltip={i18n._(t`Relaunch Job`)}
>
<LaunchButton resource={job}>
{({ handleRelaunch }) => (
<Button
variant="plain"
onClick={handleRelaunch}
aria-label={i18n._(t`Relaunch`)}
>
<RocketIcon />
</Button>
)}
</LaunchButton>
</ActionItem>
</ActionsTd>
</Tr>
<Tr isExpanded={isExpanded} id={`expanded-job-row-${job.id}`}>
<Td colSpan={2} />
<Td colSpan={showTypeColumn ? 5 : 4}>
<ExpandableRowContent>
<DetailList>
<LaunchedByDetail job={job} i18n={i18n} />
{credentials && credentials.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Credentials`)}
value={
<ChipGroup numChips={5} totalChips={credentials.length}>
{credentials.map(c => (
<CredentialChip key={c.id} credential={c} isReadOnly />
))}
</ChipGroup>
}
/>
)}
{labels && labels.count > 0 && (
<Detail
label={i18n._(t`Labels`)}
value={
<ChipGroup numChips={5} totalChips={labels.results.length}>
{labels.results.map(l => (
<Chip key={l.id} isReadOnly>
{l.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
{inventory && (
<Detail
label={i18n._(t`Inventory`)}
value={
<Link
to={
inventory.kind === 'smart'
? `/inventories/smart_inventory/${inventory.id}`
: `/inventories/inventory/${inventory.id}`
}
>
{inventory.name}
</Link>
}
/>
)}
</DetailList>
</ExpandableRowContent>
</Td>
</Tr>
</>
); );
} }

View File

@@ -32,7 +32,11 @@ describe('<JobListItem />', () => {
initialEntries: ['/jobs'], initialEntries: ['/jobs'],
}); });
wrapper = mountWithContexts( wrapper = mountWithContexts(
<JobListItem job={mockJob} isSelected onSelect={() => {}} />, <table>
<tbody>
<JobListItem job={mockJob} isSelected onSelect={() => {}} />
</tbody>
</table>,
{ context: { router: { history } } } { context: { router: { history } } }
); );
}); });
@@ -51,32 +55,40 @@ describe('<JobListItem />', () => {
test('launch button hidden from users without launch capabilities', () => { test('launch button hidden from users without launch capabilities', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<JobListItem <table>
job={{ <tbody>
...mockJob, <JobListItem
summary_fields: { user_capabilities: { start: false } }, job={{
}} ...mockJob,
detailUrl={`/jobs/playbook/${mockJob.id}`} summary_fields: { user_capabilities: { start: false } },
onSelect={() => {}} }}
isSelected={false} detailUrl={`/jobs/playbook/${mockJob.id}`}
/> onSelect={() => {}}
isSelected={false}
/>
</tbody>
</table>
); );
expect(wrapper.find('LaunchButton').length).toBe(0); expect(wrapper.find('LaunchButton').length).toBe(0);
}); });
test('should hide type column when showTypeColumn is false', () => { test('should hide type column when showTypeColumn is false', () => {
expect(wrapper.find('DataListCell[aria-label="type"]').length).toBe(0); expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(0);
}); });
test('should show type column when showTypeColumn is true', () => { test('should show type column when showTypeColumn is true', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<JobListItem <table>
job={mockJob} <tbody>
showTypeColumn <JobListItem
isSelected job={mockJob}
onSelect={() => {}} showTypeColumn
/> isSelected
onSelect={() => {}}
/>
</tbody>
</table>
); );
expect(wrapper.find('DataListCell[aria-label="type"]').length).toBe(1); expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(1);
}); });
}); });

View File

@@ -25,6 +25,8 @@ function canLaunchWithoutPrompt(launchData) {
!launchData.ask_limit_on_launch && !launchData.ask_limit_on_launch &&
!launchData.ask_scm_branch_on_launch && !launchData.ask_scm_branch_on_launch &&
!launchData.survey_enabled && !launchData.survey_enabled &&
(!launchData.passwords_needed_to_start ||
launchData.passwords_needed_to_start.length === 0) &&
(!launchData.variables_needed_to_start || (!launchData.variables_needed_to_start ||
launchData.variables_needed_to_start.length === 0) launchData.variables_needed_to_start.length === 0)
); );
@@ -100,17 +102,20 @@ class LaunchButton extends React.Component {
async launchWithParams(params) { async launchWithParams(params) {
try { try {
const { history, resource } = this.props; const { history, resource } = this.props;
const jobPromise = let jobPromise;
resource.type === 'workflow_job_template'
? WorkflowJobTemplatesAPI.launch(resource.id, params || {}) if (resource.type === 'job_template') {
: JobTemplatesAPI.launch(resource.id, params || {}); jobPromise = JobTemplatesAPI.launch(resource.id, params || {});
} else if (resource.type === 'workflow_job_template') {
jobPromise = WorkflowJobTemplatesAPI.launch(resource.id, params || {});
} else if (resource.type === 'job') {
jobPromise = JobsAPI.relaunch(resource.id, params || {});
} else if (resource.type === 'workflow_job') {
jobPromise = WorkflowJobsAPI.relaunch(resource.id, params || {});
}
const { data: job } = await jobPromise; const { data: job } = await jobPromise;
history.push( history.push(`/jobs/${job.id}/output`);
`/${
resource.type === 'workflow_job_template' ? 'jobs/workflow' : 'jobs'
}/${job.id}/output`
);
} catch (launchError) { } catch (launchError) {
this.setState({ launchError }); this.setState({ launchError });
} }
@@ -127,20 +132,15 @@ class LaunchButton extends React.Component {
readRelaunch = InventorySourcesAPI.readLaunchUpdate( readRelaunch = InventorySourcesAPI.readLaunchUpdate(
resource.inventory_source resource.inventory_source
); );
relaunch = InventorySourcesAPI.launchUpdate(resource.inventory_source);
} else if (resource.type === 'project_update') { } else if (resource.type === 'project_update') {
// We'll need to handle the scenario where the project no longer exists // We'll need to handle the scenario where the project no longer exists
readRelaunch = ProjectsAPI.readLaunchUpdate(resource.project); readRelaunch = ProjectsAPI.readLaunchUpdate(resource.project);
relaunch = ProjectsAPI.launchUpdate(resource.project);
} else if (resource.type === 'workflow_job') { } else if (resource.type === 'workflow_job') {
readRelaunch = WorkflowJobsAPI.readRelaunch(resource.id); readRelaunch = WorkflowJobsAPI.readRelaunch(resource.id);
relaunch = WorkflowJobsAPI.relaunch(resource.id);
} else if (resource.type === 'ad_hoc_command') { } else if (resource.type === 'ad_hoc_command') {
readRelaunch = AdHocCommandsAPI.readRelaunch(resource.id); readRelaunch = AdHocCommandsAPI.readRelaunch(resource.id);
relaunch = AdHocCommandsAPI.relaunch(resource.id);
} else if (resource.type === 'job') { } else if (resource.type === 'job') {
readRelaunch = JobsAPI.readRelaunch(resource.id); readRelaunch = JobsAPI.readRelaunch(resource.id);
relaunch = JobsAPI.relaunch(resource.id);
} }
try { try {
@@ -149,11 +149,22 @@ class LaunchButton extends React.Component {
!relaunchConfig.passwords_needed_to_start || !relaunchConfig.passwords_needed_to_start ||
relaunchConfig.passwords_needed_to_start.length === 0 relaunchConfig.passwords_needed_to_start.length === 0
) { ) {
if (resource.type === 'inventory_update') {
relaunch = InventorySourcesAPI.launchUpdate(
resource.inventory_source
);
} else if (resource.type === 'project_update') {
relaunch = ProjectsAPI.launchUpdate(resource.project);
} else if (resource.type === 'workflow_job') {
relaunch = WorkflowJobsAPI.relaunch(resource.id);
} else if (resource.type === 'ad_hoc_command') {
relaunch = AdHocCommandsAPI.relaunch(resource.id);
} else if (resource.type === 'job') {
relaunch = JobsAPI.relaunch(resource.id);
}
const { data: job } = await relaunch; const { data: job } = await relaunch;
history.push(`/jobs/${job.id}/output`); history.push(`/jobs/${job.id}/output`);
} else { } else {
// TODO: restructure (async?) to send launch command after prompts
// TODO: does relaunch need different prompt treatment than launch?
this.setState({ this.setState({
showLaunchPrompt: true, showLaunchPrompt: true,
launchConfig: relaunchConfig, launchConfig: relaunchConfig,

View File

@@ -4,10 +4,16 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import { sleep } from '../../../testUtils/testUtils'; import { sleep } from '../../../testUtils/testUtils';
import LaunchButton from './LaunchButton'; import LaunchButton from './LaunchButton';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api'; import {
InventorySourcesAPI,
JobsAPI,
JobTemplatesAPI,
ProjectsAPI,
WorkflowJobsAPI,
WorkflowJobTemplatesAPI,
} from '../../api';
jest.mock('../../api/models/WorkflowJobTemplates'); jest.mock('../../api');
jest.mock('../../api/models/JobTemplates');
describe('LaunchButton', () => { describe('LaunchButton', () => {
JobTemplatesAPI.readLaunch.mockResolvedValue({ JobTemplatesAPI.readLaunch.mockResolvedValue({
@@ -22,10 +28,14 @@ describe('LaunchButton', () => {
}, },
}); });
const children = ({ handleLaunch }) => ( const launchButton = ({ handleLaunch }) => (
<button type="submit" onClick={() => handleLaunch()} /> <button type="submit" onClick={() => handleLaunch()} />
); );
const relaunchButton = ({ handleRelaunch }) => (
<button type="submit" onClick={() => handleRelaunch()} />
);
const resource = { const resource = {
id: 1, id: 1,
type: 'job_template', type: 'job_template',
@@ -35,7 +45,7 @@ describe('LaunchButton', () => {
test('renders the expected content', () => { test('renders the expected content', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<LaunchButton resource={resource}>{children}</LaunchButton> <LaunchButton resource={resource}>{launchButton}</LaunchButton>
); );
expect(wrapper).toHaveLength(1); expect(wrapper).toHaveLength(1);
}); });
@@ -51,7 +61,7 @@ describe('LaunchButton', () => {
}, },
}); });
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<LaunchButton resource={resource}>{children}</LaunchButton>, <LaunchButton resource={resource}>{launchButton}</LaunchButton>,
{ {
context: { context: {
router: { history }, router: { history },
@@ -87,7 +97,7 @@ describe('LaunchButton', () => {
type: 'workflow_job_template', type: 'workflow_job_template',
}} }}
> >
{children} {launchButton}
</LaunchButton>, </LaunchButton>,
{ {
context: { context: {
@@ -100,12 +110,162 @@ describe('LaunchButton', () => {
expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1); expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
await sleep(0); await sleep(0);
expect(WorkflowJobTemplatesAPI.launch).toHaveBeenCalledWith(1, {}); expect(WorkflowJobTemplatesAPI.launch).toHaveBeenCalledWith(1, {});
expect(history.location.pathname).toEqual('/jobs/workflow/9000/output'); expect(history.location.pathname).toEqual('/jobs/9000/output');
});
test('should relaunch job correctly', async () => {
JobsAPI.readRelaunch.mockResolvedValue({
data: {
can_start_without_user_input: true,
},
});
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
JobsAPI.relaunch.mockResolvedValue({
data: {
id: 9000,
},
});
const wrapper = mountWithContexts(
<LaunchButton
resource={{
id: 1,
type: 'job',
}}
>
{relaunchButton}
</LaunchButton>,
{
context: {
router: { history },
},
}
);
const button = wrapper.find('button');
button.prop('onClick')();
expect(JobsAPI.readRelaunch).toHaveBeenCalledWith(1);
await sleep(0);
expect(JobsAPI.relaunch).toHaveBeenCalledWith(1);
expect(history.location.pathname).toEqual('/jobs/9000/output');
});
test('should relaunch workflow job correctly', async () => {
WorkflowJobsAPI.readRelaunch.mockResolvedValue({
data: {
can_start_without_user_input: true,
},
});
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
WorkflowJobsAPI.relaunch.mockResolvedValue({
data: {
id: 9000,
},
});
const wrapper = mountWithContexts(
<LaunchButton
resource={{
id: 1,
type: 'workflow_job',
}}
>
{relaunchButton}
</LaunchButton>,
{
context: {
router: { history },
},
}
);
const button = wrapper.find('button');
button.prop('onClick')();
expect(WorkflowJobsAPI.readRelaunch).toHaveBeenCalledWith(1);
await sleep(0);
expect(WorkflowJobsAPI.relaunch).toHaveBeenCalledWith(1);
expect(history.location.pathname).toEqual('/jobs/9000/output');
});
test('should relaunch project sync correctly', async () => {
ProjectsAPI.readLaunchUpdate.mockResolvedValue({
data: {
can_start_without_user_input: true,
},
});
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
ProjectsAPI.launchUpdate.mockResolvedValue({
data: {
id: 9000,
},
});
const wrapper = mountWithContexts(
<LaunchButton
resource={{
id: 1,
project: 5,
type: 'project_update',
}}
>
{relaunchButton}
</LaunchButton>,
{
context: {
router: { history },
},
}
);
const button = wrapper.find('button');
button.prop('onClick')();
expect(ProjectsAPI.readLaunchUpdate).toHaveBeenCalledWith(5);
await sleep(0);
expect(ProjectsAPI.launchUpdate).toHaveBeenCalledWith(5);
expect(history.location.pathname).toEqual('/jobs/9000/output');
});
test('should relaunch project sync correctly', async () => {
InventorySourcesAPI.readLaunchUpdate.mockResolvedValue({
data: {
can_start_without_user_input: true,
},
});
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
InventorySourcesAPI.launchUpdate.mockResolvedValue({
data: {
id: 9000,
},
});
const wrapper = mountWithContexts(
<LaunchButton
resource={{
id: 1,
inventory_source: 5,
type: 'inventory_update',
}}
>
{relaunchButton}
</LaunchButton>,
{
context: {
router: { history },
},
}
);
const button = wrapper.find('button');
button.prop('onClick')();
expect(InventorySourcesAPI.readLaunchUpdate).toHaveBeenCalledWith(5);
await sleep(0);
expect(InventorySourcesAPI.launchUpdate).toHaveBeenCalledWith(5);
expect(history.location.pathname).toEqual('/jobs/9000/output');
}); });
test('displays error modal after unsuccessful launch', async () => { test('displays error modal after unsuccessful launch', async () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<LaunchButton resource={resource}>{children}</LaunchButton> <LaunchButton resource={resource}>{launchButton}</LaunchButton>
); );
JobTemplatesAPI.launch.mockRejectedValue( JobTemplatesAPI.launch.mockRejectedValue(
new Error({ new Error({

View File

@@ -19,17 +19,18 @@ function PromptModalForm({
resource, resource,
surveyConfig, surveyConfig,
}) { }) {
const { values, setTouched, validateForm } = useFormikContext(); const { setFieldTouched, values } = useFormikContext();
const { const {
steps, steps,
isReady, isReady,
validateStep,
visitStep, visitStep,
visitAllSteps, visitAllSteps,
contentError, contentError,
} = useLaunchSteps(launchConfig, surveyConfig, resource, i18n); } = useLaunchSteps(launchConfig, surveyConfig, resource, i18n);
const handleSave = () => { const handleSubmit = () => {
const postValues = {}; const postValues = {};
const setValue = (key, value) => { const setValue = (key, value) => {
if (typeof value !== 'undefined' && value !== null) { if (typeof value !== 'undefined' && value !== null) {
@@ -37,6 +38,7 @@ function PromptModalForm({
} }
}; };
const surveyValues = getSurveyValues(values); const surveyValues = getSurveyValues(values);
setValue('credential_passwords', values.credential_passwords);
setValue('inventory_id', values.inventory?.id); setValue('inventory_id', values.inventory?.id);
setValue( setValue(
'credentials', 'credentials',
@@ -75,22 +77,25 @@ function PromptModalForm({
<Wizard <Wizard
isOpen isOpen
onClose={onCancel} onClose={onCancel}
onSave={handleSave} onSave={handleSubmit}
onBack={async nextStep => {
validateStep(nextStep.id);
}}
onNext={async (nextStep, prevStep) => { onNext={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') { if (nextStep.id === 'preview') {
visitAllSteps(setTouched); visitAllSteps(setFieldTouched);
} else { } else {
visitStep(prevStep.prevId); visitStep(prevStep.prevId, setFieldTouched);
validateStep(nextStep.id);
} }
await validateForm();
}} }}
onGoToStep={async (nextStep, prevStep) => { onGoToStep={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') { if (nextStep.id === 'preview') {
visitAllSteps(setTouched); visitAllSteps(setFieldTouched);
} else { } else {
visitStep(prevStep.prevId); visitStep(prevStep.prevId, setFieldTouched);
validateStep(nextStep.id);
} }
await validateForm();
}} }}
title={i18n._(t`Prompts`)} title={i18n._(t`Prompts`)}
steps={ steps={

View File

@@ -82,8 +82,26 @@ describe('LaunchPrompt', () => {
ask_credential_on_launch: true, ask_credential_on_launch: true,
ask_scm_branch_on_launch: true, ask_scm_branch_on_launch: true,
survey_enabled: true, survey_enabled: true,
passwords_needed_to_start: ['ssh_password'],
defaults: {
credentials: [
{
id: 1,
passwords_needed: ['ssh_password'],
},
],
},
}}
resource={{
...resource,
summary_fields: {
credentials: [
{
id: 1,
},
],
},
}} }}
resource={resource}
onLaunch={noop} onLaunch={noop}
onCancel={noop} onCancel={noop}
surveyConfig={{ surveyConfig={{
@@ -110,12 +128,13 @@ describe('LaunchPrompt', () => {
const wizard = await waitForElement(wrapper, 'Wizard'); const wizard = await waitForElement(wrapper, 'Wizard');
const steps = wizard.prop('steps'); const steps = wizard.prop('steps');
expect(steps).toHaveLength(5); expect(steps).toHaveLength(6);
expect(steps[0].name.props.children).toEqual('Inventory'); expect(steps[0].name.props.children).toEqual('Inventory');
expect(steps[1].name.props.children).toEqual('Credentials'); expect(steps[1].name.props.children).toEqual('Credentials');
expect(steps[2].name.props.children).toEqual('Other prompts'); expect(steps[2].name.props.children).toEqual('Credential passwords');
expect(steps[3].name.props.children).toEqual('Survey'); expect(steps[3].name.props.children).toEqual('Other prompts');
expect(steps[4].name.props.children).toEqual('Preview'); expect(steps[4].name.props.children).toEqual('Survey');
expect(steps[5].name.props.children).toEqual('Preview');
}); });
test('should add inventory step', async () => { test('should add inventory step', async () => {

View File

@@ -0,0 +1,131 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Form } from '@patternfly/react-core';
import { useFormikContext } from 'formik';
import { PasswordField } from '../../FormField';
function CredentialPasswordsStep({ launchConfig, i18n }) {
const {
values: { credentials },
} = useFormikContext();
const vaultsThatPrompt = [];
let showcredentialPasswordSsh = false;
let showcredentialPasswordPrivilegeEscalation = false;
let showcredentialPasswordPrivateKeyPassphrase = false;
if (
!launchConfig.ask_credential_on_launch &&
launchConfig.passwords_needed_to_start
) {
launchConfig.passwords_needed_to_start.forEach(password => {
if (password === 'ssh_password') {
showcredentialPasswordSsh = true;
} else if (password === 'become_password') {
showcredentialPasswordPrivilegeEscalation = true;
} else if (password === 'ssh_key_unlock') {
showcredentialPasswordPrivateKeyPassphrase = true;
} else if (password.startsWith('vault_password')) {
const vaultId = password.split(/\.(.+)/)[1] || '';
vaultsThatPrompt.push(vaultId);
}
});
} else if (credentials) {
credentials.forEach(credential => {
if (!credential.inputs) {
const launchConfigCredential = launchConfig.defaults.credentials.find(
defaultCred => defaultCred.id === credential.id
);
if (launchConfigCredential?.passwords_needed.length > 0) {
if (
launchConfigCredential.passwords_needed.includes('ssh_password')
) {
showcredentialPasswordSsh = true;
}
if (
launchConfigCredential.passwords_needed.includes('become_password')
) {
showcredentialPasswordPrivilegeEscalation = true;
}
if (
launchConfigCredential.passwords_needed.includes('ssh_key_unlock')
) {
showcredentialPasswordPrivateKeyPassphrase = true;
}
const vaultPasswordIds = launchConfigCredential.passwords_needed
.filter(passwordNeeded =>
passwordNeeded.startsWith('vault_password')
)
.map(vaultPassword => vaultPassword.split(/\.(.+)/)[1] || '');
vaultsThatPrompt.push(...vaultPasswordIds);
}
} else {
if (credential?.inputs?.password === 'ASK') {
showcredentialPasswordSsh = true;
}
if (credential?.inputs?.become_password === 'ASK') {
showcredentialPasswordPrivilegeEscalation = true;
}
if (credential?.inputs?.ssh_key_unlock === 'ASK') {
showcredentialPasswordPrivateKeyPassphrase = true;
}
if (credential?.inputs?.vault_password === 'ASK') {
vaultsThatPrompt.push(credential.inputs.vault_id);
}
}
});
}
return (
<Form>
{showcredentialPasswordSsh && (
<PasswordField
id="launch-ssh-password"
label={i18n._(t`SSH password`)}
name="credential_passwords.ssh_password"
isRequired
/>
)}
{showcredentialPasswordPrivateKeyPassphrase && (
<PasswordField
id="launch-private-key-passphrase"
label={i18n._(t`Private key passphrase`)}
name="credential_passwords.ssh_key_unlock"
isRequired
/>
)}
{showcredentialPasswordPrivilegeEscalation && (
<PasswordField
id="launch-privilege-escalation-password"
label={i18n._(t`Privilege escalation password`)}
name="credential_passwords.become_password"
isRequired
/>
)}
{vaultsThatPrompt.map(credId => (
<PasswordField
id={`launch-vault-password-${credId}`}
key={credId}
label={
credId === ''
? i18n._(t`Vault password`)
: i18n._(t`Vault password | ${credId}`)
}
name={`credential_passwords['vault_password${
credId !== '' ? `.${credId}` : ''
}']`}
isRequired
/>
))}
</Form>
);
}
export default withI18n()(CredentialPasswordsStep);

View File

@@ -0,0 +1,603 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import CredentialPasswordsStep from './CredentialPasswordsStep';
describe('CredentialPasswordsStep', () => {
describe('JT default credentials (no credential replacement) and creds are promptable', () => {
test('should render ssh password field when JT has default machine cred', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [
{
id: 1,
passwords_needed: ['ssh_password'],
},
],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render become password field when JT has default machine cred', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [
{
id: 1,
passwords_needed: ['become_password'],
},
],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render private key passphrase field when JT has default machine cred', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
defaults: {
ask_credential_on_launch: true,
credentials: [
{
id: 1,
passwords_needed: ['ssh_key_unlock'],
},
],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render vault password field when JT has default vault cred', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [
{
id: 1,
passwords_needed: ['vault_password.1'],
},
],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-1')
).toHaveLength(1);
});
test('should render all password field when JT has default vault cred and machine cred', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
},
{
id: 2,
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [
{
id: 1,
passwords_needed: [
'ssh_password',
'become_password',
'ssh_key_unlock',
],
},
{
id: 2,
passwords_needed: ['vault_password.1'],
},
],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-1')
).toHaveLength(1);
});
});
describe('Credentials have been replaced and creds are promptable', () => {
test('should render ssh password field when replacement machine cred prompts for it', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
inputs: {
password: 'ASK',
become_password: null,
ssh_key_unlock: null,
vault_password: null,
},
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render become password field when replacement machine cred prompts for it', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
inputs: {
password: null,
become_password: 'ASK',
ssh_key_unlock: null,
vault_password: null,
},
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render private key passphrase field when replacement machine cred prompts for it', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
inputs: {
password: null,
become_password: null,
ssh_key_unlock: 'ASK',
vault_password: null,
},
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render vault password field when replacement vault cred prompts for it', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
inputs: {
password: null,
become_password: null,
ssh_key_unlock: null,
vault_password: 'ASK',
vault_id: 'foobar',
},
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-foobar')
).toHaveLength(1);
});
test('should render all password fields when replacement vault and machine creds prompt for it', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
inputs: {
password: 'ASK',
become_password: 'ASK',
ssh_key_unlock: 'ASK',
},
},
{
id: 2,
inputs: {
password: null,
become_password: null,
ssh_key_unlock: null,
vault_password: 'ASK',
vault_id: 'foobar',
},
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-foobar')
).toHaveLength(1);
});
});
describe('Credentials have been replaced and creds are not promptable', () => {
test('should render ssh password field when required', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{}}>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: false,
passwords_needed_to_start: ['ssh_password'],
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render become password field when required', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{}}>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: false,
passwords_needed_to_start: ['become_password'],
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render private key passphrase field when required', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{}}>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: false,
passwords_needed_to_start: ['ssh_key_unlock'],
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render vault password field when required', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{}}>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: false,
passwords_needed_to_start: ['vault_password.foobar'],
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-foobar')
).toHaveLength(1);
});
test('should render all password fields when required', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{}}>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: false,
passwords_needed_to_start: [
'ssh_password',
'become_password',
'ssh_key_unlock',
'vault_password.foobar',
],
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-foobar')
).toHaveLength(1);
});
});
});

View File

@@ -3,6 +3,7 @@ import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useField } from 'formik'; import { useField } from 'formik';
import { Alert } from '@patternfly/react-core';
import { InventoriesAPI } from '../../../api'; import { InventoriesAPI } from '../../../api';
import { getQSConfig, parseQueryString } from '../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../util/qs';
import useRequest from '../../../util/useRequest'; import useRequest from '../../../util/useRequest';
@@ -17,9 +18,10 @@ const QS_CONFIG = getQSConfig('inventory', {
}); });
function InventoryStep({ i18n }) { function InventoryStep({ i18n }) {
const [field, , helpers] = useField({ const [field, meta, helpers] = useField({
name: 'inventory', name: 'inventory',
}); });
const history = useHistory(); const history = useHistory();
const { const {
@@ -65,40 +67,45 @@ function InventoryStep({ i18n }) {
} }
return ( return (
<OptionsList <>
value={field.value ? [field.value] : []} <OptionsList
options={inventories} value={field.value ? [field.value] : []}
optionCount={count} options={inventories}
searchColumns={[ optionCount={count}
{ searchColumns={[
name: i18n._(t`Name`), {
key: 'name__icontains', name: i18n._(t`Name`),
isDefault: true, key: 'name__icontains',
}, isDefault: true,
{ },
name: i18n._(t`Created By (Username)`), {
key: 'created_by__username__icontains', name: i18n._(t`Created By (Username)`),
}, key: 'created_by__username__icontains',
{ },
name: i18n._(t`Modified By (Username)`), {
key: 'modified_by__username__icontains', name: i18n._(t`Modified By (Username)`),
}, key: 'modified_by__username__icontains',
]} },
sortColumns={[ ]}
{ sortColumns={[
name: i18n._(t`Name`), {
key: 'name', name: i18n._(t`Name`),
}, key: 'name',
]} },
searchableKeys={searchableKeys} ]}
relatedSearchableKeys={relatedSearchableKeys} searchableKeys={searchableKeys}
header={i18n._(t`Inventory`)} relatedSearchableKeys={relatedSearchableKeys}
name="inventory" header={i18n._(t`Inventory`)}
qsConfig={QS_CONFIG} name="inventory"
readOnly qsConfig={QS_CONFIG}
selectItem={helpers.setValue} readOnly
deselectItem={() => field.onChange(null)} selectItem={helpers.setValue}
/> deselectItem={() => field.onChange(null)}
/>
{meta.touched && meta.error && (
<Alert variant="danger" isInline title={meta.error} />
)}
</>
); );
} }

View File

@@ -0,0 +1,254 @@
import React from 'react';
import { t } from '@lingui/macro';
import { useFormikContext } from 'formik';
import CredentialPasswordsStep from './CredentialPasswordsStep';
import StepName from './StepName';
const STEP_ID = 'credentialPasswords';
const isValueMissing = val => {
return !val || val === '';
};
export default function useCredentialPasswordsStep(
launchConfig,
i18n,
showStep,
visitedSteps
) {
const { values, setFieldError } = useFormikContext();
const hasError =
Object.keys(visitedSteps).includes(STEP_ID) &&
checkForError(launchConfig, values);
return {
step: showStep
? {
id: STEP_ID,
name: (
<StepName hasErrors={hasError} id="credential-passwords-step">
{i18n._(t`Credential passwords`)}
</StepName>
),
component: (
<CredentialPasswordsStep launchConfig={launchConfig} i18n={i18n} />
),
enableNext: true,
}
: null,
initialValues: getInitialValues(launchConfig, values.credentials),
isReady: true,
contentError: null,
hasError,
setTouched: setFieldTouched => {
Object.keys(values.credential_passwords).forEach(credentialValueKey =>
setFieldTouched(
`credential_passwords['${credentialValueKey}']`,
true,
false
)
);
},
validate: () => {
const setPasswordFieldError = fieldName => {
setFieldError(fieldName, i18n._(t`This field may not be blank`));
};
if (
!launchConfig.ask_credential_on_launch &&
launchConfig.passwords_needed_to_start
) {
launchConfig.passwords_needed_to_start.forEach(password => {
if (isValueMissing(values.credential_passwords[password])) {
setPasswordFieldError(`credential_passwords['${password}']`);
}
});
} else if (values.credentials) {
values.credentials.forEach(credential => {
if (!credential.inputs) {
const launchConfigCredential = launchConfig.defaults.credentials.find(
defaultCred => defaultCred.id === credential.id
);
if (launchConfigCredential?.passwords_needed.length > 0) {
launchConfigCredential.passwords_needed.forEach(password => {
if (isValueMissing(values.credential_passwords[password])) {
setPasswordFieldError(`credential_passwords['${password}']`);
}
});
}
} else {
if (
credential?.inputs?.password === 'ASK' &&
isValueMissing(values.credential_passwords.ssh_password)
) {
setPasswordFieldError('credential_passwords.ssh_password');
}
if (
credential?.inputs?.become_password === 'ASK' &&
isValueMissing(values.credential_passwords.become_password)
) {
setPasswordFieldError('credential_passwords.become_password');
}
if (
credential?.inputs?.ssh_key_unlock === 'ASK' &&
isValueMissing(values.credential_passwords.ssh_key_unlock)
) {
setPasswordFieldError('credential_passwords.ssh_key_unlock');
}
if (
credential?.inputs?.vault_password === 'ASK' &&
isValueMissing(
values.credential_passwords[
`vault_password${
credential.inputs.vault_id !== ''
? `.${credential.inputs.vault_id}`
: ''
}`
]
)
) {
setPasswordFieldError(
`credential_passwords['vault_password${
credential.inputs.vault_id !== ''
? `.${credential.inputs.vault_id}`
: ''
}']`
);
}
}
});
}
},
};
}
function getInitialValues(launchConfig, selectedCredentials = []) {
const initialValues = {
credential_passwords: {},
};
if (!launchConfig) {
return initialValues;
}
if (
!launchConfig.ask_credential_on_launch &&
launchConfig.passwords_needed_to_start
) {
launchConfig.passwords_needed_to_start.forEach(password => {
initialValues.credential_passwords[password] = '';
});
return initialValues;
}
selectedCredentials.forEach(credential => {
if (!credential.inputs) {
const launchConfigCredential = launchConfig.defaults.credentials.find(
defaultCred => defaultCred.id === credential.id
);
if (launchConfigCredential?.passwords_needed.length > 0) {
launchConfigCredential.passwords_needed.forEach(password => {
initialValues.credential_passwords[password] = '';
});
}
} else {
if (credential?.inputs?.password === 'ASK') {
initialValues.credential_passwords.ssh_password = '';
}
if (credential?.inputs?.become_password === 'ASK') {
initialValues.credential_passwords.become_password = '';
}
if (credential?.inputs?.ssh_key_unlock === 'ASK') {
initialValues.credential_passwords.ssh_key_unlock = '';
}
if (credential?.inputs?.vault_password === 'ASK') {
if (!credential.inputs.vault_id || credential.inputs.vault_id === '') {
initialValues.credential_passwords.vault_password = '';
} else {
initialValues.credential_passwords[
`vault_password.${credential.inputs.vault_id}`
] = '';
}
}
}
});
return initialValues;
}
function checkForError(launchConfig, values) {
let hasError = false;
if (
!launchConfig.ask_credential_on_launch &&
launchConfig.passwords_needed_to_start
) {
launchConfig.passwords_needed_to_start.forEach(password => {
if (isValueMissing(values.credential_passwords[password])) {
hasError = true;
}
});
} else if (values.credentials) {
values.credentials.forEach(credential => {
if (!credential.inputs) {
const launchConfigCredential = launchConfig.defaults.credentials.find(
defaultCred => defaultCred.id === credential.id
);
if (launchConfigCredential?.passwords_needed.length > 0) {
launchConfigCredential.passwords_needed.forEach(password => {
if (isValueMissing(values.credential_passwords[password])) {
hasError = true;
}
});
}
} else {
if (
credential?.inputs?.password === 'ASK' &&
isValueMissing(values.credential_passwords.ssh_password)
) {
hasError = true;
}
if (
credential?.inputs?.become_password === 'ASK' &&
isValueMissing(values.credential_passwords.become_password)
) {
hasError = true;
}
if (
credential?.inputs?.ssh_key_unlock === 'ASK' &&
isValueMissing(values.credential_passwords.ssh_key_unlock)
) {
hasError = true;
}
if (
credential?.inputs?.vault_password === 'ASK' &&
isValueMissing(
values.credential_passwords[
`vault_password${
credential.inputs.vault_id !== ''
? `.${credential.inputs.vault_id}`
: ''
}`
]
)
) {
hasError = true;
}
}
});
}
return hasError;
}

View File

@@ -9,15 +9,13 @@ export default function useCredentialsStep(launchConfig, resource, i18n) {
return { return {
step: getStep(launchConfig, i18n), step: getStep(launchConfig, i18n),
initialValues: getInitialValues(launchConfig, resource), initialValues: getInitialValues(launchConfig, resource),
validate: () => ({}),
isReady: true, isReady: true,
contentError: null, contentError: null,
formError: null, hasError: false,
setTouched: setFieldsTouched => { setTouched: setFieldTouched => {
setFieldsTouched({ setFieldTouched('credentials', true, false);
credentials: true,
});
}, },
validate: () => {},
}; };
} }

View File

@@ -12,20 +12,27 @@ export default function useInventoryStep(
i18n, i18n,
visitedSteps visitedSteps
) { ) {
const [, meta] = useField('inventory'); const [, meta, helpers] = useField('inventory');
const formError = const formError =
Object.keys(visitedSteps).includes(STEP_ID) && (!meta.value || meta.error); !resource || resource?.type === 'workflow_job_template'
? false
: Object.keys(visitedSteps).includes(STEP_ID) &&
meta.touched &&
!meta.value;
return { return {
step: getStep(launchConfig, i18n, formError), step: getStep(launchConfig, i18n, formError),
initialValues: getInitialValues(launchConfig, resource), initialValues: getInitialValues(launchConfig, resource),
isReady: true, isReady: true,
contentError: null, contentError: null,
formError: launchConfig.ask_inventory_on_launch && formError, hasError: launchConfig.ask_inventory_on_launch && formError,
setTouched: setFieldsTouched => { setTouched: setFieldTouched => {
setFieldsTouched({ setFieldTouched('inventory', true, false);
inventory: true, },
}); validate: () => {
if (meta.touched && !meta.value && resource.type === 'job_template') {
helpers.setError(i18n._(t`An inventory must be selected`));
}
}, },
}; };
} }

View File

@@ -22,18 +22,19 @@ export default function useOtherPromptsStep(launchConfig, resource, i18n) {
initialValues: getInitialValues(launchConfig, resource), initialValues: getInitialValues(launchConfig, resource),
isReady: true, isReady: true,
contentError: null, contentError: null,
formError: null, hasError: false,
setTouched: setFieldsTouched => { setTouched: setFieldTouched => {
setFieldsTouched({ [
job_type: true, 'job_type',
limit: true, 'limit',
verbosity: true, 'verbosity',
diff_mode: true, 'diff_mode',
job_tags: true, 'job_tags',
skip_tags: true, 'skip_tags',
extra_vars: true, 'extra_vars',
}); ].forEach(field => setFieldTouched(field, true, false));
}, },
validate: () => {},
}; };
} }

View File

@@ -35,9 +35,9 @@ export default function usePreviewStep(
} }
: null, : null,
initialValues: {}, initialValues: {},
validate: () => ({}),
isReady: true, isReady: true,
error: null, error: null,
setTouched: () => {}, setTouched: () => {},
validate: () => {},
}; };
} }

View File

@@ -13,89 +13,51 @@ export default function useSurveyStep(
i18n, i18n,
visitedSteps visitedSteps
) { ) {
const { values } = useFormikContext(); const { setFieldError, values } = useFormikContext();
const errors = {}; const hasError =
const validate = () => { Object.keys(visitedSteps).includes(STEP_ID) &&
if (!launchConfig.survey_enabled || !surveyConfig?.spec) { checkForError(launchConfig, surveyConfig, values);
return {};
}
surveyConfig.spec.forEach(question => {
const errMessage = validateField(
question,
values[`survey_${question.variable}`],
i18n
);
if (errMessage) {
errors[`survey_${question.variable}`] = errMessage;
}
});
return errors;
};
const formError = Object.keys(validate()).length > 0;
return { return {
step: getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps), step: launchConfig.survey_enabled
? {
id: STEP_ID,
name: (
<StepName hasErrors={hasError} id="survey-step">
{i18n._(t`Survey`)}
</StepName>
),
component: <SurveyStep surveyConfig={surveyConfig} i18n={i18n} />,
enableNext: true,
}
: null,
initialValues: getInitialValues(launchConfig, surveyConfig, resource), initialValues: getInitialValues(launchConfig, surveyConfig, resource),
validate,
surveyConfig, surveyConfig,
isReady: true, isReady: true,
contentError: null, contentError: null,
formError, hasError,
setTouched: setFieldsTouched => { setTouched: setFieldTouched => {
if (!surveyConfig?.spec) { if (!surveyConfig?.spec) {
return; return;
} }
const fields = {};
surveyConfig.spec.forEach(question => { surveyConfig.spec.forEach(question => {
fields[`survey_${question.variable}`] = true; setFieldTouched(`survey_${question.variable}`, true, false);
}); });
setFieldsTouched(fields);
}, },
}; validate: () => {
} if (launchConfig.survey_enabled && surveyConfig.spec) {
surveyConfig.spec.forEach(question => {
function validateField(question, value, i18n) { const errMessage = validateSurveyField(
const isTextField = ['text', 'textarea'].includes(question.type); question,
const isNumeric = ['integer', 'float'].includes(question.type); values[`survey_${question.variable}`],
if (isTextField && (value || value === 0)) { i18n
if (question.min && value.length < question.min) { );
return i18n._(t`This field must be at least ${question.min} characters`); if (errMessage) {
} setFieldError(`survey_${question.variable}`, errMessage);
if (question.max && value.length > question.max) { }
return i18n._(t`This field must not exceed ${question.max} characters`); });
} }
} },
if (isNumeric && (value || value === 0)) {
if (value < question.min || value > question.max) {
return i18n._(
t`This field must be a number and have a value between ${question.min} and ${question.max}`
);
}
}
if (question.required && !value && value !== 0) {
return i18n._(t`This field must not be blank`);
}
return null;
}
function getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps) {
if (!launchConfig.survey_enabled) {
return null;
}
return {
id: STEP_ID,
name: (
<StepName
hasErrors={
Object.keys(visitedSteps).includes(STEP_ID) &&
Object.keys(validate()).length
}
id="survey-step"
>
{i18n._(t`Survey`)}
</StepName>
),
component: <SurveyStep surveyConfig={surveyConfig} i18n={i18n} />,
enableNext: true,
}; };
} }
@@ -133,3 +95,56 @@ function getInitialValues(launchConfig, surveyConfig, resource) {
return values; return values;
} }
function validateSurveyField(question, value, i18n) {
const isTextField = ['text', 'textarea'].includes(question.type);
const isNumeric = ['integer', 'float'].includes(question.type);
if (isTextField && (value || value === 0)) {
if (question.min && value.length < question.min) {
return i18n._(t`This field must be at least ${question.min} characters`);
}
if (question.max && value.length > question.max) {
return i18n._(t`This field must not exceed ${question.max} characters`);
}
}
if (isNumeric && (value || value === 0)) {
if (value < question.min || value > question.max) {
return i18n._(
t`This field must be a number and have a value between ${question.min} and ${question.max}`
);
}
}
if (question.required && !value && value !== 0) {
return i18n._(t`This field must not be blank`);
}
return null;
}
function checkForError(launchConfig, surveyConfig, values) {
let hasError = false;
if (launchConfig.survey_enabled && surveyConfig.spec) {
surveyConfig.spec.forEach(question => {
const value = values[`survey_${question.variable}`];
const isTextField = ['text', 'textarea'].includes(question.type);
const isNumeric = ['integer', 'float'].includes(question.type);
if (isTextField && (value || value === 0)) {
if (
(question.min && value.length < question.min) ||
(question.max && value.length > question.max)
) {
hasError = true;
}
}
if (isNumeric && (value || value === 0)) {
if (value < question.min || value > question.max) {
hasError = true;
}
}
if (question.required && !value && value !== 0) {
hasError = true;
}
});
}
return hasError;
}

View File

@@ -2,10 +2,43 @@ import { useState, useEffect } from 'react';
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import useInventoryStep from './steps/useInventoryStep'; import useInventoryStep from './steps/useInventoryStep';
import useCredentialsStep from './steps/useCredentialsStep'; import useCredentialsStep from './steps/useCredentialsStep';
import useCredentialPasswordsStep from './steps/useCredentialPasswordsStep';
import useOtherPromptsStep from './steps/useOtherPromptsStep'; import useOtherPromptsStep from './steps/useOtherPromptsStep';
import useSurveyStep from './steps/useSurveyStep'; import useSurveyStep from './steps/useSurveyStep';
import usePreviewStep from './steps/usePreviewStep'; import usePreviewStep from './steps/usePreviewStep';
function showCredentialPasswordsStep(credentials = [], launchConfig) {
if (
!launchConfig?.ask_credential_on_launch &&
launchConfig?.passwords_needed_to_start
) {
return launchConfig.passwords_needed_to_start.length > 0;
}
let credentialPasswordStepRequired = false;
credentials.forEach(credential => {
if (!credential.inputs) {
const launchConfigCredential = launchConfig.defaults.credentials.find(
defaultCred => defaultCred.id === credential.id
);
if (launchConfigCredential?.passwords_needed.length > 0) {
credentialPasswordStepRequired = true;
}
} else if (
credential?.inputs?.password === 'ASK' ||
credential?.inputs?.become_password === 'ASK' ||
credential?.inputs?.ssh_key_unlock === 'ASK' ||
credential?.inputs?.vault_password === 'ASK'
) {
credentialPasswordStepRequired = true;
}
});
return credentialPasswordStepRequired;
}
export default function useLaunchSteps( export default function useLaunchSteps(
launchConfig, launchConfig,
surveyConfig, surveyConfig,
@@ -14,14 +47,21 @@ export default function useLaunchSteps(
) { ) {
const [visited, setVisited] = useState({}); const [visited, setVisited] = useState({});
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const { touched, values: formikValues } = useFormikContext();
const steps = [ const steps = [
useInventoryStep(launchConfig, resource, i18n, visited), useInventoryStep(launchConfig, resource, i18n, visited),
useCredentialsStep(launchConfig, resource, i18n), useCredentialsStep(launchConfig, resource, i18n),
useCredentialPasswordsStep(
launchConfig,
i18n,
showCredentialPasswordsStep(formikValues.credentials, launchConfig),
visited
),
useOtherPromptsStep(launchConfig, resource, i18n), useOtherPromptsStep(launchConfig, resource, i18n),
useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited), useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited),
]; ];
const { resetForm } = useFormikContext(); const { resetForm } = useFormikContext();
const hasErrors = steps.some(step => step.formError); const hasErrors = steps.some(step => step.hasError);
steps.push( steps.push(
usePreviewStep(launchConfig, i18n, resource, surveyConfig, hasErrors, true) usePreviewStep(launchConfig, i18n, resource, surveyConfig, hasErrors, true)
@@ -38,16 +78,47 @@ export default function useLaunchSteps(
...cur.initialValues, ...cur.initialValues,
}; };
}, {}); }, {});
const newFormValues = { ...initialValues };
Object.keys(formikValues).forEach(formikValueKey => {
if (
formikValueKey === 'credential_passwords' &&
Object.prototype.hasOwnProperty.call(
newFormValues,
'credential_passwords'
)
) {
const formikCredentialPasswords = formikValues.credential_passwords;
Object.keys(formikCredentialPasswords).forEach(
credentialPasswordValueKey => {
if (
Object.prototype.hasOwnProperty.call(
newFormValues.credential_passwords,
credentialPasswordValueKey
)
) {
newFormValues.credential_passwords[credentialPasswordValueKey] =
formikCredentialPasswords[credentialPasswordValueKey];
}
}
);
} else if (
Object.prototype.hasOwnProperty.call(newFormValues, formikValueKey)
) {
newFormValues[formikValueKey] = formikValues[formikValueKey];
}
});
resetForm({ resetForm({
values: { values: newFormValues,
...initialValues, touched,
},
}); });
setIsReady(true); setIsReady(true);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [stepsAreReady]); }, [formikValues.credentials, stepsAreReady]);
const stepWithError = steps.find(s => s.contentError); const stepWithError = steps.find(s => s.contentError);
const contentError = stepWithError ? stepWithError.contentError : null; const contentError = stepWithError ? stepWithError.contentError : null;
@@ -55,20 +126,26 @@ export default function useLaunchSteps(
return { return {
steps: pfSteps, steps: pfSteps,
isReady, isReady,
visitStep: stepId => validateStep: stepId => {
steps.find(s => s?.step?.id === stepId).validate();
},
visitStep: (prevStepId, setFieldTouched) => {
setVisited({ setVisited({
...visited, ...visited,
[stepId]: true, [prevStepId]: true,
}), });
visitAllSteps: setFieldsTouched => { steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched);
},
visitAllSteps: setFieldTouched => {
setVisited({ setVisited({
inventory: true, inventory: true,
credentials: true, credentials: true,
credentialPasswords: true,
other: true, other: true,
survey: true, survey: true,
preview: true, preview: true,
}); });
steps.forEach(s => s.setTouched(setFieldsTouched)); steps.forEach(s => s.setTouched(setFieldTouched));
}, },
contentError, contentError,
}; };

View File

@@ -85,7 +85,12 @@ class ListHeader extends React.Component {
pushHistoryState(params) { pushHistoryState(params) {
const { history, qsConfig } = this.props; const { history, qsConfig } = this.props;
const { pathname } = history.location; const { pathname } = history.location;
const encodedParams = encodeNonDefaultQueryString(qsConfig, params); const nonNamespacedParams = parseQueryString({}, history.location.search);
const encodedParams = encodeNonDefaultQueryString(
qsConfig,
params,
nonNamespacedParams
);
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname); history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
} }

View File

@@ -12,6 +12,7 @@ import {
FormGroup, FormGroup,
InputGroup, InputGroup,
Modal, Modal,
Tooltip,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import ChipGroup from '../ChipGroup'; import ChipGroup from '../ChipGroup';
import Popover from '../Popover'; import Popover from '../Popover';
@@ -243,6 +244,36 @@ function HostFilterLookup({
}); });
}; };
const renderLookup = () => (
<InputGroup onBlur={onBlur}>
<Button
aria-label={i18n._(t`Search`)}
id="host-filter"
isDisabled={isDisabled}
onClick={handleOpenModal}
variant={ButtonVariant.control}
>
<SearchIcon />
</Button>
<ChipHolder className="pf-c-form-control">
{searchColumns.map(({ name, key }) => (
<ChipGroup
categoryName={name}
key={name}
numChips={5}
totalChips={chips[key]?.chips?.length || 0}
>
{chips[key]?.chips?.map(chip => (
<Chip key={chip.key} isReadOnly>
{chip.node}
</Chip>
))}
</ChipGroup>
))}
</ChipHolder>
</InputGroup>
);
return ( return (
<FormGroup <FormGroup
fieldId="host-filter" fieldId="host-filter"
@@ -261,33 +292,17 @@ function HostFilterLookup({
/> />
} }
> >
<InputGroup onBlur={onBlur}> {isDisabled ? (
<Button <Tooltip
aria-label={i18n._(t`Search`)} content={i18n._(
id="host-filter" t`Please select an organization before editing the host filter`
isDisabled={isDisabled} )}
onClick={handleOpenModal}
variant={ButtonVariant.control}
> >
<SearchIcon /> {renderLookup()}
</Button> </Tooltip>
<ChipHolder className="pf-c-form-control"> ) : (
{searchColumns.map(({ name, key }) => ( renderLookup()
<ChipGroup )}
categoryName={name}
key={name}
numChips={5}
totalChips={chips[key]?.chips?.length || 0}
>
{chips[key]?.chips?.map(chip => (
<Chip key={chip.key} isReadOnly>
{chip.node}
</Chip>
))}
</ChipGroup>
))}
</ChipHolder>
</InputGroup>
<Modal <Modal
aria-label={i18n._(t`Lookup modal`)} aria-label={i18n._(t`Lookup modal`)}
isOpen={isModalOpen} isOpen={isModalOpen}

View File

@@ -61,7 +61,12 @@ function PaginatedDataList({
}; };
const pushHistoryState = params => { const pushHistoryState = params => {
const encodedParams = encodeNonDefaultQueryString(qsConfig, params); const nonNamespacedParams = parseQueryString({}, history.location.search);
const encodedParams = encodeNonDefaultQueryString(
qsConfig,
params,
nonNamespacedParams
);
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname); history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
}; };

View File

@@ -12,7 +12,7 @@ const Th = styled(PFTh)`
--pf-c-table--cell--Overflow: initial; --pf-c-table--cell--Overflow: initial;
`; `;
export default function HeaderRow({ qsConfig, children }) { export default function HeaderRow({ qsConfig, isExpandable, children }) {
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
@@ -23,7 +23,12 @@ export default function HeaderRow({ qsConfig, children }) {
order_by: order === 'asc' ? key : `-${key}`, order_by: order === 'asc' ? key : `-${key}`,
page: null, page: null,
}); });
const encodedParams = encodeNonDefaultQueryString(qsConfig, newParams); const nonNamespacedParams = parseQueryString({}, history.location.search);
const encodedParams = encodeNonDefaultQueryString(
qsConfig,
newParams,
nonNamespacedParams
);
history.push( history.push(
encodedParams encodedParams
? `${location.pathname}?${encodedParams}` ? `${location.pathname}?${encodedParams}`
@@ -36,25 +41,38 @@ export default function HeaderRow({ qsConfig, children }) {
index: sortKey || qsConfig.defaultParams?.order_by, index: sortKey || qsConfig.defaultParams?.order_by,
direction: params.order_by?.startsWith('-') ? 'desc' : 'asc', direction: params.order_by?.startsWith('-') ? 'desc' : 'asc',
}; };
const idPrefix = `${qsConfig.namespace}-table-sort`;
// empty first Th aligns with checkboxes in table rows // empty first Th aligns with checkboxes in table rows
return ( return (
<Thead> <Thead>
<Tr> <Tr>
{isExpandable && <Th />}
<Th /> <Th />
{React.Children.map(children, child => {React.Children.map(
React.cloneElement(child, { children,
onSort, child =>
sortBy, child &&
columnIndex: child.props.sortKey, React.cloneElement(child, {
}) onSort,
sortBy,
columnIndex: child.props.sortKey,
idPrefix,
})
)} )}
</Tr> </Tr>
</Thead> </Thead>
); );
} }
export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) { export function HeaderCell({
sortKey,
onSort,
sortBy,
columnIndex,
idPrefix,
children,
}) {
const sort = sortKey const sort = sortKey
? { ? {
onSort: (event, key, order) => onSort(sortKey, order), onSort: (event, key, order) => onSort(sortKey, order),
@@ -62,5 +80,9 @@ export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) {
columnIndex, columnIndex,
} }
: null; : null;
return <Th sort={sort}>{children}</Th>; return (
<Th sort={sort} id={sortKey ? `${idPrefix}-${sortKey}` : null}>
{children}
</Th>
);
} }

View File

@@ -62,4 +62,20 @@ describe('<HeaderRow />', () => {
const cell = wrapper.find('Th').at(2); const cell = wrapper.find('Th').at(2);
expect(cell.prop('sort')).toEqual(null); expect(cell.prop('sort')).toEqual(null);
}); });
test('should handle null children gracefully', async () => {
const nope = false;
const wrapper = mountWithContexts(
<table>
<HeaderRow qsConfig={qsConfig}>
<HeaderCell sortKey="one">One</HeaderCell>
{nope && <HeaderCell>Hidden</HeaderCell>}
<HeaderCell>Two</HeaderCell>
</HeaderRow>
</table>
);
const cells = wrapper.find('Th');
expect(cells).toHaveLength(3);
});
}); });

View File

@@ -40,8 +40,13 @@ function PaginatedTable({
const history = useHistory(); const history = useHistory();
const pushHistoryState = params => { const pushHistoryState = params => {
const { pathname } = history.location; const { pathname, search } = history.location;
const encodedParams = encodeNonDefaultQueryString(qsConfig, params); const nonNamespacedParams = parseQueryString({}, search);
const encodedParams = encodeNonDefaultQueryString(
qsConfig,
params,
nonNamespacedParams
);
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname); history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
}; };

View File

@@ -7,69 +7,71 @@ import { t } from '@lingui/macro';
import AlertModal from '../AlertModal'; import AlertModal from '../AlertModal';
import { Role } from '../../types'; import { Role } from '../../types';
class DeleteRoleConfirmationModal extends React.Component { function DeleteRoleConfirmationModal({
static propTypes = { role,
role: Role.isRequired, username,
username: string, onCancel,
onCancel: func.isRequired, onConfirm,
onConfirm: func.isRequired, i18n,
}; }) {
const isTeamRole = () => {
static defaultProps = {
username: '',
};
isTeamRole() {
const { role } = this.props;
return typeof role.team_id !== 'undefined'; return typeof role.team_id !== 'undefined';
} };
render() { const title = i18n._(
const { role, username, onCancel, onConfirm, i18n } = this.props; t`Remove ${isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access`
const title = i18n._( );
t`Remove ${this.isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access` return (
); <AlertModal
return ( variant="danger"
<AlertModal title={title}
variant="danger" isOpen
title={title} onClose={onCancel}
isOpen actions={[
onClose={onCancel} <Button
actions={[ key="delete"
<Button variant="danger"
key="delete" aria-label={i18n._(t`Confirm delete`)}
variant="danger" onClick={onConfirm}
aria-label={i18n._(t`Confirm delete`)} >
onClick={onConfirm} {i18n._(t`Delete`)}
> </Button>,
{i18n._(t`Delete`)} <Button key="cancel" variant="secondary" onClick={onCancel}>
</Button>, {i18n._(t`Cancel`)}
<Button key="cancel" variant="secondary" onClick={onCancel}> </Button>,
{i18n._(t`Cancel`)} ]}
</Button>, >
]} {isTeamRole() ? (
> <Fragment>
{this.isTeamRole() ? ( {i18n._(
<Fragment> t`Are you sure you want to remove ${role.name} access from ${role.team_name}? Doing so affects all members of the team.`
{i18n._( )}
t`Are you sure you want to remove ${role.name} access from ${role.team_name}? Doing so affects all members of the team.` <br />
)} <br />
<br /> {i18n._(
<br /> t`If you only want to remove access for this particular user, please remove them from the team.`
{i18n._( )}
t`If you only want to remove access for this particular user, please remove them from the team.` </Fragment>
)} ) : (
</Fragment> <Fragment>
) : ( {i18n._(
<Fragment> t`Are you sure you want to remove ${role.name} access from ${username}?`
{i18n._( )}
t`Are you sure you want to remove ${role.name} access from ${username}?` </Fragment>
)} )}
</Fragment> </AlertModal>
)} );
</AlertModal>
);
}
} }
DeleteRoleConfirmationModal.propTypes = {
role: Role.isRequired,
username: string,
onCancel: func.isRequired,
onConfirm: func.isRequired,
};
DeleteRoleConfirmationModal.defaultProps = {
username: '',
};
export default withI18n()(DeleteRoleConfirmationModal); export default withI18n()(DeleteRoleConfirmationModal);

View File

@@ -144,6 +144,7 @@ function ResourceAccessList({ i18n, apiModel, resource }) {
setDeletionRole(role); setDeletionRole(role);
setShowDeleteModal(true); setShowDeleteModal(true);
}} }}
i18n={i18n}
/> />
)} )}
/> />

View File

@@ -24,19 +24,13 @@ const DataListItemCells = styled(PFDataListItemCells)`
align-items: start; align-items: start;
`; `;
class ResourceAccessListItem extends React.Component { function ResourceAccessListItem({ accessRecord, onRoleDelete, i18n }) {
static propTypes = { ResourceAccessListItem.propTypes = {
accessRecord: AccessRecord.isRequired, accessRecord: AccessRecord.isRequired,
onRoleDelete: func.isRequired, onRoleDelete: func.isRequired,
}; };
constructor(props) { const getRoleLists = () => {
super(props);
this.renderChip = this.renderChip.bind(this);
}
getRoleLists() {
const { accessRecord } = this.props;
const teamRoles = []; const teamRoles = [];
const userRoles = []; const userRoles = [];
@@ -52,10 +46,9 @@ class ResourceAccessListItem extends React.Component {
accessRecord.summary_fields.direct_access.map(sort); accessRecord.summary_fields.direct_access.map(sort);
accessRecord.summary_fields.indirect_access.map(sort); accessRecord.summary_fields.indirect_access.map(sort);
return [teamRoles, userRoles]; return [teamRoles, userRoles];
} };
renderChip(role) { const renderChip = role => {
const { accessRecord, onRoleDelete } = this.props;
return ( return (
<Chip <Chip
key={role.id} key={role.id}
@@ -67,79 +60,76 @@ class ResourceAccessListItem extends React.Component {
{role.name} {role.name}
</Chip> </Chip>
); );
} };
render() { const [teamRoles, userRoles] = getRoleLists();
const { accessRecord, i18n } = this.props;
const [teamRoles, userRoles] = this.getRoleLists();
return ( return (
<DataListItem <DataListItem
aria-labelledby="access-list-item" aria-labelledby="access-list-item"
key={accessRecord.id} key={accessRecord.id}
id={`${accessRecord.id}`} id={`${accessRecord.id}`}
> >
<DataListItemRow> <DataListItemRow>
<DataListItemCells <DataListItemCells
dataListCells={[ dataListCells={[
<DataListCell key="name"> <DataListCell key="name">
{accessRecord.username && ( {accessRecord.username && (
<TextContent> <TextContent>
{accessRecord.id ? ( {accessRecord.id ? (
<Text component={TextVariants.h6}> <Text component={TextVariants.h6}>
<Link <Link
to={{ pathname: `/users/${accessRecord.id}/details` }} to={{ pathname: `/users/${accessRecord.id}/details` }}
css="font-weight: bold" css="font-weight: bold"
> >
{accessRecord.username}
</Link>
</Text>
) : (
<Text component={TextVariants.h6} css="font-weight: bold">
{accessRecord.username} {accessRecord.username}
</Text> </Link>
)} </Text>
</TextContent> ) : (
)} <Text component={TextVariants.h6} css="font-weight: bold">
{accessRecord.first_name || accessRecord.last_name ? ( {accessRecord.username}
<DetailList stacked> </Text>
<Detail )}
label={i18n._(t`Name`)} </TextContent>
value={`${accessRecord.first_name} ${accessRecord.last_name}`} )}
/> {accessRecord.first_name || accessRecord.last_name ? (
</DetailList>
) : null}
</DataListCell>,
<DataListCell key="roles">
<DetailList stacked> <DetailList stacked>
{userRoles.length > 0 && ( <Detail
<Detail label={i18n._(t`Name`)}
label={i18n._(t`User Roles`)} value={`${accessRecord.first_name} ${accessRecord.last_name}`}
value={ />
<ChipGroup numChips={5} totalChips={userRoles.length}>
{userRoles.map(this.renderChip)}
</ChipGroup>
}
/>
)}
{teamRoles.length > 0 && (
<Detail
label={i18n._(t`Team Roles`)}
value={
<ChipGroup numChips={5} totalChips={teamRoles.length}>
{teamRoles.map(this.renderChip)}
</ChipGroup>
}
/>
)}
</DetailList> </DetailList>
</DataListCell>, ) : null}
]} </DataListCell>,
/> <DataListCell key="roles">
</DataListItemRow> <DetailList stacked>
</DataListItem> {userRoles.length > 0 && (
); <Detail
} label={i18n._(t`User Roles`)}
value={
<ChipGroup numChips={5} totalChips={userRoles.length}>
{userRoles.map(renderChip)}
</ChipGroup>
}
/>
)}
{teamRoles.length > 0 && (
<Detail
label={i18n._(t`Team Roles`)}
value={
<ChipGroup numChips={5} totalChips={teamRoles.length}>
{teamRoles.map(renderChip)}
</ChipGroup>
}
/>
)}
</DetailList>
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
} }
export default withI18n()(ResourceAccessListItem); export default withI18n()(ResourceAccessListItem);

View File

@@ -0,0 +1,135 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Button,
PageSection,
PageSectionVariants,
Breadcrumb,
BreadcrumbItem,
Title,
Tooltip,
} from '@patternfly/react-core';
import { HistoryIcon } from '@patternfly/react-icons';
import { Link, Route, useRouteMatch } from 'react-router-dom';
const ScreenHeader = ({ breadcrumbConfig, i18n, streamType }) => {
const { light } = PageSectionVariants;
const oneCrumbMatch = useRouteMatch({
path: Object.keys(breadcrumbConfig)[0],
strict: true,
});
const isOnlyOneCrumb = oneCrumbMatch && oneCrumbMatch.isExact;
return (
<PageSection variant={light}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
{!isOnlyOneCrumb && (
<Breadcrumb>
<Route path="/:path">
<Crumb breadcrumbConfig={breadcrumbConfig} />
</Route>
</Breadcrumb>
)}
<div
style={{
minHeight: '31px',
}}
>
<Route path="/:path">
<ActualTitle breadcrumbConfig={breadcrumbConfig} />
</Route>
</div>
</div>
{streamType !== 'none' && (
<div>
<Tooltip content={i18n._(t`View activity stream`)} position="top">
<Button
aria-label={i18n._(t`View activity stream`)}
variant="plain"
component={Link}
to={`/activity_stream${
streamType ? `?type=${streamType}` : ''
}`}
>
<HistoryIcon />
</Button>
</Tooltip>
</div>
)}
</div>
</PageSection>
);
};
const ActualTitle = ({ breadcrumbConfig }) => {
const match = useRouteMatch();
const title = breadcrumbConfig[match.url];
let titleElement;
if (match.isExact) {
titleElement = (
<Title size="2xl" headingLevel="h2">
{title}
</Title>
);
}
if (!title) {
titleElement = null;
}
return (
<Fragment>
{titleElement}
<Route path={`${match.url}/:path`}>
<ActualTitle breadcrumbConfig={breadcrumbConfig} />
</Route>
</Fragment>
);
};
const Crumb = ({ breadcrumbConfig, showDivider }) => {
const match = useRouteMatch();
const crumb = breadcrumbConfig[match.url];
let crumbElement = (
<BreadcrumbItem key={match.url} showDivider={showDivider}>
<Link to={match.url}>{crumb}</Link>
</BreadcrumbItem>
);
if (match.isExact) {
crumbElement = null;
}
if (!crumb) {
crumbElement = null;
}
return (
<Fragment>
{crumbElement}
<Route path={`${match.url}/:path`}>
<Crumb breadcrumbConfig={breadcrumbConfig} showDivider />
</Route>
</Fragment>
);
};
ScreenHeader.propTypes = {
breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired,
};
Crumb.propTypes = {
breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired,
};
export default withI18n()(ScreenHeader);

View File

@@ -1,9 +1,15 @@
import React from 'react'; import React from 'react';
import { mount } from 'enzyme';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import Breadcrumbs from './Breadcrumbs';
describe('<Breadcrumb />', () => { import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ScreenHeader from './ScreenHeader';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<ScreenHeader />', () => {
let breadcrumbWrapper; let breadcrumbWrapper;
let breadcrumb; let breadcrumb;
let breadcrumbItem; let breadcrumbItem;
@@ -17,15 +23,15 @@ describe('<Breadcrumb />', () => {
}; };
const findChildren = () => { const findChildren = () => {
breadcrumb = breadcrumbWrapper.find('Breadcrumb'); breadcrumb = breadcrumbWrapper.find('ScreenHeader');
breadcrumbItem = breadcrumbWrapper.find('BreadcrumbItem'); breadcrumbItem = breadcrumbWrapper.find('BreadcrumbItem');
breadcrumbHeading = breadcrumbWrapper.find('BreadcrumbHeading'); breadcrumbHeading = breadcrumbWrapper.find('Title');
}; };
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
breadcrumbWrapper = mount( breadcrumbWrapper = mountWithContexts(
<MemoryRouter initialEntries={['/foo/1/bar']} initialIndex={0}> <MemoryRouter initialEntries={['/foo/1/bar']} initialIndex={0}>
<Breadcrumbs breadcrumbConfig={config} /> <ScreenHeader streamType="all_activity" breadcrumbConfig={config} />
</MemoryRouter> </MemoryRouter>
); );
@@ -51,9 +57,9 @@ describe('<Breadcrumb />', () => {
]; ];
routes.forEach(([location, crumbLength]) => { routes.forEach(([location, crumbLength]) => {
breadcrumbWrapper = mount( breadcrumbWrapper = mountWithContexts(
<MemoryRouter initialEntries={[location]}> <MemoryRouter initialEntries={[location]}>
<Breadcrumbs breadcrumbConfig={config} /> <ScreenHeader streamType="all_activity" breadcrumbConfig={config} />
</MemoryRouter> </MemoryRouter>
); );

View File

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

View File

@@ -1,7 +1,7 @@
import React, { Fragment } from 'react'; import React, { Fragment, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { withRouter } from 'react-router-dom'; import { useLocation, withRouter } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { import {
Button, Button,
@@ -31,140 +31,110 @@ const NoOptionDropdown = styled.div`
border-bottom-color: var(--pf-global--BorderColor--200); border-bottom-color: var(--pf-global--BorderColor--200);
`; `;
class Sort extends React.Component { function Sort({ columns, qsConfig, onSort, i18n }) {
constructor(props) { const location = useLocation();
super(props); const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
let sortKey; let sortKey;
let sortOrder; let sortOrder;
let isNumeric; let isNumeric;
const { qsConfig, location } = this.props; const queryParams = parseQueryString(qsConfig, location.search);
const queryParams = parseQueryString(qsConfig, location.search); if (queryParams.order_by && queryParams.order_by.startsWith('-')) {
if (queryParams.order_by && queryParams.order_by.startsWith('-')) { sortKey = queryParams.order_by.substr(1);
sortKey = queryParams.order_by.substr(1); sortOrder = 'descending';
sortOrder = 'descending'; } else if (queryParams.order_by) {
} else if (queryParams.order_by) { sortKey = queryParams.order_by;
sortKey = queryParams.order_by; sortOrder = 'ascending';
sortOrder = 'ascending';
}
if (qsConfig.integerFields.find(field => field === sortKey)) {
isNumeric = true;
} else {
isNumeric = false;
}
this.state = {
isSortDropdownOpen: false,
sortKey,
sortOrder,
isNumeric,
};
this.handleDropdownToggle = this.handleDropdownToggle.bind(this);
this.handleDropdownSelect = this.handleDropdownSelect.bind(this);
this.handleSort = this.handleSort.bind(this);
} }
handleDropdownToggle(isSortDropdownOpen) { if (qsConfig.integerFields.find(field => field === sortKey)) {
this.setState({ isSortDropdownOpen }); isNumeric = true;
} else {
isNumeric = false;
} }
handleDropdownSelect({ target }) { const handleDropdownToggle = isOpen => {
const { columns, onSort, qsConfig } = this.props; setIsSortDropdownOpen(isOpen);
const { sortOrder } = this.state; };
const handleDropdownSelect = ({ target }) => {
const { innerText } = target; const { innerText } = target;
const [{ key: sortKey }] = columns.filter(({ name }) => name === innerText); const [{ key }] = columns.filter(({ name }) => name === innerText);
sortKey = key;
let isNumeric; if (qsConfig.integerFields.find(field => field === key)) {
if (qsConfig.integerFields.find(field => field === sortKey)) {
isNumeric = true; isNumeric = true;
} else { } else {
isNumeric = false; isNumeric = false;
} }
this.setState({ isSortDropdownOpen: false, sortKey, isNumeric }); setIsSortDropdownOpen(false);
onSort(sortKey, sortOrder); onSort(sortKey, sortOrder);
} };
handleSort() { const handleSort = () => {
const { onSort } = this.props; onSort(sortKey, sortOrder === 'ascending' ? 'descending' : 'ascending');
const { sortKey, sortOrder } = this.state; };
const newSortOrder = sortOrder === 'ascending' ? 'descending' : 'ascending';
this.setState({ sortOrder: newSortOrder });
onSort(sortKey, newSortOrder);
}
render() { const { up } = DropdownPosition;
const { up } = DropdownPosition;
const { columns, i18n } = this.props;
const { isSortDropdownOpen, sortKey, sortOrder, isNumeric } = this.state;
const defaultSortedColumn = columns.find(({ key }) => key === sortKey); const defaultSortedColumn = columns.find(({ key }) => key === sortKey);
if (!defaultSortedColumn) { if (!defaultSortedColumn) {
throw new Error( throw new Error(
'sortKey must match one of the column keys, check the sortColumns prop passed to <Sort />' 'sortKey must match one of the column keys, check the sortColumns prop passed to <Sort />'
);
}
const sortedColumnName = defaultSortedColumn?.name;
const sortDropdownItems = columns
.filter(({ key }) => key !== sortKey)
.map(({ key, name }) => (
<DropdownItem key={key} component="button">
{name}
</DropdownItem>
));
let SortIcon;
if (isNumeric) {
SortIcon =
sortOrder === 'ascending'
? SortNumericDownIcon
: SortNumericDownAltIcon;
} else {
SortIcon =
sortOrder === 'ascending' ? SortAlphaDownIcon : SortAlphaDownAltIcon;
}
return (
<Fragment>
{sortedColumnName && (
<InputGroup>
{(sortDropdownItems.length > 0 && (
<Dropdown
onToggle={this.handleDropdownToggle}
onSelect={this.handleDropdownSelect}
direction={up}
isOpen={isSortDropdownOpen}
toggle={
<DropdownToggle
id="awx-sort"
onToggle={this.handleDropdownToggle}
>
{sortedColumnName}
</DropdownToggle>
}
dropdownItems={sortDropdownItems}
/>
)) || <NoOptionDropdown>{sortedColumnName}</NoOptionDropdown>}
<Button
variant={ButtonVariant.control}
aria-label={i18n._(t`Sort`)}
onClick={this.handleSort}
>
<SortIcon />
</Button>
</InputGroup>
)}
</Fragment>
); );
} }
const sortedColumnName = defaultSortedColumn?.name;
const sortDropdownItems = columns
.filter(({ key }) => key !== sortKey)
.map(({ key, name }) => (
<DropdownItem key={key} component="button">
{name}
</DropdownItem>
));
let SortIcon;
if (isNumeric) {
SortIcon =
sortOrder === 'ascending' ? SortNumericDownIcon : SortNumericDownAltIcon;
} else {
SortIcon =
sortOrder === 'ascending' ? SortAlphaDownIcon : SortAlphaDownAltIcon;
}
return (
<Fragment>
{sortedColumnName && (
<InputGroup>
{(sortDropdownItems.length > 0 && (
<Dropdown
onToggle={handleDropdownToggle}
onSelect={handleDropdownSelect}
direction={up}
isOpen={isSortDropdownOpen}
toggle={
<DropdownToggle id="awx-sort" onToggle={handleDropdownToggle}>
{sortedColumnName}
</DropdownToggle>
}
dropdownItems={sortDropdownItems}
/>
)) || <NoOptionDropdown>{sortedColumnName}</NoOptionDropdown>}
<Button
variant={ButtonVariant.control}
aria-label={i18n._(t`Sort`)}
onClick={handleSort}
>
<SortIcon />
</Button>
</InputGroup>
)}
</Fragment>
);
} }
Sort.propTypes = { Sort.propTypes = {

View File

@@ -1,5 +1,10 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import Sort from './Sort'; import Sort from './Sort';
describe('<Sort />', () => { describe('<Sort />', () => {
@@ -105,7 +110,7 @@ describe('<Sort />', () => {
expect(onSort).toHaveBeenCalledWith('foo', 'ascending'); expect(onSort).toHaveBeenCalledWith('foo', 'ascending');
}); });
test('Changing dropdown correctly passes back new sort key', () => { test('Changing dropdown correctly passes back new sort key', async () => {
const qsConfig = { const qsConfig = {
namespace: 'item', namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'foo' }, defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
@@ -131,44 +136,18 @@ describe('<Sort />', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<Sort qsConfig={qsConfig} columns={columns} onSort={onSort} /> <Sort qsConfig={qsConfig} columns={columns} onSort={onSort} />
).find('Sort'); );
act(() => wrapper.find('Dropdown').invoke('onToggle')(true));
wrapper.instance().handleDropdownSelect({ target: { innerText: 'Bar' } }); wrapper.update();
await waitForElement(wrapper, 'Dropdown', el => el.prop('isOpen') === true);
wrapper
.find('li')
.at(0)
.prop('onClick')({ target: { innerText: 'Bar' } });
wrapper.update();
expect(onSort).toBeCalledWith('bar', 'ascending'); expect(onSort).toBeCalledWith('bar', 'ascending');
}); });
test('Opening dropdown correctly updates state', () => {
const qsConfig = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
integerFields: ['page', 'page_size'],
};
const columns = [
{
name: 'Foo',
key: 'foo',
},
{
name: 'Bar',
key: 'bar',
},
{
name: 'Bakery',
key: 'bakery',
},
];
const onSort = jest.fn();
const wrapper = mountWithContexts(
<Sort qsConfig={qsConfig} columns={columns} onSort={onSort} />
).find('Sort');
expect(wrapper.state('isSortDropdownOpen')).toEqual(false);
wrapper.instance().handleDropdownToggle(true);
expect(wrapper.state('isSortDropdownOpen')).toEqual(true);
});
test('It displays correct sort icon', () => { test('It displays correct sort icon', () => {
const forwardNumericIconSelector = 'SortNumericDownIcon'; const forwardNumericIconSelector = 'SortNumericDownIcon';
const reverseNumericIconSelector = 'SortNumericDownAltIcon'; const reverseNumericIconSelector = 'SortNumericDownAltIcon';

View File

@@ -1,5 +1,6 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import ActivityStream from './screens/ActivityStream';
import Applications from './screens/Application'; import Applications from './screens/Application';
import Credentials from './screens/Credential'; import Credentials from './screens/Credential';
import CredentialTypes from './screens/CredentialType'; import CredentialTypes from './screens/CredentialType';
@@ -44,6 +45,11 @@ function getRouteConfig(i18n) {
path: '/schedules', path: '/schedules',
screen: Schedules, screen: Schedules,
}, },
{
title: i18n._(t`Activity Stream`),
path: '/activity_stream',
screen: ActivityStream,
},
{ {
title: i18n._(t`Workflow Approvals`), title: i18n._(t`Workflow Approvals`),
path: '/workflow_approvals', path: '/workflow_approvals',

View File

@@ -0,0 +1,269 @@
import React, { Fragment, useState, useEffect, useCallback } from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Card,
PageSection,
PageSectionVariants,
SelectGroup,
Select,
SelectVariant,
SelectOption,
Title,
} from '@patternfly/react-core';
import DatalistToolbar from '../../components/DataListToolbar';
import PaginatedTable, {
HeaderRow,
HeaderCell,
} from '../../components/PaginatedTable';
import useRequest from '../../util/useRequest';
import {
getQSConfig,
parseQueryString,
replaceParams,
encodeNonDefaultQueryString,
} from '../../util/qs';
import { ActivityStreamAPI } from '../../api';
import ActivityStreamListItem from './ActivityStreamListItem';
function ActivityStream({ i18n }) {
const { light } = PageSectionVariants;
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
const location = useLocation();
const history = useHistory();
const urlParams = new URLSearchParams(location.search);
const activityStreamType = urlParams.get('type') || 'all';
let typeParams = {};
if (activityStreamType !== 'all') {
typeParams = {
or__object1__in: activityStreamType,
or__object2__in: activityStreamType,
};
}
const QS_CONFIG = getQSConfig(
'activity_stream',
{
page: 1,
page_size: 20,
order_by: '-timestamp',
},
['id', 'page', 'page_size']
);
const {
result: { results, count, relatedSearchableKeys, searchableKeys },
error: contentError,
isLoading,
request: fetchActivityStream,
} = useRequest(
useCallback(
async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const [response, actionsResponse] = await Promise.all([
ActivityStreamAPI.read({ ...params, ...typeParams }),
ActivityStreamAPI.readOptions(),
]);
return {
results: response.data.results,
count: response.data.count,
relatedSearchableKeys: (
actionsResponse?.data?.related_search_fields || []
).map(val => val.slice(0, -8)),
searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
};
},
[location] // eslint-disable-line react-hooks/exhaustive-deps
),
{
results: [],
count: 0,
relatedSearchableKeys: [],
searchableKeys: [],
}
);
useEffect(() => {
fetchActivityStream();
}, [fetchActivityStream]);
const pushHistoryState = urlParamsToAdd => {
let searchParams = parseQueryString(QS_CONFIG, location.search);
searchParams = replaceParams(searchParams, { page: 1 });
const encodedParams = encodeNonDefaultQueryString(QS_CONFIG, searchParams, {
type: urlParamsToAdd.get('type'),
});
history.push(
encodedParams
? `${location.pathname}?${encodedParams}`
: location.pathname
);
};
return (
<Fragment>
<PageSection
variant={light}
className="pf-m-condensed"
style={{ display: 'flex', justifyContent: 'space-between' }}
>
<Title size="2xl" headingLevel="h2">
{i18n._(t`Activity Stream`)}
</Title>
<span id="grouped-type-select-id" hidden>
{i18n._(t`Activity Stream type selector`)}
</span>
<Select
width="250px"
maxHeight="480px"
variant={SelectVariant.single}
aria-labelledby="grouped-type-select-id"
className="activityTypeSelect"
onToggle={setIsTypeDropdownOpen}
onSelect={(event, selection) => {
if (selection) {
urlParams.set('type', selection);
}
setIsTypeDropdownOpen(false);
pushHistoryState(urlParams);
}}
selections={activityStreamType}
isOpen={isTypeDropdownOpen}
isGrouped
>
<SelectGroup label={i18n._(t`Views`)} key="views">
<SelectOption key="all_activity" value="all">
{i18n._(t`Dashboard (all activity)`)}
</SelectOption>
<SelectOption key="jobs" value="job">
{i18n._(t`Jobs`)}
</SelectOption>
<SelectOption key="schedules" value="schedule">
{i18n._(t`Schedules`)}
</SelectOption>
<SelectOption key="workflow_approvals" value="workflow_approval">
{i18n._(t`Workflow Approvals`)}
</SelectOption>
</SelectGroup>
<SelectGroup label={i18n._(t`Resources`)} key="resources">
<SelectOption
key="templates"
value="job_template,workflow_job_template,workflow_job_template_node"
>
{i18n._(t`Templates`)}
</SelectOption>
<SelectOption key="credentials" value="credential">
{i18n._(t`Credentials`)}
</SelectOption>
<SelectOption key="projects" value="project">
{i18n._(t`Projects`)}
</SelectOption>
<SelectOption key="inventories" value="inventory">
{i18n._(t`Inventories`)}
</SelectOption>
<SelectOption key="hosts" value="host">
{i18n._(t`Hosts`)}
</SelectOption>
</SelectGroup>
<SelectGroup label={i18n._(t`Access`)} key="access">
<SelectOption key="organizations" value="organization">
{i18n._(t`Organizations`)}
</SelectOption>
<SelectOption key="users" value="user">
{i18n._(t`Users`)}
</SelectOption>
<SelectOption key="teams" value="team">
{i18n._(t`Teams`)}
</SelectOption>
</SelectGroup>
<SelectGroup label={i18n._(t`Adminisration`)} key="administration">
<SelectOption key="credential_types" value="credential_type">
{i18n._(t`Credential Types`)}
</SelectOption>
<SelectOption
key="notification_templates"
value="notification_template"
>
{i18n._(t`Notification Templates`)}
</SelectOption>
<SelectOption key="instance_groups" value="instance_group">
{i18n._(t`Instance Groups`)}
</SelectOption>
<SelectOption
key="applications"
value="o_auth2_application,o_auth2_access_token"
>
{i18n._(t`Applications & Tokens`)}
</SelectOption>
</SelectGroup>
<SelectGroup label={i18n._(t`Settings`)} key="settings">
<SelectOption key="settings" value="setting">
{i18n._(t`Settings`)}
</SelectOption>
</SelectGroup>
</Select>
</PageSection>
<PageSection>
<Card>
<PaginatedTable
contentError={contentError}
hasContentLoading={isLoading}
items={results}
itemCount={count}
pluralizedItemName={i18n._(t`Events`)}
qsConfig={QS_CONFIG}
toolbarSearchColumns={[
{
name: i18n._(t`Keyword`),
key: 'search',
isDefault: true,
},
{
name: i18n._(t`Initiated by (username)`),
key: 'actor__username__icontains',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Time`),
key: 'timestamp',
},
{
name: i18n._(t`Initiated by`),
key: 'actor__username',
},
]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="timestamp">{i18n._(t`Time`)}</HeaderCell>
<HeaderCell sortKey="actor__username">
{i18n._(t`Initiated by`)}
</HeaderCell>
<HeaderCell>{i18n._(t`Event`)}</HeaderCell>
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
</HeaderRow>
}
renderToolbar={props => (
<DatalistToolbar {...props} qsConfig={QS_CONFIG} />
)}
renderRow={streamItem => (
<ActivityStreamListItem streamItem={streamItem} />
)}
/>
</Card>
</PageSection>
</Fragment>
);
}
export default withI18n()(ActivityStream);

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ActivityStream from './ActivityStream';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<ActivityStream />', () => {
let pageWrapper;
beforeEach(() => {
pageWrapper = mountWithContexts(<ActivityStream />);
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
});
});

View File

@@ -0,0 +1,584 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
const buildAnchor = (obj, resource, activity) => {
let url;
let name;
// try/except pattern asserts that:
// if we encounter a case where a UI url can't or
// shouldn't be generated, just supply the name of the resource
try {
// catch-all case to avoid generating urls if a resource has been deleted
// if a resource still exists, it'll be serialized in the activity's summary_fields
if (!activity.summary_fields[resource]) {
throw new Error('The referenced resource no longer exists');
}
switch (resource) {
case 'custom_inventory_script':
url = `/inventory_scripts/${obj.id}/`;
break;
case 'group':
if (
activity.operation === 'create' ||
activity.operation === 'delete'
) {
// the API formats the changes.inventory field as str 'myInventoryName-PrimaryKey'
const [inventory_id] = activity.changes.inventory
.split('-')
.slice(-1);
url = `/inventories/inventory/${inventory_id}/groups/${activity.changes.id}/details/`;
} else {
url = `/inventories/inventory/${
activity.summary_fields.inventory[0].id
}/groups/${activity.changes.id ||
activity.changes.object1_pk}/details/`;
}
break;
case 'host':
url = `/hosts/${obj.id}/`;
break;
case 'job':
url = `/jobs/${obj.id}/`;
break;
case 'inventory':
url =
obj?.kind === 'smart'
? `/inventories/smart_inventory/${obj.id}/`
: `/inventories/inventory/${obj.id}/`;
break;
case 'schedule':
// schedule urls depend on the resource they're associated with
if (activity.summary_fields.job_template) {
const jt_id = activity.summary_fields.job_template[0].id;
url = `/templates/job_template/${jt_id}/schedules/${obj.id}/`;
} else if (activity.summary_fields.workflow_job_template) {
const wfjt_id = activity.summary_fields.workflow_job_template[0].id;
url = `/templates/workflow_job_template/${wfjt_id}/schedules/${obj.id}/`;
} else if (activity.summary_fields.project) {
url = `/projects/${activity.summary_fields.project[0].id}/schedules/${obj.id}/`;
} else if (activity.summary_fields.system_job_template) {
url = null;
} else {
// urls for inventory sync schedules currently depend on having
// an inventory id and group id
throw new Error(
'activity.summary_fields to build this url not implemented yet'
);
}
break;
case 'setting':
url = `/settings/`;
break;
case 'notification_template':
url = `/notification_templates/${obj.id}/`;
break;
case 'role':
throw new Error(
'role object management is not consolidated to a single UI view'
);
case 'job_template':
url = `/templates/job_template/${obj.id}/`;
break;
case 'workflow_job_template':
url = `/templates/workflow_job_template/${obj.id}/`;
break;
case 'workflow_job_template_node': {
const {
id: wfjt_id,
name: wfjt_name,
} = activity.summary_fields.workflow_job_template[0];
url = `/templates/workflow_job_template/${wfjt_id}/`;
name = wfjt_name;
break;
}
case 'workflow_job':
url = `/jobs/workflow/${obj.id}/`;
break;
case 'label':
url = null;
break;
case 'inventory_source': {
const inventoryId = (obj.inventory || '').split('-').reverse()[0];
url = `/inventories/inventory/${inventoryId}/sources/${obj.id}/details/`;
break;
}
case 'o_auth2_application':
url = `/applications/${obj.id}/`;
break;
case 'workflow_approval':
url = `/jobs/workflow/${activity.summary_fields.workflow_job[0].id}/output/`;
name = `${activity.summary_fields.workflow_job[0].name} | ${activity.summary_fields.workflow_approval[0].name}`;
break;
case 'workflow_approval_template':
url = `/templates/workflow_job_template/${activity.summary_fields.workflow_job_template[0].id}/visualizer/`;
name = `${activity.summary_fields.workflow_job_template[0].name} | ${activity.summary_fields.workflow_approval_template[0].name}`;
break;
default:
url = `/${resource}s/${obj.id}/`;
}
name = name || obj.name || obj.username;
if (url) {
return <Link to={url}>{name}</Link>;
}
return <span>{name}</span>;
} catch (err) {
return <span>{obj.name || obj.username || ''}</span>;
}
};
const getPastTense = item => {
return /e$/.test(item) ? `${item}d` : `${item}ed`;
};
const isGroupRelationship = item => {
return (
item.object1 === 'group' &&
item.object2 === 'group' &&
item.summary_fields.group.length > 1
);
};
const buildLabeledLink = (label, link) => {
return (
<span>
{label} {link}
</span>
);
};
function ActivityStreamDescription({ i18n, activity }) {
const labeledLinks = [];
// Activity stream objects will outlive the resources they reference
// in that case, summary_fields will not be available - show generic error text instead
try {
switch (activity.object_association) {
// explicit role dis+associations
case 'role': {
let { object1, object2 } = activity;
// if object1 winds up being the role's resource, we need to swap the objects
// in order to make the sentence make sense.
if (activity.object_type === object1) {
object1 = activity.object2;
object2 = activity.object1;
}
// object1 field is resource targeted by the dis+association
// object2 field is the resource the role is inherited from
// summary_field.role[0] contains ref info about the role
switch (activity.operation) {
// expected outcome: "disassociated <object2> role_name from <object1>"
case 'disassociate':
if (isGroupRelationship(activity)) {
labeledLinks.push(
buildLabeledLink(
getPastTense(activity.operation),
buildAnchor(
activity.summary_fields.group[1],
object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`${activity.summary_fields.role[0].role_field} from`,
buildAnchor(
activity.summary_fields.group[0],
object1,
activity
)
)
);
} else {
labeledLinks.push(
buildLabeledLink(
getPastTense(activity.operation),
buildAnchor(
activity.summary_fields[object2][0],
object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`${activity.summary_fields.role[0].role_field} from`,
buildAnchor(
activity.summary_fields[object1][0],
object1,
activity
)
)
);
}
break;
// expected outcome: "associated <object2> role_name to <object1>"
case 'associate':
if (isGroupRelationship(activity)) {
labeledLinks.push(
buildLabeledLink(
getPastTense(activity.operation),
buildAnchor(
activity.summary_fields.group[1],
object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`${activity.summary_fields.role[0].role_field} to`,
buildAnchor(
activity.summary_fields.group[0],
object1,
activity
)
)
);
} else {
labeledLinks.push(
buildLabeledLink(
getPastTense(activity.operation),
buildAnchor(
activity.summary_fields[object2][0],
object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`${activity.summary_fields.role[0].role_field} to`,
buildAnchor(
activity.summary_fields[object1][0],
object1,
activity
)
)
);
}
break;
default:
break;
}
break;
// inherited role dis+associations (logic identical to case 'role')
}
case 'parents':
// object1 field is resource targeted by the dis+association
// object2 field is the resource the role is inherited from
// summary_field.role[0] contains ref info about the role
switch (activity.operation) {
// expected outcome: "disassociated <object2> role_name from <object1>"
case 'disassociate':
if (isGroupRelationship(activity)) {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object2}`,
buildAnchor(
activity.summary_fields.group[1],
activity.object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`from ${activity.object1}`,
buildAnchor(
activity.summary_fields.group[0],
activity.object1,
activity
)
)
);
} else {
labeledLinks.push(
buildLabeledLink(
getPastTense(activity.operation),
buildAnchor(
activity.summary_fields[activity.object2][0],
activity.object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`${activity.summary_fields.role[0].role_field} from`,
buildAnchor(
activity.summary_fields[activity.object1][0],
activity.object1,
activity
)
)
);
}
break;
// expected outcome: "associated <object2> role_name to <object1>"
case 'associate':
if (isGroupRelationship(activity)) {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object1}`,
buildAnchor(
activity.summary_fields.group[0],
activity.object1,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`to ${activity.object2}`,
buildAnchor(
activity.summary_fields.group[1],
activity.object2,
activity
)
)
);
} else {
labeledLinks.push(
buildLabeledLink(
getPastTense(activity.operation),
buildAnchor(
activity.summary_fields[activity.object2][0],
activity.object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`${activity.summary_fields.role[0].role_field} to`,
buildAnchor(
activity.summary_fields[activity.object1][0],
activity.object1,
activity
)
)
);
}
break;
default:
break;
}
break;
// CRUD operations / resource on resource dis+associations
default:
switch (activity.operation) {
// expected outcome: "disassociated <object2> from <object1>"
case 'disassociate':
if (isGroupRelationship(activity)) {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object2}`,
buildAnchor(
activity.summary_fields.group[1],
activity.object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`from ${activity.object1}`,
buildAnchor(
activity.summary_fields.group[0],
activity.object1,
activity
)
)
);
} else if (
activity.object1 === 'workflow_job_template_node' &&
activity.object2 === 'workflow_job_template_node'
) {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} two nodes on workflow`,
buildAnchor(
activity.summary_fields[activity.object1[0]],
activity.object1,
activity
)
)
);
} else {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object2}`,
buildAnchor(
activity.summary_fields[activity.object2][0],
activity.object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`from ${activity.object1}`,
buildAnchor(
activity.summary_fields[activity.object1][0],
activity.object1,
activity
)
)
);
}
break;
// expected outcome "associated <object2> to <object1>"
case 'associate':
// groups are the only resource that can be associated/disassociated into each other
if (isGroupRelationship(activity)) {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object1}`,
buildAnchor(
activity.summary_fields.group[0],
activity.object1,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`to ${activity.object2}`,
buildAnchor(
activity.summary_fields.group[1],
activity.object2,
activity
)
)
);
} else if (
activity.object1 === 'workflow_job_template_node' &&
activity.object2 === 'workflow_job_template_node'
) {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} two nodes on workflow`,
buildAnchor(
activity.summary_fields[activity.object1[0]],
activity.object1,
activity
)
)
);
} else {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object1}`,
buildAnchor(
activity.summary_fields[activity.object1][0],
activity.object1,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`to ${activity.object2}`,
buildAnchor(
activity.summary_fields[activity.object2][0],
activity.object2,
activity
)
)
);
}
break;
case 'delete':
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object1}`,
buildAnchor(activity.changes, activity.object1, activity)
)
);
break;
// expected outcome: "operation <object1>"
case 'update':
if (
activity.object1 === 'workflow_approval' &&
activity?.changes?.status?.length === 2
) {
let operationText = '';
if (activity.changes.status[1] === 'successful') {
operationText = i18n._(t`approved`);
} else if (activity.changes.status[1] === 'failed') {
if (
activity.changes.timed_out &&
activity.changes.timed_out[1] === true
) {
operationText = i18n._(t`timed out`);
} else {
operationText = i18n._(t`denied`);
}
} else {
operationText = i18n._(t`updated`);
}
labeledLinks.push(
buildLabeledLink(
`${operationText} ${activity.object1}`,
buildAnchor(
activity.summary_fields[activity.object1][0],
activity.object1,
activity
)
)
);
} else {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object1}`,
buildAnchor(
activity.summary_fields[activity.object1][0],
activity.object1,
activity
)
)
);
}
break;
case 'create':
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object1}`,
buildAnchor(activity.changes, activity.object1, activity)
)
);
break;
default:
break;
}
break;
}
} catch (err) {
return <span>{i18n._(t`Event summary not available`)}</span>;
}
return (
<span>
{labeledLinks.reduce(
(acc, x) =>
acc === null ? (
x
) : (
<>
{acc} {x}
</>
),
null
)}
</span>
);
}
export default withI18n()(ActivityStreamDescription);

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ActivityStreamDescription from './ActivityStreamDescription';
describe('ActivityStreamDescription', () => {
test('initially renders succesfully', () => {
const description = mountWithContexts(
<ActivityStreamDescription activity={{}} />
);
expect(description.find('span').length).toBe(1);
});
});

View File

@@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button, Modal } from '@patternfly/react-core';
import { SearchPlusIcon } from '@patternfly/react-icons';
import { formatDateString } from '../../util/dates';
import { DetailList, Detail } from '../../components/DetailList';
import { VariablesDetail } from '../../components/CodeMirrorInput';
function ActivityStreamDetailButton({ i18n, streamItem, user, description }) {
const [isOpen, setIsOpen] = useState(false);
const setting = streamItem?.summary_fields?.setting;
const changeRows = Math.max(
Object.keys(streamItem?.changes || []).length + 2,
6
);
return (
<>
<Button
aria-label={i18n._(t`View event details`)}
variant="plain"
component="button"
onClick={() => setIsOpen(true)}
>
<SearchPlusIcon />
</Button>
<Modal
variant="large"
isOpen={isOpen}
title={i18n._(t`Event detail`)}
aria-label={i18n._(t`Event detail modal`)}
onClose={() => setIsOpen(false)}
>
<DetailList gutter="sm">
<Detail
label={i18n._(t`Time`)}
value={formatDateString(streamItem.timestamp)}
/>
<Detail label={i18n._(t`Initiated by`)} value={user} />
<Detail
label={i18n._(t`Setting category`)}
value={setting && setting[0]?.category}
/>
<Detail
label={i18n._(t`Setting name`)}
value={setting && setting[0]?.name}
/>
<Detail fullWidth label={i18n._(t`Action`)} value={description} />
{streamItem?.changes && (
<VariablesDetail
label={i18n._(t`Changes`)}
rows={changeRows}
value={streamItem?.changes}
/>
)}
</DetailList>
</Modal>
</>
);
}
export default withI18n()(ActivityStreamDetailButton);

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ActivityStreamDetailButton from './ActivityStreamDetailButton';
jest.mock('../../api/models/ActivityStream');
describe('<ActivityStreamDetailButton />', () => {
test('initially renders succesfully', () => {
mountWithContexts(
<ActivityStreamDetailButton
streamItem={{
timestamp: '12:00:00',
}}
user={<Link to="/users/1/details">Bob</Link>}
description={<span>foo</span>}
/>
);
});
});

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { shape } from 'prop-types';
import { withI18n } from '@lingui/react';
import { Tr, Td } from '@patternfly/react-table';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import { formatDateString } from '../../util/dates';
import { ActionsTd, ActionItem } from '../../components/PaginatedTable';
import ActivityStreamDetailButton from './ActivityStreamDetailButton';
import ActivityStreamDescription from './ActivityStreamDescription';
function ActivityStreamListItem({ streamItem, i18n }) {
ActivityStreamListItem.propTypes = {
streamItem: shape({}).isRequired,
};
const buildUser = item => {
let link;
if (item?.summary_fields?.actor?.id) {
link = (
<Link to={`/users/${item.summary_fields.actor.id}/details`}>
{item.summary_fields.actor.username}
</Link>
);
} else if (item?.summary_fields?.actor) {
link = i18n._(t`${item.summary_fields.actor.username} (deleted)`);
} else {
link = i18n._(t`system`);
}
return link;
};
const labelId = `check-action-${streamItem.id}`;
const user = buildUser(streamItem);
const description = <ActivityStreamDescription activity={streamItem} />;
return (
<Tr id={streamItem.id} aria-labelledby={labelId}>
<Td />
<Td dataLabel={i18n._(t`Time`)}>
{streamItem.timestamp ? formatDateString(streamItem.timestamp) : ''}
</Td>
<Td dataLabel={i18n._(t`Initiated By`)}>{user}</Td>
<Td id={labelId} dataLabel={i18n._(t`Event`)}>
{description}
</Td>
<ActionsTd dataLabel={i18n._(t`Actions`)}>
<ActionItem visible tooltip={i18n._(t`View event details`)}>
<ActivityStreamDetailButton
streamItem={streamItem}
user={user}
description={description}
/>
</ActionItem>
</ActionsTd>
</Tr>
);
}
export default withI18n()(ActivityStreamListItem);

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ActivityStreamListItem from './ActivityStreamListItem';
jest.mock('../../api/models/ActivityStream');
describe('<ActivityStreamListItem />', () => {
test('initially renders succesfully', () => {
mountWithContexts(
<table>
<tbody>
<ActivityStreamListItem
streamItem={{
timestamp: '12:00:00',
}}
onSelect={() => {}}
/>
</tbody>
</table>
);
});
});

View File

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

View File

@@ -12,7 +12,7 @@ import {
import ApplicationsList from './ApplicationsList'; import ApplicationsList from './ApplicationsList';
import ApplicationAdd from './ApplicationAdd'; import ApplicationAdd from './ApplicationAdd';
import Application from './Application'; import Application from './Application';
import Breadcrumbs from '../../components/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader';
import { Detail, DetailList } from '../../components/DetailList'; import { Detail, DetailList } from '../../components/DetailList';
const ApplicationAlert = styled(Alert)` const ApplicationAlert = styled(Alert)`
@@ -45,7 +45,10 @@ function Applications({ i18n }) {
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader
streamType="o_auth2_application,o_auth2_access_token"
breadcrumbConfig={breadcrumbConfig}
/>
<Switch> <Switch>
<Route path="/applications/add"> <Route path="/applications/add">
<ApplicationAdd <ApplicationAdd

View File

@@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Applications from './Applications'; import Applications from './Applications';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Applications />', () => { describe('<Applications />', () => {
let wrapper; let wrapper;

View File

@@ -3,7 +3,7 @@ import { Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Config } from '../../contexts/Config'; import { Config } from '../../contexts/Config';
import Breadcrumbs from '../../components/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader';
import Credential from './Credential'; import Credential from './Credential';
import CredentialAdd from './CredentialAdd'; import CredentialAdd from './CredentialAdd';
import { CredentialList } from './CredentialList'; import { CredentialList } from './CredentialList';
@@ -34,7 +34,10 @@ function Credentials({ i18n }) {
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader
streamType="credential"
breadcrumbConfig={breadcrumbConfig}
/>
<Switch> <Switch>
<Route path="/credentials/add"> <Route path="/credentials/add">
<Config>{({ me }) => <CredentialAdd me={me || {}} />}</Config> <Config>{({ me }) => <CredentialAdd me={me || {}} />}</Config>

View File

@@ -3,6 +3,10 @@ import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Credentials from './Credentials'; import Credentials from './Credentials';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Credentials />', () => { describe('<Credentials />', () => {
let wrapper; let wrapper;
@@ -30,8 +34,8 @@ describe('<Credentials />', () => {
}, },
}); });
expect(wrapper.find('Crumb').length).toBe(1); expect(wrapper.find('Crumb').length).toBe(0);
expect(wrapper.find('BreadcrumbHeading').text()).toBe('Credentials'); expect(wrapper.find('Title').text()).toBe('Credentials');
}); });
test('should display create new credential breadcrumb heading', () => { test('should display create new credential breadcrumb heading', () => {
@@ -51,8 +55,6 @@ describe('<Credentials />', () => {
}); });
expect(wrapper.find('Crumb').length).toBe(2); expect(wrapper.find('Crumb').length).toBe(2);
expect(wrapper.find('BreadcrumbHeading').text()).toBe( expect(wrapper.find('Title').text()).toBe('Create New Credential');
'Create New Credential'
);
}); });
}); });

View File

@@ -6,7 +6,7 @@ import { Route, Switch } from 'react-router-dom';
import CredentialTypeAdd from './CredentialTypeAdd'; import CredentialTypeAdd from './CredentialTypeAdd';
import CredentialTypeList from './CredentialTypeList'; import CredentialTypeList from './CredentialTypeList';
import CredentialType from './CredentialType'; import CredentialType from './CredentialType';
import Breadcrumbs from '../../components/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader';
function CredentialTypes({ i18n }) { function CredentialTypes({ i18n }) {
const [breadcrumbConfig, setBreadcrumbConfig] = useState({ const [breadcrumbConfig, setBreadcrumbConfig] = useState({
@@ -33,7 +33,10 @@ function CredentialTypes({ i18n }) {
); );
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader
streamType="credential_type"
breadcrumbConfig={breadcrumbConfig}
/>
<Switch> <Switch>
<Route path="/credential_types/add"> <Route path="/credential_types/add">
<CredentialTypeAdd /> <CredentialTypeAdd />

View File

@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import CredentialTypes from './CredentialTypes'; import CredentialTypes from './CredentialTypes';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<CredentialTypes/>', () => { describe('<CredentialTypes/>', () => {
let pageWrapper; let pageWrapper;
let pageSections; let pageSections;

View File

@@ -18,7 +18,7 @@ import {
import useRequest from '../../util/useRequest'; import useRequest from '../../util/useRequest';
import { DashboardAPI } from '../../api'; import { DashboardAPI } from '../../api';
import Breadcrumbs from '../../components/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader';
import JobList from '../../components/JobList'; import JobList from '../../components/JobList';
import ContentLoading from '../../components/ContentLoading'; import ContentLoading from '../../components/ContentLoading';
import LineChart from './shared/LineChart'; import LineChart from './shared/LineChart';
@@ -117,7 +117,10 @@ function Dashboard({ i18n }) {
} }
return ( return (
<Fragment> <Fragment>
<Breadcrumbs breadcrumbConfig={{ '/home': i18n._(t`Dashboard`) }} /> <ScreenHeader
streamType="all"
breadcrumbConfig={{ '/home': i18n._(t`Dashboard`) }}
/>
<PageSection> <PageSection>
<Counts> <Counts>
<Count <Count

View File

@@ -7,6 +7,9 @@ import { DashboardAPI } from '../../api';
import Dashboard from './Dashboard'; import Dashboard from './Dashboard';
jest.mock('../../api'); jest.mock('../../api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Dashboard />', () => { describe('<Dashboard />', () => {
let pageWrapper; let pageWrapper;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useLocation, useRouteMatch } from 'react-router-dom'; import { useHistory, useLocation, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core'; import { Card, PageSection } from '@patternfly/react-core';
@@ -13,9 +13,14 @@ import PaginatedDataList, {
ToolbarDeleteButton, ToolbarDeleteButton,
} from '../../../components/PaginatedDataList'; } from '../../../components/PaginatedDataList';
import useRequest, { useDeleteItems } from '../../../util/useRequest'; import useRequest, { useDeleteItems } from '../../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../../util/qs'; import {
encodeQueryString,
getQSConfig,
parseQueryString,
} from '../../../util/qs';
import HostListItem from './HostListItem'; import HostListItem from './HostListItem';
import SmartInventoryButton from './SmartInventoryButton';
const QS_CONFIG = getQSConfig('host', { const QS_CONFIG = getQSConfig('host', {
page: 1, page: 1,
@@ -24,9 +29,21 @@ const QS_CONFIG = getQSConfig('host', {
}); });
function HostList({ i18n }) { function HostList({ i18n }) {
const history = useHistory();
const location = useLocation(); const location = useLocation();
const match = useRouteMatch(); const match = useRouteMatch();
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const parsedQueryStrings = parseQueryString(QS_CONFIG, location.search);
const nonDefaultSearchParams = {};
Object.keys(parsedQueryStrings).forEach(key => {
if (!QS_CONFIG.defaultParams[key]) {
nonDefaultSearchParams[key] = parsedQueryStrings[key];
}
});
const hasNonDefaultSearchParams =
Object.keys(nonDefaultSearchParams).length > 0;
const { const {
result: { hosts, count, actions, relatedSearchableKeys, searchableKeys }, result: { hosts, count, actions, relatedSearchableKeys, searchableKeys },
@@ -99,6 +116,14 @@ function HostList({ i18n }) {
} }
}; };
const handleSmartInventoryClick = () => {
history.push(
`/inventories/smart_inventory/add?host_filter=${encodeURIComponent(
encodeQueryString(nonDefaultSearchParams)
)}`
);
};
const canAdd = const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
@@ -157,6 +182,14 @@ function HostList({ i18n }) {
itemsToDelete={selected} itemsToDelete={selected}
pluralizedItemName={i18n._(t`Hosts`)} pluralizedItemName={i18n._(t`Hosts`)}
/>, />,
...(canAdd
? [
<SmartInventoryButton
isDisabled={!hasNonDefaultSearchParams}
onClick={() => handleSmartInventoryClick()}
/>,
]
: []),
]} ]}
/> />
)} )}

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { HostsAPI } from '../../../api'; import { HostsAPI } from '../../../api';
import { import {
mountWithContexts, mountWithContexts,
@@ -257,7 +258,7 @@ describe('<HostList />', () => {
expect(modal.prop('title')).toEqual('Error!'); expect(modal.prop('title')).toEqual('Error!');
}); });
test('should show Add button according to permissions', async () => { test('should show Add and Smart Inventory buttons according to permissions', async () => {
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<HostList />); wrapper = mountWithContexts(<HostList />);
@@ -265,9 +266,10 @@ describe('<HostList />', () => {
await waitForLoaded(wrapper); await waitForLoaded(wrapper);
expect(wrapper.find('ToolbarAddButton').length).toBe(1); expect(wrapper.find('ToolbarAddButton').length).toBe(1);
expect(wrapper.find('Button[aria-label="Smart Inventory"]').length).toBe(1);
}); });
test('should hide Add button according to permissions', async () => { test('should hide Add and Smart Inventory buttons according to permissions', async () => {
HostsAPI.readOptions.mockResolvedValue({ HostsAPI.readOptions.mockResolvedValue({
data: { data: {
actions: { actions: {
@@ -282,5 +284,44 @@ describe('<HostList />', () => {
await waitForLoaded(wrapper); await waitForLoaded(wrapper);
expect(wrapper.find('ToolbarAddButton').length).toBe(0); expect(wrapper.find('ToolbarAddButton').length).toBe(0);
expect(wrapper.find('Button[aria-label="Smart Inventory"]').length).toBe(0);
});
test('Smart Inventory button should be disabled when no search params are present', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<HostList />);
});
await waitForLoaded(wrapper);
expect(
wrapper.find('Button[aria-label="Smart Inventory"]').props().isDisabled
).toBe(true);
});
test('Clicking Smart Inventory button should navigate to smart inventory form with correct query param', async () => {
let wrapper;
const history = createMemoryHistory({
initialEntries: ['/hosts?host.name__icontains=foo'],
});
await act(async () => {
wrapper = mountWithContexts(<HostList />, {
context: { router: { history } },
});
});
await waitForLoaded(wrapper);
expect(
wrapper.find('Button[aria-label="Smart Inventory"]').props().isDisabled
).toBe(false);
await act(async () => {
wrapper.find('Button[aria-label="Smart Inventory"]').simulate('click');
});
wrapper.update();
expect(history.location.pathname).toEqual(
'/inventories/smart_inventory/add'
);
expect(history.location.search).toEqual(
'?host_filter=name__icontains%3Dfoo'
);
}); });
}); });

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { func } from 'prop-types';
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useKebabifiedMenu } from '../../../contexts/Kebabified';
function SmartInventoryButton({ onClick, i18n, isDisabled }) {
const { isKebabified } = useKebabifiedMenu();
if (isKebabified) {
return (
<DropdownItem
key="add"
isDisabled={isDisabled}
component="button"
onClick={onClick}
>
{i18n._(t`Smart Inventory`)}
</DropdownItem>
);
}
return (
<Tooltip
key="smartInventory"
content={
!isDisabled
? i18n._(t`Create a new Smart Inventory with the applied filter`)
: i18n._(
t`Enter at least one search filter to create a new Smart Inventory`
)
}
position="top"
>
<div>
<Button
onClick={onClick}
aria-label={i18n._(t`Smart Inventory`)}
variant="secondary"
isDisabled={isDisabled}
>
{i18n._(t`Smart Inventory`)}
</Button>
</div>
</Tooltip>
);
}
SmartInventoryButton.propTypes = {
onClick: func.isRequired,
};
export default withI18n()(SmartInventoryButton);

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import SmartInventoryButton from './SmartInventoryButton';
describe('<SmartInventoryButton />', () => {
test('should render button', () => {
const onClick = jest.fn();
const wrapper = mountWithContexts(
<SmartInventoryButton onClick={onClick} />
);
const button = wrapper.find('button');
expect(button).toHaveLength(1);
button.simulate('click');
expect(onClick).toHaveBeenCalled();
});
});

View File

@@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Config } from '../../contexts/Config'; import { Config } from '../../contexts/Config';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import HostList from './HostList'; import HostList from './HostList';
import HostAdd from './HostAdd'; import HostAdd from './HostAdd';
@@ -37,7 +37,7 @@ function Hosts({ i18n }) {
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader streamType="host" breadcrumbConfig={breadcrumbConfig} />
<Switch> <Switch>
<Route path="/hosts/add"> <Route path="/hosts/add">
<HostAdd /> <HostAdd />

View File

@@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Hosts from './Hosts'; import Hosts from './Hosts';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Hosts />', () => { describe('<Hosts />', () => {
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts(<Hosts />); mountWithContexts(<Hosts />);
@@ -27,7 +31,7 @@ describe('<Hosts />', () => {
}, },
}, },
}); });
expect(wrapper.find('BreadcrumbHeading').length).toBe(1); expect(wrapper.find('Title').length).toBe(1);
wrapper.unmount(); wrapper.unmount();
}); });

View File

@@ -9,7 +9,7 @@ import InstanceGroup from './InstanceGroup';
import ContainerGroupAdd from './ContainerGroupAdd'; import ContainerGroupAdd from './ContainerGroupAdd';
import ContainerGroup from './ContainerGroup'; import ContainerGroup from './ContainerGroup';
import Breadcrumbs from '../../components/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader';
function InstanceGroups({ i18n }) { function InstanceGroups({ i18n }) {
const [breadcrumbConfig, setBreadcrumbConfig] = useState({ const [breadcrumbConfig, setBreadcrumbConfig] = useState({
@@ -54,7 +54,10 @@ function InstanceGroups({ i18n }) {
); );
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader
streamType="instance_group"
breadcrumbConfig={breadcrumbConfig}
/>
<Switch> <Switch>
<Route path="/instance_groups/container_group/add"> <Route path="/instance_groups/container_group/add">
<ContainerGroupAdd /> <ContainerGroupAdd />

View File

@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import InstanceGroups from './InstanceGroups'; import InstanceGroups from './InstanceGroups';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<InstanceGroups/>', () => { describe('<InstanceGroups/>', () => {
let pageWrapper; let pageWrapper;
let pageSections; let pageSections;

View File

@@ -1,10 +1,10 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useRef } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import { Config } from '../../contexts/Config'; import { Config } from '../../contexts/Config';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import { InventoryList } from './InventoryList'; import { InventoryList } from './InventoryList';
import Inventory from './Inventory'; import Inventory from './Inventory';
import SmartInventory from './SmartInventory'; import SmartInventory from './SmartInventory';
@@ -12,14 +12,34 @@ import InventoryAdd from './InventoryAdd';
import SmartInventoryAdd from './SmartInventoryAdd'; import SmartInventoryAdd from './SmartInventoryAdd';
function Inventories({ i18n }) { function Inventories({ i18n }) {
const [breadcrumbConfig, setBreadcrumbConfig] = useState({ const initScreenHeader = useRef({
'/inventories': i18n._(t`Inventories`), '/inventories': i18n._(t`Inventories`),
'/inventories/inventory/add': i18n._(t`Create new inventory`), '/inventories/inventory/add': i18n._(t`Create new inventory`),
'/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`), '/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`),
}); });
const buildBreadcrumbConfig = useCallback( const [breadcrumbConfig, setScreenHeader] = useState(
(inventory, nested, schedule) => { initScreenHeader.current
);
const [inventory, setInventory] = useState();
const [nestedObject, setNestedGroup] = useState();
const [schedule, setSchedule] = useState();
const setBreadcrumbConfig = useCallback(
(passedInventory, passedNestedObject, passedSchedule) => {
if (passedInventory && passedInventory.name !== inventory?.name) {
setInventory(passedInventory);
}
if (
passedNestedObject &&
passedNestedObject.name !== nestedObject?.name
) {
setNestedGroup(passedNestedObject);
}
if (passedSchedule && passedSchedule.name !== schedule?.name) {
setSchedule(passedSchedule);
}
if (!inventory) { if (!inventory) {
return; return;
} }
@@ -32,13 +52,8 @@ function Inventories({ i18n }) {
const inventoryGroupsPath = `${inventoryPath}/groups`; const inventoryGroupsPath = `${inventoryPath}/groups`;
const inventorySourcesPath = `${inventoryPath}/sources`; const inventorySourcesPath = `${inventoryPath}/sources`;
setBreadcrumbConfig({ setScreenHeader({
'/inventories': i18n._(t`Inventories`), ...initScreenHeader.current,
'/inventories/inventory/add': i18n._(t`Create new inventory`),
'/inventories/smart_inventory/add': i18n._(
t`Create new smart inventory`
),
[inventoryPath]: `${inventory.name}`, [inventoryPath]: `${inventory.name}`,
[`${inventoryPath}/access`]: i18n._(t`Access`), [`${inventoryPath}/access`]: i18n._(t`Access`),
[`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`), [`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`),
@@ -47,55 +62,74 @@ function Inventories({ i18n }) {
[inventoryHostsPath]: i18n._(t`Hosts`), [inventoryHostsPath]: i18n._(t`Hosts`),
[`${inventoryHostsPath}/add`]: i18n._(t`Create new host`), [`${inventoryHostsPath}/add`]: i18n._(t`Create new host`),
[`${inventoryHostsPath}/${nested?.id}`]: `${nested?.name}`, [`${inventoryHostsPath}/${nestedObject?.id}`]: `${nestedObject?.name}`,
[`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), [`${inventoryHostsPath}/${nestedObject?.id}/edit`]: i18n._(
[`${inventoryHostsPath}/${nested?.id}/details`]: i18n._( t`Edit details`
),
[`${inventoryHostsPath}/${nestedObject?.id}/details`]: i18n._(
t`Host details` t`Host details`
), ),
[`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._( [`${inventoryHostsPath}/${nestedObject?.id}/completed_jobs`]: i18n._(
t`Completed jobs` t`Completed jobs`
), ),
[`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`), [`${inventoryHostsPath}/${nestedObject?.id}/facts`]: i18n._(t`Facts`),
[`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`), [`${inventoryHostsPath}/${nestedObject?.id}/groups`]: i18n._(t`Groups`),
[inventoryGroupsPath]: i18n._(t`Groups`), [inventoryGroupsPath]: i18n._(t`Groups`),
[`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`), [`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`),
[`${inventoryGroupsPath}/${nested?.id}`]: `${nested?.name}`, [`${inventoryGroupsPath}/${nestedObject?.id}`]: `${nestedObject?.name}`,
[`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), [`${inventoryGroupsPath}/${nestedObject?.id}/edit`]: i18n._(
[`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._( t`Edit details`
),
[`${inventoryGroupsPath}/${nestedObject?.id}/details`]: i18n._(
t`Group details` t`Group details`
), ),
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`), [`${inventoryGroupsPath}/${nestedObject?.id}/nested_hosts`]: i18n._(
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._( t`Hosts`
),
[`${inventoryGroupsPath}/${nestedObject?.id}/nested_hosts/add`]: i18n._(
t`Create new host` t`Create new host`
), ),
[`${inventoryGroupsPath}/${nested?.id}/nested_groups`]: i18n._( [`${inventoryGroupsPath}/${nestedObject?.id}/nested_groups`]: i18n._(
t`Groups` t`Related Groups`
), ),
[`${inventoryGroupsPath}/${nested?.id}/nested_groups/add`]: i18n._( [`${inventoryGroupsPath}/${nestedObject?.id}/nested_groups/add`]: i18n._(
t`Create new group` t`Create new group`
), ),
[`${inventorySourcesPath}`]: i18n._(t`Sources`), [`${inventorySourcesPath}`]: i18n._(t`Sources`),
[`${inventorySourcesPath}/add`]: i18n._(t`Create new source`), [`${inventorySourcesPath}/add`]: i18n._(t`Create new source`),
[`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`, [`${inventorySourcesPath}/${nestedObject?.id}`]: `${nestedObject?.name}`,
[`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`), [`${inventorySourcesPath}/${nestedObject?.id}/details`]: i18n._(
[`${inventorySourcesPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), t`Details`
[`${inventorySourcesPath}/${nested?.id}/schedules`]: i18n._( ),
[`${inventorySourcesPath}/${nestedObject?.id}/edit`]: i18n._(
t`Edit details`
),
[`${inventorySourcesPath}/${nestedObject?.id}/schedules`]: i18n._(
t`Schedules` t`Schedules`
), ),
[`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`, [`${inventorySourcesPath}/${nestedObject?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`,
[`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}/details`]: i18n._( [`${inventorySourcesPath}/${nestedObject?.id}/schedules/add`]: i18n._(
t`Create New Schedule`
),
[`${inventorySourcesPath}/${nestedObject?.id}/schedules/${schedule?.id}/details`]: i18n._(
t`Schedule details` t`Schedule details`
), ),
[`${inventorySourcesPath}/${nestedObject?.id}/notifications`]: i18n._(
t`Notifcations`
),
}); });
}, },
[i18n] [i18n, inventory, nestedObject, schedule]
); );
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader
streamType="inventory"
breadcrumbConfig={breadcrumbConfig}
/>
<Switch> <Switch>
<Route path="/inventories/inventory/add"> <Route path="/inventories/inventory/add">
<InventoryAdd /> <InventoryAdd />
@@ -106,12 +140,12 @@ function Inventories({ i18n }) {
<Route path="/inventories/inventory/:id"> <Route path="/inventories/inventory/:id">
<Config> <Config>
{({ me }) => ( {({ me }) => (
<Inventory setBreadcrumb={buildBreadcrumbConfig} me={me || {}} /> <Inventory setBreadcrumb={setBreadcrumbConfig} me={me || {}} />
)} )}
</Config> </Config>
</Route> </Route>
<Route path="/inventories/smart_inventory/:id"> <Route path="/inventories/smart_inventory/:id">
<SmartInventory setBreadcrumb={buildBreadcrumbConfig} /> <SmartInventory setBreadcrumb={setBreadcrumbConfig} />
</Route> </Route>
<Route path="/inventories"> <Route path="/inventories">
<InventoryList /> <InventoryList />

View File

@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Inventories from './Inventories'; import Inventories from './Inventories';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Inventories />', () => { describe('<Inventories />', () => {
let pageWrapper; let pageWrapper;

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useCallback } from 'react';
import { Formik, useField, useFormikContext } from 'formik'; import { Formik, useField, useFormikContext } from 'formik';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useLocation } from 'react-router-dom';
import { func, shape, arrayOf } from 'prop-types'; import { func, shape, arrayOf } from 'prop-types';
import { Form } from '@patternfly/react-core'; import { Form } from '@patternfly/react-core';
import { InstanceGroup } from '../../../types'; import { InstanceGroup } from '../../../types';
@@ -14,6 +15,10 @@ import {
FormColumnLayout, FormColumnLayout,
FormFullWidthLayout, FormFullWidthLayout,
} from '../../../components/FormLayout'; } from '../../../components/FormLayout';
import {
toHostFilter,
toSearchParams,
} from '../../../components/Lookup/shared/HostFilterUtils';
import HostFilterLookup from '../../../components/Lookup/HostFilterLookup'; import HostFilterLookup from '../../../components/Lookup/HostFilterLookup';
import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup'; import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup';
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup'; import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
@@ -109,9 +114,17 @@ function SmartInventoryForm({
onCancel, onCancel,
submitError, submitError,
}) { }) {
const { search } = useLocation();
const queryParams = new URLSearchParams(search);
const hostFilterFromParams = queryParams.get('host_filter');
const initialValues = { const initialValues = {
description: inventory.description || '', description: inventory.description || '',
host_filter: inventory.host_filter || '', host_filter:
inventory.host_filter ||
(hostFilterFromParams
? toHostFilter(toSearchParams(hostFilterFromParams))
: ''),
instance_groups: instanceGroups || [], instance_groups: instanceGroups || [],
kind: 'smart', kind: 'smart',
name: inventory.name || '', name: inventory.name || '',

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
@@ -135,6 +136,29 @@ describe('<SmartInventoryForm />', () => {
}); });
}); });
test('should pre-fill the host filter when query param present and not editing', async () => {
let wrapper;
const history = createMemoryHistory({
initialEntries: [
'/inventories/smart_inventory/add?host_filter=name__icontains%3Dfoo',
],
});
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryForm onCancel={() => {}} onSubmit={() => {}} />,
{
context: { router: { history } },
}
);
});
wrapper.update();
const nameChipGroup = wrapper.find(
'HostFilterLookup ChipGroup[categoryName="Name"]'
);
expect(nameChipGroup.find('Chip').length).toBe(1);
wrapper.unmount();
});
test('should throw content error when option request fails', async () => { test('should throw content error when option request fails', async () => {
let wrapper; let wrapper;
InventoriesAPI.readOptions.mockImplementationOnce(() => InventoriesAPI.readOptions.mockImplementationOnce(() =>

View File

@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Job from './Jobs'; import Job from './Jobs';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Job />', () => { describe('<Job />', () => {
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts(<Job />); mountWithContexts(<Job />);

View File

@@ -11,6 +11,7 @@ import {
DetailList, DetailList,
Detail, Detail,
UserDateDetail, UserDateDetail,
LaunchedByDetail,
} from '../../../components/DetailList'; } from '../../../components/DetailList';
import { CardBody, CardActionsRow } from '../../../components/Card'; import { CardBody, CardActionsRow } from '../../../components/Card';
import ChipGroup from '../../../components/ChipGroup'; import ChipGroup from '../../../components/ChipGroup';
@@ -53,35 +54,6 @@ const VERBOSITY = {
4: '4 (Connection Debug)', 4: '4 (Connection Debug)',
}; };
const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => {
const {
created_by: createdBy,
job_template: jobTemplate,
schedule,
} = summary_fields;
const { schedule: relatedSchedule } = related;
if (!createdBy && !schedule) {
return null;
}
let link;
let value;
if (createdBy) {
link = `/users/${createdBy.id}`;
value = createdBy.username;
} else if (relatedSchedule && jobTemplate) {
link = `/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`;
value = schedule.name;
} else {
link = null;
value = schedule.name;
}
return { link, value };
};
function JobDetail({ job, i18n }) { function JobDetail({ job, i18n }) {
const { const {
created_by, created_by,
@@ -93,6 +65,7 @@ function JobDetail({ job, i18n }) {
workflow_job_template: workflowJobTemplate, workflow_job_template: workflowJobTemplate,
labels, labels,
project, project,
source_workflow_job,
} = job.summary_fields; } = job.summary_fields;
const [errorMsg, setErrorMsg] = useState(); const [errorMsg, setErrorMsg] = useState();
const history = useHistory(); const history = useHistory();
@@ -106,9 +79,6 @@ function JobDetail({ job, i18n }) {
workflow_job: i18n._(t`Workflow Job`), workflow_job: i18n._(t`Workflow Job`),
}; };
const { value: launchedByValue, link: launchedByLink } =
getLaunchedByDetails(job) || {};
const deleteJob = async () => { const deleteJob = async () => {
try { try {
switch (job.type) { switch (job.type) {
@@ -195,17 +165,18 @@ function JobDetail({ job, i18n }) {
} }
/> />
)} )}
{source_workflow_job && (
<Detail
label={i18n._(t`Source Workflow Job`)}
value={
<Link to={`/jobs/workflow/${source_workflow_job.id}`}>
{source_workflow_job.id} - {source_workflow_job.name}
</Link>
}
/>
)}
<Detail label={i18n._(t`Job Type`)} value={jobTypes[job.type]} /> <Detail label={i18n._(t`Job Type`)} value={jobTypes[job.type]} />
<Detail <LaunchedByDetail job={job} i18n={i18n} />
label={i18n._(t`Launched By`)}
value={
launchedByLink ? (
<Link to={`${launchedByLink}`}>{launchedByValue}</Link>
) : (
launchedByValue
)
}
/>
{inventory && ( {inventory && (
<Detail <Detail
label={i18n._(t`Inventory`)} label={i18n._(t`Inventory`)}

View File

@@ -35,6 +35,10 @@ describe('<JobDetail />', () => {
kubernetes: false, kubernetes: false,
credential_type_id: 1, credential_type_id: 1,
}, },
source_workflow_job: {
id: 1234,
name: 'Test Source Workflow',
},
}, },
}} }}
/> />
@@ -45,6 +49,7 @@ describe('<JobDetail />', () => {
assertDetail('Started', '8/8/2019, 7:24:18 PM'); assertDetail('Started', '8/8/2019, 7:24:18 PM');
assertDetail('Finished', '8/8/2019, 7:24:50 PM'); assertDetail('Finished', '8/8/2019, 7:24:50 PM');
assertDetail('Job Template', mockJobData.summary_fields.job_template.name); assertDetail('Job Template', mockJobData.summary_fields.job_template.name);
assertDetail('Source Workflow Job', `1234 - Test Source Workflow`);
assertDetail('Job Type', 'Playbook Run'); assertDetail('Job Type', 'Playbook Run');
assertDetail('Launched By', mockJobData.summary_fields.created_by.username); assertDetail('Launched By', mockJobData.summary_fields.created_by.username);
assertDetail('Inventory', mockJobData.summary_fields.inventory.name); assertDetail('Inventory', mockJobData.summary_fields.inventory.name);

View File

@@ -1,6 +1,6 @@
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { I18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import { import {
@@ -518,7 +518,7 @@ class JobOutput extends Component {
} }
render() { render() {
const { job, i18n } = this.props; const { job } = this.props;
const { const {
contentError, contentError,
@@ -596,15 +596,21 @@ class JobOutput extends Component {
</OutputWrapper> </OutputWrapper>
</CardBody> </CardBody>
{deletionError && ( {deletionError && (
<AlertModal <>
isOpen={deletionError} <I18n>
variant="danger" {({ i18n }) => (
onClose={() => this.setState({ deletionError: null })} <AlertModal
title={i18n._(t`Job Delete Error`)} isOpen={deletionError}
label={i18n._(t`Job Delete Error`)} variant="danger"
> onClose={() => this.setState({ deletionError: null })}
<ErrorDetail error={deletionError} /> title={i18n._(t`Job Delete Error`)}
</AlertModal> label={i18n._(t`Job Delete Error`)}
>
<ErrorDetail error={deletionError} />
</AlertModal>
)}
</I18n>
</>
)} )}
</Fragment> </Fragment>
); );
@@ -612,4 +618,4 @@ class JobOutput extends Component {
} }
export { JobOutput as _JobOutput }; export { JobOutput as _JobOutput };
export default withI18n()(withRouter(JobOutput)); export default withRouter(JobOutput);

View File

@@ -6,6 +6,7 @@ import { t } from '@lingui/macro';
import useRequest from '../../util/useRequest'; import useRequest from '../../util/useRequest';
import { UnifiedJobsAPI } from '../../api'; import { UnifiedJobsAPI } from '../../api';
import ContentError from '../../components/ContentError'; import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading';
import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
const NOT_FOUND = 'not found'; const NOT_FOUND = 'not found';
@@ -46,8 +47,13 @@ function JobTypeRedirect({ id, path, view, i18n }) {
); );
} }
if (isLoading || !job?.id) { if (isLoading || !job?.id) {
// TODO show loading state return (
return <div>Loading...</div>; <PageSection>
<Card>
<ContentLoading />
</Card>
</PageSection>
);
} }
const type = JOB_TYPE_URL_SEGMENTS[job.type]; const type = JOB_TYPE_URL_SEGMENTS[job.type];
return <Redirect from={path} to={`/jobs/${type}/${job.id}/${view}`} />; return <Redirect from={path} to={`/jobs/${type}/${job.id}/${view}`} />;

View File

@@ -3,7 +3,7 @@ import { Route, Switch, useParams, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection } from '@patternfly/react-core'; import { PageSection } from '@patternfly/react-core';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import Job from './Job'; import Job from './Job';
import JobTypeRedirect from './JobTypeRedirect'; import JobTypeRedirect from './JobTypeRedirect';
import JobList from '../../components/JobList'; import JobList from '../../components/JobList';
@@ -40,7 +40,7 @@ function Jobs({ i18n }) {
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader streamType="job" breadcrumbConfig={breadcrumbConfig} />
<Switch> <Switch>
<Route exact path={match.path}> <Route exact path={match.path}>
<PageSection> <PageSection>

Some files were not shown because too many files have changed in this diff Show More