mirror of
https://github.com/ansible/awx.git
synced 2026-01-22 23:18:03 -03:30
commit
ce44a056ad
3
.gitignore
vendored
3
.gitignore
vendored
@ -149,3 +149,6 @@ use_dev_supervisor.txt
|
||||
/tools/docker-compose/overrides/
|
||||
/awx/ui_next/.ui-built
|
||||
/Dockerfile
|
||||
/_build/
|
||||
/_build_kube_dev/
|
||||
/Dockerfile.kube-dev
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@ -2,6 +2,34 @@
|
||||
|
||||
This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<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)
|
||||
- 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
|
||||
|
||||
@ -497,7 +497,7 @@ Before starting the install process, review the [inventory](./installer/inventor
|
||||
|
||||
*docker_compose_dir*
|
||||
|
||||
> When using docker-compose, the `docker-compose.yml` file will be created there (default `/tmp/awxcompose`).
|
||||
> When using docker-compose, the `docker-compose.yml` file will be created there (default `~/.awx/awxcompose`).
|
||||
|
||||
*custom_venv_dir*
|
||||
|
||||
|
||||
44
Makefile
44
Makefile
@ -267,11 +267,27 @@ collectstatic:
|
||||
fi; \
|
||||
mkdir -p awx/public/static && $(PYTHON) manage.py collectstatic --clear --noinput > /dev/null 2>&1
|
||||
|
||||
UWSGI_DEV_RELOAD_COMMAND ?= supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver
|
||||
|
||||
uwsgi: collectstatic
|
||||
@if [ "$(VENV_BASE)" ]; then \
|
||||
. $(VENV_BASE)/awx/bin/activate; \
|
||||
fi; \
|
||||
uwsgi -b 32768 --socket 127.0.0.1:8050 --module=awx.wsgi:application --home=/var/lib/awx/venv/awx --chdir=/awx_devel/ --vacuum --processes=5 --harakiri=120 --master --no-orphans --py-autoreload 1 --max-requests=1000 --stats /tmp/stats.socket --lazy-apps --logformat "%(addr) %(method) %(uri) - %(proto) %(status)" --hook-accepting1="exec:supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver"
|
||||
uwsgi -b 32768 \
|
||||
--socket 127.0.0.1:8050 \
|
||||
--module=awx.wsgi:application \
|
||||
--home=/var/lib/awx/venv/awx \
|
||||
--chdir=/awx_devel/ \
|
||||
--vacuum \
|
||||
--processes=5 \
|
||||
--harakiri=120 --master \
|
||||
--no-orphans \
|
||||
--py-autoreload 1 \
|
||||
--max-requests=1000 \
|
||||
--stats /tmp/stats.socket \
|
||||
--lazy-apps \
|
||||
--logformat "%(addr) %(method) %(uri) - %(proto) %(status)" \
|
||||
--hook-accepting1="exec: $(UWSGI_DEV_RELOAD_COMMAND)"
|
||||
|
||||
daphne:
|
||||
@if [ "$(VENV_BASE)" ]; then \
|
||||
@ -579,15 +595,18 @@ docker-compose-clean: awx/projects
|
||||
|
||||
# Base development image build
|
||||
docker-compose-build:
|
||||
ansible localhost -m template -a "src=installer/roles/image_build/templates/Dockerfile.j2 dest=tools/docker-compose/Dockerfile" -e build_dev=True
|
||||
docker build -t ansible/awx_devel -f tools/docker-compose/Dockerfile \
|
||||
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) .
|
||||
ansible-playbook installer/dockerfile.yml -e build_dev=True
|
||||
docker build -t ansible/awx_devel \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) .
|
||||
docker tag ansible/awx_devel $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
|
||||
#docker push $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
|
||||
|
||||
# For use when developing on "isolated" AWX deployments
|
||||
docker-compose-isolated-build: docker-compose-build
|
||||
docker build -t ansible/awx_isolated -f tools/docker-isolated/Dockerfile .
|
||||
docker build -t ansible/awx_isolated \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
-f tools/docker-isolated/Dockerfile .
|
||||
docker tag ansible/awx_isolated $(DEV_DOCKER_TAG_BASE)/awx_isolated:$(COMPOSE_TAG)
|
||||
#docker push $(DEV_DOCKER_TAG_BASE)/awx_isolated:$(COMPOSE_TAG)
|
||||
|
||||
@ -624,5 +643,16 @@ psql-container:
|
||||
VERSION:
|
||||
@echo "awx: $(VERSION)"
|
||||
|
||||
Dockerfile: installer/roles/image_build/templates/Dockerfile.j2
|
||||
ansible localhost -m template -a "src=installer/roles/image_build/templates/Dockerfile.j2 dest=Dockerfile"
|
||||
Dockerfile: installer/roles/dockerfile/templates/Dockerfile.j2
|
||||
ansible-playbook installer/dockerfile.yml
|
||||
|
||||
Dockerfile.kube-dev: installer/roles/dockerfile/templates/Dockerfile.j2
|
||||
ansible-playbook installer/dockerfile.yml \
|
||||
-e dockerfile_name=Dockerfile.kube-dev \
|
||||
-e kube_dev=True \
|
||||
-e template_dest=_build_kube_dev
|
||||
|
||||
awx-kube-dev-build: Dockerfile.kube-dev
|
||||
docker build -f Dockerfile.kube-dev \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
-t $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) .
|
||||
|
||||
13
README.md
13
README.md
@ -14,20 +14,20 @@ Contributing
|
||||
------------
|
||||
|
||||
- Refer to the [Contributing guide](./CONTRIBUTING.md) to get started developing, testing, and building AWX.
|
||||
- All code submissions are done through pull requests against the `devel` branch.
|
||||
- All contributors must use git commit --signoff for any commit to be merged, and agree that usage of --signoff constitutes agreement with the terms of [DCO 1.1](./DCO_1_1.md)
|
||||
- Take care to make sure no merge commits are in the submission, and use `git rebase` vs `git merge` for this reason.
|
||||
- If submitting a large code change, it's a good idea to join the `#ansible-awx` channel on irc.freenode.net, and talk about what you would like to do or add first. This not only helps everyone know what's going on, it also helps save time and effort, if the community decides some changes are needed.
|
||||
- All code submissions are made through pull requests against the `devel` branch.
|
||||
- All contributors must use git commit --signoff for any commit to be merged and agree that usage of --signoff constitutes agreement with the terms of [DCO 1.1](./DCO_1_1.md)
|
||||
- Take care to make sure no merge commits are in the submission, and use `git rebase` vs. `git merge` for this reason.
|
||||
- If submitting a large code change, it's a good idea to join the `#ansible-awx` channel on irc.freenode.net and talk about what you would like to do or add first. This not only helps everyone know what's going on, but it also helps save time and effort if the community decides some changes are needed.
|
||||
|
||||
Reporting Issues
|
||||
----------------
|
||||
|
||||
If you're experiencing a problem that you feel is a bug in AWX, or have ideas for how to improve AWX, we encourage you to open an issue, and share your feedback. But before opening a new issue, we ask that you please take a look at our [Issues guide](./ISSUES.md).
|
||||
If you're experiencing a problem that you feel is a bug in AWX or have ideas for improving AWX, we encourage you to open an issue and share your feedback. But before opening a new issue, we ask that you please take a look at our [Issues guide](./ISSUES.md).
|
||||
|
||||
Code of Conduct
|
||||
---------------
|
||||
|
||||
We ask all of our community members and contributors to adhere to the [Ansible code of conduct](http://docs.ansible.com/ansible/latest/community/code_of_conduct.html). If you have questions, or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com)
|
||||
We ask all of our community members and contributors to adhere to the [Ansible code of conduct](http://docs.ansible.com/ansible/latest/community/code_of_conduct.html). If you have questions or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com)
|
||||
|
||||
Get Involved
|
||||
------------
|
||||
@ -41,4 +41,3 @@ License
|
||||
-------
|
||||
|
||||
[Apache v2](./LICENSE.md)
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from awx.main.models import (
|
||||
ActivityStream,
|
||||
Inventory,
|
||||
Host,
|
||||
Project,
|
||||
JobTemplate,
|
||||
WorkflowJobTemplate,
|
||||
@ -98,6 +99,7 @@ class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPI
|
||||
organization__id=org_id).count()
|
||||
org_counts['job_templates'] = JobTemplate.accessible_objects(**access_kwargs).filter(
|
||||
organization__id=org_id).count()
|
||||
org_counts['hosts'] = Host.objects.org_active_count(org_id)
|
||||
|
||||
full_context['related_field_counts'] = {}
|
||||
full_context['related_field_counts'][org_id] = org_counts
|
||||
|
||||
@ -38,6 +38,7 @@ class CallbackBrokerWorker(BaseWorker):
|
||||
|
||||
MAX_RETRIES = 2
|
||||
last_stats = time.time()
|
||||
last_flush = time.time()
|
||||
total = 0
|
||||
last_event = ''
|
||||
prof = None
|
||||
@ -52,7 +53,7 @@ class CallbackBrokerWorker(BaseWorker):
|
||||
|
||||
def read(self, queue):
|
||||
try:
|
||||
res = self.redis.blpop(settings.CALLBACK_QUEUE, timeout=settings.JOB_EVENT_BUFFER_SECONDS)
|
||||
res = self.redis.blpop(settings.CALLBACK_QUEUE, timeout=1)
|
||||
if res is None:
|
||||
return {'event': 'FLUSH'}
|
||||
self.total += 1
|
||||
@ -102,6 +103,7 @@ class CallbackBrokerWorker(BaseWorker):
|
||||
now = tz_now()
|
||||
if (
|
||||
force or
|
||||
(time.time() - self.last_flush) > settings.JOB_EVENT_BUFFER_SECONDS or
|
||||
any([len(events) >= 1000 for events in self.buff.values()])
|
||||
):
|
||||
for cls, events in self.buff.items():
|
||||
@ -124,6 +126,7 @@ class CallbackBrokerWorker(BaseWorker):
|
||||
for e in events:
|
||||
emit_event_detail(e)
|
||||
self.buff = {}
|
||||
self.last_flush = time.time()
|
||||
|
||||
def perform_work(self, body):
|
||||
try:
|
||||
|
||||
@ -81,10 +81,17 @@ User.add_to_class('accessible_objects', user_accessible_objects)
|
||||
|
||||
|
||||
def enforce_bigint_pk_migration():
|
||||
#
|
||||
# NOTE: this function is not actually in use anymore,
|
||||
# but has been intentionally kept for historical purposes,
|
||||
# and to serve as an illustration if we ever need to perform
|
||||
# bulk modification/migration of event data in the future.
|
||||
#
|
||||
# see: https://github.com/ansible/awx/issues/6010
|
||||
# look at all the event tables and verify that they have been fully migrated
|
||||
# from the *old* int primary key table to the replacement bigint table
|
||||
# if not, attempt to migrate them in the background
|
||||
#
|
||||
for tblname in (
|
||||
'main_jobevent', 'main_inventoryupdateevent',
|
||||
'main_projectupdateevent', 'main_adhoccommandevent',
|
||||
|
||||
@ -357,7 +357,7 @@ class JobNotificationMixin(object):
|
||||
'url': 'https://towerhost/#/jobs/playbook/1010',
|
||||
'approval_status': 'approved',
|
||||
'approval_node_name': 'Approve Me',
|
||||
'workflow_url': 'https://towerhost/#/workflows/1010',
|
||||
'workflow_url': 'https://towerhost/#/jobs/workflow/1010',
|
||||
'job_metadata': """{'url': 'https://towerhost/$/jobs/playbook/13',
|
||||
'traceback': '',
|
||||
'status': 'running',
|
||||
|
||||
@ -620,7 +620,7 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
|
||||
return reverse('api:workflow_job_detail', kwargs={'pk': self.pk}, request=request)
|
||||
|
||||
def get_ui_url(self):
|
||||
return urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.pk))
|
||||
return urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.pk))
|
||||
|
||||
def notification_data(self):
|
||||
result = super(WorkflowJob, self).notification_data()
|
||||
@ -752,7 +752,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
||||
return None
|
||||
|
||||
def get_ui_url(self):
|
||||
return urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id))
|
||||
return urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.workflow_job.id))
|
||||
|
||||
def _get_parent_field_name(self):
|
||||
return 'workflow_approval_template'
|
||||
@ -840,7 +840,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
||||
return (msg, body)
|
||||
|
||||
def context(self, approval_status):
|
||||
workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id))
|
||||
workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.workflow_job.id))
|
||||
return {'approval_status': approval_status,
|
||||
'approval_node_name': self.workflow_approval_template.name,
|
||||
'workflow_url': workflow_url,
|
||||
|
||||
@ -60,7 +60,7 @@ from awx.main.models import (
|
||||
Inventory, InventorySource, SmartInventoryMembership,
|
||||
Job, AdHocCommand, ProjectUpdate, InventoryUpdate, SystemJob,
|
||||
JobEvent, ProjectUpdateEvent, InventoryUpdateEvent, AdHocCommandEvent, SystemJobEvent,
|
||||
build_safe_env, enforce_bigint_pk_migration
|
||||
build_safe_env
|
||||
)
|
||||
from awx.main.constants import ACTIVE_STATES
|
||||
from awx.main.exceptions import AwxTaskError, PostRunError
|
||||
@ -138,12 +138,6 @@ def dispatch_startup():
|
||||
if Instance.objects.me().is_controller():
|
||||
awx_isolated_heartbeat()
|
||||
|
||||
# at process startup, detect the need to migrate old event records from int
|
||||
# to bigint; at *some point* in the future, once certain versions of AWX
|
||||
# and Tower fall out of use/support, we can probably just _assume_ that
|
||||
# everybody has moved to bigint, and remove this code entirely
|
||||
enforce_bigint_pk_migration()
|
||||
|
||||
# Update Tower's rsyslog.conf file based on loggins settings in the db
|
||||
reconfigure_rsyslog()
|
||||
|
||||
@ -738,6 +732,12 @@ def update_host_smart_inventory_memberships():
|
||||
|
||||
@task(queue=get_local_queuename)
|
||||
def migrate_legacy_event_data(tblname):
|
||||
#
|
||||
# NOTE: this function is not actually in use anymore,
|
||||
# but has been intentionally kept for historical purposes,
|
||||
# and to serve as an illustration if we ever need to perform
|
||||
# bulk modification/migration of event data in the future.
|
||||
#
|
||||
if 'event' not in tblname:
|
||||
return
|
||||
with advisory_lock(f'bigint_migration_{tblname}', wait=False) as acquired:
|
||||
|
||||
@ -2,7 +2,7 @@ import pytest
|
||||
|
||||
from awx.api.versioning import reverse
|
||||
|
||||
from awx.main.models import Project
|
||||
from awx.main.models import Project, Host
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -81,6 +81,8 @@ def test_org_counts_detail_admin(resourced_organization, user, get):
|
||||
assert response.status_code == 200
|
||||
|
||||
counts = response.data['summary_fields']['related_field_counts']
|
||||
assert counts['hosts'] == 0
|
||||
counts.pop('hosts')
|
||||
assert counts == COUNTS_PRIMES
|
||||
|
||||
|
||||
@ -93,6 +95,8 @@ def test_org_counts_detail_member(resourced_organization, user, get):
|
||||
assert response.status_code == 200
|
||||
|
||||
counts = response.data['summary_fields']['related_field_counts']
|
||||
assert counts['hosts'] == 0
|
||||
counts.pop('hosts')
|
||||
assert counts == {
|
||||
'users': COUNTS_PRIMES['users'], # Policy is that members can see other users and admins
|
||||
'admins': COUNTS_PRIMES['admins'],
|
||||
@ -111,6 +115,7 @@ def test_org_counts_list_admin(resourced_organization, user, get):
|
||||
assert response.status_code == 200
|
||||
|
||||
counts = response.data['results'][0]['summary_fields']['related_field_counts']
|
||||
assert 'hosts' not in counts # doesn't show in list view
|
||||
assert counts == COUNTS_PRIMES
|
||||
|
||||
|
||||
@ -123,6 +128,7 @@ def test_org_counts_list_member(resourced_organization, user, get):
|
||||
assert response.status_code == 200
|
||||
|
||||
counts = response.data['results'][0]['summary_fields']['related_field_counts']
|
||||
assert 'hosts' not in counts # doesn't show in list view
|
||||
|
||||
assert counts == {
|
||||
'users': COUNTS_PRIMES['users'], # Policy is that members can see other users and admins
|
||||
@ -145,6 +151,7 @@ def test_new_org_zero_counts(user, post):
|
||||
|
||||
new_org_list = post_response.render().data
|
||||
counts_dict = new_org_list['summary_fields']['related_field_counts']
|
||||
assert 'hosts' not in counts_dict # doesn't show in list view
|
||||
assert counts_dict == COUNTS_ZEROS
|
||||
|
||||
|
||||
@ -167,6 +174,19 @@ def test_two_organizations(resourced_organization, organizations, user, get):
|
||||
assert counts[org_id_zero] == COUNTS_ZEROS
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_hosts_counted(resourced_organization, user, get):
|
||||
admin_user = user('admin', True)
|
||||
assert Host.objects.org_active_count(resourced_organization.id) == 0
|
||||
resourced_organization.inventories.first().hosts.create(name='Some Host')
|
||||
assert Host.objects.org_active_count(resourced_organization.id) == 1
|
||||
response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user)
|
||||
assert response.status_code == 200
|
||||
|
||||
counts = response.data['summary_fields']['related_field_counts']
|
||||
assert counts['hosts'] == Host.objects.org_active_count(resourced_organization.id) == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scan_JT_counted(resourced_organization, user, get):
|
||||
admin_user = user('admin', True)
|
||||
@ -180,7 +200,10 @@ def test_scan_JT_counted(resourced_organization, user, get):
|
||||
# Test detail view
|
||||
detail_response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user)
|
||||
assert detail_response.status_code == 200
|
||||
assert detail_response.data['summary_fields']['related_field_counts'] == counts_dict
|
||||
counts = detail_response.data['summary_fields']['related_field_counts']
|
||||
assert 'hosts' in counts
|
||||
counts.pop('hosts')
|
||||
assert counts == counts_dict
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -205,4 +228,7 @@ def test_JT_not_double_counted(resourced_organization, user, get):
|
||||
# Test detail view
|
||||
detail_response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user)
|
||||
assert detail_response.status_code == 200
|
||||
assert detail_response.data['summary_fields']['related_field_counts'] == counts_dict
|
||||
counts = detail_response.data['summary_fields']['related_field_counts']
|
||||
assert 'hosts' in counts
|
||||
counts.pop('hosts')
|
||||
assert counts == counts_dict
|
||||
|
||||
@ -35,16 +35,17 @@ data_loggly = {
|
||||
# Test reconfigure logging settings function
|
||||
# name this whatever you want
|
||||
@pytest.mark.parametrize(
|
||||
'enabled, log_type, host, port, protocol, expected_config', [
|
||||
'enabled, log_type, host, port, protocol, errorfile, expected_config', [
|
||||
(
|
||||
True,
|
||||
'loggly',
|
||||
'http://logs-01.loggly.com/inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/',
|
||||
None,
|
||||
'https',
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||
'action(type="omhttp" server="logs-01.loggly.com" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/")', # noqa
|
||||
'action(type="omhttp" server="logs-01.loggly.com" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/")', # noqa
|
||||
])
|
||||
),
|
||||
(
|
||||
@ -53,6 +54,7 @@ data_loggly = {
|
||||
'localhost',
|
||||
9000,
|
||||
'udp',
|
||||
'', # empty errorfile
|
||||
'\n'.join([
|
||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")',
|
||||
'action(type="omfwd" target="localhost" port="9000" protocol="udp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa
|
||||
@ -64,6 +66,7 @@ data_loggly = {
|
||||
'localhost',
|
||||
9000,
|
||||
'tcp',
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")',
|
||||
'action(type="omfwd" target="localhost" port="9000" protocol="tcp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa
|
||||
@ -75,9 +78,10 @@ data_loggly = {
|
||||
'https://yoursplunk/services/collector/event',
|
||||
None,
|
||||
None,
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||
'action(type="omhttp" server="yoursplunk" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
|
||||
'action(type="omhttp" server="yoursplunk" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||
])
|
||||
),
|
||||
(
|
||||
@ -86,9 +90,10 @@ data_loggly = {
|
||||
'http://yoursplunk/services/collector/event',
|
||||
None,
|
||||
None,
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||
'action(type="omhttp" server="yoursplunk" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
|
||||
'action(type="omhttp" server="yoursplunk" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||
])
|
||||
),
|
||||
(
|
||||
@ -97,9 +102,10 @@ data_loggly = {
|
||||
'https://yoursplunk:8088/services/collector/event',
|
||||
None,
|
||||
None,
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
|
||||
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||
])
|
||||
),
|
||||
(
|
||||
@ -108,9 +114,10 @@ data_loggly = {
|
||||
'https://yoursplunk/services/collector/event',
|
||||
8088,
|
||||
None,
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
|
||||
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||
])
|
||||
),
|
||||
(
|
||||
@ -119,9 +126,10 @@ data_loggly = {
|
||||
'yoursplunk.org/services/collector/event',
|
||||
8088,
|
||||
'https',
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
|
||||
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||
])
|
||||
),
|
||||
(
|
||||
@ -130,9 +138,10 @@ data_loggly = {
|
||||
'http://yoursplunk.org/services/collector/event',
|
||||
8088,
|
||||
None,
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
|
||||
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||
])
|
||||
),
|
||||
(
|
||||
@ -141,14 +150,15 @@ data_loggly = {
|
||||
'https://endpoint5.collection.us2.sumologic.com/receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==', # noqa
|
||||
None,
|
||||
'https',
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||
'action(type="omhttp" server="endpoint5.collection.us2.sumologic.com" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==")', # noqa
|
||||
'action(type="omhttp" server="endpoint5.collection.us2.sumologic.com" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==")', # noqa
|
||||
])
|
||||
),
|
||||
]
|
||||
)
|
||||
def test_rsyslog_conf_template(enabled, log_type, host, port, protocol, expected_config):
|
||||
def test_rsyslog_conf_template(enabled, log_type, host, port, protocol, errorfile, expected_config):
|
||||
|
||||
mock_settings, _ = _mock_logging_defaults()
|
||||
|
||||
@ -159,6 +169,7 @@ def test_rsyslog_conf_template(enabled, log_type, host, port, protocol, expected
|
||||
setattr(mock_settings, 'LOG_AGGREGATOR_ENABLED', enabled)
|
||||
setattr(mock_settings, 'LOG_AGGREGATOR_TYPE', log_type)
|
||||
setattr(mock_settings, 'LOG_AGGREGATOR_HOST', host)
|
||||
setattr(mock_settings, 'LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE', errorfile)
|
||||
if port:
|
||||
setattr(mock_settings, 'LOG_AGGREGATOR_PORT', port)
|
||||
if protocol:
|
||||
|
||||
@ -18,6 +18,7 @@ def construct_rsyslog_conf_template(settings=settings):
|
||||
timeout = getattr(settings, 'LOG_AGGREGATOR_TCP_TIMEOUT', 5)
|
||||
max_disk_space = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_GB', 1)
|
||||
spool_directory = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_PATH', '/var/lib/awx').rstrip('/')
|
||||
error_log_file = getattr(settings, 'LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE', '')
|
||||
|
||||
if not os.access(spool_directory, os.W_OK):
|
||||
spool_directory = '/var/lib/awx'
|
||||
@ -74,9 +75,10 @@ def construct_rsyslog_conf_template(settings=settings):
|
||||
f'skipverifyhost="{skip_verify}"',
|
||||
'action.resumeRetryCount="-1"',
|
||||
'template="awx"',
|
||||
'errorfile="/var/log/tower/rsyslog.err"',
|
||||
f'action.resumeInterval="{timeout}"'
|
||||
]
|
||||
if error_log_file:
|
||||
params.append(f'errorfile="{error_log_file}"')
|
||||
if parsed.path:
|
||||
path = urlparse.quote(parsed.path[1:], safe='/=')
|
||||
if parsed.query:
|
||||
|
||||
@ -196,9 +196,9 @@ LOCAL_STDOUT_EXPIRE_TIME = 2592000
|
||||
# events into the database
|
||||
JOB_EVENT_WORKERS = 4
|
||||
|
||||
# The number of seconds (must be an integer) to buffer callback receiver bulk
|
||||
# The number of seconds to buffer callback receiver bulk
|
||||
# writes in memory before flushing via JobEvent.objects.bulk_create()
|
||||
JOB_EVENT_BUFFER_SECONDS = 1
|
||||
JOB_EVENT_BUFFER_SECONDS = .1
|
||||
|
||||
# The interval at which callback receiver statistics should be
|
||||
# recorded
|
||||
@ -770,6 +770,7 @@ LOG_AGGREGATOR_LEVEL = 'INFO'
|
||||
LOG_AGGREGATOR_MAX_DISK_USAGE_GB = 1
|
||||
LOG_AGGREGATOR_MAX_DISK_USAGE_PATH = '/var/lib/awx'
|
||||
LOG_AGGREGATOR_RSYSLOGD_DEBUG = False
|
||||
LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE = '/var/log/tower/rsyslog.err'
|
||||
|
||||
# The number of retry attempts for websocket session establishment
|
||||
# If you're encountering issues establishing websockets in clustered Tower,
|
||||
@ -853,38 +854,30 @@ LOGGING = {
|
||||
},
|
||||
'tower_warnings': {
|
||||
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'class': 'logging.handlers.WatchedFileHandler',
|
||||
'filters': ['require_debug_false', 'dynamic_level_filter'],
|
||||
'filename': os.path.join(LOG_ROOT, 'tower.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'callback_receiver': {
|
||||
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'class': 'logging.handlers.WatchedFileHandler',
|
||||
'filters': ['require_debug_false', 'dynamic_level_filter'],
|
||||
'filename': os.path.join(LOG_ROOT, 'callback_receiver.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'dispatcher': {
|
||||
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'class': 'logging.handlers.WatchedFileHandler',
|
||||
'filters': ['require_debug_false', 'dynamic_level_filter'],
|
||||
'filename': os.path.join(LOG_ROOT, 'dispatcher.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'dispatcher',
|
||||
},
|
||||
'wsbroadcast': {
|
||||
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'class': 'logging.handlers.WatchedFileHandler',
|
||||
'filters': ['require_debug_false', 'dynamic_level_filter'],
|
||||
'filename': os.path.join(LOG_ROOT, 'wsbroadcast.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'celery.beat': {
|
||||
@ -898,46 +891,36 @@ LOGGING = {
|
||||
},
|
||||
'task_system': {
|
||||
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'class': 'logging.handlers.WatchedFileHandler',
|
||||
'filters': ['require_debug_false', 'dynamic_level_filter'],
|
||||
'filename': os.path.join(LOG_ROOT, 'task_system.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'management_playbooks': {
|
||||
'level': 'DEBUG',
|
||||
'class':'logging.handlers.RotatingFileHandler',
|
||||
'class':'logging.handlers.WatchedFileHandler',
|
||||
'filters': ['require_debug_false'],
|
||||
'filename': os.path.join(LOG_ROOT, 'management_playbooks.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'system_tracking_migrations': {
|
||||
'level': 'WARNING',
|
||||
'class':'logging.handlers.RotatingFileHandler',
|
||||
'class':'logging.handlers.WatchedFileHandler',
|
||||
'filters': ['require_debug_false'],
|
||||
'filename': os.path.join(LOG_ROOT, 'tower_system_tracking_migrations.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'rbac_migrations': {
|
||||
'level': 'WARNING',
|
||||
'class':'logging.handlers.RotatingFileHandler',
|
||||
'class':'logging.handlers.WatchedFileHandler',
|
||||
'filters': ['require_debug_false'],
|
||||
'filename': os.path.join(LOG_ROOT, 'tower_rbac_migrations.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'isolated_manager': {
|
||||
'level': 'WARNING',
|
||||
'class':'logging.handlers.RotatingFileHandler',
|
||||
'class':'logging.handlers.WatchedFileHandler',
|
||||
'filename': os.path.join(LOG_ROOT, 'isolated_manager.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
},
|
||||
|
||||
@ -158,7 +158,10 @@ AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx")
|
||||
# default settings for development. If not present, we can still run using
|
||||
# only the defaults.
|
||||
try:
|
||||
include(optional('local_*.py'), scope=locals())
|
||||
if os.getenv('AWX_KUBE_DEVEL', False):
|
||||
include(optional('minikube.py'), scope=locals())
|
||||
else:
|
||||
include(optional('local_*.py'), scope=locals())
|
||||
except ImportError:
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
4
awx/settings/minikube.py
Normal file
4
awx/settings/minikube.py
Normal file
@ -0,0 +1,4 @@
|
||||
BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖'
|
||||
BROADCAST_WEBSOCKET_PORT = 8013
|
||||
BROADCAST_WEBSOCKET_VERIFY_CERT = False
|
||||
BROADCAST_WEBSOCKET_PROTOCOL = 'http'
|
||||
@ -1,3 +1,4 @@
|
||||
import ActivityStream from './models/ActivityStream';
|
||||
import AdHocCommands from './models/AdHocCommands';
|
||||
import Applications from './models/Applications';
|
||||
import Auth from './models/Auth';
|
||||
@ -39,6 +40,7 @@ import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
|
||||
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
|
||||
import WorkflowJobs from './models/WorkflowJobs';
|
||||
|
||||
const ActivityStreamAPI = new ActivityStream();
|
||||
const AdHocCommandsAPI = new AdHocCommands();
|
||||
const ApplicationsAPI = new Applications();
|
||||
const AuthAPI = new Auth();
|
||||
@ -81,6 +83,7 @@ const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
|
||||
const WorkflowJobsAPI = new WorkflowJobs();
|
||||
|
||||
export {
|
||||
ActivityStreamAPI,
|
||||
AdHocCommandsAPI,
|
||||
ApplicationsAPI,
|
||||
AuthAPI,
|
||||
|
||||
10
awx/ui_next/src/api/models/ActivityStream.js
Normal file
10
awx/ui_next/src/api/models/ActivityStream.js
Normal 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;
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
@ -17,95 +17,57 @@ const readTeams = async queryParams => TeamsAPI.read(queryParams);
|
||||
|
||||
const readTeamsOptions = async () => TeamsAPI.readOptions();
|
||||
|
||||
class AddResourceRole extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selectedResource: null,
|
||||
selectedResourceRows: [],
|
||||
selectedRoleRows: [],
|
||||
currentStepId: 1,
|
||||
maxEnabledStep: 1,
|
||||
};
|
||||
|
||||
this.handleResourceCheckboxClick = this.handleResourceCheckboxClick.bind(
|
||||
this
|
||||
);
|
||||
this.handleResourceSelect = this.handleResourceSelect.bind(this);
|
||||
this.handleRoleCheckboxClick = this.handleRoleCheckboxClick.bind(this);
|
||||
this.handleWizardNext = this.handleWizardNext.bind(this);
|
||||
this.handleWizardSave = this.handleWizardSave.bind(this);
|
||||
this.handleWizardGoToStep = this.handleWizardGoToStep.bind(this);
|
||||
}
|
||||
|
||||
handleResourceCheckboxClick(user) {
|
||||
const { selectedResourceRows, currentStepId } = this.state;
|
||||
function AddResourceRole({ onSave, onClose, roles, i18n, resource }) {
|
||||
const [selectedResource, setSelectedResource] = useState(null);
|
||||
const [selectedResourceRows, setSelectedResourceRows] = useState([]);
|
||||
const [selectedRoleRows, setSelectedRoleRows] = useState([]);
|
||||
const [currentStepId, setCurrentStepId] = useState(1);
|
||||
const [maxEnabledStep, setMaxEnabledStep] = useState(1);
|
||||
|
||||
const handleResourceCheckboxClick = user => {
|
||||
const selectedIndex = selectedResourceRows.findIndex(
|
||||
selectedRow => selectedRow.id === user.id
|
||||
);
|
||||
|
||||
if (selectedIndex > -1) {
|
||||
selectedResourceRows.splice(selectedIndex, 1);
|
||||
const stateToUpdate = { selectedResourceRows };
|
||||
if (selectedResourceRows.length === 0) {
|
||||
stateToUpdate.maxEnabledStep = currentStepId;
|
||||
setMaxEnabledStep(currentStepId);
|
||||
}
|
||||
this.setState(stateToUpdate);
|
||||
setSelectedRoleRows(selectedResourceRows);
|
||||
} else {
|
||||
this.setState(prevState => ({
|
||||
selectedResourceRows: [...prevState.selectedResourceRows, user],
|
||||
}));
|
||||
setSelectedResourceRows([...selectedResourceRows, user]);
|
||||
}
|
||||
}
|
||||
|
||||
handleRoleCheckboxClick(role) {
|
||||
const { selectedRoleRows } = this.state;
|
||||
};
|
||||
|
||||
const handleRoleCheckboxClick = role => {
|
||||
const selectedIndex = selectedRoleRows.findIndex(
|
||||
selectedRow => selectedRow.id === role.id
|
||||
);
|
||||
|
||||
if (selectedIndex > -1) {
|
||||
selectedRoleRows.splice(selectedIndex, 1);
|
||||
this.setState({ selectedRoleRows });
|
||||
setSelectedRoleRows(selectedRoleRows);
|
||||
} else {
|
||||
this.setState(prevState => ({
|
||||
selectedRoleRows: [...prevState.selectedRoleRows, role],
|
||||
}));
|
||||
setSelectedRoleRows([...selectedRoleRows, role]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleResourceSelect(resourceType) {
|
||||
this.setState({
|
||||
selectedResource: resourceType,
|
||||
selectedResourceRows: [],
|
||||
selectedRoleRows: [],
|
||||
});
|
||||
}
|
||||
const handleResourceSelect = resourceType => {
|
||||
setSelectedResource(resourceType);
|
||||
setSelectedResourceRows([]);
|
||||
setSelectedRoleRows([]);
|
||||
};
|
||||
|
||||
handleWizardNext(step) {
|
||||
this.setState({
|
||||
currentStepId: step.id,
|
||||
maxEnabledStep: step.id,
|
||||
});
|
||||
}
|
||||
const handleWizardNext = step => {
|
||||
setCurrentStepId(step.id);
|
||||
setMaxEnabledStep(step.id);
|
||||
};
|
||||
|
||||
handleWizardGoToStep(step) {
|
||||
this.setState({
|
||||
currentStepId: step.id,
|
||||
});
|
||||
}
|
||||
|
||||
async handleWizardSave() {
|
||||
const { onSave } = this.props;
|
||||
const {
|
||||
selectedResourceRows,
|
||||
selectedRoleRows,
|
||||
selectedResource,
|
||||
} = this.state;
|
||||
const handleWizardGoToStep = step => {
|
||||
setCurrentStepId(step.id);
|
||||
};
|
||||
|
||||
const handleWizardSave = async () => {
|
||||
try {
|
||||
const roleRequests = [];
|
||||
|
||||
@ -134,201 +96,186 @@ class AddResourceRole extends React.Component {
|
||||
} catch (err) {
|
||||
// TODO: handle this error
|
||||
}
|
||||
};
|
||||
|
||||
// Object roles can be user only, so we remove them when
|
||||
// showing role choices for team access
|
||||
const selectableRoles = { ...roles };
|
||||
if (selectedResource === 'teams') {
|
||||
Object.keys(roles).forEach(key => {
|
||||
if (selectableRoles[key].user_only) {
|
||||
delete selectableRoles[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
selectedResource,
|
||||
selectedResourceRows,
|
||||
selectedRoleRows,
|
||||
currentStepId,
|
||||
maxEnabledStep,
|
||||
} = this.state;
|
||||
const { onClose, roles, i18n, resource } = this.props;
|
||||
const userSearchColumns = [
|
||||
{
|
||||
name: i18n._(t`Username`),
|
||||
key: 'username__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`First Name`),
|
||||
key: 'first_name__icontains',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Last Name`),
|
||||
key: 'last_name__icontains',
|
||||
},
|
||||
];
|
||||
const userSortColumns = [
|
||||
{
|
||||
name: i18n._(t`Username`),
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`First Name`),
|
||||
key: 'first_name',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Last Name`),
|
||||
key: 'last_name',
|
||||
},
|
||||
];
|
||||
const teamSearchColumns = [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username',
|
||||
},
|
||||
];
|
||||
|
||||
// Object roles can be user only, so we remove them when
|
||||
// showing role choices for team access
|
||||
const selectableRoles = { ...roles };
|
||||
if (selectedResource === 'teams') {
|
||||
Object.keys(roles).forEach(key => {
|
||||
if (selectableRoles[key].user_only) {
|
||||
delete selectableRoles[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
const teamSortColumns = [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
];
|
||||
|
||||
const userSearchColumns = [
|
||||
{
|
||||
name: i18n._(t`Username`),
|
||||
key: 'username__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`First Name`),
|
||||
key: 'first_name__icontains',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Last Name`),
|
||||
key: 'last_name__icontains',
|
||||
},
|
||||
];
|
||||
let wizardTitle = '';
|
||||
|
||||
const userSortColumns = [
|
||||
{
|
||||
name: i18n._(t`Username`),
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`First Name`),
|
||||
key: 'first_name',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Last Name`),
|
||||
key: 'last_name',
|
||||
},
|
||||
];
|
||||
switch (selectedResource) {
|
||||
case 'users':
|
||||
wizardTitle = i18n._(t`Add User Roles`);
|
||||
break;
|
||||
case 'teams':
|
||||
wizardTitle = i18n._(t`Add Team Roles`);
|
||||
break;
|
||||
default:
|
||||
wizardTitle = i18n._(t`Add Roles`);
|
||||
}
|
||||
|
||||
const teamSearchColumns = [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username',
|
||||
},
|
||||
];
|
||||
|
||||
const teamSortColumns = [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
];
|
||||
|
||||
let wizardTitle = '';
|
||||
|
||||
switch (selectedResource) {
|
||||
case 'users':
|
||||
wizardTitle = i18n._(t`Add User Roles`);
|
||||
break;
|
||||
case 'teams':
|
||||
wizardTitle = i18n._(t`Add Team Roles`);
|
||||
break;
|
||||
default:
|
||||
wizardTitle = i18n._(t`Add Roles`);
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: 1,
|
||||
name: i18n._(t`Select a Resource Type`),
|
||||
component: (
|
||||
<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')}
|
||||
/>
|
||||
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>
|
||||
),
|
||||
enableNext: selectedResource !== null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: i18n._(t`Select Items from List`),
|
||||
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}
|
||||
<SelectableCard
|
||||
isSelected={selectedResource === 'users'}
|
||||
label={i18n._(t`Users`)}
|
||||
ariaLabel={i18n._(t`Users`)}
|
||||
dataCy="add-role-users"
|
||||
onClick={() => handleResourceSelect('users')}
|
||||
/>
|
||||
),
|
||||
nextButtonText: i18n._(t`Save`),
|
||||
enableNext: selectedRoleRows.length > 0,
|
||||
canJumpTo: maxEnabledStep >= 3,
|
||||
},
|
||||
];
|
||||
{resource?.type === 'credential' && !resource?.organization ? null : (
|
||||
<SelectableCard
|
||||
isSelected={selectedResource === 'teams'}
|
||||
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
|
||||
return (
|
||||
<Wizard
|
||||
style={{ overflow: 'scroll' }}
|
||||
isOpen
|
||||
onNext={this.handleWizardNext}
|
||||
onClose={onClose}
|
||||
onSave={this.handleWizardSave}
|
||||
onGoToStep={this.handleWizardGoToStep}
|
||||
steps={steps}
|
||||
title={wizardTitle}
|
||||
nextButtonText={currentStep.nextButtonText || undefined}
|
||||
backButtonText={i18n._(t`Back`)}
|
||||
cancelButtonText={i18n._(t`Cancel`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// TODO: somehow internationalize steps and currentStep.nextButtonText
|
||||
return (
|
||||
<Wizard
|
||||
style={{ overflow: 'scroll' }}
|
||||
isOpen
|
||||
onNext={handleWizardNext}
|
||||
onClose={onClose}
|
||||
onSave={handleWizardSave}
|
||||
onGoToStep={step => handleWizardGoToStep(step)}
|
||||
steps={steps}
|
||||
title={wizardTitle}
|
||||
nextButtonText={currentStep.nextButtonText || undefined}
|
||||
backButtonText={i18n._(t`Back`)}
|
||||
cancelButtonText={i18n._(t`Cancel`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
AddResourceRole.propTypes = {
|
||||
|
||||
@ -1,22 +1,46 @@
|
||||
/* eslint-disable react/jsx-pascal-case */
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../testUtils/enzymeHelpers';
|
||||
import AddResourceRole, { _AddResourceRole } from './AddResourceRole';
|
||||
import { TeamsAPI, UsersAPI } from '../../api';
|
||||
|
||||
jest.mock('../../api');
|
||||
jest.mock('../../api/models/Teams');
|
||||
jest.mock('../../api/models/Users');
|
||||
|
||||
// TODO: Once error handling is functional in
|
||||
// this component write tests for it
|
||||
|
||||
describe('<_AddResourceRole />', () => {
|
||||
UsersAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: 2,
|
||||
results: [
|
||||
{ id: 1, username: 'foo' },
|
||||
{ id: 2, username: 'bar' },
|
||||
{ id: 1, username: 'foo', url: '' },
|
||||
{ id: 2, username: 'bar', url: '' },
|
||||
],
|
||||
},
|
||||
});
|
||||
UsersAPI.readOptions.mockResolvedValue({
|
||||
data: { related: {}, actions: { GET: {} } },
|
||||
});
|
||||
TeamsAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: 2,
|
||||
results: [
|
||||
{ id: 1, name: 'Team foo', url: '' },
|
||||
{ id: 2, name: 'Team bar', url: '' },
|
||||
],
|
||||
},
|
||||
});
|
||||
TeamsAPI.readOptions.mockResolvedValue({
|
||||
data: { related: {}, actions: { GET: {} } },
|
||||
});
|
||||
const roles = {
|
||||
admin_role: {
|
||||
description: 'Can manage all aspects of the organization',
|
||||
@ -39,191 +63,165 @@ describe('<_AddResourceRole />', () => {
|
||||
/>
|
||||
);
|
||||
});
|
||||
test('handleRoleCheckboxClick properly updates state', () => {
|
||||
const wrapper = shallow(
|
||||
<_AddResourceRole
|
||||
onClose={() => {}}
|
||||
onSave={() => {}}
|
||||
roles={roles}
|
||||
i18n={{ _: val => val.toString() }}
|
||||
/>
|
||||
);
|
||||
wrapper.setState({
|
||||
selectedRoleRows: [
|
||||
{
|
||||
description: 'Can manage all aspects of the organization',
|
||||
name: 'Admin',
|
||||
id: 1,
|
||||
},
|
||||
],
|
||||
test('should save properly', async () => {
|
||||
let wrapper;
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
|
||||
{ context: { network: { handleHttpError: () => {} } } }
|
||||
);
|
||||
});
|
||||
wrapper.instance().handleRoleCheckboxClick({
|
||||
description: 'Can manage all aspects of the organization',
|
||||
name: 'Admin',
|
||||
id: 1,
|
||||
});
|
||||
expect(wrapper.state('selectedRoleRows')).toEqual([]);
|
||||
wrapper.instance().handleRoleCheckboxClick({
|
||||
description: 'Can manage all aspects of the organization',
|
||||
name: 'Admin',
|
||||
id: 1,
|
||||
});
|
||||
expect(wrapper.state('selectedRoleRows')).toEqual([
|
||||
{
|
||||
description: 'Can manage all aspects of the organization',
|
||||
name: 'Admin',
|
||||
id: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
test('handleResourceCheckboxClick properly updates state', () => {
|
||||
const wrapper = shallow(
|
||||
<_AddResourceRole
|
||||
onClose={() => {}}
|
||||
onSave={() => {}}
|
||||
roles={roles}
|
||||
i18n={{ _: val => val.toString() }}
|
||||
/>
|
||||
);
|
||||
wrapper.setState({
|
||||
selectedResourceRows: [
|
||||
{
|
||||
id: 1,
|
||||
username: 'foobar',
|
||||
},
|
||||
],
|
||||
});
|
||||
wrapper.instance().handleResourceCheckboxClick({
|
||||
id: 1,
|
||||
username: 'foobar',
|
||||
});
|
||||
expect(wrapper.state('selectedResourceRows')).toEqual([]);
|
||||
wrapper.instance().handleResourceCheckboxClick({
|
||||
id: 1,
|
||||
username: 'foobar',
|
||||
});
|
||||
expect(wrapper.state('selectedResourceRows')).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
username: 'foobar',
|
||||
},
|
||||
]);
|
||||
});
|
||||
test('clicking user/team cards updates state', () => {
|
||||
const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect');
|
||||
const wrapper = mountWithContexts(
|
||||
<AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
|
||||
{ context: { network: { handleHttpError: () => {} } } }
|
||||
).find('AddResourceRole');
|
||||
wrapper.update();
|
||||
|
||||
// Step 1
|
||||
const selectableCardWrapper = wrapper.find('SelectableCard');
|
||||
expect(selectableCardWrapper.length).toBe(2);
|
||||
selectableCardWrapper.first().simulate('click');
|
||||
expect(spy).toHaveBeenCalledWith('users');
|
||||
expect(wrapper.state('selectedResource')).toBe('users');
|
||||
selectableCardWrapper.at(1).simulate('click');
|
||||
expect(spy).toHaveBeenCalledWith('teams');
|
||||
expect(wrapper.state('selectedResource')).toBe('teams');
|
||||
});
|
||||
test('handleResourceSelect clears out selected lists and sets selectedResource', () => {
|
||||
const wrapper = shallow(
|
||||
<_AddResourceRole
|
||||
onClose={() => {}}
|
||||
onSave={() => {}}
|
||||
roles={roles}
|
||||
i18n={{ _: val => val.toString() }}
|
||||
/>
|
||||
act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')());
|
||||
wrapper.update();
|
||||
await act(async () =>
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||
);
|
||||
wrapper.setState({
|
||||
selectedResource: 'teams',
|
||||
selectedResourceRows: [
|
||||
{
|
||||
id: 1,
|
||||
username: 'foobar',
|
||||
},
|
||||
],
|
||||
selectedRoleRows: [
|
||||
{
|
||||
description: 'Can manage all aspects of the organization',
|
||||
id: 1,
|
||||
name: 'Admin',
|
||||
},
|
||||
],
|
||||
});
|
||||
wrapper.instance().handleResourceSelect('users');
|
||||
expect(wrapper.state()).toEqual({
|
||||
selectedResource: 'users',
|
||||
selectedResourceRows: [],
|
||||
selectedRoleRows: [],
|
||||
currentStepId: 1,
|
||||
maxEnabledStep: 1,
|
||||
});
|
||||
wrapper.instance().handleResourceSelect('teams');
|
||||
expect(wrapper.state()).toEqual({
|
||||
selectedResource: 'teams',
|
||||
selectedResourceRows: [],
|
||||
selectedRoleRows: [],
|
||||
currentStepId: 1,
|
||||
maxEnabledStep: 1,
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
// Step 2
|
||||
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
||||
act(() =>
|
||||
wrapper.find('DataListCheck[name="foo"]').invoke('onChange')(true)
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('DataListCheck[name="foo"]').prop('checked')).toBe(
|
||||
true
|
||||
);
|
||||
act(() => wrapper.find('Button[type="submit"]').prop('onClick')());
|
||||
wrapper.update();
|
||||
|
||||
// Step 3
|
||||
act(() =>
|
||||
wrapper.find('Checkbox[aria-label="Admin"]').invoke('onChange')(true)
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Checkbox[aria-label="Admin"]').prop('isChecked')).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
// Save
|
||||
await act(async () =>
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||
);
|
||||
expect(UsersAPI.associateRole).toBeCalledWith(1, 1);
|
||||
});
|
||||
test('handleWizardSave makes correct api calls, calls onSave when done', async () => {
|
||||
const handleSave = jest.fn();
|
||||
const wrapper = mountWithContexts(
|
||||
<AddResourceRole onClose={() => {}} onSave={handleSave} roles={roles} />,
|
||||
{ context: { network: { handleHttpError: () => {} } } }
|
||||
).find('AddResourceRole');
|
||||
wrapper.setState({
|
||||
selectedResource: 'users',
|
||||
selectedResourceRows: [
|
||||
{
|
||||
id: 1,
|
||||
username: 'foobar',
|
||||
},
|
||||
],
|
||||
selectedRoleRows: [
|
||||
{
|
||||
description: 'Can manage all aspects of the organization',
|
||||
id: 1,
|
||||
name: 'Admin',
|
||||
},
|
||||
{
|
||||
description: 'May run any executable resources in the organization',
|
||||
id: 2,
|
||||
name: 'Execute',
|
||||
},
|
||||
],
|
||||
|
||||
test('should successfuly click user/team cards', async () => {
|
||||
let wrapper;
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
|
||||
{ context: { network: { handleHttpError: () => {} } } }
|
||||
);
|
||||
});
|
||||
await wrapper.instance().handleWizardSave();
|
||||
expect(UsersAPI.associateRole).toHaveBeenCalledTimes(2);
|
||||
expect(handleSave).toHaveBeenCalled();
|
||||
wrapper.setState({
|
||||
selectedResource: 'teams',
|
||||
selectedResourceRows: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'foobar',
|
||||
},
|
||||
],
|
||||
selectedRoleRows: [
|
||||
{
|
||||
description: 'Can manage all aspects of the organization',
|
||||
id: 1,
|
||||
name: 'Admin',
|
||||
},
|
||||
{
|
||||
description: 'May run any executable resources in the organization',
|
||||
id: 2,
|
||||
name: 'Execute',
|
||||
},
|
||||
],
|
||||
wrapper.update();
|
||||
|
||||
const selectableCardWrapper = wrapper.find('SelectableCard');
|
||||
expect(selectableCardWrapper.length).toBe(2);
|
||||
act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')());
|
||||
wrapper.update();
|
||||
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'SelectableCard[label="Users"]',
|
||||
el => el.prop('isSelected') === true
|
||||
);
|
||||
act(() => wrapper.find('SelectableCard[label="Teams"]').prop('onClick')());
|
||||
wrapper.update();
|
||||
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'SelectableCard[label="Teams"]',
|
||||
el => el.prop('isSelected') === true
|
||||
);
|
||||
});
|
||||
|
||||
test('should reset values with resource type changes', async () => {
|
||||
let wrapper;
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
|
||||
{ context: { network: { handleHttpError: () => {} } } }
|
||||
);
|
||||
});
|
||||
await wrapper.instance().handleWizardSave();
|
||||
expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2);
|
||||
expect(handleSave).toHaveBeenCalled();
|
||||
wrapper.update();
|
||||
|
||||
// Step 1
|
||||
const selectableCardWrapper = wrapper.find('SelectableCard');
|
||||
expect(selectableCardWrapper.length).toBe(2);
|
||||
act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')());
|
||||
wrapper.update();
|
||||
await act(async () =>
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
|
||||
// Step 2
|
||||
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
||||
act(() =>
|
||||
wrapper.find('DataListCheck[name="foo"]').invoke('onChange')(true)
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('DataListCheck[name="foo"]').prop('checked')).toBe(
|
||||
true
|
||||
);
|
||||
act(() => wrapper.find('Button[type="submit"]').prop('onClick')());
|
||||
wrapper.update();
|
||||
|
||||
// Step 3
|
||||
act(() =>
|
||||
wrapper.find('Checkbox[aria-label="Admin"]').invoke('onChange')(true)
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Checkbox[aria-label="Admin"]').prop('isChecked')).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
// Go back to step 1
|
||||
act(() => {
|
||||
wrapper
|
||||
.find('WizardNavItem[content="Select a Resource Type"]')
|
||||
.find('button')
|
||||
.prop('onClick')({ id: 1 });
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper
|
||||
.find('WizardNavItem[content="Select a Resource Type"]')
|
||||
.prop('isCurrent')
|
||||
).toBe(true);
|
||||
|
||||
// Go back to step 1 and this time select teams. Doing so should clear following steps
|
||||
act(() => wrapper.find('SelectableCard[label="Teams"]').prop('onClick')());
|
||||
wrapper.update();
|
||||
await act(async () =>
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
|
||||
// Make sure no teams have been selected
|
||||
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
||||
wrapper
|
||||
.find('DataListCheck')
|
||||
.map(item => expect(item.prop('checked')).toBe(false));
|
||||
act(() => wrapper.find('Button[type="submit"]').prop('onClick')());
|
||||
wrapper.update();
|
||||
|
||||
// Make sure that no roles have been selected
|
||||
wrapper
|
||||
.find('Checkbox')
|
||||
.map(card => expect(card.prop('isChecked')).toBe(false));
|
||||
|
||||
// Make sure the save button is disabled
|
||||
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
|
||||
});
|
||||
|
||||
test('should not display team as a choice in case credential does not have organization', () => {
|
||||
const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect');
|
||||
const wrapper = mountWithContexts(
|
||||
<AddResourceRole
|
||||
onClose={() => {}}
|
||||
@ -232,11 +230,13 @@ describe('<_AddResourceRole />', () => {
|
||||
resource={{ type: 'credential', organization: null }}
|
||||
/>,
|
||||
{ context: { network: { handleHttpError: () => {} } } }
|
||||
).find('AddResourceRole');
|
||||
const selectableCardWrapper = wrapper.find('SelectableCard');
|
||||
expect(selectableCardWrapper.length).toBe(1);
|
||||
selectableCardWrapper.first().simulate('click');
|
||||
expect(spy).toHaveBeenCalledWith('users');
|
||||
expect(wrapper.state('selectedResource')).toBe('users');
|
||||
);
|
||||
|
||||
expect(wrapper.find('SelectableCard').length).toBe(1);
|
||||
wrapper.find('SelectableCard[label="Users"]').simulate('click');
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('SelectableCard[label="Users"]').prop('isSelected')
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,59 +7,55 @@ import { t } from '@lingui/macro';
|
||||
import CheckboxCard from './CheckboxCard';
|
||||
import SelectedList from '../SelectedList';
|
||||
|
||||
class RolesStep extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
onRolesClick,
|
||||
roles,
|
||||
selectedListKey,
|
||||
selectedListLabel,
|
||||
selectedResourceRows,
|
||||
selectedRoleRows,
|
||||
i18n,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div>
|
||||
{i18n._(
|
||||
t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.`
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{selectedResourceRows.length > 0 && (
|
||||
<SelectedList
|
||||
displayKey={selectedListKey}
|
||||
isReadOnly
|
||||
label={selectedListLabel || i18n._(t`Selected`)}
|
||||
selected={selectedResourceRows}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '20px 20px',
|
||||
marginTop: '20px',
|
||||
}}
|
||||
>
|
||||
{Object.keys(roles).map(role => (
|
||||
<CheckboxCard
|
||||
description={roles[role].description}
|
||||
itemId={roles[role].id}
|
||||
isSelected={selectedRoleRows.some(
|
||||
item => item.id === roles[role].id
|
||||
)}
|
||||
key={roles[role].id}
|
||||
name={roles[role].name}
|
||||
onSelect={() => onRolesClick(roles[role])}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
function RolesStep({
|
||||
onRolesClick,
|
||||
roles,
|
||||
selectedListKey,
|
||||
selectedListLabel,
|
||||
selectedResourceRows,
|
||||
selectedRoleRows,
|
||||
i18n,
|
||||
}) {
|
||||
return (
|
||||
<Fragment>
|
||||
<div>
|
||||
{i18n._(
|
||||
t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.`
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{selectedResourceRows.length > 0 && (
|
||||
<SelectedList
|
||||
displayKey={selectedListKey}
|
||||
isReadOnly
|
||||
label={selectedListLabel || i18n._(t`Selected`)}
|
||||
selected={selectedResourceRows}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '20px 20px',
|
||||
marginTop: '20px',
|
||||
}}
|
||||
>
|
||||
{Object.keys(roles).map(role => (
|
||||
<CheckboxCard
|
||||
description={roles[role].description}
|
||||
itemId={roles[role].id}
|
||||
isSelected={selectedRoleRows.some(
|
||||
item => item.id === roles[role].id
|
||||
)}
|
||||
key={roles[role].id}
|
||||
name={roles[role].name}
|
||||
onSelect={() => onRolesClick(roles[role])}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
RolesStep.propTypes = {
|
||||
|
||||
@ -12,52 +12,44 @@ import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { FormSelect, FormSelectOption } from '@patternfly/react-core';
|
||||
|
||||
class AnsibleSelect extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onSelectChange = this.onSelectChange.bind(this);
|
||||
}
|
||||
|
||||
onSelectChange(val, event) {
|
||||
const { onChange, name } = this.props;
|
||||
function AnsibleSelect({
|
||||
id,
|
||||
data,
|
||||
i18n,
|
||||
isValid,
|
||||
onBlur,
|
||||
value,
|
||||
className,
|
||||
isDisabled,
|
||||
onChange,
|
||||
name,
|
||||
}) {
|
||||
const onSelectChange = (val, event) => {
|
||||
event.target.name = name;
|
||||
onChange(event, val);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
data,
|
||||
i18n,
|
||||
isValid,
|
||||
onBlur,
|
||||
value,
|
||||
className,
|
||||
isDisabled,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<FormSelect
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={this.onSelectChange}
|
||||
onBlur={onBlur}
|
||||
aria-label={i18n._(t`Select Input`)}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
className={className}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{data.map(option => (
|
||||
<FormSelectOption
|
||||
key={option.key}
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
isDisabled={option.isDisabled}
|
||||
/>
|
||||
))}
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormSelect
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onSelectChange}
|
||||
onBlur={onBlur}
|
||||
aria-label={i18n._(t`Select Input`)}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
className={className}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{data.map(option => (
|
||||
<FormSelectOption
|
||||
key={option.key}
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
isDisabled={option.isDisabled}
|
||||
/>
|
||||
))}
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
|
||||
const Option = shape({
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import AnsibleSelect, { _AnsibleSelect } from './AnsibleSelect';
|
||||
import AnsibleSelect from './AnsibleSelect';
|
||||
|
||||
const mockData = [
|
||||
{
|
||||
@ -16,6 +16,7 @@ const mockData = [
|
||||
];
|
||||
|
||||
describe('<AnsibleSelect />', () => {
|
||||
const onChange = jest.fn();
|
||||
test('initially renders succesfully', async () => {
|
||||
mountWithContexts(
|
||||
<AnsibleSelect
|
||||
@ -29,19 +30,18 @@ describe('<AnsibleSelect />', () => {
|
||||
});
|
||||
|
||||
test('calls "onSelectChange" on dropdown select change', () => {
|
||||
const spy = jest.spyOn(_AnsibleSelect.prototype, 'onSelectChange');
|
||||
const wrapper = mountWithContexts(
|
||||
<AnsibleSelect
|
||||
id="bar"
|
||||
value="foo"
|
||||
name="bar"
|
||||
onChange={() => {}}
|
||||
onChange={onChange}
|
||||
data={mockData}
|
||||
/>
|
||||
);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
wrapper.find('select').simulate('change');
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Returns correct select options', () => {
|
||||
|
||||
@ -34,7 +34,6 @@ function PageHeaderToolbar({
|
||||
const handleUserSelect = () => {
|
||||
setIsUserOpen(!isUserOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeaderTools>
|
||||
<PageHeaderToolsGroup>
|
||||
@ -90,8 +89,11 @@ function PageHeaderToolbar({
|
||||
dropdownItems={[
|
||||
<DropdownItem
|
||||
key="user"
|
||||
aria-label={i18n._(t`User details`)}
|
||||
href={
|
||||
loggedInUser ? `/users/${loggedInUser.id}/details` : '/home'
|
||||
loggedInUser
|
||||
? `/#/users/${loggedInUser.id}/details`
|
||||
: '/#/home'
|
||||
}
|
||||
>
|
||||
{i18n._(t`User Details`)}
|
||||
|
||||
@ -25,6 +25,7 @@ describe('PageHeaderToolbar', () => {
|
||||
<PageHeaderToolbar
|
||||
onAboutClick={onAboutClick}
|
||||
onLogoutClick={onLogoutClick}
|
||||
loggedInUser={{ id: 1 }}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('DropdownItem')).toHaveLength(0);
|
||||
@ -37,6 +38,10 @@ describe('PageHeaderToolbar', () => {
|
||||
|
||||
expect(wrapper.find('DropdownItem')).toHaveLength(0);
|
||||
wrapper.find(pageUserDropdownSelector).simulate('click');
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('DropdownItem[aria-label="User details"]').prop('href')
|
||||
).toBe('/#/users/1/details');
|
||||
expect(wrapper.find('DropdownItem')).toHaveLength(2);
|
||||
|
||||
const logout = wrapper.find('DropdownItem li button');
|
||||
|
||||
@ -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;
|
||||
@ -1 +0,0 @@
|
||||
export { default } from './Breadcrumbs';
|
||||
51
awx/ui_next/src/components/DetailList/LaunchedByDetail.jsx
Normal file
51
awx/ui_next/src/components/DetailList/LaunchedByDetail.jsx
Normal 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
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,7 @@ export { default as DeletedDetail } from './DeletedDetail';
|
||||
export { default as UserDateDetail } from './UserDateDetail';
|
||||
export { default as DetailBadge } from './DetailBadge';
|
||||
export { default as ArrayDetail } from './ArrayDetail';
|
||||
export { default as LaunchedByDetail } from './LaunchedByDetail';
|
||||
/*
|
||||
NOTE: CodeDetail cannot be imported here, as it causes circular
|
||||
dependencies in testing environment. Import it directly from
|
||||
|
||||
@ -31,35 +31,31 @@ const ToolbarItem = styled(PFToolbarItem)`
|
||||
|
||||
// TODO: Recommend renaming this component to avoid confusion
|
||||
// with ExpandingContainer
|
||||
class ExpandCollapse extends React.Component {
|
||||
render() {
|
||||
const { isCompact, onCompact, onExpand, i18n } = this.props;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ToolbarItem>
|
||||
<Button
|
||||
variant="plain"
|
||||
aria-label={i18n._(t`Collapse`)}
|
||||
onClick={onCompact}
|
||||
isActive={isCompact}
|
||||
>
|
||||
<BarsIcon />
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Button
|
||||
variant="plain"
|
||||
aria-label={i18n._(t`Expand`)}
|
||||
onClick={onExpand}
|
||||
isActive={!isCompact}
|
||||
>
|
||||
<EqualsIcon />
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
function ExpandCollapse({ isCompact, onCompact, onExpand, i18n }) {
|
||||
return (
|
||||
<Fragment>
|
||||
<ToolbarItem>
|
||||
<Button
|
||||
variant="plain"
|
||||
aria-label={i18n._(t`Collapse`)}
|
||||
onClick={onCompact}
|
||||
isActive={isCompact}
|
||||
>
|
||||
<BarsIcon />
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Button
|
||||
variant="plain"
|
||||
aria-label={i18n._(t`Expand`)}
|
||||
onClick={onExpand}
|
||||
isActive={!isCompact}
|
||||
>
|
||||
<EqualsIcon />
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
ExpandCollapse.propTypes = {
|
||||
|
||||
@ -12,7 +12,15 @@ import {
|
||||
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
|
||||
|
||||
function PasswordInput(props) {
|
||||
const { id, name, validate, isRequired, isDisabled, i18n } = props;
|
||||
const {
|
||||
autocomplete,
|
||||
id,
|
||||
name,
|
||||
validate,
|
||||
isRequired,
|
||||
isDisabled,
|
||||
i18n,
|
||||
} = props;
|
||||
const [inputType, setInputType] = useState('password');
|
||||
const [field, meta] = useField({ name, validate });
|
||||
|
||||
@ -38,6 +46,7 @@ function PasswordInput(props) {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TextInput
|
||||
autoComplete={autocomplete}
|
||||
id={id}
|
||||
placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined}
|
||||
{...field}
|
||||
@ -55,6 +64,7 @@ function PasswordInput(props) {
|
||||
}
|
||||
|
||||
PasswordInput.propTypes = {
|
||||
autocomplete: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
validate: PropTypes.func,
|
||||
@ -63,6 +73,7 @@ PasswordInput.propTypes = {
|
||||
};
|
||||
|
||||
PasswordInput.defaultProps = {
|
||||
autocomplete: 'new-password',
|
||||
validate: () => {},
|
||||
isRequired: false,
|
||||
isDisabled: false,
|
||||
|
||||
@ -7,7 +7,8 @@ import { Card } from '@patternfly/react-core';
|
||||
import AlertModal from '../AlertModal';
|
||||
import DatalistToolbar from '../DataListToolbar';
|
||||
import ErrorDetail from '../ErrorDetail';
|
||||
import PaginatedDataList, { ToolbarDeleteButton } from '../PaginatedDataList';
|
||||
import { ToolbarDeleteButton } from '../PaginatedDataList';
|
||||
import PaginatedTable, { HeaderRow, HeaderCell } from '../PaginatedTable';
|
||||
import useRequest, {
|
||||
useDeleteItems,
|
||||
useDismissableError,
|
||||
@ -27,7 +28,7 @@ import {
|
||||
} from '../../api';
|
||||
|
||||
function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
const QS_CONFIG = getQSConfig(
|
||||
const qsConfig = getQSConfig(
|
||||
'job',
|
||||
{
|
||||
page: 1,
|
||||
@ -49,7 +50,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
} = useRequest(
|
||||
useCallback(
|
||||
async () => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const params = parseQueryString(qsConfig, location.search);
|
||||
const [response, actionsResponse] = await Promise.all([
|
||||
UnifiedJobsAPI.read({ ...params }),
|
||||
UnifiedJobsAPI.readOptions(),
|
||||
@ -81,7 +82,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
// TODO: update QS_CONFIG to be safe for deps array
|
||||
const fetchJobsById = useCallback(
|
||||
async ids => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const params = parseQueryString(qsConfig, location.search);
|
||||
params.id__in = ids.join(',');
|
||||
const { data } = await UnifiedJobsAPI.read(params);
|
||||
return data.results;
|
||||
@ -89,7 +90,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
[location.search] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
const jobs = useWsJobs(results, fetchJobsById, QS_CONFIG);
|
||||
const jobs = useWsJobs(results, fetchJobsById, qsConfig);
|
||||
|
||||
const isAllSelected = selected.length === jobs.length && selected.length > 0;
|
||||
|
||||
@ -145,7 +146,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
);
|
||||
}, [selected]),
|
||||
{
|
||||
qsConfig: QS_CONFIG,
|
||||
qsConfig,
|
||||
allItemsSelected: isAllSelected,
|
||||
fetchItems: fetchJobs,
|
||||
}
|
||||
@ -176,14 +177,13 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || isDeleteLoading || isCancelLoading}
|
||||
items={jobs}
|
||||
itemCount={count}
|
||||
pluralizedItemName={i18n._(t`Jobs`)}
|
||||
qsConfig={QS_CONFIG}
|
||||
onRowClick={handleSelect}
|
||||
qsConfig={qsConfig}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
@ -233,32 +233,17 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
key: 'job__limit',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Finish Time`),
|
||||
key: 'finished',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`ID`),
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
]}
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={qsConfig} isExpandable>
|
||||
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||
<HeaderCell sortKey="status">{i18n._(t`Status`)}</HeaderCell>
|
||||
{showTypeColumn && <HeaderCell>{i18n._(t`Type`)}</HeaderCell>}
|
||||
<HeaderCell sortKey="started">{i18n._(t`Start Time`)}</HeaderCell>
|
||||
<HeaderCell sortKey="finished">
|
||||
{i18n._(t`Finish Time`)}
|
||||
</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
renderToolbar={props => (
|
||||
@ -267,7 +252,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
showSelectAll
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={handleSelectAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
qsConfig={qsConfig}
|
||||
additionalControls={[
|
||||
<ToolbarDeleteButton
|
||||
key="delete"
|
||||
@ -283,13 +268,14 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderItem={job => (
|
||||
renderRow={(job, index) => (
|
||||
<JobListItem
|
||||
key={job.id}
|
||||
job={job}
|
||||
showTypeColumn={showTypeColumn}
|
||||
onSelect={() => handleSelect(job)}
|
||||
isSelected={selected.some(row => row.id === job.id)}
|
||||
rowIndex={index}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -1,39 +1,29 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Button,
|
||||
DataListAction as _DataListAction,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemRow,
|
||||
DataListItemCells,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { Button, Chip } from '@patternfly/react-core';
|
||||
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
|
||||
import { RocketIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
import DataListCell from '../DataListCell';
|
||||
import { ActionsTd, ActionItem } from '../PaginatedTable';
|
||||
import LaunchButton from '../LaunchButton';
|
||||
import StatusIcon from '../StatusIcon';
|
||||
import StatusLabel from '../StatusLabel';
|
||||
import { DetailList, Detail, LaunchedByDetail } from '../DetailList';
|
||||
import ChipGroup from '../ChipGroup';
|
||||
import CredentialChip from '../CredentialChip';
|
||||
import { formatDateString } from '../../util/dates';
|
||||
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
|
||||
|
||||
const DataListAction = styled(_DataListAction)`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: 40px;
|
||||
`;
|
||||
|
||||
function JobListItem({
|
||||
i18n,
|
||||
job,
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
showTypeColumn = false,
|
||||
}) {
|
||||
const labelId = `check-action-${job.id}`;
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const jobTypes = {
|
||||
project_update: i18n._(t`Source Control Update`),
|
||||
@ -44,67 +34,123 @@ function JobListItem({
|
||||
workflow_job: i18n._(t`Workflow Job`),
|
||||
};
|
||||
|
||||
const { credentials, inventory, labels } = job.summary_fields;
|
||||
|
||||
return (
|
||||
<DataListItem aria-labelledby={labelId} id={`${job.id}`}>
|
||||
<DataListItemRow>
|
||||
<DataListCheck
|
||||
id={`select-job-${job.id}`}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
aria-labelledby={labelId}
|
||||
<>
|
||||
<Tr id={`job-row-${job.id}`}>
|
||||
<Td
|
||||
expand={{
|
||||
rowIndex: job.id,
|
||||
isExpanded,
|
||||
onToggle: () => setIsExpanded(!isExpanded),
|
||||
}}
|
||||
/>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="status" isFilled={false}>
|
||||
{job.status && <StatusIcon status={job.status} />}
|
||||
</DataListCell>,
|
||||
<DataListCell key="name">
|
||||
<span>
|
||||
<Link to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}>
|
||||
<b>
|
||||
{job.id} — {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>,
|
||||
]}
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
dataLabel={i18n._(t`Select`)}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
{job.type !== 'system_job' &&
|
||||
job.summary_fields?.user_capabilities?.start ? (
|
||||
<Tooltip content={i18n._(t`Relaunch Job`)} position="top">
|
||||
<LaunchButton resource={job}>
|
||||
{({ handleRelaunch }) => (
|
||||
<Button
|
||||
variant="plain"
|
||||
onClick={handleRelaunch}
|
||||
aria-label={i18n._(t`Relaunch`)}
|
||||
>
|
||||
<RocketIcon />
|
||||
</Button>
|
||||
)}
|
||||
</LaunchButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||
<span>
|
||||
<Link to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}>
|
||||
<b>
|
||||
{job.id} — {job.name}
|
||||
</b>
|
||||
</Link>
|
||||
</span>
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Status`)}>
|
||||
{job.status && <StatusLabel status={job.status} />}
|
||||
</Td>
|
||||
{showTypeColumn && (
|
||||
<Td dataLabel={i18n._(t`Type`)}>{jobTypes[job.type]}</Td>
|
||||
)}
|
||||
<Td dataLabel={i18n._(t`Start Time`)}>
|
||||
{formatDateString(job.started)}
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Finish Time`)}>
|
||||
{job.finished ? formatDateString(job.finished) : ''}
|
||||
</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||
<ActionItem
|
||||
visible={
|
||||
job.type !== 'system_job' &&
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -32,7 +32,11 @@ describe('<JobListItem />', () => {
|
||||
initialEntries: ['/jobs'],
|
||||
});
|
||||
wrapper = mountWithContexts(
|
||||
<JobListItem job={mockJob} isSelected onSelect={() => {}} />,
|
||||
<table>
|
||||
<tbody>
|
||||
<JobListItem job={mockJob} isSelected onSelect={() => {}} />
|
||||
</tbody>
|
||||
</table>,
|
||||
{ context: { router: { history } } }
|
||||
);
|
||||
});
|
||||
@ -51,32 +55,40 @@ describe('<JobListItem />', () => {
|
||||
|
||||
test('launch button hidden from users without launch capabilities', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<JobListItem
|
||||
job={{
|
||||
...mockJob,
|
||||
summary_fields: { user_capabilities: { start: false } },
|
||||
}}
|
||||
detailUrl={`/jobs/playbook/${mockJob.id}`}
|
||||
onSelect={() => {}}
|
||||
isSelected={false}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<JobListItem
|
||||
job={{
|
||||
...mockJob,
|
||||
summary_fields: { user_capabilities: { start: false } },
|
||||
}}
|
||||
detailUrl={`/jobs/playbook/${mockJob.id}`}
|
||||
onSelect={() => {}}
|
||||
isSelected={false}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('LaunchButton').length).toBe(0);
|
||||
});
|
||||
|
||||
test('should hide type column when showTypeColumn is false', () => {
|
||||
expect(wrapper.find('DataListCell[aria-label="type"]').length).toBe(0);
|
||||
expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(0);
|
||||
});
|
||||
|
||||
test('should show type column when showTypeColumn is true', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<JobListItem
|
||||
job={mockJob}
|
||||
showTypeColumn
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<JobListItem
|
||||
job={mockJob}
|
||||
showTypeColumn
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('DataListCell[aria-label="type"]').length).toBe(1);
|
||||
expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -25,6 +25,8 @@ function canLaunchWithoutPrompt(launchData) {
|
||||
!launchData.ask_limit_on_launch &&
|
||||
!launchData.ask_scm_branch_on_launch &&
|
||||
!launchData.survey_enabled &&
|
||||
(!launchData.passwords_needed_to_start ||
|
||||
launchData.passwords_needed_to_start.length === 0) &&
|
||||
(!launchData.variables_needed_to_start ||
|
||||
launchData.variables_needed_to_start.length === 0)
|
||||
);
|
||||
@ -100,17 +102,20 @@ class LaunchButton extends React.Component {
|
||||
async launchWithParams(params) {
|
||||
try {
|
||||
const { history, resource } = this.props;
|
||||
const jobPromise =
|
||||
resource.type === 'workflow_job_template'
|
||||
? WorkflowJobTemplatesAPI.launch(resource.id, params || {})
|
||||
: JobTemplatesAPI.launch(resource.id, params || {});
|
||||
let jobPromise;
|
||||
|
||||
if (resource.type === 'job_template') {
|
||||
jobPromise = JobTemplatesAPI.launch(resource.id, params || {});
|
||||
} else if (resource.type === 'workflow_job_template') {
|
||||
jobPromise = WorkflowJobTemplatesAPI.launch(resource.id, params || {});
|
||||
} else if (resource.type === 'job') {
|
||||
jobPromise = JobsAPI.relaunch(resource.id, params || {});
|
||||
} else if (resource.type === 'workflow_job') {
|
||||
jobPromise = WorkflowJobsAPI.relaunch(resource.id, params || {});
|
||||
}
|
||||
|
||||
const { data: job } = await jobPromise;
|
||||
history.push(
|
||||
`/${
|
||||
resource.type === 'workflow_job_template' ? 'jobs/workflow' : 'jobs'
|
||||
}/${job.id}/output`
|
||||
);
|
||||
history.push(`/jobs/${job.id}/output`);
|
||||
} catch (launchError) {
|
||||
this.setState({ launchError });
|
||||
}
|
||||
@ -127,20 +132,15 @@ class LaunchButton extends React.Component {
|
||||
readRelaunch = InventorySourcesAPI.readLaunchUpdate(
|
||||
resource.inventory_source
|
||||
);
|
||||
relaunch = InventorySourcesAPI.launchUpdate(resource.inventory_source);
|
||||
} else if (resource.type === 'project_update') {
|
||||
// We'll need to handle the scenario where the project no longer exists
|
||||
readRelaunch = ProjectsAPI.readLaunchUpdate(resource.project);
|
||||
relaunch = ProjectsAPI.launchUpdate(resource.project);
|
||||
} else if (resource.type === 'workflow_job') {
|
||||
readRelaunch = WorkflowJobsAPI.readRelaunch(resource.id);
|
||||
relaunch = WorkflowJobsAPI.relaunch(resource.id);
|
||||
} else if (resource.type === 'ad_hoc_command') {
|
||||
readRelaunch = AdHocCommandsAPI.readRelaunch(resource.id);
|
||||
relaunch = AdHocCommandsAPI.relaunch(resource.id);
|
||||
} else if (resource.type === 'job') {
|
||||
readRelaunch = JobsAPI.readRelaunch(resource.id);
|
||||
relaunch = JobsAPI.relaunch(resource.id);
|
||||
}
|
||||
|
||||
try {
|
||||
@ -149,11 +149,22 @@ class LaunchButton extends React.Component {
|
||||
!relaunchConfig.passwords_needed_to_start ||
|
||||
relaunchConfig.passwords_needed_to_start.length === 0
|
||||
) {
|
||||
if (resource.type === 'inventory_update') {
|
||||
relaunch = InventorySourcesAPI.launchUpdate(
|
||||
resource.inventory_source
|
||||
);
|
||||
} else if (resource.type === 'project_update') {
|
||||
relaunch = ProjectsAPI.launchUpdate(resource.project);
|
||||
} else if (resource.type === 'workflow_job') {
|
||||
relaunch = WorkflowJobsAPI.relaunch(resource.id);
|
||||
} else if (resource.type === 'ad_hoc_command') {
|
||||
relaunch = AdHocCommandsAPI.relaunch(resource.id);
|
||||
} else if (resource.type === 'job') {
|
||||
relaunch = JobsAPI.relaunch(resource.id);
|
||||
}
|
||||
const { data: job } = await relaunch;
|
||||
history.push(`/jobs/${job.id}/output`);
|
||||
} else {
|
||||
// TODO: restructure (async?) to send launch command after prompts
|
||||
// TODO: does relaunch need different prompt treatment than launch?
|
||||
this.setState({
|
||||
showLaunchPrompt: true,
|
||||
launchConfig: relaunchConfig,
|
||||
|
||||
@ -4,10 +4,16 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import { sleep } from '../../../testUtils/testUtils';
|
||||
|
||||
import LaunchButton from './LaunchButton';
|
||||
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api';
|
||||
import {
|
||||
InventorySourcesAPI,
|
||||
JobsAPI,
|
||||
JobTemplatesAPI,
|
||||
ProjectsAPI,
|
||||
WorkflowJobsAPI,
|
||||
WorkflowJobTemplatesAPI,
|
||||
} from '../../api';
|
||||
|
||||
jest.mock('../../api/models/WorkflowJobTemplates');
|
||||
jest.mock('../../api/models/JobTemplates');
|
||||
jest.mock('../../api');
|
||||
|
||||
describe('LaunchButton', () => {
|
||||
JobTemplatesAPI.readLaunch.mockResolvedValue({
|
||||
@ -22,10 +28,14 @@ describe('LaunchButton', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const children = ({ handleLaunch }) => (
|
||||
const launchButton = ({ handleLaunch }) => (
|
||||
<button type="submit" onClick={() => handleLaunch()} />
|
||||
);
|
||||
|
||||
const relaunchButton = ({ handleRelaunch }) => (
|
||||
<button type="submit" onClick={() => handleRelaunch()} />
|
||||
);
|
||||
|
||||
const resource = {
|
||||
id: 1,
|
||||
type: 'job_template',
|
||||
@ -35,7 +45,7 @@ describe('LaunchButton', () => {
|
||||
|
||||
test('renders the expected content', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<LaunchButton resource={resource}>{children}</LaunchButton>
|
||||
<LaunchButton resource={resource}>{launchButton}</LaunchButton>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
@ -51,7 +61,7 @@ describe('LaunchButton', () => {
|
||||
},
|
||||
});
|
||||
const wrapper = mountWithContexts(
|
||||
<LaunchButton resource={resource}>{children}</LaunchButton>,
|
||||
<LaunchButton resource={resource}>{launchButton}</LaunchButton>,
|
||||
{
|
||||
context: {
|
||||
router: { history },
|
||||
@ -87,7 +97,7 @@ describe('LaunchButton', () => {
|
||||
type: 'workflow_job_template',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{launchButton}
|
||||
</LaunchButton>,
|
||||
{
|
||||
context: {
|
||||
@ -100,12 +110,162 @@ describe('LaunchButton', () => {
|
||||
expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
|
||||
await sleep(0);
|
||||
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 () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<LaunchButton resource={resource}>{children}</LaunchButton>
|
||||
<LaunchButton resource={resource}>{launchButton}</LaunchButton>
|
||||
);
|
||||
JobTemplatesAPI.launch.mockRejectedValue(
|
||||
new Error({
|
||||
|
||||
@ -19,17 +19,18 @@ function PromptModalForm({
|
||||
resource,
|
||||
surveyConfig,
|
||||
}) {
|
||||
const { values, setTouched, validateForm } = useFormikContext();
|
||||
const { setFieldTouched, values } = useFormikContext();
|
||||
|
||||
const {
|
||||
steps,
|
||||
isReady,
|
||||
validateStep,
|
||||
visitStep,
|
||||
visitAllSteps,
|
||||
contentError,
|
||||
} = useLaunchSteps(launchConfig, surveyConfig, resource, i18n);
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSubmit = () => {
|
||||
const postValues = {};
|
||||
const setValue = (key, value) => {
|
||||
if (typeof value !== 'undefined' && value !== null) {
|
||||
@ -37,6 +38,7 @@ function PromptModalForm({
|
||||
}
|
||||
};
|
||||
const surveyValues = getSurveyValues(values);
|
||||
setValue('credential_passwords', values.credential_passwords);
|
||||
setValue('inventory_id', values.inventory?.id);
|
||||
setValue(
|
||||
'credentials',
|
||||
@ -75,22 +77,25 @@ function PromptModalForm({
|
||||
<Wizard
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
onSave={handleSave}
|
||||
onSave={handleSubmit}
|
||||
onBack={async nextStep => {
|
||||
validateStep(nextStep.id);
|
||||
}}
|
||||
onNext={async (nextStep, prevStep) => {
|
||||
if (nextStep.id === 'preview') {
|
||||
visitAllSteps(setTouched);
|
||||
visitAllSteps(setFieldTouched);
|
||||
} else {
|
||||
visitStep(prevStep.prevId);
|
||||
visitStep(prevStep.prevId, setFieldTouched);
|
||||
validateStep(nextStep.id);
|
||||
}
|
||||
await validateForm();
|
||||
}}
|
||||
onGoToStep={async (nextStep, prevStep) => {
|
||||
if (nextStep.id === 'preview') {
|
||||
visitAllSteps(setTouched);
|
||||
visitAllSteps(setFieldTouched);
|
||||
} else {
|
||||
visitStep(prevStep.prevId);
|
||||
visitStep(prevStep.prevId, setFieldTouched);
|
||||
validateStep(nextStep.id);
|
||||
}
|
||||
await validateForm();
|
||||
}}
|
||||
title={i18n._(t`Prompts`)}
|
||||
steps={
|
||||
|
||||
@ -82,8 +82,26 @@ describe('LaunchPrompt', () => {
|
||||
ask_credential_on_launch: true,
|
||||
ask_scm_branch_on_launch: 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}
|
||||
onCancel={noop}
|
||||
surveyConfig={{
|
||||
@ -110,12 +128,13 @@ describe('LaunchPrompt', () => {
|
||||
const wizard = await waitForElement(wrapper, 'Wizard');
|
||||
const steps = wizard.prop('steps');
|
||||
|
||||
expect(steps).toHaveLength(5);
|
||||
expect(steps).toHaveLength(6);
|
||||
expect(steps[0].name.props.children).toEqual('Inventory');
|
||||
expect(steps[1].name.props.children).toEqual('Credentials');
|
||||
expect(steps[2].name.props.children).toEqual('Other prompts');
|
||||
expect(steps[3].name.props.children).toEqual('Survey');
|
||||
expect(steps[4].name.props.children).toEqual('Preview');
|
||||
expect(steps[2].name.props.children).toEqual('Credential passwords');
|
||||
expect(steps[3].name.props.children).toEqual('Other prompts');
|
||||
expect(steps[4].name.props.children).toEqual('Survey');
|
||||
expect(steps[5].name.props.children).toEqual('Preview');
|
||||
});
|
||||
|
||||
test('should add inventory step', async () => {
|
||||
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -3,6 +3,7 @@ import { useHistory } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useField } from 'formik';
|
||||
import { Alert } from '@patternfly/react-core';
|
||||
import { InventoriesAPI } from '../../../api';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
@ -17,9 +18,10 @@ const QS_CONFIG = getQSConfig('inventory', {
|
||||
});
|
||||
|
||||
function InventoryStep({ i18n }) {
|
||||
const [field, , helpers] = useField({
|
||||
const [field, meta, helpers] = useField({
|
||||
name: 'inventory',
|
||||
});
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
const {
|
||||
@ -65,40 +67,45 @@ function InventoryStep({ i18n }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<OptionsList
|
||||
value={field.value ? [field.value] : []}
|
||||
options={inventories}
|
||||
optionCount={count}
|
||||
searchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username__icontains',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username__icontains',
|
||||
},
|
||||
]}
|
||||
sortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
searchableKeys={searchableKeys}
|
||||
relatedSearchableKeys={relatedSearchableKeys}
|
||||
header={i18n._(t`Inventory`)}
|
||||
name="inventory"
|
||||
qsConfig={QS_CONFIG}
|
||||
readOnly
|
||||
selectItem={helpers.setValue}
|
||||
deselectItem={() => field.onChange(null)}
|
||||
/>
|
||||
<>
|
||||
<OptionsList
|
||||
value={field.value ? [field.value] : []}
|
||||
options={inventories}
|
||||
optionCount={count}
|
||||
searchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username__icontains',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username__icontains',
|
||||
},
|
||||
]}
|
||||
sortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
searchableKeys={searchableKeys}
|
||||
relatedSearchableKeys={relatedSearchableKeys}
|
||||
header={i18n._(t`Inventory`)}
|
||||
name="inventory"
|
||||
qsConfig={QS_CONFIG}
|
||||
readOnly
|
||||
selectItem={helpers.setValue}
|
||||
deselectItem={() => field.onChange(null)}
|
||||
/>
|
||||
{meta.touched && meta.error && (
|
||||
<Alert variant="danger" isInline title={meta.error} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -9,15 +9,13 @@ export default function useCredentialsStep(launchConfig, resource, i18n) {
|
||||
return {
|
||||
step: getStep(launchConfig, i18n),
|
||||
initialValues: getInitialValues(launchConfig, resource),
|
||||
validate: () => ({}),
|
||||
isReady: true,
|
||||
contentError: null,
|
||||
formError: null,
|
||||
setTouched: setFieldsTouched => {
|
||||
setFieldsTouched({
|
||||
credentials: true,
|
||||
});
|
||||
hasError: false,
|
||||
setTouched: setFieldTouched => {
|
||||
setFieldTouched('credentials', true, false);
|
||||
},
|
||||
validate: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -12,20 +12,27 @@ export default function useInventoryStep(
|
||||
i18n,
|
||||
visitedSteps
|
||||
) {
|
||||
const [, meta] = useField('inventory');
|
||||
const [, meta, helpers] = useField('inventory');
|
||||
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 {
|
||||
step: getStep(launchConfig, i18n, formError),
|
||||
initialValues: getInitialValues(launchConfig, resource),
|
||||
isReady: true,
|
||||
contentError: null,
|
||||
formError: launchConfig.ask_inventory_on_launch && formError,
|
||||
setTouched: setFieldsTouched => {
|
||||
setFieldsTouched({
|
||||
inventory: true,
|
||||
});
|
||||
hasError: launchConfig.ask_inventory_on_launch && formError,
|
||||
setTouched: setFieldTouched => {
|
||||
setFieldTouched('inventory', true, false);
|
||||
},
|
||||
validate: () => {
|
||||
if (meta.touched && !meta.value && resource.type === 'job_template') {
|
||||
helpers.setError(i18n._(t`An inventory must be selected`));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -22,18 +22,19 @@ export default function useOtherPromptsStep(launchConfig, resource, i18n) {
|
||||
initialValues: getInitialValues(launchConfig, resource),
|
||||
isReady: true,
|
||||
contentError: null,
|
||||
formError: null,
|
||||
setTouched: setFieldsTouched => {
|
||||
setFieldsTouched({
|
||||
job_type: true,
|
||||
limit: true,
|
||||
verbosity: true,
|
||||
diff_mode: true,
|
||||
job_tags: true,
|
||||
skip_tags: true,
|
||||
extra_vars: true,
|
||||
});
|
||||
hasError: false,
|
||||
setTouched: setFieldTouched => {
|
||||
[
|
||||
'job_type',
|
||||
'limit',
|
||||
'verbosity',
|
||||
'diff_mode',
|
||||
'job_tags',
|
||||
'skip_tags',
|
||||
'extra_vars',
|
||||
].forEach(field => setFieldTouched(field, true, false));
|
||||
},
|
||||
validate: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -35,9 +35,9 @@ export default function usePreviewStep(
|
||||
}
|
||||
: null,
|
||||
initialValues: {},
|
||||
validate: () => ({}),
|
||||
isReady: true,
|
||||
error: null,
|
||||
setTouched: () => {},
|
||||
validate: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@ -13,89 +13,51 @@ export default function useSurveyStep(
|
||||
i18n,
|
||||
visitedSteps
|
||||
) {
|
||||
const { values } = useFormikContext();
|
||||
const errors = {};
|
||||
const validate = () => {
|
||||
if (!launchConfig.survey_enabled || !surveyConfig?.spec) {
|
||||
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;
|
||||
const { setFieldError, values } = useFormikContext();
|
||||
const hasError =
|
||||
Object.keys(visitedSteps).includes(STEP_ID) &&
|
||||
checkForError(launchConfig, surveyConfig, values);
|
||||
|
||||
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),
|
||||
validate,
|
||||
surveyConfig,
|
||||
isReady: true,
|
||||
contentError: null,
|
||||
formError,
|
||||
setTouched: setFieldsTouched => {
|
||||
hasError,
|
||||
setTouched: setFieldTouched => {
|
||||
if (!surveyConfig?.spec) {
|
||||
return;
|
||||
}
|
||||
const fields = {};
|
||||
surveyConfig.spec.forEach(question => {
|
||||
fields[`survey_${question.variable}`] = true;
|
||||
setFieldTouched(`survey_${question.variable}`, true, false);
|
||||
});
|
||||
setFieldsTouched(fields);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateField(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 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,
|
||||
validate: () => {
|
||||
if (launchConfig.survey_enabled && surveyConfig.spec) {
|
||||
surveyConfig.spec.forEach(question => {
|
||||
const errMessage = validateSurveyField(
|
||||
question,
|
||||
values[`survey_${question.variable}`],
|
||||
i18n
|
||||
);
|
||||
if (errMessage) {
|
||||
setFieldError(`survey_${question.variable}`, errMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -133,3 +95,56 @@ function getInitialValues(launchConfig, surveyConfig, resource) {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -2,10 +2,43 @@ import { useState, useEffect } from 'react';
|
||||
import { useFormikContext } from 'formik';
|
||||
import useInventoryStep from './steps/useInventoryStep';
|
||||
import useCredentialsStep from './steps/useCredentialsStep';
|
||||
import useCredentialPasswordsStep from './steps/useCredentialPasswordsStep';
|
||||
import useOtherPromptsStep from './steps/useOtherPromptsStep';
|
||||
import useSurveyStep from './steps/useSurveyStep';
|
||||
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(
|
||||
launchConfig,
|
||||
surveyConfig,
|
||||
@ -14,14 +47,21 @@ export default function useLaunchSteps(
|
||||
) {
|
||||
const [visited, setVisited] = useState({});
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const { touched, values: formikValues } = useFormikContext();
|
||||
const steps = [
|
||||
useInventoryStep(launchConfig, resource, i18n, visited),
|
||||
useCredentialsStep(launchConfig, resource, i18n),
|
||||
useCredentialPasswordsStep(
|
||||
launchConfig,
|
||||
i18n,
|
||||
showCredentialPasswordsStep(formikValues.credentials, launchConfig),
|
||||
visited
|
||||
),
|
||||
useOtherPromptsStep(launchConfig, resource, i18n),
|
||||
useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited),
|
||||
];
|
||||
const { resetForm } = useFormikContext();
|
||||
const hasErrors = steps.some(step => step.formError);
|
||||
const hasErrors = steps.some(step => step.hasError);
|
||||
|
||||
steps.push(
|
||||
usePreviewStep(launchConfig, i18n, resource, surveyConfig, hasErrors, true)
|
||||
@ -38,16 +78,47 @@ export default function useLaunchSteps(
|
||||
...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({
|
||||
values: {
|
||||
...initialValues,
|
||||
},
|
||||
values: newFormValues,
|
||||
touched,
|
||||
});
|
||||
|
||||
setIsReady(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stepsAreReady]);
|
||||
}, [formikValues.credentials, stepsAreReady]);
|
||||
|
||||
const stepWithError = steps.find(s => s.contentError);
|
||||
const contentError = stepWithError ? stepWithError.contentError : null;
|
||||
@ -55,20 +126,26 @@ export default function useLaunchSteps(
|
||||
return {
|
||||
steps: pfSteps,
|
||||
isReady,
|
||||
visitStep: stepId =>
|
||||
validateStep: stepId => {
|
||||
steps.find(s => s?.step?.id === stepId).validate();
|
||||
},
|
||||
visitStep: (prevStepId, setFieldTouched) => {
|
||||
setVisited({
|
||||
...visited,
|
||||
[stepId]: true,
|
||||
}),
|
||||
visitAllSteps: setFieldsTouched => {
|
||||
[prevStepId]: true,
|
||||
});
|
||||
steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched);
|
||||
},
|
||||
visitAllSteps: setFieldTouched => {
|
||||
setVisited({
|
||||
inventory: true,
|
||||
credentials: true,
|
||||
credentialPasswords: true,
|
||||
other: true,
|
||||
survey: true,
|
||||
preview: true,
|
||||
});
|
||||
steps.forEach(s => s.setTouched(setFieldsTouched));
|
||||
steps.forEach(s => s.setTouched(setFieldTouched));
|
||||
},
|
||||
contentError,
|
||||
};
|
||||
|
||||
@ -85,7 +85,12 @@ class ListHeader extends React.Component {
|
||||
pushHistoryState(params) {
|
||||
const { history, qsConfig } = this.props;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
FormGroup,
|
||||
InputGroup,
|
||||
Modal,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import ChipGroup from '../ChipGroup';
|
||||
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 (
|
||||
<FormGroup
|
||||
fieldId="host-filter"
|
||||
@ -261,33 +292,17 @@ function HostFilterLookup({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InputGroup onBlur={onBlur}>
|
||||
<Button
|
||||
aria-label={i18n._(t`Search`)}
|
||||
id="host-filter"
|
||||
isDisabled={isDisabled}
|
||||
onClick={handleOpenModal}
|
||||
variant={ButtonVariant.control}
|
||||
{isDisabled ? (
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Please select an organization before editing the host filter`
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{renderLookup()}
|
||||
</Tooltip>
|
||||
) : (
|
||||
renderLookup()
|
||||
)}
|
||||
<Modal
|
||||
aria-label={i18n._(t`Lookup modal`)}
|
||||
isOpen={isModalOpen}
|
||||
|
||||
@ -61,7 +61,12 @@ function PaginatedDataList({
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ const Th = styled(PFTh)`
|
||||
--pf-c-table--cell--Overflow: initial;
|
||||
`;
|
||||
|
||||
export default function HeaderRow({ qsConfig, children }) {
|
||||
export default function HeaderRow({ qsConfig, isExpandable, children }) {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
@ -23,7 +23,12 @@ export default function HeaderRow({ qsConfig, children }) {
|
||||
order_by: order === 'asc' ? key : `-${key}`,
|
||||
page: null,
|
||||
});
|
||||
const encodedParams = encodeNonDefaultQueryString(qsConfig, newParams);
|
||||
const nonNamespacedParams = parseQueryString({}, history.location.search);
|
||||
const encodedParams = encodeNonDefaultQueryString(
|
||||
qsConfig,
|
||||
newParams,
|
||||
nonNamespacedParams
|
||||
);
|
||||
history.push(
|
||||
encodedParams
|
||||
? `${location.pathname}?${encodedParams}`
|
||||
@ -36,25 +41,38 @@ export default function HeaderRow({ qsConfig, children }) {
|
||||
index: sortKey || qsConfig.defaultParams?.order_by,
|
||||
direction: params.order_by?.startsWith('-') ? 'desc' : 'asc',
|
||||
};
|
||||
const idPrefix = `${qsConfig.namespace}-table-sort`;
|
||||
|
||||
// empty first Th aligns with checkboxes in table rows
|
||||
return (
|
||||
<Thead>
|
||||
<Tr>
|
||||
{isExpandable && <Th />}
|
||||
<Th />
|
||||
{React.Children.map(children, child =>
|
||||
React.cloneElement(child, {
|
||||
onSort,
|
||||
sortBy,
|
||||
columnIndex: child.props.sortKey,
|
||||
})
|
||||
{React.Children.map(
|
||||
children,
|
||||
child =>
|
||||
child &&
|
||||
React.cloneElement(child, {
|
||||
onSort,
|
||||
sortBy,
|
||||
columnIndex: child.props.sortKey,
|
||||
idPrefix,
|
||||
})
|
||||
)}
|
||||
</Tr>
|
||||
</Thead>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) {
|
||||
export function HeaderCell({
|
||||
sortKey,
|
||||
onSort,
|
||||
sortBy,
|
||||
columnIndex,
|
||||
idPrefix,
|
||||
children,
|
||||
}) {
|
||||
const sort = sortKey
|
||||
? {
|
||||
onSort: (event, key, order) => onSort(sortKey, order),
|
||||
@ -62,5 +80,9 @@ export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) {
|
||||
columnIndex,
|
||||
}
|
||||
: null;
|
||||
return <Th sort={sort}>{children}</Th>;
|
||||
return (
|
||||
<Th sort={sort} id={sortKey ? `${idPrefix}-${sortKey}` : null}>
|
||||
{children}
|
||||
</Th>
|
||||
);
|
||||
}
|
||||
|
||||
@ -62,4 +62,20 @@ describe('<HeaderRow />', () => {
|
||||
const cell = wrapper.find('Th').at(2);
|
||||
expect(cell.prop('sort')).toEqual(null);
|
||||
});
|
||||
|
||||
test('should handle null children gracefully', async () => {
|
||||
const nope = false;
|
||||
const wrapper = mountWithContexts(
|
||||
<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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -40,8 +40,13 @@ function PaginatedTable({
|
||||
const history = useHistory();
|
||||
|
||||
const pushHistoryState = params => {
|
||||
const { pathname } = history.location;
|
||||
const encodedParams = encodeNonDefaultQueryString(qsConfig, params);
|
||||
const { pathname, search } = history.location;
|
||||
const nonNamespacedParams = parseQueryString({}, search);
|
||||
const encodedParams = encodeNonDefaultQueryString(
|
||||
qsConfig,
|
||||
params,
|
||||
nonNamespacedParams
|
||||
);
|
||||
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
|
||||
};
|
||||
|
||||
|
||||
@ -7,69 +7,71 @@ import { t } from '@lingui/macro';
|
||||
import AlertModal from '../AlertModal';
|
||||
import { Role } from '../../types';
|
||||
|
||||
class DeleteRoleConfirmationModal extends React.Component {
|
||||
static propTypes = {
|
||||
role: Role.isRequired,
|
||||
username: string,
|
||||
onCancel: func.isRequired,
|
||||
onConfirm: func.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
username: '',
|
||||
};
|
||||
|
||||
isTeamRole() {
|
||||
const { role } = this.props;
|
||||
function DeleteRoleConfirmationModal({
|
||||
role,
|
||||
username,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
i18n,
|
||||
}) {
|
||||
const isTeamRole = () => {
|
||||
return typeof role.team_id !== 'undefined';
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { role, username, onCancel, onConfirm, i18n } = this.props;
|
||||
const title = i18n._(
|
||||
t`Remove ${this.isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access`
|
||||
);
|
||||
return (
|
||||
<AlertModal
|
||||
variant="danger"
|
||||
title={title}
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
actions={[
|
||||
<Button
|
||||
key="delete"
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`Confirm delete`)}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>,
|
||||
<Button key="cancel" variant="secondary" onClick={onCancel}>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{this.isTeamRole() ? (
|
||||
<Fragment>
|
||||
{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 />
|
||||
{i18n._(
|
||||
t`If you only want to remove access for this particular user, please remove them from the team.`
|
||||
)}
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
{i18n._(
|
||||
t`Are you sure you want to remove ${role.name} access from ${username}?`
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
const title = i18n._(
|
||||
t`Remove ${isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access`
|
||||
);
|
||||
return (
|
||||
<AlertModal
|
||||
variant="danger"
|
||||
title={title}
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
actions={[
|
||||
<Button
|
||||
key="delete"
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`Confirm delete`)}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>,
|
||||
<Button key="cancel" variant="secondary" onClick={onCancel}>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{isTeamRole() ? (
|
||||
<Fragment>
|
||||
{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 />
|
||||
{i18n._(
|
||||
t`If you only want to remove access for this particular user, please remove them from the team.`
|
||||
)}
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
{i18n._(
|
||||
t`Are you sure you want to remove ${role.name} access from ${username}?`
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
|
||||
DeleteRoleConfirmationModal.propTypes = {
|
||||
role: Role.isRequired,
|
||||
username: string,
|
||||
onCancel: func.isRequired,
|
||||
onConfirm: func.isRequired,
|
||||
};
|
||||
|
||||
DeleteRoleConfirmationModal.defaultProps = {
|
||||
username: '',
|
||||
};
|
||||
|
||||
export default withI18n()(DeleteRoleConfirmationModal);
|
||||
|
||||
@ -144,6 +144,7 @@ function ResourceAccessList({ i18n, apiModel, resource }) {
|
||||
setDeletionRole(role);
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
i18n={i18n}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -24,19 +24,13 @@ const DataListItemCells = styled(PFDataListItemCells)`
|
||||
align-items: start;
|
||||
`;
|
||||
|
||||
class ResourceAccessListItem extends React.Component {
|
||||
static propTypes = {
|
||||
function ResourceAccessListItem({ accessRecord, onRoleDelete, i18n }) {
|
||||
ResourceAccessListItem.propTypes = {
|
||||
accessRecord: AccessRecord.isRequired,
|
||||
onRoleDelete: func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.renderChip = this.renderChip.bind(this);
|
||||
}
|
||||
|
||||
getRoleLists() {
|
||||
const { accessRecord } = this.props;
|
||||
const getRoleLists = () => {
|
||||
const teamRoles = [];
|
||||
const userRoles = [];
|
||||
|
||||
@ -52,10 +46,9 @@ class ResourceAccessListItem extends React.Component {
|
||||
accessRecord.summary_fields.direct_access.map(sort);
|
||||
accessRecord.summary_fields.indirect_access.map(sort);
|
||||
return [teamRoles, userRoles];
|
||||
}
|
||||
};
|
||||
|
||||
renderChip(role) {
|
||||
const { accessRecord, onRoleDelete } = this.props;
|
||||
const renderChip = role => {
|
||||
return (
|
||||
<Chip
|
||||
key={role.id}
|
||||
@ -67,79 +60,76 @@ class ResourceAccessListItem extends React.Component {
|
||||
{role.name}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { accessRecord, i18n } = this.props;
|
||||
const [teamRoles, userRoles] = this.getRoleLists();
|
||||
const [teamRoles, userRoles] = getRoleLists();
|
||||
|
||||
return (
|
||||
<DataListItem
|
||||
aria-labelledby="access-list-item"
|
||||
key={accessRecord.id}
|
||||
id={`${accessRecord.id}`}
|
||||
>
|
||||
<DataListItemRow>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="name">
|
||||
{accessRecord.username && (
|
||||
<TextContent>
|
||||
{accessRecord.id ? (
|
||||
<Text component={TextVariants.h6}>
|
||||
<Link
|
||||
to={{ pathname: `/users/${accessRecord.id}/details` }}
|
||||
css="font-weight: bold"
|
||||
>
|
||||
{accessRecord.username}
|
||||
</Link>
|
||||
</Text>
|
||||
) : (
|
||||
<Text component={TextVariants.h6} css="font-weight: bold">
|
||||
return (
|
||||
<DataListItem
|
||||
aria-labelledby="access-list-item"
|
||||
key={accessRecord.id}
|
||||
id={`${accessRecord.id}`}
|
||||
>
|
||||
<DataListItemRow>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="name">
|
||||
{accessRecord.username && (
|
||||
<TextContent>
|
||||
{accessRecord.id ? (
|
||||
<Text component={TextVariants.h6}>
|
||||
<Link
|
||||
to={{ pathname: `/users/${accessRecord.id}/details` }}
|
||||
css="font-weight: bold"
|
||||
>
|
||||
{accessRecord.username}
|
||||
</Text>
|
||||
)}
|
||||
</TextContent>
|
||||
)}
|
||||
{accessRecord.first_name || accessRecord.last_name ? (
|
||||
<DetailList stacked>
|
||||
<Detail
|
||||
label={i18n._(t`Name`)}
|
||||
value={`${accessRecord.first_name} ${accessRecord.last_name}`}
|
||||
/>
|
||||
</DetailList>
|
||||
) : null}
|
||||
</DataListCell>,
|
||||
<DataListCell key="roles">
|
||||
</Link>
|
||||
</Text>
|
||||
) : (
|
||||
<Text component={TextVariants.h6} css="font-weight: bold">
|
||||
{accessRecord.username}
|
||||
</Text>
|
||||
)}
|
||||
</TextContent>
|
||||
)}
|
||||
{accessRecord.first_name || accessRecord.last_name ? (
|
||||
<DetailList stacked>
|
||||
{userRoles.length > 0 && (
|
||||
<Detail
|
||||
label={i18n._(t`User Roles`)}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Detail
|
||||
label={i18n._(t`Name`)}
|
||||
value={`${accessRecord.first_name} ${accessRecord.last_name}`}
|
||||
/>
|
||||
</DetailList>
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
);
|
||||
}
|
||||
) : null}
|
||||
</DataListCell>,
|
||||
<DataListCell key="roles">
|
||||
<DetailList stacked>
|
||||
{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);
|
||||
|
||||
135
awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx
Normal file
135
awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx
Normal 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);
|
||||
@ -1,9 +1,15 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
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 breadcrumb;
|
||||
let breadcrumbItem;
|
||||
@ -17,15 +23,15 @@ describe('<Breadcrumb />', () => {
|
||||
};
|
||||
|
||||
const findChildren = () => {
|
||||
breadcrumb = breadcrumbWrapper.find('Breadcrumb');
|
||||
breadcrumb = breadcrumbWrapper.find('ScreenHeader');
|
||||
breadcrumbItem = breadcrumbWrapper.find('BreadcrumbItem');
|
||||
breadcrumbHeading = breadcrumbWrapper.find('BreadcrumbHeading');
|
||||
breadcrumbHeading = breadcrumbWrapper.find('Title');
|
||||
};
|
||||
|
||||
test('initially renders succesfully', () => {
|
||||
breadcrumbWrapper = mount(
|
||||
breadcrumbWrapper = mountWithContexts(
|
||||
<MemoryRouter initialEntries={['/foo/1/bar']} initialIndex={0}>
|
||||
<Breadcrumbs breadcrumbConfig={config} />
|
||||
<ScreenHeader streamType="all_activity" breadcrumbConfig={config} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
@ -51,9 +57,9 @@ describe('<Breadcrumb />', () => {
|
||||
];
|
||||
|
||||
routes.forEach(([location, crumbLength]) => {
|
||||
breadcrumbWrapper = mount(
|
||||
breadcrumbWrapper = mountWithContexts(
|
||||
<MemoryRouter initialEntries={[location]}>
|
||||
<Breadcrumbs breadcrumbConfig={config} />
|
||||
<ScreenHeader streamType="all_activity" breadcrumbConfig={config} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
1
awx/ui_next/src/components/ScreenHeader/index.js
Normal file
1
awx/ui_next/src/components/ScreenHeader/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ScreenHeader';
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { useLocation, withRouter } from 'react-router-dom';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Button,
|
||||
@ -31,140 +31,110 @@ const NoOptionDropdown = styled.div`
|
||||
border-bottom-color: var(--pf-global--BorderColor--200);
|
||||
`;
|
||||
|
||||
class Sort extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
function Sort({ columns, qsConfig, onSort, i18n }) {
|
||||
const location = useLocation();
|
||||
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
|
||||
|
||||
let sortKey;
|
||||
let sortOrder;
|
||||
let isNumeric;
|
||||
let sortKey;
|
||||
let sortOrder;
|
||||
let isNumeric;
|
||||
|
||||
const { qsConfig, location } = this.props;
|
||||
const queryParams = parseQueryString(qsConfig, location.search);
|
||||
if (queryParams.order_by && queryParams.order_by.startsWith('-')) {
|
||||
sortKey = queryParams.order_by.substr(1);
|
||||
sortOrder = 'descending';
|
||||
} else if (queryParams.order_by) {
|
||||
sortKey = queryParams.order_by;
|
||||
sortOrder = 'ascending';
|
||||
}
|
||||
|
||||
if (qsConfig.integerFields.find(field => field === sortKey)) {
|
||||
isNumeric = true;
|
||||
} else {
|
||||
isNumeric = false;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
isSortDropdownOpen: false,
|
||||
sortKey,
|
||||
sortOrder,
|
||||
isNumeric,
|
||||
};
|
||||
|
||||
this.handleDropdownToggle = this.handleDropdownToggle.bind(this);
|
||||
this.handleDropdownSelect = this.handleDropdownSelect.bind(this);
|
||||
this.handleSort = this.handleSort.bind(this);
|
||||
const queryParams = parseQueryString(qsConfig, location.search);
|
||||
if (queryParams.order_by && queryParams.order_by.startsWith('-')) {
|
||||
sortKey = queryParams.order_by.substr(1);
|
||||
sortOrder = 'descending';
|
||||
} else if (queryParams.order_by) {
|
||||
sortKey = queryParams.order_by;
|
||||
sortOrder = 'ascending';
|
||||
}
|
||||
|
||||
handleDropdownToggle(isSortDropdownOpen) {
|
||||
this.setState({ isSortDropdownOpen });
|
||||
if (qsConfig.integerFields.find(field => field === sortKey)) {
|
||||
isNumeric = true;
|
||||
} else {
|
||||
isNumeric = false;
|
||||
}
|
||||
|
||||
handleDropdownSelect({ target }) {
|
||||
const { columns, onSort, qsConfig } = this.props;
|
||||
const { sortOrder } = this.state;
|
||||
const handleDropdownToggle = isOpen => {
|
||||
setIsSortDropdownOpen(isOpen);
|
||||
};
|
||||
|
||||
const handleDropdownSelect = ({ target }) => {
|
||||
const { innerText } = target;
|
||||
|
||||
const [{ key: sortKey }] = columns.filter(({ name }) => name === innerText);
|
||||
|
||||
let isNumeric;
|
||||
|
||||
if (qsConfig.integerFields.find(field => field === sortKey)) {
|
||||
const [{ key }] = columns.filter(({ name }) => name === innerText);
|
||||
sortKey = key;
|
||||
if (qsConfig.integerFields.find(field => field === key)) {
|
||||
isNumeric = true;
|
||||
} else {
|
||||
isNumeric = false;
|
||||
}
|
||||
|
||||
this.setState({ isSortDropdownOpen: false, sortKey, isNumeric });
|
||||
setIsSortDropdownOpen(false);
|
||||
onSort(sortKey, sortOrder);
|
||||
}
|
||||
};
|
||||
|
||||
handleSort() {
|
||||
const { onSort } = this.props;
|
||||
const { sortKey, sortOrder } = this.state;
|
||||
const newSortOrder = sortOrder === 'ascending' ? 'descending' : 'ascending';
|
||||
this.setState({ sortOrder: newSortOrder });
|
||||
onSort(sortKey, newSortOrder);
|
||||
}
|
||||
const handleSort = () => {
|
||||
onSort(sortKey, sortOrder === 'ascending' ? 'descending' : 'ascending');
|
||||
};
|
||||
|
||||
render() {
|
||||
const { up } = DropdownPosition;
|
||||
const { columns, i18n } = this.props;
|
||||
const { isSortDropdownOpen, sortKey, sortOrder, isNumeric } = this.state;
|
||||
const { up } = DropdownPosition;
|
||||
|
||||
const defaultSortedColumn = columns.find(({ key }) => key === sortKey);
|
||||
const defaultSortedColumn = columns.find(({ key }) => key === sortKey);
|
||||
|
||||
if (!defaultSortedColumn) {
|
||||
throw new Error(
|
||||
'sortKey must match one of the column keys, check the sortColumns prop passed to <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>
|
||||
if (!defaultSortedColumn) {
|
||||
throw new Error(
|
||||
'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={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 = {
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../testUtils/enzymeHelpers';
|
||||
|
||||
import Sort from './Sort';
|
||||
|
||||
describe('<Sort />', () => {
|
||||
@ -105,7 +110,7 @@ describe('<Sort />', () => {
|
||||
expect(onSort).toHaveBeenCalledWith('foo', 'ascending');
|
||||
});
|
||||
|
||||
test('Changing dropdown correctly passes back new sort key', () => {
|
||||
test('Changing dropdown correctly passes back new sort key', async () => {
|
||||
const qsConfig = {
|
||||
namespace: 'item',
|
||||
defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
|
||||
@ -131,44 +136,18 @@ describe('<Sort />', () => {
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<Sort qsConfig={qsConfig} columns={columns} onSort={onSort} />
|
||||
).find('Sort');
|
||||
|
||||
wrapper.instance().handleDropdownSelect({ target: { innerText: 'Bar' } });
|
||||
);
|
||||
act(() => wrapper.find('Dropdown').invoke('onToggle')(true));
|
||||
wrapper.update();
|
||||
await waitForElement(wrapper, 'Dropdown', el => el.prop('isOpen') === true);
|
||||
wrapper
|
||||
.find('li')
|
||||
.at(0)
|
||||
.prop('onClick')({ target: { innerText: 'Bar' } });
|
||||
wrapper.update();
|
||||
expect(onSort).toBeCalledWith('bar', 'ascending');
|
||||
});
|
||||
|
||||
test('Opening dropdown correctly updates state', () => {
|
||||
const qsConfig = {
|
||||
namespace: 'item',
|
||||
defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
|
||||
integerFields: ['page', 'page_size'],
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'Foo',
|
||||
key: 'foo',
|
||||
},
|
||||
{
|
||||
name: 'Bar',
|
||||
key: 'bar',
|
||||
},
|
||||
{
|
||||
name: 'Bakery',
|
||||
key: 'bakery',
|
||||
},
|
||||
];
|
||||
|
||||
const onSort = jest.fn();
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<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', () => {
|
||||
const forwardNumericIconSelector = 'SortNumericDownIcon';
|
||||
const reverseNumericIconSelector = 'SortNumericDownAltIcon';
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import ActivityStream from './screens/ActivityStream';
|
||||
import Applications from './screens/Application';
|
||||
import Credentials from './screens/Credential';
|
||||
import CredentialTypes from './screens/CredentialType';
|
||||
@ -44,6 +45,11 @@ function getRouteConfig(i18n) {
|
||||
path: '/schedules',
|
||||
screen: Schedules,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Activity Stream`),
|
||||
path: '/activity_stream',
|
||||
screen: ActivityStream,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Workflow Approvals`),
|
||||
path: '/workflow_approvals',
|
||||
|
||||
269
awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx
Normal file
269
awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx
Normal 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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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>}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/screens/ActivityStream/index.js
Normal file
1
awx/ui_next/src/screens/ActivityStream/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ActivityStream';
|
||||
@ -12,7 +12,7 @@ import {
|
||||
import ApplicationsList from './ApplicationsList';
|
||||
import ApplicationAdd from './ApplicationAdd';
|
||||
import Application from './Application';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
||||
import ScreenHeader from '../../components/ScreenHeader';
|
||||
import { Detail, DetailList } from '../../components/DetailList';
|
||||
|
||||
const ApplicationAlert = styled(Alert)`
|
||||
@ -45,7 +45,10 @@ function Applications({ i18n }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||
<ScreenHeader
|
||||
streamType="o_auth2_application,o_auth2_access_token"
|
||||
breadcrumbConfig={breadcrumbConfig}
|
||||
/>
|
||||
<Switch>
|
||||
<Route path="/applications/add">
|
||||
<ApplicationAdd
|
||||
|
||||
@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
|
||||
import Applications from './Applications';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
}));
|
||||
|
||||
describe('<Applications />', () => {
|
||||
let wrapper;
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import { Route, Switch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Config } from '../../contexts/Config';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
||||
import ScreenHeader from '../../components/ScreenHeader';
|
||||
import Credential from './Credential';
|
||||
import CredentialAdd from './CredentialAdd';
|
||||
import { CredentialList } from './CredentialList';
|
||||
@ -34,7 +34,10 @@ function Credentials({ i18n }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||
<ScreenHeader
|
||||
streamType="credential"
|
||||
breadcrumbConfig={breadcrumbConfig}
|
||||
/>
|
||||
<Switch>
|
||||
<Route path="/credentials/add">
|
||||
<Config>{({ me }) => <CredentialAdd me={me || {}} />}</Config>
|
||||
|
||||
@ -3,6 +3,10 @@ import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import Credentials from './Credentials';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
}));
|
||||
|
||||
describe('<Credentials />', () => {
|
||||
let wrapper;
|
||||
|
||||
@ -30,8 +34,8 @@ describe('<Credentials />', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find('Crumb').length).toBe(1);
|
||||
expect(wrapper.find('BreadcrumbHeading').text()).toBe('Credentials');
|
||||
expect(wrapper.find('Crumb').length).toBe(0);
|
||||
expect(wrapper.find('Title').text()).toBe('Credentials');
|
||||
});
|
||||
|
||||
test('should display create new credential breadcrumb heading', () => {
|
||||
@ -51,8 +55,6 @@ describe('<Credentials />', () => {
|
||||
});
|
||||
|
||||
expect(wrapper.find('Crumb').length).toBe(2);
|
||||
expect(wrapper.find('BreadcrumbHeading').text()).toBe(
|
||||
'Create New Credential'
|
||||
);
|
||||
expect(wrapper.find('Title').text()).toBe('Create New Credential');
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,7 +6,7 @@ import { Route, Switch } from 'react-router-dom';
|
||||
import CredentialTypeAdd from './CredentialTypeAdd';
|
||||
import CredentialTypeList from './CredentialTypeList';
|
||||
import CredentialType from './CredentialType';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
||||
import ScreenHeader from '../../components/ScreenHeader';
|
||||
|
||||
function CredentialTypes({ i18n }) {
|
||||
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
|
||||
@ -33,7 +33,10 @@ function CredentialTypes({ i18n }) {
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||
<ScreenHeader
|
||||
streamType="credential_type"
|
||||
breadcrumbConfig={breadcrumbConfig}
|
||||
/>
|
||||
<Switch>
|
||||
<Route path="/credential_types/add">
|
||||
<CredentialTypeAdd />
|
||||
|
||||
@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
|
||||
import CredentialTypes from './CredentialTypes';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
}));
|
||||
|
||||
describe('<CredentialTypes/>', () => {
|
||||
let pageWrapper;
|
||||
let pageSections;
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
|
||||
import useRequest from '../../util/useRequest';
|
||||
import { DashboardAPI } from '../../api';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
||||
import ScreenHeader from '../../components/ScreenHeader';
|
||||
import JobList from '../../components/JobList';
|
||||
import ContentLoading from '../../components/ContentLoading';
|
||||
import LineChart from './shared/LineChart';
|
||||
@ -117,7 +117,10 @@ function Dashboard({ i18n }) {
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
<Breadcrumbs breadcrumbConfig={{ '/home': i18n._(t`Dashboard`) }} />
|
||||
<ScreenHeader
|
||||
streamType="all"
|
||||
breadcrumbConfig={{ '/home': i18n._(t`Dashboard`) }}
|
||||
/>
|
||||
<PageSection>
|
||||
<Counts>
|
||||
<Count
|
||||
|
||||
@ -7,6 +7,9 @@ import { DashboardAPI } from '../../api';
|
||||
import Dashboard from './Dashboard';
|
||||
|
||||
jest.mock('../../api');
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
}));
|
||||
|
||||
describe('<Dashboard />', () => {
|
||||
let pageWrapper;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useLocation, useRouteMatch } from 'react-router-dom';
|
||||
import { useHistory, useLocation, useRouteMatch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
@ -13,9 +13,14 @@ import PaginatedDataList, {
|
||||
ToolbarDeleteButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import {
|
||||
encodeQueryString,
|
||||
getQSConfig,
|
||||
parseQueryString,
|
||||
} from '../../../util/qs';
|
||||
|
||||
import HostListItem from './HostListItem';
|
||||
import SmartInventoryButton from './SmartInventoryButton';
|
||||
|
||||
const QS_CONFIG = getQSConfig('host', {
|
||||
page: 1,
|
||||
@ -24,9 +29,21 @@ const QS_CONFIG = getQSConfig('host', {
|
||||
});
|
||||
|
||||
function HostList({ i18n }) {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch();
|
||||
const [selected, setSelected] = useState([]);
|
||||
const parsedQueryStrings = parseQueryString(QS_CONFIG, location.search);
|
||||
const nonDefaultSearchParams = {};
|
||||
|
||||
Object.keys(parsedQueryStrings).forEach(key => {
|
||||
if (!QS_CONFIG.defaultParams[key]) {
|
||||
nonDefaultSearchParams[key] = parsedQueryStrings[key];
|
||||
}
|
||||
});
|
||||
|
||||
const hasNonDefaultSearchParams =
|
||||
Object.keys(nonDefaultSearchParams).length > 0;
|
||||
|
||||
const {
|
||||
result: { hosts, count, actions, relatedSearchableKeys, searchableKeys },
|
||||
@ -99,6 +116,14 @@ function HostList({ i18n }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSmartInventoryClick = () => {
|
||||
history.push(
|
||||
`/inventories/smart_inventory/add?host_filter=${encodeURIComponent(
|
||||
encodeQueryString(nonDefaultSearchParams)
|
||||
)}`
|
||||
);
|
||||
};
|
||||
|
||||
const canAdd =
|
||||
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
||||
|
||||
@ -157,6 +182,14 @@ function HostList({ i18n }) {
|
||||
itemsToDelete={selected}
|
||||
pluralizedItemName={i18n._(t`Hosts`)}
|
||||
/>,
|
||||
...(canAdd
|
||||
? [
|
||||
<SmartInventoryButton
|
||||
isDisabled={!hasNonDefaultSearchParams}
|
||||
onClick={() => handleSmartInventoryClick()}
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { HostsAPI } from '../../../api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
@ -257,7 +258,7 @@ describe('<HostList />', () => {
|
||||
expect(modal.prop('title')).toEqual('Error!');
|
||||
});
|
||||
|
||||
test('should show Add button according to permissions', async () => {
|
||||
test('should show Add and Smart Inventory buttons according to permissions', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<HostList />);
|
||||
@ -265,9 +266,10 @@ describe('<HostList />', () => {
|
||||
await waitForLoaded(wrapper);
|
||||
|
||||
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
|
||||
expect(wrapper.find('Button[aria-label="Smart Inventory"]').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should hide Add button according to permissions', async () => {
|
||||
test('should hide Add and Smart Inventory buttons according to permissions', async () => {
|
||||
HostsAPI.readOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
@ -282,5 +284,44 @@ describe('<HostList />', () => {
|
||||
await waitForLoaded(wrapper);
|
||||
|
||||
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
|
||||
expect(wrapper.find('Button[aria-label="Smart Inventory"]').length).toBe(0);
|
||||
});
|
||||
|
||||
test('Smart Inventory button should be disabled when no search params are present', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import { Config } from '../../contexts/Config';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
|
||||
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
|
||||
|
||||
import HostList from './HostList';
|
||||
import HostAdd from './HostAdd';
|
||||
@ -37,7 +37,7 @@ function Hosts({ i18n }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||
<ScreenHeader streamType="host" breadcrumbConfig={breadcrumbConfig} />
|
||||
<Switch>
|
||||
<Route path="/hosts/add">
|
||||
<HostAdd />
|
||||
|
||||
@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
|
||||
import Hosts from './Hosts';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
}));
|
||||
|
||||
describe('<Hosts />', () => {
|
||||
test('initially renders succesfully', () => {
|
||||
mountWithContexts(<Hosts />);
|
||||
@ -27,7 +31,7 @@ describe('<Hosts />', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
|
||||
expect(wrapper.find('Title').length).toBe(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ import InstanceGroup from './InstanceGroup';
|
||||
|
||||
import ContainerGroupAdd from './ContainerGroupAdd';
|
||||
import ContainerGroup from './ContainerGroup';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
||||
import ScreenHeader from '../../components/ScreenHeader';
|
||||
|
||||
function InstanceGroups({ i18n }) {
|
||||
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
|
||||
@ -54,7 +54,10 @@ function InstanceGroups({ i18n }) {
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||
<ScreenHeader
|
||||
streamType="instance_group"
|
||||
breadcrumbConfig={breadcrumbConfig}
|
||||
/>
|
||||
<Switch>
|
||||
<Route path="/instance_groups/container_group/add">
|
||||
<ContainerGroupAdd />
|
||||
|
||||
@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
|
||||
import InstanceGroups from './InstanceGroups';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
}));
|
||||
|
||||
describe('<InstanceGroups/>', () => {
|
||||
let pageWrapper;
|
||||
let pageSections;
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
|
||||
import { Config } from '../../contexts/Config';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
|
||||
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
|
||||
import { InventoryList } from './InventoryList';
|
||||
import Inventory from './Inventory';
|
||||
import SmartInventory from './SmartInventory';
|
||||
@ -12,14 +12,34 @@ import InventoryAdd from './InventoryAdd';
|
||||
import SmartInventoryAdd from './SmartInventoryAdd';
|
||||
|
||||
function Inventories({ i18n }) {
|
||||
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
|
||||
const initScreenHeader = useRef({
|
||||
'/inventories': i18n._(t`Inventories`),
|
||||
'/inventories/inventory/add': i18n._(t`Create new inventory`),
|
||||
'/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`),
|
||||
});
|
||||
|
||||
const buildBreadcrumbConfig = useCallback(
|
||||
(inventory, nested, schedule) => {
|
||||
const [breadcrumbConfig, setScreenHeader] = useState(
|
||||
initScreenHeader.current
|
||||
);
|
||||
|
||||
const [inventory, setInventory] = useState();
|
||||
const [nestedObject, setNestedGroup] = useState();
|
||||
const [schedule, setSchedule] = useState();
|
||||
|
||||
const setBreadcrumbConfig = useCallback(
|
||||
(passedInventory, passedNestedObject, passedSchedule) => {
|
||||
if (passedInventory && passedInventory.name !== inventory?.name) {
|
||||
setInventory(passedInventory);
|
||||
}
|
||||
if (
|
||||
passedNestedObject &&
|
||||
passedNestedObject.name !== nestedObject?.name
|
||||
) {
|
||||
setNestedGroup(passedNestedObject);
|
||||
}
|
||||
if (passedSchedule && passedSchedule.name !== schedule?.name) {
|
||||
setSchedule(passedSchedule);
|
||||
}
|
||||
if (!inventory) {
|
||||
return;
|
||||
}
|
||||
@ -32,13 +52,8 @@ function Inventories({ i18n }) {
|
||||
const inventoryGroupsPath = `${inventoryPath}/groups`;
|
||||
const inventorySourcesPath = `${inventoryPath}/sources`;
|
||||
|
||||
setBreadcrumbConfig({
|
||||
'/inventories': i18n._(t`Inventories`),
|
||||
'/inventories/inventory/add': i18n._(t`Create new inventory`),
|
||||
'/inventories/smart_inventory/add': i18n._(
|
||||
t`Create new smart inventory`
|
||||
),
|
||||
|
||||
setScreenHeader({
|
||||
...initScreenHeader.current,
|
||||
[inventoryPath]: `${inventory.name}`,
|
||||
[`${inventoryPath}/access`]: i18n._(t`Access`),
|
||||
[`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`),
|
||||
@ -47,55 +62,74 @@ function Inventories({ i18n }) {
|
||||
|
||||
[inventoryHostsPath]: i18n._(t`Hosts`),
|
||||
[`${inventoryHostsPath}/add`]: i18n._(t`Create new host`),
|
||||
[`${inventoryHostsPath}/${nested?.id}`]: `${nested?.name}`,
|
||||
[`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
|
||||
[`${inventoryHostsPath}/${nested?.id}/details`]: i18n._(
|
||||
[`${inventoryHostsPath}/${nestedObject?.id}`]: `${nestedObject?.name}`,
|
||||
[`${inventoryHostsPath}/${nestedObject?.id}/edit`]: i18n._(
|
||||
t`Edit details`
|
||||
),
|
||||
[`${inventoryHostsPath}/${nestedObject?.id}/details`]: i18n._(
|
||||
t`Host details`
|
||||
),
|
||||
[`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._(
|
||||
[`${inventoryHostsPath}/${nestedObject?.id}/completed_jobs`]: i18n._(
|
||||
t`Completed jobs`
|
||||
),
|
||||
[`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`),
|
||||
[`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`),
|
||||
[`${inventoryHostsPath}/${nestedObject?.id}/facts`]: i18n._(t`Facts`),
|
||||
[`${inventoryHostsPath}/${nestedObject?.id}/groups`]: i18n._(t`Groups`),
|
||||
|
||||
[inventoryGroupsPath]: i18n._(t`Groups`),
|
||||
[`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`),
|
||||
[`${inventoryGroupsPath}/${nested?.id}`]: `${nested?.name}`,
|
||||
[`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
|
||||
[`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._(
|
||||
[`${inventoryGroupsPath}/${nestedObject?.id}`]: `${nestedObject?.name}`,
|
||||
[`${inventoryGroupsPath}/${nestedObject?.id}/edit`]: i18n._(
|
||||
t`Edit details`
|
||||
),
|
||||
[`${inventoryGroupsPath}/${nestedObject?.id}/details`]: i18n._(
|
||||
t`Group details`
|
||||
),
|
||||
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`),
|
||||
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._(
|
||||
[`${inventoryGroupsPath}/${nestedObject?.id}/nested_hosts`]: i18n._(
|
||||
t`Hosts`
|
||||
),
|
||||
[`${inventoryGroupsPath}/${nestedObject?.id}/nested_hosts/add`]: i18n._(
|
||||
t`Create new host`
|
||||
),
|
||||
[`${inventoryGroupsPath}/${nested?.id}/nested_groups`]: i18n._(
|
||||
t`Groups`
|
||||
[`${inventoryGroupsPath}/${nestedObject?.id}/nested_groups`]: i18n._(
|
||||
t`Related Groups`
|
||||
),
|
||||
[`${inventoryGroupsPath}/${nested?.id}/nested_groups/add`]: i18n._(
|
||||
[`${inventoryGroupsPath}/${nestedObject?.id}/nested_groups/add`]: i18n._(
|
||||
t`Create new group`
|
||||
),
|
||||
|
||||
[`${inventorySourcesPath}`]: i18n._(t`Sources`),
|
||||
[`${inventorySourcesPath}/add`]: i18n._(t`Create new source`),
|
||||
[`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`,
|
||||
[`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`),
|
||||
[`${inventorySourcesPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
|
||||
[`${inventorySourcesPath}/${nested?.id}/schedules`]: i18n._(
|
||||
[`${inventorySourcesPath}/${nestedObject?.id}`]: `${nestedObject?.name}`,
|
||||
[`${inventorySourcesPath}/${nestedObject?.id}/details`]: i18n._(
|
||||
t`Details`
|
||||
),
|
||||
[`${inventorySourcesPath}/${nestedObject?.id}/edit`]: i18n._(
|
||||
t`Edit details`
|
||||
),
|
||||
[`${inventorySourcesPath}/${nestedObject?.id}/schedules`]: i18n._(
|
||||
t`Schedules`
|
||||
),
|
||||
[`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`,
|
||||
[`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}/details`]: i18n._(
|
||||
[`${inventorySourcesPath}/${nestedObject?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`,
|
||||
[`${inventorySourcesPath}/${nestedObject?.id}/schedules/add`]: i18n._(
|
||||
t`Create New Schedule`
|
||||
),
|
||||
[`${inventorySourcesPath}/${nestedObject?.id}/schedules/${schedule?.id}/details`]: i18n._(
|
||||
t`Schedule details`
|
||||
),
|
||||
[`${inventorySourcesPath}/${nestedObject?.id}/notifications`]: i18n._(
|
||||
t`Notifcations`
|
||||
),
|
||||
});
|
||||
},
|
||||
[i18n]
|
||||
[i18n, inventory, nestedObject, schedule]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||
<ScreenHeader
|
||||
streamType="inventory"
|
||||
breadcrumbConfig={breadcrumbConfig}
|
||||
/>
|
||||
<Switch>
|
||||
<Route path="/inventories/inventory/add">
|
||||
<InventoryAdd />
|
||||
@ -106,12 +140,12 @@ function Inventories({ i18n }) {
|
||||
<Route path="/inventories/inventory/:id">
|
||||
<Config>
|
||||
{({ me }) => (
|
||||
<Inventory setBreadcrumb={buildBreadcrumbConfig} me={me || {}} />
|
||||
<Inventory setBreadcrumb={setBreadcrumbConfig} me={me || {}} />
|
||||
)}
|
||||
</Config>
|
||||
</Route>
|
||||
<Route path="/inventories/smart_inventory/:id">
|
||||
<SmartInventory setBreadcrumb={buildBreadcrumbConfig} />
|
||||
<SmartInventory setBreadcrumb={setBreadcrumbConfig} />
|
||||
</Route>
|
||||
<Route path="/inventories">
|
||||
<InventoryList />
|
||||
|
||||
@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
|
||||
import Inventories from './Inventories';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
}));
|
||||
|
||||
describe('<Inventories />', () => {
|
||||
let pageWrapper;
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import React, { useEffect, useCallback } from 'react';
|
||||
import { Formik, useField, useFormikContext } from 'formik';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { func, shape, arrayOf } from 'prop-types';
|
||||
import { Form } from '@patternfly/react-core';
|
||||
import { InstanceGroup } from '../../../types';
|
||||
@ -14,6 +15,10 @@ import {
|
||||
FormColumnLayout,
|
||||
FormFullWidthLayout,
|
||||
} from '../../../components/FormLayout';
|
||||
import {
|
||||
toHostFilter,
|
||||
toSearchParams,
|
||||
} from '../../../components/Lookup/shared/HostFilterUtils';
|
||||
import HostFilterLookup from '../../../components/Lookup/HostFilterLookup';
|
||||
import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup';
|
||||
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
|
||||
@ -109,9 +114,17 @@ function SmartInventoryForm({
|
||||
onCancel,
|
||||
submitError,
|
||||
}) {
|
||||
const { search } = useLocation();
|
||||
const queryParams = new URLSearchParams(search);
|
||||
const hostFilterFromParams = queryParams.get('host_filter');
|
||||
|
||||
const initialValues = {
|
||||
description: inventory.description || '',
|
||||
host_filter: inventory.host_filter || '',
|
||||
host_filter:
|
||||
inventory.host_filter ||
|
||||
(hostFilterFromParams
|
||||
? toHostFilter(toSearchParams(hostFilterFromParams))
|
||||
: ''),
|
||||
instance_groups: instanceGroups || [],
|
||||
kind: 'smart',
|
||||
name: inventory.name || '',
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
@ -135,6 +136,29 @@ describe('<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 () => {
|
||||
let wrapper;
|
||||
InventoriesAPI.readOptions.mockImplementationOnce(() =>
|
||||
|
||||
@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
|
||||
import Job from './Jobs';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
}));
|
||||
|
||||
describe('<Job />', () => {
|
||||
test('initially renders succesfully', () => {
|
||||
mountWithContexts(<Job />);
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
DetailList,
|
||||
Detail,
|
||||
UserDateDetail,
|
||||
LaunchedByDetail,
|
||||
} from '../../../components/DetailList';
|
||||
import { CardBody, CardActionsRow } from '../../../components/Card';
|
||||
import ChipGroup from '../../../components/ChipGroup';
|
||||
@ -53,35 +54,6 @@ const VERBOSITY = {
|
||||
4: '4 (Connection Debug)',
|
||||
};
|
||||
|
||||
const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => {
|
||||
const {
|
||||
created_by: createdBy,
|
||||
job_template: jobTemplate,
|
||||
schedule,
|
||||
} = summary_fields;
|
||||
const { schedule: relatedSchedule } = related;
|
||||
|
||||
if (!createdBy && !schedule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let link;
|
||||
let value;
|
||||
|
||||
if (createdBy) {
|
||||
link = `/users/${createdBy.id}`;
|
||||
value = createdBy.username;
|
||||
} else if (relatedSchedule && jobTemplate) {
|
||||
link = `/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`;
|
||||
value = schedule.name;
|
||||
} else {
|
||||
link = null;
|
||||
value = schedule.name;
|
||||
}
|
||||
|
||||
return { link, value };
|
||||
};
|
||||
|
||||
function JobDetail({ job, i18n }) {
|
||||
const {
|
||||
created_by,
|
||||
@ -93,6 +65,7 @@ function JobDetail({ job, i18n }) {
|
||||
workflow_job_template: workflowJobTemplate,
|
||||
labels,
|
||||
project,
|
||||
source_workflow_job,
|
||||
} = job.summary_fields;
|
||||
const [errorMsg, setErrorMsg] = useState();
|
||||
const history = useHistory();
|
||||
@ -106,9 +79,6 @@ function JobDetail({ job, i18n }) {
|
||||
workflow_job: i18n._(t`Workflow Job`),
|
||||
};
|
||||
|
||||
const { value: launchedByValue, link: launchedByLink } =
|
||||
getLaunchedByDetails(job) || {};
|
||||
|
||||
const deleteJob = async () => {
|
||||
try {
|
||||
switch (job.type) {
|
||||
@ -195,17 +165,18 @@ function JobDetail({ job, i18n }) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{source_workflow_job && (
|
||||
<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`Launched By`)}
|
||||
value={
|
||||
launchedByLink ? (
|
||||
<Link to={`${launchedByLink}`}>{launchedByValue}</Link>
|
||||
) : (
|
||||
launchedByValue
|
||||
)
|
||||
}
|
||||
/>
|
||||
<LaunchedByDetail job={job} i18n={i18n} />
|
||||
{inventory && (
|
||||
<Detail
|
||||
label={i18n._(t`Inventory`)}
|
||||
|
||||
@ -35,6 +35,10 @@ describe('<JobDetail />', () => {
|
||||
kubernetes: false,
|
||||
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('Finished', '8/8/2019, 7:24:50 PM');
|
||||
assertDetail('Job Template', mockJobData.summary_fields.job_template.name);
|
||||
assertDetail('Source Workflow Job', `1234 - Test Source Workflow`);
|
||||
assertDetail('Job Type', 'Playbook Run');
|
||||
assertDetail('Launched By', mockJobData.summary_fields.created_by.username);
|
||||
assertDetail('Inventory', mockJobData.summary_fields.inventory.name);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
@ -518,7 +518,7 @@ class JobOutput extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { job, i18n } = this.props;
|
||||
const { job } = this.props;
|
||||
|
||||
const {
|
||||
contentError,
|
||||
@ -596,15 +596,21 @@ class JobOutput extends Component {
|
||||
</OutputWrapper>
|
||||
</CardBody>
|
||||
{deletionError && (
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="danger"
|
||||
onClose={() => this.setState({ deletionError: null })}
|
||||
title={i18n._(t`Job Delete Error`)}
|
||||
label={i18n._(t`Job Delete Error`)}
|
||||
>
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
<>
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="danger"
|
||||
onClose={() => this.setState({ deletionError: null })}
|
||||
title={i18n._(t`Job Delete Error`)}
|
||||
label={i18n._(t`Job Delete Error`)}
|
||||
>
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</I18n>
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
@ -612,4 +618,4 @@ class JobOutput extends Component {
|
||||
}
|
||||
|
||||
export { JobOutput as _JobOutput };
|
||||
export default withI18n()(withRouter(JobOutput));
|
||||
export default withRouter(JobOutput);
|
||||
|
||||
@ -6,6 +6,7 @@ import { t } from '@lingui/macro';
|
||||
import useRequest from '../../util/useRequest';
|
||||
import { UnifiedJobsAPI } from '../../api';
|
||||
import ContentError from '../../components/ContentError';
|
||||
import ContentLoading from '../../components/ContentLoading';
|
||||
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
|
||||
|
||||
const NOT_FOUND = 'not found';
|
||||
@ -46,8 +47,13 @@ function JobTypeRedirect({ id, path, view, i18n }) {
|
||||
);
|
||||
}
|
||||
if (isLoading || !job?.id) {
|
||||
// TODO show loading state
|
||||
return <div>Loading...</div>;
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<ContentLoading />
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
const type = JOB_TYPE_URL_SEGMENTS[job.type];
|
||||
return <Redirect from={path} to={`/jobs/${type}/${job.id}/${view}`} />;
|
||||
|
||||
@ -3,7 +3,7 @@ import { Route, Switch, useParams, useRouteMatch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PageSection } from '@patternfly/react-core';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
|
||||
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
|
||||
import Job from './Job';
|
||||
import JobTypeRedirect from './JobTypeRedirect';
|
||||
import JobList from '../../components/JobList';
|
||||
@ -40,7 +40,7 @@ function Jobs({ i18n }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||
<ScreenHeader streamType="job" breadcrumbConfig={breadcrumbConfig} />
|
||||
<Switch>
|
||||
<Route exact path={match.path}>
|
||||
<PageSection>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user